From 430c6e93f5f6b4acd3c916424b4f5bef20702ca9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 9 Nov 2026 06:42:25 -0500 Subject: [PATCH 1/7] Count railroad retirement benefits as income in state benefit programs State benefit programs that list Social Security as income often leave out Railroad Retirement Act benefits, whose tier 1 share replaces Social Security for railroad workers. This adds `railroad_benefits` to 50 income lists and to Missouri's Supplemental Nursing Care income variable, where the program's rule names railroad retirement, counts pensions, annuities or retirement benefits, or counts all income it doesn't exclude. Each program's own rule was read against the sources the parameter cites. Tests: a property test checks that railroad benefits raise each program's income by the amount received, and a YAML case pins each program whose rule names railroad retirement. Co-Authored-By: Claude Opus 5.5 --- changelog.d/state-benefit-railroad.fixed.md | 1 + .../countable_income/unearned_sources.yaml | 1 + .../ccsp/income/countable_income/sources.yaml | 1 + .../states/ar/ade/oec/sra/income/sources.yaml | 1 + .../ca/cdss/child_care/income/sources.yaml | 1 + .../cash/income/sources/other_unearned.yaml | 1 + .../gov/states/ca/cpuc/income_sources.yaml | 2 + .../ccap/income/countable_income/sources.yaml | 1 + .../states/co/cdhs/tanf/income/unearned.yaml | 1 + .../ct/oec/c4k/income/unearned_sources.yaml | 1 + .../ccsp/income/countable_income/sources.yaml | 1 + .../dc/dhs/tanf/income/sources/unearned.yaml | 1 + .../poc/income/countable_income/sources.yaml | 1 + .../gov/states/fl/doe/sr/income/sources.yaml | 1 + .../caps/income/countable_income/sources.yaml | 1 + .../id/dhw/iccp/income/unearned_sources.yaml | 1 + .../il/dhs/aabd/income/sources/unearned.yaml | 1 + .../il/dhs/tanf/income/sources/unearned.yaml | 1 + .../eligibility/income/sources/unearned.yaml | 1 + .../il/isbe/income/countable_sources.yaml | 1 + .../income/countable_income/unearned.yaml | 1 + .../dcf/liheap/income/sources/unearned.yaml | 1 + .../ccap/income/countable_income/sources.yaml | 1 + .../ma/dta/tcap/gross_income/unearned.yaml | 1 + .../ccs/income/countable_income/sources.yaml | 1 + .../ccap/income/countable_income/sources.yaml | 1 + .../me/dhhs/tanf/income/sources/unearned.yaml | 1 + .../ccap/income/countable_income/sources.yaml | 1 + .../income/countable_income/unearned.yaml | 1 + .../mn/dcyf/mfip/income/sources/unearned.yaml | 1 + .../ccs/income/countable_income/sources.yaml | 1 + .../countable_income/unearned_sources.yaml | 1 + .../mt/dhs/tanf/income/sources/unearned.yaml | 1 + .../ncdhhs/lieap/unearned_income_sources.yaml | 1 + .../nc/ncdhhs/tanf/income/unearned.yaml | 1 + .../nd/dhs/tanf/income/sources/unearned.yaml | 1 + .../ccap/income/countable_income/sources.yaml | 1 + .../dca/liheap/income/sources/unearned.yaml | 1 + .../countable_income/unearned_sources.yaml | 1 + .../ccdp/income/countable_income/sources.yaml | 1 + .../gov/states/ok/dhs/ccs/income/sources.yaml | 1 + .../ccap/income/countable_income/sources.yaml | 1 + .../ccap/income/countable_income/sources.yaml | 1 + .../tx/fpp/income/sources/unearned.yaml | 1 + .../states/tx/tdhca/ceap/income/sources.yaml | 1 + .../countable_income/unearned_sources.yaml | 1 + .../states/vt/dcf/ccfap/income/sources.yaml | 1 + .../gov/states/wa/dcyf/wccc/sources.yaml | 1 + .../states/wi/dcf/shares/income/sources.yaml | 1 + .../ccap/income/countable_income/sources.yaml | 1 + .../wy/dfs/power/income/sources/unearned.yaml | 1 + .../income/ca_tanf_other_unearned_income.yaml | 9 + .../co/ccap/co_ccap_countable_income.yaml | 9 + ..._tanf_countable_gross_unearned_income.yaml | 10 + .../dc/dhs/ccsp/dc_ccsp_countable_income.yaml | 9 + .../de/dss/poc/de_poc_countable_income.yaml | 9 + .../dhhs/ccap/me_ccap_countable_income.yaml | 9 + .../mdhhs/ccap/mi_ccap_countable_income.yaml | 9 + .../lieap/nc_lieap_gross_income_person.yaml | 9 + ..._tanf_countable_gross_unearned_income.yaml | 10 + .../tanf/nd_tanf_gross_unearned_income.yaml | 10 + .../sc/dss/ccap/sc_ccap_countable_income.yaml | 10 + .../power/wy_power_gross_unearned_income.yaml | 10 + .../test_state_benefit_railroad_income.py | 174 ++++++++++++++++++ .../mo/dss/ssp/mo_snc_countable_income.py | 2 +- 65 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 changelog.d/state-benefit-railroad.fixed.md create mode 100644 policyengine_us/tests/policy/baseline/gov/states/ca/cdss/tanf/cash/income/ca_tanf_other_unearned_income.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/co/ccap/co_ccap_countable_income.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/dc/dhs/ccsp/dc_ccsp_countable_income.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/de/dss/poc/de_poc_countable_income.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/me/dhhs/ccap/me_ccap_countable_income.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/mi/mdhhs/ccap/mi_ccap_countable_income.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/lieap/nc_lieap_gross_income_person.yaml create mode 100644 policyengine_us/tests/test_state_benefit_railroad_income.py diff --git a/changelog.d/state-benefit-railroad.fixed.md b/changelog.d/state-benefit-railroad.fixed.md new file mode 100644 index 00000000000..50983da7464 --- /dev/null +++ b/changelog.d/state-benefit-railroad.fixed.md @@ -0,0 +1 @@ +Count Railroad Retirement Act benefits as income in the state benefit programs whose rules name them, count pensions or retirement benefits, or count all income they do not exclude. diff --git a/policyengine_us/parameters/gov/states/ak/dpa/ccap/income/countable_income/unearned_sources.yaml b/policyengine_us/parameters/gov/states/ak/dpa/ccap/income/countable_income/unearned_sources.yaml index 61649b1c299..d4143ee3c5f 100644 --- a/policyengine_us/parameters/gov/states/ak/dpa/ccap/income/countable_income/unearned_sources.yaml +++ b/policyengine_us/parameters/gov/states/ak/dpa/ccap/income/countable_income/unearned_sources.yaml @@ -10,6 +10,7 @@ values: - child_support_received - alimony_income - social_security + - railroad_benefits - ssi - total_unemployment_compensation - veterans_benefits diff --git a/policyengine_us/parameters/gov/states/al/dhr/ccsp/income/countable_income/sources.yaml b/policyengine_us/parameters/gov/states/al/dhr/ccsp/income/countable_income/sources.yaml index 93102a0af62..b029d4aa5e4 100644 --- a/policyengine_us/parameters/gov/states/al/dhr/ccsp/income/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/states/al/dhr/ccsp/income/countable_income/sources.yaml @@ -5,6 +5,7 @@ values: - self_employment_income - farm_operations_income - social_security + - railroad_benefits - pension_income - total_unemployment_compensation - workers_compensation diff --git a/policyengine_us/parameters/gov/states/ar/ade/oec/sra/income/sources.yaml b/policyengine_us/parameters/gov/states/ar/ade/oec/sra/income/sources.yaml index c2180e44853..603210f150b 100644 --- a/policyengine_us/parameters/gov/states/ar/ade/oec/sra/income/sources.yaml +++ b/policyengine_us/parameters/gov/states/ar/ade/oec/sra/income/sources.yaml @@ -4,6 +4,7 @@ values: - employment_income - self_employment_income - social_security_retirement + - railroad_benefits - social_security_disability - social_security_survivors - ssi diff --git a/policyengine_us/parameters/gov/states/ca/cdss/child_care/income/sources.yaml b/policyengine_us/parameters/gov/states/ca/cdss/child_care/income/sources.yaml index 9810ac7b91f..9980f106625 100644 --- a/policyengine_us/parameters/gov/states/ca/cdss/child_care/income/sources.yaml +++ b/policyengine_us/parameters/gov/states/ca/cdss/child_care/income/sources.yaml @@ -14,6 +14,7 @@ values: - ca_state_disability_insurance # Social Security (excluding SSI) - social_security + - railroad_benefits # Support payments - child_support_received - alimony_income diff --git a/policyengine_us/parameters/gov/states/ca/cdss/tanf/cash/income/sources/other_unearned.yaml b/policyengine_us/parameters/gov/states/ca/cdss/tanf/cash/income/sources/other_unearned.yaml index 9d743b0eaca..a841d8eba9c 100644 --- a/policyengine_us/parameters/gov/states/ca/cdss/tanf/cash/income/sources/other_unearned.yaml +++ b/policyengine_us/parameters/gov/states/ca/cdss/tanf/cash/income/sources/other_unearned.yaml @@ -11,6 +11,7 @@ metadata: values: 2017-12-20: - social_security_retirement + - railroad_benefits - social_security_survivors - social_security_dependents - interest_income diff --git a/policyengine_us/parameters/gov/states/ca/cpuc/income_sources.yaml b/policyengine_us/parameters/gov/states/ca/cpuc/income_sources.yaml index ba8834c02ad..80fad20f407 100644 --- a/policyengine_us/parameters/gov/states/ca/cpuc/income_sources.yaml +++ b/policyengine_us/parameters/gov/states/ca/cpuc/income_sources.yaml @@ -76,6 +76,7 @@ values: - wic # Social Security - social_security + - railroad_benefits # Pensions - pension_income - retirement_distributions @@ -118,6 +119,7 @@ values: - wic # Social Security - social_security + - railroad_benefits # Pensions - pension_income - retirement_distributions diff --git a/policyengine_us/parameters/gov/states/co/ccap/income/countable_income/sources.yaml b/policyengine_us/parameters/gov/states/co/ccap/income/countable_income/sources.yaml index a8fa26f227f..1cb6ca8e8d7 100644 --- a/policyengine_us/parameters/gov/states/co/ccap/income/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/states/co/ccap/income/countable_income/sources.yaml @@ -8,6 +8,7 @@ values: - farm_operations_income # Unearned income - social_security + - railroad_benefits - pension_income - retirement_distributions - military_retirement_income diff --git a/policyengine_us/parameters/gov/states/co/cdhs/tanf/income/unearned.yaml b/policyengine_us/parameters/gov/states/co/cdhs/tanf/income/unearned.yaml index b027d8b0036..3dc845d35ed 100644 --- a/policyengine_us/parameters/gov/states/co/cdhs/tanf/income/unearned.yaml +++ b/policyengine_us/parameters/gov/states/co/cdhs/tanf/income/unearned.yaml @@ -11,6 +11,7 @@ values: - total_unemployment_compensation - gi_cash_assistance - social_security + - railroad_benefits - workers_compensation - disability_benefits # general_assistance is exempt under 9 CCR 2503-6, Section 3.605.4(A)(7) diff --git a/policyengine_us/parameters/gov/states/ct/oec/c4k/income/unearned_sources.yaml b/policyengine_us/parameters/gov/states/ct/oec/c4k/income/unearned_sources.yaml index d23fc3299bc..9750d827536 100644 --- a/policyengine_us/parameters/gov/states/ct/oec/c4k/income/unearned_sources.yaml +++ b/policyengine_us/parameters/gov/states/ct/oec/c4k/income/unearned_sources.yaml @@ -2,6 +2,7 @@ description: Connecticut counts these unearned income sources as countable incom values: 2020-01-01: - social_security + - railroad_benefits - pension_income - total_unemployment_compensation - workers_compensation diff --git a/policyengine_us/parameters/gov/states/dc/dhs/ccsp/income/countable_income/sources.yaml b/policyengine_us/parameters/gov/states/dc/dhs/ccsp/income/countable_income/sources.yaml index a8b09a66f28..8fa71d6c96f 100644 --- a/policyengine_us/parameters/gov/states/dc/dhs/ccsp/income/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/states/dc/dhs/ccsp/income/countable_income/sources.yaml @@ -8,6 +8,7 @@ values: - interest_income - rental_income - pension_income + - railroad_benefits - veterans_benefits - military_retirement_income - alimony_income diff --git a/policyengine_us/parameters/gov/states/dc/dhs/tanf/income/sources/unearned.yaml b/policyengine_us/parameters/gov/states/dc/dhs/tanf/income/sources/unearned.yaml index 97c6a8abfc0..e005badfbb0 100644 --- a/policyengine_us/parameters/gov/states/dc/dhs/tanf/income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/states/dc/dhs/tanf/income/sources/unearned.yaml @@ -14,6 +14,7 @@ values: # D.C. Code 4-205.05(4) counts District and federal public assistance payments - general_assistance - social_security + - railroad_benefits metadata: unit: list diff --git a/policyengine_us/parameters/gov/states/de/dss/poc/income/countable_income/sources.yaml b/policyengine_us/parameters/gov/states/de/dss/poc/income/countable_income/sources.yaml index 5bbedf4a4e6..efd73cae29d 100644 --- a/policyengine_us/parameters/gov/states/de/dss/poc/income/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/states/de/dss/poc/income/countable_income/sources.yaml @@ -5,6 +5,7 @@ values: - self_employment_income - sstb_self_employment_income - social_security_retirement + - railroad_benefits - social_security_disability - social_security_survivors - ssi diff --git a/policyengine_us/parameters/gov/states/fl/doe/sr/income/sources.yaml b/policyengine_us/parameters/gov/states/fl/doe/sr/income/sources.yaml index 4e98aaf153a..d3eccb3057e 100644 --- a/policyengine_us/parameters/gov/states/fl/doe/sr/income/sources.yaml +++ b/policyengine_us/parameters/gov/states/fl/doe/sr/income/sources.yaml @@ -12,6 +12,7 @@ values: - farm_income - tip_income - social_security_retirement + - railroad_benefits - social_security_disability - social_security_survivors - ssi diff --git a/policyengine_us/parameters/gov/states/ga/decal/caps/income/countable_income/sources.yaml b/policyengine_us/parameters/gov/states/ga/decal/caps/income/countable_income/sources.yaml index 8eaf3acc5c7..c417596d787 100644 --- a/policyengine_us/parameters/gov/states/ga/decal/caps/income/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/states/ga/decal/caps/income/countable_income/sources.yaml @@ -13,6 +13,7 @@ values: - dividend_income - pension_income - social_security_retirement + - railroad_benefits - military_basic_pay # The following are counted per CAPS Policy Manual §8.4 but have # no matching variables: diff --git a/policyengine_us/parameters/gov/states/id/dhw/iccp/income/unearned_sources.yaml b/policyengine_us/parameters/gov/states/id/dhw/iccp/income/unearned_sources.yaml index 7880071102a..09d4c644681 100644 --- a/policyengine_us/parameters/gov/states/id/dhw/iccp/income/unearned_sources.yaml +++ b/policyengine_us/parameters/gov/states/id/dhw/iccp/income/unearned_sources.yaml @@ -3,6 +3,7 @@ values: 2022-03-17: # social_security already includes retirement, disability, survivors, and dependents components. - social_security + - railroad_benefits - ssi - pension_income - military_retirement_income diff --git a/policyengine_us/parameters/gov/states/il/dhs/aabd/income/sources/unearned.yaml b/policyengine_us/parameters/gov/states/il/dhs/aabd/income/sources/unearned.yaml index c6b56325250..83b5caa1c0a 100644 --- a/policyengine_us/parameters/gov/states/il/dhs/aabd/income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/states/il/dhs/aabd/income/sources/unearned.yaml @@ -3,6 +3,7 @@ values: 2021-01-01: #- pension_income # exempt - social_security + - railroad_benefits - ssi - disability_benefits #- veterans_benefits # exempt diff --git a/policyengine_us/parameters/gov/states/il/dhs/tanf/income/sources/unearned.yaml b/policyengine_us/parameters/gov/states/il/dhs/tanf/income/sources/unearned.yaml index 6001fca05d5..067e5a629b0 100644 --- a/policyengine_us/parameters/gov/states/il/dhs/tanf/income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/states/il/dhs/tanf/income/sources/unearned.yaml @@ -2,6 +2,7 @@ description: Illinois counts these income sources as unearned income under the T values: 1997-07-01: - social_security + - railroad_benefits - disability_benefits - workers_compensation - total_unemployment_compensation diff --git a/policyengine_us/parameters/gov/states/il/hfs/hbwd/eligibility/income/sources/unearned.yaml b/policyengine_us/parameters/gov/states/il/hfs/hbwd/eligibility/income/sources/unearned.yaml index c284e580cd6..4ab9f97f306 100644 --- a/policyengine_us/parameters/gov/states/il/hfs/hbwd/eligibility/income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/states/il/hfs/hbwd/eligibility/income/sources/unearned.yaml @@ -2,6 +2,7 @@ description: Illinois counts these income sources as unearned income under the H values: 2022-04-01: - social_security + - railroad_benefits - disability_benefits - workers_compensation - total_unemployment_compensation diff --git a/policyengine_us/parameters/gov/states/il/isbe/income/countable_sources.yaml b/policyengine_us/parameters/gov/states/il/isbe/income/countable_sources.yaml index 1396bad9071..25161d98b13 100644 --- a/policyengine_us/parameters/gov/states/il/isbe/income/countable_sources.yaml +++ b/policyengine_us/parameters/gov/states/il/isbe/income/countable_sources.yaml @@ -11,6 +11,7 @@ values: - pension_income - retirement_distributions - social_security + - railroad_benefits - ssi - veterans_benefits - disability_benefits diff --git a/policyengine_us/parameters/gov/states/ks/dcf/ccap/income/countable_income/unearned.yaml b/policyengine_us/parameters/gov/states/ks/dcf/ccap/income/countable_income/unearned.yaml index b1ff628638e..6af0d2557fd 100644 --- a/policyengine_us/parameters/gov/states/ks/dcf/ccap/income/countable_income/unearned.yaml +++ b/policyengine_us/parameters/gov/states/ks/dcf/ccap/income/countable_income/unearned.yaml @@ -3,6 +3,7 @@ values: 2021-07-01: # Unearned income (KEESM 6200): benefits, pensions, settlements. - social_security + - railroad_benefits - total_unemployment_compensation - workers_compensation - pension_income diff --git a/policyengine_us/parameters/gov/states/ks/dcf/liheap/income/sources/unearned.yaml b/policyengine_us/parameters/gov/states/ks/dcf/liheap/income/sources/unearned.yaml index 743f086f04f..f50e17a6df7 100644 --- a/policyengine_us/parameters/gov/states/ks/dcf/liheap/income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/states/ks/dcf/liheap/income/sources/unearned.yaml @@ -5,6 +5,7 @@ values: - strike_benefits # SSA benefits are counted gross, including the Medicare deduction. - social_security + - railroad_benefits - ssi # Military retirement income (retiree pay plus Survivor Benefit Plan # annuities) is a separate input from the pension inputs that diff --git a/policyengine_us/parameters/gov/states/ky/dcbs/ccap/income/countable_income/sources.yaml b/policyengine_us/parameters/gov/states/ky/dcbs/ccap/income/countable_income/sources.yaml index f1783127c9e..37f9b73c754 100644 --- a/policyengine_us/parameters/gov/states/ky/dcbs/ccap/income/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/states/ky/dcbs/ccap/income/countable_income/sources.yaml @@ -5,6 +5,7 @@ values: - self_employment_income - farm_operations_income - social_security + - railroad_benefits - total_unemployment_compensation - workers_compensation - pension_income diff --git a/policyengine_us/parameters/gov/states/ma/dta/tcap/gross_income/unearned.yaml b/policyengine_us/parameters/gov/states/ma/dta/tcap/gross_income/unearned.yaml index 9ece76780b1..4954e9b3241 100644 --- a/policyengine_us/parameters/gov/states/ma/dta/tcap/gross_income/unearned.yaml +++ b/policyengine_us/parameters/gov/states/ma/dta/tcap/gross_income/unearned.yaml @@ -4,6 +4,7 @@ values: - dividend_income - interest_income - social_security + - railroad_benefits - total_unemployment_compensation - veterans_benefits - pension_income diff --git a/policyengine_us/parameters/gov/states/md/msde/ccs/income/countable_income/sources.yaml b/policyengine_us/parameters/gov/states/md/msde/ccs/income/countable_income/sources.yaml index f6da5365f41..737d2579b0d 100644 --- a/policyengine_us/parameters/gov/states/md/msde/ccs/income/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/states/md/msde/ccs/income/countable_income/sources.yaml @@ -5,6 +5,7 @@ values: # Note: self_employment_income is handled separately in the formula # with a 30% deduction per COMAR 13A.14.06.03F(8)(a)(i) - social_security + - railroad_benefits - pension_income - interest_income - dividend_income diff --git a/policyengine_us/parameters/gov/states/me/dhhs/ccap/income/countable_income/sources.yaml b/policyengine_us/parameters/gov/states/me/dhhs/ccap/income/countable_income/sources.yaml index 29dcb229d3f..a8556d1bd11 100644 --- a/policyengine_us/parameters/gov/states/me/dhhs/ccap/income/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/states/me/dhhs/ccap/income/countable_income/sources.yaml @@ -9,6 +9,7 @@ values: - rental_income # Unearned income (Rule §2.C.4) - social_security + - railroad_benefits - pension_income - retirement_distributions - disability_benefits diff --git a/policyengine_us/parameters/gov/states/me/dhhs/tanf/income/sources/unearned.yaml b/policyengine_us/parameters/gov/states/me/dhhs/tanf/income/sources/unearned.yaml index 26af4399f38..722caefb0c2 100644 --- a/policyengine_us/parameters/gov/states/me/dhhs/tanf/income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/states/me/dhhs/tanf/income/sources/unearned.yaml @@ -18,6 +18,7 @@ values: - total_unemployment_compensation - gi_cash_assistance - social_security + - railroad_benefits # Chapter III, Section B(2)(d) excludes General Assistance, so # general_assistance is not counted diff --git a/policyengine_us/parameters/gov/states/mi/mdhhs/ccap/income/countable_income/sources.yaml b/policyengine_us/parameters/gov/states/mi/mdhhs/ccap/income/countable_income/sources.yaml index 6a75be94507..7cd0944fc89 100644 --- a/policyengine_us/parameters/gov/states/mi/mdhhs/ccap/income/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/states/mi/mdhhs/ccap/income/countable_income/sources.yaml @@ -5,6 +5,7 @@ values: - self_employment_income - sstb_self_employment_income - social_security + - railroad_benefits # SSI (public assistance) is not counted: State Plan FFY 2025-2027 §2.2.4.c # leaves "public assistance" unchecked. SSI recipients are income-waived # anyway (see mi_ccap_income_waived), so this never affects the income test. diff --git a/policyengine_us/parameters/gov/states/mn/dcyf/ccap/income/countable_income/unearned.yaml b/policyengine_us/parameters/gov/states/mn/dcyf/ccap/income/countable_income/unearned.yaml index 11978348baa..f5db0961994 100644 --- a/policyengine_us/parameters/gov/states/mn/dcyf/ccap/income/countable_income/unearned.yaml +++ b/policyengine_us/parameters/gov/states/mn/dcyf/ccap/income/countable_income/unearned.yaml @@ -8,6 +8,7 @@ values: - dividend_income - interest_income - pension_income + - railroad_benefits - veterans_benefits # Military retirement pay and Survivor Benefit Plan annuities count as # retirement benefits under 256P.06, subd. 3(2)(ix), which has no income diff --git a/policyengine_us/parameters/gov/states/mn/dcyf/mfip/income/sources/unearned.yaml b/policyengine_us/parameters/gov/states/mn/dcyf/mfip/income/sources/unearned.yaml index 084172e982f..db5829bc1e1 100644 --- a/policyengine_us/parameters/gov/states/mn/dcyf/mfip/income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/states/mn/dcyf/mfip/income/sources/unearned.yaml @@ -16,6 +16,7 @@ values: - workers_compensation - gi_cash_assistance - social_security + - railroad_benefits # MFIP counts GA, RCA and cash benefits already issued (0017.12.03). # Receipt-of-other-assistance rules in 0011.21 are separate eligibility # rules; this input represents cash received by an assistance-unit member. diff --git a/policyengine_us/parameters/gov/states/mo/dese/ccs/income/countable_income/sources.yaml b/policyengine_us/parameters/gov/states/mo/dese/ccs/income/countable_income/sources.yaml index aa8be293026..6a24f42b200 100644 --- a/policyengine_us/parameters/gov/states/mo/dese/ccs/income/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/states/mo/dese/ccs/income/countable_income/sources.yaml @@ -7,6 +7,7 @@ values: # Regular monthly survivor / RSDI benefits are counted; only death and # burial lump sums are excluded (Manual sec. 5.6.1). - social_security + - railroad_benefits - pension_income # PolicyEngine records military retirement separately from pension_income. - military_retirement_income diff --git a/policyengine_us/parameters/gov/states/ms/dhs/ccpp/income/countable_income/unearned_sources.yaml b/policyengine_us/parameters/gov/states/ms/dhs/ccpp/income/countable_income/unearned_sources.yaml index b6fb36f70df..3e00711066f 100644 --- a/policyengine_us/parameters/gov/states/ms/dhs/ccpp/income/countable_income/unearned_sources.yaml +++ b/policyengine_us/parameters/gov/states/ms/dhs/ccpp/income/countable_income/unearned_sources.yaml @@ -2,6 +2,7 @@ description: Mississippi counts these gross unearned income sources for eligibil values: 2021-11-01: - social_security # SSA benefits for parent and child (umbrella; do not also add social_security_disability) + - railroad_benefits - ssi # SSI for parent and child (separate from the social_security umbrella) - total_unemployment_compensation # counted at redetermination only; simplified to always countable - workers_compensation diff --git a/policyengine_us/parameters/gov/states/mt/dhs/tanf/income/sources/unearned.yaml b/policyengine_us/parameters/gov/states/mt/dhs/tanf/income/sources/unearned.yaml index bb23422452f..15bbfec3e0f 100644 --- a/policyengine_us/parameters/gov/states/mt/dhs/tanf/income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/states/mt/dhs/tanf/income/sources/unearned.yaml @@ -10,6 +10,7 @@ values: - rental_income - total_unemployment_compensation - social_security + - railroad_benefits - veterans_benefits - workers_compensation # TANF 501-1 (Assistance Programs) and Mont. Admin. r. 37.78.402 count diff --git a/policyengine_us/parameters/gov/states/nc/ncdhhs/lieap/unearned_income_sources.yaml b/policyengine_us/parameters/gov/states/nc/ncdhhs/lieap/unearned_income_sources.yaml index 2be2907868d..558d329b52b 100644 --- a/policyengine_us/parameters/gov/states/nc/ncdhhs/lieap/unearned_income_sources.yaml +++ b/policyengine_us/parameters/gov/states/nc/ncdhhs/lieap/unearned_income_sources.yaml @@ -10,6 +10,7 @@ values: # "Social Security Benefits" (page 20). The plan checks "Including # MediCare deduction", so the benefit is counted gross. - social_security + - railroad_benefits # "Special Assistance (SA)" (page 20): facility SA, SA In-Home and SA for # the Certain Disabled. SA-5400 II also counts the SA In-Home payment for # Energy Programs. diff --git a/policyengine_us/parameters/gov/states/nc/ncdhhs/tanf/income/unearned.yaml b/policyengine_us/parameters/gov/states/nc/ncdhhs/tanf/income/unearned.yaml index 46e17577708..363beb2b3a2 100644 --- a/policyengine_us/parameters/gov/states/nc/ncdhhs/tanf/income/unearned.yaml +++ b/policyengine_us/parameters/gov/states/nc/ncdhhs/tanf/income/unearned.yaml @@ -12,6 +12,7 @@ values: - total_unemployment_compensation - gi_cash_assistance - social_security + - railroad_benefits # general_assistance is not counted per the WF-114 income chart, which # starts on the referenced page 6; the General Assistance row is on page 11 metadata: diff --git a/policyengine_us/parameters/gov/states/nd/dhs/tanf/income/sources/unearned.yaml b/policyengine_us/parameters/gov/states/nd/dhs/tanf/income/sources/unearned.yaml index e817b97e75b..363dad8af2f 100644 --- a/policyengine_us/parameters/gov/states/nd/dhs/tanf/income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/states/nd/dhs/tanf/income/sources/unearned.yaml @@ -16,6 +16,7 @@ values: - workers_compensation - gi_cash_assistance - social_security + - railroad_benefits # Direct GA cash is countable. Section 400-19-55-25 separately excludes # GA reimbursements, third-party payments and complementary assistance # for needs not covered by TANF; those are not represented by this input. diff --git a/policyengine_us/parameters/gov/states/nh/dhhs/ccap/income/countable_income/sources.yaml b/policyengine_us/parameters/gov/states/nh/dhhs/ccap/income/countable_income/sources.yaml index 0aa0187bb20..5a13442f076 100644 --- a/policyengine_us/parameters/gov/states/nh/dhhs/ccap/income/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/states/nh/dhhs/ccap/income/countable_income/sources.yaml @@ -8,6 +8,7 @@ values: - farm_operations_income # Unearned income - social_security + - railroad_benefits - pension_income - retirement_distributions - total_unemployment_compensation diff --git a/policyengine_us/parameters/gov/states/nj/dca/liheap/income/sources/unearned.yaml b/policyengine_us/parameters/gov/states/nj/dca/liheap/income/sources/unearned.yaml index 368a146692a..d003d5dc645 100644 --- a/policyengine_us/parameters/gov/states/nj/dca/liheap/income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/states/nj/dca/liheap/income/sources/unearned.yaml @@ -9,6 +9,7 @@ values: - strike_benefits - ssi - pension_income + - railroad_benefits # Military retirement income (retiree pay plus Survivor Benefit Plan # annuities) is a separate input from the pension inputs that pension_income # adds; a household reports the amount in one or the other. Handbook 2.3.D diff --git a/policyengine_us/parameters/gov/states/nj/njdhs/ccap/income/countable_income/unearned_sources.yaml b/policyengine_us/parameters/gov/states/nj/njdhs/ccap/income/countable_income/unearned_sources.yaml index e9288765265..6929eff68ca 100644 --- a/policyengine_us/parameters/gov/states/nj/njdhs/ccap/income/countable_income/unearned_sources.yaml +++ b/policyengine_us/parameters/gov/states/nj/njdhs/ccap/income/countable_income/unearned_sources.yaml @@ -2,6 +2,7 @@ description: New Jersey counts these unearned income sources under the Child Car values: 2024-01-01: - social_security_retirement + - railroad_benefits - social_security_disability - social_security_survivors - ssi diff --git a/policyengine_us/parameters/gov/states/nv/dwss/ccdp/income/countable_income/sources.yaml b/policyengine_us/parameters/gov/states/nv/dwss/ccdp/income/countable_income/sources.yaml index 94dbafb5cae..d67b8ea8898 100644 --- a/policyengine_us/parameters/gov/states/nv/dwss/ccdp/income/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/states/nv/dwss/ccdp/income/countable_income/sources.yaml @@ -10,6 +10,7 @@ values: - farm_operations_income # Unearned income (countable per MS 306). - social_security # MS 306.34 RSDI umbrella (Retirement, Survivors, Disability) + - railroad_benefits - disability_benefits # MS 306.9 Disability Insurance Benefits (employer/private disability; distinct from RSDI 306.34 and workers' comp 306.49) - total_unemployment_compensation # MS 306.45 - workers_compensation # MS 306.49 diff --git a/policyengine_us/parameters/gov/states/ok/dhs/ccs/income/sources.yaml b/policyengine_us/parameters/gov/states/ok/dhs/ccs/income/sources.yaml index 6cb829c7804..d67fec6338f 100644 --- a/policyengine_us/parameters/gov/states/ok/dhs/ccs/income/sources.yaml +++ b/policyengine_us/parameters/gov/states/ok/dhs/ccs/income/sources.yaml @@ -14,6 +14,7 @@ values: # social_security already includes the disability, retirement, survivors, # and dependents components. - social_security + - railroad_benefits - total_unemployment_compensation - workers_compensation - child_support_received diff --git a/policyengine_us/parameters/gov/states/ri/dhs/ccap/income/countable_income/sources.yaml b/policyengine_us/parameters/gov/states/ri/dhs/ccap/income/countable_income/sources.yaml index c35e4f9c44d..bb6eaf1ae1a 100644 --- a/policyengine_us/parameters/gov/states/ri/dhs/ccap/income/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/states/ri/dhs/ccap/income/countable_income/sources.yaml @@ -5,6 +5,7 @@ values: - self_employment_income - sstb_self_employment_income - social_security_retirement + - railroad_benefits - social_security_disability - social_security_survivors - ssi diff --git a/policyengine_us/parameters/gov/states/sc/dss/ccap/income/countable_income/sources.yaml b/policyengine_us/parameters/gov/states/sc/dss/ccap/income/countable_income/sources.yaml index b6fce673bbd..0907ff8a940 100644 --- a/policyengine_us/parameters/gov/states/sc/dss/ccap/income/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/states/sc/dss/ccap/income/countable_income/sources.yaml @@ -6,6 +6,7 @@ values: - sstb_self_employment_income - farm_operations_income - social_security + - railroad_benefits - pension_income - total_unemployment_compensation - workers_compensation diff --git a/policyengine_us/parameters/gov/states/tx/fpp/income/sources/unearned.yaml b/policyengine_us/parameters/gov/states/tx/fpp/income/sources/unearned.yaml index 3bb96c30c8d..7ad6df61961 100644 --- a/policyengine_us/parameters/gov/states/tx/fpp/income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/states/tx/fpp/income/sources/unearned.yaml @@ -3,6 +3,7 @@ values: 2016-07-01: # Retirement, survivors and disability insurance (RSDI and SSDI) - social_security + - railroad_benefits - pension_income # Employment-based disability insurance benefits - disability_benefits diff --git a/policyengine_us/parameters/gov/states/tx/tdhca/ceap/income/sources.yaml b/policyengine_us/parameters/gov/states/tx/tdhca/ceap/income/sources.yaml index 62c2170277f..6f645609194 100644 --- a/policyengine_us/parameters/gov/states/tx/tdhca/ceap/income/sources.yaml +++ b/policyengine_us/parameters/gov/states/tx/tdhca/ceap/income/sources.yaml @@ -15,6 +15,7 @@ values: # Counted excluding the Medicare deduction; we don't track # Medicare premium withholding at the moment - social_security + - railroad_benefits # Calculated SSI; API partners can override by sending ssi as an input - ssi - pension_income diff --git a/policyengine_us/parameters/gov/states/va/dss/ccsp/income/countable_income/unearned_sources.yaml b/policyengine_us/parameters/gov/states/va/dss/ccsp/income/countable_income/unearned_sources.yaml index 4a7db44d921..f9b2425753a 100644 --- a/policyengine_us/parameters/gov/states/va/dss/ccsp/income/countable_income/unearned_sources.yaml +++ b/policyengine_us/parameters/gov/states/va/dss/ccsp/income/countable_income/unearned_sources.yaml @@ -3,6 +3,7 @@ values: 2023-01-01: - child_support_received - social_security + - railroad_benefits - pension_income - total_unemployment_compensation - workers_compensation diff --git a/policyengine_us/parameters/gov/states/vt/dcf/ccfap/income/sources.yaml b/policyengine_us/parameters/gov/states/vt/dcf/ccfap/income/sources.yaml index cb90545f111..59cf685d60f 100644 --- a/policyengine_us/parameters/gov/states/vt/dcf/ccfap/income/sources.yaml +++ b/policyengine_us/parameters/gov/states/vt/dcf/ccfap/income/sources.yaml @@ -6,6 +6,7 @@ values: - sstb_self_employment_income - pension_income - social_security + - railroad_benefits - alimony_income - child_support_received - dividend_income diff --git a/policyengine_us/parameters/gov/states/wa/dcyf/wccc/sources.yaml b/policyengine_us/parameters/gov/states/wa/dcyf/wccc/sources.yaml index ef72d41196e..b4871f59b53 100644 --- a/policyengine_us/parameters/gov/states/wa/dcyf/wccc/sources.yaml +++ b/policyengine_us/parameters/gov/states/wa/dcyf/wccc/sources.yaml @@ -8,6 +8,7 @@ values: - rental_income # Unearned income (WAC 110-15-0060) - social_security + - railroad_benefits - pension_income - retirement_distributions - disability_benefits diff --git a/policyengine_us/parameters/gov/states/wi/dcf/shares/income/sources.yaml b/policyengine_us/parameters/gov/states/wi/dcf/shares/income/sources.yaml index a720047408c..b434402b6e2 100644 --- a/policyengine_us/parameters/gov/states/wi/dcf/shares/income/sources.yaml +++ b/policyengine_us/parameters/gov/states/wi/dcf/shares/income/sources.yaml @@ -31,6 +31,7 @@ values: # social_security is an umbrella that already includes the old age, # survivorship, disability, and dependents components. - social_security + - railroad_benefits # Pensions and annuities, including veteran pensions. - pension_income - total_unemployment_compensation diff --git a/policyengine_us/parameters/gov/states/wv/dhhr/ccap/income/countable_income/sources.yaml b/policyengine_us/parameters/gov/states/wv/dhhr/ccap/income/countable_income/sources.yaml index 9ad7ebeaa7f..d11602ee5b2 100644 --- a/policyengine_us/parameters/gov/states/wv/dhhr/ccap/income/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/states/wv/dhhr/ccap/income/countable_income/sources.yaml @@ -5,6 +5,7 @@ values: - self_employment_income - farm_operations_income - social_security + - railroad_benefits - ssi - total_unemployment_compensation - workers_compensation diff --git a/policyengine_us/parameters/gov/states/wy/dfs/power/income/sources/unearned.yaml b/policyengine_us/parameters/gov/states/wy/dfs/power/income/sources/unearned.yaml index 90a9dfc133a..0ba9f415cf3 100644 --- a/policyengine_us/parameters/gov/states/wy/dfs/power/income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/states/wy/dfs/power/income/sources/unearned.yaml @@ -16,6 +16,7 @@ values: - workers_compensation - gi_cash_assistance - social_security + - railroad_benefits # Section 901(J) treats state, local and tribal GA as nonexempt income. # Vendor payments for purposes not covered by POWER are separately exempt. - general_assistance diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/cdss/tanf/cash/income/ca_tanf_other_unearned_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/cdss/tanf/cash/income/ca_tanf_other_unearned_income.yaml new file mode 100644 index 00000000000..d6b44bb2207 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/cdss/tanf/cash/income/ca_tanf_other_unearned_income.yaml @@ -0,0 +1,9 @@ +- name: Case 1, railroad retirement benefits count as unearned income. + # EAS 44-113.3 counts "Net income from Social Security or from Railroad Retirement Benefits". + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: CA + railroad_benefits: 12_000 + output: + ca_tanf_other_unearned_income: 12_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/co/ccap/co_ccap_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/co/ccap/co_ccap_countable_income.yaml new file mode 100644 index 00000000000..93404108560 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/co/ccap/co_ccap_countable_income.yaml @@ -0,0 +1,9 @@ +- name: Case 1, railroad retirement benefits count as unearned income. + # 8 CCR 1403-1 section 3.111(I) includes "Railroad retirement insurance". + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: CO + railroad_benefits: 12_000 + output: + co_ccap_countable_income: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/co/cdhs/tanf/co_tanf_countable_gross_unearned_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/co/cdhs/tanf/co_tanf_countable_gross_unearned_income.yaml index 6857ce3f465..a7f1aab2daf 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/co/cdhs/tanf/co_tanf_countable_gross_unearned_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/co/cdhs/tanf/co_tanf_countable_gross_unearned_income.yaml @@ -119,3 +119,13 @@ output: # $2,400 + $3,600 + $1,200 (not enrolled, so child support counted) = $7,200 co_tanf_countable_gross_unearned_income: 7_200 + +- name: Case 9, railroad retirement benefits count as unearned income. + # 9 CCR 2503-6 section 3.605.3 counts "Railroad retirement payments". + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: CO + railroad_benefits: 12_000 + output: + co_tanf_countable_gross_unearned_income: 12_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/dc/dhs/ccsp/dc_ccsp_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/dc/dhs/ccsp/dc_ccsp_countable_income.yaml new file mode 100644 index 00000000000..d5df88aaaff --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/dc/dhs/ccsp/dc_ccsp_countable_income.yaml @@ -0,0 +1,9 @@ +- name: Case 1, railroad retirement benefits count as unearned income. + # CCSP Policy Manual 2.5.2 counts "railroad retirement, private pensions, annuities". + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: DC + railroad_benefits: 12_000 + output: + dc_ccsp_countable_income: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/de/dss/poc/de_poc_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/de/dss/poc/de_poc_countable_income.yaml new file mode 100644 index 00000000000..3a06758562d --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/de/dss/poc/de_poc_countable_income.yaml @@ -0,0 +1,9 @@ +- name: Case 1, railroad retirement benefits count as unearned income. + # 16 Del. Admin. Code 11003 counts "Pensions; viii. Railroad retirement; ix. Annuities". + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: DE + railroad_benefits: 12_000 + output: + de_poc_countable_income: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/me/dhhs/ccap/me_ccap_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/me/dhhs/ccap/me_ccap_countable_income.yaml new file mode 100644 index 00000000000..6e3c5718734 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/me/dhhs/ccap/me_ccap_countable_income.yaml @@ -0,0 +1,9 @@ +- name: Case 1, railroad retirement benefits count as unearned income. + # 10-148 C.M.R. ch. 6, 2.C counts "railroad retirement, private pensions, annuities". + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: ME + railroad_benefits: 12_000 + output: + me_ccap_countable_income: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mi/mdhhs/ccap/mi_ccap_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/mi/mdhhs/ccap/mi_ccap_countable_income.yaml new file mode 100644 index 00000000000..76cabe23eb5 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mi/mdhhs/ccap/mi_ccap_countable_income.yaml @@ -0,0 +1,9 @@ +- name: Case 1, railroad retirement benefits count as unearned income. + # BEM 503 counts the gross Railroad Retirement benefit as unearned income for all programs. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: MI + railroad_benefits: 12_000 + output: + mi_ccap_countable_income: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/lieap/nc_lieap_gross_income_person.yaml b/policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/lieap/nc_lieap_gross_income_person.yaml new file mode 100644 index 00000000000..db97a2b048c --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/lieap/nc_lieap_gross_income_person.yaml @@ -0,0 +1,9 @@ +- name: Case 1, railroad retirement benefits count as unearned income. + # EP-300 300.09 A takes the types of income from FNS 300, whose 300.01 B lists railroad retirement as unearned income. + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: NC + railroad_benefits: 12_000 + output: + nc_lieap_gross_income_person: 12_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/tanf/nc_tanf_countable_gross_unearned_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/tanf/nc_tanf_countable_gross_unearned_income.yaml index cf6d11ee2ed..ef3aa5ad594 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/tanf/nc_tanf_countable_gross_unearned_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/tanf/nc_tanf_countable_gross_unearned_income.yaml @@ -163,3 +163,13 @@ # recipients is counted (State Plan Financial Rules 3 and 4). # 1_200 + 600 = 1_800 nc_tanf_countable_gross_unearned_income: 1_800 + +- name: Case 9, railroad retirement benefits count as unearned income. + # WF-114 lists "Railroad Retirement" as countable unearned income. + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: NC + railroad_benefits: 12_000 + output: + nc_tanf_countable_gross_unearned_income: 12_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nd/dhs/tanf/nd_tanf_gross_unearned_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/nd/dhs/tanf/nd_tanf_gross_unearned_income.yaml index 3248d0004d8..d2f02c60ed6 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nd/dhs/tanf/nd_tanf_gross_unearned_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nd/dhs/tanf/nd_tanf_gross_unearned_income.yaml @@ -75,3 +75,13 @@ dividend_income: 1200 output: nd_tanf_gross_unearned_income: 300 + +- name: Case 9, railroad retirement benefits count as unearned income. + # Manual 400-19-55-20-15 lists "Railroad benefits" as countable unearned income. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: ND + railroad_benefits: 12_000 + output: + nd_tanf_gross_unearned_income: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/sc/dss/ccap/sc_ccap_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/sc/dss/ccap/sc_ccap_countable_income.yaml index afc12697219..8d5f95e8bfc 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/sc/dss/ccap/sc_ccap_countable_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/sc/dss/ccap/sc_ccap_countable_income.yaml @@ -121,3 +121,13 @@ output: # SNAP is excluded per Policy Manual 2.1.15 sc_ccap_countable_income: 20_000 + +- name: Case 7, railroad retirement benefits count as unearned income. + # The Child Care Scholarship Policy Manual counts "Railroad retirement insurance benefits". + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: SC + railroad_benefits: 12_000 + output: + sc_ccap_countable_income: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wy/dfs/power/wy_power_gross_unearned_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/wy/dfs/power/wy_power_gross_unearned_income.yaml index 63ec95cbe0f..6cce2e6d68f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wy/dfs/power/wy_power_gross_unearned_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wy/dfs/power/wy_power_gross_unearned_income.yaml @@ -63,3 +63,13 @@ taxable_interest_income: 2400 output: wy_power_gross_unearned_income: 400 + +- name: Case 8, railroad retirement benefits count as unearned income. + # Manual 901(J) marks "Railroad retirement or railroad unemployment" nonexempt. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: WY + railroad_benefits: 12_000 + output: + wy_power_gross_unearned_income: 1_000 diff --git a/policyengine_us/tests/test_state_benefit_railroad_income.py b/policyengine_us/tests/test_state_benefit_railroad_income.py new file mode 100644 index 00000000000..f3469ecc4b5 --- /dev/null +++ b/policyengine_us/tests/test_state_benefit_railroad_income.py @@ -0,0 +1,174 @@ +"""State benefit programs count Railroad Retirement Act benefits as income. + +Each row names an income list or variable, the rule that reaches railroad +retirement benefits, and the variable that reads the list. A rule reaches the +benefits by naming them, by counting pensions, annuities or retirement +benefits, or by counting all income it doesn't exclude. + +Invariants, for every program below and any benefit amount: + +- R1: railroad benefits raise the program's income by the amount received, at + the variable's period, as the program's own sources do. Where the income + total also counts cash aid that the same benefit reduces, the test holds that + aid at zero, so the row isolates the income list from the aid's own income test. +- R2: the same dollars count the same whether a person receives them as + railroad benefits or as a pension, for programs that list pensions. +""" + +from functools import lru_cache + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation +from policyengine_us.system import system + +YEAR = 2026 +MONTH = "2026-06" + +# (state, variable, rule) +PROGRAMS = [ + ("AK", "ak_ccap_countable_unearned_income", "CCAP manual 4080-3(1)"), + ("AL", "al_ccsp_countable_income", "FFY 2025-2027 CCDF plan"), + ("AR", "ar_sra_countable_income", "FFY 2025-2027 CCDF plan"), + ("CA", "ca_child_care_countable_income", "5 CCR 18078"), + ("CA", "ca_tanf_other_unearned_income", "CalWORKs EAS 44-113.3"), + ("CA", "ca_cpuc_countable_income", "CARE and FERA application"), + ("CO", "co_ccap_countable_income", "8 CCR 1403-1"), + ("CO", "co_tanf_countable_gross_unearned_income", "9 CCR 2503-6 3.605"), + ("CT", "ct_c4k_countable_income", "RCSA 17b-749 definitions"), + ("DC", "dc_ccsp_countable_income", "CCSP policy manual 2.5.2"), + ("DC", "dc_tanf_gross_unearned_income", "D.C. Code 4-205.05"), + ("DE", "de_poc_countable_income", "16 Del. Admin. Code 11003"), + ("FL", "fl_sr_countable_income", "Fla. Admin. Code 6M-4.200"), + ("GA", "ga_caps_countable_income", "CAPS policy manual"), + ("ID", "id_iccp_countable_income", "IDAPA 16.06.12"), + ("IL", "il_aabd_gross_unearned_income", "89 Ill. Adm. Code 113.100"), + ("IL", "il_tanf_gross_unearned_income", "89 Ill. Adm. Code 112.100"), + ("IL", "il_hbwd_countable_unearned_income", "89 Ill. Adm. Code 120.330"), + ("IL", "il_isbe_countable_income", "ISBE household income form"), + ("KS", "ks_ccap_countable_income", "KEESM 6200"), + ("KS", "ks_liheap_countable_unearned_income", "KEESM 13361"), + ("KY", "ky_ccap_countable_income", "922 KAR 2:160"), + ("MA", "ma_tcap_gross_unearned_income", "106 CMR 704.210(B)"), + ("MD", "md_ccs_countable_income", "COMAR 13A.14.06.02"), + ("ME", "me_ccap_countable_income", "CCAP rule"), + ("ME", "me_tanf_gross_unearned_income", "10-144 C.M.R. ch. 331"), + ("MI", "mi_ccap_countable_income", "BEM 503"), + ("MN", "mn_ccap_gross_unearned_income", "Minn. Stat. 256P.06"), + ("MN", "mn_mfip_gross_unearned_income", "Minn. Stat. 256P.06"), + ("MO", "mo_ccs_countable_income", "5 CSR 25-200.050"), + ("MO", "mo_snc_countable_income", "SNC manual 0610.005"), + ("MS", "ms_ccpp_countable_income", "CCPP policy manual"), + ("MT", "mt_tanf_gross_unearned_income_person", "TANF manual 501-1"), + ("NC", "nc_lieap_gross_income_person", "EP-300 300.09 and FNS 300.01 B"), + ("NC", "nc_tanf_countable_gross_unearned_income", "WF-114"), + ("ND", "nd_tanf_gross_unearned_income", "Manual 400-19-55-20-15"), + ("NH", "nh_ccap_countable_income", "He-C 6910.06"), + ("NJ", "nj_liheap_countable_income_person", "LIHEAP handbook 2.3.D"), + ("NJ", "nj_ccap_countable_income", "FFY 2025-2027 CCDF plan"), + ("NV", "nv_ccdp_countable_income", "Child care manual 306"), + ("OK", "ok_ccs_gross_income", "OAC 340:40-7-11(c)(2)"), + ("RI", "ri_ccap_countable_income", "218-RICR-20-00-4"), + ("SC", "sc_ccap_countable_income", "CCS policy manual"), + ("TX", "tx_fpp_countable_unearned_income", "FPP manual 9000"), + ("TX", "tx_ceap_countable_income_person", "10 TAC 6.4"), + ("VA", "va_ccsp_countable_income", "8 VAC 20-790-40(H)"), + ("VT", "vt_ccfap_countable_income", "CCFAP regulations"), + ("WA", "wa_wccc_countable_income", "WAC 110-15-0060(8)"), + ("WI", "wi_shares_countable_income", "Wisconsin Shares handbook"), + ("WV", "wv_ccap_countable_income", "Child care policy 5.2.4"), + ("WY", "wy_power_gross_unearned_income", "SNAP and POWER manual"), +] + + +# These totals count CalWORKs cash aid (ca_tanf and tanf), and CalWORKs counts +# railroad benefits as other unearned income, so each dollar of benefits removes +# a dollar of aid from a household on the program. Wages above the CalWORKs limit +# keep the aid at zero with and without the benefit. +BASE_INPUTS = { + "ca_child_care_countable_income": {"employment_income": 60_000}, + "ca_cpuc_countable_income": {"employment_income": 60_000}, +} + + +def household(state, inputs): + """A single 40-year-old parent with one child, as every program's rules apply.""" + people = { + "parent": {"age": {YEAR: 40}, **{k: {YEAR: v} for k, v in inputs.items()}}, + "child": {"age": {YEAR: 5}}, + } + members = list(people) + return { + "people": people, + "tax_units": {"tax_unit": {"members": members}}, + "families": {"family": {"members": members}}, + "marital_units": { + "parent_unit": {"members": ["parent"]}, + "child_unit": {"members": ["child"]}, + }, + "spm_units": {"spm_unit": {"members": members}}, + "households": {"household": {"members": members, "state_code": {YEAR: state}}}, + } + + +def income(state, variable, inputs): + """The program's income for the parent, at the variable's own period.""" + period = YEAR if system.variables[variable].definition_period == "year" else MONTH + situation = household(state, {**BASE_INPUTS.get(variable, {}), **inputs}) + values = Simulation(situation=situation).calculate(variable, period) + return float(values[0]) + + +@lru_cache(maxsize=None) +def base_income(state, variable): + return income(state, variable, {}) + + +def received(variable, amount): + """An annual amount at the variable's period.""" + return ( + amount + if system.variables[variable].definition_period == "year" + else amount / 12 + ) + + +@pytest.mark.parametrize( + "state, variable, rule", PROGRAMS, ids=[r[1] for r in PROGRAMS] +) +@given(st.floats(min_value=1_200, max_value=200_000).map(lambda x: round(x, 2))) +@settings(max_examples=2, deadline=None) +def test_railroad_benefits_count_as_received(state, variable, rule, amount): + """R1. The whole benefit counts, from the first dollar.""" + change = income(state, variable, {"railroad_benefits": amount}) - base_income( + state, variable + ) + assert change == pytest.approx(received(variable, amount), abs=0.05), rule + + +PENSION_PROGRAMS = [ + row + for row in PROGRAMS + if row[1] + in { + "al_ccsp_countable_income", + "co_ccap_countable_income", + "md_ccs_countable_income", + "mo_ccs_countable_income", + "wi_shares_countable_income", + } +] + + +@pytest.mark.parametrize( + "state, variable, rule", PENSION_PROGRAMS, ids=[r[1] for r in PENSION_PROGRAMS] +) +@given(st.floats(min_value=1_200, max_value=200_000).map(lambda x: round(x, 2))) +@settings(max_examples=2, deadline=None) +def test_railroad_benefits_count_as_a_pension_would(state, variable, rule, amount): + """R2. These rules count pensions and annuities without distinction.""" + railroad = income(state, variable, {"railroad_benefits": amount}) + pension = income(state, variable, {"pension_income": amount}) + assert railroad == pytest.approx(pension, abs=0.05), rule diff --git a/policyengine_us/variables/gov/states/mo/dss/ssp/mo_snc_countable_income.py b/policyengine_us/variables/gov/states/mo/dss/ssp/mo_snc_countable_income.py index 1fe256cb6fd..8e24a06d003 100644 --- a/policyengine_us/variables/gov/states/mo/dss/ssp/mo_snc_countable_income.py +++ b/policyengine_us/variables/gov/states/mo/dss/ssp/mo_snc_countable_income.py @@ -17,4 +17,4 @@ class mo_snc_countable_income(Variable): "https://dssmanuals.mo.gov/supplemental-nursing-care/0610-000-00/0610-005-00/", "https://dssmanuals.mo.gov/supplemental-nursing-care/0610-000-00/0610-025-00/", ) - adds = ["market_income", "social_security", "ssi"] + adds = ["market_income", "social_security", "railroad_benefits", "ssi"] From 83715ca62520926fa2a14007547744e6bf2db8e6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 9 Nov 2026 06:45:28 -0500 Subject: [PATCH 2/7] Run five examples per program in the railroad income property tests Passing runs cost about a tenth of a second per example, so the extra examples add little to CI. Co-Authored-By: Claude Opus 5.5 --- policyengine_us/tests/test_state_benefit_railroad_income.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/policyengine_us/tests/test_state_benefit_railroad_income.py b/policyengine_us/tests/test_state_benefit_railroad_income.py index f3469ecc4b5..23daae05839 100644 --- a/policyengine_us/tests/test_state_benefit_railroad_income.py +++ b/policyengine_us/tests/test_state_benefit_railroad_income.py @@ -139,7 +139,7 @@ def received(variable, amount): "state, variable, rule", PROGRAMS, ids=[r[1] for r in PROGRAMS] ) @given(st.floats(min_value=1_200, max_value=200_000).map(lambda x: round(x, 2))) -@settings(max_examples=2, deadline=None) +@settings(max_examples=5, deadline=None) def test_railroad_benefits_count_as_received(state, variable, rule, amount): """R1. The whole benefit counts, from the first dollar.""" change = income(state, variable, {"railroad_benefits": amount}) - base_income( @@ -166,7 +166,7 @@ def test_railroad_benefits_count_as_received(state, variable, rule, amount): "state, variable, rule", PENSION_PROGRAMS, ids=[r[1] for r in PENSION_PROGRAMS] ) @given(st.floats(min_value=1_200, max_value=200_000).map(lambda x: round(x, 2))) -@settings(max_examples=2, deadline=None) +@settings(max_examples=5, deadline=None) def test_railroad_benefits_count_as_a_pension_would(state, variable, rule, amount): """R2. These rules count pensions and annuities without distinction.""" railroad = income(state, variable, {"railroad_benefits": amount}) From 233e962616d189b01f521bec24e0125ec93b1e48 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 9 Nov 2026 07:59:05 -0500 Subject: [PATCH 3/7] Count railroad benefits in 13 more programs and guard the audit An independent review found that the first sweep skipped any file that mentioned railroad retirement, even in a comment, and hid child care lists whose rules name those benefits. Sweep parsed values instead, add the programs whose rules reach the benefits (HI, MA, MT, PA, UT and WY child care and seven California county programs), and record the disability and Medicare limits in the lists' comments. Add R3, which fails on any state or county reader of Social Security that neither counts railroad benefits nor explains why, and R4, which rejects a repeated entry. Run R1 from the first dollar, over the programs that already counted the benefits too, and extend R2 to every list with a pension line. Co-Authored-By: Claude Opus 5.5 --- changelog.d/state-benefit-railroad.fixed.md | 2 +- .../countable_income/sources.yaml | 1 + .../ca/la/dwp/ez_save/income_sources.yaml | 1 + .../ca/la/general_relief/income_sources.yaml | 1 + .../income/sources/unearned.yaml | 1 + .../cap/liheap/countable_income/sources.yaml | 1 + .../cap/share/countable_income/sources.yaml | 1 + .../general_assistance/income/sources.yaml | 1 + .../ccsp/income/countable_income/sources.yaml | 3 + .../caps/income/countable_income/sources.yaml | 3 + .../ccap/income/countable_income/sources.yaml | 4 +- .../il/dhs/tanf/income/sources/unearned.yaml | 5 + .../countable_income/sources/unearned.yaml | 3 + .../income/countable_income/unearned.yaml | 4 + .../mn/dcyf/mfip/income/sources/unearned.yaml | 2 + .../ccap/income/countable_income/sources.yaml | 3 +- .../dca/liheap/income/sources/unearned.yaml | 3 + .../ccw/income/countable_income/sources.yaml | 9 +- .../ut/dwf/ccap/income/sources/unearned.yaml | 1 + .../ccap/income/countable_income/sources.yaml | 3 +- ...al_assistance_countable_income_person.yaml | 10 + .../ca_la_ez_save_countable_income.yaml | 13 + .../la_general_relief_gross_income.yaml | 11 + ..._general_relief_gross_unearned_income.yaml | 10 + .../ca_riv_liheap_countable_income.yaml | 10 + .../share/ca_riv_share_countable_income.yaml | 10 + ...c_general_assistance_countable_income.yaml | 11 + .../bessd/ccap/hi_ccap_countable_income.yaml | 10 + .../ma/eec/ccfa/ma_ccfa_countable_income.yaml | 16 + .../dphhs/ccap/mt_ccap_countable_income.yaml | 10 + .../nj_liheap_countable_income_person.yaml | 26 ++ .../pa/dhs/ccw/pa_ccw_countable_income.yaml | 10 + .../dwf/ccap/income/ut_ccap_gross_income.yaml | 25 ++ .../dfs/ccap/income/wy_ccap_gross_income.yaml | 10 + .../test_state_benefit_railroad_income.py | 290 +++++++++++++++--- 35 files changed, 482 insertions(+), 42 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/local/ca/ala/ca_ala_general_assistance_countable_income_person.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/local/ca/la/dwp/ez_save/ca_la_ez_save_countable_income.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/local/ca/oc/general_relief/income/ca_oc_general_relief_gross_unearned_income.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/local/ca/riv/cap/liheap/ca_riv_liheap_countable_income.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/local/ca/riv/cap/share/ca_riv_share_countable_income.yaml diff --git a/changelog.d/state-benefit-railroad.fixed.md b/changelog.d/state-benefit-railroad.fixed.md index 50983da7464..d903e0839d9 100644 --- a/changelog.d/state-benefit-railroad.fixed.md +++ b/changelog.d/state-benefit-railroad.fixed.md @@ -1 +1 @@ -Count Railroad Retirement Act benefits as income in the state benefit programs whose rules name them, count pensions or retirement benefits, or count all income they do not exclude. +Count Railroad Retirement Act benefits as income in the state and county benefit programs whose rules name them, count pensions or retirement benefits, or count all income they do not exclude. diff --git a/policyengine_us/parameters/gov/local/ca/ala/general_assistance/countable_income/sources.yaml b/policyengine_us/parameters/gov/local/ca/ala/general_assistance/countable_income/sources.yaml index 9cb1d31db44..a6672e3f574 100644 --- a/policyengine_us/parameters/gov/local/ca/ala/general_assistance/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/local/ca/ala/general_assistance/countable_income/sources.yaml @@ -5,6 +5,7 @@ values: - self_employment_income - sstb_self_employment_income - social_security + - railroad_benefits - total_unemployment_compensation - veterans_benefits diff --git a/policyengine_us/parameters/gov/local/ca/la/dwp/ez_save/income_sources.yaml b/policyengine_us/parameters/gov/local/ca/la/dwp/ez_save/income_sources.yaml index c17fc01a862..0df5a6859e5 100644 --- a/policyengine_us/parameters/gov/local/ca/la/dwp/ez_save/income_sources.yaml +++ b/policyengine_us/parameters/gov/local/ca/la/dwp/ez_save/income_sources.yaml @@ -15,6 +15,7 @@ values: - child_support_received - alimony_income - social_security + - railroad_benefits - ssi - tanf - snap diff --git a/policyengine_us/parameters/gov/local/ca/la/general_relief/income_sources.yaml b/policyengine_us/parameters/gov/local/ca/la/general_relief/income_sources.yaml index 9e7d684cd95..6552547d5d5 100644 --- a/policyengine_us/parameters/gov/local/ca/la/general_relief/income_sources.yaml +++ b/policyengine_us/parameters/gov/local/ca/la/general_relief/income_sources.yaml @@ -12,6 +12,7 @@ values: - sstb_self_employment_income - ssi - social_security + - railroad_benefits - tanf metadata: unit: list diff --git a/policyengine_us/parameters/gov/local/ca/oc/general_relief/income/sources/unearned.yaml b/policyengine_us/parameters/gov/local/ca/oc/general_relief/income/sources/unearned.yaml index e5f2a7cf212..cf630f83495 100644 --- a/policyengine_us/parameters/gov/local/ca/oc/general_relief/income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/local/ca/oc/general_relief/income/sources/unearned.yaml @@ -5,6 +5,7 @@ values: - interest_income - dividend_income - social_security + - railroad_benefits - total_unemployment_compensation - ca_state_disability_insurance - veterans_benefits diff --git a/policyengine_us/parameters/gov/local/ca/riv/cap/liheap/countable_income/sources.yaml b/policyengine_us/parameters/gov/local/ca/riv/cap/liheap/countable_income/sources.yaml index 2d5bd2128bc..2f7fc98d12d 100644 --- a/policyengine_us/parameters/gov/local/ca/riv/cap/liheap/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/local/ca/riv/cap/liheap/countable_income/sources.yaml @@ -9,6 +9,7 @@ values: - pension_income - total_unemployment_compensation - social_security + - railroad_benefits - ssi - ca_tanf - ca_state_supplement diff --git a/policyengine_us/parameters/gov/local/ca/riv/cap/share/countable_income/sources.yaml b/policyengine_us/parameters/gov/local/ca/riv/cap/share/countable_income/sources.yaml index f08e2895b34..29fee9e6996 100644 --- a/policyengine_us/parameters/gov/local/ca/riv/cap/share/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/local/ca/riv/cap/share/countable_income/sources.yaml @@ -9,6 +9,7 @@ values: - pension_income - total_unemployment_compensation - social_security + - railroad_benefits - ssi - ca_tanf diff --git a/policyengine_us/parameters/gov/local/ca/smc/general_assistance/income/sources.yaml b/policyengine_us/parameters/gov/local/ca/smc/general_assistance/income/sources.yaml index 19555039689..756d23e4166 100644 --- a/policyengine_us/parameters/gov/local/ca/smc/general_assistance/income/sources.yaml +++ b/policyengine_us/parameters/gov/local/ca/smc/general_assistance/income/sources.yaml @@ -9,6 +9,7 @@ values: - disability_benefits - ca_state_disability_insurance - social_security + - railroad_benefits - veterans_benefits - pension_income - retirement_distributions diff --git a/policyengine_us/parameters/gov/states/dc/dhs/ccsp/income/countable_income/sources.yaml b/policyengine_us/parameters/gov/states/dc/dhs/ccsp/income/countable_income/sources.yaml index 8fa71d6c96f..3b46915cf46 100644 --- a/policyengine_us/parameters/gov/states/dc/dhs/ccsp/income/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/states/dc/dhs/ccsp/income/countable_income/sources.yaml @@ -8,6 +8,9 @@ values: - interest_income - rental_income - pension_income + # Manual 2.5.2 counts railroad retirement. 2.5.3 exempts permanent disability + # payments, but railroad_benefits doesn't separate disability annuities, so + # the whole amount counts. - railroad_benefits - veterans_benefits - military_retirement_income diff --git a/policyengine_us/parameters/gov/states/ga/decal/caps/income/countable_income/sources.yaml b/policyengine_us/parameters/gov/states/ga/decal/caps/income/countable_income/sources.yaml index c417596d787..d0f7c263b8a 100644 --- a/policyengine_us/parameters/gov/states/ga/decal/caps/income/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/states/ga/decal/caps/income/countable_income/sources.yaml @@ -13,6 +13,9 @@ values: - dividend_income - pension_income - social_security_retirement + # Section 8.4.1 counts retirement and pension income. Section 8.4.2 excludes + # only disability and survivors benefits from the Social Security + # Administration, and the Railroad Retirement Board pays these benefits. - railroad_benefits - military_basic_pay # The following are counted per CAPS Policy Manual §8.4 but have diff --git a/policyengine_us/parameters/gov/states/hi/bessd/ccap/income/countable_income/sources.yaml b/policyengine_us/parameters/gov/states/hi/bessd/ccap/income/countable_income/sources.yaml index ed5add6b1a1..382734595b7 100644 --- a/policyengine_us/parameters/gov/states/hi/bessd/ccap/income/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/states/hi/bessd/ccap/income/countable_income/sources.yaml @@ -7,6 +7,9 @@ values: - social_security_retirement - social_security_disability - social_security_survivors + # HAR 17-798.3-10(b)(2)(A) counts railroad retirement insurance checks + # from the U.S. government, prior to deductions for medical insurance. + - railroad_benefits # Recurring SSI is countable; HAR 17-798.2-11(5)(B) excludes only # retroactive lump-sum SSI, not recurring monthly SSI payments. - ssi @@ -27,7 +30,6 @@ values: - child_support_received # The following are counted per HAR 17-798.2-10(b) but have no # matching PolicyEngine variables: - # - railroad retirement (item (2)(A); captured via social_security_*) # - temporary disability insurance (item (4)(B)) # - income from estates or trust funds (item (15)) # - royalties (item (17)) diff --git a/policyengine_us/parameters/gov/states/il/dhs/tanf/income/sources/unearned.yaml b/policyengine_us/parameters/gov/states/il/dhs/tanf/income/sources/unearned.yaml index 067e5a629b0..73a97ca9b95 100644 --- a/policyengine_us/parameters/gov/states/il/dhs/tanf/income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/states/il/dhs/tanf/income/sources/unearned.yaml @@ -2,6 +2,11 @@ description: Illinois counts these income sources as unearned income under the T values: 1997-07-01: - social_security + # 89 Ill. Adm. Code 112.100 counts unearned income the section doesn't exempt. + # 112.110(aa) disregards Railroad Retirement disability payments up to the SSI + # payment level, as it does Social Security disability. The model doesn't apply + # that disregard to either, and railroad_benefits doesn't separate disability + # annuities, so the whole amount counts. - railroad_benefits - disability_benefits - workers_compensation diff --git a/policyengine_us/parameters/gov/states/ma/eec/ccfa/income/countable_income/sources/unearned.yaml b/policyengine_us/parameters/gov/states/ma/eec/ccfa/income/countable_income/sources/unearned.yaml index 1e8256e24b9..1eeda01eab0 100644 --- a/policyengine_us/parameters/gov/states/ma/eec/ccfa/income/countable_income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/states/ma/eec/ccfa/income/countable_income/sources/unearned.yaml @@ -7,6 +7,7 @@ values: - interest_income - rental_income - pension_income + - railroad_benefits - veterans_benefits - military_retirement_income - alimony_income @@ -25,6 +26,7 @@ values: - dividend_income - rental_income - pension_income + - railroad_benefits - military_retirement_income - alimony_income - total_unemployment_compensation @@ -35,6 +37,7 @@ values: - dividend_income - rental_income - pension_income + - railroad_benefits - military_retirement_income - alimony_income - total_unemployment_compensation diff --git a/policyengine_us/parameters/gov/states/mn/dcyf/ccap/income/countable_income/unearned.yaml b/policyengine_us/parameters/gov/states/mn/dcyf/ccap/income/countable_income/unearned.yaml index f5db0961994..466d85d6ffe 100644 --- a/policyengine_us/parameters/gov/states/mn/dcyf/ccap/income/countable_income/unearned.yaml +++ b/policyengine_us/parameters/gov/states/mn/dcyf/ccap/income/countable_income/unearned.yaml @@ -8,6 +8,10 @@ values: - dividend_income - interest_income - pension_income + # 256P.06, subd. 3(2)(ix) counts retirement benefits. Item (xvi), which the + # model applies to Social Security through rsdi_sources, doesn't name them. + # railroad_benefits doesn't separate disability annuities, so the whole + # amount counts. - railroad_benefits - veterans_benefits # Military retirement pay and Survivor Benefit Plan annuities count as diff --git a/policyengine_us/parameters/gov/states/mn/dcyf/mfip/income/sources/unearned.yaml b/policyengine_us/parameters/gov/states/mn/dcyf/mfip/income/sources/unearned.yaml index db5829bc1e1..33118dd5864 100644 --- a/policyengine_us/parameters/gov/states/mn/dcyf/mfip/income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/states/mn/dcyf/mfip/income/sources/unearned.yaml @@ -16,6 +16,8 @@ values: - workers_compensation - gi_cash_assistance - social_security + # 256P.06, subd. 3(2)(ix) counts retirement benefits. railroad_benefits doesn't + # separate disability annuities, so the whole amount counts. - railroad_benefits # MFIP counts GA, RCA and cash benefits already issued (0017.12.03). # Receipt-of-other-assistance rules in 0011.21 are separate eligibility diff --git a/policyengine_us/parameters/gov/states/mt/dphhs/ccap/income/countable_income/sources.yaml b/policyengine_us/parameters/gov/states/mt/dphhs/ccap/income/countable_income/sources.yaml index 614538ff662..a1c51fe3f37 100644 --- a/policyengine_us/parameters/gov/states/mt/dphhs/ccap/income/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/states/mt/dphhs/ccap/income/countable_income/sources.yaml @@ -13,7 +13,8 @@ values: - dividend_income - rental_income # Rental/lease income, contract for deed - social_security # SS and SSDI counted; SSI excluded (social_security omits SSI) - - pension_income # Government pension, annuity, railroad retirement, veterans' pensions + - railroad_benefits # Railroad Retirement counts as pension or retirement income + - pension_income # Government pension, annuity, veterans' pensions - retirement_distributions - total_unemployment_compensation - workers_compensation diff --git a/policyengine_us/parameters/gov/states/nj/dca/liheap/income/sources/unearned.yaml b/policyengine_us/parameters/gov/states/nj/dca/liheap/income/sources/unearned.yaml index d003d5dc645..fd7f9a7d495 100644 --- a/policyengine_us/parameters/gov/states/nj/dca/liheap/income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/states/nj/dca/liheap/income/sources/unearned.yaml @@ -9,6 +9,9 @@ values: - strike_benefits - ssi - pension_income + # Handbook 2.3.D counts benefits, pensions and annuities. The Medicare Part B + # deduction in 2.3.F.7 and the exclusion for children in 2.3.E.9 name Social + # Security only, so railroad benefits count gross and at every age. - railroad_benefits # Military retirement income (retiree pay plus Survivor Benefit Plan # annuities) is a separate input from the pension inputs that pension_income diff --git a/policyengine_us/parameters/gov/states/pa/dhs/ccw/income/countable_income/sources.yaml b/policyengine_us/parameters/gov/states/pa/dhs/ccw/income/countable_income/sources.yaml index ebcaf698363..1d15b6b3225 100644 --- a/policyengine_us/parameters/gov/states/pa/dhs/ccw/income/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/states/pa/dhs/ccw/income/countable_income/sources.yaml @@ -7,6 +7,9 @@ values: # Part I.E; social_security adds the retirement, disability, # survivors and dependents benefits. - social_security + # Part I.F counts railroad retirement, disability and survivors' benefits + # before deductions of health insurance premiums. + - railroad_benefits - ssi - pension_income - military_retirement_income @@ -18,9 +21,9 @@ values: - dividend_income - interest_income - rental_income - # Not modeled: railroad retirement (F), state blind pension (G), - # public assistance/retirement (H), royalties (Q), lump sums >$100 (R), - # lottery >$100 (S), room/board net income (U) + # Not modeled: state blind pension (G), public assistance/retirement (H), + # royalties (Q), lump sums >$100 (R), lottery >$100 (S), room/board net + # income (U) metadata: unit: list diff --git a/policyengine_us/parameters/gov/states/ut/dwf/ccap/income/sources/unearned.yaml b/policyengine_us/parameters/gov/states/ut/dwf/ccap/income/sources/unearned.yaml index a323a8baab2..0da434dee5a 100644 --- a/policyengine_us/parameters/gov/states/ut/dwf/ccap/income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/states/ut/dwf/ccap/income/sources/unearned.yaml @@ -31,6 +31,7 @@ description: Utah counts these unearned income sources as countable income under values: 2020-01-01: - social_security + - railroad_benefits - pension_income - total_unemployment_compensation - workers_compensation diff --git a/policyengine_us/parameters/gov/states/wy/dfs/ccap/income/countable_income/sources.yaml b/policyengine_us/parameters/gov/states/wy/dfs/ccap/income/countable_income/sources.yaml index e2bcfca99cd..a05e3e24501 100644 --- a/policyengine_us/parameters/gov/states/wy/dfs/ccap/income/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/states/wy/dfs/ccap/income/countable_income/sources.yaml @@ -17,7 +17,8 @@ values: - interest_income - dividend_income - rental_income # The manual's mortgage-payment deduction is not modeled - - pension_income # Pensions, annuities, retirement, railroad retirement + - pension_income # Pensions, annuities, retirement + - railroad_benefits # Rules Appendix B: RR retirement is unearned and nonexempt - retirement_distributions - alimony_income - child_support_received # From a non-assistance-unit member diff --git a/policyengine_us/tests/policy/baseline/gov/local/ca/ala/ca_ala_general_assistance_countable_income_person.yaml b/policyengine_us/tests/policy/baseline/gov/local/ca/ala/ca_ala_general_assistance_countable_income_person.yaml new file mode 100644 index 00000000000..373081ec543 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/local/ca/ala/ca_ala_general_assistance_countable_income_person.yaml @@ -0,0 +1,10 @@ +- name: Case 1, railroad retirement benefits count as income. + # GA regulations define unearned income as "all other income received" and + # name Railroad Retirement Benefits among potentially available income. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: CA + railroad_benefits: 12_000 + output: + ca_ala_general_assistance_countable_income_person: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/local/ca/la/dwp/ez_save/ca_la_ez_save_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/local/ca/la/dwp/ez_save/ca_la_ez_save_countable_income.yaml new file mode 100644 index 00000000000..e21cb54811f --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/local/ca/la/dwp/ez_save/ca_la_ez_save_countable_income.yaml @@ -0,0 +1,13 @@ +- name: Case 1, railroad retirement benefits count as income. + # EZ-SAVE defines income as all the money and benefits a household receives and + # names retirement. The income list also counts SNAP, which this case holds at + # zero to isolate the railroad benefit. + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: CA + in_la: true + snap: 0 + railroad_benefits: 12_000 + output: + ca_la_ez_save_countable_income: 12_000 diff --git a/policyengine_us/tests/policy/baseline/gov/local/ca/la/general_relief/la_general_relief_gross_income.yaml b/policyengine_us/tests/policy/baseline/gov/local/ca/la/general_relief/la_general_relief_gross_income.yaml index b93868f4c99..4b1485e8233 100644 --- a/policyengine_us/tests/policy/baseline/gov/local/ca/la/general_relief/la_general_relief_gross_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/local/ca/la/general_relief/la_general_relief_gross_income.yaml @@ -61,3 +61,14 @@ output: # 1,200 + 600 + 1,800 la_general_relief_gross_income: 3_600 + +- name: Case 3, railroad retirement benefits count as income. + # ABP 392 defines net income as any income after deductions. + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: CA + in_la: true + railroad_benefits: 12_000 + output: + la_general_relief_gross_income: 12_000 diff --git a/policyengine_us/tests/policy/baseline/gov/local/ca/oc/general_relief/income/ca_oc_general_relief_gross_unearned_income.yaml b/policyengine_us/tests/policy/baseline/gov/local/ca/oc/general_relief/income/ca_oc_general_relief_gross_unearned_income.yaml new file mode 100644 index 00000000000..02454859c9e --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/local/ca/oc/general_relief/income/ca_oc_general_relief_gross_unearned_income.yaml @@ -0,0 +1,10 @@ +- name: Case 1, railroad retirement benefits count as income. + # GR manual section 70.1 considers all income, including pensions and retirement. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: CA + in_oc: true + railroad_benefits: 12_000 + output: + ca_oc_general_relief_gross_unearned_income: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/local/ca/riv/cap/liheap/ca_riv_liheap_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/local/ca/riv/cap/liheap/ca_riv_liheap_countable_income.yaml new file mode 100644 index 00000000000..4adf22b4f86 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/local/ca/riv/cap/liheap/ca_riv_liheap_countable_income.yaml @@ -0,0 +1,10 @@ +- name: Case 1, railroad retirement benefits count as income. + # The LIHEAP application lists pension and annuities as countable income. + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: CA + in_riv: true + railroad_benefits: 12_000 + output: + ca_riv_liheap_countable_income: 12_000 diff --git a/policyengine_us/tests/policy/baseline/gov/local/ca/riv/cap/share/ca_riv_share_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/local/ca/riv/cap/share/ca_riv_share_countable_income.yaml new file mode 100644 index 00000000000..c423f8f75e7 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/local/ca/riv/cap/share/ca_riv_share_countable_income.yaml @@ -0,0 +1,10 @@ +- name: Case 1, railroad retirement benefits count as income. + # SHARE requires all household income and names pension among income proofs. + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: CA + in_riv: true + railroad_benefits: 12_000 + output: + ca_riv_share_countable_income: 12_000 diff --git a/policyengine_us/tests/policy/baseline/gov/local/ca/smc/general_assistance/income/ca_smc_general_assistance_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/local/ca/smc/general_assistance/income/ca_smc_general_assistance_countable_income.yaml index 94db088ae07..24a1b4d68d1 100644 --- a/policyengine_us/tests/policy/baseline/gov/local/ca/smc/general_assistance/income/ca_smc_general_assistance_countable_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/local/ca/smc/general_assistance/income/ca_smc_general_assistance_countable_income.yaml @@ -85,3 +85,14 @@ members: [person1] output: ca_smc_general_assistance_countable_income: 300 + +- name: Case 6, railroad retirement benefits count as income. + # The GA fact sheet counts retirement benefits and other means of income or support. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: CA + in_smc: true + railroad_benefits: 12_000 + output: + ca_smc_general_assistance_countable_income: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/hi/bessd/ccap/hi_ccap_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/hi/bessd/ccap/hi_ccap_countable_income.yaml index 3737656552e..f6b7aa243ec 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/hi/bessd/ccap/hi_ccap_countable_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/hi/bessd/ccap/hi_ccap_countable_income.yaml @@ -174,3 +174,13 @@ output: # 2,000 + 1,000 = 3,000 (adult student not excluded) hi_ccap_countable_income: 3_000 + +- name: Case 9, railroad retirement benefits count as income. + # HAR 17-798.3-10(b)(2)(A) counts railroad retirement insurance checks from the U.S. government. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: HI + railroad_benefits: 12_000 + output: + hi_ccap_countable_income: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ma/eec/ccfa/ma_ccfa_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/ma/eec/ccfa/ma_ccfa_countable_income.yaml index dfbdd4ebdc1..8b8062523fa 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ma/eec/ccfa/ma_ccfa_countable_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ma/eec/ccfa/ma_ccfa_countable_income.yaml @@ -599,3 +599,19 @@ output: ma_ccfa_is_parent: [true, true, false] ma_ccfa_countable_income: 5_300 + +- name: Case 26, railroad retirement benefits count as retirement income. + # The October 2023 interim policies list retirement income among the only + # income sources counted. They exclude Social Security, which the Social + # Security Administration pays, and railroad annuities come from the + # Railroad Retirement Board. + period: 2023 + absolute_error_margin: 0.01 + input: + age: 35 + state_code: MA + railroad_benefits: 12_000 + output: + ma_ccfa_countable_income: + 2023-09: 1_000 + 2023-10: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/dphhs/ccap/mt_ccap_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/dphhs/ccap/mt_ccap_countable_income.yaml index 320fb48e804..1ae3d8a320f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/dphhs/ccap/mt_ccap_countable_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/dphhs/ccap/mt_ccap_countable_income.yaml @@ -146,3 +146,13 @@ state_code: MT output: mt_ccap_countable_income: 1_500 # (12,000 + 6,000) / 12 + +- name: Case 8, railroad retirement benefits count as income. + # The Child Care 2-6 Income Evaluation Table marks Railroad Retirement as pension or retirement income, countable. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: MT + railroad_benefits: 12_000 + output: + mt_ccap_countable_income: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nj/dca/liheap/income/nj_liheap_countable_income_person.yaml b/policyengine_us/tests/policy/baseline/gov/states/nj/dca/liheap/income/nj_liheap_countable_income_person.yaml index 83f0b3a17e0..30e00741757 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nj/dca/liheap/income/nj_liheap_countable_income_person.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nj/dca/liheap/income/nj_liheap_countable_income_person.yaml @@ -231,3 +231,29 @@ nj_liheap_countable_income_person: [24_000, 9_600] # 2,000 and 800 a month are whole dollars, so rounding changes nothing. nj_liheap_countable_income: [24_000, 9_600] + +- name: Case 10, railroad benefits count before the Medicare Part B premium. + # Handbook 2.3.D counts benefits, pensions and annuities. The Part B deduction + # in 2.3.F.7 names Social Security benefits only. + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: NJ + age: 70 + railroad_benefits: 24_000 + medicare_part_b_premium: 2_400 + output: + nj_liheap_countable_income_person: 24_000 + +- name: Case 11, a child's railroad benefits count while a child's Social Security does not. + # Two one-person households aged 10. Handbook 2.3.E.9 excludes Social Security + # benefits for children under 18 and names no other benefit. + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: [NJ, NJ] + age: [10, 10] + railroad_benefits: [6_000, 0] + social_security: [0, 6_000] + output: + nj_liheap_countable_income_person: [6_000, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/pa/dhs/ccw/pa_ccw_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/pa/dhs/ccw/pa_ccw_countable_income.yaml index 43f099e6bba..942663a905d 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/pa/dhs/ccw/pa_ccw_countable_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/pa/dhs/ccw/pa_ccw_countable_income.yaml @@ -161,3 +161,13 @@ # The retirement benefit exceeds the federal SSI benefit rate, so ssi is 0. # Monthly: (9,600 + 2,400 + 3,600 + 18,000) / 12 = 33,600 / 12 = 2,800 pa_ccw_countable_income: 2_800 + +- name: Case 8, railroad retirement benefits count as income. + # 55 Pa. Code chapter 3042, Appendix A, Part I(F) counts railroad retirement, disability or survivors' benefits. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: PA + railroad_benefits: 12_000 + output: + pa_ccw_countable_income: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ut/dwf/ccap/income/ut_ccap_gross_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/ut/dwf/ccap/income/ut_ccap_gross_income.yaml index e8ce52fb69e..0d07cadf242 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ut/dwf/ccap/income/ut_ccap_gross_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ut/dwf/ccap/income/ut_ccap_gross_income.yaml @@ -110,3 +110,28 @@ # court-appointed guardian — so their 3,000 in wages is counted # (R986-700-710(4)(a) and (4)(b)). ut_ccap_gross_income: 3_000 + +- name: Case 5, railroad retirement benefits count as income. + # R986-200-235(2)(a) counts "pensions and annuities such as Railroad Retirement", + # and R986-700-710(2)(a) applies R986-200 to child care income. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + railroad_benefits: 12_000 + child: + age: 5 + spm_units: + spm_unit: + members: [parent, child] + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: UT + output: + ut_ccap_gross_income: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wy/dfs/ccap/income/wy_ccap_gross_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/wy/dfs/ccap/income/wy_ccap_gross_income.yaml index 5f72d0754e9..8c0f367fccf 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wy/dfs/ccap/income/wy_ccap_gross_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wy/dfs/ccap/income/wy_ccap_gross_income.yaml @@ -121,3 +121,13 @@ # disregard + 500 unearned = 2_300. wy_ccap_gross_income: 2_500 wy_ccap_countable_income: 2_300 + +- name: Case 6, railroad retirement benefits count as income. + # Child care rules, chapter 1, Appendix B, list RR retirement as unearned and nonexempt. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: WY + railroad_benefits: 12_000 + output: + wy_ccap_gross_income: 1_000 diff --git a/policyengine_us/tests/test_state_benefit_railroad_income.py b/policyengine_us/tests/test_state_benefit_railroad_income.py index 23daae05839..aa03c35cafb 100644 --- a/policyengine_us/tests/test_state_benefit_railroad_income.py +++ b/policyengine_us/tests/test_state_benefit_railroad_income.py @@ -1,23 +1,34 @@ -"""State benefit programs count Railroad Retirement Act benefits as income. +"""State and county benefit programs count Railroad Retirement Act benefits as income. Each row names an income list or variable, the rule that reaches railroad retirement benefits, and the variable that reads the list. A rule reaches the benefits by naming them, by counting pensions, annuities or retirement benefits, or by counting all income it doesn't exclude. -Invariants, for every program below and any benefit amount: +Invariants: -- R1: railroad benefits raise the program's income by the amount received, at - the variable's period, as the program's own sources do. Where the income - total also counts cash aid that the same benefit reduces, the test holds that - aid at zero, so the row isolates the income list from the aid's own income test. -- R2: the same dollars count the same whether a person receives them as - railroad benefits or as a pension, for programs that list pensions. +- R1: for every program below and any benefit amount, railroad benefits raise + the program's income by the amount received, at the variable's period. Where + the income total also counts cash aid that the same benefit reduces, the test + holds that aid at zero, so the row isolates the income list from the aid's own + income test. R1 shows that the list entry reaches the program's income. Whether + a rule reaches railroad benefits is a matter of the rule's text, which each + row cites. +- R2: the same dollars count the same whether a person receives them as railroad + benefits or as a pension, for programs whose lists count pensions. +- R3: every state or county list and variable outside the tax code that reads a + Social Security amount either counts railroad benefits or appears in + NO_RAILROAD_INCOME with the reason it doesn't. A new reader fails this test + until its author reads the rule and does one or the other. +- R4: no list holds railroad_benefits twice. """ +import ast from functools import lru_cache +from pathlib import Path import pytest +import yaml from hypothesis import given, settings from hypothesis import strategies as st @@ -26,6 +37,7 @@ YEAR = 2026 MONTH = "2026-06" +PACKAGE = Path(__file__).resolve().parents[1] # (state, variable, rule) PROGRAMS = [ @@ -43,6 +55,7 @@ ("DE", "de_poc_countable_income", "16 Del. Admin. Code 11003"), ("FL", "fl_sr_countable_income", "Fla. Admin. Code 6M-4.200"), ("GA", "ga_caps_countable_income", "CAPS policy manual"), + ("HI", "hi_ccap_countable_income", "HAR 17-798.3-10(b)(2)(A)"), ("ID", "id_iccp_countable_income", "IDAPA 16.06.12"), ("IL", "il_aabd_gross_unearned_income", "89 Ill. Adm. Code 113.100"), ("IL", "il_tanf_gross_unearned_income", "89 Ill. Adm. Code 112.100"), @@ -52,6 +65,7 @@ ("KS", "ks_liheap_countable_unearned_income", "KEESM 13361"), ("KY", "ky_ccap_countable_income", "922 KAR 2:160"), ("MA", "ma_tcap_gross_unearned_income", "106 CMR 704.210(B)"), + ("MA", "ma_ccfa_countable_income_person", "EEC interim policies, Oct. 2023"), ("MD", "md_ccs_countable_income", "COMAR 13A.14.06.02"), ("ME", "me_ccap_countable_income", "CCAP rule"), ("ME", "me_tanf_gross_unearned_income", "10-144 C.M.R. ch. 331"), @@ -62,6 +76,7 @@ ("MO", "mo_snc_countable_income", "SNC manual 0610.005"), ("MS", "ms_ccpp_countable_income", "CCPP policy manual"), ("MT", "mt_tanf_gross_unearned_income_person", "TANF manual 501-1"), + ("MT", "mt_ccap_countable_income", "Child care manual CC 2-6"), ("NC", "nc_lieap_gross_income_person", "EP-300 300.09 and FNS 300.01 B"), ("NC", "nc_tanf_countable_gross_unearned_income", "WF-114"), ("ND", "nd_tanf_gross_unearned_income", "Manual 400-19-55-20-15"), @@ -70,36 +85,86 @@ ("NJ", "nj_ccap_countable_income", "FFY 2025-2027 CCDF plan"), ("NV", "nv_ccdp_countable_income", "Child care manual 306"), ("OK", "ok_ccs_gross_income", "OAC 340:40-7-11(c)(2)"), + ("PA", "pa_ccw_countable_income", "55 Pa. Code ch. 3042 app. A, part I(F)"), ("RI", "ri_ccap_countable_income", "218-RICR-20-00-4"), ("SC", "sc_ccap_countable_income", "CCS policy manual"), ("TX", "tx_fpp_countable_unearned_income", "FPP manual 9000"), ("TX", "tx_ceap_countable_income_person", "10 TAC 6.4"), + ("UT", "ut_ccap_gross_income", "Utah Admin. Code R986-200-235(2)(a)"), ("VA", "va_ccsp_countable_income", "8 VAC 20-790-40(H)"), ("VT", "vt_ccfap_countable_income", "CCFAP regulations"), ("WA", "wa_wccc_countable_income", "WAC 110-15-0060(8)"), ("WI", "wi_shares_countable_income", "Wisconsin Shares handbook"), ("WV", "wv_ccap_countable_income", "Child care policy 5.2.4"), ("WY", "wy_power_gross_unearned_income", "SNAP and POWER manual"), + ("WY", "wy_ccap_gross_income", "Child care rules ch. 1, app. B"), + # County programs. + ("CA", "ca_ala_general_assistance_countable_income_person", "GA regulations"), + ("CA", "ca_oc_general_relief_gross_unearned_income", "GR manual section 70.1"), + ("CA", "ca_smc_general_assistance_countable_income", "GA fact sheet"), + ("CA", "la_general_relief_gross_income", "ABP 392"), + ("CA", "ca_la_ez_save_countable_income", "EZ-SAVE income definition"), + ("CA", "ca_riv_liheap_countable_income", "2025 LIHEAP application"), + ("CA", "ca_riv_share_countable_income", "SHARE program page"), ] -# These totals count CalWORKs cash aid (ca_tanf and tanf), and CalWORKs counts -# railroad benefits as other unearned income, so each dollar of benefits removes -# a dollar of aid from a household on the program. Wages above the CalWORKs limit -# keep the aid at zero with and without the benefit. +# LIHEAP and energy programs whose formulas read Social Security directly and +# take railroad benefits from the program's unearned income list, which already +# named them. R1 confirms the benefits reach the total. +ALREADY_COUNTED = [ + ("CO", "co_leap_countable_income_person", "LEAP income list"), + ("CT", "ct_ceap_countable_income", "CEAP income list"), + ("GA", "ga_liheap_income", "LIHEAP income list"), + ("IA", "ia_liheap_countable_unearned_income", "LIHEAP Appendix C"), + ("ID", "id_liheap_countable_income", "LIHEAP income list"), + ("IN", "in_eap_income", "EAP income list"), + ("KY", "ky_liheap_income", "LIHEAP income list"), + ("LA", "la_liheap_countable_income", "LIHEAP income list"), + ("MT", "mt_liheap_countable_income", "LIHEAP income list"), + ("NV", "nv_liheap_countable_income", "LIHEAP income list"), + ("OH", "oh_liheap_countable_income", "HEAP income list"), + ("SD", "sd_liheap_countable_income", "LIEAP income list"), + ("TN", "tn_liheap_countable_income", "LIHEAP income list"), + ("WA", "wa_liheap_countable_income", "LIHEAP income list"), + ("WY", "wy_lieap_countable_income", "LIEAP income list"), +] + +# These totals count cash aid (ca_tanf, ks_tanf or tanf) that falls as income +# rises, so a benefit the aid program counts removes aid from a household on the +# program. Wages above the aid programs' limits keep the aid at zero with and +# without the benefit. +WAGES_ABOVE_AID_LIMITS = {"employment_income": 60_000} BASE_INPUTS = { - "ca_child_care_countable_income": {"employment_income": 60_000}, - "ca_cpuc_countable_income": {"employment_income": 60_000}, + "ca_child_care_countable_income": WAGES_ABOVE_AID_LIMITS, + "ca_cpuc_countable_income": WAGES_ABOVE_AID_LIMITS, + "ca_la_ez_save_countable_income": WAGES_ABOVE_AID_LIMITS, + "ca_riv_liheap_countable_income": WAGES_ABOVE_AID_LIMITS, + "ca_riv_share_countable_income": WAGES_ABOVE_AID_LIMITS, + "ks_liheap_countable_unearned_income": WAGES_ABOVE_AID_LIMITS, + "la_general_relief_gross_income": WAGES_ABOVE_AID_LIMITS, } +COUNTIES = { + "ca_oc_general_relief_gross_unearned_income": "ORANGE_COUNTY_CA", + "ca_smc_general_assistance_countable_income": "SAN_MATEO_COUNTY_CA", + "la_general_relief_gross_income": "LOS_ANGELES_COUNTY_CA", + "ca_la_ez_save_countable_income": "LOS_ANGELES_COUNTY_CA", + "ca_riv_liheap_countable_income": "RIVERSIDE_COUNTY_CA", + "ca_riv_share_countable_income": "RIVERSIDE_COUNTY_CA", +} -def household(state, inputs): + +def household(state, inputs, county=None): """A single 40-year-old parent with one child, as every program's rules apply.""" people = { "parent": {"age": {YEAR: 40}, **{k: {YEAR: v} for k, v in inputs.items()}}, "child": {"age": {YEAR: 5}}, } members = list(people) + home = {"members": members, "state_code": {YEAR: state}} + if county: + home["county_str"] = {YEAR: county} return { "people": people, "tax_units": {"tax_unit": {"members": members}}, @@ -109,14 +174,16 @@ def household(state, inputs): "child_unit": {"members": ["child"]}, }, "spm_units": {"spm_unit": {"members": members}}, - "households": {"household": {"members": members, "state_code": {YEAR: state}}}, + "households": {"household": home}, } def income(state, variable, inputs): """The program's income for the parent, at the variable's own period.""" period = YEAR if system.variables[variable].definition_period == "year" else MONTH - situation = household(state, {**BASE_INPUTS.get(variable, {}), **inputs}) + situation = household( + state, {**BASE_INPUTS.get(variable, {}), **inputs}, COUNTIES.get(variable) + ) values = Simulation(situation=situation).calculate(variable, period) return float(values[0]) @@ -135,10 +202,12 @@ def received(variable, amount): ) -@pytest.mark.parametrize( - "state, variable, rule", PROGRAMS, ids=[r[1] for r in PROGRAMS] -) -@given(st.floats(min_value=1_200, max_value=200_000).map(lambda x: round(x, 2))) +AMOUNTS = st.floats(min_value=1, max_value=200_000).map(lambda x: round(x, 2)) +R1_ROWS = PROGRAMS + ALREADY_COUNTED + + +@pytest.mark.parametrize("state, variable, rule", R1_ROWS, ids=[r[1] for r in R1_ROWS]) +@given(AMOUNTS) @settings(max_examples=5, deadline=None) def test_railroad_benefits_count_as_received(state, variable, rule, amount): """R1. The whole benefit counts, from the first dollar.""" @@ -148,27 +217,178 @@ def test_railroad_benefits_count_as_received(state, variable, rule, amount): assert change == pytest.approx(received(variable, amount), abs=0.05), rule -PENSION_PROGRAMS = [ - row - for row in PROGRAMS - if row[1] - in { - "al_ccsp_countable_income", - "co_ccap_countable_income", - "md_ccs_countable_income", - "mo_ccs_countable_income", - "wi_shares_countable_income", - } -] +# Programs whose modeled lists have no pension line, so R2 doesn't apply. Illinois +# marks pension_income as exempt in its TANF list; the other lists don't name it. +NO_PENSION_LINE = { + "ca_ala_general_assistance_countable_income_person", + "ca_tanf_other_unearned_income", + "il_aabd_gross_unearned_income", + "il_hbwd_countable_unearned_income", + "il_tanf_gross_unearned_income", + "la_general_relief_gross_income", +} +PENSION_PROGRAMS = [row for row in PROGRAMS if row[1] not in NO_PENSION_LINE] @pytest.mark.parametrize( "state, variable, rule", PENSION_PROGRAMS, ids=[r[1] for r in PENSION_PROGRAMS] ) -@given(st.floats(min_value=1_200, max_value=200_000).map(lambda x: round(x, 2))) +@given(AMOUNTS) @settings(max_examples=5, deadline=None) def test_railroad_benefits_count_as_a_pension_would(state, variable, rule, amount): - """R2. These rules count pensions and annuities without distinction.""" + """R2. Railroad benefits and a pension of the same size count the same.""" railroad = income(state, variable, {"railroad_benefits": amount}) pension = income(state, variable, {"pension_income": amount}) assert railroad == pytest.approx(pension, abs=0.05), rule + + +SOCIAL_SECURITY = { + "social_security", + "social_security_dependents", + "social_security_disability", + "social_security_retirement", + "social_security_survivors", +} + +SIGNAL = "Reads receipt of a benefit as a yes-or-no test, not an income amount." +SOCIAL_SECURITY_ONLY = "The rule or list covers Social Security benefits alone." +UNRESOLVED = "The cited sources don't settle whether railroad benefits count." +_STATES = "parameters/gov/states/" +_COUNTY = "parameters/gov/local/ca/" +_STATE_CODE = "variables/gov/states/" + +# Readers outside the tax code that leave railroad benefits out, and why. A +# variable whose formula reads Social Security directly and takes railroad benefits +# from a list passes through ALREADY_COUNTED instead. +NO_RAILROAD_INCOME = { + _COUNTY + "cc/general_assistance/countable_income/sources.yaml": UNRESOLVED, + _COUNTY + "riv/general_relief/income/sources/unearned.yaml": UNRESOLVED, + _STATES + "ca/cdss/tanf/cash/income/sources/db_unearned.yaml": SOCIAL_SECURITY_ONLY, + # An exemption list: counting railroad benefits here would exempt them. + _STATES + "ca/chhs/wdp/eligibility/income/sources/disability.yaml": ( + "This list exempts disability income, and railroad_benefits doesn't " + "separate disability annuities, so every railroad payment counts." + ), + _STATES + "in/fssa/ccdf/income/sources.yaml": UNRESOLVED, + _STATES + "la/ldoe/ccap/income/unearned_sources.yaml": UNRESOLVED, + _STATES + + "mn/dcyf/ccap/income/countable_income/rsdi_sources.yaml": SOCIAL_SECURITY_ONLY, + _STATES + "ms/dom/wd/eligibility/income/sources/unearned.yaml": UNRESOLVED, + _STATES + "nc/ncdhhs/scca/income/sources.yaml": UNRESOLVED, + _STATES + "nd/dhs/ccap/income/sources.yaml": UNRESOLVED, + _STATES + "or/delc/erdc/income/countable_income/sources.yaml": UNRESOLVED, + _STATES + "sc/oeo/liheap/income/minor_unearned_sources.yaml": SOCIAL_SECURITY_ONLY, + _STATES + "sd/dss/cca/income/sources.yaml": UNRESOLVED, + _STATES + "wa/dcyf/eceap/income_sources.yaml": UNRESOLVED, + "variables/gov/local/ca/cc/ga/eligibility/" + "ca_cc_general_assistance_eligible_person.py": SIGNAL, + _STATE_CODE + "az/des/liheap/points/az_liheap_energy_need_points.py": SIGNAL, + _STATE_CODE + "ca/chhs/wdp/eligibility/ca_wdp_disability_eligible.py": SIGNAL, + _STATE_CODE + "dc/dhs/ccsp/eligibility/qualified_activity_or_need/" + "dc_ccsp_qualified_need_eligible.py": SIGNAL, + _STATE_CODE + "hi/dhs/liheap/hi_liheap_points.py": SIGNAL, + _STATE_CODE + "il/hfs/hbwd/eligibility/il_hbwd_disability_eligible.py": SIGNAL, + _STATE_CODE + "in/ihcda/eap/in_eap.py": SIGNAL, + _STATE_CODE + "la/lhc/liheap/la_liheap.py": SIGNAL, + _STATE_CODE + "md/dhs/fia/paa/md_paa_eligible.py": SIGNAL, + _STATE_CODE + "ms/dom/wd/eligibility/ms_wd_disability_eligible.py": SIGNAL, + _STATE_CODE + "nm/hca/liheap/nm_liheap.py": SIGNAL, + _STATE_CODE + "pa/dhs/liheap/payment/pa_liheap_supplement.py": SIGNAL, + _STATE_CODE + "va/dss/eap/va_eap_disabled.py": SIGNAL, + _STATE_CODE + "wi/dhs/ssp/wi_ssp_countable_for_hud.py": SIGNAL, + _STATE_CODE + "wi/doa/wheap/wi_wheap_household_size.py": SIGNAL, +} + + +def string_lists(node): + """Every list of strings in a parsed parameter file.""" + if isinstance(node, dict): + for value in node.values(): + yield from string_lists(value) + elif isinstance(node, list): + if all(isinstance(item, str) for item in node): + yield node + else: + for value in node: + yield from string_lists(value) + + +def parameter_lists(text): + """Lists in a parameter file. BaseLoader keeps year-zero dates as strings.""" + return list(string_lists(yaml.load(text, Loader=yaml.BaseLoader))) + + +def code_strings(text): + """String constants in a module's code, leaving out docstrings and comments.""" + tree = ast.parse(text) + docstrings = { + id(node.body[0].value) + for node in ast.walk(tree) + if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef)) + and node.body + and isinstance(node.body[0], ast.Expr) + and isinstance(node.body[0].value, ast.Constant) + } + return { + node.value + for node in ast.walk(tree) + if isinstance(node, ast.Constant) + and isinstance(node.value, str) + and id(node) not in docstrings + } + + +def reads_without_railroad(path, text): + """Whether a file reads Social Security and leaves railroad benefits out.""" + if path.suffix == ".yaml": + return any( + SOCIAL_SECURITY & set(names) and "railroad_benefits" not in names + for names in parameter_lists(text) + ) + names = code_strings(text) + return bool(SOCIAL_SECURITY & names) and "railroad_benefits" not in names + + +def readers_without_railroad(): + """Non-tax state and county files that read Social Security without railroad benefits.""" + found = set() + for tree, suffix in (("parameters/gov", ".yaml"), ("variables/gov", ".py")): + for area in ("states", "local"): + for path in (PACKAGE / tree / area).rglob(f"*{suffix}"): + relative = path.relative_to(PACKAGE).as_posix() + if "/tax/" in relative: + continue + text = path.read_text(encoding="utf8") + if "social_security" in text and reads_without_railroad(path, text): + found.add(relative) + return found + + +def test_each_social_security_reader_counts_railroad_benefits_or_says_why(): + """R3. Tax provisions read the three railroad parts, so this test leaves them out.""" + checked_by_r1 = {variable for _, variable, _ in R1_ROWS} + found = { + path + for path in readers_without_railroad() + if Path(path).stem not in checked_by_r1 + } + unexplained = sorted(found - set(NO_RAILROAD_INCOME)) + assert not unexplained, ( + "These read Social Security and leave out railroad_benefits. Read the rule, " + "then add railroad_benefits or add the file to NO_RAILROAD_INCOME with the " + f"reason: {unexplained}" + ) + stale = sorted(set(NO_RAILROAD_INCOME) - found) + assert not stale, f"These no longer leave railroad_benefits out: {stale}" + + +def test_no_list_holds_railroad_benefits_twice(): + """R4. A second entry would count the same dollars twice.""" + repeated = [] + for path in (PACKAGE / "parameters" / "gov").rglob("*.yaml"): + text = path.read_text(encoding="utf8") + if "railroad_benefits" not in text: + continue + if any(names.count("railroad_benefits") > 1 for names in parameter_lists(text)): + repeated.append(path.relative_to(PACKAGE).as_posix()) + assert not repeated, repeated From ba7dc66ace35dc98a7fcdd746efafee33289fd78 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 9 Nov 2026 14:47:25 -0500 Subject: [PATCH 4/7] Count railroad benefits in five more lists and answer the re-review Louisiana CCAP (LAC 28:CLXV.509.A.3.b), North Dakota CCAP (NDAC 75-02-01.3-08 disregards no recurring railroad benefit), Oregon ERDC (OAR 414-175-0035(52)), South Dakota CCA (subsidy manual, section 4) and Washington ECEAP (DCYF 05-006, section 9) now count railroad_benefits, each with a YAML case and an R1 row that fails without the entry. The R3 check now reads each list, and each function or class body, on its own, and counts the railroad total or any of its parts. R4 also fails when a list holds railroad_benefits with one of its parts. R5 finds state and local programs that read the federal TANF or SNAP lists, which leave out railroad benefits (#10551). Los Angeles County General Relief cites GR policy 44-101. Massachusetts CCFA's list explains why railroad payments count as "Retirement income". Utah's case drops a citation that governs the assistance unit. Co-Authored-By: Claude Opus 5.5 --- .../ca/la/general_relief/income_sources.yaml | 6 + .../la/ldoe/ccap/income/unearned_sources.yaml | 6 + .../countable_income/sources/unearned.yaml | 5 + .../states/nd/dhs/ccap/income/sources.yaml | 6 + .../erdc/income/countable_income/sources.yaml | 5 + .../gov/states/sd/dss/cca/income/sources.yaml | 2 + .../states/wa/dcyf/eceap/income_sources.yaml | 7 +- .../ldoe/ccap/la_ccap_countable_income.yaml | 26 ++ .../ccap/income/nd_ccap_countable_income.yaml | 26 ++ .../delc/erdc/or_erdc_countable_income.yaml | 26 ++ .../cca/income/sd_cca_countable_income.yaml | 26 ++ .../dwf/ccap/income/ut_ccap_gross_income.yaml | 3 +- .../wa/dcyf/eceap/wa_eceap_family_income.yaml | 27 +++ .../test_state_benefit_railroad_income.py | 228 +++++++++++++++--- 14 files changed, 366 insertions(+), 33 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/states/wa/dcyf/eceap/wa_eceap_family_income.yaml diff --git a/policyengine_us/parameters/gov/local/ca/la/general_relief/income_sources.yaml b/policyengine_us/parameters/gov/local/ca/la/general_relief/income_sources.yaml index 6552547d5d5..a2ed9242991 100644 --- a/policyengine_us/parameters/gov/local/ca/la/general_relief/income_sources.yaml +++ b/policyengine_us/parameters/gov/local/ca/la/general_relief/income_sources.yaml @@ -12,6 +12,10 @@ values: - sstb_self_employment_income - ssi - social_security + # GR policy 44-101 deducts "all net income to the General Relief (GR) + # household" from the grant, and its current income takes in "the cash + # (including loans), food, housing, and other items that are received by + # and available to the GR household", so railroad benefits count. - railroad_benefits - tanf metadata: @@ -21,3 +25,5 @@ metadata: reference: - title: ABP 392 General Relief Information Sheet Form rev 07-2021 href: https://drive.google.com/file/d/1Oc7UuRFxJj-eDwTeox92PtmRVGnG9RjW/view?usp=sharing + - title: DPSS ePolicy, General Relief 44-101, Income + href: https://my.dpss.lacounty.gov/public/en/home/epolicy/program/gr/income/income.html diff --git a/policyengine_us/parameters/gov/states/la/ldoe/ccap/income/unearned_sources.yaml b/policyengine_us/parameters/gov/states/la/ldoe/ccap/income/unearned_sources.yaml index 668df3bbe46..c40dd237f57 100644 --- a/policyengine_us/parameters/gov/states/la/ldoe/ccap/income/unearned_sources.yaml +++ b/policyengine_us/parameters/gov/states/la/ldoe/ccap/income/unearned_sources.yaml @@ -5,6 +5,9 @@ values: - ssi - veterans_benefits - pension_income + # LAC 28:CLXV.509.A.3.b counts recurring "retirement benefits" (iv) and + # "disability benefits" (v), which railroad retirement annuities are. + - railroad_benefits - disability_benefits - total_unemployment_compensation - workers_compensation @@ -18,6 +21,9 @@ values: - ssi - veterans_benefits - pension_income + # LAC 28:CLXV.509.A.3.b counts recurring "retirement benefits" (iv) and + # "disability benefits" (v), which railroad retirement annuities are. + - railroad_benefits - disability_benefits - total_unemployment_compensation - workers_compensation diff --git a/policyengine_us/parameters/gov/states/ma/eec/ccfa/income/countable_income/sources/unearned.yaml b/policyengine_us/parameters/gov/states/ma/eec/ccfa/income/countable_income/sources/unearned.yaml index 1eeda01eab0..d282a883ba0 100644 --- a/policyengine_us/parameters/gov/states/ma/eec/ccfa/income/countable_income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/states/ma/eec/ccfa/income/countable_income/sources/unearned.yaml @@ -20,6 +20,11 @@ values: # (all types), SSI, child support, and veterans disability benefits, and # the countable list becomes exclusive; interest income is not on it. # CCFA mapping assumption: veterans_benefits contains disability payments. + # railroad_benefits counts as "Retirement income": the Railroad Retirement + # Board pays it under the Railroad Retirement Act, and the exclusion of + # "Social Security Income" names Social Security benefits. The input doesn't + # separate railroad disability and survivor annuities, which the list may + # not reach, so the model counts every railroad payment. 2023-10-01: - farm_rent_income - estate_income diff --git a/policyengine_us/parameters/gov/states/nd/dhs/ccap/income/sources.yaml b/policyengine_us/parameters/gov/states/nd/dhs/ccap/income/sources.yaml index f3d11f8a4eb..51c0f852d0c 100644 --- a/policyengine_us/parameters/gov/states/nd/dhs/ccap/income/sources.yaml +++ b/policyengine_us/parameters/gov/states/nd/dhs/ccap/income/sources.yaml @@ -16,6 +16,10 @@ values: # Regular periodic withdrawals from annuities, pensions, and other # retirement plans are countable income (exclusion #8 exception). - pension_income + # NDAC 75-02-01.3-08 disregards twenty types of income, and recurring + # railroad retirement benefits are not one of them; exclusion #1 below + # reaches only retroactive lump sums. + - railroad_benefits - alimony_income - child_support_received @@ -93,3 +97,5 @@ metadata: href: https://www.nd.gov/dhs/policymanuals/40028/40028.htm - title: NDAC 75-02-01.3-07 href: https://ndlegis.gov/prod/acdata/pdf/75-02-01.3.pdf + - title: NDAC 75-02-01.3-08, disregarded income + href: https://ndlegis.gov/prod/acdata/pdf/75-02-01.3.pdf#page=5 diff --git a/policyengine_us/parameters/gov/states/or/delc/erdc/income/countable_income/sources.yaml b/policyengine_us/parameters/gov/states/or/delc/erdc/income/countable_income/sources.yaml index 5406d40ab97..346ab7f9b9e 100644 --- a/policyengine_us/parameters/gov/states/or/delc/erdc/income/countable_income/sources.yaml +++ b/policyengine_us/parameters/gov/states/or/delc/erdc/income/countable_income/sources.yaml @@ -8,6 +8,11 @@ values: - social_security - ssi - pension_income + # OAR 414-175-0035(52)(a)(A) and (c)(A) count monthly benefits employees + # receive only when they retire as unearned income. OAR 414-175-0030(1)(c) + # counts income no rule excludes, and 414-175-0035 excludes no railroad + # benefit. + - railroad_benefits - retirement_distributions - military_retirement_income - total_unemployment_compensation diff --git a/policyengine_us/parameters/gov/states/sd/dss/cca/income/sources.yaml b/policyengine_us/parameters/gov/states/sd/dss/cca/income/sources.yaml index f80335bff2b..e8958aabeff 100644 --- a/policyengine_us/parameters/gov/states/sd/dss/cca/income/sources.yaml +++ b/policyengine_us/parameters/gov/states/sd/dss/cca/income/sources.yaml @@ -14,6 +14,8 @@ values: - workers_compensation - veterans_benefits # "VA benefits"; VA disability is non-countable - pension_income # "pensions" and "retirement" + # Railroad retirement annuities are pensions and retirement benefits. + - railroad_benefits - alimony_income - child_support_received # "child support" and "back child support" - interest_income # "interest income" diff --git a/policyengine_us/parameters/gov/states/wa/dcyf/eceap/income_sources.yaml b/policyengine_us/parameters/gov/states/wa/dcyf/eceap/income_sources.yaml index f1493945d32..5d7e6633b5b 100644 --- a/policyengine_us/parameters/gov/states/wa/dcyf/eceap/income_sources.yaml +++ b/policyengine_us/parameters/gov/states/wa/dcyf/eceap/income_sources.yaml @@ -6,6 +6,9 @@ values: - tanf - ssi - social_security + # DCYF 05-006 Section 9 lists "Social Security or other retirement + # benefits" and "Retirement or pension plans". + - railroad_benefits - child_support_received - total_unemployment_compensation - veterans_benefits @@ -16,7 +19,7 @@ metadata: period: year label: Washington ECEAP family income sources reference: - - title: DCYF 05-006 ECEAP Prescreen and Application (Combined Form), Section 9 - Income Received by Child's Parent(s) or Guardian(s) - href: https://olycap.org/hubfs/PDF%20Attachments/ECEAP%202025-2026%20(EN).pdf#page=6 + - title: DCYF 05-006 ECEAP Prescreen and Application (Combined Form, revised 04/2025), Section 9 - Income Received by Child's Parent(s) or Guardian(s) + href: https://www.dcyf.wa.gov/sites/default/files/forms/05-006.pdf#page=7 - title: RCW 43.216.505 Eligible children href: https://app.leg.wa.gov/RCW/default.aspx?cite=43.216.505 diff --git a/policyengine_us/tests/policy/baseline/gov/states/la/ldoe/ccap/la_ccap_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/la/ldoe/ccap/la_ccap_countable_income.yaml index 00e96802360..0d8d5d2802b 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/la/ldoe/ccap/la_ccap_countable_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/la/ldoe/ccap/la_ccap_countable_income.yaml @@ -144,3 +144,29 @@ state_code: LA output: la_ccap_countable_income: 0 + +- name: Case 8, railroad retirement benefits count as unearned income. + # LAC 28:CLXV.509.A.3.b counts recurring retirement (iv) and disability (v) + # benefits of all household members. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + railroad_benefits: 12_000 + child: + age: 4 + tax_units: + tax_unit: + members: [parent, child] + spm_units: + spm_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: LA + output: + # 12,000 / 12 = 1,000 + la_ccap_countable_income: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nd/dhs/ccap/income/nd_ccap_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/nd/dhs/ccap/income/nd_ccap_countable_income.yaml index 451b29f017a..98d39548a4f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nd/dhs/ccap/income/nd_ccap_countable_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nd/dhs/ccap/income/nd_ccap_countable_income.yaml @@ -152,3 +152,29 @@ output: # 3,000 + 500 (19-year-old's earnings counted) = 3,500 nd_ccap_countable_income: 3_500 + +- name: Case 8, railroad retirement benefits count as unearned income. + # NDAC 75-02-01.3-08 disregards no recurring railroad retirement benefit. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + railroad_benefits: 12_000 + child: + age: 4 + tax_units: + tax_unit: + members: [parent, child] + spm_units: + spm_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: ND + output: + # 12,000 / 12 = 1,000 + nd_ccap_gross_income: 1_000 + nd_ccap_countable_income: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/or/delc/erdc/or_erdc_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/or/delc/erdc/or_erdc_countable_income.yaml index 81bf6c33506..3e57537025f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/or/delc/erdc/or_erdc_countable_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/or/delc/erdc/or_erdc_countable_income.yaml @@ -82,3 +82,29 @@ # Pension 12,000/year = 1,000/month, rental 6,000/year = 500/month, and # workers compensation 4,800/year = 400/month: 1,000 + 500 + 400 = 1,900. or_erdc_countable_income: 1_900 + +- name: Case 4, railroad retirement benefits count as unearned income. + # OAR 414-175-0035(52)(c)(A) counts monthly retirement benefits as unearned + # income. + period: 2027-01 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + railroad_benefits: 12_000 + child: + age: 4 + tax_units: + tax_unit: + members: [parent, child] + spm_units: + spm_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: OR + output: + # 12,000 / 12 = 1,000 + or_erdc_countable_income: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/sd/dss/cca/income/sd_cca_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/sd/dss/cca/income/sd_cca_countable_income.yaml index 30510bbab1d..f438012e7e7 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/sd/dss/cca/income/sd_cca_countable_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/sd/dss/cca/income/sd_cca_countable_income.yaml @@ -302,3 +302,29 @@ state_code: SD output: sd_cca_countable_income: 3_000 + +- name: Case 14, railroad retirement benefits count as unearned income. + # Subsidy Manual Section 4 counts "pensions" and "retirement". + period: 2027-01 + absolute_error_margin: 0.1 + input: + people: + parent: + age: 40 + railroad_benefits: 12_000 + child: + age: 4 + tax_units: + tax_unit: + members: [parent, child] + spm_units: + spm_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: SD + output: + # 12,000 / 12 = 1,000, with no disregard on unearned income. + sd_cca_gross_income: 1_000 + sd_cca_countable_income: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ut/dwf/ccap/income/ut_ccap_gross_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/ut/dwf/ccap/income/ut_ccap_gross_income.yaml index 0d07cadf242..d0180a9775a 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ut/dwf/ccap/income/ut_ccap_gross_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ut/dwf/ccap/income/ut_ccap_gross_income.yaml @@ -112,8 +112,7 @@ ut_ccap_gross_income: 3_000 - name: Case 5, railroad retirement benefits count as income. - # R986-200-235(2)(a) counts "pensions and annuities such as Railroad Retirement", - # and R986-700-710(2)(a) applies R986-200 to child care income. + # R986-200-235(2)(a) counts "pensions and annuities such as Railroad Retirement". period: 2026-01 absolute_error_margin: 0.01 input: diff --git a/policyengine_us/tests/policy/baseline/gov/states/wa/dcyf/eceap/wa_eceap_family_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/wa/dcyf/eceap/wa_eceap_family_income.yaml new file mode 100644 index 00000000000..6b14b503a0a --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/wa/dcyf/eceap/wa_eceap_family_income.yaml @@ -0,0 +1,27 @@ +- name: Case 1, railroad retirement benefits count as family income. + # DCYF 05-006 Section 9 lists "Social Security or other retirement benefits". + # Wages above TANF's limit keep TANF, which the list also counts, at zero. + # TANF counts wages before labor supply responses, so the case sets those. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income_before_lsr: 60_000 + railroad_benefits: 12_000 + child: + age: 4 + tax_units: + tax_unit: + members: [parent, child] + spm_units: + spm_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: WA + output: + # 60,000 + 12,000 = 72,000 + wa_eceap_family_income: 72_000 diff --git a/policyengine_us/tests/test_state_benefit_railroad_income.py b/policyengine_us/tests/test_state_benefit_railroad_income.py index aa03c35cafb..a540608a25e 100644 --- a/policyengine_us/tests/test_state_benefit_railroad_income.py +++ b/policyengine_us/tests/test_state_benefit_railroad_income.py @@ -16,14 +16,22 @@ row cites. - R2: the same dollars count the same whether a person receives them as railroad benefits or as a pension, for programs whose lists count pensions. -- R3: every state or county list and variable outside the tax code that reads a - Social Security amount either counts railroad benefits or appears in +- R3: every state or county list, and every function or class body of a state or + county variable, outside the tax code that reads a Social Security amount + either counts railroad benefits (the total or one of its parts) or appears in NO_RAILROAD_INCOME with the reason it doesn't. A new reader fails this test - until its author reads the rule and does one or the other. -- R4: no list holds railroad_benefits twice. + until its author reads the rule and does one or the other. R3 reads names + written out in full, so a name built with an f-string or concatenation, or a + scalar parameter, passes unseen. +- R4: no list holds railroad_benefits twice, or with one of its parts. +- R5: a state or county program that reads a federal income total or list takes + that list's sources. Each federal total or list that counts Social Security + without railroad benefits, and that a state or county program reads, appears in + FEDERAL_GAPS, and an entry ends when its federal list counts railroad benefits. """ import ast +import inspect from functools import lru_cache from pathlib import Path @@ -64,6 +72,7 @@ ("KS", "ks_ccap_countable_income", "KEESM 6200"), ("KS", "ks_liheap_countable_unearned_income", "KEESM 13361"), ("KY", "ky_ccap_countable_income", "922 KAR 2:160"), + ("LA", "la_ccap_countable_income", "LAC 28:CLXV.509.A.3.b"), ("MA", "ma_tcap_gross_unearned_income", "106 CMR 704.210(B)"), ("MA", "ma_ccfa_countable_income_person", "EEC interim policies, Oct. 2023"), ("MD", "md_ccs_countable_income", "COMAR 13A.14.06.02"), @@ -80,20 +89,24 @@ ("NC", "nc_lieap_gross_income_person", "EP-300 300.09 and FNS 300.01 B"), ("NC", "nc_tanf_countable_gross_unearned_income", "WF-114"), ("ND", "nd_tanf_gross_unearned_income", "Manual 400-19-55-20-15"), + ("ND", "nd_ccap_gross_income", "NDAC 75-02-01.3-08"), ("NH", "nh_ccap_countable_income", "He-C 6910.06"), ("NJ", "nj_liheap_countable_income_person", "LIHEAP handbook 2.3.D"), ("NJ", "nj_ccap_countable_income", "FFY 2025-2027 CCDF plan"), ("NV", "nv_ccdp_countable_income", "Child care manual 306"), ("OK", "ok_ccs_gross_income", "OAC 340:40-7-11(c)(2)"), + ("OR", "or_erdc_countable_income", "OAR 414-175-0035(52)"), ("PA", "pa_ccw_countable_income", "55 Pa. Code ch. 3042 app. A, part I(F)"), ("RI", "ri_ccap_countable_income", "218-RICR-20-00-4"), ("SC", "sc_ccap_countable_income", "CCS policy manual"), + ("SD", "sd_cca_gross_income", "CCA subsidy manual, section 4"), ("TX", "tx_fpp_countable_unearned_income", "FPP manual 9000"), ("TX", "tx_ceap_countable_income_person", "10 TAC 6.4"), ("UT", "ut_ccap_gross_income", "Utah Admin. Code R986-200-235(2)(a)"), ("VA", "va_ccsp_countable_income", "8 VAC 20-790-40(H)"), ("VT", "vt_ccfap_countable_income", "CCFAP regulations"), ("WA", "wa_wccc_countable_income", "WAC 110-15-0060(8)"), + ("WA", "wa_eceap_family_income", "DCYF 05-006, section 9"), ("WI", "wi_shares_countable_income", "Wisconsin Shares handbook"), ("WV", "wv_ccap_countable_income", "Child care policy 5.2.4"), ("WY", "wy_power_gross_unearned_income", "SNAP and POWER manual"), @@ -102,7 +115,7 @@ ("CA", "ca_ala_general_assistance_countable_income_person", "GA regulations"), ("CA", "ca_oc_general_relief_gross_unearned_income", "GR manual section 70.1"), ("CA", "ca_smc_general_assistance_countable_income", "GA fact sheet"), - ("CA", "la_general_relief_gross_income", "ABP 392"), + ("CA", "la_general_relief_gross_income", "GR policy 44-101"), ("CA", "ca_la_ez_save_countable_income", "EZ-SAVE income definition"), ("CA", "ca_riv_liheap_countable_income", "2025 LIHEAP application"), ("CA", "ca_riv_share_countable_income", "SHARE program page"), @@ -143,6 +156,7 @@ "ca_riv_share_countable_income": WAGES_ABOVE_AID_LIMITS, "ks_liheap_countable_unearned_income": WAGES_ABOVE_AID_LIMITS, "la_general_relief_gross_income": WAGES_ABOVE_AID_LIMITS, + "wa_eceap_family_income": WAGES_ABOVE_AID_LIMITS, } COUNTIES = { @@ -248,10 +262,34 @@ def test_railroad_benefits_count_as_a_pension_would(state, variable, rule, amoun "social_security_disability", "social_security_retirement", "social_security_survivors", + "tax_exempt_social_security", } +# The total and its parts. PolicyEngine/policyengine-us#10696 splits +# railroad_benefits into the tier 1, taxable tier 2 and tax-free tier 2 parts and +# defines the federal tax amounts. +RAILROAD = { + "railroad_benefits", + "railroad_social_security_equivalent_benefits", + "taxable_railroad_pension_benefits", + "tax_exempt_railroad_pension_benefits", + "taxable_railroad_social_security_equivalent_benefits", + "tax_exempt_railroad_social_security_equivalent_benefits", + "taxable_railroad_benefits", +} +RAILROAD_PARTS = RAILROAD - {"railroad_benefits"} + SIGNAL = "Reads receipt of a benefit as a yes-or-no test, not an income amount." SOCIAL_SECURITY_ONLY = "The rule or list covers Social Security benefits alone." +DISABILITY_ONLY = ( + "The list holds disability income, and railroad_benefits doesn't separate " + "railroad disability annuities. CalWORKs counts railroad benefits with its " + "other unearned income (ca_tanf_other_unearned_income, an R1 row)." +) +MINOR_SOCIAL_SECURITY_OR_DISABILITY = ( + "The guide counts a minor's Social Security or disability income, and " + "railroad_benefits doesn't separate railroad disability annuities." +) UNRESOLVED = "The cited sources don't settle whether railroad benefits count." _STATES = "parameters/gov/states/" _COUNTY = "parameters/gov/local/ca/" @@ -263,23 +301,27 @@ def test_railroad_benefits_count_as_a_pension_would(state, variable, rule, amoun NO_RAILROAD_INCOME = { _COUNTY + "cc/general_assistance/countable_income/sources.yaml": UNRESOLVED, _COUNTY + "riv/general_relief/income/sources/unearned.yaml": UNRESOLVED, - _STATES + "ca/cdss/tanf/cash/income/sources/db_unearned.yaml": SOCIAL_SECURITY_ONLY, + _STATES + "ca/cdss/tanf/cash/income/sources/db_unearned.yaml": DISABILITY_ONLY, # An exemption list: counting railroad benefits here would exempt them. _STATES + "ca/chhs/wdp/eligibility/income/sources/disability.yaml": ( "This list exempts disability income, and railroad_benefits doesn't " "separate disability annuities, so every railroad payment counts." ), _STATES + "in/fssa/ccdf/income/sources.yaml": UNRESOLVED, - _STATES + "la/ldoe/ccap/income/unearned_sources.yaml": UNRESOLVED, _STATES + "mn/dcyf/ccap/income/countable_income/rsdi_sources.yaml": SOCIAL_SECURITY_ONLY, _STATES + "ms/dom/wd/eligibility/income/sources/unearned.yaml": UNRESOLVED, _STATES + "nc/ncdhhs/scca/income/sources.yaml": UNRESOLVED, - _STATES + "nd/dhs/ccap/income/sources.yaml": UNRESOLVED, - _STATES + "or/delc/erdc/income/countable_income/sources.yaml": UNRESOLVED, - _STATES + "sc/oeo/liheap/income/minor_unearned_sources.yaml": SOCIAL_SECURITY_ONLY, - _STATES + "sd/dss/cca/income/sources.yaml": UNRESOLVED, - _STATES + "wa/dcyf/eceap/income_sources.yaml": UNRESOLVED, + _STATES + "sc/oeo/liheap/income/minor_unearned_sources.yaml": ( + MINOR_SOCIAL_SECURITY_OR_DISABILITY + ), + # RCW 84.36.383(7)(g) counts Social Security and railroad retirement benefits + # that AGI leaves out. The tax-free railroad amounts come with the federal + # treatment in PolicyEngine/policyengine-us#10696, and + # PolicyEngine/policyengine-us#10722 adds them to this list. + _STATES + "wa/dor/property_tax_exemption/senior_disabled/income/sources.yaml": ( + "Main has no tax-free railroad amount for this list to add." + ), "variables/gov/local/ca/cc/ga/eligibility/" "ca_cc_general_assistance_eligible_person.py": SIGNAL, _STATE_CODE + "az/des/liheap/points/az_liheap_energy_need_points.py": SIGNAL, @@ -318,8 +360,20 @@ def parameter_lists(text): return list(string_lists(yaml.load(text, Loader=yaml.BaseLoader))) -def code_strings(text): - """String constants in a module's code, leaving out docstrings and comments.""" +def code_strings(nodes, docstrings): + """String constants under the nodes, leaving out docstrings and comments.""" + return { + node.value + for root in nodes + for node in ast.walk(root) + if isinstance(node, ast.Constant) + and isinstance(node.value, str) + and id(node) not in docstrings + } + + +def code_scopes(text): + """The string constants of each function, and of each class body outside them.""" tree = ast.parse(text) docstrings = { id(node.body[0].value) @@ -329,24 +383,25 @@ def code_strings(text): and isinstance(node.body[0], ast.Expr) and isinstance(node.body[0].value, ast.Constant) } - return { - node.value - for node in ast.walk(tree) - if isinstance(node, ast.Constant) - and isinstance(node.value, str) - and id(node) not in docstrings - } + functions = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda) + for node in ast.walk(tree): + if isinstance(node, functions): + yield code_strings([node], docstrings) + elif isinstance(node, ast.ClassDef): + body = [child for child in node.body if not isinstance(child, functions)] + yield code_strings(body, docstrings) + + +def lacks_railroad(names): + """Whether names count Social Security and no railroad benefit.""" + return bool(SOCIAL_SECURITY & set(names)) and not RAILROAD & set(names) def reads_without_railroad(path, text): """Whether a file reads Social Security and leaves railroad benefits out.""" if path.suffix == ".yaml": - return any( - SOCIAL_SECURITY & set(names) and "railroad_benefits" not in names - for names in parameter_lists(text) - ) - names = code_strings(text) - return bool(SOCIAL_SECURITY & names) and "railroad_benefits" not in names + return any(lacks_railroad(names) for names in parameter_lists(text)) + return any(lacks_railroad(names) for names in code_scopes(text)) def readers_without_railroad(): @@ -383,12 +438,127 @@ def test_each_social_security_reader_counts_railroad_benefits_or_says_why(): def test_no_list_holds_railroad_benefits_twice(): - """R4. A second entry would count the same dollars twice.""" + """R4. A second entry, or the total with a part, would count dollars twice.""" repeated = [] for path in (PACKAGE / "parameters" / "gov").rglob("*.yaml"): text = path.read_text(encoding="utf8") if "railroad_benefits" not in text: continue - if any(names.count("railroad_benefits") > 1 for names in parameter_lists(text)): + if any( + names.count("railroad_benefits") > 1 + or ("railroad_benefits" in names and RAILROAD_PARTS & set(names)) + for names in parameter_lists(text) + ): repeated.append(path.relative_to(PACKAGE).as_posix()) assert not repeated, repeated + + +# Federal totals and lists that state and county programs read. Each counts +# Social Security and leaves railroad benefits out, so the programs that read +# them leave railroad benefits out too, although the rules count them: SNAP +# counts "Annuities; pensions; retirement, veteran's, or disability benefits" +# (7 CFR 273.9(b)(2)(ii)), and Michigan's BEM 503, for one state's TANF, counts +# the "gross benefit amount" the Railroad Retirement Board pays. +# PolicyEngine/policyengine-us#10551 tracks both lists. +FEDERAL_GAPS = { + # 64 TANF variables in 37 states read the baseline unearned income list. + "tanf_gross_unearned_income": "TANF baseline unearned income list", + # Nebraska LIHEAP (476 NAC 2-002.01). + "gov.usda.snap.income.sources.unearned": "SNAP unearned income list", +} + + +def is_state_or_local(relative): + """Whether a path below the package sits in the states or local tree.""" + parts = Path(relative).parts + return len(parts) > 2 and parts[2] in ("states", "local") + + +def federal_lists_without_railroad(): + """Dotted paths of federal parameter lists that count Social Security alone.""" + found = set() + for path in (PACKAGE / "parameters" / "gov").rglob("*.yaml"): + relative = path.relative_to(PACKAGE).as_posix() + if is_state_or_local(relative): + continue + text = path.read_text(encoding="utf8") + if "social_security" in text and reads_without_railroad(path, text): + parts = path.relative_to(PACKAGE / "parameters").with_suffix("").parts + found.add(".".join(parts)) + return found + + +def federal_totals_without_railroad(lists): + """Federal variables that add Social Security and no railroad benefit.""" + found = set() + for name, variable in system.variables.items(): + if name == "social_security": + continue + try: + source = Path(inspect.getfile(type(variable))).resolve() + relative = source.relative_to(PACKAGE).as_posix() + except (TypeError, ValueError): + continue + if is_state_or_local(relative): + continue + adds = variable.adds + if (isinstance(adds, str) and adds in lists) or ( + isinstance(adds, (list, tuple)) and lacks_railroad(adds) + ): + found.add(name) + return found + + +def attribute_chains(tree): + """Dotted parameter paths from gov onward, and every attribute name.""" + chains, attributes = set(), set() + for node in ast.walk(tree): + if isinstance(node, ast.Attribute): + attributes.add(node.attr) + parts = [] + current = node + while isinstance(current, ast.Attribute): + parts.append(current.attr) + current = current.value + parts.reverse() + if "gov" in parts: + chains.add(".".join(parts[parts.index("gov") :])) + return chains, attributes + + +def inherited_federal_sources(): + """Federal totals and lists without railroad benefits that state code reads.""" + lists = federal_lists_without_railroad() + totals = federal_totals_without_railroad(lists) + found = set() + for tree, suffix in (("parameters/gov", ".yaml"), ("variables/gov", ".py")): + for area in ("states", "local"): + for path in (PACKAGE / tree / area).rglob(f"*{suffix}"): + if "/tax/" in path.relative_to(PACKAGE).as_posix(): + continue + text = path.read_text(encoding="utf8") + if suffix == ".yaml": + for names in parameter_lists(text): + found |= totals & set(names) + continue + parsed = ast.parse(text) + found |= totals & code_strings([parsed], set()) + chains, attributes = attribute_chains(parsed) + for listed in lists: + parent, leaf = listed.rsplit(".", 1) + if listed in chains or (parent in chains and leaf in attributes): + found.add(listed) + return found, lists | totals + + +def test_state_programs_that_read_a_federal_list_without_railroad_are_known(): + """R5. A program that reads a federal total or list takes its sources.""" + inherited, without_railroad = inherited_federal_sources() + unknown = sorted(inherited - set(FEDERAL_GAPS)) + assert not unknown, ( + "State or county code reads these federal totals or lists, which count " + "Social Security and leave out railroad benefits. Read the program's rule, " + f"then count railroad benefits or add the source to FEDERAL_GAPS: {unknown}" + ) + fixed = sorted(set(FEDERAL_GAPS) - without_railroad) + assert not fixed, f"These now count railroad benefits; remove them: {fixed}" From 781f37d74fa47f95171a5dad2aeb38d84454849f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 11 Nov 2026 01:02:26 -0500 Subject: [PATCH 5/7] Answer the 57fa735519 review of the railroad income audit - Record Washington's new disability retirement test (#10330) as a yes-or-no signal, so R3 passes after the merge of main. - R3 also scans each variable file's top level, so a module-level list of Social Security names no longer passes unseen. - R4 checks variable adds lists as well as parameter lists, and fails on any railroad amount listed twice or with a part it holds. - R5 follows federal lists through the variables that add or read them and up to two more formulas, which finds the school meals ratio and the Medicaid and ACA MAGI measures that state programs read. Its entries end when their source counts railroad benefits or no state code reads them. - State SC LIHEAP's reason as the guide's wording, and fix the docstring's R1 range. Co-Authored-By: Claude Opus 5.5 --- changelog.d/state-benefit-railroad.fixed.md | 2 +- .../test_state_benefit_railroad_income.py | 237 ++++++++++++------ 2 files changed, 163 insertions(+), 76 deletions(-) diff --git a/changelog.d/state-benefit-railroad.fixed.md b/changelog.d/state-benefit-railroad.fixed.md index d903e0839d9..0e908abdc95 100644 --- a/changelog.d/state-benefit-railroad.fixed.md +++ b/changelog.d/state-benefit-railroad.fixed.md @@ -1 +1 @@ -Count Railroad Retirement Act benefits as income in the state and county benefit programs whose rules name them, count pensions or retirement benefits, or count all income they do not exclude. +Count Railroad Retirement Act benefits as income in the state and county benefit programs whose rules name them, count pensions, retirement benefits or federal benefits, or count all income they do not exclude. diff --git a/policyengine_us/tests/test_state_benefit_railroad_income.py b/policyengine_us/tests/test_state_benefit_railroad_income.py index a540608a25e..772161a0097 100644 --- a/policyengine_us/tests/test_state_benefit_railroad_income.py +++ b/policyengine_us/tests/test_state_benefit_railroad_income.py @@ -7,27 +7,35 @@ Invariants: -- R1: for every program below and any benefit amount, railroad benefits raise - the program's income by the amount received, at the variable's period. Where - the income total also counts cash aid that the same benefit reduces, the test - holds that aid at zero, so the row isolates the income list from the aid's own - income test. R1 shows that the list entry reaches the program's income. Whether - a rule reaches railroad benefits is a matter of the rule's text, which each - row cites. +- R1: for every program below and amounts from $1 to $200,000, railroad benefits + raise the program's income by the amount received, at the variable's period. + Where the income total also counts cash aid that the same benefit reduces, the + test holds that aid at zero, so the row isolates the income list from the aid's + own income test. R1 shows that the list entry reaches the program's income. + Whether a rule reaches railroad benefits is a matter of the rule's text, which + each row cites. - R2: the same dollars count the same whether a person receives them as railroad benefits or as a pension, for programs whose lists count pensions. -- R3: every state or county list, and every function or class body of a state or - county variable, outside the tax code that reads a Social Security amount - either counts railroad benefits (the total or one of its parts) or appears in +- R3 scans every state and county list outside the tax code, and the top level + and each function or class body of every state and county variable outside the + tax code. Each one that reads a Social Security amount counts railroad benefits + (the total or one of its parts), belongs to an R1 variable, or appears in NO_RAILROAD_INCOME with the reason it doesn't. A new reader fails this test until its author reads the rule and does one or the other. R3 reads names written out in full, so a name built with an f-string or concatenation, or a scalar parameter, passes unseen. -- R4: no list holds railroad_benefits twice, or with one of its parts. -- R5: a state or county program that reads a federal income total or list takes - that list's sources. Each federal total or list that counts Social Security - without railroad benefits, and that a state or county program reads, appears in - FEDERAL_GAPS, and an entry ends when its federal list counts railroad benefits. +- R4: no parameter list or variable adds list holds a railroad amount twice, or + holds a railroad amount with a part of it. +- R5: a state or county program that reads a federal total, list or test takes + its sources. R5 finds each federal list that holds Social Security without + railroad benefits, each federal variable that adds such a list or reads it in a + formula, and each federal variable built on those through up to two more + formulas that name no railroad amount. Two steps reach the MAGI amounts and + poverty ratios that state code reads; a third reaches eligibility tests and + benefit amounts, such as is_snap_eligible and aca_ptc. State or county code + that reads one of these must appear in FEDERAL_GAPS, and an entry ends when its + source counts railroad benefits or no state or county code reads it. A new + reader of a source already in FEDERAL_GAPS passes. """ import ast @@ -278,6 +286,18 @@ def test_railroad_benefits_count_as_a_pension_would(state, variable, rule, amoun "taxable_railroad_benefits", } RAILROAD_PARTS = RAILROAD - {"railroad_benefits"} +# Each railroad amount and the amounts it holds. +RAILROAD_HOLDS = { + "railroad_benefits": RAILROAD_PARTS, + "railroad_social_security_equivalent_benefits": { + "taxable_railroad_social_security_equivalent_benefits", + "tax_exempt_railroad_social_security_equivalent_benefits", + }, + "taxable_railroad_benefits": { + "taxable_railroad_social_security_equivalent_benefits", + "taxable_railroad_pension_benefits", + }, +} SIGNAL = "Reads receipt of a benefit as a yes-or-no test, not an income amount." SOCIAL_SECURITY_ONLY = "The rule or list covers Social Security benefits alone." @@ -287,8 +307,9 @@ def test_railroad_benefits_count_as_a_pension_would(state, variable, rule, amoun "other unearned income (ca_tanf_other_unearned_income, an R1 row)." ) MINOR_SOCIAL_SECURITY_OR_DISABILITY = ( - "The guide counts a minor's Social Security or disability income, and " - "railroad_benefits doesn't separate railroad disability annuities." + "The guide counts a minor's Social Security or disability income. It names " + "Social Security, not railroad benefits, and a minor's railroad annuity pays " + "a child or survivor, not a disabled worker." ) UNRESOLVED = "The cited sources don't settle whether railroad benefits count." _STATES = "parameters/gov/states/" @@ -337,6 +358,8 @@ def test_railroad_benefits_count_as_a_pension_would(state, variable, rule, amoun _STATE_CODE + "nm/hca/liheap/nm_liheap.py": SIGNAL, _STATE_CODE + "pa/dhs/liheap/payment/pa_liheap_supplement.py": SIGNAL, _STATE_CODE + "va/dss/eap/va_eap_disabled.py": SIGNAL, + _STATE_CODE + "wa/dor/property_tax_exemption/senior_disabled/" + "wa_pte_retired_due_to_disability.py": SIGNAL, _STATE_CODE + "wi/dhs/ssp/wi_ssp_countable_for_hud.py": SIGNAL, _STATE_CODE + "wi/doa/wheap/wi_wheap_household_size.py": SIGNAL, } @@ -373,7 +396,7 @@ def code_strings(nodes, docstrings): def code_scopes(text): - """The string constants of each function, and of each class body outside them.""" + """String constants of the module's top level, each function and each class body.""" tree = ast.parse(text) docstrings = { id(node.body[0].value) @@ -384,6 +407,14 @@ def code_scopes(text): and isinstance(node.body[0].value, ast.Constant) } functions = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda) + yield code_strings( + [ + child + for child in tree.body + if not isinstance(child, (*functions, ast.ClassDef)) + ], + docstrings, + ) for node in ast.walk(tree): if isinstance(node, functions): yield code_strings([node], docstrings) @@ -437,35 +468,60 @@ def test_each_social_security_reader_counts_railroad_benefits_or_says_why(): assert not stale, f"These no longer leave railroad_benefits out: {stale}" +def counts_railroad_twice(names): + """Whether a list holds a railroad amount twice, or with a part of it.""" + return any(names.count(name) > 1 for name in RAILROAD & set(names)) or any( + RAILROAD_HOLDS[name] & set(names) for name in set(RAILROAD_HOLDS) & set(names) + ) + + def test_no_list_holds_railroad_benefits_twice(): - """R4. A second entry, or the total with a part, would count dollars twice.""" + """R4. A second entry, or an amount with its part, would count dollars twice.""" repeated = [] for path in (PACKAGE / "parameters" / "gov").rglob("*.yaml"): text = path.read_text(encoding="utf8") - if "railroad_benefits" not in text: - continue - if any( - names.count("railroad_benefits") > 1 - or ("railroad_benefits" in names and RAILROAD_PARTS & set(names)) - for names in parameter_lists(text) + if "railroad" in text and any( + counts_railroad_twice(names) for names in parameter_lists(text) ): repeated.append(path.relative_to(PACKAGE).as_posix()) + repeated += [ + name + for name, variable in system.variables.items() + if isinstance(variable.adds, (list, tuple)) + and counts_railroad_twice(list(variable.adds)) + ] assert not repeated, repeated -# Federal totals and lists that state and county programs read. Each counts -# Social Security and leaves railroad benefits out, so the programs that read -# them leave railroad benefits out too, although the rules count them: SNAP -# counts "Annuities; pensions; retirement, veteran's, or disability benefits" -# (7 CFR 273.9(b)(2)(ii)), and Michigan's BEM 503, for one state's TANF, counts -# the "gross benefit amount" the Railroad Retirement Board pays. -# PolicyEngine/policyengine-us#10551 tracks both lists. +# Federal totals, lists and tests that state and county programs read. Each one +# counts Social Security, or tests for Social Security disability, and leaves +# railroad benefits out, so the programs that read it leave them out too, +# although their rules count them. FEDERAL_GAPS = { - # 64 TANF variables in 37 states read the baseline unearned income list. - "tanf_gross_unearned_income": "TANF baseline unearned income list", - # Nebraska LIHEAP (476 NAC 2-002.01). - "gov.usda.snap.income.sources.unearned": "SNAP unearned income list", + # PolicyEngine/policyengine-us#10551 tracks the TANF, SNAP and school meals + # lists. SNAP counts "Annuities; pensions; retirement, veteran's, or + # disability benefits" (7 CFR 273.9(b)(2)(ii)), and Michigan's BEM 503, for + # one state's TANF, counts the "gross benefit amount" the Railroad + # Retirement Board pays. + "tanf_gross_unearned_income": "TANF baseline list: 64 TANF variables in 37 states", + "gov.usda.snap.income.sources.unearned": "SNAP unearned list: Nebraska LIHEAP", + "school_meal_fpg_ratio": "School meals list: Texas CSFP, California CVRP", + # IRC 36B(d)(2)(B)(iii) adds the untaxed share of "social security benefits + # (as defined in section 86(d))", which takes in tier 1. On main, neither + # adjusted_gross_income nor the MAGI additions list counts railroad benefits. + # PolicyEngine/policyengine-us#10696 puts the taxable amounts in AGI and + # adds the tax-free tier 1 amount to the additions list. + "medicaid_magi": "MAGI: Colorado CHP+, Healthy Michigan, Montana HELP", + "tax_unit_medicaid_income_level": "MAGI ratio: 17 CHIP premiums, IN HIP, MI, MT", + "medicaid_income_level": "MAGI ratio: AR, CO, IL, MS, OR and WA programs", + "aca_magi": "ACA MAGI: CA, MD, NM and VT premium help", + "aca_magi_fraction": "ACA MAGI ratio: premium help in nine states", + # 7 U.S.C. 2012(j)(7) counts a railroad disability annuitant as disabled, and + # railroad_benefits doesn't separate disability annuities from the others. + "is_usda_disabled": "SNAP disability test: NC, NV, NY and OK energy aid", } +# Formulas R5 follows past a federal list or the variable that adds it. +FEDERAL_FORMULA_STEPS = 2 def is_state_or_local(relative): @@ -488,27 +544,6 @@ def federal_lists_without_railroad(): return found -def federal_totals_without_railroad(lists): - """Federal variables that add Social Security and no railroad benefit.""" - found = set() - for name, variable in system.variables.items(): - if name == "social_security": - continue - try: - source = Path(inspect.getfile(type(variable))).resolve() - relative = source.relative_to(PACKAGE).as_posix() - except (TypeError, ValueError): - continue - if is_state_or_local(relative): - continue - adds = variable.adds - if (isinstance(adds, str) and adds in lists) or ( - isinstance(adds, (list, tuple)) and lacks_railroad(adds) - ): - found.add(name) - return found - - def attribute_chains(tree): """Dotted parameter paths from gov onward, and every attribute name.""" chains, attributes = set(), set() @@ -526,10 +561,63 @@ def attribute_chains(tree): return chains, attributes -def inherited_federal_sources(): - """Federal totals and lists without railroad benefits that state code reads.""" +def reads_a_list(tree, lists): + """The listed parameter paths that parsed code reads.""" + chains, attributes = attribute_chains(tree) + found = set() + for listed in lists: + parent, leaf = listed.rsplit(".", 1) + if listed in chains or (parent in chains and leaf in attributes): + found.add(listed) + return found + + +def federal_variables(): + """Each federal variable, its adds, and the variables and lists its code names.""" + found = {} + for name, variable in system.variables.items(): + try: + source = Path(inspect.getfile(type(variable))).resolve() + relative = source.relative_to(PACKAGE).as_posix() + except (TypeError, ValueError): + continue + if is_state_or_local(relative): + continue + adds = variable.adds + tree = ast.parse(source.read_text(encoding="utf8")) + names = code_strings([tree], set()) + if isinstance(adds, (list, tuple)): + names |= set(adds) + found[name] = (adds, names & set(system.variables), tree) + return found + + +def federal_sources_without_railroad(): + """Federal lists and variables that count Social Security without railroad.""" lists = federal_lists_without_railroad() - totals = federal_totals_without_railroad(lists) + variables = federal_variables() + found = set() + for name, (adds, names, tree) in variables.items(): + if name in SOCIAL_SECURITY or names & RAILROAD: + continue + if ( + (isinstance(adds, str) and adds in lists) + or (isinstance(adds, (list, tuple)) and lacks_railroad(adds)) + or reads_a_list(tree, lists) + ): + found.add(name) + for _ in range(FEDERAL_FORMULA_STEPS): + found |= { + name + for name, (_, names, _) in variables.items() + if name not in SOCIAL_SECURITY and names & found and not names & RAILROAD + } + return lists, found + + +def inherited_federal_sources(): + """Federal sources without railroad benefits that state or county code reads.""" + lists, variables = federal_sources_without_railroad() found = set() for tree, suffix in (("parameters/gov", ".yaml"), ("variables/gov", ".py")): for area in ("states", "local"): @@ -539,26 +627,25 @@ def inherited_federal_sources(): text = path.read_text(encoding="utf8") if suffix == ".yaml": for names in parameter_lists(text): - found |= totals & set(names) + found |= variables & set(names) continue parsed = ast.parse(text) - found |= totals & code_strings([parsed], set()) - chains, attributes = attribute_chains(parsed) - for listed in lists: - parent, leaf = listed.rsplit(".", 1) - if listed in chains or (parent in chains and leaf in attributes): - found.add(listed) - return found, lists | totals + found |= variables & code_strings([parsed], set()) + found |= reads_a_list(parsed, lists) + return found def test_state_programs_that_read_a_federal_list_without_railroad_are_known(): - """R5. A program that reads a federal total or list takes its sources.""" - inherited, without_railroad = inherited_federal_sources() + """R5. A program that reads a federal total, list or test takes its sources.""" + inherited = inherited_federal_sources() unknown = sorted(inherited - set(FEDERAL_GAPS)) assert not unknown, ( - "State or county code reads these federal totals or lists, which count " - "Social Security and leave out railroad benefits. Read the program's rule, " - f"then count railroad benefits or add the source to FEDERAL_GAPS: {unknown}" + "State or county code reads these federal sources, which count Social " + "Security and leave out railroad benefits. Read the program's rule, then " + f"count railroad benefits or add the source to FEDERAL_GAPS: {unknown}" + ) + stale = sorted(set(FEDERAL_GAPS) - inherited) + assert not stale, ( + "No state or county code reads these as sources without railroad " + f"benefits; remove them: {stale}" ) - fixed = sorted(set(FEDERAL_GAPS) - without_railroad) - assert not fixed, f"These now count railroad benefits; remove them: {fixed}" From aa927dbbe7efa93727cedd779ad9d5ef8dad8bde Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 11 Nov 2026 01:15:10 -0500 Subject: [PATCH 6/7] Name who acts in the railroad audit's docstrings and gap comments The R1 and R5 docstrings and the FEDERAL_GAPS comments now match the PR body: agencies and taxpayers act under each cited rule, R5's docstring says why it stops at two formula steps, and the group descriptions list federal benefits beside pensions, annuities and retirement benefits. Co-Authored-By: Claude Opus 5.5 --- .../test_state_benefit_railroad_income.py | 56 ++++++++++--------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/policyengine_us/tests/test_state_benefit_railroad_income.py b/policyengine_us/tests/test_state_benefit_railroad_income.py index 772161a0097..c5349df5647 100644 --- a/policyengine_us/tests/test_state_benefit_railroad_income.py +++ b/policyengine_us/tests/test_state_benefit_railroad_income.py @@ -1,9 +1,10 @@ """State and county benefit programs count Railroad Retirement Act benefits as income. -Each row names an income list or variable, the rule that reaches railroad -retirement benefits, and the variable that reads the list. A rule reaches the -benefits by naming them, by counting pensions, annuities or retirement -benefits, or by counting all income it doesn't exclude. +Each row names an income list or variable, the rule that covers railroad +retirement benefits, and the variable that reads the list. A rule covers the +benefits by naming railroad retirement, by listing pensions, annuities, +retirement benefits or federal benefits as income, or by defining income as +everything outside its exclusions. Invariants: @@ -11,9 +12,8 @@ raise the program's income by the amount received, at the variable's period. Where the income total also counts cash aid that the same benefit reduces, the test holds that aid at zero, so the row isolates the income list from the aid's - own income test. R1 shows that the list entry reaches the program's income. - Whether a rule reaches railroad benefits is a matter of the rule's text, which - each row cites. + own income test. R1 shows that the list entry reaches the program's income, + and each row cites the rule whose text covers railroad benefits. - R2: the same dollars count the same whether a person receives them as railroad benefits or as a pension, for programs whose lists count pensions. - R3 scans every state and county list outside the tax code, and the top level @@ -28,14 +28,15 @@ holds a railroad amount with a part of it. - R5: a state or county program that reads a federal total, list or test takes its sources. R5 finds each federal list that holds Social Security without - railroad benefits, each federal variable that adds such a list or reads it in a - formula, and each federal variable built on those through up to two more - formulas that name no railroad amount. Two steps reach the MAGI amounts and - poverty ratios that state code reads; a third reaches eligibility tests and - benefit amounts, such as is_snap_eligible and aca_ptc. State or county code - that reads one of these must appear in FEDERAL_GAPS, and an entry ends when its - source counts railroad benefits or no state or county code reads it. A new - reader of a source already in FEDERAL_GAPS passes. + railroad benefits and each federal variable that adds such a list or reads it + in a formula. It then follows those variables through up to two more formulas + that name no railroad amount. Two steps reach the MAGI amounts and poverty + ratios that state code reads; a third would reach eligibility tests and + benefit amounts, such as is_snap_eligible and aca_ptc, so R5 stops at two. + Each source that state or county code reads must appear in FEDERAL_GAPS, and + the test fails on an entry once its source counts railroad benefits or no + state or county code reads it. A new reader of a source already in + FEDERAL_GAPS passes. """ import ast @@ -498,26 +499,27 @@ def test_no_list_holds_railroad_benefits_twice(): # railroad benefits out, so the programs that read it leave them out too, # although their rules count them. FEDERAL_GAPS = { - # PolicyEngine/policyengine-us#10551 tracks the TANF, SNAP and school meals - # lists. SNAP counts "Annuities; pensions; retirement, veteran's, or - # disability benefits" (7 CFR 273.9(b)(2)(ii)), and Michigan's BEM 503, for - # one state's TANF, counts the "gross benefit amount" the Railroad - # Retirement Board pays. + # Issue PolicyEngine/policyengine-us#10551 covers the TANF, SNAP and school + # meals lists. Under 7 CFR 273.9(b)(2)(ii), state SNAP agencies count + # "Annuities; pensions; retirement, veteran's, or disability benefits". + # Under BEM 503, Michigan's MDHHS counts for FIP, its TANF program, the + # "gross benefit amount" that the Railroad Retirement Board pays. "tanf_gross_unearned_income": "TANF baseline list: 64 TANF variables in 37 states", "gov.usda.snap.income.sources.unearned": "SNAP unearned list: Nebraska LIHEAP", "school_meal_fpg_ratio": "School meals list: Texas CSFP, California CVRP", - # IRC 36B(d)(2)(B)(iii) adds the untaxed share of "social security benefits - # (as defined in section 86(d))", which takes in tier 1. On main, neither - # adjusted_gross_income nor the MAGI additions list counts railroad benefits. - # PolicyEngine/policyengine-us#10696 puts the taxable amounts in AGI and - # adds the tax-free tier 1 amount to the additions list. + # Under IRC 36B(d)(2)(B)(iii), taxpayers add to AGI the untaxed share of + # "social security benefits (as defined in section 86(d))", tier 1 included. + # On main, neither adjusted_gross_income nor the MAGI additions list counts + # railroad benefits. PolicyEngine/policyengine-us#10696 adds the taxable + # amounts to AGI and the tax-free tier 1 amount to the additions list. "medicaid_magi": "MAGI: Colorado CHP+, Healthy Michigan, Montana HELP", "tax_unit_medicaid_income_level": "MAGI ratio: 17 CHIP premiums, IN HIP, MI, MT", "medicaid_income_level": "MAGI ratio: AR, CO, IL, MS, OR and WA programs", "aca_magi": "ACA MAGI: CA, MD, NM and VT premium help", "aca_magi_fraction": "ACA MAGI ratio: premium help in nine states", - # 7 U.S.C. 2012(j)(7) counts a railroad disability annuitant as disabled, and - # railroad_benefits doesn't separate disability annuities from the others. + # Under 7 U.S.C. 2012(j)(7), SNAP agencies treat a railroad disability + # annuitant as disabled, and railroad_benefits doesn't separate disability + # annuities from the others. "is_usda_disabled": "SNAP disability test: NC, NV, NY and OK energy aid", } # Formulas R5 follows past a federal list or the variable that adds it. From d54a23961efeec2fa3bb85f03d6f7ef90c68aae0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 11 Nov 2026 12:55:45 -0500 Subject: [PATCH 7/7] Answer the aa927dbbe7 review of the railroad income audit Hawaii CCAP counts Hawaii TANF since #10332, so its R1 and R2 rows set wages above the aid limits, as the other eight rows with cash aid do. R5 starts from one more kind of federal source: a variable whose own code names a Social Security amount and no railroad amount, the check R3 runs on state code. That finds is_medicare_eligible, which tests Social Security disability for 24 months and leaves out the disabled railroad annuitants 42 U.S.C. 426(b)(2)(B) covers. FEDERAL_GAPS lists it and the seven premiums and tests state code reads within two steps of it. R5 reads a parameter path through names assigned in the same module or function, and a list named in a string. R4 maps each railroad amount to the dollars it holds and fails when two entries of a list share any, so the tier 1 amount beside the taxable total fails too. Co-Authored-By: Claude Fable 5.2 --- .../test_state_benefit_railroad_income.py | 241 ++++++++++++------ 1 file changed, 159 insertions(+), 82 deletions(-) diff --git a/policyengine_us/tests/test_state_benefit_railroad_income.py b/policyengine_us/tests/test_state_benefit_railroad_income.py index c5349df5647..ef5e43511fc 100644 --- a/policyengine_us/tests/test_state_benefit_railroad_income.py +++ b/policyengine_us/tests/test_state_benefit_railroad_income.py @@ -23,20 +23,28 @@ NO_RAILROAD_INCOME with the reason it doesn't. A new reader fails this test until its author reads the rule and does one or the other. R3 reads names written out in full, so a name built with an f-string or concatenation, or a - scalar parameter, passes unseen. -- R4: no parameter list or variable adds list holds a railroad amount twice, or - holds a railroad amount with a part of it. + scalar parameter, passes unseen. So does a module that names Social Security in + one top-level list and railroad benefits in another, since R3 reads a + module's top level as one scope. +- R4: no two entries of a parameter list or variable adds list hold the same + railroad dollars. The total, the tier 1 amount, the taxable total and each + part map to the taxable and tax-free tier 1 benefits and the taxable and + tax-free rest of the annuity. - R5: a state or county program that reads a federal total, list or test takes - its sources. R5 finds each federal list that holds Social Security without - railroad benefits and each federal variable that adds such a list or reads it - in a formula. It then follows those variables through up to two more formulas - that name no railroad amount. Two steps reach the MAGI amounts and poverty - ratios that state code reads; a third would reach eligibility tests and - benefit amounts, such as is_snap_eligible and aca_ptc, so R5 stops at two. - Each source that state or county code reads must appear in FEDERAL_GAPS, and - the test fails on an entry once its source counts railroad benefits or no - state or county code reads it. A new reader of a source already in - FEDERAL_GAPS passes. + its sources. R5 starts from each federal list that holds Social Security + without railroad benefits, each federal variable that adds such a list or + reads it in a formula, and each federal variable whose own code names a + Social Security amount and no railroad amount, as R3 checks state code. It + then follows those variables through up to two more formulas that name no + railroad amount. Each source that state or county code reads must appear in + FEDERAL_GAPS, and the test fails on an entry once its source counts railroad + benefits or no state or county code reads it. A new reader of a source + already in FEDERAL_GAPS passes. A third formula step would reach further + eligibility tests and benefit amounts, such as is_snap_eligible and aca_ptc, + and R5 doesn't follow it. R5 reads a parameter path written out in code, or + built through names assigned in the same module or function, or named in a + string. A path passed as a function argument or read with getattr passes + unseen. """ import ast @@ -152,9 +160,9 @@ ("WY", "wy_lieap_countable_income", "LIEAP income list"), ] -# These totals count cash aid (ca_tanf, ks_tanf or tanf) that falls as income -# rises, so a benefit the aid program counts removes aid from a household on the -# program. Wages above the aid programs' limits keep the aid at zero with and +# These totals count cash aid (ca_tanf, hi_tanf, ks_tanf or tanf) that falls as +# income rises, so a benefit the aid program counts removes aid from a household +# on the program. Wages above the aid programs' limits keep the aid at zero with and # without the benefit. WAGES_ABOVE_AID_LIMITS = {"employment_income": 60_000} BASE_INPUTS = { @@ -163,6 +171,7 @@ "ca_la_ez_save_countable_income": WAGES_ABOVE_AID_LIMITS, "ca_riv_liheap_countable_income": WAGES_ABOVE_AID_LIMITS, "ca_riv_share_countable_income": WAGES_ABOVE_AID_LIMITS, + "hi_ccap_countable_income": WAGES_ABOVE_AID_LIMITS, "ks_liheap_countable_unearned_income": WAGES_ABOVE_AID_LIMITS, "la_general_relief_gross_income": WAGES_ABOVE_AID_LIMITS, "wa_eceap_family_income": WAGES_ABOVE_AID_LIMITS, @@ -274,33 +283,51 @@ def test_railroad_benefits_count_as_a_pension_would(state, variable, rule, amoun "tax_exempt_social_security", } -# The total and its parts. PolicyEngine/policyengine-us#10696 splits -# railroad_benefits into the tier 1, taxable tier 2 and tax-free tier 2 parts and -# defines the federal tax amounts. -RAILROAD = { - "railroad_benefits", - "railroad_social_security_equivalent_benefits", - "taxable_railroad_pension_benefits", - "tax_exempt_railroad_pension_benefits", - "taxable_railroad_social_security_equivalent_benefits", - "tax_exempt_railroad_social_security_equivalent_benefits", - "taxable_railroad_benefits", -} -RAILROAD_PARTS = RAILROAD - {"railroad_benefits"} -# Each railroad amount and the amounts it holds. -RAILROAD_HOLDS = { - "railroad_benefits": RAILROAD_PARTS, +# Each railroad amount and the dollars it holds: the taxable and tax-free tier 1 +# social security equivalent benefits, and the taxable and tax-free rest of the +# annuity. PolicyEngine/policyengine-us#10696 splits railroad_benefits into the +# tier 1 amount and the two parts of the rest, and defines the tax amounts. +TIER_1_TAXABLE = "taxable_railroad_social_security_equivalent_benefits" +TIER_1_TAX_FREE = "tax_exempt_railroad_social_security_equivalent_benefits" +REST_TAXABLE = "taxable_railroad_pension_benefits" +REST_TAX_FREE = "tax_exempt_railroad_pension_benefits" +RAILROAD_DOLLARS = { + "railroad_benefits": { + TIER_1_TAXABLE, + TIER_1_TAX_FREE, + REST_TAXABLE, + REST_TAX_FREE, + }, "railroad_social_security_equivalent_benefits": { - "taxable_railroad_social_security_equivalent_benefits", - "tax_exempt_railroad_social_security_equivalent_benefits", + TIER_1_TAXABLE, + TIER_1_TAX_FREE, + }, + "tax_unit_railroad_social_security_equivalent_benefits": { + TIER_1_TAXABLE, + TIER_1_TAX_FREE, }, - "taxable_railroad_benefits": { - "taxable_railroad_social_security_equivalent_benefits", - "taxable_railroad_pension_benefits", + "tax_unit_railroad_social_security_equivalent_benefits_for_taxability": { + TIER_1_TAXABLE, + TIER_1_TAX_FREE, }, + "taxable_railroad_benefits": {TIER_1_TAXABLE, REST_TAXABLE}, + "tax_unit_taxable_railroad_social_security_equivalent_benefits": {TIER_1_TAXABLE}, + TIER_1_TAXABLE: {TIER_1_TAXABLE}, + TIER_1_TAX_FREE: {TIER_1_TAX_FREE}, + REST_TAXABLE: {REST_TAXABLE}, + REST_TAX_FREE: {REST_TAX_FREE}, +} +# A dependent's own return taxes the dependent's tier 1 benefits, so this amount +# and the filers' taxable tier 1 amount hold different people's dollars. +RAILROAD = set(RAILROAD_DOLLARS) | { + "dependent_taxable_railroad_social_security_equivalent_benefits" } -SIGNAL = "Reads receipt of a benefit as a yes-or-no test, not an income amount." +SIGNAL = ( + "Reads receipt of a benefit as a yes-or-no test, not an income amount. " + "railroad_benefits doesn't separate disability annuities, so it can't stand " + "in for Social Security disability in a disability test." +) SOCIAL_SECURITY_ONLY = "The rule or list covers Social Security benefits alone." DISABILITY_ONLY = ( "The list holds disability income, and railroad_benefits doesn't separate " @@ -470,14 +497,13 @@ def test_each_social_security_reader_counts_railroad_benefits_or_says_why(): def counts_railroad_twice(names): - """Whether a list holds a railroad amount twice, or with a part of it.""" - return any(names.count(name) > 1 for name in RAILROAD & set(names)) or any( - RAILROAD_HOLDS[name] & set(names) for name in set(RAILROAD_HOLDS) & set(names) - ) + """Whether two entries of a list hold the same railroad dollars.""" + dollars = [part for name in names for part in RAILROAD_DOLLARS.get(name, ())] + return len(dollars) != len(set(dollars)) def test_no_list_holds_railroad_benefits_twice(): - """R4. A second entry, or an amount with its part, would count dollars twice.""" + """R4. Two entries that hold the same dollars would count them twice.""" repeated = [] for path in (PACKAGE / "parameters" / "gov").rglob("*.yaml"): text = path.read_text(encoding="utf8") @@ -496,8 +522,7 @@ def test_no_list_holds_railroad_benefits_twice(): # Federal totals, lists and tests that state and county programs read. Each one # counts Social Security, or tests for Social Security disability, and leaves -# railroad benefits out, so the programs that read it leave them out too, -# although their rules count them. +# railroad benefits out, so the programs that read it leave them out too. FEDERAL_GAPS = { # Issue PolicyEngine/policyengine-us#10551 covers the TANF, SNAP and school # meals lists. Under 7 CFR 273.9(b)(2)(ii), state SNAP agencies count @@ -521,8 +546,20 @@ def test_no_list_holds_railroad_benefits_twice(): # annuitant as disabled, and railroad_benefits doesn't separate disability # annuities from the others. "is_usda_disabled": "SNAP disability test: NC, NV, NY and OK energy aid", + # Under 42 U.S.C. 426(b)(2)(B), a person under 65 gets Medicare Part A + # after 24 months as "a disabled qualified railroad retirement beneficiary". + # is_medicare_eligible tests age 65, or Social Security disability for 24 + # months, and the premiums, enrollment and Medicaid tests below read it. + "is_medicare_eligible": "Medicare test: IL SMIB and fares, MBTA, MS HMW, DART", + "base_part_b_premium": "Part B premium: IL SMIB", + "medicare_enrolled": "Medicare enrollment: RI Medicaid third-party coverage", + "medicare_part_a_premium": "Part A premium: energy aid income in eight states", + "medicare_part_b_premium": "Part B premium: energy aid in 28 states, WA exemption", + "is_adult_for_medicaid": "Medicaid adult test: MS HMW other coverage", + "is_medicaid_eligible": "Medicaid test: CA CCHIP, CO CHP+", + "medicaid_optional_senior_or_disabled_countable_income": "MS HMW income", } -# Formulas R5 follows past a federal list or the variable that adds it. +# Formulas R5 follows past each federal variable it starts from. FEDERAL_FORMULA_STEPS = 2 @@ -546,36 +583,67 @@ def federal_lists_without_railroad(): return found -def attribute_chains(tree): - """Dotted parameter paths from gov onward, and every attribute name.""" - chains, attributes = set(), set() - for node in ast.walk(tree): +def parameter_paths(node, aliases): + """The dotted paths from gov onward that an expression can name.""" + parts = [] + while isinstance(node, ast.Attribute): + parts.append(node.attr) + node = node.value + parts.reverse() + if "gov" in parts: + return {".".join(parts[parts.index("gov") :])} + if isinstance(node, ast.Name): + return {".".join([base, *parts]) for base in aliases.get(node.id, ())} + return set() + + +def scope_nodes(scope): + """The nodes of a module or function, without the bodies of functions inside.""" + pending = list(ast.iter_child_nodes(scope)) + while pending: + node = pending.pop() + yield node + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + pending.extend(ast.iter_child_nodes(node)) + + +def scope_paths(scope, outer): + """The parameter paths a scope reads, through the names it assigns paths to.""" + aliases = {name: set(paths) for name, paths in outer.items()} + nodes = list(scope_nodes(scope)) + assignments = [ + (node.targets[0].id, node.value) + for node in nodes + if isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + ] + grew = True + while grew: + grew = False + for name, value in assignments: + new = parameter_paths(value, aliases) - aliases.get(name, set()) + if new: + aliases.setdefault(name, set()).update(new) + grew = True + paths = set() + for node in nodes: if isinstance(node, ast.Attribute): - attributes.add(node.attr) - parts = [] - current = node - while isinstance(current, ast.Attribute): - parts.append(current.attr) - current = current.value - parts.reverse() - if "gov" in parts: - chains.add(".".join(parts[parts.index("gov") :])) - return chains, attributes + paths |= parameter_paths(node, aliases) + return paths, aliases def reads_a_list(tree, lists): - """The listed parameter paths that parsed code reads.""" - chains, attributes = attribute_chains(tree) - found = set() - for listed in lists: - parent, leaf = listed.rsplit(".", 1) - if listed in chains or (parent in chains and leaf in attributes): - found.add(listed) - return found + """The listed parameter paths that parsed code reads or names in a string.""" + paths, aliases = scope_paths(tree, {}) + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + paths |= scope_paths(node, aliases)[0] + return lists & (paths | code_strings([tree], set())) def federal_variables(): - """Each federal variable, its adds, and the variables and lists its code names.""" + """Each federal variable: its adds, the variables it names, its code and scopes.""" found = {} for name, variable in system.variables.items(): try: @@ -586,32 +654,41 @@ def federal_variables(): if is_state_or_local(relative): continue adds = variable.adds - tree = ast.parse(source.read_text(encoding="utf8")) + text = source.read_text(encoding="utf8") + tree = ast.parse(text) names = code_strings([tree], set()) if isinstance(adds, (list, tuple)): names |= set(adds) - found[name] = (adds, names & set(system.variables), tree) + scopes = list(code_scopes(text)) + found[name] = (adds, names & set(system.variables), tree, scopes) return found +def counts_social_security_alone(adds, names, tree, scopes, lists): + """Whether a federal variable takes Social Security with no railroad amount.""" + if isinstance(adds, str): + listed = adds in lists + else: + listed = isinstance(adds, (list, tuple)) and lacks_railroad(adds) + if (listed or reads_a_list(tree, lists)) and not names & RAILROAD: + return True + return any(lacks_railroad(scope) for scope in scopes) + + def federal_sources_without_railroad(): """Federal lists and variables that count Social Security without railroad.""" lists = federal_lists_without_railroad() variables = federal_variables() - found = set() - for name, (adds, names, tree) in variables.items(): - if name in SOCIAL_SECURITY or names & RAILROAD: - continue - if ( - (isinstance(adds, str) and adds in lists) - or (isinstance(adds, (list, tuple)) and lacks_railroad(adds)) - or reads_a_list(tree, lists) - ): - found.add(name) + found = { + name + for name, (adds, names, tree, scopes) in variables.items() + if name not in SOCIAL_SECURITY + and counts_social_security_alone(adds, names, tree, scopes, lists) + } for _ in range(FEDERAL_FORMULA_STEPS): found |= { name - for name, (_, names, _) in variables.items() + for name, (_, names, _, _) in variables.items() if name not in SOCIAL_SECURITY and names & found and not names & RAILROAD } return lists, found