diff --git a/changelog.d/fix-hi-va-repeal-claimable.fixed.md b/changelog.d/fix-hi-va-repeal-claimable.fixed.md new file mode 100644 index 00000000000..0e7cd8b1237 --- /dev/null +++ b/changelog.d/fix-hi-va-repeal-claimable.fixed.md @@ -0,0 +1 @@ +Keep a claimable filer's own exemptions under the Hawaii and Virginia dependent-exemption repeal: the contributed repeal no longer gives a filer whom another taxpayer can claim the regular, aged or personal exemption that the baseline denies (HRS 235-54(a); Va. Code 58.1-322.03(2)(a)). diff --git a/policyengine_us/reforms/state_dependent_exemptions/repeal_state_dependent_exemptions.py b/policyengine_us/reforms/state_dependent_exemptions/repeal_state_dependent_exemptions.py index d05820bfb6b..e6960115eff 100644 --- a/policyengine_us/reforms/state_dependent_exemptions/repeal_state_dependent_exemptions.py +++ b/policyengine_us/reforms/state_dependent_exemptions/repeal_state_dependent_exemptions.py @@ -8,18 +8,30 @@ class hi_regular_exemptions(Variable): entity = TaxUnit label = "Hawaii regular exemptions" unit = USD - reference = "https://files.hawaii.gov/tax/forms/2022/n11ins.pdf#page=20" + reference = ( + "https://files.hawaii.gov/tax/forms/2022/n11ins.pdf#page=20", + # HRS 235-54(a): the exemption of an individual whom another + # taxpayer can claim "shall be zero", including the additional + # exemption for age 65 or over. + "https://files.hawaii.gov/tax/legal/hrs/hrs_235.pdf#page=45", + ) definition_period = YEAR defined_for = StateCode.HI def formula(tax_unit, period, parameters): - exemptions_count = tax_unit("head_spouse_count", period) + # Repeal removes dependents, preserving the baseline filer count: + # a filer who can be claimed as a dependent has no exemption. + exemptions_count = tax_unit( + "head_spouse_count_not_dependent_elsewhere", period + ) p = parameters(period).gov.states.hi.tax.income.exemptions - # Aged heads and spouses get an extra base exemption. + # Aged heads and spouses who cannot be claimed get an extra base + # exemption, as in the baseline. person = tax_unit.members head_or_spouse = person("is_tax_unit_head_or_spouse", period) + claimed = person("claimable_as_dependent_on_another_return", period) aged = person("age", period) >= p.aged_threshold - aged_head_spouse_count = tax_unit.sum(aged & head_or_spouse) + aged_head_spouse_count = tax_unit.sum(aged & head_or_spouse & ~claimed) total_exemption_count_including_aged = ( exemptions_count + aged_head_spouse_count ) @@ -200,9 +212,14 @@ class va_personal_exemption_person(Variable): ) def formula(person, period, parameters): - head_or_spouse = person("is_tax_unit_head_or_spouse", period) + # Repeal removes dependents, preserving the baseline filer gate: + # a filer who can be claimed as a dependent has no personal + # exemption allowable federally (Va. Code 58.1-322.03(2)(a); + # IRC 151(d)(2)). + filer = person("is_tax_unit_head_or_spouse", period) + claimed = person("claimable_as_dependent_on_another_return", period) amount = parameters(period).gov.states.va.tax.income.exemptions.personal - return amount * head_or_spouse + return amount * (filer & ~claimed) class wv_personal_exemption(Variable): value_type = float diff --git a/policyengine_us/tests/core/test_claimable_filer_hi_va_repeal_invariants.py b/policyengine_us/tests/core/test_claimable_filer_hi_va_repeal_invariants.py new file mode 100644 index 00000000000..0df4065abad --- /dev/null +++ b/policyengine_us/tests/core/test_claimable_filer_hi_va_repeal_invariants.py @@ -0,0 +1,225 @@ +"""Hawaii and Virginia exemptions under the contributed repeal of state +dependent exemptions, for filers who can be claimed as a dependent. + +The repeal removes the dependent exemptions and nothing else. A filer whom +another taxpayer can claim has no exemption of their own in the baseline: +Hawaii sets it, and the additional exemption for age 65 or over, to zero +(HRS 235-54(a)), and Virginia allows $930 only for each exemption allowable +federally (Va. Code 58.1-322.03(2)(a); IRC 151(d)(2)). Virginia's $800 aged or +blind exemption (58.1-322.03(2)(b)) has no such bar. + +For single filers and couples, each filer not claimable, claimable but +unclaimed, claimed, or within the claimant filing exception, with and without +dependents (an exhaustive grid and Hypothesis batches): + +1. Dependents only: each output under the repeal equals the baseline output + for the same return with its dependents removed, and each filer's Virginia + personal exemption equals the baseline's for that filer on that return. +2. Never above baseline: no output is higher under the repeal than in the + baseline. +3. No claimable filer gets a Virginia personal exemption under the repeal, + and no dependent does. +""" + +from itertools import product + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import CountryTaxBenefitSystem, Simulation +from policyengine_us.reforms.state_dependent_exemptions.repeal_state_dependent_exemptions import ( + repeal_state_dependent_exemptions, +) +from policyengine_us.tests.core.test_dependent_elsewhere_head_spouse_swap_invariance import ( + adults, + dependents, +) + +STATES = ["HI", "VA"] +YEARS = [2021, 2025, 2026] +OUTPUTS = [ + "hi_regular_exemptions", + "hi_disabled_exemptions", + "hi_exemptions", + "va_personal_exemption", + "va_aged_blind_exemption", + "va_total_exemptions", +] +TOLERANCE = 0.01 +# (claimable, claimed, claimant filing exception) for each filer. +CLAIM_STATES = { + "none": (False, False, False), + "claimable": (True, False, False), + "claimed": (True, True, False), + "exception": (True, False, True), +} + + +@pytest.fixture(scope="module") +def repeal_system(): + # Built once for the module and released when its tests end. + return CountryTaxBenefitSystem(reform=repeal_state_dependent_exemptions) + + +def _grid(): + young = {"age": 22, "employment_income": 8_000.0} + old = {"age": 70, "social_security_retirement": 12_000.0} + impaired = {"age": 40, "is_blind": True, "is_disabled": True} + children = [{"age": 4}, {"age": 19, "is_full_time_student": True}] + filer_sets = [[young], [old], [impaired], [young, old], [old, old]] + filer_sets += [[young, impaired]] + units = [] + for state, filers, with_children in product(STATES, filer_sets, [False, True]): + for states in product(CLAIM_STATES, repeat=len(filers)): + claimable, claimed, exception = zip(*(CLAIM_STATES[s] for s in states)) + units.append( + { + "state": state, + "adults": filers, + "dependents": children if with_children else [], + "claimable": claimable, + "claimed": claimed, + "exception": exception, + } + ) + return units + + +@st.composite +def returns(draw): + filers = draw(st.integers(1, 2)) + claimable = tuple(draw(st.booleans()) for _ in range(filers)) + claimed = tuple(can and draw(st.booleans()) for can in claimable) + return { + "state": draw(st.sampled_from(STATES)), + "adults": [draw(adults()) for _ in range(filers)], + "dependents": draw(st.lists(dependents(), max_size=2)), + "claimable": claimable, + "claimed": claimed, + "exception": tuple( + can and not is_claimed and draw(st.booleans()) + for can, is_claimed in zip(claimable, claimed) + ), + } + + +def _situation(units, year, with_dependents=True): + people = {} + groups = { + "tax_units": {}, + "spm_units": {}, + "families": {}, + "marital_units": {}, + "households": {}, + } + for i, unit in enumerate(units): + filers = [] + for j, amounts in enumerate(unit["adults"]): + name = f"filer_{i}_{j}" + people[name] = { + **amounts, + "is_tax_unit_head": j == 0, + "is_tax_unit_spouse": j == 1, + "is_tax_unit_dependent": False, + "claimable_as_dependent_on_another_return": unit["claimable"][j], + "claimed_as_dependent_on_another_return": unit["claimed"][j], + "dependent_claimant_filing_exception": unit["exception"][j], + } + filers.append(name) + groups["marital_units"][f"filers_{i}"] = {"members": list(filers)} + members = list(filers) + for k, amounts in enumerate(unit["dependents"] if with_dependents else []): + name = f"dependent_{i}_{k}" + people[name] = { + **amounts, + "is_tax_unit_head": False, + "is_tax_unit_spouse": False, + "is_tax_unit_dependent": True, + } + members.append(name) + groups["marital_units"][name] = {"members": [name]} + for group, prefix in ( + ("tax_units", "tax_unit"), + ("spm_units", "spm_unit"), + ("families", "family"), + ): + groups[group][f"{prefix}_{i}"] = {"members": members} + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: unit["state"]}, + } + people = { + name: {k: {year: v} for k, v in values.items()} + for name, values in people.items() + } + return {"people": people, **groups} + + +def _calc(sim, name, year): + return np.asarray(sim.calculate(name, year), dtype=float) + + +def _describe(units, rows): + return ", ".join( + f"{units[i]['state']} filers={len(units[i]['adults'])} " + f"claimable={units[i]['claimable']} claimed={units[i]['claimed']} " + f"exception={units[i]['exception']} " + f"dependents={len(units[i]['dependents'])}" + for i in rows[:5] + ) + + +def check_repeal(units, year, system): + situation = _situation(units, year) + repeal = Simulation(situation=situation, tax_benefit_system=system) + baseline = Simulation(situation=situation) + no_dependents = Simulation(situation=_situation(units, year, False)) + for name in OUTPUTS: + reformed = _calc(repeal, name, year) + expected = _calc(no_dependents, name, year) + current = _calc(baseline, name, year) + assert np.isfinite(reformed).all(), f"{name} in {year} is not finite" + differs = np.flatnonzero(np.abs(reformed - expected) > TOLERANCE) + assert not differs.size, ( + f"{name} in {year} under the repeal differs from the baseline " + "without dependents: " + + _describe(units, differs) + + f"; repeal {reformed[differs[:5]]}, expected {expected[differs[:5]]}" + ) + rises = np.flatnonzero(reformed > current + TOLERANCE) + assert not rises.size, ( + f"{name} in {year} is higher under the repeal than in the " + "baseline: " + _describe(units, rises) + ) + # Filers keep their order in both situations, so their rows line up. + filer = _calc(repeal, "is_tax_unit_head_or_spouse", year) > 0 + claimable = _calc(repeal, "claimable_as_dependent_on_another_return", year) > 0 + person = _calc(repeal, "va_personal_exemption_person", year) + np.testing.assert_allclose( + person[filer], + _calc(no_dependents, "va_personal_exemption_person", year), + atol=TOLERANCE, + err_msg=f"va_personal_exemption_person in {year}", + ) + assert not person[~filer].any(), f"a dependent has a VA exemption in {year}" + assert not person[filer & claimable].any(), ( + f"a claimable filer has a VA personal exemption in {year}" + ) + + +@pytest.mark.parametrize("year", YEARS) +def test_hi_va_repeal_grid(year, repeal_system): + check_repeal(_grid(), year, repeal_system) + + +@settings( + max_examples=3, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(year=st.sampled_from(YEARS), units=st.lists(returns(), min_size=6, max_size=12)) +def test_hi_va_repeal_generated_returns(year, units, repeal_system): + check_repeal(units, year, repeal_system) diff --git a/policyengine_us/tests/policy/contrib/state_dependent_exemptions/repeal_state_dependent_exemptions.yaml b/policyengine_us/tests/policy/contrib/state_dependent_exemptions/repeal_state_dependent_exemptions.yaml index 2c9f3842667..163fdaeac14 100644 --- a/policyengine_us/tests/policy/contrib/state_dependent_exemptions/repeal_state_dependent_exemptions.yaml +++ b/policyengine_us/tests/policy/contrib/state_dependent_exemptions/repeal_state_dependent_exemptions.yaml @@ -80,6 +80,349 @@ output: vt_personal_exemptions: 15_300 +# HRS 235-54(a): the exemption of an individual whom another taxpayer can claim +# is zero, including the additional exemption for age 65 or over. The repeal +# removes dependents only, so it never gives such a filer an exemption. +- name: HI repeal - unclaimed but claimable single filer - baseline + period: 2025 + input: + gov.contrib.repeal_state_dependent_exemptions.in_effect: false + people: + head: + age: 22 + claimable_as_dependent_on_another_return: true + claimed_as_dependent_on_another_return: false + tax_units: {tax_unit: {members: [head], filing_status: SINGLE}} + households: {household: {members: [head], state_code: HI}} + output: + hi_regular_exemptions: 0 + hi_exemptions: 0 + +- name: HI repeal - unclaimed but claimable single filer - reform + period: 2025 + reforms: policyengine_us.reforms.state_dependent_exemptions.repeal_state_dependent_exemptions.repeal_state_dependent_exemptions + input: + gov.contrib.repeal_state_dependent_exemptions.in_effect: true + people: + head: + age: 22 + claimable_as_dependent_on_another_return: true + claimed_as_dependent_on_another_return: false + tax_units: {tax_unit: {members: [head], filing_status: SINGLE}} + households: {household: {members: [head], state_code: HI}} + output: + hi_regular_exemptions: 0 + hi_exemptions: 0 + +- name: HI repeal - joint return with one claimable spouse and two children keeps only the other spouse's exemption + period: 2025 + reforms: policyengine_us.reforms.state_dependent_exemptions.repeal_state_dependent_exemptions.repeal_state_dependent_exemptions + input: + gov.contrib.repeal_state_dependent_exemptions.in_effect: true + people: + head: + age: 40 + is_tax_unit_head: true + spouse: + age: 22 + is_tax_unit_spouse: true + claimable_as_dependent_on_another_return: true + child1: + age: 4 + is_tax_unit_dependent: true + child2: + age: 7 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, spouse, child1, child2] + filing_status: JOINT + households: + household: + members: [head, spouse, child1, child2] + state_code: HI + output: + # Baseline is also 1 x 1,144: the return has no dependents (IRC 152(b)(1)). + hi_regular_exemptions: 1_144 + +- name: HI repeal - joint return whose claimable spouse is within the filing exception loses only the dependents + period: 2025 + reforms: policyengine_us.reforms.state_dependent_exemptions.repeal_state_dependent_exemptions.repeal_state_dependent_exemptions + input: + gov.contrib.repeal_state_dependent_exemptions.in_effect: true + people: + head: + age: 22 + is_tax_unit_head: true + claimable_as_dependent_on_another_return: true + dependent_claimant_filing_exception: true + spouse: + age: 40 + is_tax_unit_spouse: true + child1: + age: 4 + is_tax_unit_dependent: true + child2: + age: 7 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, spouse, child1, child2] + filing_status: JOINT + households: + household: + members: [head, spouse, child1, child2] + state_code: HI + output: + # Baseline: (1 own + 2 dependents) x 1,144 = 3,432. + hi_regular_exemptions: 1_144 + +- name: HI repeal - joint return with both spouses claimable + period: 2025 + reforms: policyengine_us.reforms.state_dependent_exemptions.repeal_state_dependent_exemptions.repeal_state_dependent_exemptions + input: + gov.contrib.repeal_state_dependent_exemptions.in_effect: true + people: + head: + age: 22 + is_tax_unit_head: true + claimable_as_dependent_on_another_return: true + spouse: + age: 21 + is_tax_unit_spouse: true + claimable_as_dependent_on_another_return: true + tax_units: {tax_unit: {members: [head, spouse], filing_status: JOINT}} + households: {household: {members: [head, spouse], state_code: HI}} + output: + hi_regular_exemptions: 0 + hi_exemptions: 0 + +- name: HI repeal - aged claimable single filer gets neither the regular nor the aged exemption + period: 2025 + reforms: policyengine_us.reforms.state_dependent_exemptions.repeal_state_dependent_exemptions.repeal_state_dependent_exemptions + input: + gov.contrib.repeal_state_dependent_exemptions.in_effect: true + people: + head: + age: 66 + claimable_as_dependent_on_another_return: true + tax_units: {tax_unit: {members: [head], filing_status: SINGLE}} + households: {household: {members: [head], state_code: HI}} + output: + hi_regular_exemptions: 0 + +- name: HI repeal - aged couple with one claimable spouse keeps the other spouse's regular and aged exemptions + period: 2025 + reforms: policyengine_us.reforms.state_dependent_exemptions.repeal_state_dependent_exemptions.repeal_state_dependent_exemptions + input: + gov.contrib.repeal_state_dependent_exemptions.in_effect: true + people: + head: + age: 67 + is_tax_unit_head: true + claimable_as_dependent_on_another_return: true + spouse: + age: 70 + is_tax_unit_spouse: true + tax_units: {tax_unit: {members: [head, spouse], filing_status: JOINT}} + households: {household: {members: [head, spouse], state_code: HI}} + output: + # Baseline is also (1 + 1 aged) x 1,144. + hi_regular_exemptions: 2_288 + +- name: HI repeal - aged couple with a child keeps both regular and aged exemptions and loses the dependent + period: 2025 + reforms: policyengine_us.reforms.state_dependent_exemptions.repeal_state_dependent_exemptions.repeal_state_dependent_exemptions + input: + gov.contrib.repeal_state_dependent_exemptions.in_effect: true + people: + head: + age: 70 + is_tax_unit_head: true + spouse: + age: 68 + is_tax_unit_spouse: true + child: + age: 15 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, spouse, child] + filing_status: JOINT + households: + household: + members: [head, spouse, child] + state_code: HI + output: + # (2 + 2 aged) x 1,144; the baseline adds the child for 5,720. + hi_regular_exemptions: 4_576 + +# Va. Code 58.1-322.03(2)(a) allows $930 for each personal exemption allowable +# federally, and a filer whom another taxpayer can claim has none +# (IRC 151(d)(2)). The $800 aged or blind exemption in (2)(b) has no such bar. +- name: VA repeal - unclaimed but claimable single filer - baseline + period: 2025 + input: + gov.contrib.repeal_state_dependent_exemptions.in_effect: false + people: + head: + age: 22 + claimable_as_dependent_on_another_return: true + claimed_as_dependent_on_another_return: false + tax_units: {tax_unit: {members: [head], filing_status: SINGLE}} + households: {household: {members: [head], state_code: VA}} + output: + va_personal_exemption: 0 + va_total_exemptions: 0 + +- name: VA repeal - unclaimed but claimable single filer - reform + period: 2025 + reforms: policyengine_us.reforms.state_dependent_exemptions.repeal_state_dependent_exemptions.repeal_state_dependent_exemptions + input: + gov.contrib.repeal_state_dependent_exemptions.in_effect: true + people: + head: + age: 22 + claimable_as_dependent_on_another_return: true + claimed_as_dependent_on_another_return: false + tax_units: {tax_unit: {members: [head], filing_status: SINGLE}} + households: {household: {members: [head], state_code: VA}} + output: + va_personal_exemption: 0 + va_total_exemptions: 0 + +- name: VA repeal - joint return with one claimable spouse and two children keeps only the other spouse's exemption + period: 2025 + reforms: policyengine_us.reforms.state_dependent_exemptions.repeal_state_dependent_exemptions.repeal_state_dependent_exemptions + input: + gov.contrib.repeal_state_dependent_exemptions.in_effect: true + people: + head: + age: 40 + is_tax_unit_head: true + spouse: + age: 22 + is_tax_unit_spouse: true + claimable_as_dependent_on_another_return: true + child1: + age: 4 + is_tax_unit_dependent: true + child2: + age: 7 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, spouse, child1, child2] + filing_status: JOINT + households: + household: + members: [head, spouse, child1, child2] + state_code: VA + output: + # Baseline is also 930: the return has no dependents (IRC 152(b)(1)). + va_personal_exemption: 930 + va_personal_exemption_person: [930, 0, 0, 0] + +- name: VA repeal - joint return whose claimable spouse is within the filing exception loses only the dependents + period: 2025 + reforms: policyengine_us.reforms.state_dependent_exemptions.repeal_state_dependent_exemptions.repeal_state_dependent_exemptions + input: + gov.contrib.repeal_state_dependent_exemptions.in_effect: true + people: + head: + age: 22 + is_tax_unit_head: true + claimable_as_dependent_on_another_return: true + dependent_claimant_filing_exception: true + spouse: + age: 40 + is_tax_unit_spouse: true + child1: + age: 4 + is_tax_unit_dependent: true + child2: + age: 7 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, spouse, child1, child2] + filing_status: JOINT + households: + household: + members: [head, spouse, child1, child2] + state_code: VA + output: + # Baseline: 3 x 930 = 2,790. + va_personal_exemption: 930 + va_personal_exemption_person: [0, 930, 0, 0] + +- name: VA repeal - joint return with both spouses claimable + period: 2025 + reforms: policyengine_us.reforms.state_dependent_exemptions.repeal_state_dependent_exemptions.repeal_state_dependent_exemptions + input: + gov.contrib.repeal_state_dependent_exemptions.in_effect: true + people: + head: + age: 22 + is_tax_unit_head: true + claimable_as_dependent_on_another_return: true + spouse: + age: 21 + is_tax_unit_spouse: true + claimable_as_dependent_on_another_return: true + tax_units: {tax_unit: {members: [head, spouse], filing_status: JOINT}} + households: {household: {members: [head, spouse], state_code: VA}} + output: + va_personal_exemption: 0 + va_total_exemptions: 0 + +- name: VA repeal - aged claimable single filer keeps only the aged exemption + period: 2025 + reforms: policyengine_us.reforms.state_dependent_exemptions.repeal_state_dependent_exemptions.repeal_state_dependent_exemptions + input: + gov.contrib.repeal_state_dependent_exemptions.in_effect: true + people: + head: + age: 66 + claimable_as_dependent_on_another_return: true + tax_units: {tax_unit: {members: [head], filing_status: SINGLE}} + households: {household: {members: [head], state_code: VA}} + output: + va_personal_exemption: 0 + va_aged_blind_exemption: 800 + va_total_exemptions: 800 + +- name: VA repeal - joint filers with two children keep their own exemptions and lose the dependents + period: 2025 + reforms: policyengine_us.reforms.state_dependent_exemptions.repeal_state_dependent_exemptions.repeal_state_dependent_exemptions + input: + gov.contrib.repeal_state_dependent_exemptions.in_effect: true + people: + head: + age: 40 + is_tax_unit_head: true + spouse: + age: 38 + is_tax_unit_spouse: true + child1: + age: 4 + is_tax_unit_dependent: true + child2: + age: 7 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, spouse, child1, child2] + filing_status: JOINT + households: + household: + members: [head, spouse, child1, child2] + state_code: VA + output: + # 2 x 930; the baseline adds the children for 3,720. + va_personal_exemption: 1_860 + va_personal_exemption_person: [930, 930, 0, 0] + # Test for KY family size tax credit rate - verifies state_group_str is used correctly # This test ensures state_group is derived from state_code, not set explicitly - name: Kentucky family size tax credit with reform - state_group derived from state_code