From a8b188437e376b3a2c51f736dcd275a540a0cd69 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 24 Sep 2026 14:07:28 -0400 Subject: [PATCH 1/2] Fix duplicate Arkansas low-income table threshold and guard scale thresholds The 2024 Arkansas low-income tax table for head of household and surviving spouse filers with two or more dependents keyed its $92 row at $24,200, the threshold of the $104 row. ParameterScale._get_at_instant passes each bracket to add_bracket, which sums the amounts of equal thresholds, so the 2024 scale became [(0, 0), (24200, 196), (24300, 116), ...]: AGI of $24,177-$24,200 got $0 instead of $92 and $24,201-$24,300 got $196 instead of $104. The official table (2024 AR1000F/AR1000NR instructions, PDF page 28) reads $0-$24,176 -> $0 and $24,177-$24,200 -> $92. The tables are evaluated with right=True, so the threshold is the upper bound of the $0 row: 24_176. The 2024 reference anchor now points at PDF page 28. calibration/gov/irs/soi/agi/total_agi repeated its $10,000,000-or-more bracket, which doubled the top bucket when loaded. Its 18 values match IRS SOI Table 1.1 for tax year 2020 (20in11si.xls, column 3), where the top bucket appears once. No code reads this parameter. Add YAML tests for AGI of $24,100-$24,401 for both filing statuses and a repo-level test that fails when any parameter scale has two brackets with the same finite threshold at any instant. Co-Authored-By: Claude Opus 5.5 --- ...ar-low-income-duplicate-threshold.fixed.md | 1 + .../gov/irs/soi/agi/total_agi.yaml | 7 - .../two_or_more_dependents.yaml | 4 +- .../two_or_more_dependents.yaml | 4 +- .../low_income/ar_low_income_tax_joint.yaml | 151 ++++++++++++++++++ .../tests/test_parameter_scale_thresholds.py | 117 ++++++++++++++ 6 files changed, 273 insertions(+), 11 deletions(-) create mode 100644 changelog.d/fix-ar-low-income-duplicate-threshold.fixed.md create mode 100644 policyengine_us/tests/test_parameter_scale_thresholds.py diff --git a/changelog.d/fix-ar-low-income-duplicate-threshold.fixed.md b/changelog.d/fix-ar-low-income-duplicate-threshold.fixed.md new file mode 100644 index 00000000000..10680ba8996 --- /dev/null +++ b/changelog.d/fix-ar-low-income-duplicate-threshold.fixed.md @@ -0,0 +1 @@ +Corrected the 2024 Arkansas low-income tax table for head of household and surviving spouse filers with two or more dependents, whose $92 row shared the $24,200 threshold of the $104 row and was summed into a $196 row; removed a duplicated top bracket from the IRS SOI AGI-by-size calibration scale; and added a test that fails when any parameter scale repeats a finite threshold. diff --git a/policyengine_us/parameters/calibration/gov/irs/soi/agi/total_agi.yaml b/policyengine_us/parameters/calibration/gov/irs/soi/agi/total_agi.yaml index 229e85151d6..a5753724b4d 100644 --- a/policyengine_us/parameters/calibration/gov/irs/soi/agi/total_agi.yaml +++ b/policyengine_us/parameters/calibration/gov/irs/soi/agi/total_agi.yaml @@ -132,10 +132,3 @@ brackets: uprating: calibration.gov.cbo.income_by_source.adjusted_gross_income values: 2015-01-01: 824_093_126_000 - - threshold: - 2015-01-01: 10_000_000 - amount: - metadata: - uprating: calibration.gov.cbo.income_by_source.adjusted_gross_income - values: - 2015-01-01: 824_093_126_000 diff --git a/policyengine_us/parameters/gov/states/ar/tax/income/rates/low_income_tax_tables/head_of_household/two_or_more_dependents.yaml b/policyengine_us/parameters/gov/states/ar/tax/income/rates/low_income_tax_tables/head_of_household/two_or_more_dependents.yaml index bd7467476ee..4528a2230fc 100644 --- a/policyengine_us/parameters/gov/states/ar/tax/income/rates/low_income_tax_tables/head_of_household/two_or_more_dependents.yaml +++ b/policyengine_us/parameters/gov/states/ar/tax/income/rates/low_income_tax_tables/head_of_household/two_or_more_dependents.yaml @@ -16,7 +16,7 @@ metadata: - title: Arkansas Income Tax Instructions, 2023, Low Income Tax Tables href: https://www.dfa.arkansas.gov/images/uploads/incomeTaxOffice/2023_AR1000F_and_AR1000NR_Instructions.pdf#page=25 - title: Arkansas Income Tax Instructions, 2024, Low Income Tax Tables - href: https://www.dfa.arkansas.gov/wp-content/uploads/2024_AR1000F_and_AR1000NR_Instructions.pdf#page=24 + href: https://www.dfa.arkansas.gov/wp-content/uploads/2024_AR1000F_and_AR1000NR_Instructions.pdf#page=28 - title: Arkansas Income Tax Instructions, 2025, Low Income Tax Tables href: https://www.dfa.arkansas.gov/wp-content/uploads/TaxTables_FI_2025.pdf#page=2 brackets: @@ -28,7 +28,7 @@ brackets: 2021-01-01: 22_125 2022-01-01: 22_789 2023-01-01: 23_472 - 2024-01-01: 24_200 + 2024-01-01: 24_176 2025-01-01: 24_818 amount: 2021-01-01: 86 diff --git a/policyengine_us/parameters/gov/states/ar/tax/income/rates/low_income_tax_tables/surviving_spouse/two_or_more_dependents.yaml b/policyengine_us/parameters/gov/states/ar/tax/income/rates/low_income_tax_tables/surviving_spouse/two_or_more_dependents.yaml index 78d2f749e3f..f6c24ef3253 100644 --- a/policyengine_us/parameters/gov/states/ar/tax/income/rates/low_income_tax_tables/surviving_spouse/two_or_more_dependents.yaml +++ b/policyengine_us/parameters/gov/states/ar/tax/income/rates/low_income_tax_tables/surviving_spouse/two_or_more_dependents.yaml @@ -16,7 +16,7 @@ metadata: - title: Arkansas Income Tax Instructions, 2023, Low Income Tax Tables href: https://www.dfa.arkansas.gov/images/uploads/incomeTaxOffice/2023_AR1000F_and_AR1000NR_Instructions.pdf#page=25 - title: Arkansas Income Tax Instructions, 2024, Low Income Tax Tables - href: https://www.dfa.arkansas.gov/wp-content/uploads/2024_AR1000F_and_AR1000NR_Instructions.pdf#page=24 + href: https://www.dfa.arkansas.gov/wp-content/uploads/2024_AR1000F_and_AR1000NR_Instructions.pdf#page=28 - title: Arkansas Income Tax Instructions, 2025, Low Income Tax Tables href: https://www.dfa.arkansas.gov/wp-content/uploads/TaxTables_FI_2025.pdf#page=2 brackets: @@ -28,7 +28,7 @@ brackets: 2021-01-01: 22_125 2022-01-01: 22_789 2023-01-01: 23_472 - 2024-01-01: 24_200 + 2024-01-01: 24_176 2025-01-01: 24_818 amount: 2021-01-01: 86 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/low_income/ar_low_income_tax_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/low_income/ar_low_income_tax_joint.yaml index 4f2f15109c6..4fbd3d8dac5 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/low_income/ar_low_income_tax_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/low_income/ar_low_income_tax_joint.yaml @@ -334,3 +334,154 @@ state_code: AR output: ar_uses_low_income_tax_tables: false + +# The 2024 Head of Household/Surviving Spouse with 2 or More Dependents table +# (2024 AR1000F/AR1000NR instructions, PDF page 28) reads: $0-$24,176 -> $0, +# $24,177-$24,200 -> $92, $24,201-$24,300 -> $104, $24,301-$24,400 -> $116, +# $24,401-$24,500 -> $129. The $92 row's threshold was once keyed at $24,200, +# equal to the next row's, and policyengine-core sums the amounts of brackets +# with equal thresholds, so $24,201-$24,300 got $196 and the $92 row vanished. +# One single-member tax unit per income covers every row edge in the range. +- name: Case 19, 2024 head of household with 2 dependents from $24,100 to $24,401. + period: 2024 + input: + people: + person1: + ar_agi_joint: 24_100 + person2: + ar_agi_joint: 24_176 + person3: + ar_agi_joint: 24_177 + person4: + ar_agi_joint: 24_200 + person5: + ar_agi_joint: 24_201 + person6: + ar_agi_joint: 24_250 + person7: + ar_agi_joint: 24_300 + person8: + ar_agi_joint: 24_301 + person9: + ar_agi_joint: 24_400 + person10: + ar_agi_joint: 24_401 + tax_units: + tax_unit1: + members: [person1] + filing_status: HEAD_OF_HOUSEHOLD + tax_unit_dependents: 2 + tax_unit2: + members: [person2] + filing_status: HEAD_OF_HOUSEHOLD + tax_unit_dependents: 2 + tax_unit3: + members: [person3] + filing_status: HEAD_OF_HOUSEHOLD + tax_unit_dependents: 2 + tax_unit4: + members: [person4] + filing_status: HEAD_OF_HOUSEHOLD + tax_unit_dependents: 2 + tax_unit5: + members: [person5] + filing_status: HEAD_OF_HOUSEHOLD + tax_unit_dependents: 2 + tax_unit6: + members: [person6] + filing_status: HEAD_OF_HOUSEHOLD + tax_unit_dependents: 2 + tax_unit7: + members: [person7] + filing_status: HEAD_OF_HOUSEHOLD + tax_unit_dependents: 2 + tax_unit8: + members: [person8] + filing_status: HEAD_OF_HOUSEHOLD + tax_unit_dependents: 2 + tax_unit9: + members: [person9] + filing_status: HEAD_OF_HOUSEHOLD + tax_unit_dependents: 2 + tax_unit10: + members: [person10] + filing_status: HEAD_OF_HOUSEHOLD + tax_unit_dependents: 2 + households: + household: + members: [person1, person2, person3, person4, person5, person6, person7, person8, person9, person10] + state_code: AR + output: + ar_low_income_tax_joint: [0, 0, 92, 92, 104, 104, 104, 116, 116, 129] + +- name: Case 20, 2024 surviving spouse with 2 dependents from $24,100 to $24,401. + period: 2024 + input: + people: + person1: + ar_agi_joint: 24_100 + person2: + ar_agi_joint: 24_176 + person3: + ar_agi_joint: 24_177 + person4: + ar_agi_joint: 24_200 + person5: + ar_agi_joint: 24_201 + person6: + ar_agi_joint: 24_250 + person7: + ar_agi_joint: 24_300 + person8: + ar_agi_joint: 24_301 + person9: + ar_agi_joint: 24_400 + person10: + ar_agi_joint: 24_401 + tax_units: + tax_unit1: + members: [person1] + filing_status: SURVIVING_SPOUSE + tax_unit_dependents: 2 + tax_unit2: + members: [person2] + filing_status: SURVIVING_SPOUSE + tax_unit_dependents: 2 + tax_unit3: + members: [person3] + filing_status: SURVIVING_SPOUSE + tax_unit_dependents: 2 + tax_unit4: + members: [person4] + filing_status: SURVIVING_SPOUSE + tax_unit_dependents: 2 + tax_unit5: + members: [person5] + filing_status: SURVIVING_SPOUSE + tax_unit_dependents: 2 + tax_unit6: + members: [person6] + filing_status: SURVIVING_SPOUSE + tax_unit_dependents: 2 + tax_unit7: + members: [person7] + filing_status: SURVIVING_SPOUSE + tax_unit_dependents: 2 + tax_unit8: + members: [person8] + filing_status: SURVIVING_SPOUSE + tax_unit_dependents: 2 + tax_unit9: + members: [person9] + filing_status: SURVIVING_SPOUSE + tax_unit_dependents: 2 + tax_unit10: + members: [person10] + filing_status: SURVIVING_SPOUSE + tax_unit_dependents: 2 + households: + household: + members: [person1, person2, person3, person4, person5, person6, person7, person8, person9, person10] + state_code: AR + output: + ar_low_income_tax_joint: [0, 0, 92, 92, 104, 104, 104, 116, 116, 129] diff --git a/policyengine_us/tests/test_parameter_scale_thresholds.py b/policyengine_us/tests/test_parameter_scale_thresholds.py new file mode 100644 index 00000000000..f7ab331c316 --- /dev/null +++ b/policyengine_us/tests/test_parameter_scale_thresholds.py @@ -0,0 +1,117 @@ +"""Parameter scales must not repeat a finite threshold at any instant. + +To build a scale at an instant, policyengine-core's +`ParameterScale._get_at_instant` passes every bracket to `add_bracket`, +which adds a bracket's amount (or rate) to an existing row with an equal +threshold instead of keeping a second row. Two brackets keyed to the same +threshold therefore collapse into one row that carries their sum, with no +error. The 2024 Arkansas low-income tax tables for head of household and +surviving spouse filers with two or more dependents shipped this way: the +$92 row was keyed at $24,200, the $104 row's threshold, so AGI of +$24,201-$24,300 got $196 and the $92 row disappeared. + +Duplicate infinite thresholds are ignored: tables whose length changes over +time park unused brackets at `.inf`. +""" + +import math +from collections import defaultdict + +from policyengine_core.parameters import ParameterScale + +from policyengine_us.system import system + + +def _value_at(parameter, instant_str): + # Read the step function directly: some parameters carry padded + # 0000-01-01 entries that policyengine-core cannot turn into a date. + value = None + for value_at_instant in sorted( + parameter.values_list, key=lambda entry: entry.instant_str + ): + if value_at_instant.instant_str > instant_str: + break + value = value_at_instant.value + return value + + +def _duplicate_threshold_errors(scale): + thresholds = [bracket.children.get("threshold") for bracket in scale.brackets] + instants = sorted( + { + value_at_instant.instant_str + for threshold in thresholds + if threshold is not None + for value_at_instant in threshold.values_list + } + ) + errors = [] + for instant_str in instants: + brackets_by_threshold = defaultdict(list) + for index, threshold in enumerate(thresholds): + if threshold is None: + continue + value = _value_at(threshold, instant_str) + if value is None or math.isinf(value): + continue + brackets_by_threshold[float(value)].append(index) + for value, indices in brackets_by_threshold.items(): + if len(indices) > 1: + errors.append( + f"{scale.name} at {instant_str}: brackets {indices} " + f"share threshold {value:,.15g}" + ) + return errors + + +def _scale(brackets): + return ParameterScale( + "test_scale", + {"metadata": {"type": "single_amount"}, "brackets": brackets}, + "test_scale.yaml", + ) + + +def test_duplicate_threshold_guard_flags_a_later_collision(): + scale = _scale( + [ + {"threshold": {"2021-01-01": 0}, "amount": {"2021-01-01": 0}}, + { + "threshold": {"2021-01-01": 100, "2024-01-01": 200}, + "amount": {"2021-01-01": 1}, + }, + {"threshold": {"2021-01-01": 200}, "amount": {"2021-01-01": 2}}, + ] + ) + + assert _duplicate_threshold_errors(scale) == [ + "test_scale at 2024-01-01: brackets [1, 2] share threshold 200" + ] + # The guard targets the silent sum policyengine-core produces. + at_2024 = scale("2024-01-01") + assert at_2024.thresholds == [0, 200] + assert at_2024.amounts == [0, 3] + + +def test_duplicate_threshold_guard_ignores_infinite_thresholds(): + scale = _scale( + [ + {"threshold": {"2021-01-01": 0}, "amount": {"2021-01-01": 0}}, + {"threshold": {"2021-01-01": math.inf}, "amount": {"2021-01-01": 1}}, + {"threshold": {"2021-01-01": math.inf}, "amount": {"2021-01-01": 2}}, + ] + ) + + assert _duplicate_threshold_errors(scale) == [] + + +def test_parameter_scales_have_no_duplicate_finite_thresholds(): + scales = [ + node + for node in system.parameters.get_descendants() + if isinstance(node, ParameterScale) + ] + errors = [error for scale in scales for error in _duplicate_threshold_errors(scale)] + + assert scales + assert errors == [] From 52e3c6776ae49f402119f1e84ecca3b7c270f92c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 24 Sep 2026 19:54:16 -0400 Subject: [PATCH 2/2] Key IRS SOI AGI-by-size amounts to tax year 2020 and fix SOI citations calibration.gov.irs.soi.agi.total_agi holds IRS SOI Table 1.1 values for tax year 2020 (20in11si.xls, column 3), matching all 18 brackets, but keyed them at 2015-01-01. uprate_parameter grows values forward from the latest dated value, so uprating by calibration.gov.cbo.income_by_source.adjusted_gross_income put the 2020 amounts 40.03% above the published table (the index's 2020/2015 ratio) and every later year 40.03% above its value uprated from the published 2020 base. Rekey the amounts to 2020-01-01; thresholds stay at 2015-01-01, and backdate_parameters extends the 2020 values back to 2015 as it already does for number_of_returns. Correct both citations: total_agi cited "IRS SOI PUF 2015 (20in11.xls)" with no href; number_of_returns matches tax year 2021 (21in11si.xls) on all 18 brackets and its href was right, but its title named 20in11si.xls. Add tests pinning each scale's amounts at its table's tax year, checking that uprating runs from the latest dated value, and that 2015 up to the first dated value still evaluates. They select values by date, so a later SOI year can be added without rewriting them. Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-soi-agi-year-keying.fixed.md | 1 + .../gov/irs/soi/agi/number_of_returns.yaml | 2 +- .../gov/irs/soi/agi/total_agi.yaml | 40 ++++----- .../tests/core/test_soi_agi_band_keying.py | 83 +++++++++++++++++++ policyengine_us/tests/test_parameter_files.py | 33 ++++++++ 5 files changed, 139 insertions(+), 20 deletions(-) create mode 100644 changelog.d/fix-soi-agi-year-keying.fixed.md create mode 100644 policyengine_us/tests/core/test_soi_agi_band_keying.py diff --git a/changelog.d/fix-soi-agi-year-keying.fixed.md b/changelog.d/fix-soi-agi-year-keying.fixed.md new file mode 100644 index 00000000000..2a79bd90115 --- /dev/null +++ b/changelog.d/fix-soi-agi-year-keying.fixed.md @@ -0,0 +1 @@ +Keyed the IRS SOI AGI-by-size calibration amounts to 2020, the tax year of the IRS SOI Table 1.1 values they hold, instead of 2015, where uprating had put the 2020 amounts 40% above the published table and every later year 40% above its correctly uprated value; and corrected the source citations for the SOI AGI and return-count scales. diff --git a/policyengine_us/parameters/calibration/gov/irs/soi/agi/number_of_returns.yaml b/policyengine_us/parameters/calibration/gov/irs/soi/agi/number_of_returns.yaml index 82ce792d802..432b2282a60 100644 --- a/policyengine_us/parameters/calibration/gov/irs/soi/agi/number_of_returns.yaml +++ b/policyengine_us/parameters/calibration/gov/irs/soi/agi/number_of_returns.yaml @@ -4,7 +4,7 @@ metadata: amount_unit: return threshold_unit: currency-USD reference: - - title: IRS SOI 2021 (20in11si.xls) + - title: IRS SOI Tax Stats - Publication 1304 Table 1.1, tax year 2021, number of returns href: https://www.irs.gov/pub/irs-soi/21in11si.xls # From https://www.irs.gov/statistics/soi-tax-stats-individual-statistical-tables-by-size-of-adjusted-gross-income brackets: diff --git a/policyengine_us/parameters/calibration/gov/irs/soi/agi/total_agi.yaml b/policyengine_us/parameters/calibration/gov/irs/soi/agi/total_agi.yaml index a5753724b4d..df1ba4a5cc0 100644 --- a/policyengine_us/parameters/calibration/gov/irs/soi/agi/total_agi.yaml +++ b/policyengine_us/parameters/calibration/gov/irs/soi/agi/total_agi.yaml @@ -4,13 +4,15 @@ metadata: amount_unit: currency-USD threshold_unit: currency-USD reference: - - title: IRS SOI PUF 2015 (20in11.xls) + - title: IRS SOI Tax Stats - Publication 1304 Table 1.1, tax year 2020, adjusted gross income less deficit + href: https://www.irs.gov/pub/irs-soi/20in11si.xls + # From https://www.irs.gov/statistics/soi-tax-stats-individual-statistical-tables-by-size-of-adjusted-gross-income brackets: - threshold: 2015-01-01: 1 amount: values: - 2015-01-01: 24_087_842_000 + 2020-01-01: 24_087_842_000 metadata: uprating: calibration.gov.cbo.income_by_source.adjusted_gross_income - threshold: @@ -19,116 +21,116 @@ brackets: metadata: uprating: calibration.gov.cbo.income_by_source.adjusted_gross_income values: - 2015-01-01: 76_144_251_000 + 2020-01-01: 76_144_251_000 - threshold: 2015-01-01: 10_000 amount: metadata: uprating: calibration.gov.cbo.income_by_source.adjusted_gross_income values: - 2015-01-01: 131_879_612_000 + 2020-01-01: 131_879_612_000 - threshold: 2015-01-01: 15_000 amount: metadata: uprating: calibration.gov.cbo.income_by_source.adjusted_gross_income values: - 2015-01-01: 175_213_815_000 + 2020-01-01: 175_213_815_000 - threshold: 2015-01-01: 20_000 amount: metadata: uprating: calibration.gov.cbo.income_by_source.adjusted_gross_income values: - 2015-01-01: 218_827_831_000 + 2020-01-01: 218_827_831_000 - threshold: 2015-01-01: 25_000 amount: metadata: uprating: calibration.gov.cbo.income_by_source.adjusted_gross_income values: - 2015-01-01: 261_305_433_000 + 2020-01-01: 261_305_433_000 - threshold: 2015-01-01: 30_000 amount: metadata: uprating: calibration.gov.cbo.income_by_source.adjusted_gross_income values: - 2015-01-01: 583_890_556_000 + 2020-01-01: 583_890_556_000 - threshold: 2015-01-01: 40_000 amount: metadata: uprating: calibration.gov.cbo.income_by_source.adjusted_gross_income values: - 2015-01-01: 589_155_857_000 + 2020-01-01: 589_155_857_000 - threshold: 2015-01-01: 50_000 amount: metadata: uprating: calibration.gov.cbo.income_by_source.adjusted_gross_income values: - 2015-01-01: 1_388_153_537_000 + 2020-01-01: 1_388_153_537_000 - threshold: 2015-01-01: 75_000 amount: metadata: uprating: calibration.gov.cbo.income_by_source.adjusted_gross_income values: - 2015-01-01: 1_247_263_301_000 + 2020-01-01: 1_247_263_301_000 - threshold: 2015-01-01: 100_000 amount: metadata: uprating: calibration.gov.cbo.income_by_source.adjusted_gross_income values: - 2015-01-01: 3_060_065_884_000 + 2020-01-01: 3_060_065_884_000 - threshold: 2015-01-01: 200_000 amount: metadata: uprating: calibration.gov.cbo.income_by_source.adjusted_gross_income values: - 2015-01-01: 2_187_228_752_000 + 2020-01-01: 2_187_228_752_000 - threshold: 2015-01-01: 500_000 amount: metadata: uprating: calibration.gov.cbo.income_by_source.adjusted_gross_income values: - 2015-01-01: 833_075_077_000 + 2020-01-01: 833_075_077_000 - threshold: 2015-01-01: 1_000_000 amount: metadata: uprating: calibration.gov.cbo.income_by_source.adjusted_gross_income values: - 2015-01-01: 328_832_716_000 + 2020-01-01: 328_832_716_000 - threshold: 2015-01-01: 1_500_000 amount: metadata: uprating: calibration.gov.cbo.income_by_source.adjusted_gross_income values: - 2015-01-01: 187_713_632_000 + 2020-01-01: 187_713_632_000 - threshold: 2015-01-01: 2_000_000 amount: metadata: uprating: calibration.gov.cbo.income_by_source.adjusted_gross_income values: - 2015-01-01: 471_209_618_000 + 2020-01-01: 471_209_618_000 - threshold: 2015-01-01: 5_000_000 amount: metadata: uprating: calibration.gov.cbo.income_by_source.adjusted_gross_income values: - 2015-01-01: 278_473_308_000 + 2020-01-01: 278_473_308_000 - threshold: 2015-01-01: 10_000_000 amount: metadata: uprating: calibration.gov.cbo.income_by_source.adjusted_gross_income values: - 2015-01-01: 824_093_126_000 + 2020-01-01: 824_093_126_000 diff --git a/policyengine_us/tests/core/test_soi_agi_band_keying.py b/policyengine_us/tests/core/test_soi_agi_band_keying.py new file mode 100644 index 00000000000..511510b1042 --- /dev/null +++ b/policyengine_us/tests/core/test_soi_agi_band_keying.py @@ -0,0 +1,83 @@ +"""The IRS SOI AGI-band calibration scales hold their published values in the +tax year they come from. + +``calibration.gov.irs.soi.agi.total_agi`` holds tax year 2020 values from IRS +SOI Publication 1304 Table 1.1 and ``number_of_returns`` holds tax year 2021 +values. ``uprate_parameters`` grows each amount forward from its latest dated +value, so keying a scale to a year before its data inflates every later year: +``total_agi`` sat at 2015-01-01 and its 2020 amounts were about 40% above the +published table. ``CountryTaxBenefitSystem`` then backdates each parameter's +earliest value to 2015-01-01, so years before the first dated amount still +evaluate. +""" + +from datetime import date +from pathlib import Path + +import pytest +import yaml + +from policyengine_us.system import system as SYSTEM + +SOI_AGI_DIR = ( + Path(__file__).resolve().parents[2] / "parameters/calibration/gov/irs/soi/agi" +) +CASES = { + "total_agi": (2020, "calibration.gov.cbo.income_by_source.adjusted_gross_income"), + "number_of_returns": (2021, "calibration.gov.census.populations.total"), +} + + +def _amount_values(name): + parameter = yaml.safe_load((SOI_AGI_DIR / f"{name}.yaml").read_text()) + return [bracket["amount"]["values"] for bracket in parameter["brackets"]] + + +def _scale(name): + return getattr(SYSTEM.parameters.calibration.gov.irs.soi.agi, name) + + +def _uprating_index(path): + node = SYSTEM.parameters + for key in path.split("."): + node = getattr(node, key) + return node + + +@pytest.mark.parametrize("name", CASES) +def test_soi_agi_band_holds_published_values_in_its_tax_year(name): + tax_year, _ = CASES[name] + key = date(tax_year, 1, 1) + amounts = _amount_values(name) + + assert all(key in values for values in amounts), f"{name} lacks {key} amounts" + assert _scale(name)(f"{tax_year}-01-01").amounts == [ + values[key] for values in amounts + ] + + +@pytest.mark.parametrize("name", CASES) +def test_soi_agi_band_uprates_from_its_latest_value(name): + _, index_path = CASES[name] + latest = max(max(values) for values in _amount_values(name)).year + scale = _scale(name) + index = _uprating_index(index_path) + anchor = f"{latest}-01-01" + later = f"{latest + 5}-01-01" + growth = index(later) / index(anchor) + + assert scale(later).amounts == pytest.approx( + [amount * growth for amount in scale(anchor).amounts] + ) + + +@pytest.mark.parametrize("name", CASES) +def test_soi_agi_band_evaluates_before_its_first_value(name): + earliest = min(min(values) for values in _amount_values(name)).year + scale = _scale(name) + at_earliest = scale(f"{earliest}-01-01") + + for year in range(2015, earliest): + earlier = scale(f"{year}-01-01") + assert earlier.thresholds == at_earliest.thresholds, year + assert earlier.amounts == at_earliest.amounts, year diff --git a/policyengine_us/tests/test_parameter_files.py b/policyengine_us/tests/test_parameter_files.py index 1ce87fc239b..06f47a58368 100644 --- a/policyengine_us/tests/test_parameter_files.py +++ b/policyengine_us/tests/test_parameter_files.py @@ -8,6 +8,24 @@ SOI_LONG_TERM_CAPITAL_GAINS_PATH = ( PARAMETERS_DIR / "calibration/gov/irs/soi/long_term_capital_gains.yaml" ) +SOI_AGI_DIR = PARAMETERS_DIR / "calibration/gov/irs/soi/agi" +# IRS SOI Publication 1304 Table 1.1, "$1 under $5,000" and "$10,000,000 or +# more" rows: column 3 (AGI less deficit, published in thousands, stored here in +# dollars) for tax year 2020 and column 1 (number of returns) for tax year 2021. +SOI_AGI_BAND_TABLES = { + "total_agi.yaml": ( + date(2020, 1, 1), + "https://www.irs.gov/pub/irs-soi/20in11si.xls", + 24_087_842_000, + 824_093_126_000, + ), + "number_of_returns.yaml": ( + date(2021, 1, 1), + "https://www.irs.gov/pub/irs-soi/21in11si.xls", + 8_487_025, + 45_404, + ), +} MI_INCOME_TAX_RATE_PATH = PARAMETERS_DIR / "gov/states/mi/tax/income/rate.yaml" SOI_LONG_TERM_CAPITAL_GAINS_ANCHORS = { date(2015, 1, 1): 733_313_255_000, @@ -150,6 +168,21 @@ def test_soi_long_term_capital_gains_uses_latest_publication_1304_anchor(): ) +def test_soi_agi_bands_are_keyed_to_their_table_1_1_tax_year(): + for filename, (period, href, lowest, highest) in SOI_AGI_BAND_TABLES.items(): + parameter = yaml.safe_load((SOI_AGI_DIR / filename).read_text()) + amounts = [bracket["amount"]["values"] for bracket in parameter["brackets"]] + + assert len(amounts) == 18, filename + for index, values in enumerate(amounts): + assert period in values, f"{filename} bracket {index} lacks {period}" + assert amounts[0][period] == lowest, filename + assert amounts[-1][period] == highest, filename + reference = parameter["metadata"]["reference"][0] + assert reference["href"] == href, filename + assert f"tax year {period.year}" in reference["title"], filename + + def test_mi_2026_income_tax_rate_uses_official_annual_determination(): parameter = yaml.safe_load(MI_INCOME_TAX_RATE_PATH.read_text())