From 7c8ff22dbe51f79c2c2e611700c785908227d11f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 19:04:34 -0400 Subject: [PATCH 1/8] Count South Carolina TANF unearned income at gross amounts The SC TANF unearned income list was copied from the IRS gross income sources, so it counted only the taxable part of Social Security, unemployment compensation, pensions and interest. The TANF Policy Manual (Volume 67, Sections 7.1 and 7.7) defines income as money received from any source and counts SSA benefits at their gross amount. Rebuild the list from the Section 7.7 chart: gross Social Security, unemployment compensation, pensions and interest; add child support, alimony, veterans benefits, workers' compensation, disability insurance, survivor and railroad retirement benefits and cash contributions; drop retirement account distributions (Section 6.6 counts the balances as resources) and discharged debt; move farm income to earned income. Apply the $400 per benefit group annual exclusion of interest, dividends and royalties, and exclude SSI recipients' income. Co-Authored-By: Claude Opus 5.5 --- changelog.d/sc-tanf-unearned-gross.fixed.md | 1 + .../states/sc/tanf/income/earned/earned.yaml | 9 ++--- .../gov/states/sc/tanf/income/unearned.yaml | 28 --------------- .../interest_and_dividends/exclusion.yaml | 11 ++++++ .../interest_and_dividends/sources.yaml | 13 +++++++ .../sc/tanf/income/unearned/sources.yaml | 35 +++++++++++++++++++ .../income/sc_tanf_gross_earned_income.py | 10 ++++-- .../income/sc_tanf_gross_unearned_income.py | 28 +++++++++++++-- .../states/sc/tanf/sc_tanf_ssi_recipient.py | 16 +++++++++ 9 files changed, 115 insertions(+), 36 deletions(-) create mode 100644 changelog.d/sc-tanf-unearned-gross.fixed.md delete mode 100644 policyengine_us/parameters/gov/states/sc/tanf/income/unearned.yaml create mode 100644 policyengine_us/parameters/gov/states/sc/tanf/income/unearned/interest_and_dividends/exclusion.yaml create mode 100644 policyengine_us/parameters/gov/states/sc/tanf/income/unearned/interest_and_dividends/sources.yaml create mode 100644 policyengine_us/parameters/gov/states/sc/tanf/income/unearned/sources.yaml create mode 100644 policyengine_us/variables/gov/states/sc/tanf/sc_tanf_ssi_recipient.py diff --git a/changelog.d/sc-tanf-unearned-gross.fixed.md b/changelog.d/sc-tanf-unearned-gross.fixed.md new file mode 100644 index 00000000000..b32067e29f9 --- /dev/null +++ b/changelog.d/sc-tanf-unearned-gross.fixed.md @@ -0,0 +1 @@ +South Carolina TANF now counts Social Security, unemployment compensation, pensions and interest at their gross amounts, adds the unearned income the TANF Policy Manual counts (child support, alimony, veterans benefits, workers' compensation, disability insurance, survivor and railroad retirement benefits, cash contributions), drops retirement account distributions and discharged debt, treats farm income as earned, excludes the first $400 a year of a benefit group's interest and dividends, and excludes SSI recipients' income. diff --git a/policyengine_us/parameters/gov/states/sc/tanf/income/earned/earned.yaml b/policyengine_us/parameters/gov/states/sc/tanf/income/earned/earned.yaml index 02b09ea4d32..308693e947f 100644 --- a/policyengine_us/parameters/gov/states/sc/tanf/income/earned/earned.yaml +++ b/policyengine_us/parameters/gov/states/sc/tanf/income/earned/earned.yaml @@ -4,10 +4,11 @@ values: - employment_income_before_lsr - self_employment_income_before_lsr - sstb_self_employment_income_before_lsr + - farm_operations_income # Farm income is self-employment income metadata: unit: list - period: month - label: South Carolina TANF earned income + period: month + label: South Carolina TANF earned income reference: - - title: SC DSS TANF Policy Manual Section 7.1 - href: https://dss.sc.gov/media/ojqddxsk/tanf-policy-manual-volume-65.pdf#page=101 + - title: "SC DSS TANF Policy Manual (Volume 67), Sections 7.1 and 7.7 #page 102,112" + href: https://dss.sc.gov/media/bxwc1jdj/tanf-policy-manual-volume-67-final.pdf#page=102 diff --git a/policyengine_us/parameters/gov/states/sc/tanf/income/unearned.yaml b/policyengine_us/parameters/gov/states/sc/tanf/income/unearned.yaml deleted file mode 100644 index 247415c9c25..00000000000 --- a/policyengine_us/parameters/gov/states/sc/tanf/income/unearned.yaml +++ /dev/null @@ -1,28 +0,0 @@ -description: South Carolina counts these income sources as unearned income under the Temporary Assistance for Needy Families program. -values: - 1996-07-01: - - partnership_s_corp_income - - farm_operations_income - - farm_rent_income - - capital_gains - - taxable_interest_income - - rental_income - # Royalties included in rental income - - dividend_income - - taxable_pension_income - # Pension income includes annuities and endowment contracts - - debt_relief - # Decedent income - # Estate interest income - - taxable_unemployment_compensation - - taxable_social_security - - illicit_income - - taxable_retirement_distributions - - miscellaneous_income -metadata: - unit: list - period: month - label: South Carolina TANF unearned income - reference: - - title: SC DSS TANF Policy Manual Section 7.1 - href: https://dss.sc.gov/media/ojqddxsk/tanf-policy-manual-volume-65.pdf#page=101 diff --git a/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/interest_and_dividends/exclusion.yaml b/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/interest_and_dividends/exclusion.yaml new file mode 100644 index 00000000000..75e3c54d269 --- /dev/null +++ b/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/interest_and_dividends/exclusion.yaml @@ -0,0 +1,11 @@ +description: South Carolina excludes this amount of interest, dividends and royalties per benefit group each year under the Temporary Assistance for Needy Families program. +values: + 1996-07-01: 400 + +metadata: + unit: currency-USD + period: year + label: South Carolina TANF interest and dividend exclusion + reference: + - title: SC DSS TANF Policy Manual (Volume 67), Section 7.7 + href: https://dss.sc.gov/media/bxwc1jdj/tanf-policy-manual-volume-67-final.pdf#page=113 diff --git a/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/interest_and_dividends/sources.yaml b/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/interest_and_dividends/sources.yaml new file mode 100644 index 00000000000..281581c5e0f --- /dev/null +++ b/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/interest_and_dividends/sources.yaml @@ -0,0 +1,13 @@ +description: South Carolina applies the interest, dividend and royalty exclusion to these income sources under the Temporary Assistance for Needy Families program. +values: + 1996-07-01: + - interest_income + - ordinary_dividend_income + # Royalties are included in rental income and are not separately excluded. +metadata: + unit: list + period: year + label: South Carolina TANF interest and dividend exclusion sources + reference: + - title: SC DSS TANF Policy Manual (Volume 67), Section 7.7 + href: https://dss.sc.gov/media/bxwc1jdj/tanf-policy-manual-volume-67-final.pdf#page=113 diff --git a/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/sources.yaml b/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/sources.yaml new file mode 100644 index 00000000000..0f10a2e1a79 --- /dev/null +++ b/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/sources.yaml @@ -0,0 +1,35 @@ +description: South Carolina counts these income sources as unearned income under the Temporary Assistance for Needy Families program. +values: + 1996-07-01: + # Section 7.1 defines income as money received from any source, and the + # Section 7.7 chart counts each source below at its gross amount. + - social_security # SSA benefits: count the gross amount + - unemployment_compensation + - pension_income # Pension; annuities and endowment contracts + - railroad_benefits # Retirement Pay (Railroad Retirement) + - survivor_benefits # Pension and retirement pay to survivors + - veterans_benefits + - workers_compensation + - disability_benefits # Income maintenance insurance, including disability insurance + - child_support_received # Child support/direct + - alimony_income + - financial_assistance # Cash contributions + - interest_income # Interest, dividends and royalties + - ordinary_dividend_income + - capital_gains # Capital gains (other) + - rental_income # Rent payments; royalties are included in rental income + - farm_rent_income + - partnership_s_corp_income + - miscellaneous_income + - illicit_income + # Not listed: farm operations income is earned self-employment income + # (Section 7.7); retirement account distributions draw down IRA, Keogh and + # deferred compensation balances that Section 6.6 treats as resources; and + # discharged debt is not money received. +metadata: + unit: list + period: month + label: South Carolina TANF unearned income sources + reference: + - title: "SC DSS TANF Policy Manual (Volume 67), Sections 7.1 and 7.7 #page 102,107-120" + href: https://dss.sc.gov/media/bxwc1jdj/tanf-policy-manual-volume-67-final.pdf#page=102 diff --git a/policyengine_us/variables/gov/states/sc/tanf/income/sc_tanf_gross_earned_income.py b/policyengine_us/variables/gov/states/sc/tanf/income/sc_tanf_gross_earned_income.py index 7c599d576d3..f63cbfc90fd 100644 --- a/policyengine_us/variables/gov/states/sc/tanf/income/sc_tanf_gross_earned_income.py +++ b/policyengine_us/variables/gov/states/sc/tanf/income/sc_tanf_gross_earned_income.py @@ -8,5 +8,11 @@ class sc_tanf_gross_earned_income(Variable): unit = USD definition_period = MONTH defined_for = StateCode.SC - reference = "https://dss.sc.gov/media/ojqddxsk/tanf-policy-manual-volume-65.pdf#page=100" # Section 7.1 - adds = "gov.states.sc.tanf.income.earned.earned" + # PDF pages 102, 112, 117 + reference = "https://dss.sc.gov/media/bxwc1jdj/tanf-policy-manual-volume-67-final.pdf#page=102" # Sections 7.1 and 7.7 + + def formula(person, period, parameters): + p = parameters(period).gov.states.sc.tanf.income.earned + earned = add(person, period, p.earned) + # Section 7.7 excludes the income of SSI recipients. + return earned * ~person("sc_tanf_ssi_recipient", period) diff --git a/policyengine_us/variables/gov/states/sc/tanf/income/sc_tanf_gross_unearned_income.py b/policyengine_us/variables/gov/states/sc/tanf/income/sc_tanf_gross_unearned_income.py index 36273d0a264..9c597e12e2b 100644 --- a/policyengine_us/variables/gov/states/sc/tanf/income/sc_tanf_gross_unearned_income.py +++ b/policyengine_us/variables/gov/states/sc/tanf/income/sc_tanf_gross_unearned_income.py @@ -8,5 +8,29 @@ class sc_tanf_gross_unearned_income(Variable): unit = USD definition_period = MONTH defined_for = StateCode.SC - reference = "https://dss.sc.gov/media/ojqddxsk/tanf-policy-manual-volume-65.pdf#page=100" # Section 7.1 - adds = "gov.states.sc.tanf.income.unearned" + # PDF pages 102, 107-120 + reference = "https://dss.sc.gov/media/bxwc1jdj/tanf-policy-manual-volume-67-final.pdf#page=102" # Sections 7.1 and 7.7 + + def formula(person, period, parameters): + p = parameters(period).gov.states.sc.tanf.income.unearned + # Section 7.7 excludes the income of SSI recipients. + counted = ~person("sc_tanf_ssi_recipient", period) + unearned = add(person, period, p.sources) * counted + # Section 7.7 excludes up to $400 of interest, dividends and royalties + # per benefit group each year and averages the rest over 12 months. + # Each member's exclusion is proportional to their share of that income. + p_exclusion = p.interest_and_dividends + interest_and_dividends = ( + max_(add(person, period, p_exclusion.sources), 0) * counted + ) + unit_interest_and_dividends = person.spm_unit.sum(interest_and_dividends) + unit_exclusion = min_( + unit_interest_and_dividends, p_exclusion.exclusion / MONTHS_IN_YEAR + ) + share = np.divide( + interest_and_dividends, + unit_interest_and_dividends, + out=np.zeros_like(interest_and_dividends), + where=unit_interest_and_dividends > 0, + ) + return unearned - unit_exclusion * share diff --git a/policyengine_us/variables/gov/states/sc/tanf/sc_tanf_ssi_recipient.py b/policyengine_us/variables/gov/states/sc/tanf/sc_tanf_ssi_recipient.py new file mode 100644 index 00000000000..276a3442394 --- /dev/null +++ b/policyengine_us/variables/gov/states/sc/tanf/sc_tanf_ssi_recipient.py @@ -0,0 +1,16 @@ +from policyengine_us.model_api import * + + +class sc_tanf_ssi_recipient(Variable): + value_type = bool + entity = Person + label = "South Carolina TANF SSI recipient" + definition_period = MONTH + defined_for = StateCode.SC + reference = "https://dss.sc.gov/media/bxwc1jdj/tanf-policy-manual-volume-67-final.pdf#page=117" # Section 7.7, SSI benefit + + def formula(person, period, parameters): + # Section 7.7 excludes the SSI recipient and his or her income when + # determining benefits for the other benefit group members. Reported + # receipt also counts, covering people the model computes as $0. + return (person("ssi", period) > 0) | person("receives_ssi", period) From 530cc1516ef50f80fb104d7652018b9fe7c87c65 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 19:27:00 -0400 Subject: [PATCH 2/8] Test SC TANF unearned income against the Section 7.7 chart Add YAML tests for gross Social Security and 2020 unemployment compensation, gross pensions, the non-taxable sources the chart counts, the excluded retirement distributions and discharged debt, farm income as earned income, the $400 interest and dividend exclusion and its sharing across the benefit group, and SSI recipients. Integration Case 5 now enters Social Security instead of its taxable part, and Case 9 works a full benefit from child support, a child's Social Security and interest. Co-Authored-By: Claude Opus 5.5 --- .../gov/states/sc/tanf/integration.yaml | 40 ++++- .../sc/tanf/sc_tanf_gross_earned_income.yaml | 23 +++ .../tanf/sc_tanf_gross_unearned_income.yaml | 166 ++++++++++++++++++ .../states/sc/tanf/sc_tanf_ssi_recipient.yaml | 33 ++++ 4 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_earned_income.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_unearned_income.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_ssi_recipient.yaml diff --git a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/integration.yaml index 41aecd45e29..221267a265e 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/integration.yaml @@ -115,7 +115,7 @@ person1: age: 35 employment_income_before_lsr: 0 - taxable_social_security: 3_600 + social_security_disability: 3_600 person2: age: 8 is_child_dependent: true @@ -129,6 +129,7 @@ state_code: SC output: sc_tanf: 223.20 + # SSA benefits count at the gross amount (Section 7.7, page 116) # monthly unearned = 3,600/12 = 300 # tanf_fpg for size 2 = 1703.33 # need_standard = 1703.33 * 0.5 = 851.67 @@ -222,3 +223,40 @@ sc_tanf: 0 # Both members are undocumented, so no one qualifies as # citizen or legal immigrant -> ineligible + +- name: Case 9, child support, a child's Social Security and interest above the exclusion. + period: 2025-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 30 + employment_income_before_lsr: 0 + child_support_received: 3_600 + taxable_interest_income: 1_000 + person2: + age: 9 + is_child_dependent: true + social_security_survivors: 4_800 + person3: + age: 6 + is_child_dependent: true + spm_units: + spm_unit: + members: [person1, person2, person3] + spm_unit_cash_assets: 500 + households: + household: + members: [person1, person2, person3] + state_code: SC + output: + sc_tanf_gross_income: 750 + sc_tanf: 131.83 + # SC DSS TANF Policy Manual (Volume 67), Section 7.7: + # child support paid directly = 3,600/12 = 300 (page 108) + # child's SSA benefits, gross = 4,800/12 = 400 (page 116) + # interest = (1,000 - 400 exclusion)/12 = 50 (page 113) + # gross income = 300 + 400 + 50 = 750 + # tanf_fpg for size 3 = 2151.67; need standard = 1075.83 + # gross income limit = 1075.83 * 1.85 = 1990.29; 750 passes + # benefit = (1075.83 - 750) * 0.4046 = 131.83 diff --git a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_earned_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_earned_income.yaml new file mode 100644 index 00000000000..e2fb138f4e1 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_earned_income.yaml @@ -0,0 +1,23 @@ +# SC DSS TANF Policy Manual (Volume 67), Sections 7.1 and 7.7. + +- name: Case 1, wages and farm income are earned income. + period: 2025-01 + absolute_error_margin: 0.01 + input: + state_code: SC + employment_income_before_lsr: 12_000 + farm_operations_income: 2_400 + output: + # Wages (page 102) and farm income (self-employment, page 112) are earned. + # (12,000 + 2,400) / 12 = 1,200. + sc_tanf_gross_earned_income: 1_200 + +- name: Case 2, an SSI recipient's earnings are excluded. + period: 2025-01 + input: + state_code: SC + receives_ssi: true + employment_income_before_lsr: 6_000 + output: + # SSI benefit: exclude the SSI recipient and his or her income (page 117). + sc_tanf_gross_earned_income: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_unearned_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_unearned_income.yaml new file mode 100644 index 00000000000..6fe7d670e8d --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_unearned_income.yaml @@ -0,0 +1,166 @@ +# SC DSS TANF Policy Manual (Volume 67), Section 7.1 (page 102) defines income +# as money received from any source; the Section 7.7 chart (pages 107-120) +# lists how each source is counted. + +- name: Case 1, Social Security counts at the gross amount, not the taxable part. + period: 2025-01 + absolute_error_margin: 0.01 + input: + state_code: SC + social_security_retirement: 6_000 + output: + # SSA benefits: "Count gross amount" (page 116). + # 6,000 / 12 = 500. The federally taxable part is 0 at this income. + taxable_social_security: 0 + sc_tanf_gross_unearned_income: 500 + +- name: Case 2, 2020 unemployment compensation counts in full despite the IRC 85(c) exclusion. + period: 2020-01 + absolute_error_margin: 0.01 + input: + state_code: SC + unemployment_compensation: 12_000 + output: + # Unemployment compensation benefits are counted (page 118). + # 12,000 / 12 = 1,000. Federal law excluded $10,200 for 2020, so the + # taxable part was 1,800 a year (150 a month); TANF counts all 1,000. + sc_tanf_gross_unearned_income: 1_000 + +- name: Case 3, taxable and tax-exempt pension income both count. + period: 2025-01 + absolute_error_margin: 0.01 + input: + state_code: SC + taxable_private_pension_income: 2_400 + tax_exempt_private_pension_income: 1_200 + output: + # Pension and retirement pay are counted (pages 115-116). + # (2,400 + 1,200) / 12 = 300. + sc_tanf_gross_unearned_income: 300 + +- name: Case 4, non-taxable cash income the chart counts. + period: 2025-01 + absolute_error_margin: 0.01 + input: + state_code: SC + child_support_received: 1_200 + alimony_income: 1_200 + veterans_benefits: 1_200 + workers_compensation: 1_200 + disability_benefits: 1_200 + survivor_benefits: 1_200 + railroad_benefits: 1_200 + financial_assistance: 1_200 + output: + # Child support/direct, alimony, cash contributions (page 108), income + # maintenance insurance including disability insurance (page 113), + # retirement pay including railroad retirement (page 116), VA benefits + # (page 119) and workers' compensation (page 120) are counted. + # 8 sources x 1,200 / 12 = 800. + sc_tanf_gross_unearned_income: 800 + +- name: Case 5, retirement account distributions and discharged debt are not income. + period: 2025-01 + input: + state_code: SC + taxable_ira_distributions: 12_000 + taxable_401k_distributions: 6_000 + debt_relief: 6_000 + output: + # Section 6.6 counts IRA, SEP and Keogh balances as resources and excludes + # deferred compensation funds until withdrawn (pages 92-93), so a + # withdrawal draws down a resource rather than adding income. A discharged + # debt is not money received (Section 7.1). + sc_tanf_gross_unearned_income: 0 + +- name: Case 6, farm operations income is earned, not unearned. + period: 2025-01 + input: + state_code: SC + farm_operations_income: 6_000 + output: + # Farm income is self-employment income (page 112). + sc_tanf_gross_unearned_income: 0 + sc_tanf_gross_earned_income: 500 + +- name: Case 7, interest within the 400 dollar annual exclusion is not counted. + period: 2025-01 + input: + state_code: SC + taxable_interest_income: 300 + output: + # Up to $400 per benefit group annually is excluded (page 113). + # 300 / 12 = 25 a month, below the 400 / 12 = 33.33 monthly exclusion. + sc_tanf_gross_unearned_income: 0 + +- name: Case 8, interest above the exclusion is averaged over 12 months, including tax-exempt interest. + period: 2025-01 + absolute_error_margin: 0.01 + input: + state_code: SC + taxable_interest_income: 600 + tax_exempt_interest_income: 1_000 + output: + # Interest is money received whether or not it is taxable. + # (600 + 1,000 - 400) / 12 = 100. + sc_tanf_gross_unearned_income: 100 + +- name: Case 9, the exclusion is shared across the benefit group in proportion to each member's interest and dividends. + period: 2025-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 35 + taxable_interest_income: 600 + person2: + age: 10 + qualified_dividend_income: 600 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: SC + output: + # Each member has 600 / 12 = 50 a month; the group has 100. + # Group exclusion = min(100, 400 / 12 = 33.33) = 33.33, half to each. + # Each member: 50 - 16.67 = 33.33. Group total = (1,200 - 400) / 12 = 66.67. + sc_tanf_gross_unearned_income: [33.33, 33.33] + +- name: Case 10, an SSI recipient's income is excluded. + period: 2025-01 + input: + state_code: SC + receives_ssi: true + social_security_disability: 4_800 + taxable_interest_income: 1_200 + output: + # SSI benefit: exclude the SSI recipient and his or her income (page 117). + sc_tanf_ssi_recipient: true + sc_tanf_gross_unearned_income: 0 + +- name: Case 11, an SSI recipient's interest does not use up the group's exclusion. + period: 2025-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 35 + taxable_interest_income: 600 + person2: + age: 60 + receives_ssi: true + taxable_interest_income: 1_200 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: SC + output: + # Only person1's income counts: 600 / 12 = 50, less the full 33.33 + # exclusion = 16.67. Person2's income is excluded. + sc_tanf_gross_unearned_income: [16.67, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_ssi_recipient.yaml b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_ssi_recipient.yaml new file mode 100644 index 00000000000..61ea5e4862f --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_ssi_recipient.yaml @@ -0,0 +1,33 @@ +# SC DSS TANF Policy Manual (Volume 67), Section 7.7, SSI benefit (page 117). + +- name: Case 1, reported SSI receipt. + period: 2025-01 + input: + state_code: SC + receives_ssi: true + output: + sc_tanf_ssi_recipient: true + +- name: Case 2, an SSI payment. + period: 2025-01 + input: + state_code: SC + ssi: 500 + output: + sc_tanf_ssi_recipient: true + +- name: Case 3, no SSI. + period: 2025-01 + input: + state_code: SC + age: 35 + output: + sc_tanf_ssi_recipient: false + +- name: Case 4, not defined outside South Carolina. + period: 2025-01 + input: + state_code: NC + receives_ssi: true + output: + sc_tanf_ssi_recipient: false From 4809a5634289fe86dcf53c7d258c16044952da38 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 19:46:51 -0400 Subject: [PATCH 3/8] Add property tests for SC TANF gross income Check, over generated benefit groups in one vectorized simulation, that gross unearned income matches a reference written from the manual's Section 7.7 chart, that the $400 interest and dividend exclusion is conserved across members and never exceeds a member's own interest and dividends, that retirement withdrawals, discharged debt and wages are ignored, that SSI recipients count nothing, and that unearned income is monotone in interest. YAML cannot express these identities over arbitrary member mixes. Locally: 25 s of test time, 976 MB peak RSS. Co-Authored-By: Claude Opus 5.5 --- ...test_sc_tanf_unearned_income_invariants.py | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 policyengine_us/tests/test_sc_tanf_unearned_income_invariants.py diff --git a/policyengine_us/tests/test_sc_tanf_unearned_income_invariants.py b/policyengine_us/tests/test_sc_tanf_unearned_income_invariants.py new file mode 100644 index 00000000000..84f4d34a18f --- /dev/null +++ b/policyengine_us/tests/test_sc_tanf_unearned_income_invariants.py @@ -0,0 +1,269 @@ +"""Property tests: South Carolina TANF gross earned and unearned income. + +SC DSS TANF Policy Manual, Volume 67 (July 2026): + +- Section 7.1 (PDF page 102): "Income is money received by a BG member (s) + from any source." +- Section 7.7 (pages 107-120) counts Social Security at the gross amount + (page 116), unemployment compensation (page 118), pensions (page 115), + child support (page 108) and VA benefits (page 119), and counts interest, + dividends and royalties with "Up to $400 per BG annually ... excluded. + Income above $400 must be averaged over the period of the remaining 12 + months" (page 113). It excludes "the SSI recipient, his/her income" + (page 117). +- Section 6.6 (pages 92-93) counts IRA and Keogh balances as resources, so + a withdrawal is not income; a discharged debt is not money received. + +For every benefit group, in a month: + +1. Each member's sc_tanf_gross_unearned_income equals a reference written + from the chart above, not from the model's parameter list: the member's + counted sources, less a share of min(group interest and dividends, + 400 / 12) proportional to the member's interest and dividends. SSI + recipients count nothing and take no share. +2. The group's total is its counted sources less min(group interest and + dividends, 400 / 12), and no member's exclusion exceeds the member's own + interest and dividends. +3. Retirement account withdrawals, discharged debt and wages never change + unearned income (property 1 holds with them present). +4. sc_tanf_gross_earned_income is wages, or zero for an SSI recipient. +5. Raising the head's interest by d raises the group's unearned income by + between 0 and d / 12: by exactly d / 12 when the group's interest and + dividends already exceed the exclusion, and by 0 when the head receives + SSI. + +Each generated group is simulated twice in one vectorized simulation: as +drawn, and with the head's interest raised (property 5). +""" + +import numpy as np +import pytest + +from policyengine_us import Simulation + +YEAR = 2025 +MONTH = f"{YEAR}-01" +EXCLUSION = 400 # Section 7.7, page 113, per benefit group per year. +TOLERANCE = 0.01 + +# Sources the chart counts at the gross amount, in model input names. +COUNTED = [ + "social_security_retirement", + "unemployment_compensation", + "taxable_private_pension_income", + "tax_exempt_private_pension_income", + "child_support_received", + "veterans_benefits", +] +INTEREST_AND_DIVIDENDS = [ + "taxable_interest_income", + "tax_exempt_interest_income", + "qualified_dividend_income", + "non_qualified_dividend_income", +] +# Inputs that must not count as unearned income. +IGNORED = ["taxable_ira_distributions", "debt_relief"] +WAGES = "employment_income_before_lsr" +AMOUNT_INPUTS = [*COUNTED, *INTEREST_AND_DIVIDENDS, *IGNORED, WAGES] + + +def build_situation(groups): + """One household, SPM unit, tax unit and family per group; each person + is unmarried. Adults are 30 to 50 and children 1 to 17, and nobody is + disabled, so the model computes no SSI; SSI receipt comes only from + receives_ssi.""" + people, tax_units, spm_units, families, households = {}, {}, {}, {}, {} + marital_units = {} + for i, group in enumerate(groups): + members = [] + for j, member in enumerate(group): + name = f"person_{i}_{j}" + person = { + "age": {YEAR: member["age"]}, + "receives_ssi": {MONTH: member["ssi"]}, + } + for variable in AMOUNT_INPUTS: + person[variable] = {YEAR: member.get(variable, 0)} + people[name] = person + members.append(name) + marital_units[f"marital_unit_{i}_{j}"] = {"members": [name]} + tax_units[f"tax_unit_{i}"] = {"members": members} + spm_units[f"spm_unit_{i}"] = {"members": members} + families[f"family_{i}"] = {"members": members} + households[f"household_{i}"] = { + "members": members, + "state_code": {YEAR: "SC"}, + } + return { + "people": people, + "tax_units": tax_units, + "spm_units": spm_units, + "families": families, + "marital_units": marital_units, + "households": households, + } + + +def raise_head_interest(group, d): + raised = [dict(member) for member in group] + raised[0]["taxable_interest_income"] = ( + raised[0].get("taxable_interest_income", 0) + d + ) + return raised + + +def reference(group): + """Monthly gross unearned and earned income per member, from the + manual.""" + counted, idr, earned = [], [], [] + for member in group: + keep = 0 if member["ssi"] else 1 + member_idr = sum(member.get(v, 0) for v in INTEREST_AND_DIVIDENDS) + member_counted = sum(member.get(v, 0) for v in COUNTED) + member_idr + counted.append(keep * member_counted / 12) + idr.append(keep * member_idr / 12) + earned.append(keep * member.get(WAGES, 0) / 12) + group_idr = sum(idr) + exclusion = min(group_idr, EXCLUSION / 12) + unearned = [ + c - (exclusion * x / group_idr if group_idr > 0 else 0) + for c, x in zip(counted, idr) + ] + return unearned, earned, exclusion + + +def run(groups, raises): + """Simulate each group as drawn and with the head's interest raised. + Return per-member values for both copies.""" + doubled = groups + [raise_head_interest(g, d) for g, d in zip(groups, raises)] + sim = Simulation(situation=build_situation(doubled)) + unearned = np.asarray(sim.calculate("sc_tanf_gross_unearned_income", MONTH)) + earned = np.asarray(sim.calculate("sc_tanf_gross_earned_income", MONTH)) + ssi = np.asarray(sim.calculate("sc_tanf_ssi_recipient", MONTH)) + sizes = [len(g) for g in doubled] + bounds = np.cumsum([0, *sizes]) + split = lambda a: [a[bounds[k] : bounds[k + 1]] for k in range(len(doubled))] + n = len(groups) + u, e, s = split(unearned), split(earned), split(ssi) + return (u[:n], e[:n], s[:n]), (u[n:], e[n:], s[n:]) + + +def check(groups, raises): + (unearned, earned, ssi), (raised_unearned, _, _) = run(groups, raises) + for k, group in enumerate(groups): + expected_unearned, expected_earned, exclusion = reference(group) + context = {"group": group} + # Property 1 and 3: the reference from the manual. + assert unearned[k] == pytest.approx(expected_unearned, abs=TOLERANCE), context + # Property 4. + assert earned[k] == pytest.approx(expected_earned, abs=TOLERANCE), context + assert list(ssi[k]) == [bool(m["ssi"]) for m in group], context + # Property 2: group conservation and per-member bounds. + counted = [ + 0 + if m["ssi"] + else sum(m.get(v, 0) for v in COUNTED + INTEREST_AND_DIVIDENDS) / 12 + for m in group + ] + assert unearned[k].sum() == pytest.approx( + sum(counted) - exclusion, abs=TOLERANCE + ), context + for member, value, member_counted in zip(group, unearned[k], counted): + member_idr = ( + 0 + if member["ssi"] + else sum(member.get(v, 0) for v in INTEREST_AND_DIVIDENDS) / 12 + ) + assert -TOLERANCE <= member_counted - value <= member_idr + TOLERANCE + # Property 5: monotone in the head's interest. + change = raised_unearned[k].sum() - unearned[k].sum() + d = raises[k] / 12 + assert -TOLERANCE <= change <= d + TOLERANCE, context + group_idr = sum( + 0 if m["ssi"] else sum(m.get(v, 0) for v in INTEREST_AND_DIVIDENDS) / 12 + for m in group + ) + if group[0]["ssi"]: + assert change == pytest.approx(0, abs=TOLERANCE), context + elif group_idr >= EXCLUSION / 12: + assert change == pytest.approx(d, abs=TOLERANCE), context + + +def test_worked_example(): + """Integration Case 9's benefit group, from the manual: child support + 3,600, interest 1,000, and a child's Social Security 4,800 a year.""" + groups = [ + [ + { + "age": 30, + "ssi": False, + "child_support_received": 3_600, + "taxable_interest_income": 1_000, + }, + {"age": 9, "ssi": False, "social_security_retirement": 4_800}, + {"age": 6, "ssi": False}, + ] + ] + (unearned, _, _), _ = run(groups, [0]) + # 300 + (1,000 - 400) / 12 = 350 for the parent; 400 for the child. + assert unearned[0] == pytest.approx([350, 400, 0], abs=TOLERANCE) + + +try: + import hypothesis + import hypothesis.strategies as st +except ImportError: # pragma: no cover + hypothesis = None + +if hypothesis is not None: + SLOW = [hypothesis.HealthCheck.too_slow] + + def amount(high): + return st.one_of(st.just(0), st.integers(min_value=1, max_value=high)) + + @st.composite + def member(draw, age): + return { + "age": age, + "ssi": draw(st.booleans()), + "social_security_retirement": draw(amount(24_000)), + "unemployment_compensation": draw(amount(15_000)), + "taxable_private_pension_income": draw(amount(10_000)), + "tax_exempt_private_pension_income": draw(amount(5_000)), + "child_support_received": draw(amount(8_000)), + "veterans_benefits": draw(amount(12_000)), + "taxable_interest_income": draw(amount(3_000)), + "tax_exempt_interest_income": draw(amount(1_000)), + "qualified_dividend_income": draw(amount(2_000)), + "non_qualified_dividend_income": draw(amount(1_000)), + "taxable_ira_distributions": draw(amount(20_000)), + "debt_relief": draw(amount(5_000)), + "employment_income_before_lsr": draw(amount(30_000)), + } + + @st.composite + def group(draw): + head = draw(member(draw(st.integers(min_value=30, max_value=50)))) + children = draw( + st.lists( + member(draw(st.integers(min_value=1, max_value=17))), + min_size=0, + max_size=3, + ) + ) + return [head, *children] + + @hypothesis.settings( + max_examples=15, derandomize=True, deadline=None, suppress_health_check=SLOW + ) + @hypothesis.given( + st.lists( + st.tuples(group(), st.integers(min_value=1, max_value=5_000)), + min_size=1, + max_size=8, + ) + ) + def test_properties(cases): + groups = [g for g, _ in cases] + raises = [d for _, d in cases] + check(groups, raises) From 39248f108997d54d0ad462a02013e2503904f797 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 20:56:28 -0400 Subject: [PATCH 4/8] Leave SSI recipients out of the SC TANF benefit group Address the independent review of 530cc15: - Section 3.6 (page 56) excludes an SSI recipient living with the benefit group, not only his or her income. Add sc_tanf_benefit_group_size and sc_tanf_need_standard (50% of the poverty guideline for that size, Section 8.15) and use the need standard in the payment, the need test and the gross income limit, so an SSI member's needs are no longer counted while their income is excluded. - Floor each member's interest and dividends at zero before the $400 exclusion, so negative interest cannot offset other income. - Read dividends through dividend_income, which also carries legacy dividend inputs. - Count guaranteed income and trust income, which are money received (Section 7.1); estate settlements remain resources. - Keep counting retirement account withdrawals, now at the gross amount: the manual counts IRA balances as resources but does not say periodic withdrawals are not income. Tests that set spm_unit_size now set the benefit group size. Co-Authored-By: Claude Opus 5.5 --- changelog.d/sc-tanf-unearned-gross.fixed.md | 2 +- .../interest_and_dividends/sources.yaml | 9 +- .../sc/tanf/income/unearned/sources.yaml | 13 +- .../gov/states/sc/tanf/integration.yaml | 65 ++++++- .../baseline/gov/states/sc/tanf/sc_tanf.yaml | 10 +- .../sc/tanf/sc_tanf_benefit_group_size.yaml | 63 +++++++ .../sc_tanf_countable_income_eligible.yaml | 12 +- .../tanf/sc_tanf_gross_income_eligible.yaml | 12 +- .../tanf/sc_tanf_gross_unearned_income.yaml | 70 ++++++-- .../sc/tanf/sc_tanf_income_eligible.yaml | 10 +- .../states/sc/tanf/sc_tanf_need_standard.yaml | 32 ++++ ...test_sc_tanf_unearned_income_invariants.py | 169 +++++++++++------- .../sc_tanf_countable_income_eligible.py | 4 +- .../sc_tanf_gross_income_eligible.py | 3 +- .../income/sc_tanf_gross_unearned_income.py | 12 +- .../variables/gov/states/sc/tanf/sc_tanf.py | 4 +- .../sc/tanf/sc_tanf_benefit_group_size.py | 17 ++ .../states/sc/tanf/sc_tanf_need_standard.py | 31 ++++ 18 files changed, 414 insertions(+), 124 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_benefit_group_size.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_need_standard.yaml create mode 100644 policyengine_us/variables/gov/states/sc/tanf/sc_tanf_benefit_group_size.py create mode 100644 policyengine_us/variables/gov/states/sc/tanf/sc_tanf_need_standard.py diff --git a/changelog.d/sc-tanf-unearned-gross.fixed.md b/changelog.d/sc-tanf-unearned-gross.fixed.md index b32067e29f9..e33e00b870a 100644 --- a/changelog.d/sc-tanf-unearned-gross.fixed.md +++ b/changelog.d/sc-tanf-unearned-gross.fixed.md @@ -1 +1 @@ -South Carolina TANF now counts Social Security, unemployment compensation, pensions and interest at their gross amounts, adds the unearned income the TANF Policy Manual counts (child support, alimony, veterans benefits, workers' compensation, disability insurance, survivor and railroad retirement benefits, cash contributions), drops retirement account distributions and discharged debt, treats farm income as earned, excludes the first $400 a year of a benefit group's interest and dividends, and excludes SSI recipients' income. +South Carolina TANF now counts Social Security, unemployment compensation, pensions, retirement account withdrawals and interest at their gross amounts; adds child support, alimony, veterans benefits, workers' compensation, disability insurance, survivor and railroad retirement benefits, cash contributions, guaranteed income and trust income; drops discharged debt; treats farm income as earned; excludes the first $400 a year of a benefit group's interest and dividends; and leaves SSI recipients and their income out of the benefit group, as the TANF Policy Manual requires. diff --git a/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/interest_and_dividends/sources.yaml b/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/interest_and_dividends/sources.yaml index 281581c5e0f..3a5de9446f7 100644 --- a/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/interest_and_dividends/sources.yaml +++ b/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/interest_and_dividends/sources.yaml @@ -1,13 +1,14 @@ -description: South Carolina applies the interest, dividend and royalty exclusion to these income sources under the Temporary Assistance for Needy Families program. +description: South Carolina counts these income sources as unearned income after the interest, dividend and royalty exclusion under the Temporary Assistance for Needy Families program. values: 1996-07-01: - interest_income - - ordinary_dividend_income - # Royalties are included in rental income and are not separately excluded. + - dividend_income + # Royalties are included in rental income, which has no separate + # royalty input, so they are counted without this exclusion. metadata: unit: list period: year - label: South Carolina TANF interest and dividend exclusion sources + label: South Carolina TANF interest and dividend sources reference: - title: SC DSS TANF Policy Manual (Volume 67), Section 7.7 href: https://dss.sc.gov/media/bxwc1jdj/tanf-policy-manual-volume-67-final.pdf#page=113 diff --git a/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/sources.yaml b/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/sources.yaml index 0f10a2e1a79..38884e75041 100644 --- a/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/sources.yaml +++ b/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/sources.yaml @@ -1,11 +1,14 @@ -description: South Carolina counts these income sources as unearned income under the Temporary Assistance for Needy Families program. +description: South Carolina counts these income sources, along with interest and dividends, as unearned income under the Temporary Assistance for Needy Families program. values: 1996-07-01: # Section 7.1 defines income as money received from any source, and the # Section 7.7 chart counts each source below at its gross amount. + # Interest and dividends are counted separately, after the $400 + # exclusion (see interest_and_dividends). - social_security # SSA benefits: count the gross amount - unemployment_compensation - pension_income # Pension; annuities and endowment contracts + - retirement_distributions # IRA and 401(k) withdrawals: money received - railroad_benefits # Retirement Pay (Railroad Retirement) - survivor_benefits # Pension and retirement pay to survivors - veterans_benefits @@ -14,18 +17,16 @@ values: - child_support_received # Child support/direct - alimony_income - financial_assistance # Cash contributions - - interest_income # Interest, dividends and royalties - - ordinary_dividend_income + - gi_cash_assistance - capital_gains # Capital gains (other) - rental_income # Rent payments; royalties are included in rental income - farm_rent_income + - estate_income # Trust and estate income; settlements are resources - partnership_s_corp_income - miscellaneous_income - illicit_income # Not listed: farm operations income is earned self-employment income - # (Section 7.7); retirement account distributions draw down IRA, Keogh and - # deferred compensation balances that Section 6.6 treats as resources; and - # discharged debt is not money received. + # (Section 7.7), and discharged debt is not money received (Section 7.1). metadata: unit: list period: month diff --git a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/integration.yaml index 221267a265e..3d334a20544 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/integration.yaml @@ -24,7 +24,7 @@ sc_tanf: 435.28 # tanf_fpg for size 3 (2025-01, uses 2024-10 FPG) = 2151.67/month # need_standard = 2151.67 * 0.5 = 1075.83 - # benefit = 1075.83 * 0.4046 = 435.49 + # benefit = 1075.83 * 0.4046 = 435.28 - name: Case 2, single parent with earned income. absolute_error_margin: 0.1 @@ -260,3 +260,66 @@ # tanf_fpg for size 3 = 2151.67; need standard = 1075.83 # gross income limit = 1075.83 * 1.85 = 1990.29; 750 passes # benefit = (1075.83 - 750) * 0.4046 = 131.83 + +- name: Case 10, an SSI child and the child's income are excluded from the benefit group. + period: 2025-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 30 + employment_income_before_lsr: 0 + person2: + age: 9 + is_child_dependent: true + receives_ssi: true + taxable_private_pension_income: 3_600 + person3: + age: 6 + is_child_dependent: true + spm_units: + spm_unit: + members: [person1, person2, person3] + spm_unit_cash_assets: 500 + households: + household: + members: [person1, person2, person3] + state_code: SC + output: + sc_tanf_benefit_group_size: 2 + sc_tanf_gross_income: 0 + sc_tanf_need_standard: 851.67 + sc_tanf: 344.58 + # SC DSS TANF Policy Manual (Volume 67), Section 3.6 (page 56) and + # Section 7.7 (page 117): exclude the SSI recipient, his/her income and + # resources. The benefit group is the parent and the other child. + # FPG for 2 = 1703.33; need standard = 851.67 + # benefit = 851.67 * 0.4046 = 344.58 + +- name: Case 11, when the only child receives SSI, the parent is the only benefit group member. + period: 2025-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 30 + employment_income_before_lsr: 0 + person2: + age: 9 + is_child_dependent: true + receives_ssi: true + spm_units: + spm_unit: + members: [person1, person2] + spm_unit_cash_assets: 500 + households: + household: + members: [person1, person2] + state_code: SC + output: + sc_tanf_benefit_group_size: 1 + sc_tanf: 253.89 + # Section 3.6 (page 56): "If an SSI child is the only child in the home, + # include the parent(s) as the only BG member." + # FPG for 1 = 1255; need standard = 627.50 + # benefit = 627.50 * 0.4046 = 253.89 diff --git a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf.yaml b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf.yaml index 01f7c7cfc9a..b8b094df985 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf.yaml @@ -5,10 +5,10 @@ state_code: SC sc_tanf_countable_income: 0 sc_tanf_eligible: true - spm_unit_size: 2 + sc_tanf_benefit_group_size: 2 output: sc_tanf: 344.58 - # tanf_fpg for size 2 (2025-01, uses 2024-10 FPG) = 1703.33/month + # FPG for a benefit group of 2 (2025-01, uses 2024-10 FPG) = 1703.33/month # need_standard = 1703.33 * 0.5 = 851.67 # benefit = 851.67 * 0.4046 = 344.59 @@ -18,7 +18,7 @@ state_code: SC sc_tanf_countable_income: 1_000 sc_tanf_eligible: false - spm_unit_size: 2 + sc_tanf_benefit_group_size: 2 output: sc_tanf: 0 @@ -29,10 +29,10 @@ state_code: SC sc_tanf_countable_income: 500 sc_tanf_eligible: true - spm_unit_size: 2 + sc_tanf_benefit_group_size: 2 output: sc_tanf: 142.28 - # tanf_fpg for size 2 (2025-01) = 1703.33/month + # FPG for a benefit group of 2 (2025-01) = 1703.33/month # need_standard = 1703.33 * 0.5 = 851.67 # excess = 851.67 - 500 = 351.67 # benefit = 351.67 * 0.4046 = 142.31 diff --git a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_benefit_group_size.yaml b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_benefit_group_size.yaml new file mode 100644 index 00000000000..3153dc06e45 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_benefit_group_size.yaml @@ -0,0 +1,63 @@ +# SC DSS TANF Policy Manual (Volume 67), Section 3.6 (page 56) and Section 7.7 +# (page 117): exclude an SSI recipient living with the benefit group. + +- name: Case 1, everyone in the unit is in the benefit group. + period: 2025-01 + input: + people: + person1: + age: 30 + person2: + age: 9 + person3: + age: 6 + spm_units: + spm_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: SC + output: + sc_tanf_benefit_group_size: 3 + +- name: Case 2, an SSI recipient is left out. + period: 2025-01 + input: + people: + person1: + age: 30 + person2: + age: 9 + receives_ssi: true + person3: + age: 6 + spm_units: + spm_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: SC + output: + sc_tanf_benefit_group_size: 2 + +- name: Case 3, a unit of SSI recipients has no benefit group members. + period: 2025-01 + input: + people: + person1: + age: 30 + receives_ssi: true + person2: + age: 9 + receives_ssi: true + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: SC + output: + sc_tanf_benefit_group_size: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_countable_income_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_countable_income_eligible.yaml index ed35d4779a1..624cdcf1949 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_countable_income_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_countable_income_eligible.yaml @@ -2,7 +2,7 @@ period: 2025-01 input: state_code: SC - spm_unit_size: 2 + sc_tanf_benefit_group_size: 2 output: sc_tanf_countable_income_eligible: true @@ -10,11 +10,11 @@ period: 2025-01 input: state_code: SC - spm_unit_size: 2 + sc_tanf_benefit_group_size: 2 sc_tanf_countable_income: 800 output: sc_tanf_countable_income_eligible: true - # tanf_fpg for size 2 (2025-01) = 1703.33/month + # FPG for a benefit group of 2 (2025-01) = 1703.33/month # need_standard = 1703.33 * 0.5 = 851.67 # 800 < 851.67 -> eligible @@ -22,7 +22,7 @@ period: 2025-01 input: state_code: SC - spm_unit_size: 2 + sc_tanf_benefit_group_size: 2 sc_tanf_countable_income: 900 output: sc_tanf_countable_income_eligible: false @@ -34,11 +34,11 @@ absolute_error_margin: 0.1 input: state_code: SC - spm_unit_size: 2 + sc_tanf_benefit_group_size: 2 sc_tanf_countable_income: 851.67 output: sc_tanf_countable_income_eligible: false - # tanf_fpg for size 2 = 1703.33 + # FPG for a benefit group of 2 = 1703.33 # need_standard = 1703.33 * 0.5 = 851.67 # 851.67 < 851.67 is FALSE (strict less than) # -> not eligible diff --git a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_income_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_income_eligible.yaml index dd0656ebf42..4e3c80b52bd 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_income_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_income_eligible.yaml @@ -2,7 +2,7 @@ period: 2025-01 input: state_code: SC - spm_unit_size: 2 + sc_tanf_benefit_group_size: 2 output: sc_tanf_gross_income_eligible: true @@ -10,12 +10,12 @@ period: 2025-01 input: state_code: SC - spm_unit_size: 2 + sc_tanf_benefit_group_size: 2 employment_income_before_lsr: 18_000 output: sc_tanf_gross_income_eligible: true # monthly earned = 18,000 / 12 = 1,500 - # tanf_fpg for size 2 (2025-01) = 1703.33/month + # FPG for a benefit group of 2 (2025-01) = 1703.33/month # need_standard = 1703.33 * 0.5 = 851.67 # gross_income_limit = 851.67 * 1.85 = 1575.58 # 1,500 <= 1,575.58 -> eligible @@ -24,7 +24,7 @@ period: 2025-01 input: state_code: SC - spm_unit_size: 2 + sc_tanf_benefit_group_size: 2 employment_income_before_lsr: 24_000 output: sc_tanf_gross_income_eligible: false @@ -37,11 +37,11 @@ absolute_error_margin: 0.1 input: state_code: SC - spm_unit_size: 2 + sc_tanf_benefit_group_size: 2 sc_tanf_gross_income: 1_575.58 output: sc_tanf_gross_income_eligible: true - # tanf_fpg for size 2 = 1703.33 + # FPG for a benefit group of 2 = 1703.33 # need_standard = 1703.33 * 0.5 = 851.665 # gross_income_limit = 851.665 * 1.85 = 1575.58 # 1575.58 <= 1575.58 -> eligible (uses <=) diff --git a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_unearned_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_unearned_income.yaml index 6fe7d670e8d..4e7ce514d15 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_unearned_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_unearned_income.yaml @@ -51,27 +51,31 @@ survivor_benefits: 1_200 railroad_benefits: 1_200 financial_assistance: 1_200 + gi_cash_assistance: 1_200 + estate_income: 1_200 output: # Child support/direct, alimony, cash contributions (page 108), income # maintenance insurance including disability insurance (page 113), # retirement pay including railroad retirement (page 116), VA benefits - # (page 119) and workers' compensation (page 120) are counted. - # 8 sources x 1,200 / 12 = 800. - sc_tanf_gross_unearned_income: 800 + # (page 119) and workers' compensation (page 120) are counted, as are + # guaranteed income payments and trust income, which are money received + # (Section 7.1, page 102). + # 10 sources x 1,200 / 12 = 1,000. + sc_tanf_gross_unearned_income: 1_000 -- name: Case 5, retirement account distributions and discharged debt are not income. +- name: Case 5, retirement account withdrawals count at the gross amount and discharged debt does not. period: 2025-01 + absolute_error_margin: 0.01 input: state_code: SC - taxable_ira_distributions: 12_000 - taxable_401k_distributions: 6_000 + taxable_ira_distributions: 9_600 + taxable_401k_distributions: 2_400 debt_relief: 6_000 output: - # Section 6.6 counts IRA, SEP and Keogh balances as resources and excludes - # deferred compensation funds until withdrawn (pages 92-93), so a - # withdrawal draws down a resource rather than adding income. A discharged - # debt is not money received (Section 7.1). - sc_tanf_gross_unearned_income: 0 + # Withdrawals are money received (Section 7.1, page 102). A discharged + # debt is not money received, and in-kind gains are excluded. + # (9,600 + 2,400) / 12 = 1,000. + sc_tanf_gross_unearned_income: 1_000 - name: Case 6, farm operations income is earned, not unearned. period: 2025-01 @@ -164,3 +168,47 @@ # Only person1's income counts: 600 / 12 = 50, less the full 33.33 # exclusion = 16.67. Person2's income is excluded. sc_tanf_gross_unearned_income: [16.67, 0] + +- name: Case 12, negative interest does not offset other income. + period: 2025-01 + absolute_error_margin: 0.01 + input: + state_code: SC + taxable_interest_income: -1_200 + taxable_private_pension_income: 2_400 + output: + # Interest and dividends count only as money received, floored at zero, + # before the exclusion. Pension 2,400 / 12 = 200. + sc_tanf_gross_unearned_income: 200 + +- name: Case 13, one member's negative interest does not reduce another member's. + period: 2025-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 35 + taxable_interest_income: 600 + person2: + age: 10 + taxable_interest_income: -600 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: SC + output: + # Person1: 600 / 12 = 50, less the 33.33 exclusion = 16.67. Person2: 0. + sc_tanf_gross_unearned_income: [16.67, 0] + +- name: Case 14, dividends entered as the legacy dividend_income input count. + period: 2025-01 + absolute_error_margin: 0.01 + input: + state_code: SC + dividend_income: 1_200 + output: + # (1,200 - 400) / 12 = 66.67. + sc_tanf_gross_unearned_income: 66.67 diff --git a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_income_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_income_eligible.yaml index da84d0c22da..cbeec5cd65a 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_income_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_income_eligible.yaml @@ -4,7 +4,7 @@ state_code: SC sc_tanf_countable_income: 0 sc_tanf_gross_income: 0 - spm_unit_size: 2 + sc_tanf_benefit_group_size: 2 output: sc_tanf_income_eligible: true @@ -14,10 +14,10 @@ state_code: SC sc_tanf_countable_income: 500 sc_tanf_gross_income: 2_000 - spm_unit_size: 2 + sc_tanf_benefit_group_size: 2 output: sc_tanf_income_eligible: false - # tanf_fpg for size 2 (2025-01) = 1703.33/month + # FPG for a benefit group of 2 (2025-01) = 1703.33/month # need_standard = 1703.33 * 0.5 = 851.67 # gross_income_limit = 851.67 * 1.85 = 1575.58 # gross_income 2,000 > 1,575.58 -> not eligible @@ -28,7 +28,7 @@ state_code: SC sc_tanf_countable_income: 900 sc_tanf_gross_income: 1_200 - spm_unit_size: 2 + sc_tanf_benefit_group_size: 2 output: sc_tanf_income_eligible: false # need_standard = 851.67 @@ -42,7 +42,7 @@ state_code: SC sc_tanf_countable_income: 800 sc_tanf_gross_income: 1_000 - spm_unit_size: 2 + sc_tanf_benefit_group_size: 2 output: sc_tanf_income_eligible: true # gross 1,000 < 1,575.58 -> passes gross test diff --git a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_need_standard.yaml b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_need_standard.yaml new file mode 100644 index 00000000000..31ab0230586 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_need_standard.yaml @@ -0,0 +1,32 @@ +# SC DSS TANF Policy Manual (Volume 67), Section 8.15 (page 133): the need +# standard is 50% of the federal poverty guideline for the number of persons +# in the benefit group, revised each October 1. + +- name: Case 1, benefit group of three in January 2025 uses the October 2024 guideline. + period: 2025-01 + absolute_error_margin: 0.01 + input: + state_code: SC + sc_tanf_benefit_group_size: 3 + output: + # (15,060 + 2 x 5,380) / 12 = 2,151.67; x 0.5 = 1,075.83 + sc_tanf_need_standard: 1_075.83 + +- name: Case 2, benefit group of three in October 2025 uses the October 2025 guideline. + period: 2025-10 + absolute_error_margin: 0.01 + input: + state_code: + 2025: SC + sc_tanf_benefit_group_size: 3 + output: + # (15,650 + 2 x 5,500) / 12 = 2,220.83; x 0.5 = 1,110.42 + sc_tanf_need_standard: 1_110.42 + +- name: Case 3, an empty benefit group has no need standard. + period: 2025-01 + input: + state_code: SC + sc_tanf_benefit_group_size: 0 + output: + sc_tanf_need_standard: 0 diff --git a/policyengine_us/tests/test_sc_tanf_unearned_income_invariants.py b/policyengine_us/tests/test_sc_tanf_unearned_income_invariants.py index 84f4d34a18f..94ee81bce74 100644 --- a/policyengine_us/tests/test_sc_tanf_unearned_income_invariants.py +++ b/policyengine_us/tests/test_sc_tanf_unearned_income_invariants.py @@ -1,36 +1,43 @@ -"""Property tests: South Carolina TANF gross earned and unearned income. +"""Property tests: South Carolina TANF gross income and need standard. -SC DSS TANF Policy Manual, Volume 67 (July 2026): +SC DSS TANF Policy Manual, Volume 67 (effective August 1, 2026): - Section 7.1 (PDF page 102): "Income is money received by a BG member (s) - from any source." + from any source." A discharged debt is not money received. - Section 7.7 (pages 107-120) counts Social Security at the gross amount (page 116), unemployment compensation (page 118), pensions (page 115), child support (page 108) and VA benefits (page 119), and counts interest, dividends and royalties with "Up to $400 per BG annually ... excluded. Income above $400 must be averaged over the period of the remaining 12 - months" (page 113). It excludes "the SSI recipient, his/her income" - (page 117). -- Section 6.6 (pages 92-93) counts IRA and Keogh balances as resources, so - a withdrawal is not income; a discharged debt is not money received. + months" (page 113). +- Sections 3.6 (page 56) and 7.7 (page 117): "Exclude the SSI recipient, + his/her income, resources and SSI payments in determining benefits for + other BG members." +- Section 8.15 (page 133): the need standard is 50% of the federal poverty + guideline for the benefit group's size; the table's 2024 guideline is + $15,060 for one person plus $5,380 for each additional person. -For every benefit group, in a month: +For every benefit group, in January 2025: 1. Each member's sc_tanf_gross_unearned_income equals a reference written - from the chart above, not from the model's parameter list: the member's - counted sources, less a share of min(group interest and dividends, - 400 / 12) proportional to the member's interest and dividends. SSI - recipients count nothing and take no share. + from the manual, not from the model's parameter list: the member's + counted sources plus their interest and dividends (floored at zero), less + a share of min(group interest and dividends, 400 / 12) proportional to + the member's interest and dividends. SSI recipients count nothing and + take no share. 2. The group's total is its counted sources less min(group interest and dividends, 400 / 12), and no member's exclusion exceeds the member's own interest and dividends. -3. Retirement account withdrawals, discharged debt and wages never change - unearned income (property 1 holds with them present). +3. Discharged debt and wages never change unearned income, and negative + interest never reduces other income (property 1 holds with them present). 4. sc_tanf_gross_earned_income is wages, or zero for an SSI recipient. 5. Raising the head's interest by d raises the group's unearned income by - between 0 and d / 12: by exactly d / 12 when the group's interest and - dividends already exceed the exclusion, and by 0 when the head receives - SSI. + between 0 and d / 12, by exactly the rise in the head's floored interest + when the group's interest and dividends already exceed the exclusion, and + by 0 when the head receives SSI. +6. The benefit group size is the number of members who do not receive SSI, + and the need standard is 50% of the guideline for that size (0 for an + empty group). Each generated group is simulated twice in one vectorized simulation: as drawn, and with the head's interest raised (property 5). @@ -44,14 +51,19 @@ YEAR = 2025 MONTH = f"{YEAR}-01" EXCLUSION = 400 # Section 7.7, page 113, per benefit group per year. +# Section 8.15, page 133: 2024 guideline, in effect from October 2024. +FPG_FIRST_PERSON = 15_060 +FPG_ADDITIONAL_PERSON = 5_380 +NEED_STANDARD_RATE = 0.5 TOLERANCE = 0.01 -# Sources the chart counts at the gross amount, in model input names. +# Sources counted at the gross amount, in model input names. COUNTED = [ "social_security_retirement", "unemployment_compensation", "taxable_private_pension_income", "tax_exempt_private_pension_income", + "taxable_ira_distributions", "child_support_received", "veterans_benefits", ] @@ -62,7 +74,7 @@ "non_qualified_dividend_income", ] # Inputs that must not count as unearned income. -IGNORED = ["taxable_ira_distributions", "debt_relief"] +IGNORED = ["debt_relief"] WAGES = "employment_income_before_lsr" AMOUNT_INPUTS = [*COUNTED, *INTEREST_AND_DIVIDENDS, *IGNORED, WAGES] @@ -112,81 +124,103 @@ def raise_head_interest(group, d): return raised +def member_interest_and_dividends(member): + """Monthly interest and dividends counted for a member: floored at zero, + and zero for an SSI recipient.""" + if member["ssi"]: + return 0 + return max(sum(member.get(v, 0) for v in INTEREST_AND_DIVIDENDS), 0) / 12 + + +def member_counted(member): + """Monthly counted income before the exclusion.""" + if member["ssi"]: + return 0 + other = sum(member.get(v, 0) for v in COUNTED) / 12 + return other + member_interest_and_dividends(member) + + def reference(group): - """Monthly gross unearned and earned income per member, from the + """Monthly gross unearned and earned income per member, the group's + exclusion, and the benefit group's size and need standard, from the manual.""" - counted, idr, earned = [], [], [] - for member in group: - keep = 0 if member["ssi"] else 1 - member_idr = sum(member.get(v, 0) for v in INTEREST_AND_DIVIDENDS) - member_counted = sum(member.get(v, 0) for v in COUNTED) + member_idr - counted.append(keep * member_counted / 12) - idr.append(keep * member_idr / 12) - earned.append(keep * member.get(WAGES, 0) / 12) + idr = [member_interest_and_dividends(m) for m in group] group_idr = sum(idr) exclusion = min(group_idr, EXCLUSION / 12) unearned = [ - c - (exclusion * x / group_idr if group_idr > 0 else 0) - for c, x in zip(counted, idr) + member_counted(m) - (exclusion * x / group_idr if group_idr > 0 else 0) + for m, x in zip(group, idr) ] - return unearned, earned, exclusion + earned = [0 if m["ssi"] else m.get(WAGES, 0) / 12 for m in group] + size = sum(not m["ssi"] for m in group) + fpg = (FPG_FIRST_PERSON + FPG_ADDITIONAL_PERSON * (size - 1)) / 12 + need_standard = NEED_STANDARD_RATE * fpg if size > 0 else 0 + return unearned, earned, exclusion, size, need_standard def run(groups, raises): """Simulate each group as drawn and with the head's interest raised. - Return per-member values for both copies.""" + Return per-member and per-group values for both copies.""" doubled = groups + [raise_head_interest(g, d) for g, d in zip(groups, raises)] sim = Simulation(situation=build_situation(doubled)) - unearned = np.asarray(sim.calculate("sc_tanf_gross_unearned_income", MONTH)) - earned = np.asarray(sim.calculate("sc_tanf_gross_earned_income", MONTH)) - ssi = np.asarray(sim.calculate("sc_tanf_ssi_recipient", MONTH)) - sizes = [len(g) for g in doubled] - bounds = np.cumsum([0, *sizes]) - split = lambda a: [a[bounds[k] : bounds[k + 1]] for k in range(len(doubled))] + + def calc(variable): + return np.asarray(sim.calculate(variable, MONTH)) + + unearned = calc("sc_tanf_gross_unearned_income") + earned = calc("sc_tanf_gross_earned_income") + ssi = calc("sc_tanf_ssi_recipient") + size = calc("sc_tanf_benefit_group_size") + need = calc("sc_tanf_need_standard") + bounds = np.cumsum([0, *[len(g) for g in doubled]]) n = len(groups) + + def split(a): + return [a[bounds[k] : bounds[k + 1]] for k in range(len(doubled))] + u, e, s = split(unearned), split(earned), split(ssi) - return (u[:n], e[:n], s[:n]), (u[n:], e[n:], s[n:]) + drawn = (u[:n], e[:n], s[:n], size[:n], need[:n]) + raised = (u[n:], e[n:], s[n:], size[n:], need[n:]) + return drawn, raised def check(groups, raises): - (unearned, earned, ssi), (raised_unearned, _, _) = run(groups, raises) + (unearned, earned, ssi, size, need), raised = run(groups, raises) + raised_unearned = raised[0] for k, group in enumerate(groups): - expected_unearned, expected_earned, exclusion = reference(group) + expected = reference(group) + expected_unearned, expected_earned, exclusion = expected[:3] + expected_size, expected_need = expected[3:] context = {"group": group} - # Property 1 and 3: the reference from the manual. + # Properties 1 and 3: the reference from the manual. assert unearned[k] == pytest.approx(expected_unearned, abs=TOLERANCE), context # Property 4. assert earned[k] == pytest.approx(expected_earned, abs=TOLERANCE), context assert list(ssi[k]) == [bool(m["ssi"]) for m in group], context # Property 2: group conservation and per-member bounds. - counted = [ - 0 - if m["ssi"] - else sum(m.get(v, 0) for v in COUNTED + INTEREST_AND_DIVIDENDS) / 12 - for m in group - ] + counted = [member_counted(m) for m in group] assert unearned[k].sum() == pytest.approx( sum(counted) - exclusion, abs=TOLERANCE ), context - for member, value, member_counted in zip(group, unearned[k], counted): - member_idr = ( - 0 - if member["ssi"] - else sum(member.get(v, 0) for v in INTEREST_AND_DIVIDENDS) / 12 - ) - assert -TOLERANCE <= member_counted - value <= member_idr + TOLERANCE + for member, value, before in zip(group, unearned[k], counted): + own = member_interest_and_dividends(member) + assert -TOLERANCE <= before - value <= own + TOLERANCE, context # Property 5: monotone in the head's interest. change = raised_unearned[k].sum() - unearned[k].sum() d = raises[k] / 12 assert -TOLERANCE <= change <= d + TOLERANCE, context - group_idr = sum( - 0 if m["ssi"] else sum(m.get(v, 0) for v in INTEREST_AND_DIVIDENDS) / 12 - for m in group - ) - if group[0]["ssi"]: + head = group[0] + head_rise = member_interest_and_dividends( + raise_head_interest([head], raises[k])[0] + ) - member_interest_and_dividends(head) + group_idr = sum(member_interest_and_dividends(m) for m in group) + if head["ssi"]: assert change == pytest.approx(0, abs=TOLERANCE), context elif group_idr >= EXCLUSION / 12: - assert change == pytest.approx(d, abs=TOLERANCE), context + assert change == pytest.approx(head_rise, abs=TOLERANCE), context + # Property 6: benefit group size and need standard. + assert size[k] == expected_size, context + assert need[k] == pytest.approx(expected_need, abs=TOLERANCE), context def test_worked_example(): @@ -204,9 +238,12 @@ def test_worked_example(): {"age": 6, "ssi": False}, ] ] - (unearned, _, _), _ = run(groups, [0]) + (unearned, _, _, size, need), _ = run(groups, [0]) # 300 + (1,000 - 400) / 12 = 350 for the parent; 400 for the child. assert unearned[0] == pytest.approx([350, 400, 0], abs=TOLERANCE) + # Section 8.15 table, three persons: (15,060 + 2 x 5,380) / 12 x 0.5. + assert size[0] == 3 + assert need[0] == pytest.approx(1_075.83, abs=TOLERANCE) try: @@ -230,13 +267,17 @@ def member(draw, age): "unemployment_compensation": draw(amount(15_000)), "taxable_private_pension_income": draw(amount(10_000)), "tax_exempt_private_pension_income": draw(amount(5_000)), + "taxable_ira_distributions": draw(amount(20_000)), "child_support_received": draw(amount(8_000)), "veterans_benefits": draw(amount(12_000)), - "taxable_interest_income": draw(amount(3_000)), + # Negative interest is allowed as an input and must not offset + # other income. + "taxable_interest_income": draw( + st.one_of(amount(3_000), st.integers(min_value=-3_000, max_value=-1)) + ), "tax_exempt_interest_income": draw(amount(1_000)), "qualified_dividend_income": draw(amount(2_000)), "non_qualified_dividend_income": draw(amount(1_000)), - "taxable_ira_distributions": draw(amount(20_000)), "debt_relief": draw(amount(5_000)), "employment_income_before_lsr": draw(amount(30_000)), } diff --git a/policyengine_us/variables/gov/states/sc/tanf/eligibility/sc_tanf_countable_income_eligible.py b/policyengine_us/variables/gov/states/sc/tanf/eligibility/sc_tanf_countable_income_eligible.py index d91f33f82ef..f6583d47d06 100644 --- a/policyengine_us/variables/gov/states/sc/tanf/eligibility/sc_tanf_countable_income_eligible.py +++ b/policyengine_us/variables/gov/states/sc/tanf/eligibility/sc_tanf_countable_income_eligible.py @@ -12,8 +12,6 @@ class sc_tanf_countable_income_eligible(Variable): ) def formula(spm_unit, period, parameters): - p = parameters(period).gov.states.sc.tanf.income - fpg = spm_unit("tanf_fpg", period) - need_standard = fpg * p.need_standard.rate + need_standard = spm_unit("sc_tanf_need_standard", period) countable_income = spm_unit("sc_tanf_countable_income", period) return countable_income < need_standard diff --git a/policyengine_us/variables/gov/states/sc/tanf/eligibility/sc_tanf_gross_income_eligible.py b/policyengine_us/variables/gov/states/sc/tanf/eligibility/sc_tanf_gross_income_eligible.py index 63da1a846f1..61ac97c146d 100644 --- a/policyengine_us/variables/gov/states/sc/tanf/eligibility/sc_tanf_gross_income_eligible.py +++ b/policyengine_us/variables/gov/states/sc/tanf/eligibility/sc_tanf_gross_income_eligible.py @@ -13,8 +13,7 @@ class sc_tanf_gross_income_eligible(Variable): def formula(spm_unit, period, parameters): p = parameters(period).gov.states.sc.tanf.income - fpg = spm_unit("tanf_fpg", period) - need_standard = fpg * p.need_standard.rate + need_standard = spm_unit("sc_tanf_need_standard", period) gross_income_limit = need_standard * p.gross_income_limit gross_income = spm_unit("sc_tanf_gross_income", period) return gross_income <= gross_income_limit diff --git a/policyengine_us/variables/gov/states/sc/tanf/income/sc_tanf_gross_unearned_income.py b/policyengine_us/variables/gov/states/sc/tanf/income/sc_tanf_gross_unearned_income.py index 9c597e12e2b..a4472789ddb 100644 --- a/policyengine_us/variables/gov/states/sc/tanf/income/sc_tanf_gross_unearned_income.py +++ b/policyengine_us/variables/gov/states/sc/tanf/income/sc_tanf_gross_unearned_income.py @@ -15,17 +15,15 @@ def formula(person, period, parameters): p = parameters(period).gov.states.sc.tanf.income.unearned # Section 7.7 excludes the income of SSI recipients. counted = ~person("sc_tanf_ssi_recipient", period) - unearned = add(person, period, p.sources) * counted + other_sources = add(person, period, p.sources) * counted # Section 7.7 excludes up to $400 of interest, dividends and royalties # per benefit group each year and averages the rest over 12 months. # Each member's exclusion is proportional to their share of that income. - p_exclusion = p.interest_and_dividends - interest_and_dividends = ( - max_(add(person, period, p_exclusion.sources), 0) * counted - ) + p_idr = p.interest_and_dividends + interest_and_dividends = max_(add(person, period, p_idr.sources), 0) * counted unit_interest_and_dividends = person.spm_unit.sum(interest_and_dividends) unit_exclusion = min_( - unit_interest_and_dividends, p_exclusion.exclusion / MONTHS_IN_YEAR + unit_interest_and_dividends, p_idr.exclusion / MONTHS_IN_YEAR ) share = np.divide( interest_and_dividends, @@ -33,4 +31,4 @@ def formula(person, period, parameters): out=np.zeros_like(interest_and_dividends), where=unit_interest_and_dividends > 0, ) - return unearned - unit_exclusion * share + return other_sources + interest_and_dividends - unit_exclusion * share diff --git a/policyengine_us/variables/gov/states/sc/tanf/sc_tanf.py b/policyengine_us/variables/gov/states/sc/tanf/sc_tanf.py index 061c427ad58..c5e491e4d7a 100644 --- a/policyengine_us/variables/gov/states/sc/tanf/sc_tanf.py +++ b/policyengine_us/variables/gov/states/sc/tanf/sc_tanf.py @@ -14,9 +14,7 @@ class sc_tanf(Variable): def formula(spm_unit, period, parameters): p = parameters(period).gov.states.sc.tanf - # Compute need standard based on federal poverty guidelines - fpg = spm_unit("tanf_fpg", period) - need_standard = fpg * p.income.need_standard.rate + need_standard = spm_unit("sc_tanf_need_standard", period) countable_income = spm_unit("sc_tanf_countable_income", period) excess_income = max_(need_standard - countable_income, 0) benefit = excess_income * p.payment.rate diff --git a/policyengine_us/variables/gov/states/sc/tanf/sc_tanf_benefit_group_size.py b/policyengine_us/variables/gov/states/sc/tanf/sc_tanf_benefit_group_size.py new file mode 100644 index 00000000000..0a73415430c --- /dev/null +++ b/policyengine_us/variables/gov/states/sc/tanf/sc_tanf_benefit_group_size.py @@ -0,0 +1,17 @@ +from policyengine_us.model_api import * + + +class sc_tanf_benefit_group_size(Variable): + value_type = int + entity = SPMUnit + label = "South Carolina TANF benefit group size" + definition_period = MONTH + defined_for = StateCode.SC + # PDF pages 56, 117 + reference = "https://dss.sc.gov/media/bxwc1jdj/tanf-policy-manual-volume-67-final.pdf#page=56" # Sections 3.6 and 7.7 + + def formula(spm_unit, period, parameters): + # Section 3.6 excludes an SSI recipient living with the benefit group; + # if an SSI child is the only child, the parents are the only members. + ssi_recipient = spm_unit.members("sc_tanf_ssi_recipient", period) + return spm_unit.sum(~ssi_recipient) diff --git a/policyengine_us/variables/gov/states/sc/tanf/sc_tanf_need_standard.py b/policyengine_us/variables/gov/states/sc/tanf/sc_tanf_need_standard.py new file mode 100644 index 00000000000..1a869558d4b --- /dev/null +++ b/policyengine_us/variables/gov/states/sc/tanf/sc_tanf_need_standard.py @@ -0,0 +1,31 @@ +from policyengine_us.model_api import * + + +class sc_tanf_need_standard(Variable): + value_type = float + entity = SPMUnit + label = "South Carolina TANF need standard" + unit = USD + definition_period = MONTH + defined_for = StateCode.SC + # PDF pages 133-134 + reference = "https://dss.sc.gov/media/bxwc1jdj/tanf-policy-manual-volume-67-final.pdf#page=133" # Sections 8.15 and 8.16 + + def formula(spm_unit, period, parameters): + p = parameters(period).gov.states.sc.tanf.income.need_standard + size = spm_unit("sc_tanf_benefit_group_size", period) + # Section 8.15 sets the need standard as a share of the federal + # poverty guideline for the number of persons in the benefit group, + # revised each October 1, as in tanf_fpg. + year = period.start.year + if period.start.month >= 10: + instant_str = f"{year}-10-01" + else: + instant_str = f"{year - 1}-10-01" + p_fpg = parameters(instant_str).gov.hhs.fpg + state_group = spm_unit.household("state_group_str", period.this_year) + fpg = ( + p_fpg.first_person[state_group] + + p_fpg.additional_person[state_group] * (size - 1) + ) / MONTHS_IN_YEAR + return where(size > 0, fpg * p.rate, 0) From 91d0d3f757268c04b045d8cb0df40bd813ba7e2d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 20:58:55 -0400 Subject: [PATCH 5/8] Cite Volume 65's Section 8.15 table for the 2024 poverty guideline Volume 67's table lists the 2025 guideline; the January 2025 property test uses the October 2024 guideline from Volume 65 (PDF page 132). Docstring and comments only. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_sc_tanf_unearned_income_invariants.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/policyengine_us/tests/test_sc_tanf_unearned_income_invariants.py b/policyengine_us/tests/test_sc_tanf_unearned_income_invariants.py index 94ee81bce74..4294af1a357 100644 --- a/policyengine_us/tests/test_sc_tanf_unearned_income_invariants.py +++ b/policyengine_us/tests/test_sc_tanf_unearned_income_invariants.py @@ -14,8 +14,10 @@ his/her income, resources and SSI payments in determining benefits for other BG members." - Section 8.15 (page 133): the need standard is 50% of the federal poverty - guideline for the benefit group's size; the table's 2024 guideline is - $15,060 for one person plus $5,380 for each additional person. + guideline for the number of persons in the benefit group, revised each + October 1. For January 2025 the guideline is the one in Volume 65's + table (PDF page 132): $15,060 for one person plus $5,380 for each + additional person. For every benefit group, in January 2025: @@ -51,7 +53,7 @@ YEAR = 2025 MONTH = f"{YEAR}-01" EXCLUSION = 400 # Section 7.7, page 113, per benefit group per year. -# Section 8.15, page 133: 2024 guideline, in effect from October 2024. +# Volume 65, Section 8.15 (PDF page 132): in effect from October 2024. FPG_FIRST_PERSON = 15_060 FPG_ADDITIONAL_PERSON = 5_380 NEED_STANDARD_RATE = 0.5 @@ -241,7 +243,7 @@ def test_worked_example(): (unearned, _, _, size, need), _ = run(groups, [0]) # 300 + (1,000 - 400) / 12 = 350 for the parent; 400 for the child. assert unearned[0] == pytest.approx([350, 400, 0], abs=TOLERANCE) - # Section 8.15 table, three persons: (15,060 + 2 x 5,380) / 12 x 0.5. + # Volume 65 Section 8.15 guideline, three persons: (15,060 + 2 x 5,380) / 12 x 0.5. assert size[0] == 3 assert need[0] == pytest.approx(1_075.83, abs=TOLERANCE) From f6340c2521fba19a86773eb59f4930a9314a2742 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 10:49:27 -0400 Subject: [PATCH 6/8] Exclude SSI recipients' resources and floor SC TANF estate losses Address the re-review of 39248f1: - Sections 3.6 and 7.7 exclude the SSI recipient's resources as well as income and needs. Add sc_tanf_countable_resources, which subtracts SSI recipients' own bank, stock and bond assets from spm_unit_cash_assets (as Missouri's TANF does), and use it in the resource test. An aggregate-only asset input is counted as given. - Count estate_income only when positive, through a positive_sources list: a pass-through loss is not money received (Section 7.1) and could otherwise offset a pension or another member's income. Co-Authored-By: Claude Opus 5.5 --- changelog.d/sc-tanf-unearned-gross.fixed.md | 2 +- .../income/unearned/positive_sources.yaml | 14 +++++ .../sc/tanf/income/unearned/sources.yaml | 4 +- .../gov/states/sc/tanf/integration.yaml | 31 ++++++++++ .../sc/tanf/sc_tanf_countable_resources.yaml | 60 +++++++++++++++++++ .../tanf/sc_tanf_gross_unearned_income.yaml | 33 ++++++++++ ...test_sc_tanf_unearned_income_invariants.py | 40 ++++++++++--- .../sc_tanf_countable_resources.py | 32 ++++++++++ .../eligibility/sc_tanf_resources_eligible.py | 2 +- .../income/sc_tanf_gross_unearned_income.py | 7 ++- 10 files changed, 213 insertions(+), 12 deletions(-) create mode 100644 policyengine_us/parameters/gov/states/sc/tanf/income/unearned/positive_sources.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_countable_resources.yaml create mode 100644 policyengine_us/variables/gov/states/sc/tanf/eligibility/sc_tanf_countable_resources.py diff --git a/changelog.d/sc-tanf-unearned-gross.fixed.md b/changelog.d/sc-tanf-unearned-gross.fixed.md index e33e00b870a..2b901e73811 100644 --- a/changelog.d/sc-tanf-unearned-gross.fixed.md +++ b/changelog.d/sc-tanf-unearned-gross.fixed.md @@ -1 +1 @@ -South Carolina TANF now counts Social Security, unemployment compensation, pensions, retirement account withdrawals and interest at their gross amounts; adds child support, alimony, veterans benefits, workers' compensation, disability insurance, survivor and railroad retirement benefits, cash contributions, guaranteed income and trust income; drops discharged debt; treats farm income as earned; excludes the first $400 a year of a benefit group's interest and dividends; and leaves SSI recipients and their income out of the benefit group, as the TANF Policy Manual requires. +South Carolina TANF now counts Social Security, unemployment compensation, pensions, retirement account withdrawals and interest at their gross amounts; adds child support, alimony, veterans benefits, workers' compensation, disability insurance, survivor and railroad retirement benefits, cash contributions, guaranteed income and positive trust income; drops discharged debt; treats farm income as earned; excludes the first $400 a year of a benefit group's interest and dividends; and leaves SSI recipients, their income and their resources out of the benefit group, as the TANF Policy Manual requires. diff --git a/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/positive_sources.yaml b/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/positive_sources.yaml new file mode 100644 index 00000000000..72589f53615 --- /dev/null +++ b/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/positive_sources.yaml @@ -0,0 +1,14 @@ +description: South Carolina counts the positive amount of each of these income sources as unearned income under the Temporary Assistance for Needy Families program. +values: + 1996-07-01: + # Trust and estate income is money received (Section 7.1); a net loss + # passed through from an estate or trust is not, and Section 8.16 allows + # no deduction for it. Estate settlements are resources (Section 6.6). + - estate_income +metadata: + unit: list + period: month + label: South Carolina TANF unearned income sources counted when positive + reference: + - title: "SC DSS TANF Policy Manual (Volume 67), Sections 6.6, 7.1 and 8.16 #page 92,102,134" + href: https://dss.sc.gov/media/bxwc1jdj/tanf-policy-manual-volume-67-final.pdf#page=92 diff --git a/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/sources.yaml b/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/sources.yaml index 38884e75041..a4345eab7c8 100644 --- a/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/sources.yaml +++ b/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/sources.yaml @@ -4,7 +4,8 @@ values: # Section 7.1 defines income as money received from any source, and the # Section 7.7 chart counts each source below at its gross amount. # Interest and dividends are counted separately, after the $400 - # exclusion (see interest_and_dividends). + # exclusion (see interest_and_dividends), and trust and estate income + # only when positive (see positive_sources). - social_security # SSA benefits: count the gross amount - unemployment_compensation - pension_income # Pension; annuities and endowment contracts @@ -21,7 +22,6 @@ values: - capital_gains # Capital gains (other) - rental_income # Rent payments; royalties are included in rental income - farm_rent_income - - estate_income # Trust and estate income; settlements are resources - partnership_s_corp_income - miscellaneous_income - illicit_income diff --git a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/integration.yaml index 3d334a20544..132ca63c7e7 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/integration.yaml @@ -323,3 +323,34 @@ # include the parent(s) as the only BG member." # FPG for 1 = 1255; need standard = 627.50 # benefit = 627.50 * 0.4046 = 253.89 + +- name: Case 12, an SSI child's savings do not count against the resource limit. + period: 2025-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 30 + employment_income_before_lsr: 0 + bank_account_assets: 800 + person2: + age: 9 + is_child_dependent: true + receives_ssi: true + bank_account_assets: 1_800 + person3: + age: 6 + is_child_dependent: true + spm_units: + spm_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: SC + output: + sc_tanf_countable_resources: 800 + sc_tanf: 344.58 + # Sections 3.6 (page 56) and 7.7 (page 117) exclude the SSI child's + # resources: 2,600 - 1,800 = 800, within the $2,500 limit (page 91). + # Benefit group of 2: need standard 851.67; benefit = 851.67 * 0.4046 diff --git a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_countable_resources.yaml b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_countable_resources.yaml new file mode 100644 index 00000000000..d94d83f5f8a --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_countable_resources.yaml @@ -0,0 +1,60 @@ +# SC DSS TANF Policy Manual (Volume 67), Sections 3.6 (page 56) and 7.7 +# (page 117): exclude the SSI recipient's resources; Section 6.4 (page 91): +# $2,500 limit per benefit group. + +- name: Case 1, an SSI recipient's own assets are left out. + period: 2025-01 + input: + people: + person1: + age: 30 + bank_account_assets: 800 + person2: + age: 9 + receives_ssi: true + bank_account_assets: 1_800 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: SC + output: + # 2,600 in the unit, less the SSI child's 1,800. + sc_tanf_countable_resources: 800 + sc_tanf_resources_eligible: true + +- name: Case 2, stocks and bonds of an SSI recipient are left out too. + period: 2025-01 + input: + people: + person1: + age: 30 + bank_account_assets: 500 + stock_assets: 1_000 + person2: + age: 40 + receives_ssi: true + stock_assets: 2_000 + bond_assets: 1_000 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: SC + output: + sc_tanf_countable_resources: 1_500 + +- name: Case 3, an aggregate asset input is counted as given. + period: 2025-01 + input: + state_code: SC + receives_ssi: true + spm_unit_cash_assets: 3_000 + output: + # Without person-level assets nothing can be attributed to the SSI + # recipient, so the aggregate stands. + sc_tanf_countable_resources: 3_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_unearned_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_unearned_income.yaml index 4e7ce514d15..892705b8536 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_unearned_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_unearned_income.yaml @@ -212,3 +212,36 @@ output: # (1,200 - 400) / 12 = 66.67. sc_tanf_gross_unearned_income: 66.67 + +- name: Case 15, a trust or estate loss does not offset other income. + period: 2025-01 + absolute_error_margin: 0.01 + input: + state_code: SC + estate_income: -1_200 + taxable_private_pension_income: 2_400 + output: + # A pass-through loss is not money received (Section 7.1, page 102). + # Pension 2,400 / 12 = 200. + sc_tanf_gross_unearned_income: 200 + +- name: Case 16, one member's estate loss does not offset another member's income. + period: 2025-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 35 + taxable_private_pension_income: 2_400 + person2: + age: 10 + estate_income: -1_200 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: SC + output: + sc_tanf_gross_unearned_income: [200, 0] diff --git a/policyengine_us/tests/test_sc_tanf_unearned_income_invariants.py b/policyengine_us/tests/test_sc_tanf_unearned_income_invariants.py index 4294af1a357..ba8f8202b6b 100644 --- a/policyengine_us/tests/test_sc_tanf_unearned_income_invariants.py +++ b/policyengine_us/tests/test_sc_tanf_unearned_income_invariants.py @@ -40,6 +40,10 @@ 6. The benefit group size is the number of members who do not receive SSI, and the need standard is 50% of the guideline for that size (0 for an empty group). +7. Trust and estate income counts only when positive, so a pass-through + loss never offsets another source or another member's income. +8. Countable resources are the unit's cash assets less SSI recipients' own + bank, stock and bond assets (Sections 3.6 and 7.7). Each generated group is simulated twice in one vectorized simulation: as drawn, and with the head's interest raised (property 5). @@ -76,9 +80,19 @@ "non_qualified_dividend_income", ] # Inputs that must not count as unearned income. +# Counted only when positive. +POSITIVE_ONLY = ["estate_income"] IGNORED = ["debt_relief"] +ASSETS = ["bank_account_assets", "stock_assets", "bond_assets"] WAGES = "employment_income_before_lsr" -AMOUNT_INPUTS = [*COUNTED, *INTEREST_AND_DIVIDENDS, *IGNORED, WAGES] +AMOUNT_INPUTS = [ + *COUNTED, + *POSITIVE_ONLY, + *INTEREST_AND_DIVIDENDS, + *IGNORED, + WAGES, + *ASSETS, +] def build_situation(groups): @@ -139,6 +153,7 @@ def member_counted(member): if member["ssi"]: return 0 other = sum(member.get(v, 0) for v in COUNTED) / 12 + other += sum(max(member.get(v, 0), 0) for v in POSITIVE_ONLY) / 12 return other + member_interest_and_dividends(member) @@ -157,7 +172,8 @@ def reference(group): size = sum(not m["ssi"] for m in group) fpg = (FPG_FIRST_PERSON + FPG_ADDITIONAL_PERSON * (size - 1)) / 12 need_standard = NEED_STANDARD_RATE * fpg if size > 0 else 0 - return unearned, earned, exclusion, size, need_standard + resources = sum(sum(m.get(v, 0) for v in ASSETS) for m in group if not m["ssi"]) + return unearned, earned, exclusion, size, need_standard, resources def run(groups, raises): @@ -174,6 +190,7 @@ def calc(variable): ssi = calc("sc_tanf_ssi_recipient") size = calc("sc_tanf_benefit_group_size") need = calc("sc_tanf_need_standard") + resources = calc("sc_tanf_countable_resources") bounds = np.cumsum([0, *[len(g) for g in doubled]]) n = len(groups) @@ -181,18 +198,18 @@ def split(a): return [a[bounds[k] : bounds[k + 1]] for k in range(len(doubled))] u, e, s = split(unearned), split(earned), split(ssi) - drawn = (u[:n], e[:n], s[:n], size[:n], need[:n]) - raised = (u[n:], e[n:], s[n:], size[n:], need[n:]) + drawn = (u[:n], e[:n], s[:n], size[:n], need[:n], resources[:n]) + raised = (u[n:], e[n:], s[n:], size[n:], need[n:], resources[n:]) return drawn, raised def check(groups, raises): - (unearned, earned, ssi, size, need), raised = run(groups, raises) + (unearned, earned, ssi, size, need, resources), raised = run(groups, raises) raised_unearned = raised[0] for k, group in enumerate(groups): expected = reference(group) expected_unearned, expected_earned, exclusion = expected[:3] - expected_size, expected_need = expected[3:] + expected_size, expected_need, expected_resources = expected[3:] context = {"group": group} # Properties 1 and 3: the reference from the manual. assert unearned[k] == pytest.approx(expected_unearned, abs=TOLERANCE), context @@ -223,6 +240,8 @@ def check(groups, raises): # Property 6: benefit group size and need standard. assert size[k] == expected_size, context assert need[k] == pytest.approx(expected_need, abs=TOLERANCE), context + # Property 8: SSI recipients' own assets are left out. + assert resources[k] == pytest.approx(expected_resources, abs=TOLERANCE), context def test_worked_example(): @@ -240,7 +259,7 @@ def test_worked_example(): {"age": 6, "ssi": False}, ] ] - (unearned, _, _, size, need), _ = run(groups, [0]) + (unearned, _, _, size, need, _), _ = run(groups, [0]) # 300 + (1,000 - 400) / 12 = 350 for the parent; 400 for the child. assert unearned[0] == pytest.approx([350, 400, 0], abs=TOLERANCE) # Volume 65 Section 8.15 guideline, three persons: (15,060 + 2 x 5,380) / 12 x 0.5. @@ -280,7 +299,14 @@ def member(draw, age): "tax_exempt_interest_income": draw(amount(1_000)), "qualified_dividend_income": draw(amount(2_000)), "non_qualified_dividend_income": draw(amount(1_000)), + # Signed, like a Schedule E Part III net income or loss. + "estate_income": draw( + st.one_of(amount(6_000), st.integers(min_value=-6_000, max_value=-1)) + ), "debt_relief": draw(amount(5_000)), + "bank_account_assets": draw(amount(3_000)), + "stock_assets": draw(amount(2_000)), + "bond_assets": draw(amount(1_000)), "employment_income_before_lsr": draw(amount(30_000)), } diff --git a/policyengine_us/variables/gov/states/sc/tanf/eligibility/sc_tanf_countable_resources.py b/policyengine_us/variables/gov/states/sc/tanf/eligibility/sc_tanf_countable_resources.py new file mode 100644 index 00000000000..dabae216069 --- /dev/null +++ b/policyengine_us/variables/gov/states/sc/tanf/eligibility/sc_tanf_countable_resources.py @@ -0,0 +1,32 @@ +from policyengine_us.model_api import * + + +class sc_tanf_countable_resources(Variable): + value_type = float + entity = SPMUnit + label = "South Carolina TANF countable resources" + unit = USD + definition_period = MONTH + quantity_type = STOCK + defined_for = StateCode.SC + # PDF pages 56, 91, 117 + reference = "https://dss.sc.gov/media/bxwc1jdj/tanf-policy-manual-volume-67-final.pdf#page=56" # Sections 3.6, 6.4 and 7.7 + + def formula(spm_unit, period, parameters): + # Sections 3.6 and 7.7 exclude the SSI recipient's resources along + # with his or her income and needs. The excluded people's assets are + # subtracted from the unit aggregate rather than the total being + # rebuilt from person-level values, so households that supply only + # the spm_unit_cash_assets aggregate keep the aggregate behavior. + total = spm_unit("spm_unit_cash_assets", period.this_year) + person = spm_unit.members + ssi_recipient = person("sc_tanf_ssi_recipient", period) + # This component list must mirror spm_unit_cash_assets.adds, so the + # subtraction removes exactly the excluded members' share. + person_assets = add( + person, + period.this_year, + ["bank_account_assets", "stock_assets", "bond_assets"], + ) + excluded_assets = spm_unit.sum(person_assets * ssi_recipient) + return max_(total - excluded_assets, 0) diff --git a/policyengine_us/variables/gov/states/sc/tanf/eligibility/sc_tanf_resources_eligible.py b/policyengine_us/variables/gov/states/sc/tanf/eligibility/sc_tanf_resources_eligible.py index 181d4a32c21..1e6c3f92bac 100644 --- a/policyengine_us/variables/gov/states/sc/tanf/eligibility/sc_tanf_resources_eligible.py +++ b/policyengine_us/variables/gov/states/sc/tanf/eligibility/sc_tanf_resources_eligible.py @@ -11,5 +11,5 @@ class sc_tanf_resources_eligible(Variable): def formula(spm_unit, period, parameters): p = parameters(period).gov.states.sc.tanf.resources - assets = spm_unit("spm_unit_cash_assets", period.this_year) + assets = spm_unit("sc_tanf_countable_resources", period) return assets <= p.limit diff --git a/policyengine_us/variables/gov/states/sc/tanf/income/sc_tanf_gross_unearned_income.py b/policyengine_us/variables/gov/states/sc/tanf/income/sc_tanf_gross_unearned_income.py index a4472789ddb..e98ec2b41fa 100644 --- a/policyengine_us/variables/gov/states/sc/tanf/income/sc_tanf_gross_unearned_income.py +++ b/policyengine_us/variables/gov/states/sc/tanf/income/sc_tanf_gross_unearned_income.py @@ -15,7 +15,12 @@ def formula(person, period, parameters): p = parameters(period).gov.states.sc.tanf.income.unearned # Section 7.7 excludes the income of SSI recipients. counted = ~person("sc_tanf_ssi_recipient", period) - other_sources = add(person, period, p.sources) * counted + other_sources = add(person, period, p.sources) + # Sources that can carry a pass-through loss count only when positive: + # a loss is not money received (Section 7.1). + for source in p.positive_sources: + other_sources = other_sources + max_(person(source, period), 0) + other_sources = other_sources * counted # Section 7.7 excludes up to $400 of interest, dividends and royalties # per benefit group each year and averages the rest over 12 months. # Each member's exclusion is proportional to their share of that income. From 4b419e531caf67573872101f004f7aada3d1df1a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 10:54:13 -0400 Subject: [PATCH 7/8] Read total_unemployment_compensation in SC TANF unearned income #9558 merged, so income flows read total_unemployment_compensation (reported UC, or modeled state UI when none is reported), and the unemployment reader guard now requires it. Co-Authored-By: Claude Opus 5.5 --- .../parameters/gov/states/sc/tanf/income/unearned/sources.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/sources.yaml b/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/sources.yaml index a4345eab7c8..3ade1cf4c2f 100644 --- a/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/sources.yaml +++ b/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/sources.yaml @@ -7,7 +7,7 @@ values: # exclusion (see interest_and_dividends), and trust and estate income # only when positive (see positive_sources). - social_security # SSA benefits: count the gross amount - - unemployment_compensation + - total_unemployment_compensation # Reported, or modeled state UI - pension_income # Pension; annuities and endowment contracts - retirement_distributions # IRA and 401(k) withdrawals: money received - railroad_benefits # Retirement Pay (Railroad Retirement) From 96245b0f7a60ef7ca480043b9ce4550fd30c972a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 12:26:28 -0400 Subject: [PATCH 8/8] Count SC TANF net income sources only when positive Round-3 review (approved 4b419e5) noted that the money-received reading behind the estate floor applies equally to the other net amounts in the list. Move capital gains, rental, farm rent, partnership/S-corp and miscellaneous income into positive_sources with estate income, so a net loss counts as zero instead of offsetting another source or another member's income (Section 7.1; Section 8.16 allows no such deduction). This netting predates the PR: main summed these sources with their signs. Also read each positive source with add() so any entity works, and put the property test's list comments above their lists. Co-Authored-By: Claude Opus 5.5 --- changelog.d/sc-tanf-unearned-gross.fixed.md | 2 +- .../income/unearned/positive_sources.yaml | 15 +++++--- .../sc/tanf/income/unearned/sources.yaml | 9 ++--- .../tanf/sc_tanf_gross_unearned_income.yaml | 36 +++++++++++++++++++ ...test_sc_tanf_unearned_income_invariants.py | 22 +++++++----- .../income/sc_tanf_gross_unearned_income.py | 6 ++-- 6 files changed, 66 insertions(+), 24 deletions(-) diff --git a/changelog.d/sc-tanf-unearned-gross.fixed.md b/changelog.d/sc-tanf-unearned-gross.fixed.md index 2b901e73811..f0683951987 100644 --- a/changelog.d/sc-tanf-unearned-gross.fixed.md +++ b/changelog.d/sc-tanf-unearned-gross.fixed.md @@ -1 +1 @@ -South Carolina TANF now counts Social Security, unemployment compensation, pensions, retirement account withdrawals and interest at their gross amounts; adds child support, alimony, veterans benefits, workers' compensation, disability insurance, survivor and railroad retirement benefits, cash contributions, guaranteed income and positive trust income; drops discharged debt; treats farm income as earned; excludes the first $400 a year of a benefit group's interest and dividends; and leaves SSI recipients, their income and their resources out of the benefit group, as the TANF Policy Manual requires. +South Carolina TANF now counts Social Security, unemployment compensation, pensions, retirement account withdrawals and interest at their gross amounts; adds child support, alimony, veterans benefits, workers' compensation, disability insurance, survivor and railroad retirement benefits, cash contributions, guaranteed income and trust income; counts capital gains, rent, partnership, trust and other net income only when positive, so a loss no longer offsets other income; drops discharged debt; treats farm income as earned; excludes the first $400 a year of a benefit group's interest and dividends; and leaves SSI recipients, their income and their resources out of the benefit group, as the TANF Policy Manual requires. diff --git a/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/positive_sources.yaml b/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/positive_sources.yaml index 72589f53615..b5de332b214 100644 --- a/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/positive_sources.yaml +++ b/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/positive_sources.yaml @@ -1,14 +1,19 @@ description: South Carolina counts the positive amount of each of these income sources as unearned income under the Temporary Assistance for Needy Families program. values: 1996-07-01: - # Trust and estate income is money received (Section 7.1); a net loss - # passed through from an estate or trust is not, and Section 8.16 allows - # no deduction for it. Estate settlements are resources (Section 6.6). - - estate_income + # These are net amounts that can be losses. Income is money received + # (Section 7.1), so a net loss counts as zero rather than offsetting other + # income; Section 8.16 allows no deduction for one. + - capital_gains # Capital gains (other) + - rental_income # Rent payments, after the cost of doing business; includes royalties + - farm_rent_income + - partnership_s_corp_income + - estate_income # Trust and estate income; estate settlements are resources (Section 6.6) + - miscellaneous_income metadata: unit: list period: month label: South Carolina TANF unearned income sources counted when positive reference: - - title: "SC DSS TANF Policy Manual (Volume 67), Sections 6.6, 7.1 and 8.16 #page 92,102,134" + - title: "SC DSS TANF Policy Manual (Volume 67), Sections 6.6, 7.1, 7.7 and 8.16 #page 92,102,108,116,134" href: https://dss.sc.gov/media/bxwc1jdj/tanf-policy-manual-volume-67-final.pdf#page=92 diff --git a/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/sources.yaml b/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/sources.yaml index 3ade1cf4c2f..17c0fd64194 100644 --- a/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/sources.yaml +++ b/policyengine_us/parameters/gov/states/sc/tanf/income/unearned/sources.yaml @@ -4,8 +4,8 @@ values: # Section 7.1 defines income as money received from any source, and the # Section 7.7 chart counts each source below at its gross amount. # Interest and dividends are counted separately, after the $400 - # exclusion (see interest_and_dividends), and trust and estate income - # only when positive (see positive_sources). + # exclusion (see interest_and_dividends), and net amounts that can be + # losses only when positive (see positive_sources). - social_security # SSA benefits: count the gross amount - total_unemployment_compensation # Reported, or modeled state UI - pension_income # Pension; annuities and endowment contracts @@ -19,11 +19,6 @@ values: - alimony_income - financial_assistance # Cash contributions - gi_cash_assistance - - capital_gains # Capital gains (other) - - rental_income # Rent payments; royalties are included in rental income - - farm_rent_income - - partnership_s_corp_income - - miscellaneous_income - illicit_income # Not listed: farm operations income is earned self-employment income # (Section 7.7), and discharged debt is not money received (Section 7.1). diff --git a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_unearned_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_unearned_income.yaml index 892705b8536..c9fece1b5b0 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_unearned_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/sc/tanf/sc_tanf_gross_unearned_income.yaml @@ -245,3 +245,39 @@ state_code: SC output: sc_tanf_gross_unearned_income: [200, 0] + +- name: Case 17, one member's partnership loss does not offset another member's pension. + period: 2025-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 35 + taxable_private_pension_income: 2_400 + person2: + age: 10 + partnership_income: -1_200 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: SC + output: + # A net loss is not money received (Section 7.1, page 102). + sc_tanf_gross_unearned_income: [200, 0] + +- name: Case 18, capital and rental losses do not offset the same member's income. + period: 2025-01 + absolute_error_margin: 0.01 + input: + state_code: SC + short_term_capital_gains: -3_000 + rental_income: -2_400 + farm_rent_income: 1_200 + taxable_private_pension_income: 2_400 + output: + # Each net source is floored at zero on its own: farm rent 1,200 / 12 = + # 100 plus pension 2,400 / 12 = 200. + sc_tanf_gross_unearned_income: 300 diff --git a/policyengine_us/tests/test_sc_tanf_unearned_income_invariants.py b/policyengine_us/tests/test_sc_tanf_unearned_income_invariants.py index ba8f8202b6b..b1008275d5f 100644 --- a/policyengine_us/tests/test_sc_tanf_unearned_income_invariants.py +++ b/policyengine_us/tests/test_sc_tanf_unearned_income_invariants.py @@ -40,8 +40,9 @@ 6. The benefit group size is the number of members who do not receive SSI, and the need standard is 50% of the guideline for that size (0 for an empty group). -7. Trust and estate income counts only when positive, so a pass-through - loss never offsets another source or another member's income. +7. Net amounts that can be losses (trust and estate, rental and + partnership income here) count only when positive, so a loss never + offsets another source or another member's income. 8. Countable resources are the unit's cash assets less SSI recipients' own bank, stock and bond assets (Sections 3.6 and 7.7). @@ -79,9 +80,11 @@ "qualified_dividend_income", "non_qualified_dividend_income", ] +# Net amounts counted only when positive. partnership_income is the only +# component drawn of partnership_s_corp_income, so flooring it matches the +# model's floor on that total. +POSITIVE_ONLY = ["estate_income", "rental_income", "partnership_income"] # Inputs that must not count as unearned income. -# Counted only when positive. -POSITIVE_ONLY = ["estate_income"] IGNORED = ["debt_relief"] ASSETS = ["bank_account_assets", "stock_assets", "bond_assets"] WAGES = "employment_income_before_lsr" @@ -279,6 +282,9 @@ def test_worked_example(): def amount(high): return st.one_of(st.just(0), st.integers(min_value=1, max_value=high)) + def signed(high): + return st.one_of(amount(high), st.integers(min_value=-high, max_value=-1)) + @st.composite def member(draw, age): return { @@ -299,10 +305,10 @@ def member(draw, age): "tax_exempt_interest_income": draw(amount(1_000)), "qualified_dividend_income": draw(amount(2_000)), "non_qualified_dividend_income": draw(amount(1_000)), - # Signed, like a Schedule E Part III net income or loss. - "estate_income": draw( - st.one_of(amount(6_000), st.integers(min_value=-6_000, max_value=-1)) - ), + # Signed net amounts, like Schedule E income or loss. + "estate_income": draw(signed(6_000)), + "rental_income": draw(signed(8_000)), + "partnership_income": draw(signed(8_000)), "debt_relief": draw(amount(5_000)), "bank_account_assets": draw(amount(3_000)), "stock_assets": draw(amount(2_000)), diff --git a/policyengine_us/variables/gov/states/sc/tanf/income/sc_tanf_gross_unearned_income.py b/policyengine_us/variables/gov/states/sc/tanf/income/sc_tanf_gross_unearned_income.py index e98ec2b41fa..2368dd07f73 100644 --- a/policyengine_us/variables/gov/states/sc/tanf/income/sc_tanf_gross_unearned_income.py +++ b/policyengine_us/variables/gov/states/sc/tanf/income/sc_tanf_gross_unearned_income.py @@ -16,10 +16,10 @@ def formula(person, period, parameters): # Section 7.7 excludes the income of SSI recipients. counted = ~person("sc_tanf_ssi_recipient", period) other_sources = add(person, period, p.sources) - # Sources that can carry a pass-through loss count only when positive: - # a loss is not money received (Section 7.1). + # Net amounts that can be losses count only when positive: a loss is + # not money received (Section 7.1). for source in p.positive_sources: - other_sources = other_sources + max_(person(source, period), 0) + other_sources = other_sources + max_(add(person, period, [source]), 0) other_sources = other_sources * counted # Section 7.7 excludes up to $400 of interest, dividends and royalties # per benefit group each year and averages the rest over 12 months.