diff --git a/changelog.d/mtr-uncomputed-adults.added.md b/changelog.d/mtr-uncomputed-adults.added.md new file mode 100644 index 00000000000..8a287e66b06 --- /dev/null +++ b/changelog.d/mtr-uncomputed-adults.added.md @@ -0,0 +1 @@ +Add `marginal_tax_rate_computed`, which flags the people whose marginal tax rates are simulated (the top `simulation.marginal_tax_rate_adults` earners among each household's adults); the marginal tax rate variables are a placeholder zero for everyone else. diff --git a/changelog.d/mtr-uncomputed-adults.fixed.md b/changelog.d/mtr-uncomputed-adults.fixed.md new file mode 100644 index 00000000000..7cfb6daf93d --- /dev/null +++ b/changelog.d/mtr-uncomputed-adults.fixed.md @@ -0,0 +1 @@ +Fix `cliff_evaluated` to rank adults by market income, as the marginal tax rate loop does, instead of by age. diff --git a/policyengine_us/parameters/simulation/marginal_tax_rate_adults.yaml b/policyengine_us/parameters/simulation/marginal_tax_rate_adults.yaml index 1f1ae60a4d1..2d4bb345bc4 100644 --- a/policyengine_us/parameters/simulation/marginal_tax_rate_adults.yaml +++ b/policyengine_us/parameters/simulation/marginal_tax_rate_adults.yaml @@ -1,4 +1,4 @@ -description: Number of adults to simulate a marginal tax rate for, in each household. +description: Number of adults in each household, ranked by market income, for whom the earnings marginal tax rates are simulated. Everyone else gets a marginal tax rate of zero; marginal_tax_rate_computed flags who is simulated. values: 0000-01-01: 2 metadata: diff --git a/policyengine_us/tests/core/test_marginal_tax_rate_coverage.py b/policyengine_us/tests/core/test_marginal_tax_rate_coverage.py new file mode 100644 index 00000000000..bb75598951f --- /dev/null +++ b/policyengine_us/tests/core/test_marginal_tax_rate_coverage.py @@ -0,0 +1,195 @@ +"""Invariants of which people get a simulated marginal tax rate. + +The marginal tax rate variables run one branch simulation per adult rank, from +1 to simulation.marginal_tax_rate_adults, perturbing the adult whose +adult_earnings_index (rank by market income within the household) equals that +rank. Everyone else keeps a placeholder of zero. marginal_tax_rate_computed +flags the simulated people, and cliff_evaluated must agree with it because the +cliff gap is derived from marginal_tax_rate. + +Every household in a grid of adult counts, earnings orders, ties, negative +market income, teen earners and children shares one simulation per cap, and is +checked against that rule, restated here without adult_earnings_index: + +1. Each household flags min(cap, adults) people, all adults, and every flagged + adult has at least the market income of every unflagged adult. +2. cliff_evaluated equals marginal_tax_rate_computed. +3. Every marginal tax rate variable is exactly zero wherever the flag is false. +4. Raising the cap only adds people: anyone flagged under a lower cap keeps the + same flag and the same marginal tax rates under a higher one. This holds with + behavioral responses off, as here; with labor supply responses on, the cap + changes measured marginal tax rates and so earnings and ranks. +""" + +from itertools import product + +import numpy as np +import pytest +from policyengine_core.reforms import Reform + +from policyengine_us import Simulation + +YEAR = 2024 +CAP = "simulation.marginal_tax_rate_adults" +# One variable per code path: the net income loop, its health-benefit copy, and +# the component helper shared by the federal, state and FICA variables. +MTR_VARIABLES = ( + "marginal_tax_rate", + "marginal_tax_rate_including_health_benefits", + "federal_marginal_tax_rate", +) +# Employment income per adult; a negative entry is a self-employment loss, which +# makes that adult's market income negative. +ADULT_EARNINGS = ( + (0,), + (40_000,), + (80_000, 0), + (0, 80_000), + (50_000, 50_000), + (0, 0, 0), + (30_000, 90_000, 60_000), + (20_000, 0, 20_000, 70_000), + (10_000, 45_000, 0, 45_000, 120_000), + (-20_000, 30_000), + (10_000, -5_000, 0, 60_000), +) +TEEN_EARNINGS = (None, 0, 8_000) +HAS_CHILD = (False, True) + + +def _situation(): + people, tax_units, spm_units, households = {}, {}, {}, {} + shapes = product(ADULT_EARNINGS, TEEN_EARNINGS, HAS_CHILD) + for h, (adults, teen, child) in enumerate(shapes): + members = [] + for a, earnings in enumerate(adults): + name = f"h{h}_adult{a}" + income = "employment_income" if earnings >= 0 else "self_employment_income" + people[name] = {"age": {YEAR: 25 + 7 * a}, income: {YEAR: earnings}} + tax_units[f"{name}_tax_unit"] = {"members": [name]} + members.append(name) + dependents = [] + if teen is not None: + dependents.append(f"h{h}_teen") + people[f"h{h}_teen"] = { + "age": {YEAR: 16}, + "employment_income": {YEAR: teen}, + } + if child: + dependents.append(f"h{h}_child") + people[f"h{h}_child"] = {"age": {YEAR: 6}} + tax_units[f"h{h}_adult0_tax_unit"]["members"] += dependents + members += dependents + spm_units[f"h{h}_spm_unit"] = {"members": members} + households[f"h{h}"] = {"members": members, "state_code": {YEAR: "TX"}} + return { + "people": people, + "tax_units": tax_units, + "spm_units": spm_units, + "households": households, + } + + +def _simulation(cap): + return Simulation( + situation=_situation(), + reform=Reform.from_dict({CAP: {str(YEAR): cap}}, country_id="us"), + ) + + +def _calc(simulation, variable): + return np.asarray(simulation.calculate(variable, YEAR)) + + +@pytest.fixture(scope="module") +def households(): + simulation = _simulation(0) + return { + "household_id": simulation.populations["household"].members_entity_id, + "is_adult": _calc(simulation, "is_adult").astype(bool), + "market_income": _calc(simulation, "market_income"), + } + + +@pytest.mark.parametrize("cap", [0, 1, 2, 3, 5]) +def test_flag_selects_the_top_earning_adults(households, cap): + simulation = _simulation(cap) + flag = _calc(simulation, "marginal_tax_rate_computed").astype(bool) + assert np.array_equal(_calc(simulation, "cliff_evaluated").astype(bool), flag) + assert not flag[~households["is_adult"]].any() + for household_id in np.unique(households["household_id"]): + in_household = households["household_id"] == household_id + adults = in_household & households["is_adult"] + flagged = in_household & flag + assert flagged.sum() == min(cap, adults.sum()) + unflagged_adults = adults & ~flag + if flagged.any() and unflagged_adults.any(): + income = households["market_income"] + assert income[flagged].min() >= income[unflagged_adults].max() + + +@pytest.fixture(scope="module") +def mtr_by_cap(): + results = {} + for cap in (2, 3): + simulation = _simulation(cap) + results[cap] = { + variable: _calc(simulation, variable) + for variable in ("marginal_tax_rate_computed",) + MTR_VARIABLES + } + return results + + +@pytest.mark.parametrize("variable", MTR_VARIABLES) +def test_unsimulated_people_get_a_placeholder_zero(mtr_by_cap, variable): + for results in mtr_by_cap.values(): + flag = results["marginal_tax_rate_computed"].astype(bool) + assert (results[variable][~flag] == 0).all() + + +@pytest.mark.parametrize("variable", MTR_VARIABLES) +def test_raising_the_cap_only_adds_people(mtr_by_cap, variable): + lower, higher = mtr_by_cap[2], mtr_by_cap[3] + lower_flag = lower["marginal_tax_rate_computed"].astype(bool) + higher_flag = higher["marginal_tax_rate_computed"].astype(bool) + assert (higher_flag[lower_flag]).all() + assert higher_flag.sum() > lower_flag.sum() + assert np.array_equal(lower[variable][lower_flag], higher[variable][lower_flag]) + + +# Three single roommates in Texas in 2024, each filing alone. Taxable income is +# wages less the $14,600 standard deduction ($75,400, $45,400, $15,400), so a +# $1,000 raise is taxed at 22%, 12% and 12%, plus 7.65% employee payroll tax. +# Nobody crosses a bracket or payroll threshold, and Texas has no income tax. +ROOMMATE_WAGES = (90_000, 60_000, 30_000) +ROOMMATE_RATES = { + "marginal_tax_rate": (0.2965, 0.1965, 0.1965), + "federal_marginal_tax_rate": (0.22, 0.12, 0.12), + "fica_marginal_tax_rate": (0.0765, 0.0765, 0.0765), + "state_marginal_tax_rate": (0, 0, 0), +} + + +@pytest.mark.parametrize("cap", [2, 3]) +def test_roommates_match_hand_computed_rates(cap): + names = [f"roommate{i}" for i in range(len(ROOMMATE_WAGES))] + situation = { + "people": { + name: {"age": {YEAR: 40 - 5 * i}, "employment_income": {YEAR: wages}} + for i, (name, wages) in enumerate(zip(names, ROOMMATE_WAGES)) + }, + "tax_units": {f"{name}_tax_unit": {"members": [name]} for name in names}, + "spm_units": {f"{name}_spm_unit": {"members": [name]} for name in names}, + "households": {"home": {"members": names, "state_code": {YEAR: "TX"}}}, + } + simulation = Simulation( + situation=situation, + reform=Reform.from_dict({CAP: {str(YEAR): cap}}, country_id="us"), + ) + simulated = np.arange(len(names)) < cap + assert np.array_equal( + _calc(simulation, "marginal_tax_rate_computed").astype(bool), simulated + ) + for variable, rates in ROOMMATE_RATES.items(): + expected = np.where(simulated, rates, 0) + assert np.allclose(_calc(simulation, variable), expected, atol=1e-4) diff --git a/policyengine_us/tests/policy/baseline/household/cliff_evaluated.yaml b/policyengine_us/tests/policy/baseline/household/cliff_evaluated.yaml index fe9a4312347..6b4bbb036aa 100644 --- a/policyengine_us/tests/policy/baseline/household/cliff_evaluated.yaml +++ b/policyengine_us/tests/policy/baseline/household/cliff_evaluated.yaml @@ -65,3 +65,25 @@ members: [person1, person2, person3] output: cliff_evaluated: [true, true, true] + +- name: cliff_evaluated follows earnings rank, not age, like marginal_tax_rate + period: 2024 + input: + people: + person1: + age: 70 + person2: + age: 40 + employment_income: 80_000 + person3: + age: 38 + employment_income: 50_000 + person4: + age: 22 + employment_income: 30_000 + households: + household: + members: [person1, person2, person3, person4] + state_code: TX + output: + cliff_evaluated: [false, true, true, false] diff --git a/policyengine_us/tests/policy/baseline/household/marginal_tax_rate.yaml b/policyengine_us/tests/policy/baseline/household/marginal_tax_rate.yaml index c6fc58a09cb..8ad352eb148 100644 --- a/policyengine_us/tests/policy/baseline/household/marginal_tax_rate.yaml +++ b/policyengine_us/tests/policy/baseline/household/marginal_tax_rate.yaml @@ -35,7 +35,7 @@ absolute_error_margin: 0.0001 period: 2021 input: - simulation.marginal_tax_rate_adults: 1 # the default value + simulation.marginal_tax_rate_adults: 1 # below the default of 2 people: person1: age: 43 @@ -88,3 +88,49 @@ state_fips: 48 # TX output: marginal_tax_rate: [0.3165, -0.0765] + +# Three single roommates in Texas in 2024, each filing alone. Taxable income is +# earnings less the $14,600 standard deduction, so the $90,000 earner is in the +# 22% bracket and the $60,000 and $30,000 earners are in the 12% bracket; each +# also pays 7.65% employee payroll tax. The federal and FICA components check the +# shared component helper, which has its own copy of the adult loop. +# The same household with the cap raised to 3 is in +# tests/core/test_marginal_tax_rate_coverage.py, which avoids another cached +# tax-benefit system in the batched YAML runner. +- name: Third earner in a household gets a placeholder zero under the default cap + absolute_error_margin: 0.0001 + period: 2024 + input: + people: + person1: + age: 40 + employment_income: 90_000 + person2: + age: 35 + employment_income: 60_000 + person3: + age: 30 + employment_income: 30_000 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + tax_unit3: + members: [person3] + spm_units: + spm_unit1: + members: [person1] + spm_unit2: + members: [person2] + spm_unit3: + members: [person3] + households: + household: + members: [person1, person2, person3] + state_code: TX + output: + marginal_tax_rate: [0.2965, 0.1965, 0] + marginal_tax_rate_computed: [true, true, false] + federal_marginal_tax_rate: [0.22, 0.12, 0] + fica_marginal_tax_rate: [0.0765, 0.0765, 0] diff --git a/policyengine_us/tests/policy/baseline/household/marginal_tax_rate_computed.yaml b/policyengine_us/tests/policy/baseline/household/marginal_tax_rate_computed.yaml new file mode 100644 index 00000000000..cf66b77d6fe --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/marginal_tax_rate_computed.yaml @@ -0,0 +1,49 @@ +# The marginal tax rate loop simulates adults whose adult_earnings_index (rank +# by market income within the household) is 1 through +# simulation.marginal_tax_rate_adults; everyone else gets a placeholder zero. +# Cases here use the default cap of 2: every distinct parameter override pins +# another copy of the tax-benefit system in the batched YAML runner, so cases +# that vary the cap live in tests/core/test_marginal_tax_rate_coverage.py. + +- name: Default cap simulates the top two earners, not the two oldest adults + period: 2024 + input: + people: + person1: + age: 70 + person2: + age: 40 + employment_income: 80_000 + person3: + age: 38 + employment_income: 50_000 + person4: + age: 22 + employment_income: 30_000 + households: + household: + members: [person1, person2, person3, person4] + state_code: TX + output: + adult_earnings_index: [4, 1, 2, 3] + marginal_tax_rate_computed: [false, true, true, false] + +- name: Children are never simulated, even with earnings and room under the cap + period: 2024 + input: + people: + person1: + age: 40 + employment_income: 60_000 + person2: + age: 16 + employment_income: 8_000 + person3: + age: 10 + households: + household: + members: [person1, person2, person3] + state_code: TX + output: + adult_earnings_index: [1, 0, 0] + marginal_tax_rate_computed: [true, false, false] diff --git a/policyengine_us/variables/gov/simulation/capital_gains_responses.py b/policyengine_us/variables/gov/simulation/capital_gains_responses.py index d39a372f76a..cf19dfcb2e7 100644 --- a/policyengine_us/variables/gov/simulation/capital_gains_responses.py +++ b/policyengine_us/variables/gov/simulation/capital_gains_responses.py @@ -86,6 +86,9 @@ class marginal_tax_rate_on_capital_gains(Variable): label = "capital gains marginal tax rate" documentation = ( "Percent of marginal capital gains that do not increase household net income." + " Simulated only for the two adults in each household with the largest" + " long-term capital gains (adult_index_cg 1 and 2), regardless of" + " simulation.marginal_tax_rate_adults; zero for everyone else." ) entity = Person definition_period = YEAR diff --git a/policyengine_us/variables/household/cliff.py b/policyengine_us/variables/household/cliff.py index 28f24445a93..984ff7f5ca7 100644 --- a/policyengine_us/variables/household/cliff.py +++ b/policyengine_us/variables/household/cliff.py @@ -9,10 +9,9 @@ class cliff_evaluated(Variable): definition_period = YEAR def formula(person, period, parameters): - adult_index_values = person("adult_index", period) - mtr_adult_count = parameters(period).simulation.marginal_tax_rate_adults - is_adult = person("is_adult", period) - return is_adult & (adult_index_values <= mtr_adult_count) + # The cliff gap comes from marginal_tax_rate, so it is simulated + # exactly where the marginal tax rate is. + return person("marginal_tax_rate_computed", period) class cliff_gap(Variable): diff --git a/policyengine_us/variables/household/federal_marginal_tax_rate.py b/policyengine_us/variables/household/federal_marginal_tax_rate.py index 5d6da27865a..34094a87d78 100644 --- a/policyengine_us/variables/household/federal_marginal_tax_rate.py +++ b/policyengine_us/variables/household/federal_marginal_tax_rate.py @@ -8,6 +8,9 @@ class federal_marginal_tax_rate(Variable): label = "federal marginal tax rate" documentation = ( "Marginal change in federal income tax per dollar of additional earnings." + " Simulated only where marginal_tax_rate_computed is true: for up to" + " simulation.marginal_tax_rate_adults adults per household, those with" + " the highest market income. Zero for everyone else." ) entity = Person definition_period = YEAR diff --git a/policyengine_us/variables/household/fica_marginal_tax_rate.py b/policyengine_us/variables/household/fica_marginal_tax_rate.py index d434d34cdd3..2dfa8b4655c 100644 --- a/policyengine_us/variables/household/fica_marginal_tax_rate.py +++ b/policyengine_us/variables/household/fica_marginal_tax_rate.py @@ -8,6 +8,9 @@ class fica_marginal_tax_rate(Variable): label = "FICA marginal tax rate" documentation = ( "Marginal change in employee payroll tax per dollar of additional earnings." + " Simulated only where marginal_tax_rate_computed is true: for up to" + " simulation.marginal_tax_rate_adults adults per household, those with" + " the highest market income. Zero for everyone else." ) entity = Person definition_period = YEAR diff --git a/policyengine_us/variables/household/marginal_tax_rate.py b/policyengine_us/variables/household/marginal_tax_rate.py index a0abd052d58..879241a29ab 100644 --- a/policyengine_us/variables/household/marginal_tax_rate.py +++ b/policyengine_us/variables/household/marginal_tax_rate.py @@ -5,6 +5,9 @@ class marginal_tax_rate(Variable): label = "marginal tax rate" documentation = ( "Fraction of marginal income gains that do not increase household net income." + " Simulated only where marginal_tax_rate_computed is true: for up to" + " simulation.marginal_tax_rate_adults adults per household, those with" + " the highest market income. Zero for everyone else." ) entity = Person definition_period = YEAR diff --git a/policyengine_us/variables/household/marginal_tax_rate_computed.py b/policyengine_us/variables/household/marginal_tax_rate_computed.py new file mode 100644 index 00000000000..917a754fc2e --- /dev/null +++ b/policyengine_us/variables/household/marginal_tax_rate_computed.py @@ -0,0 +1,29 @@ +from policyengine_us.model_api import * + + +class marginal_tax_rate_computed(Variable): + value_type = bool + entity = Person + label = "marginal tax rate computed" + documentation = ( + "Whether marginal_tax_rate, marginal_tax_rate_including_health_benefits, " + "federal_marginal_tax_rate, state_marginal_tax_rate and " + "fica_marginal_tax_rate are simulated for this person. They are " + "simulated only for adults whose adult_earnings_index (rank by market " + "income within the household) is at most " + "simulation.marginal_tax_rate_adults. Everyone else, including " + "children with earnings, gets a zero that is a placeholder, not a " + "simulated result. Filter on this flag before summarizing those " + "variables across people. marginal_tax_rate_on_capital_gains ranks " + "adults separately and is not covered by this flag." + ) + definition_period = YEAR + + def formula(person, period, parameters): + adult_count = parameters(period).simulation.marginal_tax_rate_adults + adult_earnings_index = person("adult_earnings_index", period) + return ( + person("is_adult", period) + & (adult_earnings_index >= 1) + & (adult_earnings_index <= adult_count) + ) diff --git a/policyengine_us/variables/household/marginal_tax_rate_including_health_benefits.py b/policyengine_us/variables/household/marginal_tax_rate_including_health_benefits.py index 02fe206ca49..b6b5580fae0 100644 --- a/policyengine_us/variables/household/marginal_tax_rate_including_health_benefits.py +++ b/policyengine_us/variables/household/marginal_tax_rate_including_health_benefits.py @@ -5,6 +5,9 @@ class marginal_tax_rate_including_health_benefits(Variable): label = "Marginal tax rate including health benefits" documentation = ( "Fraction of marginal income gains that do not increase household net income." + " Simulated only where marginal_tax_rate_computed is true: for up to" + " simulation.marginal_tax_rate_adults adults per household, those with" + " the highest market income. Zero for everyone else." ) entity = Person definition_period = YEAR diff --git a/policyengine_us/variables/household/state_marginal_tax_rate.py b/policyengine_us/variables/household/state_marginal_tax_rate.py index 68071d3516f..d7f4a20fcd2 100644 --- a/policyengine_us/variables/household/state_marginal_tax_rate.py +++ b/policyengine_us/variables/household/state_marginal_tax_rate.py @@ -8,6 +8,9 @@ class state_marginal_tax_rate(Variable): label = "state marginal tax rate" documentation = ( "Marginal change in state income tax per dollar of additional earnings." + " Simulated only where marginal_tax_rate_computed is true: for up to" + " simulation.marginal_tax_rate_adults adults per household, those with" + " the highest market income. Zero for everyone else." ) entity = Person definition_period = YEAR