Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fix-dc-se-loss-addition-filers.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
D.C.'s self-employment loss addition no longer counts a dependent's self-employment loss, either in the amount added back or in its split between spouses.
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""Invariants for the D.C. self-employment loss addition.

The addition adds back the filers' self-employment losses deducted in federal
AGI above D.C.'s $12,000 threshold (Schedule I, Calculation A, line 6). A
tax-unit dependent's losses belong on the dependent's own return and are not in
`loss_ald`, so they must not enter the addition or its split between spouses.

The properties must hold for every tax unit, so the tests draw a seeded random
population of D.C. tax units (single, head of household, joint, joint with
dependents). Filers have wages, and self-employment and rental income of
either sign; dependents have self-employment income of either sign. The tests
run it twice as vectorized simulations: as drawn, and with the dependents'
self-employment income set to zero. Losses stay far below the section 461(l)
limit, so every filer loss is deducted in federal AGI.

1. Dependents' self-employment income never changes anyone's addition.
2. Dependents get no addition, and no addition is negative.
3. The tax unit's addition is the head's and spouse's self-employment losses
less $12,000, and never less than zero. A filer's rental loss and a
dependent's loss leave it unchanged.
4. A joint return splits the addition between the spouses in proportion to
their own losses.
"""

import numpy as np
import pytest

from policyengine_us import Simulation

YEAR = 2025
N_RANDOM = 200
SEED = 20261006
THRESHOLD = 12_000
TOLERANCE = 0.02 # dollars; float32 sums


def _self_employment(rng, low, high):
return float(round(rng.choice([0.0, rng.uniform(low, 0), rng.uniform(0, high)])))


def _situation(zero_dependents=False):
rng = np.random.default_rng(SEED)
people, tax_units, households = {}, {}, {}
for i in range(N_RANDOM):
kind = rng.choice(
["single", "hoh", "joint", "joint_dependents"], p=[0.15, 0.2, 0.2, 0.45]
)
roles = ["head", "spouse"] if kind.startswith("joint") else ["head"]
members = []
for role in roles:
name = f"{role}_{i}"
members.append(name)
people[name] = {
"age": {YEAR: 45},
"employment_income": {YEAR: float(round(rng.uniform(0, 150_000)))},
"self_employment_income": {
YEAR: _self_employment(rng, -60_000, 40_000)
},
"rental_income": {YEAR: _self_employment(rng, -40_000, 30_000)},
f"is_tax_unit_{role}": {YEAR: True},
}
if kind in ("hoh", "joint_dependents"):
for j in range(int(rng.integers(1, 3))):
name = f"child_{i}_{j}"
members.append(name)
amount = _self_employment(rng, -40_000, 20_000)
people[name] = {
"age": {YEAR: 16 + j},
"is_tax_unit_dependent": {YEAR: True},
"self_employment_income": {
YEAR: 0.0 if zero_dependents else amount
},
}
tax_units[f"tu_{i}"] = {"members": members}
households[f"hh_{i}"] = {"members": members, "state_code": {YEAR: "DC"}}
return {"people": people, "tax_units": tax_units, "households": households}


@pytest.fixture(scope="module")
def run():
out = {}
for name, zero_dependents in {"with": False, "without": True}.items():
sim = Simulation(situation=_situation(zero_dependents=zero_dependents))
out[name] = np.asarray(
sim.calculate("dc_self_employment_loss_addition", YEAR), dtype=float
)
if name == "with":
for variable in [
"total_self_employment_income",
"is_tax_unit_dependent",
"is_tax_unit_spouse",
]:
out[variable] = np.asarray(sim.calculate(variable, YEAR))
out["joint"] = (
sim.calculate("filing_status", YEAR).decode_to_str() == "JOINT"
)
out["person_tax_unit"] = sim.populations["tax_unit"].members_entity_id
return out


def _unit_sum(run, values):
return np.bincount(
run["person_tax_unit"], weights=values, minlength=len(run["joint"])
)


def test_population_has_dependent_losses_and_additions(run):
dependent = run["is_tax_unit_dependent"]
dependent_loss = _unit_sum(
run, dependent * (run["total_self_employment_income"] < 0)
)
added = _unit_sum(run, run["with"]) > 0
assert ((dependent_loss > 0) & added & run["joint"]).sum() > 5


def test_dependents_income_never_changes_the_addition(run):
np.testing.assert_allclose(run["with"], run["without"], atol=TOLERANCE)


def test_dependents_get_nothing_and_nothing_is_negative(run):
assert np.all(run["with"][run["is_tax_unit_dependent"]] == 0)
assert np.all(run["with"] >= 0)


def _filer_loss(run):
loss = np.maximum(0, -run["total_self_employment_income"])
return loss * ~run["is_tax_unit_dependent"]


def test_addition_is_filers_losses_over_threshold(run):
expected = np.maximum(0, _unit_sum(run, _filer_loss(run)) - THRESHOLD)
np.testing.assert_allclose(_unit_sum(run, run["with"]), expected, atol=TOLERANCE)


def test_joint_split_follows_each_spouses_loss(run):
filer_loss = _filer_loss(run)
unit = run["person_tax_unit"]
unit_loss = _unit_sum(run, filer_loss)[unit]
unit_addition = np.maximum(0, unit_loss - THRESHOLD)
share = np.divide(
filer_loss, unit_loss, out=np.zeros_like(filer_loss), where=unit_loss > 0
)
joint_filer = ~run["is_tax_unit_dependent"] & run["joint"][unit]
np.testing.assert_allclose(
run["with"][joint_filer],
(share * unit_addition)[joint_filer],
atol=TOLERANCE,
)
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,91 @@
dc_self_employment_loss_addition: 8_000
dc_agi: 68_000

- name: A dependent's self-employment loss does not dilute the head's addition.
absolute_error_margin: 0.01
period: 2024
input:
people:
head:
age: 45
employment_income: 60_000
self_employment_income: -20_000
spouse:
age: 45
employment_income: 40_000
dependent:
age: 17
is_tax_unit_dependent: true
self_employment_income: -20_000
tax_units:
tax_unit:
members: [head, spouse, dependent]
households:
household:
members: [head, spouse, dependent]
state_code: DC
output:
# The dependent's loss is on their own return, so loss_ald holds only
# the head's $20,000. Addition: max(0, 20,000 - 12,000) = 8,000, all
# of it the head's.
dc_self_employment_loss_addition: [8_000, 0, 0]

- name: A dependent's self-employment loss does not raise a head of household's addition.
absolute_error_margin: 0.01
period: 2024
input:
people:
head:
age: 45
employment_income: 60_000
self_employment_income: -5_000
rental_income: -30_000
dependent:
age: 17
is_tax_unit_dependent: true
self_employment_income: -20_000
tax_units:
tax_unit:
members: [head, dependent]
households:
household:
members: [head, dependent]
state_code: DC
output:
# The head's own self-employment loss is $5,000, under the $12,000
# threshold. Counting the dependent's $20,000 would give
# min(25,000, 35,000 of business losses in loss_ald) - 12,000 = 13,000.
dc_self_employment_loss_addition: [0, 0]

- name: Both spouses' losses share the addition; a dependent's loss is left out.
absolute_error_margin: 0.01
period: 2024
input:
people:
head:
age: 45
employment_income: 60_000
self_employment_income: -20_000
spouse:
age: 45
employment_income: 50_000
self_employment_income: -10_000
dependent:
age: 17
is_tax_unit_dependent: true
self_employment_income: -15_000
tax_units:
tax_unit:
members: [head, spouse, dependent]
households:
household:
members: [head, spouse, dependent]
state_code: DC
output:
# Addition: max(0, 30,000 - 12,000) = 18,000, split 20:10 by the
# spouses' own losses.
dc_self_employment_loss_addition: [12_000, 6_000, 0]

- name: Case 3, large SE loss capped by federal 461(l).
absolute_error_margin: 0.01
period: 2024
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,25 @@ class dc_self_employment_loss_addition(Variable):
defined_for = StateCode.DC

def formula(person, period, parameters):
loss_person = max_(0, -person("total_self_employment_income", period))
# Only the head's and spouse's losses are on this return. A tax-unit
# dependent's losses are not in loss_ald, so counting them would raise
# the addition or dilute the filers' shares of it.
is_head_or_spouse = person("is_tax_unit_head_or_spouse", period)
loss_person = is_head_or_spouse * max_(
0, -person("total_self_employment_income", period)
)
loss_taxunit = person.tax_unit.sum(loss_person)
# Cap at SE loss actually deducted in federal AGI via loss_ald.
# loss_ald includes both SE and capital losses; isolate SE portion.
# Cap at the business losses actually deducted in federal AGI: loss_ald
# less its capital loss parts (self-employment, farm, rental,
# partnership, estate and other business losses after section 461(l)).
loss_ald = person.tax_unit("loss_ald", period)
capital_loss_in_ald = add(
person.tax_unit,
period,
["capital_losses_allowed_against_gains", "limited_capital_loss"],
)
se_loss_in_ald = max_(0, loss_ald - capital_loss_in_ald)
effective_loss = min_(loss_taxunit, se_loss_in_ald)
business_loss_in_ald = max_(0, loss_ald - capital_loss_in_ald)
effective_loss = min_(loss_taxunit, business_loss_in_ald)
p = parameters(period).gov.states.dc.tax.income.additions
addition_taxunit = max_(0, effective_loss - p.self_employment_loss.threshold)
# allocate taxunit addition in proportion to head and spouse losses
Expand All @@ -35,6 +42,4 @@ def formula(person, period, parameters):
mask = loss_taxunit > 0
loss_fraction[mask] = loss_person[mask] / loss_taxunit[mask]
addition_fraction = where(is_joint, loss_fraction, 1)
is_head = person("is_tax_unit_head", period)
is_spouse = person("is_tax_unit_spouse", period)
return (is_head | is_spouse) * addition_taxunit * addition_fraction
return is_head_or_spouse * addition_taxunit * addition_fraction
Loading