From 3f7cd2fbee0bb931786cfd4ac1502f18c58b5ce6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 11:00:16 -0400 Subject: [PATCH 1/6] Stop projecting group amounts onto every Riverside GR member ca_riv_general_relief_countable_property_value was a Person variable whose adds listed the SPM-unit cash assets and countable vehicle value. Core projects a group value onto every member, and the property test then summed the members, so cash and vehicle value counted once per member. It is now an SPMUnit stock: unit amounts are added once and personal property is summed. ca_riv_general_relief_earned_income_deductions added the tax unit's state_withheld_income_tax and additional_medicare_tax, so every member deducted the whole unit's withholding from their own wages. It now adds each person's own ca_withheld_income_tax and the new person-level additional_medicare_tax_withheld (26 U.S.C. 3102(f)(1): 0.9% of the employee's wages above $200,000, regardless of filing status). Co-Authored-By: Claude Opus 5.5 --- ...fix-riverside-gr-group-projection.fixed.md | 1 + .../additional/withholding_threshold.yaml | 12 + .../income/deductions/sources.yaml | 5 +- ...e_general_relief_attribution_invariants.py | 309 ++++++++++++++++++ .../additional_medicare_tax_withheld.yaml | 70 ++++ ...neral_relief_countable_property_value.yaml | 66 ++++ ...neral_relief_earned_income_deductions.yaml | 76 +++++ ..._riv_general_relief_property_eligible.yaml | 63 ++++ .../ca/riv/general_relief/integration.yaml | 97 +++++- .../additional_medicare_tax_withheld.py | 22 ++ ...general_relief_countable_property_value.py | 7 +- 11 files changed, 724 insertions(+), 4 deletions(-) create mode 100644 changelog.d/fix-riverside-gr-group-projection.fixed.md create mode 100644 policyengine_us/parameters/gov/irs/payroll/medicare/additional/withholding_threshold.yaml create mode 100644 policyengine_us/tests/core/test_riverside_general_relief_attribution_invariants.py create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/payroll/medicare/additional_medicare_tax_withheld.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/ca_riv_general_relief_countable_property_value.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/ca_riv_general_relief_earned_income_deductions.yaml create mode 100644 policyengine_us/variables/gov/irs/tax/payroll/medicare/additional_medicare_tax_withheld.py diff --git a/changelog.d/fix-riverside-gr-group-projection.fixed.md b/changelog.d/fix-riverside-gr-group-projection.fixed.md new file mode 100644 index 00000000000..f95dceab3db --- /dev/null +++ b/changelog.d/fix-riverside-gr-group-projection.fixed.md @@ -0,0 +1 @@ +Count Riverside County General Relief property once per unit instead of once per member, and limit each earner's General Relief wage deductions to their own CA income tax withholding and Additional Medicare Tax withholding. Add `additional_medicare_tax_withheld`, the Additional Medicare Tax an employer withholds from wages above $200,000 under 26 U.S.C. 3102(f)(1). diff --git a/policyengine_us/parameters/gov/irs/payroll/medicare/additional/withholding_threshold.yaml b/policyengine_us/parameters/gov/irs/payroll/medicare/additional/withholding_threshold.yaml new file mode 100644 index 00000000000..209dccf7910 --- /dev/null +++ b/policyengine_us/parameters/gov/irs/payroll/medicare/additional/withholding_threshold.yaml @@ -0,0 +1,12 @@ +description: Employers withhold the Additional Medicare Tax from the wages they pay an employee above this amount in a calendar year, regardless of the employee's filing status. +values: + 2013-01-01: 200_000 +metadata: + unit: currency-USD + period: year + label: Additional Medicare Tax employer withholding threshold + reference: + - title: 26 U.S. Code § 3102(f)(1) + href: https://www.law.cornell.edu/uscode/text/26/3102#f_1 + - title: 26 CFR § 31.3102-4(a) + href: https://www.law.cornell.edu/cfr/text/26/31.3102-4#a diff --git a/policyengine_us/parameters/gov/local/ca/riv/general_relief/income/deductions/sources.yaml b/policyengine_us/parameters/gov/local/ca/riv/general_relief/income/deductions/sources.yaml index 1be2c706183..87f5bc96c50 100644 --- a/policyengine_us/parameters/gov/local/ca/riv/general_relief/income/deductions/sources.yaml +++ b/policyengine_us/parameters/gov/local/ca/riv/general_relief/income/deductions/sources.yaml @@ -1,10 +1,11 @@ description: Riverside County deducts these mandatory wage deductions from earned income under General Relief. values: 2020-01-01: - - state_withheld_income_tax + # Each person deducts only what is withheld from their own wages. + - ca_withheld_income_tax - employee_social_security_tax - employee_medicare_tax - - additional_medicare_tax + - additional_medicare_tax_withheld - traditional_ira_contributions metadata: diff --git a/policyengine_us/tests/core/test_riverside_general_relief_attribution_invariants.py b/policyengine_us/tests/core/test_riverside_general_relief_attribution_invariants.py new file mode 100644 index 00000000000..52f549db318 --- /dev/null +++ b/policyengine_us/tests/core/test_riverside_general_relief_attribution_invariants.py @@ -0,0 +1,309 @@ +"""Invariants for Riverside County General Relief property and wage deductions. + +Riverside compares a unit's combined property with one $500 limit, so each +asset counts once however many people are in the unit. Wage deductions are +what is withheld from each earner's own pay, so one member's deductions never +depend on another member's wages. Additional Medicare Tax is withheld from an +employee's wages above $200,000 without regard to filing status or a spouse's +wages (26 U.S.C. 3102(f)(1)). + +Hypothesis draws batches of adult-only units (one to four members, each its +own SPM unit, tax unit and household in Riverside), and a seeded population of +120 units adds breadth. Each batch runs as one vectorized simulation, and +again with the first member's assets and wages raised. For every unit: + +1. Property differential: countable property equals an independent numpy sum + of the members' bank, stock and bond assets, vehicle value above the + $4,650 exemption and the members' personal property. Property eligibility + is that sum below $500. +2. Property monotone: raising one member's assets never lowers countable + property. +3. Withholding bounds and differential: 0 <= additional_medicare_tax_withheld + = 0.9% of wages above $200,000 <= 0.9% of wages, with the threshold and + rate taken from the statute rather than the model's parameters. For a + single filer it equals the Additional Medicare Tax liability. +4. Deduction differential: each member's monthly GR deductions equal their + own Social Security, Medicare and Additional Medicare withholding plus + their own CA withholding, divided by 12. Summed over a tax unit, the CA + withholding is state_withheld_income_tax counted once. +5. Locality: raising the first member's wages leaves every other member's + deductions unchanged. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 # dollars +YEAR = 2024 +MONTH = "2024-01" + +# Riverside DPSS General Assistance Eligibility manual pp. 22 and 25. +PROPERTY_LIMIT = 500 +VEHICLE_EXEMPTION = 4_650 +# 26 U.S.C. 3101(a), (b)(1), (b)(2) and 3102(f)(1); SSA 2024 wage base. +SOCIAL_SECURITY_RATE = 0.062 +SOCIAL_SECURITY_WAGE_BASE = 168_600 +MEDICARE_RATE = 0.0145 +ADDITIONAL_MEDICARE_RATE = 0.009 +ADDITIONAL_MEDICARE_WITHHOLDING_THRESHOLD = 200_000 + +assets = st.one_of(st.just(0.0), st.integers(1, 400).map(float)) +wages = st.one_of( + st.just(0.0), + st.integers(1, 12_000).map(float), + st.integers(12_000, 400_000).map(float), +) + + +@st.composite +def members(draw): + return { + "bank_account_assets": draw(assets), + "stock_assets": draw(assets), + "bond_assets": draw(assets), + "personal_property": draw(assets), + "employment_income": draw(wages), + } + + +@st.composite +def units(draw): + return { + "members": draw(st.lists(members(), min_size=1, max_size=4)), + "household_vehicles_value": draw( + st.one_of(st.just(0.0), st.integers(4_000, 5_500).map(float)) + ), + "raise": { + "bank_account_assets": draw(assets), + "employment_income": draw(wages), + }, + } + + +SEED = 20261006 + + +def _seeded_units(n=120): + rng = np.random.default_rng(SEED) + + def amount(high): + return float(rng.integers(1, high + 1)) if rng.random() < 0.6 else 0.0 + + def wage(): + return [0.0, amount(12_000), float(rng.integers(12_000, 400_001))][ + rng.integers(0, 3) + ] + + out = [] + for _ in range(n): + out.append( + { + "members": [ + { + "bank_account_assets": amount(400), + "stock_assets": amount(400), + "bond_assets": amount(400), + "personal_property": amount(400), + "employment_income": wage(), + } + for _ in range(int(rng.integers(1, 5))) + ], + "household_vehicles_value": ( + float(rng.integers(4_000, 5_501)) if rng.random() < 0.5 else 0.0 + ), + "raise": { + "bank_account_assets": amount(400), + "employment_income": wage(), + }, + } + ) + return out + + +def _situation(batch, *, raised): + people = {} + groups = { + name: {} + for name in [ + "tax_units", + "spm_units", + "families", + "marital_units", + "households", + ] + } + for i, unit in enumerate(batch): + names = [] + for j, values in enumerate(unit["members"]): + name = f"person_{i}_{j}" + values = dict(values) + if raised and j == 0: + for key, increase in unit["raise"].items(): + values[key] += increase + # Distinct ages fix head and spouse; any others are dependents. + people[name] = { + "age": 60 - 5 * j, + "is_tax_unit_head": j == 0, + "is_tax_unit_spouse": j == 1, + "is_tax_unit_dependent": j > 1, + **values, + } + names.append(name) + groups["marital_units"][f"marital_unit_{i}_{j}"] = {"members": [name]} + if len(names) > 1: + for j in range(2): + del groups["marital_units"][f"marital_unit_{i}_{j}"] + groups["marital_units"][f"couple_{i}"] = {"members": names[:2]} + groups["tax_units"][f"tax_unit_{i}"] = {"members": names} + groups["spm_units"][f"spm_unit_{i}"] = {"members": names} + groups["families"][f"family_{i}"] = {"members": names} + groups["households"][f"household_{i}"] = { + "members": names, + "state_code": "CA", + "in_riv": True, + "household_vehicles_value": unit["household_vehicles_value"], + } + people = { + name: {key: {YEAR: value} for key, value in values.items()} + for name, values in people.items() + } + for group in groups.values(): + for values in group.values(): + for key in list(values): + if key != "members": + values[key] = {YEAR: values[key]} + return {"people": people, **groups} + + +PERSON_YEAR = [ + "bank_account_assets", + "stock_assets", + "bond_assets", + "personal_property", + "payroll_tax_gross_wages", + "employee_social_security_tax", + "employee_medicare_tax", + "additional_medicare_tax_withheld", + "ca_withheld_income_tax", +] + + +def _run(batch, *, raised=False): + sim = Simulation(situation=_situation(batch, raised=raised)) + out = { + name: np.asarray(sim.calculate(name, YEAR), dtype=float) + for name in PERSON_YEAR + + [ + "household_vehicles_value", + "ca_riv_general_relief_countable_property_value", + "ca_riv_general_relief_property_eligible", + "state_withheld_income_tax", + "additional_medicare_tax", + ] + } + out["deductions"] = np.asarray( + sim.calculate("ca_riv_general_relief_earned_income_deductions", MONTH), + dtype=float, + ) + out["filing_status"] = sim.calculate("filing_status", YEAR).decode_to_str() + out["unit"] = sim.populations["spm_unit"].members_entity_id + out["n_units"] = len(out["ca_riv_general_relief_countable_property_value"]) + out["first"] = np.array( + [name.endswith("_0") for name in sim.populations["person"].ids] + ) + return out + + +def _unit_sum(run, values): + return np.bincount(run["unit"], weights=values, minlength=run["n_units"]) + + +def _check(batch): + base = _run(batch) + + # 1. Property differential. + cash = _unit_sum( + base, + base["bank_account_assets"] + base["stock_assets"] + base["bond_assets"], + ) + vehicles = np.maximum(base["household_vehicles_value"] - VEHICLE_EXEMPTION, 0) + property_value = cash + vehicles + _unit_sum(base, base["personal_property"]) + countable = base["ca_riv_general_relief_countable_property_value"] + np.testing.assert_allclose(countable, property_value, atol=TOLERANCE) + np.testing.assert_array_equal( + base["ca_riv_general_relief_property_eligible"].astype(bool), + property_value < PROPERTY_LIMIT, + ) + + # 3. Withholding bounds and differential. + wages = base["payroll_tax_gross_wages"] + withheld = base["additional_medicare_tax_withheld"] + expected_withheld = ADDITIONAL_MEDICARE_RATE * np.maximum( + wages - ADDITIONAL_MEDICARE_WITHHOLDING_THRESHOLD, 0 + ) + np.testing.assert_allclose(withheld, expected_withheld, atol=TOLERANCE) + assert (withheld >= 0).all() + assert (withheld <= ADDITIONAL_MEDICARE_RATE * wages + TOLERANCE).all() + # Each unit is one SPM unit and one tax unit, in the same order. + unit = base["unit"] + single = (base["filing_status"][unit] == "SINGLE") & (np.bincount(unit)[unit] == 1) + np.testing.assert_allclose( + withheld[single], + base["additional_medicare_tax"][unit][single], + atol=TOLERANCE, + ) + + # 4. Deduction differential and conservation. + payroll = ( + SOCIAL_SECURITY_RATE * np.minimum(wages, SOCIAL_SECURITY_WAGE_BASE) + + MEDICARE_RATE * wages + + expected_withheld + ) + np.testing.assert_allclose( + base["deductions"], + (payroll + base["ca_withheld_income_tax"]) / 12, + atol=TOLERANCE, + ) + np.testing.assert_allclose( + _unit_sum(base, base["ca_withheld_income_tax"]), + base["state_withheld_income_tax"], + atol=TOLERANCE, + ) + + raised = _run(batch, raised=True) + + # 2. Property monotone. + assert ( + raised["ca_riv_general_relief_countable_property_value"] + >= countable - TOLERANCE + ).all() + + # 5. Locality: only the first member's deductions may move. + others = ~base["first"] + np.testing.assert_allclose( + raised["deductions"][others], base["deductions"][others], atol=TOLERANCE + ) + + +# A batch's cost is mostly per-variable overhead, so each example is a large +# batch and there are few examples. +SETTINGS = dict( + max_examples=6, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given(st.lists(units(), min_size=5, max_size=25)) +def test_riverside_general_relief_attribution_invariants(batch): + _check(batch) + + +def test_seeded_population(): + _check(_seeded_units()) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/payroll/medicare/additional_medicare_tax_withheld.yaml b/policyengine_us/tests/policy/baseline/gov/irs/payroll/medicare/additional_medicare_tax_withheld.yaml new file mode 100644 index 00000000000..1f8c6be379d --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/payroll/medicare/additional_medicare_tax_withheld.yaml @@ -0,0 +1,70 @@ +- name: Case 1, nothing is withheld on wages at the threshold. + period: 2024 + input: + employment_income: 200_000 + output: + additional_medicare_tax_withheld: 0 + +- name: Case 2, 0.9% is withheld on wages above the threshold. + period: 2024 + input: + employment_income: 250_000 + output: + # 0.9% * (250,000 - 200,000) + additional_medicare_tax_withheld: 450 + +- name: Case 3, 26 CFR 31.3102-4(a) example of spouses with $100,000 and $300,000 wages. + period: 2024 + input: + people: + person1: + age: 45 + employment_income: 100_000 + person2: + age: 43 + employment_income: 300_000 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + output: + # Each employer withholds on its own employee's wages above $200,000, + # disregarding the spouse's wages: 0.9% * 100,000 for person2. + additional_medicare_tax_withheld: [0, 900] + # The joint liability is 0.9% * (400,000 - 250,000). + additional_medicare_tax: 1_350 + +- name: Case 4, 26 CFR 31.3102-4(b) example of spouses with $190,000 and $150,000 wages. + period: 2024 + input: + people: + person1: + age: 45 + employment_income: 190_000 + person2: + age: 43 + employment_income: 150_000 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + output: + # Neither spouse's wages exceed $200,000, so nothing is withheld. + additional_medicare_tax_withheld: [0, 0] + # The couple still owes 0.9% * (340,000 - 250,000) on the joint return. + additional_medicare_tax: 810 + +- name: Case 5, nothing is withheld from self-employment income. + period: 2024 + input: + self_employment_income: 300_000 + output: + additional_medicare_tax_withheld: 0 + +- name: Case 6, traditional 401(k) deferrals do not reduce Medicare wages. + period: 2025 + input: + employment_income: 250_000 + traditional_401k_contributions_desired: 50_000 + output: + additional_medicare_tax_withheld: 450 diff --git a/policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/ca_riv_general_relief_countable_property_value.yaml b/policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/ca_riv_general_relief_countable_property_value.yaml new file mode 100644 index 00000000000..3d7a3102e6d --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/ca_riv_general_relief_countable_property_value.yaml @@ -0,0 +1,66 @@ +- name: Case 1, unit cash assets count once in a two-person unit. + period: 2024 + input: + people: + person1: + age: 30 + bank_account_assets: 300 + person2: + age: 65 + personal_property: 100 + households: + household: + members: [person1, person2] + in_riv: true + spm_units: + spm_unit: + members: [person1, person2] + output: + # $300 cash + $0 countable vehicle value + $100 personal property. + ca_riv_general_relief_countable_property_value: 400 + +- name: Case 2, countable vehicle value counts once in a three-person unit. + period: 2024 + input: + people: + person1: + age: 30 + person2: + age: 40 + person3: + age: 65 + households: + household: + members: [person1, person2, person3] + in_riv: true + household_vehicles_value: 4_850 + spm_units: + spm_unit: + members: [person1, person2, person3] + output: + # 4,850 - 4,650 vehicle exemption. + ca_riv_general_relief_countable_property_value: 200 + +- name: Case 3, each member's cash and personal property are summed. + period: 2024 + input: + people: + person1: + age: 30 + bank_account_assets: 150 + personal_property: 50 + person2: + age: 65 + stock_assets: 200 + personal_property: 75 + households: + household: + members: [person1, person2] + in_riv: true + household_vehicles_value: 4_700 + spm_units: + spm_unit: + members: [person1, person2] + output: + # Cash 150 + 200, vehicle 4,700 - 4,650 = 50, personal property 50 + 75. + ca_riv_general_relief_countable_property_value: 525 diff --git a/policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/ca_riv_general_relief_earned_income_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/ca_riv_general_relief_earned_income_deductions.yaml new file mode 100644 index 00000000000..1f7633eac7a --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/ca_riv_general_relief_earned_income_deductions.yaml @@ -0,0 +1,76 @@ +- name: Case 1, a member deducts only their own state income tax withholding. + period: 2024-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 66 + taxable_pension_income: 30_000 + person2: + age: 30 + employment_income: 4_800 + households: + household: + members: [person1, person2] + in_riv: true + spm_units: + spm_unit: + members: [person1, person2] + output: + # person1's CA withholding on a $30,000 pension: + # (107.56 + 2% * (30,000 - 5,540 - 10,756)) / 12 = 31.80. + ca_withheld_income_tax: [31.80, 0] + # person2: 400 * (6.2% + 1.45%) = 30.60, with no withholding of their own. + ca_riv_general_relief_earned_income_deductions: [31.80, 30.60] + ca_riv_general_relief_net_earned_income: [0, 369.40] + +- name: Case 2, two earners each deduct their own payroll taxes and withholding. + period: 2024-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 6_720 + person2: + age: 30 + employment_income: 360 + households: + household: + members: [person1, person2] + in_riv: true + spm_units: + spm_unit: + members: [person1, person2] + output: + # person1: 560 * 7.65% = 42.84, plus CA withholding of + # 1% * (6,720 - 5,540) / 12 = 0.98. + # person2: 30 * 7.65% = 2.30; their AGI is below the CA standard deduction. + ca_riv_general_relief_earned_income_deductions: [43.82, 2.30] + +- name: Case 3, Additional Medicare Tax withholding stays with the high earner. + period: 2024-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + employment_income: 300_000 + person2: + age: 40 + employment_income: 4_800 + households: + household: + members: [person1, person2] + in_riv: true + spm_units: + spm_unit: + members: [person1, person2] + output: + # Withheld from person1's wages: 0.9% * (300,000 - 200,000) / 12 = 75. + additional_medicare_tax_withheld: [75, 0] + # person1: 871.10 Social Security (6.2% of the 168,600 wage base / 12) + # + 362.50 Medicare + 75 Additional Medicare + 1,993.93 of their own CA + # withholding (CA single schedule on 300,000 - 5,540). + # person2: 400 * 7.65% = 30.60, with nothing of person1's. + ca_riv_general_relief_earned_income_deductions: [3_302.53, 30.60] diff --git a/policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/eligibility/ca_riv_general_relief_property_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/eligibility/ca_riv_general_relief_property_eligible.yaml index 14b54c7f904..18323066d1c 100644 --- a/policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/eligibility/ca_riv_general_relief_property_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/eligibility/ca_riv_general_relief_property_eligible.yaml @@ -13,3 +13,66 @@ in_riv: true output: ca_riv_general_relief_property_eligible: false + +- name: Case 3, cash held by one member of a two-person unit counts once. + period: 2024 + input: + people: + person1: + age: 30 + bank_account_assets: 300 + person2: + age: 65 + households: + household: + members: [person1, person2] + in_riv: true + spm_units: + spm_unit: + members: [person1, person2] + output: + # Countable property is $300, below the $500 limit. + ca_riv_general_relief_property_eligible: true + +- name: Case 4, the limit applies to the members' combined cash. + period: 2024 + input: + people: + person1: + age: 30 + bank_account_assets: 300 + person2: + age: 65 + bank_account_assets: 300 + households: + household: + members: [person1, person2] + in_riv: true + spm_units: + spm_unit: + members: [person1, person2] + output: + # Countable property is $600, above the $500 limit. + ca_riv_general_relief_property_eligible: false + +- name: Case 5, countable vehicle value counts once in a three-person unit. + period: 2024 + input: + people: + person1: + age: 30 + person2: + age: 40 + person3: + age: 65 + households: + household: + members: [person1, person2, person3] + in_riv: true + household_vehicles_value: 4_850 + spm_units: + spm_unit: + members: [person1, person2, person3] + output: + # Countable property is 4,850 - 4,650 = $200, below the $500 limit. + ca_riv_general_relief_property_eligible: true diff --git a/policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/integration.yaml b/policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/integration.yaml index 5752180e5ac..9e33dee4f32 100644 --- a/policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/integration.yaml @@ -50,7 +50,8 @@ housing_cost: 300 * 12 output: state_withheld_income_tax: 0.38 - ca_riv_general_relief_earned_income_deductions: [38.63, 0.38] + # person2 has no wages or withholding of their own. + ca_riv_general_relief_earned_income_deductions: [38.63, 0] ca_riv_general_relief_countable_income: 461.37 # 500 - 38.63 ca_riv_general_relief_needs_standards: 436 ca_riv_general_relief_income_eligible: false @@ -183,3 +184,97 @@ ca_riv_general_relief_income_eligible: false ca_riv_general_relief_eligible: false ca_riv_general_relief: 0 + +- name: Case 8, cash held by one member of a two-person unit counts once. + period: 2024-01 + input: + people: + person1: + age: 30 + employment_income: 0 + ssi: 0 + bank_account_assets: 300 + person2: + age: 65 + employment_income: 0 + ssi: 0 + is_incapable_of_self_care: true + households: + household: + members: [person1, person2] + in_riv: true + spm_units: + spm_unit: + members: [person1, person2] + ca_tanf: 0 + housing_cost: 300 * 12 + output: + # Countable property is $300, below the $500 limit, so the unit gets + # the same grant as Case 1. + ca_riv_general_relief_countable_property_value: 300 + ca_riv_general_relief_property_eligible: true + ca_riv_general_relief_eligible: true + ca_riv_general_relief: 438.5 + +- name: Case 9, an earner does not deduct another member's withholding. + period: 2024-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 66 + taxable_pension_income: 30_000 + ssi: 0 + person2: + age: 30 + employment_income: 4_800 + ssi: 0 + households: + household: + members: [person1, person2] + in_riv: true + spm_units: + spm_unit: + members: [person1, person2] + ca_tanf: 0 + housing_cost: 300 * 12 + output: + # person2's $400 monthly wages less their own payroll taxes of $30.60. + # The pension is not GR income, and person1's $31.80 CA withholding stays + # with person1. + ca_riv_general_relief_earned_income_deductions: [31.80, 30.60] + ca_riv_general_relief_countable_income: 369.40 + ca_riv_general_relief_eligible: true + # 436 needs standard + 2.5 special needs - 369.40 + ca_riv_general_relief: 69.10 + +- name: Case 10, Additional Medicare Tax withholding is a wage deduction. + period: 2024-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + employment_income: 300_000 + self_employment_income: -280_800 + ssi: 0 + households: + household: + members: [person1] + in_riv: true + spm_units: + spm_unit: + members: [person1] + ca_tanf: 0 + housing_cost: 300 * 12 + output: + # A business loss offsets most of the wages: + # (300,000 - 280,800) / 12 = 1,600 of monthly earned income. + ca_riv_general_relief_earned_income: 1_600 + # 871.10 Social Security + 362.50 Medicare + 75 Additional Medicare + # + 13.80 CA withholding on AGI of 19,200. + ca_riv_general_relief_earned_income_deductions: 1_322.40 + ca_riv_general_relief_countable_income: 277.60 + # 326 needs standard for one person + 2.5 special needs - 277.60 + ca_riv_general_relief_eligible: true + ca_riv_general_relief: 50.90 diff --git a/policyengine_us/variables/gov/irs/tax/payroll/medicare/additional_medicare_tax_withheld.py b/policyengine_us/variables/gov/irs/tax/payroll/medicare/additional_medicare_tax_withheld.py new file mode 100644 index 00000000000..47e7443058e --- /dev/null +++ b/policyengine_us/variables/gov/irs/tax/payroll/medicare/additional_medicare_tax_withheld.py @@ -0,0 +1,22 @@ +from policyengine_us.model_api import * + + +class additional_medicare_tax_withheld(Variable): + value_type = float + entity = Person + label = "Additional Medicare Tax withheld from wages" + documentation = "Additional Medicare Tax an employer withholds from the employee's wages. This can differ from the tax unit's additional_medicare_tax liability, which is reconciled on Form 8959." + unit = USD + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/3102#f_1", + "https://www.law.cornell.edu/cfr/text/26/31.3102-4#a", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.irs.payroll.medicare.additional + # Employers withhold on wages above the threshold without regard to + # filing status, a spouse's wages or wages from other employers. + # Wages are not split by employer, so this treats them as paid by one. + wages = person("payroll_tax_gross_wages", period) + return p.rate * max_(wages - p.withholding_threshold, 0) diff --git a/policyengine_us/variables/gov/local/ca/riv/general_relief/property/ca_riv_general_relief_countable_property_value.py b/policyengine_us/variables/gov/local/ca/riv/general_relief/property/ca_riv_general_relief_countable_property_value.py index 40b2d51e276..5ccefcccbee 100644 --- a/policyengine_us/variables/gov/local/ca/riv/general_relief/property/ca_riv_general_relief_countable_property_value.py +++ b/policyengine_us/variables/gov/local/ca/riv/general_relief/property/ca_riv_general_relief_countable_property_value.py @@ -3,10 +3,15 @@ class ca_riv_general_relief_countable_property_value(Variable): value_type = float - entity = Person + entity = SPMUnit unit = USD label = "Riverside County General Relief countable property value" definition_period = YEAR + quantity_type = STOCK defined_for = "in_riv" + # The property limit applies to the unit's combined property. The sources + # mix unit-level amounts (cash assets, countable vehicle value), which are + # counted once, with person-level personal property, which is summed over + # members. adds = "gov.local.ca.riv.general_relief.property.sources" From d63a84a0bfebb8cb42d899f4bf9bafa4c7ff0adc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 15 Oct 2026 00:06:56 -0400 Subject: [PATCH 2/6] Address C1: remove fixed Riverside projection ratchet entries --- .../test_no_group_variables_in_person_adds.py | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/policyengine_us/tests/code_health/test_no_group_variables_in_person_adds.py b/policyengine_us/tests/code_health/test_no_group_variables_in_person_adds.py index 7c527c4c010..c3a5dfdbde3 100644 --- a/policyengine_us/tests/code_health/test_no_group_variables_in_person_adds.py +++ b/policyengine_us/tests/code_health/test_no_group_variables_in_person_adds.py @@ -113,26 +113,6 @@ KNOWN_VIOLATIONS = { # PolicyEngine/policyengine-us#9888. ("mt_misc_deductions", "adds", "casualty_loss_deduction"), - ( - "ca_riv_general_relief_countable_property_value", - "adds", - "ca_riv_general_relief_countable_vehicle_value", - ), - ( - "ca_riv_general_relief_countable_property_value", - "adds", - "spm_unit_cash_assets", - ), - ( - "ca_riv_general_relief_earned_income_deductions", - "adds", - "additional_medicare_tax", - ), - ( - "ca_riv_general_relief_earned_income_deductions", - "adds", - "state_withheld_income_tax", - ), } From df96507310326b442e43ce40792d65c9cacf6efe Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 15 Oct 2026 00:09:59 -0400 Subject: [PATCH 3/6] Address S1 and S2: queue parity and distinguish Riverside property totals --- ...neral_relief_countable_property_value.yaml | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/ca_riv_general_relief_countable_property_value.yaml b/policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/ca_riv_general_relief_countable_property_value.yaml index 3d7a3102e6d..2a8fb021981 100644 --- a/policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/ca_riv_general_relief_countable_property_value.yaml +++ b/policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/ca_riv_general_relief_countable_property_value.yaml @@ -29,6 +29,7 @@ age: 40 person3: age: 65 + personal_property: 50 households: household: members: [person1, person2, person3] @@ -38,8 +39,9 @@ spm_unit: members: [person1, person2, person3] output: - # 4,850 - 4,650 vehicle exemption. - ca_riv_general_relief_countable_property_value: 200 + # 4,850 - 4,650 vehicle exemption + 50 personal property. + # The old Person variable returned [200, 200, 250], not the unit total. + ca_riv_general_relief_countable_property_value: 250 - name: Case 3, each member's cash and personal property are summed. period: 2024 @@ -64,3 +66,28 @@ output: # Cash 150 + 200, vehicle 4,700 - 4,650 = 50, personal property 50 + 75. ca_riv_general_relief_countable_property_value: 525 + +- name: Case 4, combined property remains a stock in a monthly request. + period: 2024-01 + input: + people: + person1: + age: 30 + bank_account_assets: 300 + personal_property: 50 + person2: + age: 65 + bank_account_assets: 100 + personal_property: 25 + households: + household: + members: [person1, person2] + in_riv: true + household_vehicles_value: 4_675 + spm_units: + spm_unit: + members: [person1, person2] + output: + # Cash 300 + 100, countable vehicles 25, member property 50 + 25. + # Assert the stock amount without choosing eligibility at exactly $500. + ca_riv_general_relief_countable_property_value: 500 From 86cf4012c5b1b4217dbfb8dd31306d4566a94d08 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 15 Oct 2026 00:10:09 -0400 Subject: [PATCH 4/6] Address S3: follow IRS parameter description template --- .../irs/payroll/medicare/additional/withholding_threshold.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_us/parameters/gov/irs/payroll/medicare/additional/withholding_threshold.yaml b/policyengine_us/parameters/gov/irs/payroll/medicare/additional/withholding_threshold.yaml index 209dccf7910..bd8a7034e57 100644 --- a/policyengine_us/parameters/gov/irs/payroll/medicare/additional/withholding_threshold.yaml +++ b/policyengine_us/parameters/gov/irs/payroll/medicare/additional/withholding_threshold.yaml @@ -1,4 +1,4 @@ -description: Employers withhold the Additional Medicare Tax from the wages they pay an employee above this amount in a calendar year, regardless of the employee's filing status. +description: The IRS requires employers to withhold the Additional Medicare Tax from an employee's wages above this amount in a calendar year, regardless of filing status. values: 2013-01-01: 200_000 metadata: From c2932bbc96eb72277bdfcdf4f86626ab5fd765f1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 15 Oct 2026 00:10:27 -0400 Subject: [PATCH 5/6] Address suggestions 1 and 2: track property boundary and cite DPSS fact sheet --- .../property/ca_riv_general_relief_countable_property_value.py | 1 + 1 file changed, 1 insertion(+) diff --git a/policyengine_us/variables/gov/local/ca/riv/general_relief/property/ca_riv_general_relief_countable_property_value.py b/policyengine_us/variables/gov/local/ca/riv/general_relief/property/ca_riv_general_relief_countable_property_value.py index 5ccefcccbee..9909e526ac5 100644 --- a/policyengine_us/variables/gov/local/ca/riv/general_relief/property/ca_riv_general_relief_countable_property_value.py +++ b/policyengine_us/variables/gov/local/ca/riv/general_relief/property/ca_riv_general_relief_countable_property_value.py @@ -9,6 +9,7 @@ class ca_riv_general_relief_countable_property_value(Variable): definition_period = YEAR quantity_type = STOCK defined_for = "in_riv" + reference = "https://web.archive.org/web/20210713051521/http://dpss.co.riverside.ca.us/files/pdf/dpss-general-assistance.pdf#page=1" # The property limit applies to the unit's combined property. The sources # mix unit-level amounts (cash assets, countable vehicle value), which are From 96e929e8e26589620571e1fe089592572a143c05 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 15 Oct 2026 00:10:47 -0400 Subject: [PATCH 6/6] Address suggestions 3 and 4: track deductions scope and document employer assumption --- .../tax/payroll/medicare/additional_medicare_tax_withheld.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_us/variables/gov/irs/tax/payroll/medicare/additional_medicare_tax_withheld.py b/policyengine_us/variables/gov/irs/tax/payroll/medicare/additional_medicare_tax_withheld.py index 47e7443058e..2e39dface61 100644 --- a/policyengine_us/variables/gov/irs/tax/payroll/medicare/additional_medicare_tax_withheld.py +++ b/policyengine_us/variables/gov/irs/tax/payroll/medicare/additional_medicare_tax_withheld.py @@ -5,7 +5,7 @@ class additional_medicare_tax_withheld(Variable): value_type = float entity = Person label = "Additional Medicare Tax withheld from wages" - documentation = "Additional Medicare Tax an employer withholds from the employee's wages. This can differ from the tax unit's additional_medicare_tax liability, which is reconciled on Form 8959." + documentation = "Additional Medicare Tax an employer withholds from the employee's wages. This can differ from the tax unit's additional_medicare_tax liability, which is reconciled on Form 8959. The model treats each person's aggregate wages as paid by one employer because wages are not split by employer." unit = USD definition_period = YEAR reference = (