diff --git a/changelog.d/census-money-income.added.md b/changelog.d/census-money-income.added.md new file mode 100644 index 00000000000..33bc494c118 --- /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, 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/changelog.d/census-money-income.fixed.md b/changelog.d/census-money-income.fixed.md new file mode 100644 index 00000000000..33d3f35c93d --- /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 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_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_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_additions.yaml b/policyengine_us/parameters/gov/household/cbo_market_income_additions.yaml index 0a1b39b8297..c7ca2c354aa 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,36 @@ 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 + # 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 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_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/cbo_means_tested_transfers.yaml b/policyengine_us/parameters/gov/household/cbo_means_tested_transfers.yaml index f8081bc5a9d..2ea89b516d7 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,79 @@ values: - 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 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..19d9a9b6302 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; Part D premiums stay in. + - 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..0e9f928f54a --- /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: + 0000-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..c6d887e1d5c --- /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: + 0000-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..ec868d3c963 --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/disability_benefits.yaml @@ -0,0 +1,18 @@ +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. Railroad Retirement Board +# disability annuities sit in railroad_benefits, under pension or +# retirement income. +values: + 0000-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..d4537b78709 --- /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: + 0000-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..469418ac19b --- /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: + 0000-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..d9317282d44 --- /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: + 0000-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..a6075389734 --- /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: + 0000-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..4441887e6c8 --- /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: + 0000-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..b44e125d028 --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/other_income.yaml @@ -0,0 +1,26 @@ +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, and of foster payments only the two Los +# Angeles County payments below. +values: + 0000-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..c477aa5159a --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/pension_or_retirement_income.yaml @@ -0,0 +1,24 @@ +description: The Census Bureau counts these sources as pension or retirement income, source 10 of 18 in money income. +values: + 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, 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 + # survivor benefits; the model does not split that variable. + - 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 + - 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 new file mode 100644 index 00000000000..93f9b864b0f --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/public_assistance.yaml @@ -0,0 +1,20 @@ +description: The Census Bureau counts these sources as public assistance, source 6 of 18 in money income. +values: + 0000-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..f7fa9f9fc72 --- /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: + 0000-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..2f3f537ea7d --- /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: + 0000-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..fed027a8da0 --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/supplemental_security_income.yaml @@ -0,0 +1,105 @@ +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 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 + # 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..e4757364be3 --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_money_income_sources/survivor_benefits.yaml @@ -0,0 +1,17 @@ +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: + 0000-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 + 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..7c645e4c28f --- /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: + 0000-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..0d5c04d289e --- /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: + 0000-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..88a2f2dba0d --- /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: + 0000-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/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..b800540a4d4 --- /dev/null +++ b/policyengine_us/parameters/gov/household/census_retirement_distributions_minimum_age.yaml @@ -0,0 +1,18 @@ +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 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 + 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 d341088ddc2..95819e3c389 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,18 @@ 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 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") for line in raw_yaml_lines(text): violations.append(f"{path.relative_to(PACKAGE).as_posix()}:{line}") assert not violations, ( @@ -187,6 +205,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 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..a271e40c436 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/income/census_money_income.yaml @@ -0,0 +1,273 @@ +# 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 + # 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 + 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, military survivor annuities included. + survivor_benefits: 700 + military_retirement_pay_survivors: 300 + # 9. Disability benefits. + disability_benefits: 400 + # 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 + 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: 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 + 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: 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: + 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 + +# 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: + 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: For 2017 income, before the 2019 processing system, 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/policy/baseline/household/income/household/cbo_household_income.yaml b/policyengine_us/tests/policy/baseline/household/income/household/cbo_household_income.yaml index 07d9f4f1d8e..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 @@ -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,44 @@ 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 or educational assistance + 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 + 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 + $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: 110_742 diff --git a/policyengine_us/tests/test_aggregate_list_integrity.py b/policyengine_us/tests/test_aggregate_list_integrity.py index 2599fa64000..78a12776364 100644 --- a/policyengine_us/tests/test_aggregate_list_integrity.py +++ b/policyengine_us/tests/test_aggregate_list_integrity.py @@ -156,10 +156,26 @@ 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. 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", + *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( + "*.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..5e67fcd0b46 --- /dev/null +++ b/policyengine_us/tests/test_income_concept_parity.py @@ -0,0 +1,1148 @@ +"""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. + +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 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` 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 + 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 + 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 ast +import functools +import inspect +import itertools +import re +import textwrap +from collections import Counter +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 begins in 2022. +YEARS = range(2022, 2027) +TOLERANCE = 0.05 +# Census household money income adds about 100 float32 terms, so the +# reconciliation allows for their rounding. +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" +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 " + "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, + "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, + "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) +# 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", + "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 " + "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 " + "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, + "state_property_tax_refunds": 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 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 Census counts only in part, through the variable that applies the +# 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", +} + +# Sources CBO counts through the listed variable that sums them. +CBO_COUNTED_AS = { + "medicaid": ("medicaid_cost",), + "military_retirement_income": ( + "military_retirement_pay", + "military_retirement_pay_survivors", + ), +} + +# 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", + "sstb_self_employment_income", + "partnership_s_corp_income", + "farm_operations_income", + "farm_rent_income", + "rental_income", + "estate_income", +} + + +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) + 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): + 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): + return { + name: block(HOUSEHOLD_LISTS / f"cbo_{name}.yaml", year) + for name in CBO_LIST_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 + + +@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`, 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. + 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") + 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) + 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 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) + 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 + # 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(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() + + +def test_aliases_sum_exactly_their_parts(variables): + # A part added to one of these variables later would otherwise reach + # 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 + + +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) +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 + 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) + # 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}" + 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. + 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) + 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 + 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()) + 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 + 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(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" + aliased = set(CBO_COUNTED_AS) | set().union(*CBO_COUNTED_AS.values()) + assert aliased - every_year == set(), "stale CBO aliases" + + +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 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 = { + "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): + 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} + 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): + 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 + + +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) + + +@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=CENSUS_CASE_COUNT, + max_size=CENSUS_CASE_COUNT, + ), + losses=st.booleans(), +) +@example( + size=3, + amounts=[1_000 + index for index in range(CENSUS_CASE_COUNT)], + losses=True, +) +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(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, 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): + 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 == [] + + +@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 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. + 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) + + +@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=CBO_CASE_COUNT, + max_size=CBO_CASE_COUNT, + ), +) +@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(year), amounts) + ] + 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, year) for total in incomes} + market = values_by_id(simulation, "household_market_income", year) + + 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 == [] + + +@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(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, 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( + "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)], 1, year, earnings=500_000 + ) + 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, year), 0) == pytest.approx( + expected, abs=TOLERANCE + ), total + + +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 + + +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( + 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), +) +# 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, 0, 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( + year, + spm_unit_of, + tax_unit_of, + family_of, + earnings, + veterans, + tanf, + basic_income, + infant_supplement, + state, +): + # 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}, + "employment_income": {year: earnings[index]}, + "veterans_benefits": {year: veterans[index]}, + } + for index, name in enumerate(names) + } + simulation = Simulation( + situation={ + "people": people, + "tax_units": { + 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() + }, + "families": { + 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}, + "ca_la_infant_supplement": {year: infant_supplement}, + } + }, + } + ) + 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) + 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 + ) + + +@pytest.mark.parametrize("year", YEARS) +@settings(max_examples=1, 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(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. 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, 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 = {}, {} + + 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]].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) + # 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, + **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..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 @@ -8,10 +8,13 @@ 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 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 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..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, gross Medicare benefits, 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/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..6549528c2fe --- /dev/null +++ b/policyengine_us/variables/household/income/person/general/census_money_income.py @@ -0,0 +1,64 @@ +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 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 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 " + "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. It has foster child payments only for Los Angeles County, " + "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", + # 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): + 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 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..80ad3b1f60f --- /dev/null +++ b/policyengine_us/variables/household/income/person/general/census_retirement_distributions.py @@ -0,0 +1,29 @@ +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 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. + "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)