From 3c9c1d0b76bf4c1954539ba35d0746ff5fdc8944 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 8 Nov 2026 15:37:05 -0500 Subject: [PATCH 1/7] Add Census money income and align the CBO income measures with CBO Add census_money_income for people, families and households. One list parameter per source in Census Current Population Reports P60-200, Appendix A holds the model variables that source covers, so the 18 sources and their citations sit in gov.household.census_money_income_sources. A benefit the model computes for a group gives each member an equal share. Check the CBO lists against CBO's published definitions (The Distribution of Household Income in 2021, Appendix D, and its sources) and correct them: Medicare counts net of premiums, the premium tax credit reduces federal taxes, corporate income tax counts in market income as well as in taxes, retirement income for past services and other nongovernmental income join market income, and WIC, the Medicare Savings Programs, state SSI supplements and state cash assistance count as means-tested transfers. Tests: Census and CBO YAML cases from the published definitions, and test_income_concept_parity.py, which requires every source any income list counts to be in each concept or in its documented exclusions, and runs every listed and excluded source through paired households to show each concept moves dollar for dollar with what it lists and not at all with what it leaves out. Co-Authored-By: Claude Opus 5.5 --- changelog.d/census-money-income.added.md | 1 + changelog.d/census-money-income.fixed.md | 1 + .../cbo_federal_tax_subtractions.yaml | 14 + .../cbo_market_income_additions.yaml | 25 + .../household/cbo_means_tested_transfers.yaml | 98 ++- .../cbo_social_insurance_benefits.yaml | 10 +- .../census_money_income_sources/alimony.yaml | 11 + .../child_support.yaml | 11 + .../disability_benefits.yaml | 16 + .../dividends.yaml | 12 + .../census_money_income_sources/earnings.yaml | 23 + .../educational_assistance.yaml | 11 + .../financial_assistance.yaml | 11 + .../census_money_income_sources/index.yaml | 3 + .../census_money_income_sources/interest.yaml | 11 + .../other_income.yaml | 35 + .../pension_or_retirement_income.yaml | 17 + .../public_assistance.yaml | 24 + .../rents_royalties_estates_and_trusts.yaml | 13 + .../social_security.yaml | 12 + .../supplemental_security_income.yaml | 106 +++ .../survivor_benefits.yaml | 14 + .../unemployment_compensation.yaml | 13 + .../veterans_payments.yaml | 11 + .../workers_compensation.yaml | 11 + .../household/income/census_money_income.yaml | 157 ++++ .../household/cbo_household_income.yaml | 69 +- .../tests/test_aggregate_list_integrity.py | 20 +- .../tests/test_income_concept_parity.py | 717 ++++++++++++++++++ .../hhs/medicare/costs/medicare_gross_cost.py | 5 +- .../family/census_family_money_income.py | 21 + .../household/cbo_household_federal_taxes.py | 8 +- .../household/cbo_household_market_income.py | 12 +- ...cbo_household_social_insurance_benefits.py | 4 +- .../census_household_money_income.py | 20 + .../person/general/census_money_income.py | 51 ++ 36 files changed, 1571 insertions(+), 27 deletions(-) create mode 100644 changelog.d/census-money-income.added.md create mode 100644 changelog.d/census-money-income.fixed.md create mode 100644 policyengine_us/parameters/gov/household/cbo_federal_tax_subtractions.yaml create mode 100644 policyengine_us/parameters/gov/household/census_money_income_sources/alimony.yaml create mode 100644 policyengine_us/parameters/gov/household/census_money_income_sources/child_support.yaml create mode 100644 policyengine_us/parameters/gov/household/census_money_income_sources/disability_benefits.yaml create mode 100644 policyengine_us/parameters/gov/household/census_money_income_sources/dividends.yaml create mode 100644 policyengine_us/parameters/gov/household/census_money_income_sources/earnings.yaml create mode 100644 policyengine_us/parameters/gov/household/census_money_income_sources/educational_assistance.yaml create mode 100644 policyengine_us/parameters/gov/household/census_money_income_sources/financial_assistance.yaml create mode 100644 policyengine_us/parameters/gov/household/census_money_income_sources/index.yaml create mode 100644 policyengine_us/parameters/gov/household/census_money_income_sources/interest.yaml create mode 100644 policyengine_us/parameters/gov/household/census_money_income_sources/other_income.yaml create mode 100644 policyengine_us/parameters/gov/household/census_money_income_sources/pension_or_retirement_income.yaml create mode 100644 policyengine_us/parameters/gov/household/census_money_income_sources/public_assistance.yaml create mode 100644 policyengine_us/parameters/gov/household/census_money_income_sources/rents_royalties_estates_and_trusts.yaml create mode 100644 policyengine_us/parameters/gov/household/census_money_income_sources/social_security.yaml create mode 100644 policyengine_us/parameters/gov/household/census_money_income_sources/supplemental_security_income.yaml create mode 100644 policyengine_us/parameters/gov/household/census_money_income_sources/survivor_benefits.yaml create mode 100644 policyengine_us/parameters/gov/household/census_money_income_sources/unemployment_compensation.yaml create mode 100644 policyengine_us/parameters/gov/household/census_money_income_sources/veterans_payments.yaml create mode 100644 policyengine_us/parameters/gov/household/census_money_income_sources/workers_compensation.yaml create mode 100644 policyengine_us/tests/policy/baseline/household/income/census_money_income.yaml create mode 100644 policyengine_us/tests/test_income_concept_parity.py create mode 100644 policyengine_us/variables/household/income/family/census_family_money_income.py create mode 100644 policyengine_us/variables/household/income/household/census_household_money_income.py create mode 100644 policyengine_us/variables/household/income/person/general/census_money_income.py diff --git a/changelog.d/census-money-income.added.md b/changelog.d/census-money-income.added.md new file mode 100644 index 00000000000..5f6cdd36517 --- /dev/null +++ b/changelog.d/census-money-income.added.md @@ -0,0 +1 @@ +Add Census Bureau money income for people, families and households (census_money_income, census_family_money_income and census_household_money_income), built from one list for each of the 18 sources in Current Population Reports P60-200, Appendix A. diff --git a/changelog.d/census-money-income.fixed.md b/changelog.d/census-money-income.fixed.md new file mode 100644 index 00000000000..ee8e27f89c2 --- /dev/null +++ b/changelog.d/census-money-income.fixed.md @@ -0,0 +1 @@ +Align the CBO household income measures with CBO's published definitions: count Medicare net of premiums, subtract the premium tax credit from federal taxes, count corporate income tax in market income as well as in taxes, add railroad retirement, survivor benefits, child support, financial assistance and educational assistance to market income, and count WIC, the Medicare Savings Programs, state SSI supplements and state cash assistance as means-tested transfers. diff --git a/policyengine_us/parameters/gov/household/cbo_federal_tax_subtractions.yaml b/policyengine_us/parameters/gov/household/cbo_federal_tax_subtractions.yaml new file mode 100644 index 00000000000..12c5013be3a --- /dev/null +++ b/policyengine_us/parameters/gov/household/cbo_federal_tax_subtractions.yaml @@ -0,0 +1,14 @@ +description: Refundable credits that CBO household federal taxes subtract beyond those in income_tax. +values: + 0000-01-01: + # CBO's individual income taxes include the premium tax credit, which + # income_tax leaves out. + - assigned_aca_ptc + +metadata: + unit: list + period: year + label: CBO federal tax subtractions + reference: + - title: Congressional Budget Office, The Distribution of Household Income in 2021, Appendix D (individual income taxes include premium tax credits) + href: https://www.cbo.gov/system/files/2024-09/60341-income.pdf#page=42 diff --git a/policyengine_us/parameters/gov/household/cbo_market_income_additions.yaml b/policyengine_us/parameters/gov/household/cbo_market_income_additions.yaml index 0a1b39b8297..88b1b9c15b7 100644 --- a/policyengine_us/parameters/gov/household/cbo_market_income_additions.yaml +++ b/policyengine_us/parameters/gov/household/cbo_market_income_additions.yaml @@ -1,12 +1,37 @@ description: Additional components included in CBO household market income. values: 0000-01-01: + # Labor income: employer-paid health insurance premiums and the + # employer's share of payroll taxes. - employer_sponsored_insurance_premiums - employer_social_security_tax - employer_medicare_tax - employer_federal_unemployment_tax + # Labor and capital income include the shares of corporate income taxes + # that workers and capital owners bear. + - cbo_corporate_income_tax + # Other income sources: income received in retirement for past services. + # The Current Population Survey records railroad retirement as retirement + # income. Survivor benefits are survivors' pensions and annuities and + # payments from estates and trusts. + - railroad_benefits + - survivor_benefits + # Other income sources: other nongovernmental sources of income, which + # CBO draws from the Current Population Survey. educational_assistance + # also holds government grants such as Pell Grants, which CBO's lists do + # not name. + - child_support_received + - financial_assistance + - educational_assistance metadata: unit: list period: year label: Modeled CBO market income additions + reference: + - title: Congressional Budget Office, The Distribution of Household Income in 2021, Appendix D (market income) + href: https://www.cbo.gov/system/files/2024-09/60341-income.pdf#page=41 + - title: Congressional Budget Office, Working Paper 2017-09, Measuring Income (income that appears only in the Current Population Survey) + href: https://www.cbo.gov/system/files/115th-congress-2017-2018/workingpaper/53345-workingpaper.pdf#page=37 + - title: IPUMS CPS, INCSS (railroad retirement payments counted as retirement income since 1980) + href: https://cps.ipums.org/cps-action/variables/INCSS diff --git a/policyengine_us/parameters/gov/household/cbo_means_tested_transfers.yaml b/policyengine_us/parameters/gov/household/cbo_means_tested_transfers.yaml index f8081bc5a9d..59235051575 100644 --- a/policyengine_us/parameters/gov/household/cbo_means_tested_transfers.yaml +++ b/policyengine_us/parameters/gov/household/cbo_means_tested_transfers.yaml @@ -1,4 +1,6 @@ description: Means-tested transfers included in CBO household income after transfers and taxes. +# CBO also counts low-income subsidies for Medicare Part D and the +# Affordable Care Act's cost-sharing subsidies, which the model does not value. values: 0000-01-01: - medicaid @@ -11,11 +13,105 @@ values: - reduced_price_school_meals - spm_unit_energy_subsidy - general_assistance + # WIC and the Medicare Savings Programs join when household_benefits begins. + 2022-01-01: + - medicaid + - chip + - snap + - housing_assistance + - ssi + - tanf + - free_school_meals + - reduced_price_school_meals + - spm_unit_energy_subsidy + - general_assistance + # CBO names child nutrition programs without listing them. WIC is section + # 17 of the Child Nutrition Act of 1966 (42 U.S.C. 1786, in the Child + # Nutrition chapter of Title 42), so this list counts it there. + - wic + # Medicaid pays Medicare premiums and cost sharing for Medicare + # Savings Program enrollees (42 U.S.C. 1396a(a)(10)(E)). + - msp_cost + # State programs join in 2023, when household_state_benefits begins. + 2023-01-01: + - medicaid + - chip + - snap + - housing_assistance + - ssi + - tanf + - free_school_meals + - reduced_price_school_meals + - spm_unit_energy_subsidy + - general_assistance + - wic + - msp_cost + # State supplements to Supplemental Security Income. + - al_ssp + - ak_ssp + - ca_state_supplement + - ca_capi + - co_state_supplement + - ct_ssp + - de_ssp + - dc_ossp + - fl_oss + - ga_ssp + - hi_oss + - id_aabd + - in_ssp + - ia_ssa + - ks_sspp + - ky_ssp + - la_oss + - me_ssp + - md_paa + - ma_state_supplement + - mi_ssp + - mn_msa + - mo_ssp + - mt_ssp + - ne_aabd + - nv_oss + - nh_ssp + - nj_ssp + - nm_ssi_state_supplement + - ny_ssp + - nc_sa + - oh_rss + - ok_ssp + - or_ssp + - pa_ssp + - ri_ssp + - sc_ssi_state_supplement + - sd_ssp + - tx_ssi_state_supplement + - ut_ssp + - vt_aabd + - va_ag + - wa_ssp + - wi_ssp + - wy_ssp + # State and local cash assistance: Colorado Old Age Pension, + # Massachusetts Emergency Aid to the Elderly, Disabled and Children, + # and Washington State Family Assistance and Refugee Cash Assistance. + - co_oap + - ma_eaedc + - wa_sfa + - wa_rca metadata: unit: list period: year label: CBO means-tested transfers reference: - - title: Congressional Budget Office, Reconciling the Official Poverty Measure and CBO’s Distributional Analysis of Household Income, Including In-Kind Transfers + - title: Congressional Budget Office, The Distribution of Household Income in 2021, Appendix D (means-tested transfers) + href: https://www.cbo.gov/system/files/2024-09/60341-income.pdf#page=41 + - title: Congressional Budget Office, The Distribution of Household Income, 2022 (other transfers) + href: https://www.cbo.gov/publication/61911 + - title: Congressional Budget Office, Reconciling the Official Poverty Measure and CBO's Distributional Analysis of Household Income, Including In-Kind Transfers href: https://www.cbo.gov/publication/61157 + - title: 42 U.S. Code § 1786 - Special supplemental nutrition program for women, infants, and children + href: https://www.law.cornell.edu/uscode/text/42/1786 + - title: 42 U.S. Code § 1396a(a)(10)(E) - Medicare cost-sharing for qualified Medicare beneficiaries + href: https://www.law.cornell.edu/uscode/text/42/1396a#a_10_E diff --git a/policyengine_us/parameters/gov/household/cbo_social_insurance_benefits.yaml b/policyengine_us/parameters/gov/household/cbo_social_insurance_benefits.yaml index 340ddec03b1..c1583cdf3d6 100644 --- a/policyengine_us/parameters/gov/household/cbo_social_insurance_benefits.yaml +++ b/policyengine_us/parameters/gov/household/cbo_social_insurance_benefits.yaml @@ -2,7 +2,10 @@ description: Social insurance benefits included in CBO household income. values: 0000-01-01: - social_security - - medicare_gross_cost + # CBO measures Medicare as the average cost to the government net of + # offsetting receipts. medicare_cost subtracts the Part A and Part B + # premiums from the average cost per enrollee. + - medicare_cost - total_unemployment_compensation - workers_compensation @@ -10,3 +13,8 @@ metadata: unit: list period: year label: CBO social insurance benefits + reference: + - title: "Congressional Budget Office, The Distribution of Household Income in 2021, Appendix A note 3 (Medicare net of offsetting receipts) and Appendix D (social insurance benefits) #page 27,41" + href: https://www.cbo.gov/system/files/2024-09/60341-income.pdf#page=27 + - title: Congressional Budget Office, Reconciling the Official Poverty Measure and CBO's Distributional Analysis of Household Income (Medicare benefits net of premiums) + href: https://www.cbo.gov/publication/61157 diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/alimony.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/alimony.yaml new file mode 100644 index 00000000000..8aff3670246 --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/alimony.yaml @@ -0,0 +1,11 @@ +description: The Census Bureau counts these sources as alimony, source 15 of 18 in money income. +values: + 2022-01-01: + - alimony_income +metadata: + unit: list + period: year + label: Census money income alimony sources + reference: + - title: "Census Bureau, Current Population Reports P60-200, Appendix A, source 15 (alimony) #page 2-3" + href: https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=2 diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/child_support.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/child_support.yaml new file mode 100644 index 00000000000..344817bcb68 --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/child_support.yaml @@ -0,0 +1,11 @@ +description: The Census Bureau counts these sources as child support, source 16 of 18 in money income. +values: + 2022-01-01: + - child_support_received +metadata: + unit: list + period: year + label: Census money income child support sources + reference: + - title: "Census Bureau, Current Population Reports P60-200, Appendix A, source 16 (child support) #page 2-3" + href: https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=2 diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/disability_benefits.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/disability_benefits.yaml new file mode 100644 index 00000000000..c5443470af4 --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/disability_benefits.yaml @@ -0,0 +1,16 @@ +description: The Census Bureau counts these sources as disability benefits, source 9 of 18 in money income. +# pfml_benefits holds state paid family and medical leave benefits; +# Appendix A lists state temporary sickness payments among the sources +# of disability benefits. The model has no variable for Black lung +# payments, another source Appendix A lists here. +values: + 2022-01-01: + - disability_benefits + - pfml_benefits +metadata: + unit: list + period: year + label: Census money income disability benefits sources + reference: + - title: "Census Bureau, Current Population Reports P60-200, Appendix A, source 9 (disability benefits) #page 1,3" + href: https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=1 diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/dividends.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/dividends.yaml new file mode 100644 index 00000000000..40b5b322a60 --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/dividends.yaml @@ -0,0 +1,12 @@ +description: The Census Bureau counts these sources as dividends, source 12 of 18 in money income. +# Appendix A leaves out capital gains from the sale of stock. +values: + 2022-01-01: + - dividend_income +metadata: + unit: list + period: year + label: Census money income dividends sources + reference: + - title: "Census Bureau, Current Population Reports P60-200, Appendix A, source 12 (dividends) #page 2-3" + href: https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=2 diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/earnings.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/earnings.yaml new file mode 100644 index 00000000000..af5a4807877 --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/earnings.yaml @@ -0,0 +1,23 @@ +description: The Census Bureau counts these sources as earnings, source 1 of 18 in money income. +values: + 2022-01-01: + # Money wage or salary income, before deductions such as pension + # contributions. + - employment_income + # Net income from nonfarm self-employment, including a business, + # professional enterprise or partnership. + - self_employment_income + - sstb_self_employment_income + - partnership_s_corp_income + # Net income from farm self-employment. Appendix A counts rent paid as + # a share of the crops in farm gross receipts; farm_rent_income holds + # the crop and livestock shares reported on Form 4835. + - farm_operations_income + - farm_rent_income +metadata: + unit: list + period: year + label: Census money income earnings sources + reference: + - title: "Census Bureau, Current Population Reports P60-200, Appendix A, source 1 (earnings) #page 1-2" + href: https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=1 diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/educational_assistance.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/educational_assistance.yaml new file mode 100644 index 00000000000..ca3b9245b96 --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/educational_assistance.yaml @@ -0,0 +1,11 @@ +description: The Census Bureau counts these sources as educational assistance, source 14 of 18 in money income. +values: + 2022-01-01: + - educational_assistance +metadata: + unit: list + period: year + label: Census money income educational assistance sources + reference: + - title: "Census Bureau, Current Population Reports P60-200, Appendix A, source 14 (educational assistance) #page 2-3" + href: https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=2 diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/financial_assistance.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/financial_assistance.yaml new file mode 100644 index 00000000000..e14bfb664db --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/financial_assistance.yaml @@ -0,0 +1,11 @@ +description: The Census Bureau counts these sources as financial assistance from outside of the household, source 17 of 18 in money income. +values: + 2022-01-01: + - financial_assistance +metadata: + unit: list + period: year + label: Census money income financial assistance from outside of the household sources + reference: + - title: "Census Bureau, Current Population Reports P60-200, Appendix A, source 17 (financial assistance from outside of the household) #page 2-3" + href: https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=2 diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/index.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/index.yaml new file mode 100644 index 00000000000..9f7f37a527a --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/index.yaml @@ -0,0 +1,3 @@ +description: The Census Bureau counts these sources as money income, one list for each of the 18 sources in Current Population Reports P60-200, Appendix A. +metadata: + label: Census money income sources diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/interest.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/interest.yaml new file mode 100644 index 00000000000..54c99a2c902 --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/interest.yaml @@ -0,0 +1,11 @@ +description: The Census Bureau counts these sources as interest, source 11 of 18 in money income. +values: + 2022-01-01: + - interest_income +metadata: + unit: list + period: year + label: Census money income interest sources + reference: + - title: "Census Bureau, Current Population Reports P60-200, Appendix A, source 11 (interest) #page 2-3" + href: https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=2 diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/other_income.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/other_income.yaml new file mode 100644 index 00000000000..629b26ccea8 --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/other_income.yaml @@ -0,0 +1,35 @@ +description: The Census Bureau counts these sources as other income, source 18 of 18 in money income. +# Appendix A's other income covers payments received regularly that no +# other source covers, such as state foster child payments, military +# family allotments and foreign government pensions. The model has no +# variables for the last two. +values: + 2022-01-01: + # Guaranteed income pilot payments. + - gi_cash_assistance + # Alaska's annual dividend and the energy relief paid with it. + - ak_permanent_fund_dividend + - ak_energy_relief + # Contributed cash payments that are zero unless a reform turns them on. + - basic_income + - trump_dividend + # State programs join in 2023, when household_state_benefits begins. + 2023-01-01: + # Guaranteed income pilot payments. + - gi_cash_assistance + # Alaska's annual dividend and the energy relief paid with it. + - ak_permanent_fund_dividend + - ak_energy_relief + # Contributed cash payments that are zero unless a reform turns them on. + - basic_income + - trump_dividend + # Los Angeles County payments to parenting and expectant foster youth. + - ca_la_infant_supplement + - ca_la_expectant_parent_payment +metadata: + unit: list + period: year + label: Census money income other income sources + reference: + - title: "Census Bureau, Current Population Reports P60-200, Appendix A, source 18 (other income) #page 2-3" + href: https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=2 diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/pension_or_retirement_income.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/pension_or_retirement_income.yaml new file mode 100644 index 00000000000..a90029f9bac --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/pension_or_retirement_income.yaml @@ -0,0 +1,17 @@ +description: The Census Bureau counts these sources as pension or retirement income, source 10 of 18 in money income. +values: + 2022-01-01: + # Pensions, annuities and paid-up insurance policies. + - pension_income + - military_retirement_income + # IRA, Keogh and 401(k) payments. + - retirement_distributions + # Railroad retirement, one of the eight sources Appendix A lists here. + - railroad_benefits +metadata: + unit: list + period: year + label: Census money income pension or retirement income sources + reference: + - title: "Census Bureau, Current Population Reports P60-200, Appendix A, source 10 (pension or retirement income) #page 1,3" + href: https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=1 diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/public_assistance.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/public_assistance.yaml new file mode 100644 index 00000000000..80fb2a8b348 --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/public_assistance.yaml @@ -0,0 +1,24 @@ +description: The Census Bureau counts these sources as public assistance, source 6 of 18 in money income. +values: + 2022-01-01: + - tanf + - general_assistance + # State programs join in 2023, when household_state_benefits begins. + 2023-01-01: + - tanf + - general_assistance + # Colorado Old Age Pension covers people 60 and older, a wider group + # than source 5's 65 and older. + - co_oap + # Massachusetts Emergency Aid to the Elderly, Disabled and Children. + - ma_eaedc + # Washington State Family Assistance and Refugee Cash Assistance. + - wa_sfa + - wa_rca +metadata: + unit: list + period: year + label: Census money income public assistance sources + reference: + - title: "Census Bureau, Current Population Reports P60-200, Appendix A, source 6 (public assistance) #page 1,3" + href: https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=1 diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/rents_royalties_estates_and_trusts.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/rents_royalties_estates_and_trusts.yaml new file mode 100644 index 00000000000..1f9322696a9 --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/rents_royalties_estates_and_trusts.yaml @@ -0,0 +1,13 @@ +description: The Census Bureau counts these sources as rents, royalties, and estates and trusts, source 13 of 18 in money income. +values: + 2022-01-01: + # rental_income includes royalties. + - rental_income + - estate_income +metadata: + unit: list + period: year + label: Census money income rents, royalties, and estates and trusts sources + reference: + - title: "Census Bureau, Current Population Reports P60-200, Appendix A, source 13 (rents, royalties, and estates and trusts) #page 2-3" + href: https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=2 diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/social_security.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/social_security.yaml new file mode 100644 index 00000000000..313c715b871 --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/social_security.yaml @@ -0,0 +1,12 @@ +description: The Census Bureau counts these sources as Social Security, source 4 of 18 in money income. +# Appendix A counts benefits before the deduction for Medicare premiums. +values: + 2022-01-01: + - social_security +metadata: + unit: list + period: year + label: Census money income Social Security sources + reference: + - title: "Census Bureau, Current Population Reports P60-200, Appendix A, source 4 (Social Security) #page 1,3" + href: https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=1 diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/supplemental_security_income.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/supplemental_security_income.yaml new file mode 100644 index 00000000000..f8c7c080675 --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/supplemental_security_income.yaml @@ -0,0 +1,106 @@ +description: The Census Bureau counts these sources as Supplemental Security Income, source 5 of 18 in money income. +# Appendix A counts federal, state and local payments to low-income +# people 65 or older, blind or disabled, so state supplements to SSI +# count here alongside the federal payment. +values: + 2022-01-01: + - ssi + # State programs join in 2023, when household_state_benefits begins. + 2023-01-01: + - ssi + # Alabama + - al_ssp + # Alaska + - ak_ssp + # California + - ca_state_supplement + - ca_capi + # Colorado + - co_state_supplement + # Connecticut + - ct_ssp + # Delaware + - de_ssp + # District of Columbia + - dc_ossp + # Florida + - fl_oss + # Georgia + - ga_ssp + # Hawaii + - hi_oss + # Idaho + - id_aabd + # Indiana + - in_ssp + # Iowa + - ia_ssa + # Kansas + - ks_sspp + # Kentucky + - ky_ssp + # Louisiana + - la_oss + # Maine + - me_ssp + # Maryland + - md_paa + # Massachusetts + - ma_state_supplement + # Michigan + - mi_ssp + # Minnesota + - mn_msa + # Missouri + - mo_ssp + # Montana + - mt_ssp + # Nebraska + - ne_aabd + # Nevada + - nv_oss + # New Hampshire + - nh_ssp + # New Jersey + - nj_ssp + # New Mexico + - nm_ssi_state_supplement + # New York + - ny_ssp + # North Carolina + - nc_sa + # Ohio + - oh_rss + # Oklahoma + - ok_ssp + # Oregon + - or_ssp + # Pennsylvania + - pa_ssp + # Rhode Island + - ri_ssp + # South Carolina + - sc_ssi_state_supplement + # South Dakota + - sd_ssp + # Texas + - tx_ssi_state_supplement + # Utah + - ut_ssp + # Vermont + - vt_aabd + # Virginia + - va_ag + # Washington + - wa_ssp + # Wisconsin + - wi_ssp + # Wyoming + - wy_ssp +metadata: + unit: list + period: year + label: Census money income Supplemental Security Income sources + reference: + - title: "Census Bureau, Current Population Reports P60-200, Appendix A, source 5 (Supplemental Security Income) #page 1,3" + href: https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=1 diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/survivor_benefits.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/survivor_benefits.yaml new file mode 100644 index 00000000000..17fd6afedc3 --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/survivor_benefits.yaml @@ -0,0 +1,14 @@ +description: The Census Bureau counts these sources as survivor benefits, source 8 of 18 in money income. +# Railroad Retirement Board survivor annuities sit in railroad_benefits, +# under pension or retirement income, because the model does not split +# that variable by type of annuity. +values: + 2022-01-01: + - survivor_benefits +metadata: + unit: list + period: year + label: Census money income survivor benefits sources + reference: + - title: "Census Bureau, Current Population Reports P60-200, Appendix A, source 8 (survivor benefits) #page 1,3" + href: https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=1 diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/unemployment_compensation.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/unemployment_compensation.yaml new file mode 100644 index 00000000000..fea93ea9c6f --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/unemployment_compensation.yaml @@ -0,0 +1,13 @@ +description: The Census Bureau counts these sources as unemployment compensation, source 2 of 18 in money income. +values: + 2022-01-01: + - total_unemployment_compensation + # Appendix A counts strike benefits from union funds here. + - strike_benefits +metadata: + unit: list + period: year + label: Census money income unemployment compensation sources + reference: + - title: "Census Bureau, Current Population Reports P60-200, Appendix A, source 2 (unemployment compensation) #page 1-2" + href: https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=1 diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/veterans_payments.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/veterans_payments.yaml new file mode 100644 index 00000000000..4610dc98094 --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/veterans_payments.yaml @@ -0,0 +1,11 @@ +description: The Census Bureau counts these sources as veterans' payments, source 7 of 18 in money income. +values: + 2022-01-01: + - veterans_benefits +metadata: + unit: list + period: year + label: Census money income veterans' payments sources + reference: + - title: "Census Bureau, Current Population Reports P60-200, Appendix A, source 7 (veterans' payments) #page 1,3" + href: https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=1 diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/workers_compensation.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/workers_compensation.yaml new file mode 100644 index 00000000000..2cb7af5b55d --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/workers_compensation.yaml @@ -0,0 +1,11 @@ +description: The Census Bureau counts these sources as workers' compensation, source 3 of 18 in money income. +values: + 2022-01-01: + - workers_compensation +metadata: + unit: list + period: year + label: Census money income workers' compensation sources + reference: + - title: "Census Bureau, Current Population Reports P60-200, Appendix A, source 3 (workers' compensation) #page 1,3" + href: https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=1 diff --git a/policyengine_us/tests/policy/baseline/household/income/census_money_income.yaml b/policyengine_us/tests/policy/baseline/household/income/census_money_income.yaml new file mode 100644 index 00000000000..e5966a20bf1 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/income/census_money_income.yaml @@ -0,0 +1,157 @@ +# Census Bureau money income, Current Population Reports P60-200, Appendix A. +# Expected values add the amounts of the sources Appendix A lists (pages A-1 to +# A-3) and leave out the receipts it says it does not count (page A-3). + +- name: Money income adds every Appendix A source a person receives + period: 2026 + input: + state_code: TX + age: 40 + ssi: 0 + tanf: 0 + # 1. Earnings: wages, nonfarm and farm self-employment. + employment_income: 30_000 + self_employment_income: 4_000 + farm_operations_income: 1_000 + # 2. Unemployment compensation, with strike benefits from union funds. + unemployment_compensation: 2_000 + strike_benefits: 500 + # 3. Workers' compensation. + workers_compensation: 1_000 + # 4. Social Security. + social_security: 6_000 + # 7. Veterans' payments. + veterans_benefits: 3_000 + # 8. Survivor benefits. + survivor_benefits: 700 + # 9. Disability benefits. + disability_benefits: 400 + # 10. Pension or retirement income, railroad retirement included. + pension_income: 2_500 + railroad_benefits: 1_100 + # 11. Interest, 12. dividends, 13. rents, royalties, estates and trusts. + interest_income: 300 + dividend_income: 200 + rental_income: 1_200 + estate_income: 100 + # 14. Educational assistance, 15. alimony, 16. child support. + educational_assistance: 900 + alimony_income: 600 + child_support_received: 800 + # 17. Financial assistance from outside the household, 18. other income. + financial_assistance: 250 + gi_cash_assistance: 150 + output: + census_money_income: 56_700 + census_family_money_income: 56_700 + census_household_money_income: 56_700 + +- name: Receipts Appendix A does not count leave money income unchanged + period: 2026 + input: + state_code: TX + age: 40 + ssi: 0 + tanf: 0 + employment_income: 20_000 + # Capital gains, tax refunds and lump-sum insurance payments. + capital_gains: 10_000 + salt_refund_income: 300 + life_insurance_benefits: 5_000 + # Discharged debt is not a receipt of money. + debt_relief: 2_000 + # Noncash benefits. + snap: 1_200 + wic: 500 + free_school_meals: 400 + housing_assistance: 3_000 + output: + census_money_income: 20_000 + census_household_money_income: 20_000 + +- name: A benefit paid to an SPM unit is split equally among its members + period: 2026 + input: + people: + parent: + age: 40 + employment_income: 30_000 + ssi: 0 + partner: + age: 38 + railroad_benefits: 1_200 + ssi: 0 + child: + age: 5 + ssi: 0 + spm_units: + spm_unit: + members: [parent, partner, child] + tanf: 1_200 + # SNAP is noncash and stays out. + snap: 900 + households: + household: + members: [parent, partner, child] + state_code: TX + output: + # TANF of $1,200 over three members gives each $400. + census_money_income: [30_400, 1_600, 400] + census_family_money_income: 32_400 + census_household_money_income: 32_400 + +- name: Two unrelated roommates form two families in one household + period: 2026 + input: + people: + first: + age: 30 + employment_income: 25_000 + ssi: 0 + second: + age: 40 + alimony_income: 9_000 + interest_income: 500 + ssi: 0 + tax_units: + first_tax_unit: + members: [first] + second_tax_unit: + members: [second] + spm_units: + first_spm_unit: + members: [first] + tanf: 0 + second_spm_unit: + members: [second] + tanf: 0 + families: + first_family: + members: [first] + second_family: + members: [second] + marital_units: + first_marital_unit: + members: [first] + second_marital_unit: + members: [second] + households: + household: + members: [first, second] + state_code: TX + output: + census_money_income: [25_000, 9_500] + census_family_money_income: [25_000, 9_500] + census_household_money_income: 34_500 + +- name: A state cash program for aged, blind and disabled people counts as SSI + period: 2026 + input: + state_code: CA + age: 70 + employment_income: 500_000 + # California's Cash Assistance Program for Immigrants, paid to the SPM unit. + ca_capi: 6_000 + output: + census_money_income: 506_000 + census_household_money_income: 506_000 diff --git a/policyengine_us/tests/policy/baseline/household/income/household/cbo_household_income.yaml b/policyengine_us/tests/policy/baseline/household/income/household/cbo_household_income.yaml index 07d9f4f1d8e..cfecfb1cc45 100644 --- a/policyengine_us/tests/policy/baseline/household/income/household/cbo_household_income.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/household/cbo_household_income.yaml @@ -35,14 +35,19 @@ cbo_household_market_income: 49_102 # CMS Medicare spending per enrollee, 2025. medicare_gross_cost: 17_751 - # $12,000 + $17,751 + $2,000 + $1,000. - cbo_household_social_insurance_benefits: 32_751 - cbo_household_income_before_transfers_and_taxes: 81_853 - cbo_household_income: 81_853 + # CBO counts Medicare net of premiums: $17,751 less the 2025 standard + # Part B premium of $185 a month ($2,220); Part A is premium-free with + # 40 quarters of coverage. + medicare_cost: 15_531 + # $12,000 + $15,531 + $2,000 + $1,000. + cbo_household_social_insurance_benefits: 30_531 + cbo_household_income_before_transfers_and_taxes: 79_633 + cbo_household_income: 79_633 - name: CBO means-tested transfers include only the listed programs period: 2026 input: + state_code: WA medicaid: 10 chip: 5 snap: 7 @@ -54,16 +59,27 @@ reduced_price_school_meals: 1 spm_unit_energy_subsidy: 9 general_assistance: 6 - wic: 100 + # WIC is a child nutrition program (42 U.S.C. 1786). + wic: 11 + # Medicare Savings Programs pay Medicare premiums and cost sharing for + # low-income enrollees through Medicaid (42 U.S.C. 1396a(a)(10)(E)). + msp_cost: 12 + # Washington's SSI state supplement and State Family Assistance. + wa_ssp: 13 + wa_sfa: 14 head_start: 100 early_head_start: 100 basic_income: 100 household_state_benefits: 100 household_health_benefits: 100 + veterans_benefits: 100 + # CBO subtracts the premium tax credit from federal income taxes. + assigned_aca_ptc: 100 output: - cbo_household_means_tested_transfers: 55 + # $55 from the federal programs above WIC, plus $11 + $12 + $13 + $14. + cbo_household_means_tested_transfers: 105 -- name: CBO after-tax income uses federal taxes only and keeps negative income tax +- name: CBO after-tax income uses federal taxes only, keeps negative income tax and subtracts the premium tax credit period: 2025 input: age: 40 @@ -84,9 +100,42 @@ self_employment_tax: 0 cbo_corporate_income_tax: 100 cbo_excise_tax: 20 + assigned_aca_ptc: 800 household_state_tax_before_refundable_credits: 9_999 output: - cbo_household_income_before_transfers_and_taxes: 37_337 + # $30,000 + $1,860 + $435 + $42 + $100 of corporate income tax, which + # CBO counts in market income as well as in taxes, plus $5,000. + cbo_household_income_before_transfers_and_taxes: 37_437 cbo_household_means_tested_transfers: 11_000 - cbo_household_federal_taxes: 3_252 - cbo_household_income_after_transfers_and_taxes: 45_085 + # -$1,500 + $1,860 + $435 + $1,860 + $435 + $42 + $100 + $20 - $800. + cbo_household_federal_taxes: 2_452 + # $37,437 + $11,000 - $2,452. + cbo_household_income_after_transfers_and_taxes: 45_985 + +- name: CBO market income adds retirement income and private transfers but not veterans' benefits + period: 2026 + input: + state_code: TX + age: 40 + employment_income: 100_000 + employer_social_security_tax: 6_200 + employer_medicare_tax: 1_450 + employer_federal_unemployment_tax: 42 + # Income received in retirement for past services. + railroad_benefits: 1_200 + survivor_benefits: 700 + # Other nongovernmental sources. + child_support_received: 800 + financial_assistance: 250 + educational_assistance: 900 + cbo_corporate_income_tax: 100 + # CBO's income categories have not named veterans' benefits since its + # report on 2014, published in March 2018. + veterans_benefits: 3_000 + output: + # $100,000 + $6,200 + $1,450 + $42 + $1,200 + $700 + $800 + $250 + $900 + # + $100. + cbo_household_market_income: 111_642 + cbo_household_social_insurance_benefits: 0 + cbo_household_means_tested_transfers: 0 + cbo_household_income_before_transfers_and_taxes: 111_642 diff --git a/policyengine_us/tests/test_aggregate_list_integrity.py b/policyengine_us/tests/test_aggregate_list_integrity.py index e76e164a2f0..04df3354b68 100644 --- a/policyengine_us/tests/test_aggregate_list_integrity.py +++ b/policyengine_us/tests/test_aggregate_list_integrity.py @@ -155,10 +155,22 @@ def test_members_are_state_gated(list_path, variables): ) -# household_benefits needs no state gate, because most members are national. -@pytest.mark.parametrize( - "list_path", [*AGGREGATE_LISTS, "gov/household/household_benefits.yaml"] -) +# household_benefits and the income concept lists need no state gate, because +# most of their members are national. +INCOME_CONCEPT_LISTS = [ + "gov/household/household_benefits.yaml", + "gov/household/cbo_means_tested_transfers.yaml", + *sorted( + str(path.relative_to(PARAMETERS)) + for path in (PARAMETERS / "gov/household/census_money_income_sources").glob( + "*.yaml" + ) + if path.name != "index.yaml" + ), +] + + +@pytest.mark.parametrize("list_path", [*AGGREGATE_LISTS, *INCOME_CONCEPT_LISTS]) def test_no_silent_removals_between_year_blocks(list_path): blocks = blocks_of(list_path) for (_, prev), (date, curr) in zip(blocks, blocks[1:]): diff --git a/policyengine_us/tests/test_income_concept_parity.py b/policyengine_us/tests/test_income_concept_parity.py new file mode 100644 index 00000000000..59b3089a722 --- /dev/null +++ b/policyengine_us/tests/test_income_concept_parity.py @@ -0,0 +1,717 @@ +"""Invariants for Census money income and the CBO household income measures. + +census_money_income sums the 18 sources of Census Current Population Reports +P60-200, Appendix A, one list each in gov.household.census_money_income_sources. +CBO's measures sum their own lists in gov.household (cbo_*). Both sit beside +household_benefits and market_income_sources, and lists kept in separate files +drift: a program added to household_benefits reaches neither concept, or a +source lands in two CBO categories and counts twice. + +Five invariants hold for every input. + +1. Every source that any of these lists counts appears in each concept, or in + that concept's documented exclusions below, with the reason. +2. No source counts twice: no variable sits under two Census sources, the CBO + categories are disjoint, and CBO adds nothing household market income + already holds. +3. A dollar of a listed source adds a dollar to its concept. A person's Census + money income gains the dollar, or an equal share of a dollar paid to a + group, and the family and household totals gain the dollar. The CBO + category that lists a source gains the dollar and the others do not. +4. A dollar of a source a concept leaves out moves that concept not at all. +5. Family and household totals sum their members, census_money_income matches + an independent allocation of each source, and Census household money income + reconciles with household market income and household benefits through the + documented differences, in every state. + +YAML pins one household per case. These tests run every listed and excluded +source at once, with Hypothesis over amounts and household sizes. +""" + +import functools +import re +from pathlib import Path + +import numpy as np +import pytest +import yaml +from hypothesis import example, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +PACKAGE = Path(__file__).parent.parent +HOUSEHOLD_LISTS = PACKAGE / "parameters/gov/household" +CENSUS_LISTS = HOUSEHOLD_LISTS / "census_money_income_sources" +MARKET_INCOME_SOURCES = HOUSEHOLD_LISTS / "market_income_sources.yaml" +# household_benefits and the Census lists begin in 2022. +YEARS = range(2022, 2027) +YEAR = 2026 +TOLERANCE = 0.05 +# Census household money income adds about 100 float32 terms, so the +# reconciliation allows for their rounding. A misclassified program moves the +# total by the whole benefit. +RECONCILIATION_TOLERANCE = 1.0 + +STATES = ( + "AL AK AZ AR CA CO CT DE DC FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS " + "MO MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY" +).split() + +# Appendix A's 18 sources, in its order (pages A-1 and A-2). +CENSUS_SOURCES = ( + "earnings", + "unemployment_compensation", + "workers_compensation", + "social_security", + "supplemental_security_income", + "public_assistance", + "veterans_payments", + "survivor_benefits", + "disability_benefits", + "pension_or_retirement_income", + "interest", + "dividends", + "rents_royalties_estates_and_trusts", + "educational_assistance", + "alimony", + "child_support", + "financial_assistance", + "other_income", +) + +# Money income leaves out noncash benefits (Appendix A, page A-2) and these +# receipts (page A-3): capital gains, bank withdrawals, borrowing, tax refunds, +# gifts, and lump-sum inheritances or insurance payments. +NONCASH = "a noncash benefit" +HEALTH = "health coverage, a noncash benefit" +EMPLOYER = "an employer payment, not money the worker receives" +DISCOUNT = "lowers a bill, not money received" +REBATE = "lowers the price of a purchase, not money received" +CENSUS_EXCLUDED = { + # Market income. + "capital_gains": "capital gains", + "non_sch_d_capital_gains": "capital gains", + "other_net_gain_person": "gains on sales of business property (Form 4797)", + "debt_relief": "discharged debt, not a receipt of money", + "illicit_income": "income from bribes and other illegal activity, which " + "no Appendix A source covers", + "miscellaneous_income": "irregular items of gross income (26 CFR 1.61-14); " + "other income covers payments people receive regularly", + # CBO's employer-side and imputed components. + "employer_sponsored_insurance_premiums": EMPLOYER, + "employer_social_security_tax": EMPLOYER, + "employer_medicare_tax": EMPLOYER, + "employer_federal_unemployment_tax": EMPLOYER, + "cbo_corporate_income_tax": "CBO's allocation of corporate income tax, " + "not a receipt", + # Health coverage. + "medicaid": HEALTH, + "medicaid_cost": HEALTH, + "chip": HEALTH, + "msp_cost": HEALTH, + "medicare_cost": HEALTH, + "assigned_aca_ptc": "a tax credit paid to the insurer", + "assigned_ca_premium_subsidy": HEALTH, + "assigned_co_premium_assistance": HEALTH, + "assigned_nm_premium_assistance": HEALTH, + "basic_health_program": HEALTH, + "co_omnisalud": HEALTH, + "ct_covered_connecticut": HEALTH, + "ma_connector_care": HEALTH, + "md_premium_assistance": HEALTH, + "nj_njhps": HEALTH, + "or_healthier_oregon_cost": HEALTH, + "vt_premium_assistance": HEALTH, + "wa_cascade_care_savings": HEALTH, + # Food, housing, energy, child care and early education. + "snap": NONCASH, + "wic": NONCASH, + "free_school_meals": NONCASH, + "reduced_price_school_meals": NONCASH, + "commodity_supplemental_food_program": NONCASH, + "housing_assistance": NONCASH, + "spm_unit_energy_subsidy": NONCASH, + "household_child_care_subsidies": NONCASH, + "head_start": NONCASH, + "early_head_start": NONCASH, + # Discounts and rebates. + "acp": DISCOUNT, + "ebb": DISCOUNT, + "ca_care": DISCOUNT, + "ca_fera": DISCOUNT, + "ca_la_ez_save": DISCOUNT, + "ca_riv_share_payment": DISCOUNT, + "ca_cvrp": REBATE, + "high_efficiency_electric_home_rebate": REBATE, + "residential_efficiency_electrification_rebate": REBATE, + "ny_drive_clean_rebate": REBATE, + # Tax relief. + "nj_property_tax_relief": "property tax relief, which returns tax: " + "Appendix A leaves out tax refunds", + "co_denver_property_tax_relief": "property tax relief, which returns tax: " + "Appendix A leaves out tax refunds", +} + +# CBO's measures (The Distribution of Household Income in 2021, Appendix D) +# count market income, four social insurance programs, listed means-tested +# programs, and federal taxes net of refundable credits. +OUTSIDE_CBO = "not in market income or a program CBO lists" +CBO_EXCLUDED = { + "veterans_benefits": "CBO's categories have not named veterans' benefits " + "since its report on 2014, published in March 2018; its working paper " + "2017-09 counted them as means-tested transfers", + "pfml_benefits": "state paid leave insurance: CBO's social insurance is " + "Social Security, Medicare, unemployment insurance and workers' " + "compensation", + # cbo_market_income_subtractions removes these two from household market + # income. + "gi_cash_assistance": "guaranteed income pilot payments, which CBO's lists " + "do not name", + "ak_permanent_fund_dividend": "Alaska's dividend to eligible residents, " + "a government payment outside CBO's categories", + "ak_energy_relief": "Alaska's energy relief, paid with the dividend, " + "outside CBO's categories", + "basic_income": "a proposed universal payment (contributed reform), " + "outside CBO's categories", + "trump_dividend": "a proposed payment (contributed reform), outside CBO's " + "categories", + "acp": OUTSIDE_CBO, + "ebb": OUTSIDE_CBO, + "ca_care": OUTSIDE_CBO, + "ca_fera": OUTSIDE_CBO, + "ca_la_ez_save": OUTSIDE_CBO, + "ca_riv_share_payment": OUTSIDE_CBO, + "ca_cvrp": OUTSIDE_CBO, + "high_efficiency_electric_home_rebate": OUTSIDE_CBO, + "residential_efficiency_electrification_rebate": OUTSIDE_CBO, + "ny_drive_clean_rebate": OUTSIDE_CBO, + "ca_la_expectant_parent_payment": OUTSIDE_CBO, + "ca_la_infant_supplement": OUTSIDE_CBO, + "co_denver_property_tax_relief": OUTSIDE_CBO, + "nj_property_tax_relief": OUTSIDE_CBO, + "commodity_supplemental_food_program": OUTSIDE_CBO, + "head_start": OUTSIDE_CBO, + "early_head_start": OUTSIDE_CBO, + "household_child_care_subsidies": OUTSIDE_CBO, + "assigned_ca_premium_subsidy": OUTSIDE_CBO, + "assigned_co_premium_assistance": OUTSIDE_CBO, + "assigned_nm_premium_assistance": OUTSIDE_CBO, + "basic_health_program": OUTSIDE_CBO, + "co_omnisalud": OUTSIDE_CBO, + "ct_covered_connecticut": OUTSIDE_CBO, + "ma_connector_care": OUTSIDE_CBO, + "md_premium_assistance": OUTSIDE_CBO, + "nj_njhps": OUTSIDE_CBO, + "or_healthier_oregon_cost": OUTSIDE_CBO, + "vt_premium_assistance": OUTSIDE_CBO, + "wa_cascade_care_savings": OUTSIDE_CBO, +} + +# CBO's income totals, before and after transfers. +CBO_INCOMES = ( + "cbo_household_market_income", + "cbo_household_social_insurance_benefits", + "cbo_household_means_tested_transfers", +) + +# Sources CBO counts through another listed variable that sums them. +CBO_COUNTED_AS = {"medicaid_cost": "medicaid"} + +# Sources that can be losses. +LOSSES = { + "self_employment_income", + "sstb_self_employment_income", + "partnership_s_corp_income", + "farm_operations_income", + "farm_rent_income", + "rental_income", + "estate_income", +} + + +def block(path, year): + """The members of a list parameter in effect during a year.""" + raw = yaml.load(path.read_text(), Loader=yaml.BaseLoader) + dated = sorted(item for item in raw["values"].items() if item[0] <= f"{year}-12-31") + return set(dated[-1][1]) if dated else set() + + +def census_lists(year): + return { + source: block(CENSUS_LISTS / f"{source}.yaml", year) + for source in CENSUS_SOURCES + } + + +def census_members(year): + return set().union(*census_lists(year).values()) + + +def cbo_lists(year): + names = ( + "market_income_additions", + "market_income_subtractions", + "social_insurance_benefits", + "means_tested_transfers", + "federal_tax_subtractions", + ) + return {name: block(HOUSEHOLD_LISTS / f"cbo_{name}.yaml", year) for name in names} + + +def cbo_categories(year): + """Each CBO total and the sources it counts; federal taxes subtract theirs.""" + lists = cbo_lists(year) + market = block(MARKET_INCOME_SOURCES, year) + return { + "cbo_household_market_income": (market - lists["market_income_subtractions"]) + | lists["market_income_additions"], + "cbo_household_social_insurance_benefits": lists["social_insurance_benefits"], + "cbo_household_means_tested_transfers": lists["means_tested_transfers"], + "cbo_household_federal_taxes": lists["federal_tax_subtractions"], + } + + +def benefit_terms(year): + """household_benefits with its state and local lists opened up. + + The health and Head Start totals stay whole: a switch turns each on. + """ + terms = set() + for name in block(HOUSEHOLD_LISTS / "household_benefits.yaml", year): + if name in ("household_state_benefits", "household_local_benefits"): + terms |= block(HOUSEHOLD_LISTS / f"{name}.yaml", year) + else: + terms.add(name) + return terms + + +def benefit_leaves(year): + """household_benefits with every nested list opened up.""" + leaves = set() + for name in benefit_terms(year): + if name in ("household_health_benefits", "household_head_start_benefits"): + leaves |= block(HOUSEHOLD_LISTS / f"{name}.yaml", year) + else: + leaves.add(name) + return leaves + + +def universe(year): + """Every source that any concept's list counts during a year.""" + return ( + benefit_leaves(year) + | block(MARKET_INCOME_SOURCES, year) + | census_members(year) + | set().union(*cbo_lists(year).values()) + ) + + +def state_of(name): + """The state that pays a program its name starts with, or Texas.""" + for match in re.finditer(r"(?:^|_)([a-z]{2})(?=_)", name): + if match.group(1).upper() in STATES: + return match.group(1).upper() + return "TX" + + +@pytest.fixture(scope="module") +def variables(): + from policyengine_us.system import system + + return system.variables + + +@functools.cache +def entity_of(name): + from policyengine_us.system import system + + return system.variables[name].entity.key + + +def test_census_lists_follow_appendix_a(): + files = {path.stem for path in CENSUS_LISTS.glob("*.yaml")} - {"index"} + assert files == set(CENSUS_SOURCES) + for number, source in enumerate(CENSUS_SOURCES, start=1): + raw = yaml.load( + (CENSUS_LISTS / f"{source}.yaml").read_text(), Loader=yaml.BaseLoader + ) + assert f"source {number} of 18" in raw["description"], source + titles = [reference["title"] for reference in raw["metadata"]["reference"]] + assert any("P60-200, Appendix A" in title for title in titles), source + + +def test_concept_lists_name_defined_variables(variables): + names = set(CENSUS_EXCLUDED) | set(CBO_EXCLUDED) + names |= set(CBO_COUNTED_AS) | set(CBO_COUNTED_AS.values()) + for year in YEARS: + names |= census_members(year) | set().union(*cbo_lists(year).values()) + assert names - set(variables) == set() + + +@pytest.mark.parametrize("year", YEARS) +def test_no_source_counts_twice(year): + seen = {} + for source, members in census_lists(year).items(): + for name in members: + assert name not in seen, f"{name} is under {seen[name]} and {source}" + seen[name] = source + + lists = cbo_lists(year) + market = block(MARKET_INCOME_SOURCES, year) + # An addition already in household market income would count twice, and a + # subtraction missing from it would take away income never added. + assert lists["market_income_additions"] & market == set() + assert lists["market_income_subtractions"] <= market + categories = cbo_categories(year) + totals = list(categories) + for index, first in enumerate(totals): + for second in totals[index + 1 :]: + both = categories[first] & categories[second] + assert both == set(), f"{sorted(both)} count in {first} and {second}" + counted = set().union(*categories.values()) + for name, alias in CBO_COUNTED_AS.items(): + assert name not in counted, f"{name} counts beside {alias}, which sums it" + + # household_market_income + household_benefits must not count a source + # twice either, or the reconciliation below would not hold. + assert market & benefit_terms(year) == set() + + +@pytest.mark.parametrize("year", YEARS) +def test_census_classifies_every_source(year): + counted = census_members(year) + excluded = set(CENSUS_EXCLUDED) + assert counted & excluded == set(), "listed and excluded" + unclassified = universe(year) - counted - excluded + assert unclassified == set(), ( + f"{year}: {sorted(unclassified)} reach another income concept but " + "neither a Census money income list nor CENSUS_EXCLUDED. Add each to " + "the Appendix A source that covers it, or document why money income " + "leaves it out." + ) + + +@pytest.mark.parametrize("year", YEARS) +def test_cbo_classifies_every_source(year): + counted = set().union(*cbo_categories(year).values()) + counted |= {name for name, alias in CBO_COUNTED_AS.items() if alias in counted} + excluded = set(CBO_EXCLUDED) + assert counted & excluded == set(), "listed and excluded" + unclassified = universe(year) - counted - excluded + assert unclassified == set(), ( + f"{year}: {sorted(unclassified)} reach another income concept but no " + "CBO category and not CBO_EXCLUDED. Add each to the CBO list that " + "covers it, or document why CBO leaves it out." + ) + # Each source CBO removes from household market income says why. + assert cbo_lists(year)["market_income_subtractions"] <= excluded + + +def test_exclusions_are_current(): + every_year = set().union(*(universe(year) for year in YEARS)) + assert set(CENSUS_EXCLUDED) - every_year == set(), "stale Census entries" + assert set(CBO_EXCLUDED) - every_year == set(), "stale CBO entries" + assert set(CBO_COUNTED_AS) - every_year == set(), "stale CBO aliases" + + +def paired_households(cases, size, earnings=0, year=YEAR): + """A paid and an unpaid household for each (name, entity, state, amount). + + Each household holds a 40-year-old head, who earns `earnings`, and + `size - 1` children, in one tax unit, SPM unit and family. A source the + situation names becomes an input for every household, zero where unset, + so no formula responds to another case's payment. + """ + people = {} + units = { + "tax_units": {}, + "spm_units": {}, + "families": {}, + "marital_units": {}, + "households": {}, + } + for index, (name, entity, state, amount) in enumerate(cases): + for side, value in (("paid", amount), ("zero", 0)): + key = f"{side}_{index}" + members = [f"{key}_{position}" for position in range(size)] + for position, member in enumerate(members): + people[member] = {"age": {year: 40 if position == 0 else 10}} + units["marital_units"][member] = {"members": [member]} + head = people[members[0]] + head["employment_income"] = {year: earnings} + groups = { + "person": head, + "marital_unit": units["marital_units"][members[0]], + "tax_unit": {"members": members}, + "spm_unit": {"members": members}, + "family": {"members": members}, + "household": {"members": members, "state_name": {year: state}}, + } + if name == "employment_income": + value += earnings + groups[entity][name] = {year: value} + units["tax_units"][key] = groups["tax_unit"] + units["spm_units"][key] = groups["spm_unit"] + units["families"][key] = groups["family"] + units["households"][key] = groups["household"] + return Simulation(situation={"people": people, **units}) + + +def values_by_id(simulation, variable, year=YEAR): + entity = simulation.tax_benefit_system.variables[variable].entity.key + ids = simulation.populations[entity].ids + return dict(zip(ids, simulation.calculate(variable, year).astype(float))) + + +def change(values, index, member=None): + suffix = "" if member is None else f"_{member}" + return values[f"paid_{index}{suffix}"] - values[f"zero_{index}{suffix}"] + + +def shares(entity, amount, size): + """What each member gains: a person's own source, or an equal share.""" + if entity in ("person", "marital_unit"): + return [amount] + [0] * (size - 1) + return [amount / size] * size + + +CENSUS_CASES = sorted(census_members(YEAR) | (set(CENSUS_EXCLUDED) & universe(YEAR))) + + +@settings(max_examples=5, deadline=None) +@given( + size=st.integers(min_value=1, max_value=3), + amounts=st.lists( + st.integers(min_value=1, max_value=100_000), + min_size=len(CENSUS_CASES), + max_size=len(CENSUS_CASES), + ), + losses=st.booleans(), +) +@example( + size=3, + amounts=[1_000 + index for index in range(len(CENSUS_CASES))], + losses=True, +) +def test_census_money_income_moves_dollar_for_dollar(size, amounts, losses): + counted = census_members(YEAR) + cases = [] + for name, amount in zip(CENSUS_CASES, amounts): + if losses and name in LOSSES: + amount = -amount + cases.append((name, entity_of(name), state_of(name), amount)) + simulation = paired_households(cases, size) + person = values_by_id(simulation, "census_money_income") + family = values_by_id(simulation, "census_family_money_income") + household = values_by_id(simulation, "census_household_money_income") + + failures = [] + for index, (name, entity, _, amount) in enumerate(cases): + expected = amount if name in counted else 0 + observed = [change(person, index, member) for member in range(size)] + if not np.allclose(observed, shares(entity, expected, size), atol=TOLERANCE): + failures.append((name, "person", observed, expected)) + for label, values in (("family", family), ("household", household)): + if abs(change(values, index) - expected) > TOLERANCE: + failures.append((name, label, change(values, index), expected)) + assert failures == [] + + +CBO_CASES = sorted( + set().union(*cbo_categories(YEAR).values()) | (set(CBO_EXCLUDED) & universe(YEAR)) +) + + +@settings(max_examples=5, deadline=None) +@given( + size=st.integers(min_value=1, max_value=3), + amounts=st.lists( + st.integers(min_value=1, max_value=100_000), + min_size=len(CBO_CASES), + max_size=len(CBO_CASES), + ), +) +@example(size=2, amounts=[1_000 + index for index in range(len(CBO_CASES))]) +def test_cbo_categories_move_dollar_for_dollar(size, amounts): + categories = cbo_categories(YEAR) + subtractions = cbo_lists(YEAR)["market_income_subtractions"] + cases = [ + (name, entity_of(name), state_of(name), amount) + for name, amount in zip(CBO_CASES, amounts) + ] + simulation = paired_households(cases, size) + # Federal taxes respond to income, so the next test checks them alone. + incomes = {key: value for key, value in categories.items() if key in CBO_INCOMES} + totals = {total: values_by_id(simulation, total) for total in incomes} + market = values_by_id(simulation, "household_market_income") + + failures = [] + for index, (name, _, _, amount) in enumerate(cases): + for total, members in incomes.items(): + expected = amount if name in members else 0 + if abs(change(totals[total], index) - expected) > TOLERANCE: + failures.append((name, total, change(totals[total], index), expected)) + if name in subtractions and abs(change(market, index) - amount) > TOLERANCE: + failures.append((name, "household_market_income", change(market, index))) + assert failures == [] + + +@settings(max_examples=3, deadline=None) +@given(amount=st.integers(min_value=1, max_value=20_000)) +@example(amount=4_321) +def test_cbo_federal_taxes_subtract_each_refundable_credit(amount): + credits = sorted(cbo_categories(YEAR)["cbo_household_federal_taxes"]) + cases = [(name, entity_of(name), "TX", amount) for name in credits] + # Earnings this high rule out every means-tested response. + simulation = paired_households(cases, size=2, earnings=500_000) + taxes = values_by_id(simulation, "cbo_household_federal_taxes") + for index, name in enumerate(credits): + assert change(taxes, index) == pytest.approx(-amount, abs=TOLERANCE), name + + +@pytest.mark.parametrize("name, alias", sorted(CBO_COUNTED_AS.items())) +def test_cbo_counts_a_source_through_the_variable_that_sums_it(name, alias): + simulation = paired_households( + [(name, entity_of(name), "TX", 1_000)], size=1, earnings=500_000 + ) + for total, members in cbo_categories(YEAR).items(): + if alias in members: + assert change(values_by_id(simulation, total), 0) == pytest.approx( + 1_000, abs=TOLERANCE + ) + + +def reference_census_money_income(simulation, year): + """census_money_income rebuilt from its sources with numpy. + + A person keeps their own sources and an equal share of each group's. + """ + total = np.zeros(simulation.persons.count) + for name in census_members(year): + entity = simulation.tax_benefit_system.variables[name].entity + values = simulation.calculate(name, year).astype(float) + if entity.is_person: + total += values + else: + population = simulation.populations[entity.key] + group = population.members_entity_id + size = np.bincount(group, minlength=population.count) + total += values[group] / size[group] + return total + + +@settings(max_examples=10, deadline=None) +@given( + family_of=st.lists(st.integers(min_value=0, max_value=2), min_size=1, max_size=5), + earnings=st.lists(st.integers(min_value=0, max_value=60_000), min_size=5), + veterans=st.lists(st.integers(min_value=0, max_value=20_000), min_size=5), + tanf=st.integers(min_value=0, max_value=10_000), + state=st.sampled_from(STATES), +) +@example( + family_of=[0, 0, 1], + earnings=[20_000, 0, 9_000], + veterans=[0, 0, 3_000], + tanf=1_200, + state="CA", +) +def test_totals_sum_members_and_match_an_independent_allocation( + family_of, earnings, veterans, tanf, state +): + year = YEAR + names = [f"person_{index}" for index in range(len(family_of))] + families = {} + for name, family in zip(names, family_of): + families.setdefault(f"family_{family}", []).append(name) + people = { + name: { + "age": {year: 25 + 10 * index}, + "employment_income": {year: earnings[index]}, + "veterans_benefits": {year: veterans[index]}, + } + for index, name in enumerate(names) + } + simulation = Simulation( + situation={ + "people": people, + "tax_units": { + key: {"members": members} for key, members in families.items() + }, + "spm_units": {"spm_unit": {"members": names, "tanf": {year: tanf}}}, + "families": { + key: {"members": members} for key, members in families.items() + }, + "marital_units": {name: {"members": [name]} for name in names}, + "households": { + "household": {"members": names, "state_name": {year: state}} + }, + } + ) + person = simulation.calculate("census_money_income", year).astype(float) + family = simulation.calculate("census_family_money_income", year).astype(float) + household = simulation.calculate("census_household_money_income", year) + assert household.sum() == pytest.approx(person.sum(), abs=TOLERANCE) + assert family.sum() == pytest.approx(person.sum(), abs=TOLERANCE) + assert np.allclose( + person, reference_census_money_income(simulation, year), atol=TOLERANCE + ) + + +@settings(max_examples=2, deadline=None) +@given( + earnings=st.lists( + st.integers(min_value=0, max_value=150_000), + min_size=len(STATES), + max_size=len(STATES), + ), + children=st.integers(min_value=0, max_value=2), +) +@example(earnings=[15_000] * len(STATES), children=2) +def test_census_money_income_reconciles_with_household_totals(earnings, children): + """A differential test of Census money income in every state at once. + + household_market_income and household_benefits sum their own lists. Take + away the terms money income leaves out and add the sources only money + income counts, and the result is Census household money income. + """ + year = YEAR + people, units = {}, {key: {} for key in ("tax_units", "spm_units", "families")} + marital_units, households = {}, {} + for state, amount in zip(STATES, earnings): + members = [f"{state}_{index}" for index in range(children + 1)] + for index, member in enumerate(members): + people[member] = {"age": {year: 40 if index == 0 else 6 + index}} + marital_units[member] = {"members": [member]} + people[members[0]]["employment_income"] = {year: amount} + # Rent makes rent-tested programs, such as New Jersey's relief, pay. + people[members[0]]["pre_subsidy_rent"] = {year: 12_000} + for key in units: + units[key][state] = {"members": members} + households[state] = {"members": members, "state_name": {year: state}} + simulation = Simulation( + situation={ + "people": people, + **units, + "marital_units": marital_units, + "households": households, + } + ) + + def household_total(name): + return simulation.calculate(name, year, map_to="household").astype(float) + + terms = block(MARKET_INCOME_SOURCES, year) | benefit_terms(year) + census = census_members(year) + reconciled = household_total("household_market_income") + household_total( + "household_benefits" + ) + for name in terms - census: + reconciled -= household_total(name) + for name in census - terms: + reconciled += household_total(name) + assert np.allclose( + household_total("census_household_money_income"), + reconciled, + atol=RECONCILIATION_TOLERANCE, + ) diff --git a/policyengine_us/variables/gov/hhs/medicare/costs/medicare_gross_cost.py b/policyengine_us/variables/gov/hhs/medicare/costs/medicare_gross_cost.py index 48533e92fb4..cc3594e4b0d 100644 --- a/policyengine_us/variables/gov/hhs/medicare/costs/medicare_gross_cost.py +++ b/policyengine_us/variables/gov/hhs/medicare/costs/medicare_gross_cost.py @@ -7,8 +7,9 @@ class medicare_gross_cost(Variable): label = "Gross Medicare cost" documentation = ( "Annual Medicare spending on behalf of the beneficiary before " - "subtracting beneficiary premiums. This matches the gross Medicare " - "benefit concept used in the CBO household-income framework." + "subtracting beneficiary premiums. CBO's household income framework " + "measures Medicare net of offsetting receipts, so its social " + "insurance benefits use medicare_cost instead." ) unit = USD definition_period = YEAR diff --git a/policyengine_us/variables/household/income/family/census_family_money_income.py b/policyengine_us/variables/household/income/family/census_family_money_income.py new file mode 100644 index 00000000000..655a12285de --- /dev/null +++ b/policyengine_us/variables/household/income/family/census_family_money_income.py @@ -0,0 +1,21 @@ +from policyengine_us.model_api import * + + +class census_family_money_income(Variable): + value_type = float + entity = Family + label = "Census family money income" + unit = USD + definition_period = YEAR + documentation = ( + "Census Bureau money income of the family: census_money_income summed " + "over every member. Census families are two or more people related by " + "birth, marriage or adoption who live together, and it tabulates " + "people living without relatives as unrelated individuals; a model " + "family can have one member." + ) + reference = ( + "https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=1", + "https://cps.ipums.org/cps-action/variables/FTOTVAL", + ) + adds = ["census_money_income"] diff --git a/policyengine_us/variables/household/income/household/cbo_household_federal_taxes.py b/policyengine_us/variables/household/income/household/cbo_household_federal_taxes.py index a45a2865f6a..56175a39667 100644 --- a/policyengine_us/variables/household/income/household/cbo_household_federal_taxes.py +++ b/policyengine_us/variables/household/income/household/cbo_household_federal_taxes.py @@ -7,10 +7,12 @@ class cbo_household_federal_taxes(Variable): label = "CBO household federal taxes" documentation = ( "Modeled federal taxes included in the PE-US CBO-style household " - "income framework: individual income taxes plus employee, self-" - "employment, employer-side federal payroll taxes, and any explicitly " - "provided corporate or excise tax incidence." + "income framework: individual income taxes net of refundable credits, " + "including the premium tax credit, plus employee, self-employment and " + "employer-side federal payroll taxes, and any explicitly provided " + "corporate or excise tax incidence." ) definition_period = YEAR unit = USD adds = "gov.household.cbo_federal_taxes" + subtracts = "gov.household.cbo_federal_tax_subtractions" diff --git a/policyengine_us/variables/household/income/household/cbo_household_market_income.py b/policyengine_us/variables/household/income/household/cbo_household_market_income.py index 927652c6792..3c1ca254bbe 100644 --- a/policyengine_us/variables/household/income/household/cbo_household_market_income.py +++ b/policyengine_us/variables/household/income/household/cbo_household_market_income.py @@ -8,10 +8,14 @@ class cbo_household_market_income(Variable): documentation = ( "Household market income under the Congressional Budget Office " "distributional framework: PolicyEngine household market income, less " - "government cash assistance and the Alaska Permanent Fund Dividend, " - "plus employer-sponsored insurance premiums and the modeled federal " - "employer payroll tax components. Corporate income tax incidence is " - "not yet allocated in the default PE-US benchmark." + "guaranteed income pilot payments and the Alaska Permanent Fund " + "Dividend, plus employer-sponsored insurance premiums, the modeled " + "federal employer payroll taxes, any corporate income tax incidence " + "input, railroad retirement and survivor benefits (income received in " + "retirement for past services), and child support, financial " + "assistance and educational assistance (other nongovernmental " + "sources). Corporate income tax incidence is not yet allocated in the " + "default PE-US benchmark." ) definition_period = YEAR unit = USD diff --git a/policyengine_us/variables/household/income/household/cbo_household_social_insurance_benefits.py b/policyengine_us/variables/household/income/household/cbo_household_social_insurance_benefits.py index b39c09e5db5..a96a2c60a08 100644 --- a/policyengine_us/variables/household/income/household/cbo_household_social_insurance_benefits.py +++ b/policyengine_us/variables/household/income/household/cbo_household_social_insurance_benefits.py @@ -7,8 +7,8 @@ class cbo_household_social_insurance_benefits(Variable): label = "CBO household social insurance benefits" documentation = ( "Social insurance benefits included in CBO household income: Social " - "Security, gross Medicare benefits, unemployment insurance, and " - "workers' compensation." + "Security, Medicare benefits net of premiums, unemployment insurance, " + "and workers' compensation." ) definition_period = YEAR unit = USD diff --git a/policyengine_us/variables/household/income/household/census_household_money_income.py b/policyengine_us/variables/household/income/household/census_household_money_income.py new file mode 100644 index 00000000000..7267c6378d5 --- /dev/null +++ b/policyengine_us/variables/household/income/household/census_household_money_income.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * + + +class census_household_money_income(Variable): + value_type = float + entity = Household + label = "Census household money income" + unit = USD + definition_period = YEAR + documentation = ( + "Census Bureau money income of the household: census_money_income " + "summed over every member. Census sums the money income of household " + "members 15 and older; this total also counts any amount the model " + "gives a younger member." + ) + reference = ( + "https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=1", + "https://cps.ipums.org/cps-action/variables/HHINCOME", + ) + adds = ["census_money_income"] diff --git a/policyengine_us/variables/household/income/person/general/census_money_income.py b/policyengine_us/variables/household/income/person/general/census_money_income.py new file mode 100644 index 00000000000..be36c1462f8 --- /dev/null +++ b/policyengine_us/variables/household/income/person/general/census_money_income.py @@ -0,0 +1,51 @@ +from policyengine_us.model_api import * + + +class census_money_income(Variable): + value_type = float + entity = Person + label = "Census money income" + unit = USD + definition_period = YEAR + documentation = ( + "Money income as the Census Bureau defines it for its official income " + "statistics: the sum of the 18 sources in Current Population Reports " + "P60-200, Appendix A, before taxes. Each source is a list in " + "gov.household.census_money_income_sources. It leaves out capital " + "gains, discharged debt, tax refunds and credits, lump-sum insurance " + "payments, and noncash benefits such as SNAP, school meals, housing " + "assistance, child care subsidies and health coverage. A benefit the " + "model computes for a group (an SPM unit, tax unit or household) is " + "split equally among the group's members, as household totals split " + "group amounts; the Current Population Survey records each payment " + "for the person who reports it. The variable covers people of every " + "age, while Census asks the income questions of people 15 and older " + "and records SSI paid for a younger child in a parent's or guardian's " + "SSI income. The model lacks three sources Appendix A lists: Black " + "lung payments, military family allotments and foreign government " + "pensions." + ) + reference = ( + # PDF pages 1-3 + "https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=1", + "https://cps.ipums.org/cps-action/variables/SSIKID", + ) + + def formula(person, period, parameters): + sources = parameters(period).gov.household.census_money_income_sources + person_level = [] + group_level = [] + for item in sources: + for name in sources[item]: + entity = person.entity.get_variable(name).entity + if entity.is_person: + person_level.append(name) + else: + group_level.append((name, entity.key)) + total = add(person, period, person_level) + for name, entity_key in group_level: + group = person.simulation.populations[entity_key] + total = total + group.project(group(name, period)) / group.project( + group.nb_persons() + ) + return total From bebdb12cc03149abd63575a19ad21a2ea1a44feb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 8 Nov 2026 16:19:38 -0500 Subject: [PATCH 2/7] Count military survivor annuities as Census survivor benefits; keep educational assistance out of CBO income Appendix A of P60-200 lists military survivor pensions among survivor benefits and military retirement among pension or retirement income, so the Census lists now take military_retirement_income apart: military_retirement_pay under pension or retirement income and military_retirement_pay_survivors under survivor benefits. The parity tests count the aggregate through its parts and fail if it gains a part no list holds. educational_assistance holds Pell Grants and other government aid beside private scholarships (IPUMS INCEDUC). CBO's market income takes only "other nongovernmental sources of income", and the model can't split the variable, so it stays out of the CBO measures, as on main, recorded in CBO_EXCLUDED with the reason. Co-Authored-By: Claude Opus 5.5 --- changelog.d/census-money-income.fixed.md | 2 +- .../cbo_market_income_additions.yaml | 5 +- .../disability_benefits.yaml | 4 +- .../pension_or_retirement_income.yaml | 7 ++- .../survivor_benefits.yaml | 3 ++ .../household/income/census_money_income.yaml | 13 +++-- .../household/cbo_household_income.yaml | 14 ++--- .../tests/test_income_concept_parity.py | 53 ++++++++++++++++--- .../household/cbo_household_market_income.py | 7 ++- 9 files changed, 79 insertions(+), 29 deletions(-) diff --git a/changelog.d/census-money-income.fixed.md b/changelog.d/census-money-income.fixed.md index ee8e27f89c2..c0425efb10f 100644 --- a/changelog.d/census-money-income.fixed.md +++ b/changelog.d/census-money-income.fixed.md @@ -1 +1 @@ -Align the CBO household income measures with CBO's published definitions: count Medicare net of premiums, subtract the premium tax credit from federal taxes, count corporate income tax in market income as well as in taxes, add railroad retirement, survivor benefits, child support, financial assistance and educational assistance to market income, and count WIC, the Medicare Savings Programs, state SSI supplements and state cash assistance as means-tested transfers. +Align the CBO household income measures with CBO's published definitions: count Medicare net of premiums, subtract the premium tax credit from federal taxes, count corporate income tax in market income as well as in taxes, add railroad retirement, survivor benefits, child support and financial assistance to market income, and count WIC, the Medicare Savings Programs, state SSI supplements and state cash assistance as means-tested transfers. diff --git a/policyengine_us/parameters/gov/household/cbo_market_income_additions.yaml b/policyengine_us/parameters/gov/household/cbo_market_income_additions.yaml index 88b1b9c15b7..c7ca2c354aa 100644 --- a/policyengine_us/parameters/gov/household/cbo_market_income_additions.yaml +++ b/policyengine_us/parameters/gov/household/cbo_market_income_additions.yaml @@ -18,11 +18,10 @@ values: - survivor_benefits # Other income sources: other nongovernmental sources of income, which # CBO draws from the Current Population Survey. educational_assistance - # also holds government grants such as Pell Grants, which CBO's lists do - # not name. + # stays out: it holds Pell Grants and other government aid beside + # private scholarships, and the model does not split them. - child_support_received - financial_assistance - - educational_assistance metadata: unit: list diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/disability_benefits.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/disability_benefits.yaml index c5443470af4..52d7c033c92 100644 --- a/policyengine_us/parameters/gov/household/census_money_income_sources/disability_benefits.yaml +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/disability_benefits.yaml @@ -2,7 +2,9 @@ description: The Census Bureau counts these sources as disability benefits, sour # pfml_benefits holds state paid family and medical leave benefits; # Appendix A lists state temporary sickness payments among the sources # of disability benefits. The model has no variable for Black lung -# payments, another source Appendix A lists here. +# payments, another source Appendix A lists here. Railroad Retirement Board +# disability annuities sit in railroad_benefits, under pension or +# retirement income. values: 2022-01-01: - disability_benefits diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/pension_or_retirement_income.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/pension_or_retirement_income.yaml index a90029f9bac..817043932db 100644 --- a/policyengine_us/parameters/gov/household/census_money_income_sources/pension_or_retirement_income.yaml +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/pension_or_retirement_income.yaml @@ -3,10 +3,15 @@ values: 2022-01-01: # Pensions, annuities and paid-up insurance policies. - pension_income - - military_retirement_income + # Military retirement pay. Survivor annuities under the military + # retirement plans count as survivor benefits. + - military_retirement_pay # IRA, Keogh and 401(k) payments. - retirement_distributions # Railroad retirement, one of the eight sources Appendix A lists here. + # railroad_benefits also holds the Railroad Retirement Board's disability + # and survivor annuities, which Appendix A counts as disability and + # survivor benefits; the model does not split that variable. - railroad_benefits metadata: unit: list diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/survivor_benefits.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/survivor_benefits.yaml index 17fd6afedc3..929310ffad6 100644 --- a/policyengine_us/parameters/gov/household/census_money_income_sources/survivor_benefits.yaml +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/survivor_benefits.yaml @@ -5,6 +5,9 @@ description: The Census Bureau counts these sources as survivor benefits, source values: 2022-01-01: - survivor_benefits + # Survivor annuities under the uniformed services retirement plans: + # Appendix A lists military survivor pensions here. + - military_retirement_pay_survivors metadata: unit: list period: year diff --git a/policyengine_us/tests/policy/baseline/household/income/census_money_income.yaml b/policyengine_us/tests/policy/baseline/household/income/census_money_income.yaml index e5966a20bf1..1325e93bcf4 100644 --- a/policyengine_us/tests/policy/baseline/household/income/census_money_income.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/census_money_income.yaml @@ -22,12 +22,15 @@ social_security: 6_000 # 7. Veterans' payments. veterans_benefits: 3_000 - # 8. Survivor benefits. + # 8. Survivor benefits, military survivor annuities included. survivor_benefits: 700 + military_retirement_pay_survivors: 300 # 9. Disability benefits. disability_benefits: 400 - # 10. Pension or retirement income, railroad retirement included. + # 10. Pension or retirement income, military and railroad retirement + # included. pension_income: 2_500 + military_retirement_pay: 800 railroad_benefits: 1_100 # 11. Interest, 12. dividends, 13. rents, royalties, estates and trusts. interest_income: 300 @@ -42,9 +45,9 @@ financial_assistance: 250 gi_cash_assistance: 150 output: - census_money_income: 56_700 - census_family_money_income: 56_700 - census_household_money_income: 56_700 + census_money_income: 57_800 + census_family_money_income: 57_800 + census_household_money_income: 57_800 - name: Receipts Appendix A does not count leave money income unchanged period: 2026 diff --git a/policyengine_us/tests/policy/baseline/household/income/household/cbo_household_income.yaml b/policyengine_us/tests/policy/baseline/household/income/household/cbo_household_income.yaml index cfecfb1cc45..0f39314c7a7 100644 --- a/policyengine_us/tests/policy/baseline/household/income/household/cbo_household_income.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/household/cbo_household_income.yaml @@ -112,7 +112,7 @@ # $37,437 + $11,000 - $2,452. cbo_household_income_after_transfers_and_taxes: 45_985 -- name: CBO market income adds retirement income and private transfers but not veterans' benefits +- name: CBO market income adds retirement income and private transfers but not veterans' benefits or educational assistance period: 2026 input: state_code: TX @@ -127,15 +127,17 @@ # Other nongovernmental sources. child_support_received: 800 financial_assistance: 250 - educational_assistance: 900 cbo_corporate_income_tax: 100 + # Educational assistance mixes Pell Grants and other government aid with + # private scholarships, and CBO's other income covers only + # nongovernmental sources. + educational_assistance: 900 # CBO's income categories have not named veterans' benefits since its # report on 2014, published in March 2018. veterans_benefits: 3_000 output: - # $100,000 + $6,200 + $1,450 + $42 + $1,200 + $700 + $800 + $250 + $900 - # + $100. - cbo_household_market_income: 111_642 + # $100,000 + $6,200 + $1,450 + $42 + $1,200 + $700 + $800 + $250 + $100. + cbo_household_market_income: 110_742 cbo_household_social_insurance_benefits: 0 cbo_household_means_tested_transfers: 0 - cbo_household_income_before_transfers_and_taxes: 111_642 + cbo_household_income_before_transfers_and_taxes: 110_742 diff --git a/policyengine_us/tests/test_income_concept_parity.py b/policyengine_us/tests/test_income_concept_parity.py index 59b3089a722..bd6f199a075 100644 --- a/policyengine_us/tests/test_income_concept_parity.py +++ b/policyengine_us/tests/test_income_concept_parity.py @@ -12,8 +12,8 @@ 1. Every source that any of these lists counts appears in each concept, or in that concept's documented exclusions below, with the reason. 2. No source counts twice: no variable sits under two Census sources, the CBO - categories are disjoint, and CBO adds nothing household market income - already holds. + categories are disjoint, CBO adds nothing household market income already + holds, and neither concept counts a variable beside another that sums it. 3. A dollar of a listed source adds a dollar to its concept. A person's Census money income gains the dollar, or an equal share of a dollar paid to a group, and the family and household totals gain the dollar. The CBO @@ -164,6 +164,10 @@ "pfml_benefits": "state paid leave insurance: CBO's social insurance is " "Social Security, Medicare, unemployment insurance and workers' " "compensation", + "educational_assistance": "Pell Grants and other government aid beside " + "private scholarships (IPUMS INCEDUC), which the model does not split: " + "CBO's other income covers only nongovernmental sources, and its transfer " + "lists name no education grants", # cbo_market_income_subtractions removes these two from household market # income. "gi_cash_assistance": "guaranteed income pilot payments, which CBO's lists " @@ -215,8 +219,23 @@ "cbo_household_means_tested_transfers", ) +# Sources Census counts through the variables they add, because Appendix A +# puts the parts under different sources: military retirement pay under +# pension or retirement income, military survivor annuities under survivor +# benefits. +CENSUS_COUNTED_AS = { + "military_retirement_income": ( + "military_retirement_pay", + "military_retirement_pay_survivors", + ), +} + # Sources CBO counts through another listed variable that sums them. -CBO_COUNTED_AS = {"medicaid_cost": "medicaid"} +CBO_COUNTED_AS = { + "medicaid_cost": "medicaid", + "military_retirement_pay": "military_retirement_income", + "military_retirement_pay_survivors": "military_retirement_income", +} # Sources that can be losses. LOSSES = { @@ -343,12 +362,22 @@ def test_census_lists_follow_appendix_a(): def test_concept_lists_name_defined_variables(variables): names = set(CENSUS_EXCLUDED) | set(CBO_EXCLUDED) + names |= set(CENSUS_COUNTED_AS) | set().union(*CENSUS_COUNTED_AS.values()) names |= set(CBO_COUNTED_AS) | set(CBO_COUNTED_AS.values()) for year in YEARS: names |= census_members(year) | set().union(*cbo_lists(year).values()) assert names - set(variables) == set() +def test_aliases_sum_exactly_their_parts(variables): + # A part added to one of these variables later would otherwise reach + # neither concept. + for name, parts in CENSUS_COUNTED_AS.items(): + assert set(variables[name].adds) == set(parts), name + for name, alias in CBO_COUNTED_AS.items(): + assert name in variables[alias].adds, f"{alias} does not add {name}" + + @pytest.mark.parametrize("year", YEARS) def test_no_source_counts_twice(year): seen = {} @@ -356,6 +385,8 @@ def test_no_source_counts_twice(year): for name in members: assert name not in seen, f"{name} is under {seen[name]} and {source}" seen[name] = source + for name, parts in CENSUS_COUNTED_AS.items(): + assert name not in seen, f"{name} counts beside {parts}, which it sums" lists = cbo_lists(year) market = block(MARKET_INCOME_SOURCES, year) @@ -381,6 +412,9 @@ def test_no_source_counts_twice(year): @pytest.mark.parametrize("year", YEARS) def test_census_classifies_every_source(year): counted = census_members(year) + counted |= { + name for name, parts in CENSUS_COUNTED_AS.items() if set(parts) <= counted + } excluded = set(CENSUS_EXCLUDED) assert counted & excluded == set(), "listed and excluded" unclassified = universe(year) - counted - excluded @@ -411,6 +445,7 @@ def test_cbo_classifies_every_source(year): def test_exclusions_are_current(): every_year = set().union(*(universe(year) for year in YEARS)) assert set(CENSUS_EXCLUDED) - every_year == set(), "stale Census entries" + assert set(CENSUS_COUNTED_AS) - every_year == set(), "stale Census aliases" assert set(CBO_EXCLUDED) - every_year == set(), "stale CBO entries" assert set(CBO_COUNTED_AS) - every_year == set(), "stale CBO aliases" @@ -575,11 +610,13 @@ def test_cbo_counts_a_source_through_the_variable_that_sums_it(name, alias): simulation = paired_households( [(name, entity_of(name), "TX", 1_000)], size=1, earnings=500_000 ) - for total, members in cbo_categories(YEAR).items(): - if alias in members: - assert change(values_by_id(simulation, total), 0) == pytest.approx( - 1_000, abs=TOLERANCE - ) + categories = cbo_categories(YEAR) + assert any(alias in categories[total] for total in CBO_INCOMES), alias + for total in CBO_INCOMES: + expected = 1_000 if alias in categories[total] else 0 + assert change(values_by_id(simulation, total), 0) == pytest.approx( + expected, abs=TOLERANCE + ), total def reference_census_money_income(simulation, year): diff --git a/policyengine_us/variables/household/income/household/cbo_household_market_income.py b/policyengine_us/variables/household/income/household/cbo_household_market_income.py index 3c1ca254bbe..3901705a04c 100644 --- a/policyengine_us/variables/household/income/household/cbo_household_market_income.py +++ b/policyengine_us/variables/household/income/household/cbo_household_market_income.py @@ -12,10 +12,9 @@ class cbo_household_market_income(Variable): "Dividend, plus employer-sponsored insurance premiums, the modeled " "federal employer payroll taxes, any corporate income tax incidence " "input, railroad retirement and survivor benefits (income received in " - "retirement for past services), and child support, financial " - "assistance and educational assistance (other nongovernmental " - "sources). Corporate income tax incidence is not yet allocated in the " - "default PE-US benchmark." + "retirement for past services), and child support and financial " + "assistance (other nongovernmental sources). Corporate income tax " + "incidence is not yet allocated in the default PE-US benchmark." ) definition_period = YEAR unit = USD From 2b59cbd02d70f3aadc2ab8a3b97925edbcac461c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 9 Nov 2026 04:40:05 -0500 Subject: [PATCH 3/7] Let the military retirement guard see lists split by Census source test_military_retirement_readers.py fails any non-tax list that names military_retirement_pay without military_retirement_pay_survivors, so a reader can't miss survivor annuities. Census money income counts both, but Appendix A puts them under two sources, so they sit in two list files and the file-level check can't see that. SPLIT_YAML_FILES now names the pension list's partner, and the guard passes the file only if the partner lists the survivor annuities. A new test fails if an entry stops being needed. Co-Authored-By: Claude Opus 5.5 --- .../test_military_retirement_readers.py | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/policyengine_us/tests/code_health/test_military_retirement_readers.py b/policyengine_us/tests/code_health/test_military_retirement_readers.py index d341088ddc2..8b17b8631d2 100644 --- a/policyengine_us/tests/code_health/test_military_retirement_readers.py +++ b/policyengine_us/tests/code_health/test_military_retirement_readers.py @@ -13,7 +13,8 @@ Checked outside the tax side: - Parameter YAML: a block list entry `- military_retirement_pay` or a - flow-style list containing it, unless the same file also lists + flow-style list containing it, unless the same file, or the partner list + SPLIT_YAML_FILES names for it, also lists `military_retirement_pay_survivors`. Comments are ignored, so commented-out entries and comments that name the input do not count. - Python in the package (tests excluded): a string constant exactly equal to @@ -46,6 +47,16 @@ ), } +# A list that names the raw input because a partner list in the same concept +# holds the survivor annuities. Census money income sums 18 lists, and +# Appendix A counts a retiree's pay under pension or retirement income and a +# survivor's annuity under survivor benefits. +SPLIT_YAML_FILES = { + "gov/household/census_money_income_sources/pension_or_retirement_income.yaml": ( + "gov/household/census_money_income_sources/survivor_benefits.yaml" + ), +} + def list_entry_pattern(name: str) -> re.Pattern: """A block list entry (`- name`) or a flow list entry (`[a, name]`).""" @@ -153,11 +164,16 @@ def test_detectors_flag_raw_readers_and_skip_everything_else(): def test_benefit_source_lists_do_not_read_raw_military_retirement_pay(): violations = [] for path in sorted(PARAMETERS.rglob("*.yaml")): - if is_tax_side(path.relative_to(PARAMETERS)): + relative = path.relative_to(PARAMETERS) + if is_tax_side(relative): continue text = path.read_text(encoding="utf-8") if RAW not in text: continue + # The partner must itself list the annuities for this list to pass. + partner = SPLIT_YAML_FILES.get(relative.as_posix()) + if partner is not None: + text += "\n" + (PARAMETERS / partner).read_text(encoding="utf-8") for line in raw_yaml_lines(text): violations.append(f"{path.relative_to(PACKAGE).as_posix()}:{line}") assert not violations, ( @@ -187,6 +203,19 @@ def test_benefit_python_does_not_read_raw_military_retirement_pay(): ) +def test_split_yaml_files_still_need_their_partner(): + needless = [ + relative + for relative in SPLIT_YAML_FILES + if not (PARAMETERS / relative).exists() + or not raw_yaml_lines((PARAMETERS / relative).read_text(encoding="utf-8")) + ] + assert not needless, ( + "These SPLIT_YAML_FILES entries no longer list the raw input without " + "the survivor annuities; remove them:\n" + "\n".join(needless) + ) + + def test_allowed_python_files_still_read_the_raw_input(): stale = [ relative From 79f39e61520e4ca972f3dabcc2e889a271ca1cf3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 9 Nov 2026 06:10:57 -0500 Subject: [PATCH 4/7] Count retirement account distributions from age 58, as the survey does The Current Population Survey has left withdrawals by people under 58 out of total income since its 2019 redesign, which collects 2018 income. Census money income now counts distributions at any age through 2017 and from age 58 from 2018, through census_retirement_distributions and its minimum age parameter. This also answers the two reviews of 2b59cbd02d. Each Census list holds one block from 0000-01-01, and the CBO lists drop their year splits, because the system backdates household_state_benefits to 2015. The parity tests now simulate each year from 2022 to 2026, follow `adds` recursively for double counting, reject repeated list entries and pin each alias's parts. New cases split group benefits by the group that pays them and reconcile aged and disabled households in every state. Co-Authored-By: Claude Opus 5.5 --- changelog.d/census-money-income.added.md | 2 +- changelog.d/census-money-income.fixed.md | 2 +- .../household/cbo_means_tested_transfers.yaml | 26 - .../cbo_social_insurance_benefits.yaml | 2 +- .../census_money_income_sources/alimony.yaml | 2 +- .../child_support.yaml | 2 +- .../disability_benefits.yaml | 2 +- .../dividends.yaml | 2 +- .../census_money_income_sources/earnings.yaml | 2 +- .../educational_assistance.yaml | 2 +- .../financial_assistance.yaml | 2 +- .../census_money_income_sources/interest.yaml | 2 +- .../other_income.yaml | 15 +- .../pension_or_retirement_income.yaml | 8 +- .../public_assistance.yaml | 6 +- .../rents_royalties_estates_and_trusts.yaml | 2 +- .../social_security.yaml | 2 +- .../supplemental_security_income.yaml | 5 +- .../survivor_benefits.yaml | 2 +- .../unemployment_compensation.yaml | 2 +- .../veterans_payments.yaml | 2 +- .../workers_compensation.yaml | 2 +- ..._retirement_distributions_minimum_age.yaml | 17 + .../test_military_retirement_readers.py | 4 +- .../household/income/census_money_income.yaml | 122 ++++- .../tests/test_income_concept_parity.py | 446 +++++++++++++----- ...cbo_household_social_insurance_benefits.py | 4 +- .../person/general/census_money_income.py | 16 +- .../census_retirement_distributions.py | 28 ++ 29 files changed, 530 insertions(+), 201 deletions(-) create mode 100644 policyengine_us/parameters/gov/household/census_retirement_distributions_minimum_age.yaml create mode 100644 policyengine_us/variables/household/income/person/general/census_retirement_distributions.py diff --git a/changelog.d/census-money-income.added.md b/changelog.d/census-money-income.added.md index 5f6cdd36517..7656c26b193 100644 --- a/changelog.d/census-money-income.added.md +++ b/changelog.d/census-money-income.added.md @@ -1 +1 @@ -Add Census Bureau money income for people, families and households (census_money_income, census_family_money_income and census_household_money_income), built from one list for each of the 18 sources in Current Population Reports P60-200, Appendix A. +Add Census Bureau money income for people, families and households (census_money_income, census_family_money_income and census_household_money_income), built from one list for each of the 18 sources in Current Population Reports P60-200, Appendix A, and counting retirement account distributions from age 58, as the Current Population Survey has since its 2019 redesign. diff --git a/changelog.d/census-money-income.fixed.md b/changelog.d/census-money-income.fixed.md index c0425efb10f..33d3f35c93d 100644 --- a/changelog.d/census-money-income.fixed.md +++ b/changelog.d/census-money-income.fixed.md @@ -1 +1 @@ -Align the CBO household income measures with CBO's published definitions: count Medicare net of premiums, subtract the premium tax credit from federal taxes, count corporate income tax in market income as well as in taxes, add railroad retirement, survivor benefits, child support and financial assistance to market income, and count WIC, the Medicare Savings Programs, state SSI supplements and state cash assistance as means-tested transfers. +Align the CBO household income measures with CBO's published definitions: count Medicare net of Part A and Part B premiums, subtract the premium tax credit from federal taxes, count corporate income tax in market income as well as in taxes, add railroad retirement, survivor benefits, child support and financial assistance to market income, and count WIC, the Medicare Savings Programs, state SSI supplements and state cash assistance as means-tested transfers. diff --git a/policyengine_us/parameters/gov/household/cbo_means_tested_transfers.yaml b/policyengine_us/parameters/gov/household/cbo_means_tested_transfers.yaml index 59235051575..2ea89b516d7 100644 --- a/policyengine_us/parameters/gov/household/cbo_means_tested_transfers.yaml +++ b/policyengine_us/parameters/gov/household/cbo_means_tested_transfers.yaml @@ -13,18 +13,6 @@ values: - reduced_price_school_meals - spm_unit_energy_subsidy - general_assistance - # WIC and the Medicare Savings Programs join when household_benefits begins. - 2022-01-01: - - medicaid - - chip - - snap - - housing_assistance - - ssi - - tanf - - free_school_meals - - reduced_price_school_meals - - spm_unit_energy_subsidy - - general_assistance # CBO names child nutrition programs without listing them. WIC is section # 17 of the Child Nutrition Act of 1966 (42 U.S.C. 1786, in the Child # Nutrition chapter of Title 42), so this list counts it there. @@ -32,20 +20,6 @@ values: # Medicaid pays Medicare premiums and cost sharing for Medicare # Savings Program enrollees (42 U.S.C. 1396a(a)(10)(E)). - msp_cost - # State programs join in 2023, when household_state_benefits begins. - 2023-01-01: - - medicaid - - chip - - snap - - housing_assistance - - ssi - - tanf - - free_school_meals - - reduced_price_school_meals - - spm_unit_energy_subsidy - - general_assistance - - wic - - msp_cost # State supplements to Supplemental Security Income. - al_ssp - ak_ssp diff --git a/policyengine_us/parameters/gov/household/cbo_social_insurance_benefits.yaml b/policyengine_us/parameters/gov/household/cbo_social_insurance_benefits.yaml index c1583cdf3d6..19d9a9b6302 100644 --- a/policyengine_us/parameters/gov/household/cbo_social_insurance_benefits.yaml +++ b/policyengine_us/parameters/gov/household/cbo_social_insurance_benefits.yaml @@ -4,7 +4,7 @@ values: - social_security # CBO measures Medicare as the average cost to the government net of # offsetting receipts. medicare_cost subtracts the Part A and Part B - # premiums from the average cost per enrollee. + # premiums from the average cost per enrollee; Part D premiums stay in. - medicare_cost - total_unemployment_compensation - workers_compensation diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/alimony.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/alimony.yaml index 8aff3670246..0e9f928f54a 100644 --- a/policyengine_us/parameters/gov/household/census_money_income_sources/alimony.yaml +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/alimony.yaml @@ -1,6 +1,6 @@ description: The Census Bureau counts these sources as alimony, source 15 of 18 in money income. values: - 2022-01-01: + 0000-01-01: - alimony_income metadata: unit: list diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/child_support.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/child_support.yaml index 344817bcb68..c6d887e1d5c 100644 --- a/policyengine_us/parameters/gov/household/census_money_income_sources/child_support.yaml +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/child_support.yaml @@ -1,6 +1,6 @@ description: The Census Bureau counts these sources as child support, source 16 of 18 in money income. values: - 2022-01-01: + 0000-01-01: - child_support_received metadata: unit: list diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/disability_benefits.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/disability_benefits.yaml index 52d7c033c92..ec868d3c963 100644 --- a/policyengine_us/parameters/gov/household/census_money_income_sources/disability_benefits.yaml +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/disability_benefits.yaml @@ -6,7 +6,7 @@ description: The Census Bureau counts these sources as disability benefits, sour # disability annuities sit in railroad_benefits, under pension or # retirement income. values: - 2022-01-01: + 0000-01-01: - disability_benefits - pfml_benefits metadata: diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/dividends.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/dividends.yaml index 40b5b322a60..d4537b78709 100644 --- a/policyengine_us/parameters/gov/household/census_money_income_sources/dividends.yaml +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/dividends.yaml @@ -1,7 +1,7 @@ description: The Census Bureau counts these sources as dividends, source 12 of 18 in money income. # Appendix A leaves out capital gains from the sale of stock. values: - 2022-01-01: + 0000-01-01: - dividend_income metadata: unit: list diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/earnings.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/earnings.yaml index af5a4807877..469418ac19b 100644 --- a/policyengine_us/parameters/gov/household/census_money_income_sources/earnings.yaml +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/earnings.yaml @@ -1,6 +1,6 @@ description: The Census Bureau counts these sources as earnings, source 1 of 18 in money income. values: - 2022-01-01: + 0000-01-01: # Money wage or salary income, before deductions such as pension # contributions. - employment_income diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/educational_assistance.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/educational_assistance.yaml index ca3b9245b96..d9317282d44 100644 --- a/policyengine_us/parameters/gov/household/census_money_income_sources/educational_assistance.yaml +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/educational_assistance.yaml @@ -1,6 +1,6 @@ description: The Census Bureau counts these sources as educational assistance, source 14 of 18 in money income. values: - 2022-01-01: + 0000-01-01: - educational_assistance metadata: unit: list diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/financial_assistance.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/financial_assistance.yaml index e14bfb664db..a6075389734 100644 --- a/policyengine_us/parameters/gov/household/census_money_income_sources/financial_assistance.yaml +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/financial_assistance.yaml @@ -1,6 +1,6 @@ description: The Census Bureau counts these sources as financial assistance from outside of the household, source 17 of 18 in money income. values: - 2022-01-01: + 0000-01-01: - financial_assistance metadata: unit: list diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/interest.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/interest.yaml index 54c99a2c902..4441887e6c8 100644 --- a/policyengine_us/parameters/gov/household/census_money_income_sources/interest.yaml +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/interest.yaml @@ -1,6 +1,6 @@ description: The Census Bureau counts these sources as interest, source 11 of 18 in money income. values: - 2022-01-01: + 0000-01-01: - interest_income metadata: unit: list diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/other_income.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/other_income.yaml index 629b26ccea8..b44e125d028 100644 --- a/policyengine_us/parameters/gov/household/census_money_income_sources/other_income.yaml +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/other_income.yaml @@ -2,19 +2,10 @@ description: The Census Bureau counts these sources as other income, source 18 o # Appendix A's other income covers payments received regularly that no # other source covers, such as state foster child payments, military # family allotments and foreign government pensions. The model has no -# variables for the last two. +# variables for the last two, and of foster payments only the two Los +# Angeles County payments below. values: - 2022-01-01: - # Guaranteed income pilot payments. - - gi_cash_assistance - # Alaska's annual dividend and the energy relief paid with it. - - ak_permanent_fund_dividend - - ak_energy_relief - # Contributed cash payments that are zero unless a reform turns them on. - - basic_income - - trump_dividend - # State programs join in 2023, when household_state_benefits begins. - 2023-01-01: + 0000-01-01: # Guaranteed income pilot payments. - gi_cash_assistance # Alaska's annual dividend and the energy relief paid with it. diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/pension_or_retirement_income.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/pension_or_retirement_income.yaml index 817043932db..c477aa5159a 100644 --- a/policyengine_us/parameters/gov/household/census_money_income_sources/pension_or_retirement_income.yaml +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/pension_or_retirement_income.yaml @@ -1,13 +1,13 @@ description: The Census Bureau counts these sources as pension or retirement income, source 10 of 18 in money income. values: - 2022-01-01: + 0000-01-01: # Pensions, annuities and paid-up insurance policies. - pension_income # Military retirement pay. Survivor annuities under the military # retirement plans count as survivor benefits. - military_retirement_pay - # IRA, Keogh and 401(k) payments. - - retirement_distributions + # IRA, Keogh and 401(k) payments, at the ages Census counts them. + - census_retirement_distributions # Railroad retirement, one of the eight sources Appendix A lists here. # railroad_benefits also holds the Railroad Retirement Board's disability # and survivor annuities, which Appendix A counts as disability and @@ -20,3 +20,5 @@ metadata: reference: - title: "Census Bureau, Current Population Reports P60-200, Appendix A, source 10 (pension or retirement income) #page 1,3" href: https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=1 + - title: "Census Bureau, 2026 Current Population Survey Annual Social and Economic Supplement data dictionary, retirement distributions by age (DST_VAL1, DST_VAL1_YNG, DBTN_VAL) #page 57-58" + href: https://www2.census.gov/programs-surveys/cps/datasets/2026/march/asec2026_ddl_pub_full.pdf#page=57 diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/public_assistance.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/public_assistance.yaml index 80fb2a8b348..93f9b864b0f 100644 --- a/policyengine_us/parameters/gov/household/census_money_income_sources/public_assistance.yaml +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/public_assistance.yaml @@ -1,10 +1,6 @@ description: The Census Bureau counts these sources as public assistance, source 6 of 18 in money income. values: - 2022-01-01: - - tanf - - general_assistance - # State programs join in 2023, when household_state_benefits begins. - 2023-01-01: + 0000-01-01: - tanf - general_assistance # Colorado Old Age Pension covers people 60 and older, a wider group diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/rents_royalties_estates_and_trusts.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/rents_royalties_estates_and_trusts.yaml index 1f9322696a9..f7fa9f9fc72 100644 --- a/policyengine_us/parameters/gov/household/census_money_income_sources/rents_royalties_estates_and_trusts.yaml +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/rents_royalties_estates_and_trusts.yaml @@ -1,6 +1,6 @@ description: The Census Bureau counts these sources as rents, royalties, and estates and trusts, source 13 of 18 in money income. values: - 2022-01-01: + 0000-01-01: # rental_income includes royalties. - rental_income - estate_income diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/social_security.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/social_security.yaml index 313c715b871..2f3f537ea7d 100644 --- a/policyengine_us/parameters/gov/household/census_money_income_sources/social_security.yaml +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/social_security.yaml @@ -1,7 +1,7 @@ description: The Census Bureau counts these sources as Social Security, source 4 of 18 in money income. # Appendix A counts benefits before the deduction for Medicare premiums. values: - 2022-01-01: + 0000-01-01: - social_security metadata: unit: list diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/supplemental_security_income.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/supplemental_security_income.yaml index f8c7c080675..1b06f3cc1da 100644 --- a/policyengine_us/parameters/gov/household/census_money_income_sources/supplemental_security_income.yaml +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/supplemental_security_income.yaml @@ -3,10 +3,7 @@ description: The Census Bureau counts these sources as Supplemental Security Inc # people 65 or older, blind or disabled, so state supplements to SSI # count here alongside the federal payment. values: - 2022-01-01: - - ssi - # State programs join in 2023, when household_state_benefits begins. - 2023-01-01: + 0000-01-01: - ssi # Alabama - al_ssp diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/survivor_benefits.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/survivor_benefits.yaml index 929310ffad6..e4757364be3 100644 --- a/policyengine_us/parameters/gov/household/census_money_income_sources/survivor_benefits.yaml +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/survivor_benefits.yaml @@ -3,7 +3,7 @@ description: The Census Bureau counts these sources as survivor benefits, source # under pension or retirement income, because the model does not split # that variable by type of annuity. values: - 2022-01-01: + 0000-01-01: - survivor_benefits # Survivor annuities under the uniformed services retirement plans: # Appendix A lists military survivor pensions here. diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/unemployment_compensation.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/unemployment_compensation.yaml index fea93ea9c6f..7c645e4c28f 100644 --- a/policyengine_us/parameters/gov/household/census_money_income_sources/unemployment_compensation.yaml +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/unemployment_compensation.yaml @@ -1,6 +1,6 @@ description: The Census Bureau counts these sources as unemployment compensation, source 2 of 18 in money income. values: - 2022-01-01: + 0000-01-01: - total_unemployment_compensation # Appendix A counts strike benefits from union funds here. - strike_benefits diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/veterans_payments.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/veterans_payments.yaml index 4610dc98094..0d5c04d289e 100644 --- a/policyengine_us/parameters/gov/household/census_money_income_sources/veterans_payments.yaml +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/veterans_payments.yaml @@ -1,6 +1,6 @@ description: The Census Bureau counts these sources as veterans' payments, source 7 of 18 in money income. values: - 2022-01-01: + 0000-01-01: - veterans_benefits metadata: unit: list diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/workers_compensation.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/workers_compensation.yaml index 2cb7af5b55d..88a2f2dba0d 100644 --- a/policyengine_us/parameters/gov/household/census_money_income_sources/workers_compensation.yaml +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/workers_compensation.yaml @@ -1,6 +1,6 @@ description: The Census Bureau counts these sources as workers' compensation, source 3 of 18 in money income. values: - 2022-01-01: + 0000-01-01: - workers_compensation metadata: unit: list diff --git a/policyengine_us/parameters/gov/household/census_retirement_distributions_minimum_age.yaml b/policyengine_us/parameters/gov/household/census_retirement_distributions_minimum_age.yaml new file mode 100644 index 00000000000..ca18f3ecd21 --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_retirement_distributions_minimum_age.yaml @@ -0,0 +1,17 @@ +description: The Census Bureau counts a person's distributions from retirement accounts as money income from this age. +values: + # Appendix A counts IRA, Keogh and 401(k) payments at any age. + 0000-01-01: 0 + # The survey's 2019 redesign collects 2018 income. It asks people under 58 + # about withdrawals separately (DST_VAL1_YNG, DST_VAL2_YNG) and leaves + # those amounts out of total income (PTOTVAL). + 2018-01-01: 58 +metadata: + unit: year + period: year + label: Census money income minimum age for retirement account distributions + reference: + - title: "Census Bureau, 2026 Current Population Survey Annual Social and Economic Supplement data dictionary, retirement distributions by age (DST_VAL1, DST_VAL1_YNG, DBTN_VAL) #page 57-58" + href: https://www2.census.gov/programs-surveys/cps/datasets/2026/march/asec2026_ddl_pub_full.pdf#page=57 + - title: IPUMS CPS, INCRETIR (from 2019, only people 58 and older) + href: https://cps.ipums.org/cps-action/variables/INCRETIR diff --git a/policyengine_us/tests/code_health/test_military_retirement_readers.py b/policyengine_us/tests/code_health/test_military_retirement_readers.py index 8b17b8631d2..95819e3c389 100644 --- a/policyengine_us/tests/code_health/test_military_retirement_readers.py +++ b/policyengine_us/tests/code_health/test_military_retirement_readers.py @@ -170,7 +170,9 @@ def test_benefit_source_lists_do_not_read_raw_military_retirement_pay(): text = path.read_text(encoding="utf-8") if RAW not in text: continue - # The partner must itself list the annuities for this list to pass. + # The list passes while its partner's file names the annuities. This + # guard reads each file's text; test_income_concept_parity checks that + # every year counts them. partner = SPLIT_YAML_FILES.get(relative.as_posix()) if partner is not None: text += "\n" + (PARAMETERS / partner).read_text(encoding="utf-8") diff --git a/policyengine_us/tests/policy/baseline/household/income/census_money_income.yaml b/policyengine_us/tests/policy/baseline/household/income/census_money_income.yaml index 1325e93bcf4..81988f1e6b7 100644 --- a/policyengine_us/tests/policy/baseline/household/income/census_money_income.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/census_money_income.yaml @@ -7,8 +7,10 @@ input: state_code: TX age: 40 - ssi: 0 - tanf: 0 + # 5. Supplemental Security Income, 6. public assistance. + ssi: 1_100 + tanf: 700 + general_assistance: 200 # 1. Earnings: wages, nonfarm and farm self-employment. employment_income: 30_000 self_employment_income: 4_000 @@ -45,9 +47,9 @@ financial_assistance: 250 gi_cash_assistance: 150 output: - census_money_income: 57_800 - census_family_money_income: 57_800 - census_household_money_income: 57_800 + census_money_income: 59_800 + census_family_money_income: 59_800 + census_household_money_income: 59_800 - name: Receipts Appendix A does not count leave money income unchanged period: 2026 @@ -103,6 +105,71 @@ census_family_money_income: 32_400 census_household_money_income: 32_400 +- name: Each group benefit splits among the members of the group that receives it + period: 2026 + input: + people: + first: + age: 40 + ssi: 0 + second: + age: 38 + ssi: 0 + third: + age: 35 + ssi: 0 + fourth: + age: 12 + ssi: 0 + fifth: + age: 10 + ssi: 0 + tax_units: + first_tax_unit: + members: [first] + basic_income: 100 + second_tax_unit: + members: [second] + basic_income: 200 + third_tax_unit: + members: [third, fourth, fifth] + basic_income: 300 + spm_units: + first_spm_unit: + members: [first, second] + tanf: 1_000 + second_spm_unit: + members: [third, fourth, fifth] + tanf: 900 + families: + first_family: + members: [first, second] + second_family: + members: [third, fourth, fifth] + marital_units: + first_marital_unit: + members: [first] + second_marital_unit: + members: [second] + third_marital_unit: + members: [third] + fourth_marital_unit: + members: [fourth] + fifth_marital_unit: + members: [fifth] + households: + household: + members: [first, second, third, fourth, fifth] + state_code: CA + # Other income, paid to the household. + ca_la_infant_supplement: 1_000 + output: + # TANF: 1,000 over two and 900 over three. Basic income: 100, 200, and 300 + # over three. Household payment: 1,000 over five. + census_money_income: [800, 900, 600, 600, 600] + census_family_money_income: [1_700, 1_800] + census_household_money_income: 3_500 + - name: Two unrelated roommates form two families in one household period: 2026 input: @@ -158,3 +225,48 @@ output: census_money_income: 506_000 census_household_money_income: 506_000 + +# Since its 2019 redesign, which collects 2018 income, the Current Population +# Survey leaves retirement account distributions to people under 58 out of +# total income (data dictionary: DST_VAL1 for 58 and over, DST_VAL1_YNG under). +- name: A retirement account distribution counts at age 58 + period: 2026 + input: + state_code: TX + age: 58 + ssi: 0 + tanf: 0 + employment_income: 20_000 + retirement_distributions: 5_000 + output: + census_retirement_distributions: 5_000 + census_money_income: 25_000 + census_household_money_income: 25_000 + +- name: A retirement account distribution does not count at age 57 + period: 2026 + input: + state_code: TX + age: 57 + ssi: 0 + tanf: 0 + employment_income: 20_000 + retirement_distributions: 5_000 + output: + census_retirement_distributions: 0 + census_money_income: 20_000 + census_household_money_income: 20_000 + +- name: Before the survey's redesign a distribution counts at any age + period: 2017 + input: + state_code: TX + age: 40 + ssi: 0 + tanf: 0 + employment_income: 20_000 + retirement_distributions: 5_000 + output: + census_retirement_distributions: 5_000 + census_money_income: 25_000 + census_household_money_income: 25_000 diff --git a/policyengine_us/tests/test_income_concept_parity.py b/policyengine_us/tests/test_income_concept_parity.py index bd6f199a075..e5b5c860c9c 100644 --- a/policyengine_us/tests/test_income_concept_parity.py +++ b/policyengine_us/tests/test_income_concept_parity.py @@ -7,17 +7,23 @@ drift: a program added to household_benefits reaches neither concept, or a source lands in two CBO categories and counts twice. -Five invariants hold for every input. +These tests check five invariants in each year from 2022, the first year of +household_benefits. The first two read the lists. The last three hold each +source as an input, because a program's own formula responds to income: a +dollar of wages can change a benefit. 1. Every source that any of these lists counts appears in each concept, or in that concept's documented exclusions below, with the reason. -2. No source counts twice: no variable sits under two Census sources, the CBO - categories are disjoint, CBO adds nothing household market income already - holds, and neither concept counts a variable beside another that sums it. +2. No source counts twice: no list repeats a variable, no variable sits under + two Census sources, the CBO categories are disjoint, CBO adds nothing + household market income already holds, and neither concept counts a variable + beside another that reaches it through `adds`. 3. A dollar of a listed source adds a dollar to its concept. A person's Census money income gains the dollar, or an equal share of a dollar paid to a group, and the family and household totals gain the dollar. The CBO category that lists a source gains the dollar and the others do not. + Retirement account distributions count only from the age Census counts + them. 4. A dollar of a source a concept leaves out moves that concept not at all. 5. Family and household totals sum their members, census_money_income matches an independent allocation of each source, and Census household money income @@ -29,7 +35,9 @@ """ import functools +import itertools import re +from collections import Counter from pathlib import Path import numpy as np @@ -44,13 +52,11 @@ HOUSEHOLD_LISTS = PACKAGE / "parameters/gov/household" CENSUS_LISTS = HOUSEHOLD_LISTS / "census_money_income_sources" MARKET_INCOME_SOURCES = HOUSEHOLD_LISTS / "market_income_sources.yaml" -# household_benefits and the Census lists begin in 2022. +# household_benefits begins in 2022. YEARS = range(2022, 2027) -YEAR = 2026 TOLERANCE = 0.05 # Census household money income adds about 100 float32 terms, so the -# reconciliation allows for their rounding. A misclassified program moves the -# total by the whole benefit. +# reconciliation allows for their rounding. RECONCILIATION_TOLERANCE = 1.0 STATES = ( @@ -88,16 +94,22 @@ EMPLOYER = "an employer payment, not money the worker receives" DISCOUNT = "lowers a bill, not money received" REBATE = "lowers the price of a purchase, not money received" +PROPERTY_TAX_RELIEF = ( + "property tax relief, a tax refund for homeowners, which Appendix A leaves " + "out; this list treats renters' relief the same way" +) CENSUS_EXCLUDED = { # Market income. "capital_gains": "capital gains", "non_sch_d_capital_gains": "capital gains", "other_net_gain_person": "gains on sales of business property (Form 4797)", "debt_relief": "discharged debt, not a receipt of money", - "illicit_income": "income from bribes and other illegal activity, which " - "no Appendix A source covers", - "miscellaneous_income": "irregular items of gross income (26 CFR 1.61-14); " - "other income covers payments people receive regularly", + "illicit_income": "income from bribes and other illegal activity, not a " + "regular payment under other income, and no other source covers it", + "miscellaneous_income": "no Appendix A source fits: the default data fills " + "it with Census other income on survey records and Form 4797 business " + "property gains and losses on tax records (microcosm#393), and the model " + "cannot separate the two", # CBO's employer-side and imputed components. "employer_sponsored_insurance_premiums": EMPLOYER, "employer_social_security_tax": EMPLOYER, @@ -147,10 +159,8 @@ "residential_efficiency_electrification_rebate": REBATE, "ny_drive_clean_rebate": REBATE, # Tax relief. - "nj_property_tax_relief": "property tax relief, which returns tax: " - "Appendix A leaves out tax refunds", - "co_denver_property_tax_relief": "property tax relief, which returns tax: " - "Appendix A leaves out tax refunds", + "nj_property_tax_relief": PROPERTY_TAX_RELIEF, + "co_denver_property_tax_relief": PROPERTY_TAX_RELIEF, } # CBO's measures (The Distribution of Household Income in 2021, Appendix D) @@ -164,6 +174,8 @@ "pfml_benefits": "state paid leave insurance: CBO's social insurance is " "Social Security, Medicare, unemployment insurance and workers' " "compensation", + "census_retirement_distributions": "Census's age-limited view of " + "retirement_distributions, which CBO's market income counts in full", "educational_assistance": "Pell Grants and other government aid beside " "private scholarships (IPUMS INCEDUC), which the model does not split: " "CBO's other income covers only nongovernmental sources, and its transfer " @@ -230,11 +242,20 @@ ), } -# Sources CBO counts through another listed variable that sums them. +# Sources Census counts only in part, through the variable that applies the +# limit: since its 2019 redesign the survey leaves retirement account +# distributions to people under 58 out of total income. +CENSUS_COUNTED_IN_PART = { + "retirement_distributions": "census_retirement_distributions", +} + +# Sources CBO counts through the listed variable that sums them. CBO_COUNTED_AS = { - "medicaid_cost": "medicaid", - "military_retirement_pay": "military_retirement_income", - "military_retirement_pay_survivors": "military_retirement_income", + "medicaid": ("medicaid_cost",), + "military_retirement_income": ( + "military_retirement_pay", + "military_retirement_pay_survivors", + ), } # Sources that can be losses. @@ -249,11 +270,38 @@ } -def block(path, year): - """The members of a list parameter in effect during a year.""" +CBO_LIST_NAMES = ( + "market_income_additions", + "market_income_subtractions", + "social_insurance_benefits", + "means_tested_transfers", + "federal_tax_subtractions", +) +BENEFIT_LIST_NAMES = ( + "household_benefits", + "household_state_benefits", + "household_local_benefits", + "household_health_benefits", + "household_head_start_benefits", +) + + +def dated_blocks(path): + """A list parameter's (date, members) pairs, earliest first.""" raw = yaml.load(path.read_text(), Loader=yaml.BaseLoader) - dated = sorted(item for item in raw["values"].items() if item[0] <= f"{year}-12-31") - return set(dated[-1][1]) if dated else set() + return sorted(raw["values"].items()) + + +def block(path, year): + """The members of a list parameter in effect during a year. + + A year reads the block in effect on January 1, as the model does. The system + backdates each list's first block to 2015, so a list applies before its + first date too. + """ + dated = dated_blocks(path) + started = [members for date, members in dated if date <= f"{year}-01-01"] + return set(started[-1] if started else dated[0][1]) def census_lists(year): @@ -268,14 +316,10 @@ def census_members(year): def cbo_lists(year): - names = ( - "market_income_additions", - "market_income_subtractions", - "social_insurance_benefits", - "means_tested_transfers", - "federal_tax_subtractions", - ) - return {name: block(HOUSEHOLD_LISTS / f"cbo_{name}.yaml", year) for name in names} + return { + name: block(HOUSEHOLD_LISTS / f"cbo_{name}.yaml", year) + for name in CBO_LIST_NAMES + } def cbo_categories(year): @@ -348,6 +392,31 @@ def entity_of(name): return system.variables[name].entity.key +@functools.cache +def reachable(name, year): + """A variable and every variable it reaches through `adds`.""" + from policyengine_us.system import system + + # `adds` can also name a parameter, which is a leaf. + if name not in system.variables: + return frozenset({name}) + parts = system.variables[name].adds or () + if isinstance(parts, str): + node = functools.reduce(getattr, parts.split("."), system.parameters) + parts = node(f"{year}-01-01") + found = {name} + for part in parts: + found |= reachable(part, year) + return frozenset(found) + + +def assert_none_sums_another(names, year): + reached = {name: reachable(name, year) for name in names} + for first, second in itertools.combinations(sorted(names), 2): + both = reached[first] & reached[second] + assert not both, f"{first} and {second} both count {sorted(both)}" + + def test_census_lists_follow_appendix_a(): files = {path.stem for path in CENSUS_LISTS.glob("*.yaml")} - {"index"} assert files == set(CENSUS_SOURCES) @@ -358,12 +427,15 @@ def test_census_lists_follow_appendix_a(): assert f"source {number} of 18" in raw["description"], source titles = [reference["title"] for reference in raw["metadata"]["reference"]] assert any("P60-200, Appendix A" in title for title in titles), source + # The definition applies to every year, so no year reads an empty list. + assert min(raw["values"]) == "0000-01-01", source def test_concept_lists_name_defined_variables(variables): names = set(CENSUS_EXCLUDED) | set(CBO_EXCLUDED) names |= set(CENSUS_COUNTED_AS) | set().union(*CENSUS_COUNTED_AS.values()) - names |= set(CBO_COUNTED_AS) | set(CBO_COUNTED_AS.values()) + names |= set(CENSUS_COUNTED_IN_PART) | set(CENSUS_COUNTED_IN_PART.values()) + names |= set(CBO_COUNTED_AS) | set().union(*CBO_COUNTED_AS.values()) for year in YEARS: names |= census_members(year) | set().union(*cbo_lists(year).values()) assert names - set(variables) == set() @@ -371,11 +443,27 @@ def test_concept_lists_name_defined_variables(variables): def test_aliases_sum_exactly_their_parts(variables): # A part added to one of these variables later would otherwise reach - # neither concept. - for name, parts in CENSUS_COUNTED_AS.items(): + # neither concept, or count where the concept leaves it out. + for name, parts in {**CENSUS_COUNTED_AS, **CBO_COUNTED_AS}.items(): assert set(variables[name].adds) == set(parts), name - for name, alias in CBO_COUNTED_AS.items(): - assert name in variables[alias].adds, f"{alias} does not add {name}" + + +def test_no_list_names_a_source_twice(): + # block() reads a list as a set, while a formula sums it as written. + paths = [ + *(CENSUS_LISTS / f"{source}.yaml" for source in CENSUS_SOURCES), + *(HOUSEHOLD_LISTS / f"cbo_{name}.yaml" for name in CBO_LIST_NAMES), + *(HOUSEHOLD_LISTS / f"{name}.yaml" for name in BENEFIT_LIST_NAMES), + MARKET_INCOME_SOURCES, + ] + repeats = [ + (path.stem, date, name) + for path in paths + for date, members in dated_blocks(path) + for name, count in Counter(members).items() + if count > 1 + ] + assert repeats == [] @pytest.mark.parametrize("year", YEARS) @@ -385,8 +473,10 @@ def test_no_source_counts_twice(year): for name in members: assert name not in seen, f"{name} is under {seen[name]} and {source}" seen[name] = source - for name, parts in CENSUS_COUNTED_AS.items(): - assert name not in seen, f"{name} counts beside {parts}, which it sums" + assert_none_sums_another(set(seen), year) + for name, partial in CENSUS_COUNTED_IN_PART.items(): + assert name not in seen, f"{name} counts beside {partial}, its counted part" + assert partial in seen, f"{partial} no longer carries {name}" lists = cbo_lists(year) market = block(MARKET_INCOME_SOURCES, year) @@ -400,9 +490,7 @@ def test_no_source_counts_twice(year): for second in totals[index + 1 :]: both = categories[first] & categories[second] assert both == set(), f"{sorted(both)} count in {first} and {second}" - counted = set().union(*categories.values()) - for name, alias in CBO_COUNTED_AS.items(): - assert name not in counted, f"{name} counts beside {alias}, which sums it" + assert_none_sums_another(set().union(*categories.values()), year) # household_market_income + household_benefits must not count a source # twice either, or the reconciliation below would not hold. @@ -415,6 +503,9 @@ def test_census_classifies_every_source(year): counted |= { name for name, parts in CENSUS_COUNTED_AS.items() if set(parts) <= counted } + counted |= { + name for name, partial in CENSUS_COUNTED_IN_PART.items() if partial in counted + } excluded = set(CENSUS_EXCLUDED) assert counted & excluded == set(), "listed and excluded" unclassified = universe(year) - counted - excluded @@ -429,7 +520,9 @@ def test_census_classifies_every_source(year): @pytest.mark.parametrize("year", YEARS) def test_cbo_classifies_every_source(year): counted = set().union(*cbo_categories(year).values()) - counted |= {name for name, alias in CBO_COUNTED_AS.items() if alias in counted} + for alias, parts in CBO_COUNTED_AS.items(): + if alias in counted: + counted |= set(parts) excluded = set(CBO_EXCLUDED) assert counted & excluded == set(), "listed and excluded" unclassified = universe(year) - counted - excluded @@ -446,17 +539,19 @@ def test_exclusions_are_current(): every_year = set().union(*(universe(year) for year in YEARS)) assert set(CENSUS_EXCLUDED) - every_year == set(), "stale Census entries" assert set(CENSUS_COUNTED_AS) - every_year == set(), "stale Census aliases" + assert set(CENSUS_COUNTED_IN_PART) - every_year == set(), "stale Census parts" assert set(CBO_EXCLUDED) - every_year == set(), "stale CBO entries" - assert set(CBO_COUNTED_AS) - every_year == set(), "stale CBO aliases" + aliased = set(CBO_COUNTED_AS) | set().union(*CBO_COUNTED_AS.values()) + assert aliased - every_year == set(), "stale CBO aliases" -def paired_households(cases, size, earnings=0, year=YEAR): +def paired_households(cases, size, year, earnings=0, ages=None): """A paid and an unpaid household for each (name, entity, state, amount). - Each household holds a 40-year-old head, who earns `earnings`, and - `size - 1` children, in one tax unit, SPM unit and family. A source the - situation names becomes an input for every household, zero where unset, - so no formula responds to another case's payment. + Each household holds a head, 40 years old unless `ages` gives one per case, + who earns `earnings`, and `size - 1` children, in one tax unit, SPM unit and + family. A source the situation names becomes an input for every household, + zero where unset, so no formula responds to another case's payment. """ people = {} units = { @@ -471,7 +566,8 @@ def paired_households(cases, size, earnings=0, year=YEAR): key = f"{side}_{index}" members = [f"{key}_{position}" for position in range(size)] for position, member in enumerate(members): - people[member] = {"age": {year: 40 if position == 0 else 10}} + head_age = 40 if ages is None else ages[index] + people[member] = {"age": {year: head_age if position == 0 else 10}} units["marital_units"][member] = {"members": [member]} head = people[members[0]] head["employment_income"] = {year: earnings} @@ -493,7 +589,7 @@ def paired_households(cases, size, earnings=0, year=YEAR): return Simulation(situation={"people": people, **units}) -def values_by_id(simulation, variable, year=YEAR): +def values_by_id(simulation, variable, year): entity = simulation.tax_benefit_system.variables[variable].entity.key ids = simulation.populations[entity].ids return dict(zip(ids, simulation.calculate(variable, year).astype(float))) @@ -511,35 +607,40 @@ def shares(entity, amount, size): return [amount / size] * size -CENSUS_CASES = sorted(census_members(YEAR) | (set(CENSUS_EXCLUDED) & universe(YEAR))) +def census_cases(year): + return sorted(census_members(year) | (set(CENSUS_EXCLUDED) & universe(year))) + +CENSUS_CASE_COUNT = max(len(census_cases(year)) for year in YEARS) -@settings(max_examples=5, deadline=None) + +@pytest.mark.parametrize("year", YEARS) +@settings(max_examples=2, deadline=None) @given( size=st.integers(min_value=1, max_value=3), amounts=st.lists( st.integers(min_value=1, max_value=100_000), - min_size=len(CENSUS_CASES), - max_size=len(CENSUS_CASES), + min_size=CENSUS_CASE_COUNT, + max_size=CENSUS_CASE_COUNT, ), losses=st.booleans(), ) @example( size=3, - amounts=[1_000 + index for index in range(len(CENSUS_CASES))], + amounts=[1_000 + index for index in range(CENSUS_CASE_COUNT)], losses=True, ) -def test_census_money_income_moves_dollar_for_dollar(size, amounts, losses): - counted = census_members(YEAR) +def test_census_money_income_moves_dollar_for_dollar(year, size, amounts, losses): + counted = census_members(year) cases = [] - for name, amount in zip(CENSUS_CASES, amounts): + for name, amount in zip(census_cases(year), amounts): if losses and name in LOSSES: amount = -amount cases.append((name, entity_of(name), state_of(name), amount)) - simulation = paired_households(cases, size) - person = values_by_id(simulation, "census_money_income") - family = values_by_id(simulation, "census_family_money_income") - household = values_by_id(simulation, "census_household_money_income") + simulation = paired_households(cases, size, year) + person = values_by_id(simulation, "census_money_income", year) + family = values_by_id(simulation, "census_family_money_income", year) + household = values_by_id(simulation, "census_household_money_income", year) failures = [] for index, (name, entity, _, amount) in enumerate(cases): @@ -553,33 +654,59 @@ def test_census_money_income_moves_dollar_for_dollar(size, amounts, losses): assert failures == [] -CBO_CASES = sorted( - set().union(*cbo_categories(YEAR).values()) | (set(CBO_EXCLUDED) & universe(YEAR)) -) +@pytest.mark.parametrize("year", (2017, 2018, 2026)) +@settings(max_examples=3, deadline=None) +@given(amount=st.integers(min_value=1, max_value=200_000)) +@example(amount=10_000) +def test_census_counts_retirement_distributions_from_age_58(year, amount): + # The survey's 2019 redesign, which collects 2018 income, leaves + # distributions to people under 58 out of total income; the earlier rule + # has no age limit. + ages = list(range(15, 91)) + cases = [("retirement_distributions", "person", "TX", amount)] * len(ages) + # Earnings this high rule out every means-tested response. + simulation = paired_households(cases, 1, year, earnings=100_000, ages=ages) + person = values_by_id(simulation, "census_money_income", year) + household = values_by_id(simulation, "census_household_money_income", year) + for index, age in enumerate(ages): + expected = amount if year < 2018 or age >= 58 else 0 + assert change(person, index, 0) == pytest.approx(expected, abs=TOLERANCE), age + assert change(household, index) == pytest.approx(expected, abs=TOLERANCE), age + + +def cbo_cases(year): + return sorted( + set().union(*cbo_categories(year).values()) + | (set(CBO_EXCLUDED) & universe(year)) + ) + +CBO_CASE_COUNT = max(len(cbo_cases(year)) for year in YEARS) -@settings(max_examples=5, deadline=None) + +@pytest.mark.parametrize("year", YEARS) +@settings(max_examples=2, deadline=None) @given( size=st.integers(min_value=1, max_value=3), amounts=st.lists( st.integers(min_value=1, max_value=100_000), - min_size=len(CBO_CASES), - max_size=len(CBO_CASES), + min_size=CBO_CASE_COUNT, + max_size=CBO_CASE_COUNT, ), ) -@example(size=2, amounts=[1_000 + index for index in range(len(CBO_CASES))]) -def test_cbo_categories_move_dollar_for_dollar(size, amounts): - categories = cbo_categories(YEAR) - subtractions = cbo_lists(YEAR)["market_income_subtractions"] +@example(size=2, amounts=[1_000 + index for index in range(CBO_CASE_COUNT)]) +def test_cbo_categories_move_dollar_for_dollar(year, size, amounts): + categories = cbo_categories(year) + subtractions = cbo_lists(year)["market_income_subtractions"] cases = [ (name, entity_of(name), state_of(name), amount) - for name, amount in zip(CBO_CASES, amounts) + for name, amount in zip(cbo_cases(year), amounts) ] - simulation = paired_households(cases, size) + simulation = paired_households(cases, size, year) # Federal taxes respond to income, so the next test checks them alone. incomes = {key: value for key, value in categories.items() if key in CBO_INCOMES} - totals = {total: values_by_id(simulation, total) for total in incomes} - market = values_by_id(simulation, "household_market_income") + totals = {total: values_by_id(simulation, total, year) for total in incomes} + market = values_by_id(simulation, "household_market_income", year) failures = [] for index, (name, _, _, amount) in enumerate(cases): @@ -592,29 +719,38 @@ def test_cbo_categories_move_dollar_for_dollar(size, amounts): assert failures == [] -@settings(max_examples=3, deadline=None) +@pytest.mark.parametrize("year", YEARS) +@settings(max_examples=2, deadline=None) @given(amount=st.integers(min_value=1, max_value=20_000)) @example(amount=4_321) -def test_cbo_federal_taxes_subtract_each_refundable_credit(amount): - credits = sorted(cbo_categories(YEAR)["cbo_household_federal_taxes"]) +def test_cbo_federal_taxes_subtract_each_refundable_credit(year, amount): + credits = sorted(cbo_categories(year)["cbo_household_federal_taxes"]) cases = [(name, entity_of(name), "TX", amount) for name in credits] # Earnings this high rule out every means-tested response. - simulation = paired_households(cases, size=2, earnings=500_000) - taxes = values_by_id(simulation, "cbo_household_federal_taxes") + simulation = paired_households(cases, 2, year, earnings=500_000) + taxes = values_by_id(simulation, "cbo_household_federal_taxes", year) for index, name in enumerate(credits): assert change(taxes, index) == pytest.approx(-amount, abs=TOLERANCE), name -@pytest.mark.parametrize("name, alias", sorted(CBO_COUNTED_AS.items())) -def test_cbo_counts_a_source_through_the_variable_that_sums_it(name, alias): +@pytest.mark.parametrize( + "year, name, alias", + [ + (year, name, alias) + for year in YEARS + for alias, parts in CBO_COUNTED_AS.items() + for name in parts + ], +) +def test_cbo_counts_a_source_through_the_variable_that_sums_it(year, name, alias): simulation = paired_households( - [(name, entity_of(name), "TX", 1_000)], size=1, earnings=500_000 + [(name, entity_of(name), "TX", 1_000)], 1, year, earnings=500_000 ) - categories = cbo_categories(YEAR) + categories = cbo_categories(year) assert any(alias in categories[total] for total in CBO_INCOMES), alias for total in CBO_INCOMES: expected = 1_000 if alias in categories[total] else 0 - assert change(values_by_id(simulation, total), 0) == pytest.approx( + assert change(values_by_id(simulation, total, year), 0) == pytest.approx( expected, abs=TOLERANCE ), total @@ -638,29 +774,59 @@ def reference_census_money_income(simulation, year): return total -@settings(max_examples=10, deadline=None) +def members_by_group(names, assignment): + """Each group's members, keyed by the group number the assignment gives.""" + groups = {} + for name, group in zip(names, assignment): + groups.setdefault(group, []).append(name) + return groups + + +@pytest.mark.parametrize("year", YEARS) +@settings(max_examples=2, deadline=None) @given( - family_of=st.lists(st.integers(min_value=0, max_value=2), min_size=1, max_size=5), - earnings=st.lists(st.integers(min_value=0, max_value=60_000), min_size=5), - veterans=st.lists(st.integers(min_value=0, max_value=20_000), min_size=5), - tanf=st.integers(min_value=0, max_value=10_000), + spm_unit_of=st.lists(st.integers(min_value=0, max_value=2), min_size=1, max_size=5), + tax_unit_of=st.lists(st.integers(min_value=0, max_value=2), min_size=5, max_size=5), + family_of=st.lists(st.integers(min_value=0, max_value=2), min_size=5, max_size=5), + earnings=st.lists( + st.integers(min_value=0, max_value=60_000), min_size=5, max_size=5 + ), + veterans=st.lists( + st.integers(min_value=0, max_value=20_000), min_size=5, max_size=5 + ), + tanf=st.lists(st.integers(min_value=0, max_value=10_000), min_size=3, max_size=3), + basic_income=st.lists( + st.integers(min_value=0, max_value=10_000), min_size=3, max_size=3 + ), + infant_supplement=st.integers(min_value=0, max_value=10_000), state=st.sampled_from(STATES), ) @example( - family_of=[0, 0, 1], - earnings=[20_000, 0, 9_000], - veterans=[0, 0, 3_000], - tanf=1_200, + spm_unit_of=[0, 0, 1, 1, 1], + tax_unit_of=[0, 1, 2, 2, 2], + family_of=[0, 0, 1, 1, 1], + earnings=[20_000, 0, 9_000, 0, 0], + veterans=[0, 0, 3_000, 0, 0], + tanf=[1_000, 900, 0], + basic_income=[100, 200, 300], + infant_supplement=1_000, state="CA", ) def test_totals_sum_members_and_match_an_independent_allocation( - family_of, earnings, veterans, tanf, state + year, + spm_unit_of, + tax_unit_of, + family_of, + earnings, + veterans, + tanf, + basic_income, + infant_supplement, + state, ): - year = YEAR - names = [f"person_{index}" for index in range(len(family_of))] - families = {} - for name, family in zip(names, family_of): - families.setdefault(f"family_{family}", []).append(name) + # One household holds up to three SPM units, tax units and families, drawn + # separately, so a split by the wrong group fails. + names = [f"person_{index}" for index in range(len(spm_unit_of))] people = { name: { "age": {year: 25 + 10 * index}, @@ -673,15 +839,30 @@ def test_totals_sum_members_and_match_an_independent_allocation( situation={ "people": people, "tax_units": { - key: {"members": members} for key, members in families.items() + f"tax_unit_{group}": { + "members": members, + "basic_income": {year: basic_income[group]}, + } + for group, members in members_by_group(names, tax_unit_of).items() + }, + "spm_units": { + f"spm_unit_{group}": { + "members": members, + "tanf": {year: tanf[group]}, + } + for group, members in members_by_group(names, spm_unit_of).items() }, - "spm_units": {"spm_unit": {"members": names, "tanf": {year: tanf}}}, "families": { - key: {"members": members} for key, members in families.items() + f"family_{group}": {"members": members} + for group, members in members_by_group(names, family_of).items() }, "marital_units": {name: {"members": [name]} for name in names}, "households": { - "household": {"members": names, "state_name": {year: state}} + "household": { + "members": names, + "state_name": {year: state}, + "ca_la_infant_supplement": {year: infant_supplement}, + } }, } ) @@ -689,13 +870,19 @@ def test_totals_sum_members_and_match_an_independent_allocation( family = simulation.calculate("census_family_money_income", year).astype(float) household = simulation.calculate("census_household_money_income", year) assert household.sum() == pytest.approx(person.sum(), abs=TOLERANCE) - assert family.sum() == pytest.approx(person.sum(), abs=TOLERANCE) + members_family = simulation.populations["family"].members_entity_id + assert np.allclose( + family, + np.bincount(members_family, weights=person, minlength=family.size), + atol=TOLERANCE, + ) assert np.allclose( person, reference_census_money_income(simulation, year), atol=TOLERANCE ) -@settings(max_examples=2, deadline=None) +@pytest.mark.parametrize("year", YEARS) +@settings(max_examples=1, deadline=None) @given( earnings=st.lists( st.integers(min_value=0, max_value=150_000), @@ -705,27 +892,44 @@ def test_totals_sum_members_and_match_an_independent_allocation( children=st.integers(min_value=0, max_value=2), ) @example(earnings=[15_000] * len(STATES), children=2) -def test_census_money_income_reconciles_with_household_totals(earnings, children): +def test_census_money_income_reconciles_with_household_totals(year, earnings, children): """A differential test of Census money income in every state at once. household_market_income and household_benefits sum their own lists. Take away the terms money income leaves out and add the sources only money - income counts, and the result is Census household money income. + income counts, and the result is Census household money income. The test + reads the same lists as the variables, so it checks how the variables add + them, not where a program belongs. + + Each state has a working-age household with children, an aged person and a + disabled person, with rent, so that state cash and rent-tested programs pay. """ - year = YEAR people, units = {}, {key: {} for key in ("tax_units", "spm_units", "families")} marital_units, households = {}, {} - for state, amount in zip(STATES, earnings): - members = [f"{state}_{index}" for index in range(children + 1)] - for index, member in enumerate(members): - people[member] = {"age": {year: 40 if index == 0 else 6 + index}} + + def add_household(key, state, ages, income, **head_inputs): + members = [f"{key}_{index}" for index in range(len(ages))] + for member, age in zip(members, ages): + people[member] = {"age": {year: age}} marital_units[member] = {"members": [member]} - people[members[0]]["employment_income"] = {year: amount} - # Rent makes rent-tested programs, such as New Jersey's relief, pay. - people[members[0]]["pre_subsidy_rent"] = {year: 12_000} - for key in units: - units[key][state] = {"members": members} - households[state] = {"members": members, "state_name": {year: state}} + people[members[0]].update( + employment_income={year: income}, + pre_subsidy_rent={year: 12_000}, + **{name: {year: value} for name, value in head_inputs.items()}, + ) + for unit in units.values(): + unit[key] = {"members": members} + households[key] = {"members": members, "state_name": {year: state}} + + for state, amount in zip(STATES, earnings): + add_household( + f"{state}_working", + state, + [40] + [6 + child for child in range(children)], + amount, + ) + add_household(f"{state}_aged", state, [70], 0) + add_household(f"{state}_disabled", state, [45], 0, is_disabled=True) simulation = Simulation( situation={ "people": people, diff --git a/policyengine_us/variables/household/income/household/cbo_household_social_insurance_benefits.py b/policyengine_us/variables/household/income/household/cbo_household_social_insurance_benefits.py index a96a2c60a08..3ba4d7c6387 100644 --- a/policyengine_us/variables/household/income/household/cbo_household_social_insurance_benefits.py +++ b/policyengine_us/variables/household/income/household/cbo_household_social_insurance_benefits.py @@ -7,8 +7,8 @@ class cbo_household_social_insurance_benefits(Variable): label = "CBO household social insurance benefits" documentation = ( "Social insurance benefits included in CBO household income: Social " - "Security, Medicare benefits net of premiums, unemployment insurance, " - "and workers' compensation." + "Security, Medicare benefits net of Part A and Part B premiums, " + "unemployment insurance, and workers' compensation." ) definition_period = YEAR unit = USD diff --git a/policyengine_us/variables/household/income/person/general/census_money_income.py b/policyengine_us/variables/household/income/person/general/census_money_income.py index be36c1462f8..b7523703347 100644 --- a/policyengine_us/variables/household/income/person/general/census_money_income.py +++ b/policyengine_us/variables/household/income/person/general/census_money_income.py @@ -8,10 +8,13 @@ class census_money_income(Variable): unit = USD definition_period = YEAR documentation = ( - "Money income as the Census Bureau defines it for its official income " - "statistics: the sum of the 18 sources in Current Population Reports " - "P60-200, Appendix A, before taxes. Each source is a list in " - "gov.household.census_money_income_sources. It leaves out capital " + "Money income as the Census Bureau reports it from the Current " + "Population Survey: the sum of the 18 sources in Current Population " + "Reports P60-200 (1998), Appendix A, which the survey's subject " + "definitions still list, before taxes. Each source is a list in " + "gov.household.census_money_income_sources. Retirement account " + "distributions count from age 58, as the survey has counted them " + "since its 2019 redesign. It leaves out capital " "gains, discharged debt, tax refunds and credits, lump-sum insurance " "payments, and noncash benefits such as SNAP, school meals, housing " "assistance, child care subsidies and health coverage. A benefit the " @@ -23,11 +26,14 @@ class census_money_income(Variable): "and records SSI paid for a younger child in a parent's or guardian's " "SSI income. The model lacks three sources Appendix A lists: Black " "lung payments, military family allotments and foreign government " - "pensions." + "pensions. It has foster child payments only for Los Angeles County, " + "and no variable for interest earned inside retirement accounts, " + "which the survey has counted as interest since 2019." ) reference = ( # PDF pages 1-3 "https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=1", + "https://www.census.gov/programs-surveys/cps/technical-documentation/subject-definitions.html", "https://cps.ipums.org/cps-action/variables/SSIKID", ) diff --git a/policyengine_us/variables/household/income/person/general/census_retirement_distributions.py b/policyengine_us/variables/household/income/person/general/census_retirement_distributions.py new file mode 100644 index 00000000000..15af382ed11 --- /dev/null +++ b/policyengine_us/variables/household/income/person/general/census_retirement_distributions.py @@ -0,0 +1,28 @@ +from policyengine_us.model_api import * + + +class census_retirement_distributions(Variable): + value_type = float + entity = Person + label = "Census money income retirement account distributions" + unit = USD + definition_period = YEAR + documentation = ( + "Distributions from retirement accounts that the Census Bureau counts " + "as money income. Since the Current Population Survey's 2019 redesign, " + "which collects 2018 income, the survey leaves distributions to people " + "under 58 out of total income. Census applies the test to age at the " + "survey date; this variable uses age in the income year." + ) + reference = ( + # Pages 57 and 58 define the distribution amounts by age. + "https://www2.census.gov/programs-surveys/cps/datasets/2026/march/asec2026_ddl_pub_full.pdf#page=57", + "https://cps.ipums.org/cps-action/variables/INCRETIR", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.household + counted = person("age", period) >= ( + p.census_retirement_distributions_minimum_age + ) + return counted * person("retirement_distributions", period) From 077631a6da0013633dc04c6ddadbbec174d39887 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 9 Nov 2026 12:26:55 -0500 Subject: [PATCH 5/7] Leave the state property tax and rent refunds out of Census money income and the CBO measures #10725 added state_property_tax_refunds to household benefits. Appendix A leaves out tax refunds, and CBO's lists name no state tax relief, so both concepts leave the refunds out, as they do New Jersey's ANCHOR. Co-Authored-By: Claude Opus 5.5 --- policyengine_us/tests/test_income_concept_parity.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/policyengine_us/tests/test_income_concept_parity.py b/policyengine_us/tests/test_income_concept_parity.py index e5b5c860c9c..c384b6e631c 100644 --- a/policyengine_us/tests/test_income_concept_parity.py +++ b/policyengine_us/tests/test_income_concept_parity.py @@ -161,6 +161,7 @@ # Tax relief. "nj_property_tax_relief": PROPERTY_TAX_RELIEF, "co_denver_property_tax_relief": PROPERTY_TAX_RELIEF, + "state_property_tax_refunds": PROPERTY_TAX_RELIEF, } # CBO's measures (The Distribution of Household Income in 2021, Appendix D) @@ -206,6 +207,7 @@ "ca_la_infant_supplement": OUTSIDE_CBO, "co_denver_property_tax_relief": OUTSIDE_CBO, "nj_property_tax_relief": OUTSIDE_CBO, + "state_property_tax_refunds": OUTSIDE_CBO, "commodity_supplemental_food_program": OUTSIDE_CBO, "head_start": OUTSIDE_CBO, "early_head_start": OUTSIDE_CBO, From 0603df5478633c1058b7bf4585dbdd80ef841210 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 9 Nov 2026 13:01:34 -0500 Subject: [PATCH 6/7] Answer the second sources review: survey wording, reasons and citations - Call the survey's 2019 change a redesigned processing system, as Census does; the questionnaire redesign came in 2014. - Drop the 2019 date from retirement account interest: Appendix A lists IRAs among interest sources. Cite TRDINT_VAL in the 2026 data dictionary. - Cite RESNSSI1 codes 3 and 4 for SSI received for a child, in place of IPUMS SSIKID, which ends with 2018. - Rest the illicit_income exclusion on Appendix A's silence and call it a judgment call; say miscellaneous_income mixes source 18 with Form 4797. - Cite CBO 60341 on the federal tax and market income subtraction lists. - Run the year-block check on all six CBO lists. - Name CAPI apart from the SSI supplements of 43 states and DC. Co-Authored-By: Claude Opus 5.5 --- changelog.d/census-money-income.added.md | 2 +- .../gov/household/cbo_federal_taxes.yaml | 3 +++ .../cbo_market_income_subtractions.yaml | 3 +++ .../supplemental_security_income.yaml | 6 +++-- ..._retirement_distributions_minimum_age.yaml | 7 +++--- .../household/income/census_money_income.yaml | 9 +++---- .../tests/test_aggregate_list_integrity.py | 8 +++++-- .../tests/test_income_concept_parity.py | 24 ++++++++++--------- .../person/general/census_money_income.py | 17 +++++++++---- .../census_retirement_distributions.py | 9 +++---- 10 files changed, 56 insertions(+), 32 deletions(-) diff --git a/changelog.d/census-money-income.added.md b/changelog.d/census-money-income.added.md index 7656c26b193..33bc494c118 100644 --- a/changelog.d/census-money-income.added.md +++ b/changelog.d/census-money-income.added.md @@ -1 +1 @@ -Add Census Bureau money income for people, families and households (census_money_income, census_family_money_income and census_household_money_income), built from one list for each of the 18 sources in Current Population Reports P60-200, Appendix A, and counting retirement account distributions from age 58, as the Current Population Survey has since its 2019 redesign. +Add Census Bureau money income for people, families and households (census_money_income, census_family_money_income and census_household_money_income), built from one list for each of the 18 sources in Current Population Reports P60-200, Appendix A, and counting retirement account distributions from age 58, as the Current Population Survey has since the redesigned processing system it introduced in 2019, which covers 2018 income. diff --git a/policyengine_us/parameters/gov/household/cbo_federal_taxes.yaml b/policyengine_us/parameters/gov/household/cbo_federal_taxes.yaml index 8bbb476522d..cfaeefe2ea3 100644 --- a/policyengine_us/parameters/gov/household/cbo_federal_taxes.yaml +++ b/policyengine_us/parameters/gov/household/cbo_federal_taxes.yaml @@ -16,3 +16,6 @@ metadata: unit: list period: year label: Modeled CBO federal taxes + reference: + - title: "Congressional Budget Office, The Distribution of Household Income in 2021, Appendix D (federal taxes: individual income, payroll, corporate income and excise taxes) #page 41-42" + href: https://www.cbo.gov/system/files/2024-09/60341-income.pdf#page=41 diff --git a/policyengine_us/parameters/gov/household/cbo_market_income_subtractions.yaml b/policyengine_us/parameters/gov/household/cbo_market_income_subtractions.yaml index d88e0850e03..435369b8dff 100644 --- a/policyengine_us/parameters/gov/household/cbo_market_income_subtractions.yaml +++ b/policyengine_us/parameters/gov/household/cbo_market_income_subtractions.yaml @@ -8,3 +8,6 @@ metadata: unit: list period: year label: CBO market income subtractions + reference: + - title: Congressional Budget Office, The Distribution of Household Income in 2021, Appendix D (market income counts other nongovernmental sources of income) + href: https://www.cbo.gov/system/files/2024-09/60341-income.pdf#page=41 diff --git a/policyengine_us/parameters/gov/household/census_money_income_sources/supplemental_security_income.yaml b/policyengine_us/parameters/gov/household/census_money_income_sources/supplemental_security_income.yaml index 1b06f3cc1da..fed027a8da0 100644 --- a/policyengine_us/parameters/gov/household/census_money_income_sources/supplemental_security_income.yaml +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/supplemental_security_income.yaml @@ -1,7 +1,9 @@ description: The Census Bureau counts these sources as Supplemental Security Income, source 5 of 18 in money income. # Appendix A counts federal, state and local payments to low-income -# people 65 or older, blind or disabled, so state supplements to SSI -# count here alongside the federal payment. +# people 65 or older, blind or disabled, so the SSI supplements of 43 states +# and the District of Columbia count here alongside the federal payment, as +# does California's Cash Assistance Program for Immigrants (CAPI), for aged, +# blind and disabled noncitizens whom SSI's immigration rules exclude. values: 0000-01-01: - ssi diff --git a/policyengine_us/parameters/gov/household/census_retirement_distributions_minimum_age.yaml b/policyengine_us/parameters/gov/household/census_retirement_distributions_minimum_age.yaml index ca18f3ecd21..b800540a4d4 100644 --- a/policyengine_us/parameters/gov/household/census_retirement_distributions_minimum_age.yaml +++ b/policyengine_us/parameters/gov/household/census_retirement_distributions_minimum_age.yaml @@ -2,9 +2,10 @@ description: The Census Bureau counts a person's distributions from retirement a values: # Appendix A counts IRA, Keogh and 401(k) payments at any age. 0000-01-01: 0 - # The survey's 2019 redesign collects 2018 income. It asks people under 58 - # about withdrawals separately (DST_VAL1_YNG, DST_VAL2_YNG) and leaves - # those amounts out of total income (PTOTVAL). + # The redesigned processing system the survey introduced in 2019 covers + # 2018 income. It records withdrawals by people under 58 separately + # (DST_VAL1_YNG, DST_VAL2_YNG) and leaves those amounts out of total income + # (PTOTVAL). 2018-01-01: 58 metadata: unit: year diff --git a/policyengine_us/tests/policy/baseline/household/income/census_money_income.yaml b/policyengine_us/tests/policy/baseline/household/income/census_money_income.yaml index 81988f1e6b7..a271e40c436 100644 --- a/policyengine_us/tests/policy/baseline/household/income/census_money_income.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/census_money_income.yaml @@ -226,9 +226,10 @@ census_money_income: 506_000 census_household_money_income: 506_000 -# Since its 2019 redesign, which collects 2018 income, the Current Population -# Survey leaves retirement account distributions to people under 58 out of -# total income (data dictionary: DST_VAL1 for 58 and over, DST_VAL1_YNG under). +# Since the redesigned processing system the Current Population Survey +# introduced in 2019, which covers 2018 income, the survey leaves retirement +# account distributions to people under 58 out of total income (data +# dictionary: DST_VAL1 for 58 and over, DST_VAL1_YNG under). - name: A retirement account distribution counts at age 58 period: 2026 input: @@ -257,7 +258,7 @@ census_money_income: 20_000 census_household_money_income: 20_000 -- name: Before the survey's redesign a distribution counts at any age +- name: For 2017 income, before the 2019 processing system, a distribution counts at any age period: 2017 input: state_code: TX diff --git a/policyengine_us/tests/test_aggregate_list_integrity.py b/policyengine_us/tests/test_aggregate_list_integrity.py index 0e2ddc47cdc..78a12776364 100644 --- a/policyengine_us/tests/test_aggregate_list_integrity.py +++ b/policyengine_us/tests/test_aggregate_list_integrity.py @@ -157,10 +157,14 @@ def test_members_are_state_gated(list_path, variables): # household_benefits and the income concept lists need no state gate, because -# most of their members are national. +# most of their members are national. Each CBO and Census list has one block +# today, so for them the check guards the blocks added later. INCOME_CONCEPT_LISTS = [ "gov/household/household_benefits.yaml", - "gov/household/cbo_means_tested_transfers.yaml", + *sorted( + str(path.relative_to(PARAMETERS)) + for path in (PARAMETERS / "gov/household").glob("cbo_*.yaml") + ), *sorted( str(path.relative_to(PARAMETERS)) for path in (PARAMETERS / "gov/household/census_money_income_sources").glob( diff --git a/policyengine_us/tests/test_income_concept_parity.py b/policyengine_us/tests/test_income_concept_parity.py index c384b6e631c..b873dfc5b1f 100644 --- a/policyengine_us/tests/test_income_concept_parity.py +++ b/policyengine_us/tests/test_income_concept_parity.py @@ -104,12 +104,13 @@ "non_sch_d_capital_gains": "capital gains", "other_net_gain_person": "gains on sales of business property (Form 4797)", "debt_relief": "discharged debt, not a receipt of money", - "illicit_income": "income from bribes and other illegal activity, not a " - "regular payment under other income, and no other source covers it", - "miscellaneous_income": "no Appendix A source fits: the default data fills " - "it with Census other income on survey records and Form 4797 business " - "property gains and losses on tax records (microcosm#393), and the model " - "cannot separate the two", + "illicit_income": "income from bribes and other illegal activity, which " + "neither Appendix A nor the survey's subject definitions mention (a " + "judgment call: other income could be read to take it)", + "miscellaneous_income": "the default data mixes Census other income " + "(source 18) on survey records with Form 4797 business property gains and " + "losses (left out, page A-3) on tax records (microcosm#393), and the model " + "cannot separate them", # CBO's employer-side and imputed components. "employer_sponsored_insurance_premiums": EMPLOYER, "employer_social_security_tax": EMPLOYER, @@ -245,8 +246,9 @@ } # Sources Census counts only in part, through the variable that applies the -# limit: since its 2019 redesign the survey leaves retirement account -# distributions to people under 58 out of total income. +# limit: since the redesigned processing system it introduced in 2019, which +# covers 2018 income, the survey leaves retirement account distributions to +# people under 58 out of total income. CENSUS_COUNTED_IN_PART = { "retirement_distributions": "census_retirement_distributions", } @@ -661,9 +663,9 @@ def test_census_money_income_moves_dollar_for_dollar(year, size, amounts, losses @given(amount=st.integers(min_value=1, max_value=200_000)) @example(amount=10_000) def test_census_counts_retirement_distributions_from_age_58(year, amount): - # The survey's 2019 redesign, which collects 2018 income, leaves - # distributions to people under 58 out of total income; the earlier rule - # has no age limit. + # The redesigned processing system the survey introduced in 2019, which + # covers 2018 income, leaves distributions to people under 58 out of total + # income; Appendix A has no age limit. ages = list(range(15, 91)) cases = [("retirement_distributions", "person", "TX", amount)] * len(ages) # Earnings this high rule out every means-tested response. diff --git a/policyengine_us/variables/household/income/person/general/census_money_income.py b/policyengine_us/variables/household/income/person/general/census_money_income.py index b7523703347..6549528c2fe 100644 --- a/policyengine_us/variables/household/income/person/general/census_money_income.py +++ b/policyengine_us/variables/household/income/person/general/census_money_income.py @@ -13,8 +13,9 @@ class census_money_income(Variable): "Reports P60-200 (1998), Appendix A, which the survey's subject " "definitions still list, before taxes. Each source is a list in " "gov.household.census_money_income_sources. Retirement account " - "distributions count from age 58, as the survey has counted them " - "since its 2019 redesign. It leaves out capital " + "distributions count from age 58, as in the survey since the " + "redesigned processing system it introduced in 2019, which covers " + "2018 income. It leaves out capital " "gains, discharged debt, tax refunds and credits, lump-sum insurance " "payments, and noncash benefits such as SNAP, school meals, housing " "assistance, child care subsidies and health coverage. A benefit the " @@ -27,14 +28,20 @@ class census_money_income(Variable): "SSI income. The model lacks three sources Appendix A lists: Black " "lung payments, military family allotments and foreign government " "pensions. It has foster child payments only for Los Angeles County, " - "and no variable for interest earned inside retirement accounts, " - "which the survey has counted as interest since 2019." + "and no variable for interest credited inside retirement accounts, " + "which the survey counts as interest: Appendix A lists IRAs among " + "interest sources, and the survey's files give interest excluding " + "retirement accounts as a separate amount (TRDINT_VAL)." ) reference = ( # PDF pages 1-3 "https://www2.census.gov/programs-surveys/cps/methodology/Appendix%20A%20P60-200%20income%20measurement%20definition.pdf#page=1", "https://www.census.gov/programs-surveys/cps/technical-documentation/subject-definitions.html", - "https://cps.ipums.org/cps-action/variables/SSIKID", + # SSI received on behalf of a disabled or blind child (RESNSSI1 and + # RESNSSI2, codes 3 and 4). + "https://www2.census.gov/programs-surveys/cps/datasets/2026/march/asec2026_ddl_pub_full.pdf#page=51", + # Interest excluding retirement account interest (TRDINT_VAL). + "https://www2.census.gov/programs-surveys/cps/datasets/2026/march/asec2026_ddl_pub_full.pdf#page=57", ) def formula(person, period, parameters): diff --git a/policyengine_us/variables/household/income/person/general/census_retirement_distributions.py b/policyengine_us/variables/household/income/person/general/census_retirement_distributions.py index 15af382ed11..80ad3b1f60f 100644 --- a/policyengine_us/variables/household/income/person/general/census_retirement_distributions.py +++ b/policyengine_us/variables/household/income/person/general/census_retirement_distributions.py @@ -9,10 +9,11 @@ class census_retirement_distributions(Variable): definition_period = YEAR documentation = ( "Distributions from retirement accounts that the Census Bureau counts " - "as money income. Since the Current Population Survey's 2019 redesign, " - "which collects 2018 income, the survey leaves distributions to people " - "under 58 out of total income. Census applies the test to age at the " - "survey date; this variable uses age in the income year." + "as money income. Since the redesigned processing system the Current " + "Population Survey introduced in 2019, which covers 2018 income, the " + "survey leaves distributions to people under 58 out of total income. " + "Census applies the test to age at the survey date; this variable uses " + "age in the income year." ) reference = ( # Pages 57 and 58 define the distribution amounts by age. From b48213a14b5df92793996cac9aaf6fa761a0379b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 9 Nov 2026 14:34:54 -0500 Subject: [PATCH 7/7] Follow formula sums in the double-counting check and rule on the rest The second code review found that reachable() followed only `adds`, so a source listed beside one that reaches it through a formula counted twice with every test green: `ca_tanf` beside `tanf`, whose `tanf_if_takes_up` adds the 51 state TANF programs, doubled a California family's TANF in both concepts. - reachable() now also follows the five formulas that return a sum or share of other variables: tanf_if_takes_up and snap_if_takes_up (what they pass to add()), ia_ssa, other_net_gain_person and census_retirement_distributions. - A new test requires a ruling for every other formula inside a listed source that calls add, aggr or .sum, or reads by name a variable another listed source reaches. The 38 rulings in FORMULA_READS record what each formula uses those values for; another test fails a stale ruling. - The totals test's explicit example gives SPM units, tax units and families different members, so it fails a split by the wrong group on its own. - The reconciliation adds a household per state whose disabled person meets SSI's disability test, so SSI and the state supplements' disability categories pay; the household without it keeps Massachusetts EAEDC paying. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_income_concept_parity.py | 196 +++++++++++++++++- 1 file changed, 191 insertions(+), 5 deletions(-) diff --git a/policyengine_us/tests/test_income_concept_parity.py b/policyengine_us/tests/test_income_concept_parity.py index b873dfc5b1f..5e67fcd0b46 100644 --- a/policyengine_us/tests/test_income_concept_parity.py +++ b/policyengine_us/tests/test_income_concept_parity.py @@ -17,7 +17,12 @@ 2. No source counts twice: no list repeats a variable, no variable sits under two Census sources, the CBO categories are disjoint, CBO adds nothing household market income already holds, and neither concept counts a variable - beside another that reaches it through `adds`. + beside another that reaches it through `adds` or through a formula that + FORMULA_SUMS or FORMULA_PARTS follows. Every other formula inside a listed + source that calls add, aggr or .sum, or that reads by name a variable + another listed source reaches, has a ruling in FORMULA_READS. The check + reads formula text, so a formula that reaches another source through an + intermediate variable escapes it. 3. A dollar of a listed source adds a dollar to its concept. A person's Census money income gains the dollar, or an equal share of a dollar paid to a group, and the family and household totals gain the dollar. The CBO @@ -34,9 +39,12 @@ source at once, with Hypothesis over amounts and household sizes. """ +import ast import functools +import inspect import itertools import re +import textwrap from collections import Counter from pathlib import Path @@ -262,6 +270,73 @@ ), } +# Formulas that return what they pass to add(), which `adds` would otherwise +# show. reachable() follows the variables they add. +FORMULA_SUMS = {"snap_if_takes_up", "tanf_if_takes_up"} + +# Formulas that return another variable's value, a share of it, or it plus an +# amount of their own. reachable() follows those variables too. +FORMULA_PARTS = { + # Distributions from the age Census counts them (CENSUS_COUNTED_IN_PART). + "census_retirement_distributions": ("retirement_distributions",), + # The federally administered payment plus the amount the state pays. + "ia_ssa": ("ia_ssa_federal",), + # The head's or spouse's share of the tax unit's gain or loss. + "other_net_gain_person": ("other_net_gain",), +} + +# The other formulas inside a listed source that call add, aggr or .sum, or +# read by name a variable another listed source reaches. Each was read in +# full, and none adds what it sums or reads to its own value. +COUNTS = "counts people, such as eligible members or children" +SPLITS = "splits a couple's payment between the two" +INCOME_TEST = "sums income or resources for an income or resource test" +OFFSET = "subtracts SSI or other income from its payment standard" +RECEIPT = "tests whether the person receives SSI" +REMAINDER = "takes the labor supply response the employment part leaves" +FORMULA_READS = { + "ak_ssp": SPLITS, + "ar_tea": INCOME_TEST, + "ca_capi_person": INCOME_TEST, + "ca_la_infant_supplement": COUNTS, + "ca_state_supplement_person": OFFSET, + "ca_tanf": COUNTS, + "co_oap": OFFSET, + "co_state_supplement": OFFSET, + "co_tanf": INCOME_TEST, + "ct_tfa": INCOME_TEST, + "de_ssp": SPLITS, + "ia_ssa_federal": INCOME_TEST, + "in_ssp_rcap": OFFSET, + "in_ssp_sapn": OFFSET, + "ks_tanf": INCOME_TEST, + "la_oss": OFFSET, + "ma_state_supplement": SPLITS, + "md_paa": OFFSET, + "me_ssp": COUNTS, + "mn_msa_person": INCOME_TEST, + "mo_ssp": OFFSET, + "ne_adc": INCOME_TEST, + "ny_ssp": RECEIPT, + "oh_rss": INCOME_TEST, + "ok_ssp": COUNTS, + "or_ssp": INCOME_TEST, + "pa_ssp": RECEIPT, + "pa_tanf": INCOME_TEST, + "ri_ssp": RECEIPT, + "sc_ssi_state_supplement": OFFSET, + "sd_ssp_care": INCOME_TEST, + "sd_ssp_independent": INCOME_TEST, + "self_employment_income_behavioral_response": REMAINDER, + "sstb_self_employment_income_behavioral_response": REMAINDER, + "ut_dws_ssp": SPLITS, + "vt_aabd_ep": SPLITS, + "wi_ssi_e": SPLITS, + "wi_ssp_regular": COUNTS, +} +SUMS = re.compile(r"\b(?:add|aggr)\(|\.sum\(") +NAMED_READS = re.compile(r"""\(\s*["']([a-z][a-z0-9_]*)["']""") + # Sources that can be losses. LOSSES = { "self_employment_income", @@ -396,9 +471,44 @@ def entity_of(name): return system.variables[name].entity.key +@functools.cache +def formula_text(name): + """The source of a variable's formulas, empty for an input.""" + from policyengine_us.system import system + + if name not in system.variables: + return "" + formulas = system.variables[name].formulas or {} + return "\n".join(inspect.getsource(formula) for formula in formulas.values()) + + +@functools.cache +def added_by_formula(name): + """The variables a formula passes to add() or aggr().""" + from policyengine_us.system import system + + added = set() + for formula in system.variables[name].formulas.values(): + tree = ast.parse(textwrap.dedent(inspect.getsource(formula))) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if getattr(node.func, "id", None) not in ("add", "aggr"): + continue + argument = node.args[2] + if isinstance(argument, ast.Name): + # A list the formula's module defines, such as + # STATE_TANF_VARIABLES. + added |= set(formula.__globals__[argument.id]) + else: + added |= set(ast.literal_eval(argument)) + return frozenset(added) + + @functools.cache def reachable(name, year): - """A variable and every variable it reaches through `adds`.""" + """A variable and every variable it reaches through `adds`, or through a + formula that FORMULA_SUMS or FORMULA_PARTS follows.""" from policyengine_us.system import system # `adds` can also name a parameter, which is a leaf. @@ -408,6 +518,9 @@ def reachable(name, year): if isinstance(parts, str): node = functools.reduce(getattr, parts.split("."), system.parameters) parts = node(f"{year}-01-01") + parts = [*parts, *FORMULA_PARTS.get(name, ())] + if name in FORMULA_SUMS: + parts += sorted(added_by_formula(name)) found = {name} for part in parts: found |= reachable(part, year) @@ -421,6 +534,30 @@ def assert_none_sums_another(names, year): assert not both, f"{first} and {second} both count {sorted(both)}" +def summing_formulas(sources, year): + """Formulas inside the sources that call add, aggr or .sum, or read by name + a variable another of the sources reaches, with those variables.""" + closures = {source: reachable(source, year) for source in sources} + found = {} + for source, closure in closures.items(): + others = set().union(*(c for s, c in closures.items() if s != source)) + others -= closure + for name in closure: + text = formula_text(name) + shared = set(NAMED_READS.findall(text)) & others + if SUMS.search(text) or shared: + found.setdefault(name, set()).update(shared) + return found + + +def concept_sources(year): + """The sources each concept counts during a year.""" + return { + "Census": census_members(year), + "CBO": set().union(*cbo_categories(year).values()), + } + + def test_census_lists_follow_appendix_a(): files = {path.stem for path in CENSUS_LISTS.glob("*.yaml")} - {"index"} assert files == set(CENSUS_SOURCES) @@ -501,6 +638,42 @@ def test_no_source_counts_twice(year): assert market & benefit_terms(year) == set() +@pytest.mark.parametrize("year", YEARS) +def test_formulas_inside_listed_sources_have_a_ruling(year): + # The check above sees `adds` and the formulas it follows. A formula that + # sums, or reads a variable another listed source counts, could count that + # variable again, so each one inside a listed source needs a ruling. + reviewed = FORMULA_SUMS | set(FORMULA_PARTS) | set(FORMULA_READS) + for concept, sources in concept_sources(year).items(): + found = summing_formulas(sources, year) + unreviewed = {name: sorted(found[name]) for name in set(found) - reviewed} + assert unreviewed == {}, ( + f"{year}: these formulas inside {concept} sources call add, aggr or " + f".sum, or read a variable another source counts: {unreviewed}. If " + "one returns a sum or share of other variables, add it to " + "FORMULA_SUMS or FORMULA_PARTS, so the check for double counting " + "follows it. Otherwise add it to FORMULA_READS with what it uses " + "them for." + ) + + +def test_formula_rulings_are_current(): + found = set() + for year in YEARS: + for sources in concept_sources(year).values(): + found |= set(summing_formulas(sources, year)) + # A ruling on a formula that no longer sums or reads another source, or + # that no listed source reaches, is stale. + assert set(FORMULA_READS) - found == set(), "stale FORMULA_READS entries" + assert FORMULA_SUMS - found == set(), "stale FORMULA_SUMS entries" + for name in FORMULA_SUMS: + assert re.search(r"return add\(", formula_text(name)), name + assert added_by_formula(name), name + for name, parts in FORMULA_PARTS.items(): + for part in parts: + assert f'"{part}"' in formula_text(name), f"{name} no longer reads {part}" + + @pytest.mark.parametrize("year", YEARS) def test_census_classifies_every_source(year): counted = census_members(year) @@ -805,10 +978,12 @@ def members_by_group(names, assignment): infant_supplement=st.integers(min_value=0, max_value=10_000), state=st.sampled_from(STATES), ) +# The example's three groupings differ, so it fails a split by the wrong group +# on its own. @example( spm_unit_of=[0, 0, 1, 1, 1], tax_unit_of=[0, 1, 2, 2, 2], - family_of=[0, 0, 1, 1, 1], + family_of=[0, 0, 0, 1, 1], earnings=[20_000, 0, 9_000, 0, 0], veterans=[0, 0, 3_000, 0, 0], tanf=[1_000, 900, 0], @@ -905,8 +1080,10 @@ def test_census_money_income_reconciles_with_household_totals(year, earnings, ch reads the same lists as the variables, so it checks how the variables add them, not where a program belongs. - Each state has a working-age household with children, an aged person and a - disabled person, with rent, so that state cash and rent-tested programs pay. + Each state has a working-age household with children, an aged person, a + disabled person who does not meet SSI's disability test and one who does, + each with rent, so that SSI, the state supplements and the other state cash + and rent-tested programs pay. """ people, units = {}, {key: {} for key in ("tax_units", "spm_units", "families")} marital_units, households = {}, {} @@ -933,7 +1110,16 @@ def add_household(key, state, ages, income, **head_inputs): amount, ) add_household(f"{state}_aged", state, [70], 0) + # Massachusetts EAEDC pays a disabled person without SSI. add_household(f"{state}_disabled", state, [45], 0, is_disabled=True) + add_household( + f"{state}_ssi_disabled", + state, + [45], + 0, + is_disabled=True, + meets_ssi_disability_criteria=True, + ) simulation = Simulation( situation={ "people": people,