From 37608a41d856640b9fc2a97874daaff4d62092d5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 19:38:51 -0400 Subject: [PATCH 1/9] Apply the unearned income filing test only to filers who can be claimed as dependents The federal filing requirement tested every tax unit's unearned income against $500 plus the additional standard deduction. Under 26 U.S.C. 6012(a)(1)(C)(i)(I) and (f)(2)(D) that test applies only to an individual described in section 63(c)(5), and the amount is the section 63(c)(5)(A) amount for the year ($1,350 in 2025), not the unindexed $500. - Apply the test to the head, and on a joint return each spouse, who can be claimed as a dependent on another return. - Read the amount from gov.irs.deductions.standard.dependent.amount and add that person's own additional standard deduction for age or blindness. - Count gross income from every source except wages and self-employment income, so a self-employment loss or a tax unit dependent's wages no longer change the filer's unearned income. Co-Authored-By: Claude Opus 5.5 --- ...fix-filing-requirement-dependents.fixed.md | 1 + ...x_unit_is_required_to_file_dependents.yaml | 614 ++++++++++++++++++ .../gov/irs/tax_unit_is_required_to_file.py | 62 +- 3 files changed, 668 insertions(+), 9 deletions(-) create mode 100644 changelog.d/fix-filing-requirement-dependents.fixed.md create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file_dependents.yaml diff --git a/changelog.d/fix-filing-requirement-dependents.fixed.md b/changelog.d/fix-filing-requirement-dependents.fixed.md new file mode 100644 index 00000000000..f14abcc4a9d --- /dev/null +++ b/changelog.d/fix-filing-requirement-dependents.fixed.md @@ -0,0 +1 @@ +Stop testing every filer's unearned income against $500 in the federal filing requirement (26 U.S.C. 6012): the unearned income test now applies only to a filer who can be claimed as a dependent on another return, uses the section 63(c)(5)(A) amount for the year ($1,350 in 2025) plus that filer's additional standard deduction for age or blindness, and counts gross income other than wages and self-employment income, so a self-employment loss no longer counts as unearned income. diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file_dependents.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file_dependents.yaml new file mode 100644 index 00000000000..ffcb3b3eb79 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file_dependents.yaml @@ -0,0 +1,614 @@ +# Filing requirement for a filer who can be claimed as a dependent on another +# return, and the absence of an unearned income test for everyone else. +# +# 26 U.S.C. 6012(a)(1)(C)(i) and (f), 26 U.S.C. 63(c)(5), and Chart A and +# Chart B of the Form 1040 instructions (Tables 1 and 2 of Publication 501). +# 2025 amounts (Chart B, page 10 of +# https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf): +# Single dependents, under 65 and not blind, must file if +# unearned income was over $1,350, or +# earned income was over $15,750, or +# gross income was more than the larger of $1,350 or earned income (up to +# $15,300) plus $450. +# 65 or older or blind: $3,350, $17,750, and the larger of $3,350 or earned +# income (up to $15,300) plus $2,450. 65 or older and blind: $5,350, +# $19,750, and the larger of $5,350 or earned income plus $4,450. +# Married dependents, 65 or older or blind: unearned income over $2,950. + +# Filers nobody can claim: Chart A, gross income only. + +- name: 2025, a single filer nobody can claim with $600 of interest need not file + period: 2025 + input: + filing_status: SINGLE + age: 40 + taxable_interest_income: 600 + output: + tax_unit_is_required_to_file: false + +- name: 2025, unearned income up to the standard deduction does not require a return + period: 2025 + input: + filing_status: SINGLE + age: 40 + taxable_interest_income: 15_750 + output: + # Chart A: single, under 65, $15,750. + tax_unit_is_required_to_file: false + +- name: 2025, gross income over the standard deduction requires a return + period: 2025 + input: + filing_status: SINGLE + age: 40 + taxable_interest_income: 15_751 + output: + tax_unit_is_required_to_file: true + +- name: 2025, a single filer aged 70 nobody can claim with $3,000 of interest need not file + period: 2025 + input: + filing_status: SINGLE + age: 70 + taxable_interest_income: 3_000 + output: + # Before, the test was $500 plus the $2,000 additional standard deduction. + tax_unit_is_required_to_file: false + +- name: 2025, a self-employment loss is not unearned income + period: 2025 + input: + filing_status: SINGLE + age: 40 + self_employment_income: -10_000 + output: + # Before, gross income (0) less earned income (-10,000) counted as + # $10,000 of unearned income. + tax_unit_is_required_to_file: false + +- name: 2025, the unearned income test reads the filer, not a child in the tax unit + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 10_000 + claimed_as_dependent_on_another_return: true + child: + age: 10 + taxable_interest_income: 5_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: TX + output: + # The parent has no unearned income, and gross income of $10,000 is not + # more than earned income plus $450. The child's interest belongs on the + # child's own return. + tax_unit_is_required_to_file: false + +# Single dependents, under 65 and not blind. + +- name: 2025, single dependent with unearned income of $1,350 need not file + period: 2025 + input: + filing_status: SINGLE + age: 19 + claimed_as_dependent_on_another_return: true + taxable_interest_income: 1_350 + output: + tax_unit_is_required_to_file: false + +- name: 2025, single dependent with unearned income over $1,350 must file + period: 2025 + input: + filing_status: SINGLE + age: 19 + claimed_as_dependent_on_another_return: true + taxable_interest_income: 1_351 + output: + tax_unit_is_required_to_file: true + +- name: 2025, single dependent with earned income of $15,750 need not file + period: 2025 + input: + filing_status: SINGLE + age: 19 + claimed_as_dependent_on_another_return: true + employment_income: 15_750 + output: + tax_unit_is_required_to_file: false + +- name: 2025, single dependent with earned income over $15,750 must file + period: 2025 + input: + filing_status: SINGLE + age: 19 + claimed_as_dependent_on_another_return: true + employment_income: 15_751 + output: + tax_unit_is_required_to_file: true + +- name: 2025, single dependent with gross income equal to earned income plus $450 need not file + period: 2025 + input: + filing_status: SINGLE + age: 19 + claimed_as_dependent_on_another_return: true + employment_income: 3_000 + taxable_interest_income: 450 + output: + # $3,450 is not more than $3,000 + $450. + tax_unit_is_required_to_file: false + +- name: 2025, single dependent with gross income over earned income plus $450 must file + period: 2025 + input: + filing_status: SINGLE + age: 19 + claimed_as_dependent_on_another_return: true + employment_income: 3_000 + taxable_interest_income: 451 + output: + tax_unit_is_required_to_file: true + +- name: 2025, the earned income in the gross income test stops at $15,300 + period: 2025 + input: + filing_status: SINGLE + age: 19 + claimed_as_dependent_on_another_return: true + employment_income: 15_500 + taxable_interest_income: 251 + output: + # $15,751 is more than $15,300 + $450, though not more than + # $15,500 + $450. + tax_unit_is_required_to_file: true + +- name: 2025, the same income does not require a return from a filer nobody can claim + period: 2025 + input: + filing_status: SINGLE + age: 19 + employment_income: 3_000 + taxable_interest_income: 1_351 + output: + tax_unit_is_required_to_file: false + +# Single dependents, 65 or older or blind. + +- name: 2025, single dependent aged 65 with unearned income of $3,350 need not file + period: 2025 + input: + filing_status: SINGLE + age: 65 + claimed_as_dependent_on_another_return: true + taxable_interest_income: 3_350 + output: + tax_unit_is_required_to_file: false + +- name: 2025, single dependent aged 65 with unearned income over $3,350 must file + period: 2025 + input: + filing_status: SINGLE + age: 65 + claimed_as_dependent_on_another_return: true + taxable_interest_income: 3_351 + output: + tax_unit_is_required_to_file: true + +- name: 2025, blind single dependent under 65 with unearned income of $3,350 need not file + period: 2025 + input: + filing_status: SINGLE + age: 30 + is_blind: true + claimed_as_dependent_on_another_return: true + taxable_interest_income: 3_350 + output: + tax_unit_is_required_to_file: false + +- name: 2025, blind single dependent under 65 with unearned income over $3,350 must file + period: 2025 + input: + filing_status: SINGLE + age: 30 + is_blind: true + claimed_as_dependent_on_another_return: true + taxable_interest_income: 3_351 + output: + tax_unit_is_required_to_file: true + +- name: 2025, blind single dependent aged 65 with unearned income of $5,350 need not file + period: 2025 + input: + filing_status: SINGLE + age: 65 + is_blind: true + claimed_as_dependent_on_another_return: true + taxable_interest_income: 5_350 + output: + tax_unit_is_required_to_file: false + +- name: 2025, blind single dependent aged 65 with unearned income over $5,350 must file + period: 2025 + input: + filing_status: SINGLE + age: 65 + is_blind: true + claimed_as_dependent_on_another_return: true + taxable_interest_income: 5_351 + output: + tax_unit_is_required_to_file: true + +- name: 2025, single dependent aged 65 with earned income of $17,750 need not file + period: 2025 + input: + filing_status: SINGLE + age: 65 + claimed_as_dependent_on_another_return: true + employment_income: 17_750 + output: + tax_unit_is_required_to_file: false + +- name: 2025, single dependent aged 65 with earned income over $17,750 must file + period: 2025 + input: + filing_status: SINGLE + age: 65 + claimed_as_dependent_on_another_return: true + employment_income: 17_751 + output: + tax_unit_is_required_to_file: true + +- name: 2025, single dependent aged 65 with gross income equal to earned income plus $2,450 need not file + period: 2025 + input: + filing_status: SINGLE + age: 65 + claimed_as_dependent_on_another_return: true + employment_income: 3_000 + taxable_interest_income: 2_450 + output: + tax_unit_is_required_to_file: false + +- name: 2025, single dependent aged 65 with gross income over earned income plus $2,450 must file + period: 2025 + input: + filing_status: SINGLE + age: 65 + claimed_as_dependent_on_another_return: true + employment_income: 3_000 + taxable_interest_income: 2_451 + output: + tax_unit_is_required_to_file: true + +- name: 2025, blind single dependent aged 65 with earned income of $19,750 need not file + period: 2025 + input: + filing_status: SINGLE + age: 65 + is_blind: true + claimed_as_dependent_on_another_return: true + employment_income: 19_750 + output: + tax_unit_is_required_to_file: false + +- name: 2025, blind single dependent aged 65 with earned income over $19,750 must file + period: 2025 + input: + filing_status: SINGLE + age: 65 + is_blind: true + claimed_as_dependent_on_another_return: true + employment_income: 19_751 + output: + tax_unit_is_required_to_file: true + +# Joint returns: the unearned income test applies to each spouse who can be +# claimed, with that spouse's own income and additional standard deduction. + +- name: 2025, joint return whose spouse can be claimed, unearned income of $1,350 + period: 2025 + input: + people: + head: + age: 40 + spouse: + age: 40 + claimed_as_dependent_on_another_return: true + taxable_interest_income: 1_350 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + tax_unit_is_required_to_file: false + +- name: 2025, joint return whose spouse can be claimed, unearned income over $1,350 + period: 2025 + input: + people: + head: + age: 40 + spouse: + age: 40 + claimed_as_dependent_on_another_return: true + taxable_interest_income: 1_351 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + tax_unit_is_required_to_file: true + +- name: 2025, the other spouse's unearned income is not the claimed spouse's + period: 2025 + input: + people: + head: + age: 40 + taxable_interest_income: 5_000 + spouse: + age: 40 + claimed_as_dependent_on_another_return: true + taxable_interest_income: 1_350 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # Gross income of $6,350 is under the $31,500 joint standard deduction. + tax_unit_is_required_to_file: false + +- name: 2025, joint return whose spouse aged 65 can be claimed, unearned income of $2,950 + period: 2025 + input: + people: + head: + age: 40 + spouse: + age: 65 + claimed_as_dependent_on_another_return: true + taxable_interest_income: 2_950 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # Chart B, married dependents, 65 or older or blind: $1,350 + $1,600. + tax_unit_is_required_to_file: false + +- name: 2025, joint return whose spouse aged 65 can be claimed, unearned income over $2,950 + period: 2025 + input: + people: + head: + age: 40 + spouse: + age: 65 + claimed_as_dependent_on_another_return: true + taxable_interest_income: 2_951 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + tax_unit_is_required_to_file: true + +- name: 2025, the head's age does not raise the claimed spouse's unearned income test + period: 2025 + input: + people: + head: + age: 70 + spouse: + age: 40 + claimed_as_dependent_on_another_return: true + taxable_interest_income: 1_351 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + tax_unit_is_required_to_file: true + +# Married filing separately: any gross income requires a return, as before. + +- name: 2025, a separate filer who can be claimed must file with any gross income + period: 2025 + input: + filing_status: SEPARATE + age: 19 + claimed_as_dependent_on_another_return: true + employment_income: 100 + output: + tax_unit_is_required_to_file: true + +# 2024 (Chart B: $1,300, $14,600, earned income plus $450; 65 or older or +# blind $3,250): https://www.irs.gov/pub/irs-prior/i1040gi--2024.pdf#page=10 + +- name: 2024, single dependent with unearned income of $1,300 need not file + period: 2024 + input: + filing_status: SINGLE + age: 19 + claimed_as_dependent_on_another_return: true + taxable_interest_income: 1_300 + output: + tax_unit_is_required_to_file: false + +- name: 2024, single dependent with unearned income over $1,300 must file + period: 2024 + input: + filing_status: SINGLE + age: 19 + claimed_as_dependent_on_another_return: true + taxable_interest_income: 1_301 + output: + tax_unit_is_required_to_file: true + +- name: 2024, single dependent with earned income of $14,600 need not file + period: 2024 + input: + filing_status: SINGLE + age: 19 + claimed_as_dependent_on_another_return: true + employment_income: 14_600 + output: + tax_unit_is_required_to_file: false + +- name: 2024, single dependent with earned income over $14,600 must file + period: 2024 + input: + filing_status: SINGLE + age: 19 + claimed_as_dependent_on_another_return: true + employment_income: 14_601 + output: + tax_unit_is_required_to_file: true + +- name: 2024, single dependent aged 65 with unearned income of $3,250 need not file + period: 2024 + input: + filing_status: SINGLE + age: 65 + claimed_as_dependent_on_another_return: true + taxable_interest_income: 3_250 + output: + tax_unit_is_required_to_file: false + +- name: 2024, single dependent aged 65 with unearned income over $3,250 must file + period: 2024 + input: + filing_status: SINGLE + age: 65 + claimed_as_dependent_on_another_return: true + taxable_interest_income: 3_251 + output: + tax_unit_is_required_to_file: true + +- name: 2024, a single filer nobody can claim with $600 of interest need not file + period: 2024 + input: + filing_status: SINGLE + age: 40 + taxable_interest_income: 600 + output: + tax_unit_is_required_to_file: false + +# 2026 (Rev. Proc. 2025-32, sec. 4.14: $1,350, $450, $16,100 and $2,050): +# https://www.irs.gov/pub/irs-drop/rp-25-32.pdf#page=18 + +- name: 2026, single dependent with unearned income of $1,350 need not file + period: 2026 + input: + filing_status: SINGLE + age: 19 + claimed_as_dependent_on_another_return: true + taxable_interest_income: 1_350 + output: + tax_unit_is_required_to_file: false + +- name: 2026, single dependent with unearned income over $1,350 must file + period: 2026 + input: + filing_status: SINGLE + age: 19 + claimed_as_dependent_on_another_return: true + taxable_interest_income: 1_351 + output: + tax_unit_is_required_to_file: true + +- name: 2026, single dependent with earned income of $16,100 need not file + period: 2026 + input: + filing_status: SINGLE + age: 19 + claimed_as_dependent_on_another_return: true + employment_income: 16_100 + output: + tax_unit_is_required_to_file: false + +- name: 2026, single dependent with earned income over $16,100 must file + period: 2026 + input: + filing_status: SINGLE + age: 19 + claimed_as_dependent_on_another_return: true + employment_income: 16_101 + output: + tax_unit_is_required_to_file: true + +- name: 2026, single dependent with gross income equal to earned income plus $450 need not file + period: 2026 + input: + filing_status: SINGLE + age: 19 + claimed_as_dependent_on_another_return: true + employment_income: 3_000 + taxable_interest_income: 450 + output: + tax_unit_is_required_to_file: false + +- name: 2026, single dependent with gross income over earned income plus $450 must file + period: 2026 + input: + filing_status: SINGLE + age: 19 + claimed_as_dependent_on_another_return: true + employment_income: 3_000 + taxable_interest_income: 451 + output: + tax_unit_is_required_to_file: true + +- name: 2026, single dependent aged 65 with unearned income of $3,400 need not file + period: 2026 + input: + filing_status: SINGLE + age: 65 + claimed_as_dependent_on_another_return: true + taxable_interest_income: 3_400 + output: + # $1,350 + $2,050. + tax_unit_is_required_to_file: false + +- name: 2026, single dependent aged 65 with unearned income over $3,400 must file + period: 2026 + input: + filing_status: SINGLE + age: 65 + claimed_as_dependent_on_another_return: true + taxable_interest_income: 3_401 + output: + tax_unit_is_required_to_file: true + +- name: 2026, a single filer nobody can claim with $600 of interest need not file + period: 2026 + input: + filing_status: SINGLE + age: 40 + taxable_interest_income: 600 + output: + tax_unit_is_required_to_file: false diff --git a/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py b/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py index 5c38c142427..71106879388 100644 --- a/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py +++ b/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py @@ -13,6 +13,12 @@ class tax_unit_is_required_to_file(Variable): definition_period = YEAR reference = ( "https://www.law.cornell.edu/uscode/text/26/6012", + # A filer who can be claimed as a dependent: section 6012(a)(1)(C)(i) + # and (f)(2)(D), the section 63(c)(5) limited standard deduction, and + # Publication 501, Table 2 (Chart B of the Form 1040 instructions). + "https://www.law.cornell.edu/uscode/text/26/6012#a_1_C", + "https://www.law.cornell.edu/uscode/text/26/63#c_5", + "https://www.irs.gov/pub/irs-prior/p501--2025.pdf#page=4", # Gross income counts amounts excluded under section 911. "https://www.law.cornell.edu/uscode/text/26/6012#c", ) @@ -39,16 +45,54 @@ def formula(tax_unit, period, parameters): income_over_exemption_amount = gross_income > threshold - # (a)(1)(C) - unearned income threshold for dependents - unearned_income_threshold = 500 + tax_unit( - "additional_standard_deduction", period + # A filer who can be claimed as a dependent on another return (an + # individual described in section 63(c)(5)) must also file when their + # income other than earned income is over the section 63(c)(5)(A) + # amount plus their own additional standard deduction for age or + # blindness: section 6012(a)(1)(C)(i)(I) and Publication 501, Table 2. + # On a joint return the test applies to each spouse who can be claimed + # (section 6012(f)(2)(D)). Nobody else has an unearned income test. + # + # Table 2's other two tests need no code here. The standard deduction + # in the threshold above is already the limited one for these filers + # (basic_standard_deduction): the greater of the section 63(c)(5)(A) + # amount or earned income plus the section 63(c)(5)(B) amount, capped + # at the regular amount, plus the additional standard deduction. That + # is Table 2's gross income test, and earned income over the regular + # amount plus the additional standard deduction always meets it. + person = tax_unit.members + head_can_be_claimed = tax_unit("head_is_dependent_elsewhere", period) + spouse_can_be_claimed = tax_unit("spouse_is_dependent_elsewhere", period) + can_be_claimed = ( + person("is_tax_unit_head", period) & tax_unit.project(head_can_be_claimed) + ) | ( + person("is_tax_unit_spouse", period) + & tax_unit.project(spouse_can_be_claimed) ) - # The section 911 exclusion covers earned income only. - unearned_income = ( - gross_income - - add(tax_unit, period, ["earned_income"]) - - section_911_exclusion + # Income other than earned income: every gross income source except + # wages and self-employment income, the items in earned_income. As in + # irs_gross_income, a loss in one source does not reduce the total. + earned_income_sources = [ + "irs_employment_income", + "self_employment_income", + "sstb_self_employment_income", + ] + unearned_income = 0 + for source in parameters(period).gov.irs.gross_income.sources: + if source not in earned_income_sources: + unearned_income += max_(0, add(person, period, [source])) + p_standard = parameters(period).gov.irs.deductions.standard + aged_or_blind_count = person("is_irs_aged", period).astype(int) + person( + "is_blind", period + ).astype(int) + additional_standard_deduction = aged_or_blind_count * tax_unit.project( + p_standard.aged_or_blind.amount[tax_unit("filing_status", period)] + ) + unearned_income_threshold = ( + p_standard.dependent.amount + additional_standard_deduction + ) + unearned_income_over_threshold = tax_unit.any( + can_be_claimed & (unearned_income > unearned_income_threshold) ) - unearned_income_over_threshold = unearned_income > unearned_income_threshold return income_over_exemption_amount | unearned_income_over_threshold From cd87bf88f50f426b2a6f88eb0f6fb19f4172720c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 20:11:45 -0400 Subject: [PATCH 2/9] Add invariant tests and boundary cases for the dependent filing requirement - Hypothesis properties: split invariance for filers nobody can claim, monotonicity in income, claimability never removing the requirement, and tax unit dependents' income never mattering. - A boundary grid checked against Publication 501 (2025), Table 2, computed directly from its published amounts, and against medicaid_person_is_required_to_file, the other Table 2 implementation. - Keep the YAML cases a dollar away from the two thresholds where the statute and the IRS charts differ at the exact dollar, and say in the formula that the joint return test follows Table 2. Co-Authored-By: Claude Opus 5.5 --- ...filing_requirement_dependent_invariants.py | 470 ++++++++++++++++++ ...x_unit_is_required_to_file_dependents.yaml | 33 +- .../gov/irs/tax_unit_is_required_to_file.py | 7 +- 3 files changed, 503 insertions(+), 7 deletions(-) create mode 100644 policyengine_us/tests/core/test_filing_requirement_dependent_invariants.py diff --git a/policyengine_us/tests/core/test_filing_requirement_dependent_invariants.py b/policyengine_us/tests/core/test_filing_requirement_dependent_invariants.py new file mode 100644 index 00000000000..79866be1b85 --- /dev/null +++ b/policyengine_us/tests/core/test_filing_requirement_dependent_invariants.py @@ -0,0 +1,470 @@ +"""Invariants for the filing requirement of a filer who can be claimed. + +`tax_unit_is_required_to_file` gives a filer who can be claimed as a dependent +on another return the three tests of Publication 501 (2025), Table 2 (Chart B +of the Form 1040 instructions; 26 USC 6012(a)(1)(C)(i) and (f), 63(c)(5)). It +codes only the unearned income test. The earned income and gross income tests +come from comparing gross income with `standard_deduction`, which is already +the limited section 63(c)(5) deduction for these filers. Nobody else has an +unearned income test. YAML cases pin the published amounts one case at a time; +these tests check what must hold for every input, which YAML cannot state. + +`test_table_2_boundary_grid` runs one simulation of single filers on a grid of +earned and unearned income at every Table 2 threshold, a dollar under and a +dollar over, for each combination of age 65 and blindness: + +1. Differential: for a filer who can be claimed, the result equals Table 2's + three tests computed directly from its published 2025 amounts. This is the + check that the two tests with no code of their own are still applied. +2. Differential: it also equals `medicaid_person_is_required_to_file`, the + other Table 2 implementation, which serves Medicaid and ACA household + income for dependents inside a tax unit. +3. For a filer nobody can claim, the result depends on gross income alone: it + is the same however that income is split between wages and interest. +4. On a joint return with no other income, where one spouse can be claimed, + the result is Table 2's unearned income test for a married dependent, with + that spouse's own age and blindness and not the other spouse's. + +`test_filing_requirement_properties` draws batches of tax units with +Hypothesis (single, head of household, surviving spouse, separate and joint; +any mix of age, blindness, claimability, wages, self-employment income or +loss, interest and dividends) and runs each batch, with four variants of every +unit, as one simulation for 2025 and 2026: + +5. Split invariance: moving a filer's wages into interest, or interest into + wages, never changes the result for a tax unit whose head and spouse cannot + be claimed. +6. Monotonicity: more wages, interest or self-employment profit never turns + "required" into "not required", for any tax unit. +7. Claimability never removes the requirement: marking the head as claimable + never turns "required" into "not required" (filers who are not blind; see + below). +8. A tax unit dependent's wages and interest never change the result: that + income belongs on the dependent's own return. +9. Table 2 and the Medicaid implementation, as in 1 and 2, on the drawn + single filers who can be claimed and have no self-employment income. +10. Lower bound: on a single, separate or joint return, a head or spouse who + can be claimed and whose interest and dividends are over Table 2's + unearned income amount for them always makes the return required. + +Intended exceptions, each from the law rather than the code: + +- Monotonicity does not hold for a smaller self-employment loss. A loss does + not reduce gross income ("don't reduce your income by any losses", Form 1040 + instructions, Chart A) but it does reduce the earned income that sets the + limited standard deduction, so a smaller loss raises the threshold and not + the income. `test_smaller_loss_can_end_the_requirement` pins the case. +- Property 7 leaves out blind filers. The model counts the additional + standard deduction for blindness in every filer's threshold. Chart A does + not (only age), while Table 2 does, so on the charts a blind filer under 65 + with wages between the two amounts must file only if nobody can claim them. + The property holds in the model today, and leaving blind filers out keeps it + true under either reading. +- Properties 1, 2 and 9 use wages as the only earned income. Earned income for + the limited deduction is net of the deductible part of self-employment tax + on the IRS worksheet, which Table 2 does not spell out. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +YEARS = (2025, 2026) + +# Publication 501 (2025), Table 2, single dependents: +# https://www.irs.gov/pub/irs-prior/p501--2025.pdf#page=4 +TABLE_2_FLOOR = 1_350 +TABLE_2_EARNED_INCOME_ADDITION = 450 +TABLE_2_EARNED_INCOME_LIMIT = 15_750 +TABLE_2_AGED_OR_BLIND_ADDITION = 2_000 +# The same table, married dependents: $2,950 less $1,350. +TABLE_2_MARRIED_AGED_OR_BLIND_ADDITION = 1_600 + +ADULT_INCOME_INPUTS = [ + "employment_income", + "self_employment_income", + "taxable_interest_income", + "qualified_dividend_income", +] +VARIANTS = ["base", "split", "raised", "claimed", "with_child"] + + +def table_2_single_dependent(earned, unearned, aged, blind): + """Table 2's three tests for a single dependent, from its 2025 amounts.""" + additional = TABLE_2_AGED_OR_BLIND_ADDITION * (aged.astype(int) + blind.astype(int)) + gross = earned + unearned + unearned_test = unearned > TABLE_2_FLOOR + additional + earned_test = earned > TABLE_2_EARNED_INCOME_LIMIT + additional + gross_test = gross > np.maximum( + TABLE_2_FLOOR + additional, + np.minimum(earned, TABLE_2_EARNED_INCOME_LIMIT - TABLE_2_EARNED_INCOME_ADDITION) + + TABLE_2_EARNED_INCOME_ADDITION + + additional, + ) + return unearned_test | earned_test | gross_test + + +def simulate(units): + """One simulation of independent tax units. + + Each unit is {"filing_status": name, "adults": [...], "children": [...]}, + with one dict of person inputs per member. Every unit states its filing + status: an input given to one tax unit is an input for all of them, so a + unit left without one would take the default, SINGLE, not the status the + model would work out. + """ + people, tax_units, households = {}, {}, {} + groups = {"marital_units": {}, "spm_units": {}, "families": {}} + for index, unit in enumerate(units): + adults = [f"adult_{index}_{n}" for n in range(len(unit["adults"]))] + children = [f"child_{index}_{n}" for n in range(len(unit["children"]))] + members = adults + children + for name, inputs in zip(members, unit["adults"] + unit["children"]): + people[name] = { + variable: {year: value for year in YEARS} + for variable, value in inputs.items() + } + tax_units[f"tax_unit_{index}"] = { + "members": members, + "filing_status": {year: unit["filing_status"] for year in YEARS}, + } + households[f"household_{index}"] = { + "members": members, + "state_code": {year: "TX" for year in YEARS}, + } + for name in ("spm_units", "families"): + groups[name][f"{name}_{index}"] = {"members": members} + groups["marital_units"][f"marital_unit_{index}"] = {"members": adults} + for child in children: + groups["marital_units"][f"marital_unit_{child}"] = {"members": [child]} + return Simulation( + situation={ + "people": people, + "tax_units": tax_units, + "households": households, + **groups, + } + ) + + +def single_filer(age, blind, claimed, wages, interest): + return { + "filing_status": "SINGLE", + "adults": [ + { + "age": age, + "is_blind": blind, + "claimed_as_dependent_on_another_return": claimed, + "employment_income": wages, + "taxable_interest_income": interest, + } + ], + "children": [], + } + + +def head_values(units, name): + return np.array([unit["adults"][0].get(name, 0) for unit in units]) + + +def head_medicaid_requirement(simulation, units, year): + """`medicaid_person_is_required_to_file` for each unit's head.""" + values = simulation.calculate("medicaid_person_is_required_to_file", year) + first = np.cumsum([0] + [len(u["adults"]) + len(u["children"]) for u in units]) + return np.asarray(values)[first[:-1]] + + +def unearned_test_met(units): + """Whether Table 2's unearned income test is met by a head or spouse who + can be claimed, for single, separate and joint returns (2025 amounts).""" + met = [] + for unit in units: + married = unit["filing_status"] in ("SEPARATE", "JOINT") + per_condition = ( + TABLE_2_MARRIED_AGED_OR_BLIND_ADDITION + if married + else TABLE_2_AGED_OR_BLIND_ADDITION + ) + met.append( + unit["filing_status"] in ("SINGLE", "SEPARATE", "JOINT") + and any( + adult["claimed_as_dependent_on_another_return"] + and adult["taxable_interest_income"] + + adult.get("qualified_dividend_income", 0) + > TABLE_2_FLOOR + + per_condition * ((adult["age"] >= 65) + adult["is_blind"]) + for adult in unit["adults"] + ) + ) + return np.array(met) + + +def assert_table_2(simulation, units, required, rows, year): + """Properties 1 and 2 (and 9) on the given rows of single claimable filers.""" + if not rows.any(): + return + np.testing.assert_array_equal( + required[rows], + head_medicaid_requirement(simulation, units, year)[rows], + err_msg=f"{year}: differs from medicaid_person_is_required_to_file", + ) + if year != 2025: + return + expected = table_2_single_dependent( + earned=head_values(units, "employment_income"), + unearned=head_values(units, "taxable_interest_income") + + head_values(units, "qualified_dividend_income"), + aged=head_values(units, "age") >= 65, + blind=head_values(units, "is_blind").astype(bool), + ) + np.testing.assert_array_equal( + required[rows], expected[rows], err_msg="2025: differs from Table 2" + ) + + +# Every Table 2 threshold for a single dependent, a dollar under and over. +EARNED_GRID = [0, 449, 899, 900, 901, 3_000] +EARNED_GRID += [15_299, 15_300, 15_301, 15_749, 15_750, 15_751] +EARNED_GRID += [17_749, 17_750, 17_751, 19_749, 19_750, 19_751] +UNEARNED_GRID = [0, 1, 449, 450, 451, 1_349, 1_350, 1_351] +UNEARNED_GRID += [2_449, 2_450, 2_451, 3_349, 3_350, 3_351] +UNEARNED_GRID += [4_449, 4_450, 4_451, 5_349, 5_350, 5_351] +GROSS_GRID = [600, 1_351, 5_351, 15_749, 15_751, 17_751, 19_751, 21_751, 40_000] + + +def test_table_2_boundary_grid(): + claimable = [ + single_filer(age, blind, True, wages, interest) + for age in (19, 65) + for blind in (False, True) + for wages in EARNED_GRID + for interest in UNEARNED_GRID + ] + # Four ways to hold the same gross income, for a filer nobody can claim. + splits = [ + (age, blind, gross, wages) + for age in (40, 65) + for blind in (False, True) + for gross in GROSS_GRID + for wages in (gross, 0, gross // 2, gross - 600) + ] + unclaimable = [ + single_filer(age, blind, False, wages, gross - wages) + for age, blind, gross, wages in splits + ] + # Joint returns whose younger spouse can be claimed and has only interest. + joint = [ + { + "filing_status": "JOINT", + "adults": [ + { + "age": head_age, + "is_blind": head_blind, + "claimed_as_dependent_on_another_return": False, + "taxable_interest_income": 0, + }, + { + "age": spouse_age, + "is_blind": spouse_blind, + "claimed_as_dependent_on_another_return": True, + "taxable_interest_income": interest, + }, + ], + "children": [], + } + for head_age, spouse_age in ((45, 40), (70, 40), (70, 65), (80, 65)) + for head_blind in (False, True) + for spouse_blind in (False, True) + for interest in UNEARNED_GRID + ] + units = claimable + unclaimable + joint + simulation = simulate(units) + is_claimable = np.arange(len(units)) < len(claimable) + is_joint = np.arange(len(units)) >= len(claimable) + len(unclaimable) + is_unclaimable = ~is_claimable & ~is_joint + for year in YEARS: + required = np.asarray( + simulation.calculate("tax_unit_is_required_to_file", year) + ) + if year == 2025: + # Property 4. + np.testing.assert_array_equal( + required[is_joint], unearned_test_met(units)[is_joint] + ) + # Properties 1 and 2. + assert_table_2(simulation, units, required, is_claimable, year) + # Property 3: one answer for each gross income, whatever the split. + by_split = required[is_unclaimable].reshape(-1, 4) + assert (by_split == by_split[:, :1]).all(), year + # Both answers occur, so the property is not vacuous. + assert by_split.any() and not by_split.all(), year + # The grid reaches both answers of each Table 2 test. + assert required[is_claimable].any() and not required[is_claimable].all() + + +boundary = st.sampled_from(sorted(set(EARNED_GRID + UNEARNED_GRID + [31_499, 31_501]))) +amount = st.one_of(st.just(0), boundary, st.integers(0, 45_000)) +loss = st.integers(-20_000, -1) + + +@st.composite +def adults(draw): + return { + "age": draw(st.sampled_from([19, 40, 64, 65, 80])), + "is_blind": draw(st.booleans()), + "claimed_as_dependent_on_another_return": draw(st.booleans()), + "employment_income": draw(amount), + "self_employment_income": draw(st.one_of(st.just(0), amount, loss)), + "taxable_interest_income": draw(amount), + "qualified_dividend_income": draw(st.one_of(st.just(0), amount)), + } + + +@st.composite +def unit_variants(draw): + """A drawn tax unit and its four variants, in VARIANTS order.""" + status = draw( + st.sampled_from( + ["SINGLE", "HEAD_OF_HOUSEHOLD", "SURVIVING_SPOUSE", "SEPARATE", "JOINT"] + ) + ) + base = { + "filing_status": status, + "adults": [draw(adults()) for _ in range(2 if status == "JOINT" else 1)], + "children": [], + } + + def variant(change): + copy = {**base, "adults": [dict(adult) for adult in base["adults"]]} + change(copy) + return copy + + who = draw(st.integers(0, len(base["adults"]) - 1)) + + # Property 5: move part of one filer's wages into interest, or back. + moved = draw(st.integers(1, 20_000)) + to_interest = draw(st.booleans()) + + def split(unit): + adult = unit["adults"][who] + source, target = "employment_income", "taxable_interest_income" + if not to_interest: + source, target = target, source + shift = min(moved, adult[source]) + adult[source] -= shift + adult[target] += shift + + # Property 6: more of one nonnegative source. A smaller loss is the + # intended exception, so a loss is left alone. + raised_source = draw(st.sampled_from(ADULT_INCOME_INPUTS)) + increase = draw(st.integers(1, 20_000)) + + def raise_income(unit): + adult = unit["adults"][who] + source = raised_source + if adult[source] < 0: + source = "taxable_interest_income" + adult[source] += increase + + # Property 7. + def claim_head(unit): + # The older adult is the head; mark both when the ages tie. + oldest = max(adult["age"] for adult in unit["adults"]) + for adult in unit["adults"]: + if adult["age"] == oldest: + adult["claimed_as_dependent_on_another_return"] = True + + # Property 8. + child = { + "age": draw(st.integers(0, 17)), + "employment_income": draw(amount), + "taxable_interest_income": draw(amount), + } + + def add_child(unit): + unit["children"] = [child] + + return [ + base, + variant(split), + variant(raise_income), + variant(claim_head), + variant(add_child), + ] + + +@settings( + max_examples=6, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(st.lists(unit_variants(), min_size=8, max_size=24)) +def test_filing_requirement_properties(drawn): + units = [unit for variants in drawn for unit in variants] + simulation = simulate(units) + anyone_claimable = np.array( + [ + any(a["claimed_as_dependent_on_another_return"] for a in unit["adults"]) + for unit in units + ] + ).reshape(-1, len(VARIANTS)) + anyone_blind = np.array( + [any(adult["is_blind"] for adult in unit["adults"]) for unit in units] + ).reshape(-1, len(VARIANTS)) + # Properties 1, 2 and 9 apply to a single filer who can be claimed and has + # wages as their only earned income. + table_2_rows = np.array( + [ + unit["filing_status"] == "SINGLE" + and unit["adults"][0]["claimed_as_dependent_on_another_return"] + and unit["adults"][0]["self_employment_income"] == 0 + for unit in units + ] + ) + for year in YEARS: + flat = np.asarray(simulation.calculate("tax_unit_is_required_to_file", year)) + required = dict(zip(VARIANTS, flat.reshape(-1, len(VARIANTS)).T)) + nobody_claimable = ~anyone_claimable[:, 0] + # Property 5. + np.testing.assert_array_equal( + required["split"][nobody_claimable], + required["base"][nobody_claimable], + err_msg=f"{year}: the earned/unearned split changed the result", + ) + # Property 6. + assert not (required["base"] & ~required["raised"]).any(), ( + f"{year}: more income ended the requirement" + ) + # Property 7. + sighted = ~anyone_blind[:, 0] + assert not (required["base"] & ~required["claimed"])[sighted].any(), ( + f"{year}: being claimable ended the requirement" + ) + # Property 8. + np.testing.assert_array_equal( + required["with_child"], + required["base"], + err_msg=f"{year}: a tax unit dependent's income changed the result", + ) + # Property 9. + assert_table_2(simulation, units, flat, table_2_rows, year) + # Property 10. + if year == 2025: + assert flat[unearned_test_met(units)].all(), ( + "2025: a claimable filer over the unearned income amount need not file" + ) + + +def test_smaller_loss_can_end_the_requirement(): + # Intended exception to monotonicity. With $2,000 of wages and a $5,000 + # loss, earned income is negative, the limited standard deduction is the + # $1,350 floor, and $2,000 of gross income is over it. With a $100 loss, + # earned income is $1,900 and the limited deduction is $2,350. + units = [single_filer(19, False, True, 2_000, 0) for _ in range(2)] + units[0]["adults"][0]["self_employment_income"] = -5_000 + units[1]["adults"][0]["self_employment_income"] = -100 + required = simulate(units).calculate("tax_unit_is_required_to_file", 2025) + assert required.tolist() == [True, False] diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file_dependents.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file_dependents.yaml index ffcb3b3eb79..7e76d514895 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file_dependents.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file_dependents.yaml @@ -3,6 +3,8 @@ # # 26 U.S.C. 6012(a)(1)(C)(i) and (f), 26 U.S.C. 63(c)(5), and Chart A and # Chart B of the Form 1040 instructions (Tables 1 and 2 of Publication 501). +# Section 6012(f) governs 2018 to 2025 and section 6012(a)(1) governs 2026. +# For an unmarried filer who can be claimed, both give Chart B's result. # 2025 amounts (Chart B, page 10 of # https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf): # Single dependents, under 65 and not blind, must file if @@ -15,7 +17,10 @@ # $19,750, and the larger of $5,350 or earned income plus $4,450. # Married dependents, 65 or older or blind: unearned income over $2,950. -# Filers nobody can claim: Chart A, gross income only. +# Filers nobody can claim: gross income only. The cases stay a dollar away +# from the threshold itself, where Chart A ("at least") and section 6012(f)(1) +# ("does not exceed the standard deduction") differ; this change does not +# touch that comparison. - name: 2025, a single filer nobody can claim with $600 of interest need not file period: 2025 @@ -26,12 +31,12 @@ output: tax_unit_is_required_to_file: false -- name: 2025, unearned income up to the standard deduction does not require a return +- name: 2025, unearned income under the standard deduction does not require a return period: 2025 input: filing_status: SINGLE age: 40 - taxable_interest_income: 15_750 + taxable_interest_income: 15_749 output: # Chart A: single, under 65, $15,750. tax_unit_is_required_to_file: false @@ -178,6 +183,19 @@ output: tax_unit_is_required_to_file: false +- name: 2025, a self-employment loss does not add to a single dependent's unearned income + period: 2025 + input: + filing_status: SINGLE + age: 19 + claimed_as_dependent_on_another_return: true + self_employment_income: -10_000 + taxable_interest_income: 1_000 + output: + # Unearned income is the $1,000 of interest, not gross income less the + # negative earned income ($11,000). + tax_unit_is_required_to_file: false + # Single dependents, 65 or older or blind. - name: 2025, single dependent aged 65 with unearned income of $3,350 need not file @@ -520,6 +538,11 @@ # 2026 (Rev. Proc. 2025-32, sec. 4.14: $1,350, $450, $16,100 and $2,050): # https://www.irs.gov/pub/irs-drop/rp-25-32.pdf#page=18 +# Section 6012(f) ends with 2025, so section 6012(a)(1) applies. Its unearned +# income and gross income tests for these filers say "in excess of", as Chart +# B does. Its earned income threshold comes from (a)(1)(A)(i), which says +# "less than", and the 2026 instructions are not yet published, so the earned +# income cases stay a dollar away from $16,100. - name: 2026, single dependent with unearned income of $1,350 need not file period: 2026 @@ -541,13 +564,13 @@ output: tax_unit_is_required_to_file: true -- name: 2026, single dependent with earned income of $16,100 need not file +- name: 2026, single dependent with earned income under $16,100 need not file period: 2026 input: filing_status: SINGLE age: 19 claimed_as_dependent_on_another_return: true - employment_income: 16_100 + employment_income: 16_099 output: tax_unit_is_required_to_file: false diff --git a/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py b/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py index 71106879388..c3c9f45210e 100644 --- a/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py +++ b/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py @@ -50,8 +50,11 @@ def formula(tax_unit, period, parameters): # income other than earned income is over the section 63(c)(5)(A) # amount plus their own additional standard deduction for age or # blindness: section 6012(a)(1)(C)(i)(I) and Publication 501, Table 2. - # On a joint return the test applies to each spouse who can be claimed - # (section 6012(f)(2)(D)). Nobody else has an unearned income test. + # On a joint return the test applies to each spouse who can be claimed. + # Section 6012(f)(2)(D), which governs joint returns for 2018 to 2025, + # names the section 63(c)(5)(A) amount alone; Table 2 adds the + # additional standard deduction, and this follows Table 2. Nobody else + # has an unearned income test. # # Table 2's other two tests need no code here. The standard deduction # in the threshold above is already the limited one for these filers From a9ed8bdd0e3078353522281fb3033e4e64820d46 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 21:05:39 -0400 Subject: [PATCH 3/9] Keep the joint return attribution case valid under the limited joint deduction When a spouse can be claimed, the Standard Deduction Worksheet for Dependents limits the joint standard deduction, which PR #10024 adopts. The case now isolates the per-spouse unearned income test under either deduction. Co-Authored-By: Claude Opus 5.5 --- .../irs/tax_unit_is_required_to_file_dependents.yaml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file_dependents.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file_dependents.yaml index 7e76d514895..7102389df46 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file_dependents.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file_dependents.yaml @@ -374,12 +374,12 @@ input: people: head: - age: 40 - taxable_interest_income: 5_000 + age: 70 + taxable_interest_income: 1_500 spouse: age: 40 claimed_as_dependent_on_another_return: true - taxable_interest_income: 1_350 + taxable_interest_income: 1_000 tax_units: tax_unit: members: [head, spouse] @@ -388,7 +388,11 @@ members: [head, spouse] state_code: TX output: - # Gross income of $6,350 is under the $31,500 joint standard deduction. + # The claimed spouse's unearned income is $1,000, not over $1,350. The + # couple's $2,500 would be. Gross income of $2,500 is also under the + # joint standard deduction, whether that is the regular $31,500 plus + # $1,600 or the limited $1,350 plus $1,600 that the Standard Deduction + # Worksheet for Dependents gives when a spouse can be claimed. tax_unit_is_required_to_file: false - name: 2025, joint return whose spouse aged 65 can be claimed, unearned income of $2,950 From 1a5dd0586af9bf6d8a1fe461b4a6827c4f028a1d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 21:09:19 -0400 Subject: [PATCH 4/9] Keep the 2026 dependent cases off the exact-dollar boundary Section 6012(a)(1)(A) excuses a filer only when gross income is less than the basic standard deduction, while Chart B says more than; the 2026 instructions are not yet out. Co-Authored-By: Claude Opus 5.5 --- ...x_unit_is_required_to_file_dependents.yaml | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file_dependents.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file_dependents.yaml index 7102389df46..3affc163f9c 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file_dependents.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file_dependents.yaml @@ -543,18 +543,20 @@ # 2026 (Rev. Proc. 2025-32, sec. 4.14: $1,350, $450, $16,100 and $2,050): # https://www.irs.gov/pub/irs-drop/rp-25-32.pdf#page=18 # Section 6012(f) ends with 2025, so section 6012(a)(1) applies. Its unearned -# income and gross income tests for these filers say "in excess of", as Chart -# B does. Its earned income threshold comes from (a)(1)(A)(i), which says -# "less than", and the 2026 instructions are not yet published, so the earned -# income cases stay a dollar away from $16,100. - -- name: 2026, single dependent with unearned income of $1,350 need not file +# income and gross income tests for these filers, in (a)(1)(C)(i), say "in +# excess of", as Chart B does. But a filer who meets neither is excused only +# by (a)(1)(A)(i), whose gross income must be "less than" the basic standard +# deduction, so on the literal text a return is due at exactly the limited +# deduction. The 2026 instructions are not yet published, so every 2026 case +# stays a dollar away from a threshold. + +- name: 2026, single dependent with unearned income under $1,350 need not file period: 2026 input: filing_status: SINGLE age: 19 claimed_as_dependent_on_another_return: true - taxable_interest_income: 1_350 + taxable_interest_income: 1_349 output: tax_unit_is_required_to_file: false @@ -588,14 +590,14 @@ output: tax_unit_is_required_to_file: true -- name: 2026, single dependent with gross income equal to earned income plus $450 need not file +- name: 2026, single dependent with gross income under earned income plus $450 need not file period: 2026 input: filing_status: SINGLE age: 19 claimed_as_dependent_on_another_return: true employment_income: 3_000 - taxable_interest_income: 450 + taxable_interest_income: 449 output: tax_unit_is_required_to_file: false @@ -610,13 +612,13 @@ output: tax_unit_is_required_to_file: true -- name: 2026, single dependent aged 65 with unearned income of $3,400 need not file +- name: 2026, single dependent aged 65 with unearned income under $3,400 need not file period: 2026 input: filing_status: SINGLE age: 65 claimed_as_dependent_on_another_return: true - taxable_interest_income: 3_400 + taxable_interest_income: 3_399 output: # $1,350 + $2,050. tax_unit_is_required_to_file: false From a9e58977ffbbb30f667afe523270e5f71bf6f4c1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 23:19:52 -0400 Subject: [PATCH 5/9] Address review round 1: earned sources parameter, open points kept out of tests - Move the earned income sources of the unearned income test into gov.irs.income.filing_requirement.dependent.earned_income_sources, citing the IRS dependent worksheet, and count Schedule F farm income as earned. A test checks that every listed source is a gross income source. - Stop pinning decision d1395: drop the 2025 case of a claimed spouse aged 65 at exactly $2,950 and the case of a claimable filer filing separately, limit the joint grid to claimed spouses under 65 and not blind, and compare with medicaid_person_is_required_to_file only in 2025. - Start every Hypothesis batch with a unit that must file and one that need not, and assert each property has rows to check. - Drop the duplicate section 6012 reference entry. Co-Authored-By: Claude Opus 5.5 --- ...fix-filing-requirement-dependents.fixed.md | 2 +- .../dependent/earned_income_sources.yaml | 24 ++++ ...filing_requirement_dependent_invariants.py | 126 +++++++++++++----- ...x_unit_is_required_to_file_dependents.yaml | 44 ++---- .../gov/irs/tax_unit_is_required_to_file.py | 23 ++-- 5 files changed, 134 insertions(+), 85 deletions(-) create mode 100644 policyengine_us/parameters/gov/irs/income/filing_requirement/dependent/earned_income_sources.yaml diff --git a/changelog.d/fix-filing-requirement-dependents.fixed.md b/changelog.d/fix-filing-requirement-dependents.fixed.md index f14abcc4a9d..48846578166 100644 --- a/changelog.d/fix-filing-requirement-dependents.fixed.md +++ b/changelog.d/fix-filing-requirement-dependents.fixed.md @@ -1 +1 @@ -Stop testing every filer's unearned income against $500 in the federal filing requirement (26 U.S.C. 6012): the unearned income test now applies only to a filer who can be claimed as a dependent on another return, uses the section 63(c)(5)(A) amount for the year ($1,350 in 2025) plus that filer's additional standard deduction for age or blindness, and counts gross income other than wages and self-employment income, so a self-employment loss no longer counts as unearned income. +Stop testing every filer's unearned income against $500 in the federal filing requirement (26 U.S.C. 6012): the unearned income test now applies only to a head or spouse who can be claimed as a dependent on another return, uses the section 63(c)(5)(A) amount for the year ($1,350 in 2025) plus that person's own additional standard deduction for age or blindness, and counts that person's gross income other than wages, business income and farm income (the earned income of the IRS dependent worksheet), so a self-employment loss no longer counts as unearned income. diff --git a/policyengine_us/parameters/gov/irs/income/filing_requirement/dependent/earned_income_sources.yaml b/policyengine_us/parameters/gov/irs/income/filing_requirement/dependent/earned_income_sources.yaml new file mode 100644 index 00000000000..22410a45918 --- /dev/null +++ b/policyengine_us/parameters/gov/irs/income/filing_requirement/dependent/earned_income_sources.yaml @@ -0,0 +1,24 @@ +description: The Internal Revenue Service counts these gross income sources as earned income when it tests the unearned income of a filer who can be claimed as a dependent; every other gross income source is unearned income. +values: + 2010-01-01: + # Form 1040 line 1z (wages). + - irs_employment_income + # Schedule 1 line 3 (business income, Schedule C). + - self_employment_income + - sstb_self_employment_income + # Schedule 1 line 6 (farm income, Schedule F). + - farm_operations_income +metadata: + unit: list + period: year + label: IRS earned income sources for the dependent unearned income filing test + reference: + - title: 26 U.S. Code § 6012(a)(1)(C)(i)(I) and (f)(2)(D) - income other than earned income + href: https://www.law.cornell.edu/uscode/text/26/6012 + # "Generally, your earned income is the total of the amount(s) you + # reported on Form 1040 or 1040-SR, line 1z, and Schedule 1, lines 3, 6, + # 8r, 8t, and 8u minus the amount, if any, on Schedule 1, line 15." + # Schedule 1 line 5 (rents, royalties, partnerships, S corporations and + # trusts) is not on the list. + - title: 2025 Form 1040 instructions, Standard Deduction Worksheet for Dependents + href: https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=35 diff --git a/policyengine_us/tests/core/test_filing_requirement_dependent_invariants.py b/policyengine_us/tests/core/test_filing_requirement_dependent_invariants.py index 79866be1b85..406fbfa2f48 100644 --- a/policyengine_us/tests/core/test_filing_requirement_dependent_invariants.py +++ b/policyengine_us/tests/core/test_filing_requirement_dependent_invariants.py @@ -9,6 +9,11 @@ unearned income test. YAML cases pin the published amounts one case at a time; these tests check what must hold for every input, which YAML cannot state. +Where the statute and the IRS charts differ (decision d1395: the age and +blindness addition in the 2018-2025 joint test, a dependent filing +separately, and in 2026 blindness and the exact dollar), the tests below +assert nothing that depends on the difference. + `test_table_2_boundary_grid` runs one simulation of single filers on a grid of earned and unearned income at every Table 2 threshold, a dollar under and a dollar over, for each combination of age 65 and blindness: @@ -16,20 +21,26 @@ 1. Differential: for a filer who can be claimed, the result equals Table 2's three tests computed directly from its published 2025 amounts. This is the check that the two tests with no code of their own are still applied. -2. Differential: it also equals `medicaid_person_is_required_to_file`, the - other Table 2 implementation, which serves Medicaid and ACA household - income for dependents inside a tax unit. +2. Differential: in 2025 it also equals `medicaid_person_is_required_to_file`, + the other Table 2 implementation, which serves Medicaid and ACA household + income for dependents inside a tax unit. In 2026 the rule is section + 6012(a)(1), whose treatment of blindness and the exact dollar is open, so + the two are not compared there. 3. For a filer nobody can claim, the result depends on gross income alone: it is the same however that income is split between wages and interest. -4. On a joint return with no other income, where one spouse can be claimed, - the result is Table 2's unearned income test for a married dependent, with - that spouse's own age and blindness and not the other spouse's. +4. On a 2025 joint return with no other income, where the spouse who can be + claimed is under 65 and not blind, the result is the unearned income test + (over $1,350), whatever the other spouse's age and blindness. (For a + claimed spouse who is 65 or older or blind, section 6012(f)(2)(D) and + Table 2 differ; that is d1395.) `test_filing_requirement_properties` draws batches of tax units with Hypothesis (single, head of household, surviving spouse, separate and joint; any mix of age, blindness, claimability, wages, self-employment income or loss, interest and dividends) and runs each batch, with four variants of every -unit, as one simulation for 2025 and 2026: +unit, as one simulation for 2025 and 2026. Every batch starts with two fixed +units, one that must file and one that need not, so that each property below +has rows to check and both answers occur; the test asserts that it does. 5. Split invariance: moving a filer's wages into interest, or interest into wages, never changes the result for a tax unit whose head and spouse cannot @@ -43,9 +54,17 @@ income belongs on the dependent's own return. 9. Table 2 and the Medicaid implementation, as in 1 and 2, on the drawn single filers who can be claimed and have no self-employment income. -10. Lower bound: on a single, separate or joint return, a head or spouse who - can be claimed and whose interest and dividends are over Table 2's - unearned income amount for them always makes the return required. +10. Lower bound (2025): on a single, separate or joint return, a head or + spouse who can be claimed and whose interest and dividends are over + Table 2's unearned income amount for them always makes the return + required. Table 2's amount is never below the statute's, so this holds + under either reading of d1395. + +`test_earned_income_sources_are_gross_income_sources`: + +11. Every earned income source of the unearned income test is a gross income + source. Otherwise a renamed earned source would silently count as + unearned income. Intended exceptions, each from the law rather than the code: @@ -70,6 +89,7 @@ from hypothesis import strategies as st from policyengine_us import Simulation +from policyengine_us.system import system as SYSTEM YEARS = (2025, 2026) @@ -202,16 +222,16 @@ def unearned_test_met(units): def assert_table_2(simulation, units, required, rows, year): - """Properties 1 and 2 (and 9) on the given rows of single claimable filers.""" - if not rows.any(): + """Properties 1 and 2 (and 9) on the given rows of single claimable filers, + in 2025.""" + assert rows.any(), "no single claimable filer to compare" + if year != 2025: return np.testing.assert_array_equal( required[rows], head_medicaid_requirement(simulation, units, year)[rows], err_msg=f"{year}: differs from medicaid_person_is_required_to_file", ) - if year != 2025: - return expected = table_2_single_dependent( earned=head_values(units, "employment_income"), unearned=head_values(units, "taxable_interest_income") @@ -254,7 +274,8 @@ def test_table_2_boundary_grid(): single_filer(age, blind, False, wages, gross - wages) for age, blind, gross, wages in splits ] - # Joint returns whose younger spouse can be claimed and has only interest. + # Joint returns whose younger spouse, under 65 and not blind, can be + # claimed and has only interest. joint = [ { "filing_status": "JOINT", @@ -266,17 +287,16 @@ def test_table_2_boundary_grid(): "taxable_interest_income": 0, }, { - "age": spouse_age, - "is_blind": spouse_blind, + "age": 40, + "is_blind": False, "claimed_as_dependent_on_another_return": True, "taxable_interest_income": interest, }, ], "children": [], } - for head_age, spouse_age in ((45, 40), (70, 40), (70, 65), (80, 65)) + for head_age in (45, 70, 80) for head_blind in (False, True) - for spouse_blind in (False, True) for interest in UNEARNED_GRID ] units = claimable + unclaimable + joint @@ -290,9 +310,9 @@ def test_table_2_boundary_grid(): ) if year == 2025: # Property 4. - np.testing.assert_array_equal( - required[is_joint], unearned_test_met(units)[is_joint] - ) + expected = unearned_test_met(units)[is_joint] + np.testing.assert_array_equal(required[is_joint], expected) + assert expected.any() and not expected.all() # Properties 1 and 2. assert_table_2(simulation, units, required, is_claimable, year) # Property 3: one answer for each gross income, whatever the split. @@ -322,19 +342,33 @@ def adults(draw): } +# Fixed units at the start of every batch: a single filer who can be claimed +# with $5,000 of interest (must file; meets every property's row filter) and a +# single filer nobody can claim with $600 of interest (need not file). +ANCHORS = [ + single_filer(19, False, True, 0, 5_000), + single_filer(40, False, False, 0, 600), +] +for anchor in ANCHORS: + anchor["adults"][0].update(self_employment_income=0, qualified_dividend_income=0) + + @st.composite -def unit_variants(draw): - """A drawn tax unit and its four variants, in VARIANTS order.""" - status = draw( - st.sampled_from( - ["SINGLE", "HEAD_OF_HOUSEHOLD", "SURVIVING_SPOUSE", "SEPARATE", "JOINT"] +def unit_variants(draw, fixed=None): + """A tax unit (drawn, or `fixed`) and its four variants, in VARIANTS order.""" + if fixed is None: + status = draw( + st.sampled_from( + ["SINGLE", "HEAD_OF_HOUSEHOLD", "SURVIVING_SPOUSE", "SEPARATE", "JOINT"] + ) ) - ) - base = { - "filing_status": status, - "adults": [draw(adults()) for _ in range(2 if status == "JOINT" else 1)], - "children": [], - } + base = { + "filing_status": status, + "adults": [draw(adults()) for _ in range(2 if status == "JOINT" else 1)], + "children": [], + } + else: + base = {**fixed, "adults": [dict(adult) for adult in fixed["adults"]]} def variant(change): copy = {**base, "adults": [dict(adult) for adult in base["adults"]]} @@ -401,9 +435,12 @@ def add_child(unit): derandomize=True, suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], ) -@given(st.lists(unit_variants(), min_size=8, max_size=24)) -def test_filing_requirement_properties(drawn): - units = [unit for variants in drawn for unit in variants] +@given( + st.tuples(*(unit_variants(fixed=anchor) for anchor in ANCHORS)), + st.lists(unit_variants(), min_size=6, max_size=22), +) +def test_filing_requirement_properties(anchors, drawn): + units = [unit for variants in [*anchors, *drawn] for unit in variants] simulation = simulate(units) anyone_claimable = np.array( [ @@ -434,10 +471,12 @@ def test_filing_requirement_properties(drawn): required["base"][nobody_claimable], err_msg=f"{year}: the earned/unearned split changed the result", ) + assert nobody_claimable.any() # Property 6. assert not (required["base"] & ~required["raised"]).any(), ( f"{year}: more income ended the requirement" ) + assert required["base"].any() and not required["base"].all() # Property 7. sighted = ~anyone_blind[:, 0] assert not (required["base"] & ~required["claimed"])[sighted].any(), ( @@ -453,11 +492,24 @@ def test_filing_requirement_properties(drawn): assert_table_2(simulation, units, flat, table_2_rows, year) # Property 10. if year == 2025: - assert flat[unearned_test_met(units)].all(), ( + met = unearned_test_met(units) + assert met.any() + assert flat[met].all(), ( "2025: a claimable filer over the unearned income amount need not file" ) +def test_earned_income_sources_are_gross_income_sources(): + # Property 11. + for year in YEARS: + parameters = SYSTEM.parameters(f"{year}-01-01").gov.irs + earned = list( + parameters.income.filing_requirement.dependent.earned_income_sources + ) + gross = list(parameters.gross_income.sources) + assert earned and set(earned) <= set(gross), set(earned) - set(gross) + + def test_smaller_loss_can_end_the_requirement(): # Intended exception to monotonicity. With $2,000 of wages and a $5,000 # loss, earned income is negative, the limited standard deduction is the diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file_dependents.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file_dependents.yaml index 3affc163f9c..f2afb20aed4 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file_dependents.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file_dependents.yaml @@ -4,7 +4,11 @@ # 26 U.S.C. 6012(a)(1)(C)(i) and (f), 26 U.S.C. 63(c)(5), and Chart A and # Chart B of the Form 1040 instructions (Tables 1 and 2 of Publication 501). # Section 6012(f) governs 2018 to 2025 and section 6012(a)(1) governs 2026. -# For an unmarried filer who can be claimed, both give Chart B's result. +# For an unmarried filer who can be claimed, section 6012(f)(1) gives Chart +# B's result. Where the statute and the charts differ (decision d1395: the +# age and blindness addition in the 2018-2025 joint test, a dependent filing +# separately, and in 2026 blindness and the exact dollar), the cases stay +# clear of the difference. # 2025 amounts (Chart B, page 10 of # https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf): # Single dependents, under 65 and not blind, must file if @@ -15,7 +19,8 @@ # 65 or older or blind: $3,350, $17,750, and the larger of $3,350 or earned # income (up to $15,300) plus $2,450. 65 or older and blind: $5,350, # $19,750, and the larger of $5,350 or earned income plus $4,450. -# Married dependents, 65 or older or blind: unearned income over $2,950. +# Married dependents, 65 or older or blind: unearned income over $2,950 +# (section 6012(f)(2)(D): over $1,350, with no addition). # Filers nobody can claim: gross income only. The cases stay a dollar away # from the threshold itself, where Chart A ("at least") and section 6012(f)(1) @@ -395,28 +400,9 @@ # Worksheet for Dependents gives when a spouse can be claimed. tax_unit_is_required_to_file: false -- name: 2025, joint return whose spouse aged 65 can be claimed, unearned income of $2,950 - period: 2025 - input: - people: - head: - age: 40 - spouse: - age: 65 - claimed_as_dependent_on_another_return: true - taxable_interest_income: 2_950 - tax_units: - tax_unit: - members: [head, spouse] - households: - household: - members: [head, spouse] - state_code: TX - output: - # Chart B, married dependents, 65 or older or blind: $1,350 + $1,600. - tax_unit_is_required_to_file: false - - name: 2025, joint return whose spouse aged 65 can be claimed, unearned income over $2,950 + # Required under both readings: over $1,350 + $1,600 (Chart B) and over + # $1,350 (section 6012(f)(2)(D)). At $2,950 or less they differ (d1395). period: 2025 input: people: @@ -456,18 +442,6 @@ output: tax_unit_is_required_to_file: true -# Married filing separately: any gross income requires a return, as before. - -- name: 2025, a separate filer who can be claimed must file with any gross income - period: 2025 - input: - filing_status: SEPARATE - age: 19 - claimed_as_dependent_on_another_return: true - employment_income: 100 - output: - tax_unit_is_required_to_file: true - # 2024 (Chart B: $1,300, $14,600, earned income plus $450; 65 or older or # blind $3,250): https://www.irs.gov/pub/irs-prior/i1040gi--2024.pdf#page=10 diff --git a/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py b/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py index c3c9f45210e..512f2c32b26 100644 --- a/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py +++ b/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py @@ -14,9 +14,9 @@ class tax_unit_is_required_to_file(Variable): reference = ( "https://www.law.cornell.edu/uscode/text/26/6012", # A filer who can be claimed as a dependent: section 6012(a)(1)(C)(i) - # and (f)(2)(D), the section 63(c)(5) limited standard deduction, and - # Publication 501, Table 2 (Chart B of the Form 1040 instructions). - "https://www.law.cornell.edu/uscode/text/26/6012#a_1_C", + # and (f)(2)(D) (the section above), the section 63(c)(5) limited + # standard deduction, and Publication 501, Table 2 (Chart B of the + # Form 1040 instructions). "https://www.law.cornell.edu/uscode/text/26/63#c_5", "https://www.irs.gov/pub/irs-prior/p501--2025.pdf#page=4", # Gross income counts amounts excluded under section 911. @@ -53,8 +53,8 @@ def formula(tax_unit, period, parameters): # On a joint return the test applies to each spouse who can be claimed. # Section 6012(f)(2)(D), which governs joint returns for 2018 to 2025, # names the section 63(c)(5)(A) amount alone; Table 2 adds the - # additional standard deduction, and this follows Table 2. Nobody else - # has an unearned income test. + # additional standard deduction, and this follows Table 2 (open as + # decision d1395). Nobody else has an unearned income test. # # Table 2's other two tests need no code here. The standard deduction # in the threshold above is already the limited one for these filers @@ -73,13 +73,12 @@ def formula(tax_unit, period, parameters): & tax_unit.project(spouse_can_be_claimed) ) # Income other than earned income: every gross income source except - # wages and self-employment income, the items in earned_income. As in - # irs_gross_income, a loss in one source does not reduce the total. - earned_income_sources = [ - "irs_employment_income", - "self_employment_income", - "sstb_self_employment_income", - ] + # the earned income sources of the IRS dependent worksheet (wages, + # business income and farm income). As in irs_gross_income, a loss in + # one source does not reduce the total. + earned_income_sources = parameters( + period + ).gov.irs.income.filing_requirement.dependent.earned_income_sources unearned_income = 0 for source in parameters(period).gov.irs.gross_income.sources: if source not in earned_income_sources: From e69b6dc57020fafa43e0ec9567fe820b1cfd0aca Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 22:55:30 -0400 Subject: [PATCH 6/9] Apply the 6012(a)(1) filing requirement from 2026 Section 6012(f) covers only taxable years 2018 through 2025, and Pub. L. 119-21 does not amend section 6012, so section 6012(a)(1) governs from 2026. For a tax unit nobody can claim as a dependent, a return is required when gross income is at least the basic standard deduction plus one additional standard deduction for each spouse aged 65 or older. Blindness does not raise the threshold. A new boolean parameter switches between the two rules by year. Tax units whose head or spouse can be claimed keep the standard deduction test (decision d1395). Co-Authored-By: Claude Opus 5.5 --- ...ix-filing-requirement-6012a1-2026.fixed.md | 1 + .../standard_deduction_rule/applies.yaml | 12 + .../gov/irs/tax_unit_is_required_to_file.yaml | 430 ++++++++++++++++++ .../gov/irs/tax_unit_is_required_to_file.py | 76 +++- 4 files changed, 505 insertions(+), 14 deletions(-) create mode 100644 changelog.d/fix-filing-requirement-6012a1-2026.fixed.md create mode 100644 policyengine_us/parameters/gov/irs/income/filing_requirement/standard_deduction_rule/applies.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file.yaml diff --git a/changelog.d/fix-filing-requirement-6012a1-2026.fixed.md b/changelog.d/fix-filing-requirement-6012a1-2026.fixed.md new file mode 100644 index 00000000000..e2be297b9d9 --- /dev/null +++ b/changelog.d/fix-filing-requirement-6012a1-2026.fixed.md @@ -0,0 +1 @@ +Apply the 26 U.S.C. 6012(a)(1) federal filing requirement from 2026, when section 6012(f) expires: for a tax unit nobody can claim as a dependent, the threshold is the basic standard deduction plus the additions for age but not blindness, and gross income equal to it requires a return. diff --git a/policyengine_us/parameters/gov/irs/income/filing_requirement/standard_deduction_rule/applies.yaml b/policyengine_us/parameters/gov/irs/income/filing_requirement/standard_deduction_rule/applies.yaml new file mode 100644 index 00000000000..c0ad170af25 --- /dev/null +++ b/policyengine_us/parameters/gov/irs/income/filing_requirement/standard_deduction_rule/applies.yaml @@ -0,0 +1,12 @@ +description: The Internal Revenue Service applies the section 6012(f) filing requirement, which compares gross income with the standard deduction, instead of section 6012(a)(1), if this is true. +values: + 2013-01-01: false + 2018-01-01: true + 2026-01-01: false +metadata: + unit: bool + period: year + label: IRS section 6012(f) filing requirement applies + reference: + - title: 26 U.S. Code § 6012(a)(1) and (f) + href: https://www.law.cornell.edu/uscode/text/26/6012#f diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file.yaml new file mode 100644 index 00000000000..f7f2db16f28 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file.yaml @@ -0,0 +1,430 @@ +# Federal filing requirement for a tax unit nobody can claim as a dependent. +# +# 2018 to 2025, 26 U.S.C. 6012(f): no return is required of an unmarried +# individual whose gross income "does not exceed the standard deduction +# applicable to such individual ... under section 63", or of a couple whose +# combined gross income does not exceed the joint standard deduction. The +# standard deduction includes the additional standard deduction for age and +# for blindness (section 63(c)(1) and (f)). +# +# From 2026, 26 U.S.C. 6012(a)(1): subsection (f) covers only taxable years +# "before January 1, 2026", and Pub. L. 119-21 does not amend section 6012. +# No return is required when gross income is "less than" the exemption amount +# (zero, section 151(d)(5)) plus the basic standard deduction. Subparagraph +# (B) raises that by one additional standard deduction for each spouse aged +# 65 or older, "by reason of section 63(f)(1)", and not for blindness. A +# married individual filing separately has no exception. +# https://www.law.cornell.edu/uscode/text/26/6012 +# +# 2026 amounts, Rev. Proc. 2025-32, sec. 4.14: basic standard deduction +# $16,100 single, $32,200 joint and surviving spouse, $24,150 head of +# household; additional standard deduction $1,650, or $2,050 if unmarried and +# not a surviving spouse. https://www.irs.gov/pub/irs-drop/rp-25-32.pdf#page=18 +# Chart A of the 2017 Form 1040 instructions, the last year under (a)(1), has +# the same form: age only, and "at least". +# https://www.irs.gov/pub/irs-prior/i1040gi--2017.pdf#page=8 +# +# 2025 amounts, Rev. Proc. 2024-40 as amended by Pub. L. 119-21 sec. 70102: +# $15,750 single, $31,500 joint, $23,625 head of household; additional +# standard deduction $1,600, or $2,000 if unmarried and not a surviving spouse. + +- name: Case 1, 2026, single filer under 65 with gross income a dollar under $16,100. + period: 2026 + input: + filing_status: SINGLE + age: 40 + taxable_interest_income: 16_099 + output: + tax_unit_is_required_to_file: false + +- name: Case 2, 2026, single filer under 65 with gross income of exactly $16,100. + period: 2026 + input: + filing_status: SINGLE + age: 40 + taxable_interest_income: 16_100 + output: + # (a)(1)(A)(i): excused only if gross income is less than $16,100. + tax_unit_is_required_to_file: true + +- name: Case 3, 2026, blind single filer under 65 with gross income of $16,100. + period: 2026 + input: + filing_status: SINGLE + age: 40 + is_blind: true + employment_income: 16_100 + output: + # Blindness does not raise the (a)(1) threshold. + tax_unit_is_required_to_file: true + +- name: Case 4, 2026, blind single filer under 65 with gross income a dollar under $16,100. + period: 2026 + input: + filing_status: SINGLE + age: 40 + is_blind: true + employment_income: 16_099 + output: + tax_unit_is_required_to_file: false + +- name: Case 5, 2026, single filer aged 65 with gross income a dollar under $18,150. + period: 2026 + input: + filing_status: SINGLE + age: 65 + taxable_interest_income: 18_149 + output: + # $16,100 + $2,050. + tax_unit_is_required_to_file: false + +- name: Case 6, 2026, single filer aged 65 with gross income of exactly $18,150. + period: 2026 + input: + filing_status: SINGLE + age: 65 + taxable_interest_income: 18_150 + output: + tax_unit_is_required_to_file: true + +- name: Case 7, 2026, blind single filer aged 65 gets only the age addition. + period: 2026 + input: + filing_status: SINGLE + age: 70 + is_blind: true + taxable_interest_income: 18_150 + output: + # $16,100 + $2,050 for age; nothing for blindness. + tax_unit_is_required_to_file: true + +- name: Case 8, 2026, joint return under 65 with combined gross income a dollar under $32,200. + period: 2026 + input: + people: + person1: + age: 40 + employment_income: 20_000 + person2: + age: 40 + taxable_interest_income: 12_199 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + filing_status: JOINT + tax_unit_is_required_to_file: false + +- name: Case 9, 2026, joint return under 65 with combined gross income of exactly $32,200. + period: 2026 + input: + people: + person1: + age: 40 + employment_income: 20_000 + person2: + age: 40 + taxable_interest_income: 12_200 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + # (a)(1)(A)(iv): excused only if combined gross income is less than + # $32,200. + tax_unit_is_required_to_file: true + +- name: Case 10, 2026, joint return with one spouse aged 65 and gross income of $33,849. + period: 2026 + input: + people: + person1: + age: 67 + taxable_interest_income: 33_849 + person2: + age: 60 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + # $32,200 + $1,650. + tax_unit_is_required_to_file: false + +- name: Case 11, 2026, joint return with one spouse aged 65 and gross income of $33,850. + period: 2026 + input: + people: + person1: + age: 67 + taxable_interest_income: 33_850 + person2: + age: 60 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + tax_unit_is_required_to_file: true + +- name: Case 12, 2026, joint return with both spouses aged 65 and gross income of $35,499. + period: 2026 + input: + people: + person1: + age: 70 + taxable_interest_income: 35_499 + person2: + age: 68 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + # $32,200 + 2 x $1,650. + tax_unit_is_required_to_file: false + +- name: Case 13, 2026, joint return with both spouses aged 65 and blind at $35,500. + period: 2026 + input: + people: + person1: + age: 70 + is_blind: true + taxable_interest_income: 35_500 + person2: + age: 68 + is_blind: true + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + # Two age additions only. The standard deduction is $38,800 with the two + # blindness additions, which (a)(1)(B) leaves out. + standard_deduction: 38_800 + tax_unit_is_required_to_file: true + +- name: Case 14, 2026, head of household aged 65 with gross income of $26,199. + period: 2026 + input: + filing_status: HEAD_OF_HOUSEHOLD + age: 66 + taxable_interest_income: 26_199 + output: + # $24,150 + $2,050. + tax_unit_is_required_to_file: false + +- name: Case 15, 2026, head of household aged 65 with gross income of $26,200. + period: 2026 + input: + filing_status: HEAD_OF_HOUSEHOLD + age: 66 + taxable_interest_income: 26_200 + output: + tax_unit_is_required_to_file: true + +- name: Case 16, 2026, surviving spouse aged 65 with gross income of $33,849. + period: 2026 + input: + filing_status: SURVIVING_SPOUSE + age: 66 + taxable_interest_income: 33_849 + output: + # $32,200 + $1,650: a surviving spouse takes the married addition. + tax_unit_is_required_to_file: false + +- name: Case 17, 2026, surviving spouse aged 65 with gross income of $33,850. + period: 2026 + input: + filing_status: SURVIVING_SPOUSE + age: 66 + taxable_interest_income: 33_850 + output: + tax_unit_is_required_to_file: true + +- name: Case 18, 2026, married individual filing separately with $1 of gross income. + period: 2026 + input: + filing_status: SEPARATE + age: 70 + is_blind: true + taxable_interest_income: 1 + output: + # The spouse files a separate return, so (a)(1)(A)(iv) does not apply, + # and the exemption amount is zero. Age and blindness do not matter. + tax_unit_is_required_to_file: true + +- name: Case 19, 2026, married individual filing separately with no gross income. + period: 2026 + input: + filing_status: SEPARATE + age: 40 + output: + tax_unit_is_required_to_file: false + +- name: Case 20, 2025, single filer under 65 with gross income of exactly $15,750. + period: 2025 + input: + filing_status: SINGLE + age: 40 + taxable_interest_income: 15_750 + output: + # (f)(1): gross income does not exceed the standard deduction. Chart A of + # the 2025 instructions says "at least" $15,750 (decision DECISION_ID). + tax_unit_is_required_to_file: false + +- name: Case 21, 2025, single filer under 65 with gross income of $15,751. + period: 2025 + input: + filing_status: SINGLE + age: 40 + taxable_interest_income: 15_751 + output: + tax_unit_is_required_to_file: true + +- name: Case 22, 2025, blind single filer under 65 with gross income of $17,750. + period: 2025 + input: + filing_status: SINGLE + age: 40 + is_blind: true + employment_income: 17_750 + output: + # (f)(1): the standard deduction is $15,750 + $2,000 for blindness. + # Chart A of the 2025 instructions lists only age (decision DECISION_ID). + tax_unit_is_required_to_file: false + +- name: Case 23, 2025, blind single filer under 65 with gross income of $17,751. + period: 2025 + input: + filing_status: SINGLE + age: 40 + is_blind: true + employment_income: 17_751 + output: + tax_unit_is_required_to_file: true + +- name: Case 24, 2025, joint return with both spouses aged 65 and blind at $37,900. + period: 2025 + input: + people: + person1: + age: 70 + is_blind: true + taxable_interest_income: 37_900 + person2: + age: 68 + is_blind: true + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + # (f)(2)(A): $31,500 + 4 x $1,600. + tax_unit_is_required_to_file: false + +- name: Case 25, 2025, joint return with both spouses aged 65 and blind at $37,901. + period: 2025 + input: + people: + person1: + age: 70 + is_blind: true + taxable_interest_income: 37_901 + person2: + age: 68 + is_blind: true + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + tax_unit_is_required_to_file: true + +- name: Case 26, 2025, married individual filing separately with $1 of gross income. + period: 2025 + input: + filing_status: SEPARATE + age: 70 + is_blind: true + taxable_interest_income: 1 + output: + # (f) excuses only unmarried individuals and joint filers. + tax_unit_is_required_to_file: true + +- name: Case 27, 2025, married individual filing separately with no gross income. + period: 2025 + input: + filing_status: SEPARATE + age: 40 + output: + tax_unit_is_required_to_file: false + +# A tax unit whose head or spouse can be claimed as a dependent keeps the +# standard deduction test from 2026 too: (a)(1)(C)(i)(II), "total gross income +# in excess of the standard deduction", which is what Chart B applies. +# Whether the (A) threshold, which leaves out blindness and requires a return +# at equality, also binds these filers is decision d1395. These cases pin the +# model's reading until it is ruled. + +- name: Case 28, 2026, blind single filer who can be claimed keeps the blindness addition. + period: 2026 + input: + filing_status: SINGLE + age: 19 + is_blind: true + claimed_as_dependent_on_another_return: true + employment_income: 17_000 + output: + # Limited standard deduction min($16,100, $17,000 + $450) + $2,050 = + # $18,150, over gross income. Nobody can claim the filer in Case 3. + standard_deduction: 18_150 + tax_unit_is_required_to_file: false + +- name: Case 29, 2026, joint return whose spouse can be claimed, at exactly the joint amount. + period: 2026 + input: + people: + person1: + age: 40 + employment_income: 32_200 + person2: + age: 40 + claimed_as_dependent_on_another_return: true + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + # Gross income does not exceed the $32,200 standard deduction. Compare + # Case 9, where nobody can be claimed. + tax_unit_is_required_to_file: false diff --git a/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py b/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py index 512f2c32b26..d2ff75a3bf5 100644 --- a/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py +++ b/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py @@ -8,11 +8,23 @@ class tax_unit_is_required_to_file(Variable): documentation = """ Whether this tax unit is legally required to file a federal income tax return under IRC § 6012, based on gross income thresholds and filing - status. + status. For 2018 to 2025, section 6012(f) compares gross income with the + standard deduction. In other years, section 6012(a)(1) compares it with + the basic standard deduction plus the additions for age, but not + blindness, and gross income equal to that amount requires a return. A + tax unit whose head or spouse can be claimed as a dependent keeps the + standard deduction test in every year. """ definition_period = YEAR reference = ( "https://www.law.cornell.edu/uscode/text/26/6012", + # From 2026 (and before 2018): section 6012(a)(1)(A) and (B), and + # Chart A of the Form 1040 instructions for 2017, the last year it + # governed. + "https://www.law.cornell.edu/uscode/text/26/6012#a_1", + "https://www.irs.gov/pub/irs-prior/i1040gi--2017.pdf#page=8", + # 2018 to 2025: section 6012(f). + "https://www.law.cornell.edu/uscode/text/26/6012#f", # A filer who can be claimed as a dependent: section 6012(a)(1)(C)(i) # and (f)(2)(D) (the section above), the section 63(c)(5) limited # standard deduction, and Publication 501, Table 2 (Chart B of the @@ -30,20 +42,58 @@ def formula(tax_unit, period, parameters): gross_income = add(tax_unit, period, ["irs_gross_income"]) + ( section_911_exclusion ) - p = parameters(period).gov.irs.income.exemption - exemption_amount = 0 if p.suspended else p.amount + p = parameters(period).gov.irs + exemption_amount = ( + 0 if p.income.exemption.suspended else p.income.exemption.amount + ) + filing_status = tax_unit("filing_status", period) + separate = filing_status == filing_status.possible_values.SEPARATE + head_can_be_claimed = tax_unit("head_is_dependent_elsewhere", period) + spouse_can_be_claimed = tax_unit("spouse_is_dependent_elsewhere", period) - # (a)(1)(A), (a)(1)(B) - filing_status = tax_unit("filing_status", period).decode_to_str() - separate = filing_status == "SEPARATE" - standard_deduction = tax_unit("standard_deduction", period) + # Section 6012(f), for 2018 to 2025: no return is required when gross + # income does not exceed the standard deduction, which includes the + # additional standard deduction for age and for blindness. A married + # individual filing separately must file with any gross income. threshold = where( separate, exemption_amount, - standard_deduction + exemption_amount, + tax_unit("standard_deduction", period) + exemption_amount, ) + income_over_standard_deduction = gross_income > threshold - income_over_exemption_amount = gross_income > threshold + # Section 6012(a)(1)(A) and (B), from 2026 (and before 2018): no return + # is required when gross income is less than the exemption amount for + # each spouse plus the basic standard deduction, raised by one + # additional standard deduction for each spouse aged 65 or older + # (section 63(f)(1)). Blindness does not raise it, and gross income + # equal to it requires a return. A married individual filing + # separately has no exception. + aged_count = tax_unit("aged_head", period).astype(int) + tax_unit( + "aged_spouse", period + ).astype(int) + basic_threshold = ( + exemption_amount * tax_unit("head_spouse_count", period) + + tax_unit("basic_standard_deduction", period) + + aged_count * p.deductions.standard.aged_or_blind.amount[filing_status] + ) + income_at_least_basic_threshold = (gross_income > 0) & ( + gross_income >= where(separate, exemption_amount, basic_threshold) + ) + + # A tax unit whose head or spouse can be claimed as a dependent keeps + # the standard deduction test in every year. From 2026 it is + # (a)(1)(C)(i)(II), and it is what Chart B applies under (a)(1); the + # (A) threshold, which leaves out blindness and requires a return at + # equality, is not applied to these filers (decision d1395). + if p.income.filing_requirement.standard_deduction_rule.applies: + income_over_threshold = income_over_standard_deduction + else: + income_over_threshold = where( + head_can_be_claimed | spouse_can_be_claimed, + income_over_standard_deduction, + income_at_least_basic_threshold, + ) # A filer who can be claimed as a dependent on another return (an # individual described in section 63(c)(5)) must also file when their @@ -64,8 +114,6 @@ def formula(tax_unit, period, parameters): # is Table 2's gross income test, and earned income over the regular # amount plus the additional standard deduction always meets it. person = tax_unit.members - head_can_be_claimed = tax_unit("head_is_dependent_elsewhere", period) - spouse_can_be_claimed = tax_unit("spouse_is_dependent_elsewhere", period) can_be_claimed = ( person("is_tax_unit_head", period) & tax_unit.project(head_can_be_claimed) ) | ( @@ -83,12 +131,12 @@ def formula(tax_unit, period, parameters): for source in parameters(period).gov.irs.gross_income.sources: if source not in earned_income_sources: unearned_income += max_(0, add(person, period, [source])) - p_standard = parameters(period).gov.irs.deductions.standard + p_standard = p.deductions.standard aged_or_blind_count = person("is_irs_aged", period).astype(int) + person( "is_blind", period ).astype(int) additional_standard_deduction = aged_or_blind_count * tax_unit.project( - p_standard.aged_or_blind.amount[tax_unit("filing_status", period)] + p_standard.aged_or_blind.amount[filing_status] ) unearned_income_threshold = ( p_standard.dependent.amount + additional_standard_deduction @@ -97,4 +145,4 @@ def formula(tax_unit, period, parameters): can_be_claimed & (unearned_income > unearned_income_threshold) ) - return income_over_exemption_amount | unearned_income_over_threshold + return income_over_threshold | unearned_income_over_threshold From 3d434a601c91ad8682e03cfda8cadeb8618e6e48 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 23:04:34 -0400 Subject: [PATCH 7/9] Add property tests for the 6012(a)(1) and 6012(f) filing thresholds A boundary grid over every filing status and combination of age 65 and blindness checks the model against both rules computed from the published 2025 and 2026 amounts. Hypothesis properties check blindness, age, the parameter switch, and that claimable units keep their 2026 result. The dependent invariants' claimability property now skips the 2026 threshold dollar, where 6012(a)(1)(A) requires a return and the claimable-filer test does not (d1395); a pinned test shows the case. The 2025 YAML cases cite decision d1403. Co-Authored-By: Claude Opus 5.5 --- ...filing_requirement_dependent_invariants.py | 56 ++- .../test_filing_requirement_invariants.py | 372 ++++++++++++++++++ .../gov/irs/tax_unit_is_required_to_file.yaml | 4 +- 3 files changed, 419 insertions(+), 13 deletions(-) create mode 100644 policyengine_us/tests/core/test_filing_requirement_invariants.py diff --git a/policyengine_us/tests/core/test_filing_requirement_dependent_invariants.py b/policyengine_us/tests/core/test_filing_requirement_dependent_invariants.py index 406fbfa2f48..803d6ec7bd6 100644 --- a/policyengine_us/tests/core/test_filing_requirement_dependent_invariants.py +++ b/policyengine_us/tests/core/test_filing_requirement_dependent_invariants.py @@ -73,12 +73,18 @@ instructions, Chart A) but it does reduce the earned income that sets the limited standard deduction, so a smaller loss raises the threshold and not the income. `test_smaller_loss_can_end_the_requirement` pins the case. -- Property 7 leaves out blind filers. The model counts the additional - standard deduction for blindness in every filer's threshold. Chart A does - not (only age), while Table 2 does, so on the charts a blind filer under 65 - with wages between the two amounts must file only if nobody can claim them. - The property holds in the model today, and leaving blind filers out keeps it - true under either reading. +- Property 7 leaves out blind filers. Chart A does not count blindness (only + age), while Table 2 does, so on the charts a blind filer under 65 with + wages between the two amounts must file only if nobody can claim them. From + 2026 the model follows Chart A here: section 6012(a)(1)(B) raises the + threshold of a filer nobody can claim for age only, while a filer who can + be claimed keeps the standard deduction test (decision d1395). +- From 2026, property 7 also fails at one dollar: gross income equal to the + threshold of a sighted filer nobody can claim. Section 6012(a)(1)(A) + excuses that filer only if gross income is "less than" the threshold, while + a filer who can be claimed is tested with "in excess of" (d1395). The + property skips those units, and + `test_claimability_can_end_the_requirement_at_the_threshold` pins the case. - Properties 1, 2 and 9 use wages as the only earned income. Earned income for the limited deduction is net of the deductible part of self-employment tax on the IRS worksheet, which Table 2 does not spell out. @@ -126,8 +132,8 @@ def table_2_single_dependent(earned, unearned, aged, blind): return unearned_test | earned_test | gross_test -def simulate(units): - """One simulation of independent tax units. +def simulate(units, **options): + """One simulation of independent tax units; options go to Simulation. Each unit is {"filing_status": name, "adults": [...], "children": [...]}, with one dict of person inputs per member. Every unit states its filing @@ -165,7 +171,8 @@ def simulate(units): "tax_units": tax_units, "households": households, **groups, - } + }, + **options, ) @@ -189,6 +196,13 @@ def head_values(units, name): return np.array([unit["adults"][0].get(name, 0) for unit in units]) +def unit_totals(simulation, units, variable, year): + """A person variable summed over the members of each unit.""" + values = np.asarray(simulation.calculate(variable, year)) + sizes = [len(unit["adults"]) + len(unit["children"]) for unit in units] + return np.add.reduceat(values, np.cumsum([0] + sizes[:-1])) + + def head_medicaid_requirement(simulation, units, year): """`medicaid_person_is_required_to_file` for each unit's head.""" values = simulation.calculate("medicaid_person_is_required_to_file", year) @@ -477,9 +491,18 @@ def test_filing_requirement_properties(anchors, drawn): f"{year}: more income ended the requirement" ) assert required["base"].any() and not required["base"].all() - # Property 7. + # Property 7, except at the 2026 threshold (see the docstring). sighted = ~anyone_blind[:, 0] - assert not (required["base"] & ~required["claimed"])[sighted].any(), ( + ended = required["base"] & ~required["claimed"] + if year >= 2026: + gross_income = unit_totals( + simulation, units, "irs_gross_income", year + ).reshape(-1, len(VARIANTS))[:, 0] + standard_deduction = np.asarray( + simulation.calculate("standard_deduction", year) + ).reshape(-1, len(VARIANTS))[:, 0] + ended &= ~(nobody_claimable & (gross_income == standard_deduction)) + assert not ended[sighted].any(), ( f"{year}: being claimable ended the requirement" ) # Property 8. @@ -520,3 +543,14 @@ def test_smaller_loss_can_end_the_requirement(): units[1]["adults"][0]["self_employment_income"] = -100 required = simulate(units).calculate("tax_unit_is_required_to_file", 2025) assert required.tolist() == [True, False] + + +def test_claimability_can_end_the_requirement_at_the_threshold(): + # Intended exception to property 7 from 2026. Nobody can claim the first + # filer, whose $16,100 of wages equals the threshold of section + # 6012(a)(1)(A)(i), so a return is due. The second filer can be claimed; + # $16,100 does not exceed their standard deduction of $16,100 + # ((a)(1)(C)(i)(II) and Chart B), so no return is due (decision d1395). + units = [single_filer(40, False, claimed, 16_100, 0) for claimed in (0, 1)] + required = simulate(units).calculate("tax_unit_is_required_to_file", 2026) + assert required.tolist() == [True, False] diff --git a/policyengine_us/tests/core/test_filing_requirement_invariants.py b/policyengine_us/tests/core/test_filing_requirement_invariants.py new file mode 100644 index 00000000000..ea1a43aae8f --- /dev/null +++ b/policyengine_us/tests/core/test_filing_requirement_invariants.py @@ -0,0 +1,372 @@ +"""Invariants for the filing requirement of a tax unit nobody can claim. + +`tax_unit_is_required_to_file` applies 26 USC 6012(f) for 2018 to 2025 and +6012(a)(1) in other years; the parameter +gov.irs.income.filing_requirement.standard_deduction_rule.applies switches +between them. YAML cases pin the published amounts one case at a time; these +tests check what must hold for every input, which YAML cannot state. + +The reference rules below are computed from the published amounts, not from +the model's parameters: + +- 6012(a)(1)(A) and (B): a return is required when gross income is at least + the basic standard deduction plus one additional standard deduction for + each spouse aged 65 or older. Blindness does not count. +- 6012(f)(1) and (2)(A): a return is required when gross income exceeds the + standard deduction, which adds the additional standard deduction for age + and for blindness. +- Under both, a married individual filing separately must file with any + gross income. + +`test_threshold_grid` runs one simulation over every filing status, every +combination of age 65 and blindness for the head and spouse, and gross income +of $0, $1, and a dollar under, at and over each rule's threshold: + +1. Differential: the 2026 result equals 6012(a)(1) with the 2026 amounts. +2. Differential: the 2025 result equals 6012(f) with the 2025 amounts. + +`test_filing_requirement_properties` draws batches of tax units with +Hypothesis (single, head of household, surviving spouse, separate and joint; +any mix of age, blindness, claimability, wages, interest, dividends, and +self-employment profit or loss; gross income often at a threshold) and runs +each batch, with two variants of every unit, as one simulation. It runs the +batch again under a reform that keeps 6012(f) in force for 2026: + +3. Differentials 1 and 2 on every drawn unit nobody can claim. +4. Blindness: for a unit nobody can claim, it never changes the 2026 result. + For every other unit and year, more blindness never adds a requirement. +5. Age: reaching 65 never adds a requirement, for any unit or year. +6. The switch: with 6012(f) kept for 2026, a unit nobody can claim gets + 6012(f) with the 2026 amounts, and the 2025 result is unchanged. +7. Claimable units are untouched: for a unit whose head or spouse can be + claimed, the 2026 result is the same with and without the switch. Their + rule is decision d1395. +8. Inclusion: in 2026, 6012(a)(1) never excuses a unit nobody can claim that + 6012(f) would require to file. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +from policyengine_core.reforms import Reform + +from policyengine_us import CountryTaxBenefitSystem +from policyengine_us.tests.core.test_filing_requirement_dependent_invariants import ( # noqa: E501 + simulate, +) + +YEARS = (2025, 2026) +STATUSES = ["SINGLE", "HEAD_OF_HOUSEHOLD", "SURVIVING_SPOUSE", "SEPARATE", "JOINT"] + +# Basic standard deduction and additional standard deduction by status. +# 2025: Chart A of the 2025 Form 1040 instructions (single $15,750, or +# $17,750 at 65; joint $31,500, $33,100 and $34,700; head of household +# $23,625 and $25,625), https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf +# 2026: Rev. Proc. 2025-32, sec. 4.14, +# https://www.irs.gov/pub/irs-drop/rp-25-32.pdf#page=18 +BASIC = { + 2025: { + "SINGLE": 15_750, + "HEAD_OF_HOUSEHOLD": 23_625, + "SURVIVING_SPOUSE": 31_500, + "SEPARATE": 15_750, + "JOINT": 31_500, + }, + 2026: { + "SINGLE": 16_100, + "HEAD_OF_HOUSEHOLD": 24_150, + "SURVIVING_SPOUSE": 32_200, + "SEPARATE": 16_100, + "JOINT": 32_200, + }, +} +UNMARRIED = ("SINGLE", "HEAD_OF_HOUSEHOLD") +ADDITIONAL = { + 2025: {status: 2_000 if status in UNMARRIED else 1_600 for status in STATUSES}, + 2026: {status: 2_050 if status in UNMARRIED else 1_650 for status in STATUSES}, +} + +GROSS_INCOME_ITEMS = [ + "employment_income", + "self_employment_income", + "taxable_interest_income", + "qualified_dividend_income", +] +VARIANTS = ["base", "blindness_flipped", "aged"] +KEEP_6012_F_IN_2026 = { + "gov.irs.income.filing_requirement.standard_deduction_rule.applies": { + "2026-01-01.2100-12-31": True + } +} + + +@pytest.fixture(scope="module") +def system_keeping_6012_f(): + """The tax-benefit system with 6012(f) kept for 2026, built once. + + Passing the reform to each Simulation would rebuild the system for every + Hypothesis example. The simulations only read it, as they read the + default system, and pytest drops it after this module. + """ + return CountryTaxBenefitSystem( + reform=Reform.from_dict(KEEP_6012_F_IN_2026, country_id="us") + ) + + +def gross_income(unit): + """Gross income of the head and spouse: no loss reduces it.""" + return sum( + max(0, adult.get(item, 0)) + for adult in unit["adults"] + for item in GROSS_INCOME_ITEMS + ) + + +def threshold(unit, year, rule): + """The reference threshold, or None for a separate return.""" + status = unit["filing_status"] + if status == "SEPARATE": + return None + aged = sum(adult["age"] >= 65 for adult in unit["adults"]) + blind = sum(adult["is_blind"] for adult in unit["adults"]) + additions = aged if rule == "6012(a)(1)" else aged + blind + return BASIC[year][status] + additions * ADDITIONAL[year][status] + + +def reference(unit, year, rule): + """Whether the reference rule requires a return.""" + income = gross_income(unit) + limit = threshold(unit, year, rule) + if limit is None: + return income > 0 + if rule == "6012(a)(1)": + return income >= limit + return income > limit + + +def rule_in_force(year): + return "6012(f)" if year <= 2025 else "6012(a)(1)" + + +def nobody_claimable(unit): + return not any( + adult.get("claimed_as_dependent_on_another_return", False) + for adult in unit["adults"] + ) + + +def adult(age, blind, claimed=False, **income): + return { + "age": age, + "is_blind": blind, + "claimed_as_dependent_on_another_return": claimed, + **income, + } + + +def with_gross_income(status, ages, blindness, income): + """A unit nobody can claim, its gross income split between the adults.""" + adults = [adult(age, blind) for age, blind in zip(ages, blindness)] + adults[0]["employment_income"] = income // 2 + adults[-1]["taxable_interest_income"] = ( + adults[-1].get("taxable_interest_income", 0) + income - income // 2 + ) + return {"filing_status": status, "adults": adults, "children": []} + + +def test_threshold_grid(): + units = [] + for status in STATUSES: + size = 2 if status == "JOINT" else 1 + for ages in np.ndindex(*([2] * size)): + ages = [65 + 5 * flag if flag else 40 for flag in ages] + for blindness in np.ndindex(*([2] * size)): + blindness = [bool(flag) for flag in blindness] + probe = with_gross_income(status, ages, blindness, 0) + incomes = {0, 1} + for year in YEARS: + for rule in ("6012(a)(1)", "6012(f)"): + limit = threshold(probe, year, rule) + if limit is not None: + incomes |= {limit - 1, limit, limit + 1} + units += [ + with_gross_income(status, ages, blindness, income) + for income in sorted(incomes) + ] + simulation = simulate(units) + for year in YEARS: + required = np.asarray( + simulation.calculate("tax_unit_is_required_to_file", year) + ) + expected = np.array( + [reference(unit, year, rule_in_force(year)) for unit in units] + ) + mismatched = [ + (unit["filing_status"], gross_income(unit), unit["adults"]) + for unit in np.array(units, dtype=object)[required != expected] + ] + assert not mismatched, f"{year}: {mismatched[:3]}" + # Both answers occur, so the check is not vacuous. + assert required.any() and not required.all() + + +# Gross incomes at each reference threshold, a dollar under and over. +BOUNDARIES = sorted( + { + BASIC[year][status] + count * ADDITIONAL[year][status] + offset + for year in YEARS + for status in STATUSES + for count in range(5) + for offset in (-1, 0, 1) + } +) + + +@st.composite +def units(draw): + status = draw(st.sampled_from(STATUSES)) + size = 2 if status == "JOINT" else 1 + claimable = draw(st.booleans()) + adults = [ + adult( + age=draw(st.sampled_from([19, 40, 64, 65, 66, 80])), + blind=draw(st.booleans()), + claimed=claimable and draw(st.booleans()), + ) + for _ in range(size) + ] + # Gross income, often exactly at a threshold, split among the adults' + # nonnegative items. A self-employment loss does not reduce it. + total = draw( + st.one_of( + st.sampled_from([0, 1] + BOUNDARIES), + st.integers(0, 45_000), + ) + ) + slots = [(n, item) for n in range(size) for item in GROSS_INCOME_ITEMS] + weights = [draw(st.integers(0, 3)) for _ in slots] + if not any(weights): + weights[0] = 1 + shares = [total * weight // sum(weights) for weight in weights] + shares[weights.index(max(weights))] += total - sum(shares) + for (n, item), share in zip(slots, shares): + adults[n][item] = share + loser = draw(st.integers(0, size)) + if loser < size and adults[loser]["self_employment_income"] == 0: + adults[loser]["self_employment_income"] = -draw(st.integers(1, 20_000)) + return {"filing_status": status, "adults": adults, "children": []} + + +@st.composite +def unit_variants(draw): + """A drawn tax unit and its variants, in VARIANTS order.""" + base = draw(units()) + + def variant(change): + copy = {**base, "adults": [dict(adult) for adult in base["adults"]]} + for person in copy["adults"]: + change(person) + return copy + + def flip_blindness(person): + person["is_blind"] = not person["is_blind"] + + def reach_65(person): + person["age"] = max(person["age"], 65) + + return [base, variant(flip_blindness), variant(reach_65)] + + +def blind_count(unit): + return sum(adult["is_blind"] for adult in unit["adults"]) + + +@settings( + max_examples=6, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(drawn=st.lists(unit_variants(), min_size=8, max_size=24)) +def test_filing_requirement_properties(system_keeping_6012_f, drawn): + flat = [unit for variants in drawn for unit in variants] + baseline = simulate(flat) + kept = simulate(flat, tax_benefit_system=system_keeping_6012_f) + by_variant = { + name: [variants[n] for variants in drawn] for n, name in enumerate(VARIANTS) + } + unclaimable = np.array([nobody_claimable(unit) for unit in by_variant["base"]]) + # Blind adults in the flipped variant less those in the base unit. On a + # joint return with one blind spouse, flipping swaps who is blind. + blindness_change = np.array( + [ + blind_count(flipped) - blind_count(base) + for base, flipped in zip( + by_variant["base"], by_variant["blindness_flipped"] + ) + ] + ) + + def results(simulation, year): + values = simulation.calculate("tax_unit_is_required_to_file", year) + columns = np.asarray(values).reshape(-1, len(VARIANTS)).T + return dict(zip(VARIANTS, columns)) + + for year in YEARS: + required = results(baseline, year) + required_kept = results(kept, year) + base = required["base"] + # Property 3. + expected = np.array( + [reference(unit, year, rule_in_force(year)) for unit in by_variant["base"]] + ) + np.testing.assert_array_equal( + base[unclaimable], + expected[unclaimable], + err_msg=f"{year}: differs from {rule_in_force(year)}", + ) + # Property 4. + flipped = required["blindness_flipped"] + if year >= 2026: + np.testing.assert_array_equal( + flipped[unclaimable], + base[unclaimable], + err_msg=f"{year}: blindness changed the result", + ) + checked = ~unclaimable if year >= 2026 else np.ones_like(unclaimable) + gained = ((blindness_change > 0) & flipped & ~base) | ( + (blindness_change < 0) & base & ~flipped + ) + assert not (gained & checked).any(), ( + f"{year}: more blindness added a requirement" + ) + # Property 5. + assert not (required["aged"] & ~base).any(), ( + f"{year}: reaching 65 added a requirement" + ) + # Property 6. + kept_base = required_kept["base"] + if year >= 2026: + expected_f = np.array( + [reference(unit, year, "6012(f)") for unit in by_variant["base"]] + ) + np.testing.assert_array_equal( + kept_base[unclaimable], + expected_f[unclaimable], + err_msg=f"{year}: the switch did not restore 6012(f)", + ) + # Property 7. + np.testing.assert_array_equal( + kept_base[~unclaimable], + base[~unclaimable], + err_msg=f"{year}: the rule changed for a claimable unit", + ) + # Property 8. + assert not (kept_base & ~base)[unclaimable].any(), ( + f"{year}: 6012(a)(1) excused a unit 6012(f) requires" + ) + else: + np.testing.assert_array_equal( + kept_base, base, err_msg=f"{year}: the 2026 reform changed {year}" + ) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file.yaml index f7f2db16f28..0c871b66a1a 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file.yaml @@ -290,7 +290,7 @@ taxable_interest_income: 15_750 output: # (f)(1): gross income does not exceed the standard deduction. Chart A of - # the 2025 instructions says "at least" $15,750 (decision DECISION_ID). + # the 2025 instructions says "at least" $15,750 (decision d1403). tax_unit_is_required_to_file: false - name: Case 21, 2025, single filer under 65 with gross income of $15,751. @@ -311,7 +311,7 @@ employment_income: 17_750 output: # (f)(1): the standard deduction is $15,750 + $2,000 for blindness. - # Chart A of the 2025 instructions lists only age (decision DECISION_ID). + # Chart A of the 2025 instructions lists only age (decision d1403). tax_unit_is_required_to_file: false - name: Case 23, 2025, blind single filer under 65 with gross income of $17,751. From 634f2ee2750c4fd5133486d90d4ab2ca977df233 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 05:38:02 -0400 Subject: [PATCH 8/9] Count the WFTCA bonus in the 2026 threshold; test without a second system The WFTCA contributed reform's bonus guaranteed deduction is, in its own parameter's words, an increase to the basic standard deduction, so it is part of the 6012(a)(1) threshold (it is zero unless the reform is on). Two contrib cases pin it. The property tests no longer build a reformed tax-benefit system, which held a second parameter tree (about 0.7 GB) in the CI core group, already at 13.3 GB. Claimable units are now checked against the standard deduction and unearned income tests directly, and 6012(a)(1) against a 6012(f) reference with the 2026 amounts. Two fixed anchor units keep every example non-vacuous. Co-Authored-By: Claude Opus 5.5 --- .../test_filing_requirement_invariants.py | 146 ++++++++++-------- .../tests/policy/contrib/congress/wftca.yaml | 23 +++ .../gov/irs/tax_unit_is_required_to_file.py | 5 +- 3 files changed, 112 insertions(+), 62 deletions(-) diff --git a/policyengine_us/tests/core/test_filing_requirement_invariants.py b/policyengine_us/tests/core/test_filing_requirement_invariants.py index ea1a43aae8f..8019d846770 100644 --- a/policyengine_us/tests/core/test_filing_requirement_invariants.py +++ b/policyengine_us/tests/core/test_filing_requirement_invariants.py @@ -29,29 +29,32 @@ Hypothesis (single, head of household, surviving spouse, separate and joint; any mix of age, blindness, claimability, wages, interest, dividends, and self-employment profit or loss; gross income often at a threshold) and runs -each batch, with two variants of every unit, as one simulation. It runs the -batch again under a reform that keeps 6012(f) in force for 2026: +each batch, with two variants of every unit, as one simulation: 3. Differentials 1 and 2 on every drawn unit nobody can claim. 4. Blindness: for a unit nobody can claim, it never changes the 2026 result. For every other unit and year, more blindness never adds a requirement. 5. Age: reaching 65 never adds a requirement, for any unit or year. -6. The switch: with 6012(f) kept for 2026, a unit nobody can claim gets - 6012(f) with the 2026 amounts, and the 2025 result is unchanged. -7. Claimable units are untouched: for a unit whose head or spouse can be - claimed, the 2026 result is the same with and without the switch. Their - rule is decision d1395. -8. Inclusion: in 2026, 6012(a)(1) never excuses a unit nobody can claim that - 6012(f) would require to file. +6. Inclusion: in 2026, the model never excuses a unit nobody can claim that + 6012(f), with the 2026 amounts, would require to file. +7. Claimable units keep one rule in both years: for a unit whose head or + spouse can be claimed, a return is required exactly when gross income is + over the unit's standard deduction (any gross income on a separate + return), or a claimable adult's interest and dividends are over the + section 63(c)(5)(A) amount ($1,350 in 2025 and 2026) plus that adult's + own additions for age and blindness. How 6012(a)(1) applies to these + filers is decision d1395, so the change leaves them alone. + +The tests use the default tax-benefit system only. A reform that kept 6012(f) +for 2026 would compare the two rules directly, but it costs a second +parameter tree (about 0.7 GB) in the CI group, so properties 6 and 7 compare +the model with references instead. """ import numpy as np -import pytest from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st -from policyengine_core.reforms import Reform -from policyengine_us import CountryTaxBenefitSystem from policyengine_us.tests.core.test_filing_requirement_dependent_invariants import ( # noqa: E501 simulate, ) @@ -93,25 +96,11 @@ "taxable_interest_income", "qualified_dividend_income", ] +# Section 63(c)(5)(A) amount: Rev. Proc. 2024-40, sec. 2.15(2), and Rev. Proc. +# 2025-32, sec. 4.14(2). +DEPENDENT_AMOUNT = {2025: 1_350, 2026: 1_350} +UNEARNED_ITEMS = ["taxable_interest_income", "qualified_dividend_income"] VARIANTS = ["base", "blindness_flipped", "aged"] -KEEP_6012_F_IN_2026 = { - "gov.irs.income.filing_requirement.standard_deduction_rule.applies": { - "2026-01-01.2100-12-31": True - } -} - - -@pytest.fixture(scope="module") -def system_keeping_6012_f(): - """The tax-benefit system with 6012(f) kept for 2026, built once. - - Passing the reform to each Simulation would rebuild the system for every - Hypothesis example. The simulations only read it, as they read the - default system, and pytest drops it after this module. - """ - return CountryTaxBenefitSystem( - reform=Reform.from_dict(KEEP_6012_F_IN_2026, country_id="us") - ) def gross_income(unit): @@ -145,6 +134,18 @@ def reference(unit, year, rule): return income > limit +def claimable_unearned_test(unit, year): + """Whether a claimable adult's interest and dividends are over the + section 63(c)(5)(A) amount plus that adult's own additions.""" + addition = ADDITIONAL[year][unit["filing_status"]] + return any( + adult["claimed_as_dependent_on_another_return"] + and sum(max(0, adult.get(item, 0)) for item in UNEARNED_ITEMS) + > DEPENDENT_AMOUNT[year] + addition * ((adult["age"] >= 65) + adult["is_blind"]) + for adult in unit["adults"] + ) + + def rule_in_force(year): return "6012(f)" if year <= 2025 else "6012(a)(1)" @@ -258,10 +259,8 @@ def units(draw): return {"filing_status": status, "adults": adults, "children": []} -@st.composite -def unit_variants(draw): - """A drawn tax unit and its variants, in VARIANTS order.""" - base = draw(units()) +def variants_of(base): + """A tax unit and its variants, in VARIANTS order.""" def variant(change): copy = {**base, "adults": [dict(adult) for adult in base["adults"]]} @@ -278,6 +277,31 @@ def reach_65(person): return [base, variant(flip_blindness), variant(reach_65)] +@st.composite +def unit_variants(draw): + return variants_of(draw(units())) + + +# Every example holds a unit nobody can claim, at the 2026 threshold of a blind +# filer, and a unit whose head can be claimed, over the unearned income test. +ANCHORS = [ + variants_of( + { + "filing_status": "SINGLE", + "adults": [adult(40, True, employment_income=16_100)], + "children": [], + } + ), + variants_of( + { + "filing_status": "SINGLE", + "adults": [adult(19, False, True, taxable_interest_income=2_000)], + "children": [], + } + ), +] + + def blind_count(unit): return sum(adult["is_blind"] for adult in unit["adults"]) @@ -289,10 +313,10 @@ def blind_count(unit): suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], ) @given(drawn=st.lists(unit_variants(), min_size=8, max_size=24)) -def test_filing_requirement_properties(system_keeping_6012_f, drawn): +def test_filing_requirement_properties(drawn): + drawn = ANCHORS + drawn flat = [unit for variants in drawn for unit in variants] - baseline = simulate(flat) - kept = simulate(flat, tax_benefit_system=system_keeping_6012_f) + simulation = simulate(flat) by_variant = { name: [variants[n] for variants in drawn] for n, name in enumerate(VARIANTS) } @@ -308,14 +332,13 @@ def test_filing_requirement_properties(system_keeping_6012_f, drawn): ] ) - def results(simulation, year): - values = simulation.calculate("tax_unit_is_required_to_file", year) + def results(variable, year): + values = simulation.calculate(variable, year) columns = np.asarray(values).reshape(-1, len(VARIANTS)).T return dict(zip(VARIANTS, columns)) for year in YEARS: - required = results(baseline, year) - required_kept = results(kept, year) + required = results("tax_unit_is_required_to_file", year) base = required["base"] # Property 3. expected = np.array( @@ -346,27 +369,28 @@ def results(simulation, year): f"{year}: reaching 65 added a requirement" ) # Property 6. - kept_base = required_kept["base"] if year >= 2026: - expected_f = np.array( + required_by_f = np.array( [reference(unit, year, "6012(f)") for unit in by_variant["base"]] ) - np.testing.assert_array_equal( - kept_base[unclaimable], - expected_f[unclaimable], - err_msg=f"{year}: the switch did not restore 6012(f)", - ) - # Property 7. - np.testing.assert_array_equal( - kept_base[~unclaimable], - base[~unclaimable], - err_msg=f"{year}: the rule changed for a claimable unit", - ) - # Property 8. - assert not (kept_base & ~base)[unclaimable].any(), ( - f"{year}: 6012(a)(1) excused a unit 6012(f) requires" - ) - else: - np.testing.assert_array_equal( - kept_base, base, err_msg=f"{year}: the 2026 reform changed {year}" + assert not (required_by_f & ~base)[unclaimable].any(), ( + f"{year}: excused a unit 6012(f) requires" ) + # Property 7. + standard_deduction = results("standard_deduction", year)["base"] + expected_claimable = np.array( + [ + ( + gross_income(unit) > 0 + if unit["filing_status"] == "SEPARATE" + else gross_income(unit) > deduction + ) + or claimable_unearned_test(unit, year) + for unit, deduction in zip(by_variant["base"], standard_deduction) + ] + ) + np.testing.assert_array_equal( + base[~unclaimable], + expected_claimable[~unclaimable], + err_msg=f"{year}: the rule for claimable units changed", + ) diff --git a/policyengine_us/tests/policy/contrib/congress/wftca.yaml b/policyengine_us/tests/policy/contrib/congress/wftca.yaml index 0728a6620b4..c132349e12f 100644 --- a/policyengine_us/tests/policy/contrib/congress/wftca.yaml +++ b/policyengine_us/tests/policy/contrib/congress/wftca.yaml @@ -15,3 +15,26 @@ filing_status: JOINT output: bonus_guaranteed_deduction: 0 + +- name: WFTCA bonus raises the 2026 filing threshold for a single filer + period: 2026 + input: + gov.contrib.congress.wftca.bonus_guaranteed_deduction.amount.SINGLE: 2_000 + filing_status: SINGLE + age: 40 + taxable_interest_income: 18_099 + output: + # Section 6012(a)(1)(A)(i): $16,100 basic standard deduction plus the + # $2,000 increase to it; $18,099 is less than $18,100. + bonus_guaranteed_deduction: 2_000 + tax_unit_is_required_to_file: false + +- name: WFTCA bonus, 2026 single filer at the raised filing threshold + period: 2026 + input: + gov.contrib.congress.wftca.bonus_guaranteed_deduction.amount.SINGLE: 2_000 + filing_status: SINGLE + age: 40 + taxable_interest_income: 18_100 + output: + tax_unit_is_required_to_file: true diff --git a/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py b/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py index d2ff75a3bf5..07474d6cc41 100644 --- a/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py +++ b/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py @@ -68,13 +68,16 @@ def formula(tax_unit, period, parameters): # additional standard deduction for each spouse aged 65 or older # (section 63(f)(1)). Blindness does not raise it, and gross income # equal to it requires a return. A married individual filing - # separately has no exception. + # separately has no exception. The WFTCA contributed reform's bonus + # guaranteed deduction is an increase to the basic standard deduction + # (zero unless that reform is on). aged_count = tax_unit("aged_head", period).astype(int) + tax_unit( "aged_spouse", period ).astype(int) basic_threshold = ( exemption_amount * tax_unit("head_spouse_count", period) + tax_unit("basic_standard_deduction", period) + + tax_unit("bonus_guaranteed_deduction", period) + aged_count * p.deductions.standard.aged_or_blind.amount[filing_status] ) income_at_least_basic_threshold = (gross_income > 0) & ( From 2a9771fcb9d83a12b1f4ea94bc28d1cf84bba1ce Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 06:43:38 -0400 Subject: [PATCH 9/9] Hold tax-unit roles fixed in the property tests; document the $0 guard Review round 1 found that the age property could fail for a reason unrelated to age: with roles left to age order, raising both spouses to 65 can make a younger claimable spouse the head, which limits the joint standard deduction. Every test unit now supplies its roles, and the reviewer's counterexample is a fixed anchor. Without the roles the property fails on that anchor; with them it passes. The positive gross income guard for a separate filer keeps the model's existing behaviour. The formula now says why, citing the National Taxpayer Advocate's account of the zero exemption amount and the IRS's $5 relief; the reading is queued with decision d1403. The section 6012 references are consolidated into one entry. Co-Authored-By: Claude Opus 5.5 --- .../test_filing_requirement_invariants.py | 41 ++++++++++++------- .../gov/irs/tax_unit_is_required_to_file.yaml | 4 ++ .../gov/irs/tax_unit_is_required_to_file.py | 22 ++++++---- 3 files changed, 45 insertions(+), 22 deletions(-) diff --git a/policyengine_us/tests/core/test_filing_requirement_invariants.py b/policyengine_us/tests/core/test_filing_requirement_invariants.py index 8019d846770..6c54dc4ff9c 100644 --- a/policyengine_us/tests/core/test_filing_requirement_invariants.py +++ b/policyengine_us/tests/core/test_filing_requirement_invariants.py @@ -34,7 +34,11 @@ 3. Differentials 1 and 2 on every drawn unit nobody can claim. 4. Blindness: for a unit nobody can claim, it never changes the 2026 result. For every other unit and year, more blindness never adds a requirement. -5. Age: reaching 65 never adds a requirement, for any unit or year. +5. Age: reaching 65 never adds a requirement, for any unit or year. Every + unit supplies its roles (the first adult heads it), so a variant cannot + change who is head: with roles left to age order, raising both spouses to + 65 can make a claimable spouse the head, which limits the joint standard + deduction for a reason that has nothing to do with age. 6. Inclusion: in 2026, the model never excuses a unit nobody can claim that 6012(f), with the 2026 amounts, would require to file. 7. Claimable units keep one rule in both years: for a unit whose head or @@ -166,6 +170,13 @@ def adult(age, blind, claimed=False, **income): } +def tax_unit(status, adults): + """A tax unit whose first adult is the head and second the spouse.""" + for n, person in enumerate(adults): + person["tax_unit_role_input"] = "SPOUSE" if n else "HEAD" + return {"filing_status": status, "adults": adults, "children": []} + + def with_gross_income(status, ages, blindness, income): """A unit nobody can claim, its gross income split between the adults.""" adults = [adult(age, blind) for age, blind in zip(ages, blindness)] @@ -173,7 +184,7 @@ def with_gross_income(status, ages, blindness, income): adults[-1]["taxable_interest_income"] = ( adults[-1].get("taxable_interest_income", 0) + income - income // 2 ) - return {"filing_status": status, "adults": adults, "children": []} + return tax_unit(status, adults) def test_threshold_grid(): @@ -256,7 +267,7 @@ def units(draw): loser = draw(st.integers(0, size)) if loser < size and adults[loser]["self_employment_income"] == 0: adults[loser]["self_employment_income"] = -draw(st.integers(1, 20_000)) - return {"filing_status": status, "adults": adults, "children": []} + return tax_unit(status, adults) def variants_of(base): @@ -283,21 +294,23 @@ def unit_variants(draw): # Every example holds a unit nobody can claim, at the 2026 threshold of a blind -# filer, and a unit whose head can be claimed, over the unearned income test. +# filer; a unit whose head can be claimed, over the unearned income test; and +# a joint unit headed by a younger, claimable adult. Were roles left to age +# order, that adult would head the unit only once both are 65 (the case that +# needs the roles fixed). ANCHORS = [ + variants_of(tax_unit("SINGLE", [adult(40, True, employment_income=16_100)])), variants_of( - { - "filing_status": "SINGLE", - "adults": [adult(40, True, employment_income=16_100)], - "children": [], - } + tax_unit("SINGLE", [adult(19, False, True, taxable_interest_income=2_000)]) ), variants_of( - { - "filing_status": "SINGLE", - "adults": [adult(19, False, True, taxable_interest_income=2_000)], - "children": [], - } + tax_unit( + "JOINT", + [ + adult(19, False, True), + adult(40, False, taxable_interest_income=10_000), + ], + ) ), ] diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file.yaml index 0c871b66a1a..5ca30c7c5cb 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file.yaml @@ -280,6 +280,10 @@ filing_status: SEPARATE age: 40 output: + # With a zero exemption amount, (a)(1)(A) read literally reaches even + # $0 of gross income; the IRS set $5 for 2018 to 2025. The model, as + # before, requires a return only for positive gross income (decision + # d1403). tax_unit_is_required_to_file: false - name: Case 20, 2025, single filer under 65 with gross income of exactly $15,750. diff --git a/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py b/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py index 07474d6cc41..8ebac827955 100644 --- a/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py +++ b/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py @@ -17,14 +17,14 @@ class tax_unit_is_required_to_file(Variable): """ definition_period = YEAR reference = ( + # Section 6012(f) for 2018 to 2025, and 6012(a)(1)(A) and (B) in other + # years. "https://www.law.cornell.edu/uscode/text/26/6012", - # From 2026 (and before 2018): section 6012(a)(1)(A) and (B), and - # Chart A of the Form 1040 instructions for 2017, the last year it - # governed. - "https://www.law.cornell.edu/uscode/text/26/6012#a_1", + # Chart A of the Form 1040 instructions for 2017, the last year + # section 6012(a)(1) governed. "https://www.irs.gov/pub/irs-prior/i1040gi--2017.pdf#page=8", - # 2018 to 2025: section 6012(f). - "https://www.law.cornell.edu/uscode/text/26/6012#f", + # Separate filers with a zero exemption amount, and the IRS's $5. + "https://www.taxpayeradvocate.irs.gov/news/nta-blog/ntablog-irs-publication-error-may-have-caused-certain-married-taxpayers-filing-separately-to-fail-to-file-required-tax-returns/2019/12/", # A filer who can be claimed as a dependent: section 6012(a)(1)(C)(i) # and (f)(2)(D) (the section above), the section 63(c)(5) limited # standard deduction, and Publication 501, Table 2 (Chart B of the @@ -67,10 +67,16 @@ def formula(tax_unit, period, parameters): # each spouse plus the basic standard deduction, raised by one # additional standard deduction for each spouse aged 65 or older # (section 63(f)(1)). Blindness does not raise it, and gross income - # equal to it requires a return. A married individual filing - # separately has no exception. The WFTCA contributed reform's bonus + # equal to it requires a return. The WFTCA contributed reform's bonus # guaranteed deduction is an increase to the basic standard deduction # (zero unless that reform is on). + # + # A married individual filing separately has no exception. With an + # exemption amount of zero, (A) read literally reaches even an + # individual with no gross income; the National Taxpayer Advocate read + # the 2018 to 2025 rule that way, and the IRS set $5 as relief. As for + # 2018 to 2025, the model requires a return only for positive gross + # income (decision d1403). aged_count = tax_unit("aged_head", period).astype(int) + tax_unit( "aged_spouse", period ).astype(int)