From a8b188437e376b3a2c51f736dcd275a540a0cd69 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 24 Sep 2026 14:07:28 -0400 Subject: [PATCH 1/3] 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 24de218c55b9245591cf4c0aef1ad949d060e373 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 26 Sep 2026 09:35:29 -0400 Subject: [PATCH 2/3] Address review: mirror core in the threshold guard, flag -inf ties, add invariants Review fixes for #9599: - The guard now reads values with core's own Parameter._get_at_instant, so entries sharing a date resolve first-match as core does (an uprating start_instant can write a second entry for a date), and it counts only the brackets ParameterScale._get_at_instant passes to add_bracket (threshold and value child both non-None, value child chosen by scale type). - It checks every date any bracket child changes, not only threshold dates, since a collision can start when an amount starts. - Only +inf duplicates are exempt; -inf is a reachable bottom row and core sums duplicates there too. - Failures print one line per collision with its instant range. - Drop the self-test assertions that pinned core's summing, which policyengine-core#550 would change. - New differential test: for all 867 scales at every calendar-valid change date, the thresholds the guard sees match the scale core builds. - New exhaustive invariant tests for the Arkansas low-income tables (2021-2030): tax is non-decreasing in integer AGI up to the cutoff and infinite above it, and the head of household and surviving spouse tables agree. On main the monotonicity test fails for exactly the two 2024 tables fixed here. - Point the 2023 booklet anchor in both edited Arkansas files at PDF page 29 (the low-income tables) instead of page 25 (a worksheet). - Add YAML cases for the last 2024 rows, the $28,200 cutoff, and tax before credits. Co-Authored-By: Claude Opus 5.5 --- ...ar-low-income-duplicate-threshold.fixed.md | 2 +- .../two_or_more_dependents.yaml | 2 +- .../two_or_more_dependents.yaml | 2 +- .../low_income/ar_low_income_tax_joint.yaml | 98 +++++++ .../test_ar_low_income_tax_tables.py | 64 +++++ .../tests/test_parameter_scale_thresholds.py | 247 +++++++++++++----- 6 files changed, 353 insertions(+), 62 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/low_income/test_ar_low_income_tax_tables.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 index 10680ba8996..afcbde8f66a 100644 --- a/changelog.d/fix-ar-low-income-duplicate-threshold.fixed.md +++ b/changelog.d/fix-ar-low-income-duplicate-threshold.fixed.md @@ -1 +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. +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 tests that fail when any parameter scale repeats a threshold (other than +inf) or an Arkansas low-income table stops rising with AGI. 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 b2356415e31..a88ebf656c5 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 @@ -14,7 +14,7 @@ metadata: - title: Arkansas Income Tax Instructions, 2021, Low Income Tax Tables href: https://www.dfa.arkansas.gov/wp-content/uploads/2021_AR1000F_and_AR1000NR_Instructions.pdf#page=26 - title: Arkansas Income Tax Instructions, 2023, Low Income Tax Tables - href: https://www.dfa.arkansas.gov/wp-content/uploads/2023_AR1000F_and_AR1000NR_Instructions.pdf#page=25 + href: https://www.dfa.arkansas.gov/wp-content/uploads/2023_AR1000F_and_AR1000NR_Instructions.pdf#page=29 - 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=28 - title: Arkansas Income Tax Instructions, 2025, Low Income Tax Tables 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 2c04eb622e4..55426c46d1f 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 @@ -14,7 +14,7 @@ metadata: - title: Arkansas Income Tax Instructions, 2021, Low Income Tax Tables href: https://www.dfa.arkansas.gov/wp-content/uploads/2021_AR1000F_and_AR1000NR_Instructions.pdf#page=26 - title: Arkansas Income Tax Instructions, 2023, Low Income Tax Tables - href: https://www.dfa.arkansas.gov/wp-content/uploads/2023_AR1000F_and_AR1000NR_Instructions.pdf#page=25 + href: https://www.dfa.arkansas.gov/wp-content/uploads/2023_AR1000F_and_AR1000NR_Instructions.pdf#page=29 - 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=28 - title: Arkansas Income Tax Instructions, 2025, Low Income Tax Tables 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 4fbd3d8dac5..49e11429e8b 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 @@ -485,3 +485,101 @@ state_code: AR output: ar_low_income_tax_joint: [0, 0, 92, 92, 104, 104, 104, 116, 116, 129] + +- name: Case 21, 2024 head of household and surviving spouse with 2 dependents in the last table rows. + period: 2024 + input: + people: + person1: + ar_agi_joint: 28_100 + person2: + ar_agi_joint: 28_101 + person3: + ar_agi_joint: 28_200 + person4: + ar_agi_joint: 28_100 + person5: + ar_agi_joint: 28_101 + person6: + ar_agi_joint: 28_200 + 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: 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 + households: + household: + members: [person1, person2, person3, person4, person5, person6] + state_code: AR + output: + # $28,001-$28,100 -> $575; $28,101-$28,200 -> $588. + ar_low_income_tax_joint: [575, 588, 588, 575, 588, 588] + +- name: Case 22, 2024 head of household and surviving spouse with 2 dependents above the $28,200 cutoff. + period: 2024 + input: + people: + person1: + ar_agi_joint: 28_201 + person2: + ar_agi_joint: 28_201 + tax_units: + tax_unit1: + members: [person1] + filing_status: HEAD_OF_HOUSEHOLD + tax_unit_dependents: 2 + tax_unit2: + members: [person2] + filing_status: SURVIVING_SPOUSE + tax_unit_dependents: 2 + households: + household: + members: [person1, person2] + state_code: AR + output: + ar_uses_low_income_tax_tables: [false, false] + +- name: Case 23, 2024 head of household with 2 dependents pays the table tax before credits. + period: 2024 + input: + people: + person1: + ar_agi_joint: 24_190 + person2: + ar_agi_joint: 24_250 + 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 + households: + household: + members: [person1, person2] + state_code: AR + output: + ar_low_income_tax_joint: [92, 104] + ar_income_tax_before_non_refundable_credits_joint: [92, 104] diff --git a/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/low_income/test_ar_low_income_tax_tables.py b/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/low_income/test_ar_low_income_tax_tables.py new file mode 100644 index 00000000000..a54c664ecd0 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/low_income/test_ar_low_income_tax_tables.py @@ -0,0 +1,64 @@ +"""Invariants of the Arkansas low-income tax tables, checked at every integer AGI. + +`ar_low_income_tax_joint` evaluates these scales with `right=True`. The +published tables charge more tax at higher AGI until a cutoff, above which +filers use the regular tax table (encoded as an infinite amount). The head of +household and surviving spouse files encode the same published column +("Filing Status 3 or 6"), so they must agree. A 2024 data error broke the +first invariant: two brackets shared a threshold, core summed them, and the +table charged $196 at $24,201-$24,300 and then $116 at $24,301-$24,400. +""" + +import numpy as np +import pytest +from policyengine_core.parameters import ParameterScale + +from policyengine_us.system import system + +TABLES = system.parameters.gov.states.ar.tax.income.rates.low_income_tax_tables +SCALES = { + node.name.removeprefix(TABLES.name + "."): node + for node in TABLES.get_descendants() + if isinstance(node, ParameterScale) +} +YEARS = range(2021, 2031) + + +def _tax_by_integer_agi(scale, year): + at_instant = scale(f"{year}-01-01") + top = max(t for t in at_instant.thresholds if np.isfinite(t)) + agi = np.arange(0, int(top) + 2) + return at_instant.calc(agi, right=True) + + +def test_tables_cover_every_filing_status_and_dependent_tier(): + assert sorted(SCALES) == [ + "head_of_household.no_or_one_dependent", + "head_of_household.two_or_more_dependents", + "joint.no_or_one_dependent", + "joint.two_or_more_dependents", + "single", + "surviving_spouse.no_or_one_dependent", + "surviving_spouse.two_or_more_dependents", + ] + + +@pytest.mark.parametrize("year", YEARS) +@pytest.mark.parametrize("table", sorted(SCALES)) +def test_tax_rises_with_agi_until_the_cutoff(table, year): + tax = _tax_by_integer_agi(SCALES[table], year) + finite = np.isfinite(tax) + + assert finite[0] and not finite[-1] + # Once AGI passes the cutoff it never re-enters the table. + assert not np.any(finite[1:] & ~finite[:-1]) + assert np.all(np.diff(tax[finite]) >= 0) + + +@pytest.mark.parametrize("year", YEARS) +@pytest.mark.parametrize("tier", ["no_or_one_dependent", "two_or_more_dependents"]) +def test_head_of_household_and_surviving_spouse_tables_agree(tier, year): + head_of_household = _tax_by_integer_agi(SCALES[f"head_of_household.{tier}"], year) + surviving_spouse = _tax_by_integer_agi(SCALES[f"surviving_spouse.{tier}"], year) + + np.testing.assert_array_equal(head_of_household, surviving_spouse) diff --git a/policyengine_us/tests/test_parameter_scale_thresholds.py b/policyengine_us/tests/test_parameter_scale_thresholds.py index f7ab331c316..3db6daabf2d 100644 --- a/policyengine_us/tests/test_parameter_scale_thresholds.py +++ b/policyengine_us/tests/test_parameter_scale_thresholds.py @@ -1,66 +1,98 @@ -"""Parameter scales must not repeat a finite threshold at any instant. +"""Parameter scales must not repeat a 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`. +`ParameterScale._get_at_instant` passes brackets 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 `+inf` thresholds are exempt: tables whose length changes over +time park unused brackets there, and no finite value reaches them. `-inf` is +a reachable bottom row, so duplicates there are flagged. """ import math from collections import defaultdict +from datetime import date -from policyengine_core.parameters import ParameterScale +from policyengine_core.parameters import ParameterNode, ParameterScale +from policyengine_core.parameters.operations.uprate_parameters import ( + uprate_parameters, +) 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 +def _add_bracket_thresholds(scale, instant_str): + """(bracket index, threshold) for each bracket that + `ParameterScale._get_at_instant` passes to `add_bracket` at the instant. + + Mirrors core: a bracket joins only when its threshold and its value child + (chosen by scale type, as core chooses it) are both non-None at the + instant. Values come from `Parameter._get_at_instant`, which takes the + first `values_list` entry on or before the instant; calling it with the + string directly also handles padded 0000-01-01 entries, which the public + lookup cannot turn into a date. + """ + present = [ + { + name: value + for name, child in bracket.children.items() + if (value := child._get_at_instant(instant_str)) is not None + } + for bracket in scale.brackets + ] + if scale.metadata.get("type") == "single_amount" or any( + "amount" in children for children in present ): - if value_at_instant.instant_str > instant_str: - break - value = value_at_instant.value - return value + value_key = "amount" + elif any("average_rate" in children for children in present): + value_key = "average_rate" + else: + value_key = "rate" + return [ + (index, children["threshold"]) + for index, children in enumerate(present) + if "threshold" in children and value_key in children + ] -def _duplicate_threshold_errors(scale): - thresholds = [bracket.children.get("threshold") for bracket in scale.brackets] - instants = sorted( +def _change_instants(scale): + # A collision can start when a threshold changes or when a bracket's + # amount or rate starts, so check every date any bracket child changes. + return sorted( { value_at_instant.instant_str - for threshold in thresholds - if threshold is not None - for value_at_instant in threshold.values_list + for bracket in scale.brackets + for child in bracket.children.values() + for value_at_instant in getattr(child, "values_list", []) } ) - errors = [] - for instant_str in instants: + + +def _duplicate_threshold_errors(scale): + instants_by_collision = defaultdict(list) + for instant_str in _change_instants(scale): 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(): + for index, threshold in _add_bracket_thresholds(scale, instant_str): + if threshold != math.inf: + brackets_by_threshold[float(threshold)].append(index) + for threshold, 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}" - ) + instants_by_collision[(tuple(indices), threshold)].append(instant_str) + errors = [] + for (indices, threshold), instants in instants_by_collision.items(): + when = instants[0] + if len(instants) > 1: + when = f"{len(instants)} instants from {instants[0]} to {instants[-1]}" + errors.append( + f"{scale.name}: brackets {list(indices)} share threshold " + f"{threshold:,.15g} at {when}" + ) return errors @@ -72,6 +104,14 @@ def _scale(brackets): ) +def _all_scales(): + return [ + node + for node in system.parameters.get_descendants() + if isinstance(node, ParameterScale) + ] + + def test_duplicate_threshold_guard_flags_a_later_collision(): scale = _scale( [ @@ -85,33 +125,122 @@ def test_duplicate_threshold_guard_flags_a_later_collision(): ) assert _duplicate_threshold_errors(scale) == [ - "test_scale at 2024-01-01: brackets [1, 2] share threshold 200" + "test_scale: brackets [1, 2] share threshold 200 at 2024-01-01" ] - # 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(): +def test_duplicate_threshold_guard_ignores_only_positive_infinity(): + def scale_with_two_brackets_at(threshold): + return _scale( + [ + {"threshold": {"2021-01-01": threshold}, "amount": {"2021-01-01": 1}}, + {"threshold": {"2021-01-01": threshold}, "amount": {"2021-01-01": 2}}, + ] + ) + + assert _duplicate_threshold_errors(scale_with_two_brackets_at(math.inf)) == [] + assert _duplicate_threshold_errors(scale_with_two_brackets_at(-math.inf)) == [ + "test_scale: brackets [0, 1] share threshold -inf at 2021-01-01" + ] + + +def test_duplicate_threshold_guard_skips_brackets_core_skips(): + # Core leaves out a bracket whose amount has not started yet, so the + # shared threshold only collides once bracket 1's amount begins. 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}}, + {"threshold": {"2021-01-01": 100}, "amount": {"2021-01-01": 1}}, + {"threshold": {"2021-01-01": 100}, "amount": {"2024-01-01": 2}}, ] ) - assert _duplicate_threshold_errors(scale) == [] + assert _duplicate_threshold_errors(scale) == [ + "test_scale: brackets [0, 1] share threshold 100 at 2024-01-01" + ] -def test_parameter_scales_have_no_duplicate_finite_thresholds(): - scales = [ - node - for node in system.parameters.get_descendants() - if isinstance(node, ParameterScale) +def _uprated_scale(other_threshold): + # Uprating from start_instant writes a second 2022-01-01 entry after the + # explicit one; core's lookup takes the explicit 300. + root = ParameterNode( + "root", + data={ + "index": {"values": {"2021-01-01": 1, "2022-01-01": 2}}, + "table": { + "metadata": {"type": "single_amount"}, + "brackets": [ + { + "threshold": { + "values": {"2021-01-01": 100, "2022-01-01": 300}, + "metadata": { + "uprating": { + "parameter": "index", + "start_instant": "2021-01-01", + } + }, + }, + "amount": {"2021-01-01": 10}, + }, + { + "threshold": {"2021-01-01": other_threshold}, + "amount": {"2021-01-01": 20}, + }, + ], + }, + }, + ) + uprate_parameters(root) + return root.table + + +def test_duplicate_threshold_guard_reads_tied_dates_like_core(): + colliding = _uprated_scale(300) + distinct = _uprated_scale(200) + tied = [ + entry.instant_str + for entry in colliding.brackets[0].children["threshold"].values_list ] + + assert tied.count("2022-01-01") == 2 + assert _duplicate_threshold_errors(colliding) == [ + "root.table: brackets [0, 1] share threshold 300 at 2022-01-01" + ] + assert _duplicate_threshold_errors(distinct) == [] + + +def _is_calendar_date(instant_str): + # Core's public lookup parses the instant, which fails for padded + # 0000-01-01 entries and for 2021-06-31 in + # gov.states.ny.nyserda.drive_clean.amount. + try: + date.fromisoformat(instant_str) + except ValueError: + return False + return True + + +def test_add_bracket_thresholds_match_core_for_every_scale_and_instant(): + mismatches = [] + for scale in _all_scales(): + for instant_str in _change_instants(scale): + if not _is_calendar_date(instant_str): + continue + expected = sorted( + { + float(threshold) + for _, threshold in _add_bracket_thresholds(scale, instant_str) + } + ) + actual = [float(threshold) for threshold in scale(instant_str).thresholds] + if expected != actual: + mismatches.append(f"{scale.name} at {instant_str}") + + assert not mismatches, "\n".join(mismatches) + + +def test_parameter_scales_have_no_duplicate_thresholds(): + scales = _all_scales() errors = [error for scale in scales for error in _duplicate_threshold_errors(scale)] assert scales - assert errors == [] + assert not errors, "\n".join(errors) From e237a7491a7d55fce0e65c2999182d7c752b875f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 27 Sep 2026 04:32:18 -0400 Subject: [PATCH 3/3] Compare add_bracket calls in the differential test; cover 2015 tables - The differential test now records every threshold core passes to add_bracket (repeats included) and compares that multiset with the guard's list. Comparing the built scale's de-duplicated thresholds could not see a disagreement about a bracket at an already-taken threshold, which is the case the guard decides. - The tied-date self-test skips instead of failing if core's uprating ever stops writing the tied entry it reproduces, since CI upgrades core. - The Arkansas table invariants now start in 2015, now that main has the 2015-2020 tables (#9615); they hold for every year 2015-2030. Co-Authored-By: Claude Opus 5.5 --- .../test_ar_low_income_tax_tables.py | 2 +- .../tests/test_parameter_scale_thresholds.py | 32 ++++++++++++++----- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/low_income/test_ar_low_income_tax_tables.py b/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/low_income/test_ar_low_income_tax_tables.py index a54c664ecd0..0b06490d828 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/low_income/test_ar_low_income_tax_tables.py +++ b/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/low_income/test_ar_low_income_tax_tables.py @@ -21,7 +21,7 @@ for node in TABLES.get_descendants() if isinstance(node, ParameterScale) } -YEARS = range(2021, 2031) +YEARS = range(2015, 2031) def _tax_by_integer_agi(scale, year): diff --git a/policyengine_us/tests/test_parameter_scale_thresholds.py b/policyengine_us/tests/test_parameter_scale_thresholds.py index 3db6daabf2d..0899577cbed 100644 --- a/policyengine_us/tests/test_parameter_scale_thresholds.py +++ b/policyengine_us/tests/test_parameter_scale_thresholds.py @@ -19,10 +19,13 @@ from collections import defaultdict from datetime import date +import pytest from policyengine_core.parameters import ParameterNode, ParameterScale from policyengine_core.parameters.operations.uprate_parameters import ( uprate_parameters, ) +from policyengine_core.taxscales.amount_tax_scale_like import AmountTaxScaleLike +from policyengine_core.taxscales.rate_tax_scale_like import RateTaxScaleLike from policyengine_us.system import system @@ -201,7 +204,8 @@ def test_duplicate_threshold_guard_reads_tied_dates_like_core(): for entry in colliding.brackets[0].children["threshold"].values_list ] - assert tied.count("2022-01-01") == 2 + if tied.count("2022-01-01") != 2: + pytest.skip("core's uprating no longer writes a tied 2022-01-01 entry") assert _duplicate_threshold_errors(colliding) == [ "root.table: brackets [0, 1] share threshold 300 at 2022-01-01" ] @@ -219,20 +223,32 @@ def _is_calendar_date(instant_str): return True -def test_add_bracket_thresholds_match_core_for_every_scale_and_instant(): +def test_add_bracket_thresholds_match_core_for_every_scale_and_instant(monkeypatch): + # Record every threshold core passes to add_bracket, repeats included, and + # compare that multiset with the guard's; comparing the built scale's + # thresholds would hide exactly the repeats the guard exists to find. + passed = [] + for scale_class in (AmountTaxScaleLike, RateTaxScaleLike): + original = scale_class.add_bracket + + def recording_add_bracket(self, threshold, value, original=original): + passed.append(float(threshold)) + return original(self, threshold, value) + + monkeypatch.setattr(scale_class, "add_bracket", recording_add_bracket) + mismatches = [] for scale in _all_scales(): for instant_str in _change_instants(scale): if not _is_calendar_date(instant_str): continue + passed.clear() + scale(instant_str) expected = sorted( - { - float(threshold) - for _, threshold in _add_bracket_thresholds(scale, instant_str) - } + float(threshold) + for _, threshold in _add_bracket_thresholds(scale, instant_str) ) - actual = [float(threshold) for threshold in scale(instant_str).thresholds] - if expected != actual: + if expected != sorted(passed): mismatches.append(f"{scale.name} at {instant_str}") assert not mismatches, "\n".join(mismatches)