From 51cfffe0159db9a513fd394bdc99680541c65ff7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 11:25:55 -0400 Subject: [PATCH 1/5] Use Louisiana's RIB 26-019 2026 standard deduction and retirement exemption LDR Revenue Information Bulletin 26-019 (2026-09-28) sets the 2026 standard deduction at $12,838 single/separate and $25,676 joint, head of household and surviving spouse, and the age-65 retirement income exemption at $12,324: each 2025 amount times a 2.7% CPI-U multiplier, rounded to the dollar, with the joint amount twice the rounded single amount. PolicyEngine carried a self-computed $12,835 / $25,670 (PR #8411, unrounded 2.677%) and held the retirement cap at $12,000. Later years follow La. R.S. 47:294(B) and 47:44.1(A): the prior year's amount times the prior calendar year's CPI-U increase. The new extend_la_cpi_u_indexed_amounts reproduces RIB 26-019 from the 2025 amounts (BLS's one-decimal December-over-December change, decimal half-up rounding, joint = 2 x single) and replaces the plain CPI-U metadata uprating, which had no joint-doubling or rounding rule. The February 20, 2026 Louisiana Register reference is dropped from the return-deduction parameter: it is LDR's withholding formula ($12,875), not the return amount. Co-Authored-By: Claude Opus 5.5 --- changelog.d/la-2026-rib-26-019.fixed.md | 1 + .../income/deductions/standard/amount.yaml | 28 +- .../income/exempt_income/retirement/cap.yaml | 14 +- .../parameters/uprating_extensions.py | 102 ++++++ .../deductions/la_standard_deduction.yaml | 32 +- .../la_retirement_exemption_person.yaml | 76 +++++ .../gov/states/la/tax/income/integration.yaml | 10 +- .../la/tax/income/test_la_cpi_u_indexing.py | 319 ++++++++++++++++++ .../la_retirement_exemption_person.py | 5 +- 9 files changed, 562 insertions(+), 25 deletions(-) create mode 100644 changelog.d/la-2026-rib-26-019.fixed.md create mode 100644 policyengine_us/tests/policy/baseline/gov/states/la/tax/income/test_la_cpi_u_indexing.py diff --git a/changelog.d/la-2026-rib-26-019.fixed.md b/changelog.d/la-2026-rib-26-019.fixed.md new file mode 100644 index 00000000000..a63a78a60d2 --- /dev/null +++ b/changelog.d/la-2026-rib-26-019.fixed.md @@ -0,0 +1 @@ +Use Louisiana's official 2026 standard deduction ($12,838 single or separate, $25,676 joint, head of household or surviving spouse) and age-65 retirement income exemption ($12,324) from LDR Revenue Information Bulletin 26-019, and project both after 2026 with La. R.S. 47:294(B) and 47:44.1(A)'s annual CPI-U adjustment. diff --git a/policyengine_us/parameters/gov/states/la/tax/income/deductions/standard/amount.yaml b/policyengine_us/parameters/gov/states/la/tax/income/deductions/standard/amount.yaml index 47e772c9500..97803903b61 100644 --- a/policyengine_us/parameters/gov/states/la/tax/income/deductions/standard/amount.yaml +++ b/policyengine_us/parameters/gov/states/la/tax/income/deductions/standard/amount.yaml @@ -3,15 +3,17 @@ metadata: period: year unit: currency-USD label: Louisiana standard deduction amount - uprating: gov.bls.cpi.cpi_u + # Years after the last value below follow R.S. 47:294(B)'s annual CPI-U + # adjustment, computed by extend_la_cpi_u_indexed_amounts in + # policyengine_us/parameters/uprating_extensions.py. propagate_metadata_to_children: true breakdown: - filing_status reference: - title: Louisiana Revised Statutes, RS 47:294 - Standard deduction href: https://www.legis.la.gov/legis/Law.aspx?d=101761 - - title: Louisiana Register, February 20, 2026, Income Tax Withholding Tables notice - href: https://bese.louisiana.gov/docs/default-source/rulemaking-docket/feb-louisiana-register.pdf#page=127 + - title: Louisiana Department of Revenue, Revenue Information Bulletin 26-019 (September 28, 2026), Inflation Adjustment to Standard Deduction and Retirement Income Exemption + href: https://dam.ldr.la.gov/lawspolicies/RIB%2026-019.pdf#page=1 - title: Consumer Price Index News Release - 2025 M12 Results href: https://www.bls.gov/news.release/archives/cpi_01132026.htm - title: 2025 Louisiana Resident Income Tax Return Instructions, Line 8 @@ -19,20 +21,22 @@ metadata: SINGLE: 2025-01-01: 12_500 - # 2026 amount applies the December-to-December 2025 CPI-U increase: - # 12,500 * (324.054 / 315.605), rounded to the nearest dollar. - 2026-01-01: 12_835 + # RIB 26-019 applies a 2.7% CPI-U multiplier (BLS reported the December + # 2024 to December 2025 increase as 2.7%): 12,500 * 1.027 = 12,837.50, + # rounded to 12,838. + 2026-01-01: 12_838 SURVIVING_SPOUSE: 2025-01-01: 25_000 - 2026-01-01: 25_670 + 2026-01-01: 25_676 SEPARATE: 2025-01-01: 12_500 - 2026-01-01: 12_835 -# The legal code defines the HoH and Joint amounts as 200% of the -# Single amount. + 2026-01-01: 12_838 +# R.S. 47:294(A)(2) sets the head of household, joint and surviving spouse +# amounts at 200% of the single amount; RIB 26-019 doubles the rounded +# single amount (2 * 12,838 = 25,676, where 25,000 * 1.027 = 25,675). HEAD_OF_HOUSEHOLD: 2025-01-01: 25_000 - 2026-01-01: 25_670 + 2026-01-01: 25_676 JOINT: 2025-01-01: 25_000 - 2026-01-01: 25_670 + 2026-01-01: 25_676 diff --git a/policyengine_us/parameters/gov/states/la/tax/income/exempt_income/retirement/cap.yaml b/policyengine_us/parameters/gov/states/la/tax/income/exempt_income/retirement/cap.yaml index f068d7cdabc..a750b07853d 100644 --- a/policyengine_us/parameters/gov/states/la/tax/income/exempt_income/retirement/cap.yaml +++ b/policyengine_us/parameters/gov/states/la/tax/income/exempt_income/retirement/cap.yaml @@ -1,20 +1,28 @@ description: Louisiana caps the retirement income exemption at this amount for filers, based on age. brackets: - - threshold: + - threshold: 2021-01-01: 0 - amount: + amount: 2021-01-01: 0 - threshold: 2001-01-01: 65 amount: 2001-01-01: 6_000 2025-01-01: 12_000 + # RIB 26-019 applies a 2.7% CPI-U multiplier (BLS reported the December + # 2024 to December 2025 increase as 2.7%): 12,000 * 1.027 = 12,324. + # Later years follow R.S. 47:44.1(A)'s annual CPI-U adjustment, + # computed by extend_la_cpi_u_indexed_amounts in + # policyengine_us/parameters/uprating_extensions.py. + 2026-01-01: 12_324 metadata: reference: - title: Louisiana statutes, RS 47:44.1 - Annual retirement or disability income; exemption from taxation (A) href: https://www.legis.la.gov/legis/Law.aspx?d=102133 + - title: Louisiana Department of Revenue, Revenue Information Bulletin 26-019 (September 28, 2026), Inflation Adjustment to Standard Deduction and Retirement Income Exemption + href: https://dam.ldr.la.gov/lawspolicies/RIB%2026-019.pdf#page=1 - title: 2025 Louisiana Resident Income Tax Return Instructions, What's New - Code 6E href: https://dam.ldr.la.gov/taxforms/IT540i%20WEB(2025)D11.pdf#page=1 - title: 2024 Louisiana Resident Income Tax Return @@ -24,7 +32,7 @@ metadata: - title: 2022 Louisiana Resident Income Tax Return Instructions, Exemption Code 06E href: https://dam.ldr.la.gov/taxforms/IT540iWEB(2022)D1.pdf#page=6 threshold_unit: year - amount_unit: currency-USD + amount_unit: currency-USD type: single_amount period: year label: Louisiana retirement income exemption cap diff --git a/policyengine_us/parameters/uprating_extensions.py b/policyengine_us/parameters/uprating_extensions.py index 1e047d86f67..ca02375d4e8 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -1,6 +1,7 @@ """Unified script to extend all uprating factors through 2100.""" import math +from decimal import ROUND_HALF_UP, Decimal from typing import Optional, Tuple from policyengine_us.model_api import * @@ -202,6 +203,101 @@ def extend_or_ctc_parameters(parameters: ParameterNode, end_year: int) -> None: ) +def get_la_cpi_u_percentage_increase(cpi: Parameter, year: int) -> float: + """The CPI-U percentage increase for calendar ``year``, as BLS reports it. + + La. R.S. 47:294(B) and 47:44.1(A) multiply the prior year's amount by + "the percentage increase in the Consumer Price Index ... (CPI-U), as + reported by the United States Department of Labor, Bureau of Labor + Statistics ... for the previous calendar year". For tax year 2026, LDR + Revenue Information Bulletin 26-019 applied 2.7%. The bulletin names no + comparison months, but 2.7% is the December 2024 to December 2025 change + as BLS reported it (324.054 / 315.605, not seasonally adjusted: 2.677%). + The 2025 annual-average change (321.943 / 313.689) was 2.6%, and the + unrounded 2.677% would give $12,835 rather than the bulletin's $12,838. + So the percentage is the December-over-December change rounded to one + decimal place, as BLS reports it. A fall in the index is not an + increase, so the percentage is never negative. + + The model's CPI-U series is seasonally adjusted from January 2024, and + after its last monthly observation it holds annual projection points at + February instants. The index is read at December 1 of each year: the + December observation once BLS publishes it, otherwise the latest + observation or projection point at or before that date. + """ + change = cpi(f"{year}-12-01") / cpi(f"{year - 1}-12-01") - 1 + return max(math.floor(1_000 * change + 0.5) / 10, 0) + + +def adjust_la_amount_for_inflation(prior_amount: float, percentage: float) -> float: + """Apply a La. R.S. 47:294(B) / 47:44.1(A) adjustment to a prior amount. + + RIB 26-019's amounts are the adjusted amounts rounded to the nearest + dollar, with half dollars rounding up: $12,500 x 1.027 = $12,837.50 + became $12,838. The arithmetic is decimal because in binary floating + point 12,500 x 1.027 is just below 12,837.5 and would round down. + """ + adjusted = Decimal(str(prior_amount)) * (100 + Decimal(str(percentage))) / 100 + return float(adjusted.quantize(Decimal(1), rounding=ROUND_HALF_UP)) + + +def extend_la_cpi_u_indexed_amounts(parameters: ParameterNode, end_year: int) -> None: + """Project Louisiana's CPI-U-indexed standard deduction and retirement cap. + + Each year after the last LDR-published amount, the single and + married-separate standard deduction (R.S. 47:294(A)(1)) and the age-65 + retirement income exemption (R.S. 47:44.1(A)) become the prior year's + amount adjusted by the prior calendar year's CPI-U percentage increase + (see ``get_la_cpi_u_percentage_increase``), rounded to the nearest + dollar. The joint, head of household and surviving spouse deduction is + 200% of the single amount (R.S. 47:294(A)(2)), so it doubles the rounded + single amount rather than being adjusted on its own. Amounts encoded in + the YAML take precedence for their own years. + """ + la = parameters.gov.states.la.tax.income + cpi = parameters.gov.bls.cpi.cpi_u + standard = la.deductions.standard.amount + retirement_cap = next( + bracket.amount + for bracket in la.exempt_income.retirement.cap.brackets + if bracket.threshold(f"{end_year}-01-01") == 65 + ) + for parameter in (standard.SINGLE, retirement_cap): + first_projected_year = 1 + max( + int(value.instant_str[:4]) for value in parameter.values_list + ) + for year in range(first_projected_year, end_year + 1): + parameter.update( + period=f"year:{year}-01-01:1", + value=adjust_la_amount_for_inflation( + parameter(f"{year - 1}-01-01"), + get_la_cpi_u_percentage_increase(cpi, year - 1), + ), + ) + parameter.update( + start=instant(f"{end_year}-01-01"), + value=parameter(f"{end_year}-01-01"), + ) + for parameter, share_of_single in ( + (standard.SEPARATE, 1), + (standard.JOINT, 2), + (standard.HEAD_OF_HOUSEHOLD, 2), + (standard.SURVIVING_SPOUSE, 2), + ): + first_projected_year = 1 + max( + int(value.instant_str[:4]) for value in parameter.values_list + ) + for year in range(first_projected_year, end_year + 1): + parameter.update( + period=f"year:{year}-01-01:1", + value=share_of_single * standard.SINGLE(f"{year}-01-01"), + ) + parameter.update( + start=instant(f"{end_year}-01-01"), + value=parameter(f"{end_year}-01-01"), + ) + + def extend_wa_millionaires_standard_deduction( parameters: ParameterNode, end_year: int, @@ -633,6 +729,12 @@ def set_all_uprating_parameters(parameters: ParameterNode) -> ParameterNode: # above so projected windows are available. extend_or_ctc_parameters(parameters, end_year=END_YEAR) + # Louisiana's standard deduction and age-65 retirement income exemption + # follow R.S. 47:294(B) and 47:44.1(A): the prior year's amount times the + # prior calendar year's CPI-U increase. Must run after the CPI-U extension + # above so projected Decembers are available. + extend_la_cpi_u_indexed_amounts(parameters, end_year=END_YEAR) + # The limits on a dependent's standard deduction follow 26 U.S.C. # 63(c)(4)'s chained CPI-U schedule, computed from the statutory 1987 and # 1997 bases. Must run after the Chained CPI-U extension above so diff --git a/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/deductions/la_standard_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/deductions/la_standard_deduction.yaml index d58a99c32fa..9a4f6eaaf7f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/deductions/la_standard_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/deductions/la_standard_deduction.yaml @@ -14,18 +14,42 @@ output: la_standard_deduction: 25_000 -- name: 2026 single filer uses prior-calendar-year CPI-U adjustment +- name: 2026 single filer gets the RIB 26-019 amount, 12,500 x 1.027 rounded period: 2026 input: state_code: LA filing_status: SINGLE output: - la_standard_deduction: 12_835 + la_standard_deduction: 12_838 -- name: 2026 head of household filer gets twice the single amount +- name: 2026 married filing separately filer gets the single amount + period: 2026 + input: + state_code: LA + filing_status: SEPARATE + output: + la_standard_deduction: 12_838 + +- name: 2026 head of household filer gets twice the rounded single amount period: 2026 input: state_code: LA filing_status: HEAD_OF_HOUSEHOLD output: - la_standard_deduction: 25_670 + la_standard_deduction: 25_676 + +- name: 2026 joint filer gets twice the rounded single amount, not 25,000 x 1.027 + period: 2026 + input: + state_code: LA + filing_status: JOINT + output: + la_standard_deduction: 25_676 + +- name: 2026 surviving spouse gets twice the rounded single amount + period: 2026 + input: + state_code: LA + filing_status: SURVIVING_SPOUSE + output: + la_standard_deduction: 25_676 diff --git a/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/exempt_income/la_retirement_exemption_person.yaml b/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/exempt_income/la_retirement_exemption_person.yaml index 9a51b5dcb9f..4231367265d 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/exempt_income/la_retirement_exemption_person.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/exempt_income/la_retirement_exemption_person.yaml @@ -95,3 +95,79 @@ state_code: LA output: la_retirement_exemption_person: [3_000, 6_000] + +- name: 2025 aged head above the $12,000 cap + period: 2025 + input: + people: + person1: + age: 70 + is_tax_unit_head: 1 + taxable_private_pension_income: 20_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: LA + output: + la_retirement_exemption_person: 12_000 + +- name: 2026 aged head above the cap gets the RIB 26-019 amount, 12,000 x 1.027 + period: 2026 + input: + people: + person1: + age: 70 + is_tax_unit_head: 1 + taxable_private_pension_income: 20_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: LA + output: + la_retirement_exemption_person: 12_324 + +- name: 2026 aged head between the 2025 and 2026 caps exempts all pension income + period: 2026 + input: + people: + person1: + age: 65 + is_tax_unit_head: 1 + taxable_private_pension_income: 12_200 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: LA + output: + la_retirement_exemption_person: 12_200 + +- name: 2026 aged head and spouse are each capped, under-65 head gets nothing + period: 2026 + input: + people: + person1: + age: 64 + is_tax_unit_head: 1 + taxable_private_pension_income: 20_000 + person2: + age: 66 + is_tax_unit_spouse: 1 + taxable_private_pension_income: 15_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: LA + output: + la_retirement_exemption_person: [0, 12_324] diff --git a/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/integration.yaml index 90b87c3a276..10e60591b9a 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/integration.yaml @@ -216,7 +216,7 @@ output: la_income_tax: 476 -- name: 2026 single filer standard deduction uses prior-calendar-year CPI-U adjustment +- name: 2026 single filer standard deduction follows RIB 26-019 period: 2026 input: state_code: LA @@ -226,6 +226,8 @@ la_federal_tax_deduction: 0 la_non_refundable_credits: 0 output: - la_standard_deduction: 12_835 - la_taxable_income: 27_165 - la_income_tax_before_refundable_credits: 814.95 + la_standard_deduction: 12_838 + # 40,000 - 12,838 + la_taxable_income: 27_162 + # 27,162 * 3% + la_income_tax_before_refundable_credits: 814.86 diff --git a/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/test_la_cpi_u_indexing.py b/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/test_la_cpi_u_indexing.py new file mode 100644 index 00000000000..b99c9b6089c --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/test_la_cpi_u_indexing.py @@ -0,0 +1,319 @@ +"""Louisiana's CPI-U-indexed standard deduction and retirement exemption. + +La. R.S. 47:294(B) and 47:44.1(A) adjust the standard deduction and the +age-65 retirement income exemption each year by the prior calendar year's +CPI-U percentage increase. LDR Revenue Information Bulletin 26-019 +(September 28, 2026) publishes the 2026 amounts, which are encoded in the +YAML; ``extend_la_cpi_u_indexed_amounts`` projects the later years. +""" + +from fractions import Fraction +from types import SimpleNamespace + +import numpy as np +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from policyengine_core.parameters import Parameter, ParameterNode + +from policyengine_us.parameters.uprating_extensions import ( + adjust_la_amount_for_inflation, + extend_la_cpi_u_indexed_amounts, + get_la_cpi_u_percentage_increase, +) +from policyengine_us.system import system + +PARAMETERS = system.parameters +CPI_U = PARAMETERS.gov.bls.cpi.cpi_u +LA = PARAMETERS.gov.states.la.tax.income +STANDARD = LA.deductions.standard.amount +LAST_PUBLISHED_YEAR = 2026 +END_YEAR = 2100 + + +def retirement_cap(parameters, year): + """The 65+ retirement income exemption cap, through the scale's calc.""" + la = parameters(f"{year}-01-01").gov.states.la.tax.income + return float(la.exempt_income.retirement.cap.calc(np.array([65]))[0]) + + +def test_reproduces_rib_26_019_from_2025_amounts(): + """The projection method recomputes every RIB 26-019 amount.""" + percentage = get_la_cpi_u_percentage_increase(CPI_U, 2025) + assert percentage == 2.7 + single = adjust_la_amount_for_inflation(12_500, percentage) + assert single == 12_838 + assert 2 * single == 25_676 + assert adjust_la_amount_for_inflation(12_000, percentage) == 12_324 + # The encoded 2026 values agree with the computation. + for status in ("SINGLE", "SEPARATE"): + assert getattr(STANDARD, status)("2026-01-01") == 12_838 + for status in ("JOINT", "HEAD_OF_HOUSEHOLD", "SURVIVING_SPOUSE"): + assert getattr(STANDARD, status)("2026-01-01") == 25_676 + assert retirement_cap(PARAMETERS, 2026) == 12_324 + + +def test_other_readings_do_not_reproduce_rib_26_019(): + """Pin the reading: BLS's rounded Dec-Dec change, doubled single amount.""" + # Unrounded December 2024 to December 2025 change (not seasonally + # adjusted): $12,835, PolicyEngine's previous self-computed amount. + unrounded = 100 * (324.054 / 315.605 - 1) + assert adjust_la_amount_for_inflation(12_500, round(unrounded, 6)) == 12_835 + # The 2025 annual-average change, 321.943 / 313.689, rounds to 2.6%. + assert round(100 * (321.943 / 313.689 - 1), 1) == 2.6 + assert adjust_la_amount_for_inflation(12_500, 2.6) == 12_825 + # Adjusting the joint amount on its own gives $25,675, not $25,676. + assert adjust_la_amount_for_inflation(25_000, 2.7) == 25_675 + + +def synthetic_cpi(values): + return Parameter("cpi_u", data=values) + + +def test_percentage_is_december_over_december_not_annual_average(): + monthly_2030 = {f"2030-{month:02d}-01": 200 + month for month in range(1, 12)} + cpi = synthetic_cpi({"2029-12-01": 200, **monthly_2030, "2030-12-01": 205.4}) + # December to December: 205.4 / 200 = 2.7%. The 2030 annual average, + # 206.2, would give 3.1%. + assert get_la_cpi_u_percentage_increase(cpi, 2030) == 2.7 + + +@pytest.mark.parametrize( + "december, percentage", + [ + (205.32, 2.7), # 2.66% rounds up + (205.28, 2.6), # 2.64% rounds down + (200, 0), + (195, 0), # a fall in the index is not an increase + ], +) +def test_percentage_rounds_to_one_decimal_and_never_falls_below_zero( + december, percentage +): + cpi = synthetic_cpi({"2029-12-01": 200, "2030-12-01": december}) + assert get_la_cpi_u_percentage_increase(cpi, 2030) == percentage + + +def test_unobserved_december_reads_latest_observation_or_projection_point(): + cpi = synthetic_cpi( + { + "2030-12-01": 205, + # Monthly observations end in June 2031. + "2031-06-01": 210, + # Annual projection point for 2032. + "2032-02-01": 220, + } + ) + # December 2031 reads the June 2031 observation: 210 / 205 = 2.44%. + assert get_la_cpi_u_percentage_increase(cpi, 2031) == 2.4 + # December 2032 reads the 2032 projection point: 220 / 210 = 4.76%. + assert get_la_cpi_u_percentage_increase(cpi, 2032) == 4.8 + + +def synthetic_parameters(single_values): + """A synthetic tree with the structure extend_la_cpi_u_indexed_amounts reads.""" + + def doubled(values): + return {date: 2 * value for date, value in values.items()} + + published = {"2025-01-01": 12_500, "2026-01-01": 12_838} + income = ParameterNode( + "income", + data={ + "deductions": { + "standard": { + "amount": { + "SINGLE": {"values": single_values}, + "SEPARATE": {"values": published}, + "JOINT": {"values": doubled(published)}, + "HEAD_OF_HOUSEHOLD": {"values": doubled(published)}, + "SURVIVING_SPOUSE": {"values": doubled(published)}, + } + } + }, + "exempt_income": { + "retirement": { + "cap": { + "brackets": [ + { + "threshold": {"values": {"2021-01-01": 0}}, + "amount": {"values": {"2021-01-01": 0}}, + }, + { + "threshold": {"values": {"2001-01-01": 65}}, + "amount": { + "values": { + "2001-01-01": 6_000, + "2025-01-01": 12_000, + "2026-01-01": 12_324, + } + }, + }, + ], + "metadata": {"type": "single_amount"}, + } + } + }, + }, + ) + cpi = synthetic_cpi( + { + "2025-12-01": 100, + "2026-12-01": 102, + # Projection points; December 2027 and 2028 read them. + "2027-02-01": 105, + "2028-02-01": 108, + } + ) + parameters = SimpleNamespace( + gov=SimpleNamespace( + bls=SimpleNamespace(cpi=SimpleNamespace(cpi_u=cpi)), + states=SimpleNamespace( + la=SimpleNamespace(tax=SimpleNamespace(income=income)) + ), + ) + ) + return parameters, income + + +def test_extension_chains_from_published_amounts_and_doubles_the_single_amount(): + parameters, income = synthetic_parameters( + {"2025-01-01": 12_500, "2026-01-01": 12_838} + ) + extend_la_cpi_u_indexed_amounts(parameters, 2029) + standard = income.deductions.standard.amount + retirement = income.exempt_income.retirement.cap.brackets[1].amount + # Published years keep their values. + assert standard.SINGLE("2026-01-01") == 12_838 + assert retirement("2026-01-01") == 12_324 + # 2027: 102 / 100 = 2.0%. 12,838 x 1.02 = 13,094.76; 12,324 x 1.02 = 12,570.48. + # 2028: 105 / 102 = 2.94%, so 2.9%. 13,095 x 1.029 = 13,474.755; + # 12,570 x 1.029 = 12,934.53. + # 2029: 108 / 105 = 2.86%, so 2.9%. 13,475 x 1.029 = 13,865.775; + # 12,935 x 1.029 = 13,310.115. + expected = {2027: (13_095, 12_570), 2028: (13_475, 12_935), 2029: (13_866, 13_310)} + for year, (single, cap) in expected.items(): + assert standard.SINGLE(f"{year}-01-01") == single, year + assert standard.SEPARATE(f"{year}-01-01") == single, year + for status in ("JOINT", "HEAD_OF_HOUSEHOLD", "SURVIVING_SPOUSE"): + assert getattr(standard, status)(f"{year}-01-01") == 2 * single, year + assert retirement(f"{year}-01-01") == cap, year + # The last projected amounts hold after the end year. + assert standard.SINGLE("2040-01-01") == 13_866 + assert standard.JOINT("2040-01-01") == 27_732 + assert retirement("2040-01-01") == 13_310 + + +def test_extension_derives_other_statuses_from_an_encoded_single_amount(): + """A later published single amount is kept and the others follow it.""" + parameters, income = synthetic_parameters( + {"2025-01-01": 12_500, "2026-01-01": 12_838, "2027-01-01": 13_000} + ) + extend_la_cpi_u_indexed_amounts(parameters, 2029) + standard = income.deductions.standard.amount + assert standard.SINGLE("2027-01-01") == 13_000 + assert standard.SEPARATE("2027-01-01") == 13_000 + assert standard.JOINT("2027-01-01") == 26_000 + # 2028: 13,000 x 1.029 = 13,377. 2029: 13,377 x 1.029 = 13,764.933. + assert standard.SINGLE("2028-01-01") == 13_377 + assert standard.HEAD_OF_HOUSEHOLD("2028-01-01") == 26_754 + assert standard.SINGLE("2029-01-01") == 13_765 + assert standard.SURVIVING_SPOUSE("2029-01-01") == 27_530 + + +def projected_years(): + return range(LAST_PUBLISHED_YEAR + 1, END_YEAR + 1) + + +def test_projected_standard_deduction_follows_47_294(): + """Every projected year: 200% rule, whole dollars, statutory recurrence.""" + previous = STANDARD.SINGLE(f"{LAST_PUBLISHED_YEAR}-01-01") + for year in projected_years(): + single = STANDARD.SINGLE(f"{year}-01-01") + assert single == adjust_la_amount_for_inflation( + previous, get_la_cpi_u_percentage_increase(CPI_U, year - 1) + ), year + assert single == int(single), year + assert single >= previous, year + assert STANDARD.SEPARATE(f"{year}-01-01") == single, year + for status in ("JOINT", "HEAD_OF_HOUSEHOLD", "SURVIVING_SPOUSE"): + assert getattr(STANDARD, status)(f"{year}-01-01") == 2 * single, year + previous = single + assert STANDARD.SINGLE("2150-01-01") == STANDARD.SINGLE(f"{END_YEAR}-01-01") + + +def test_projected_retirement_cap_follows_47_44_1(): + previous = retirement_cap(PARAMETERS, LAST_PUBLISHED_YEAR) + for year in projected_years(): + cap = retirement_cap(PARAMETERS, year) + assert cap == adjust_la_amount_for_inflation( + previous, get_la_cpi_u_percentage_increase(CPI_U, year - 1) + ), year + assert cap == int(cap), year + assert cap >= previous, year + previous = cap + # The under-65 bracket stays at zero. + la = PARAMETERS(f"{END_YEAR}-01-01").gov.states.la.tax.income + assert la.exempt_income.retirement.cap.calc(np.array([64]))[0] == 0 + + +# One-decimal percentages, as BLS reports them. +percentages = st.integers(min_value=0, max_value=300).map(lambda tenths: tenths / 10) +amounts = st.integers(min_value=0, max_value=1_000_000) + + +@given(amounts, percentages) +def test_adjustment_rounds_to_the_nearest_dollar(prior, percentage): + adjusted = adjust_la_amount_for_inflation(prior, percentage) + exact = Fraction(prior) * (1 + Fraction(round(percentage * 10), 1_000)) + assert adjusted == int(adjusted) + assert abs(Fraction(int(adjusted)) - exact) <= Fraction(1, 2) + # Half dollars round up. + if exact - int(exact) == Fraction(1, 2): + assert adjusted == int(exact) + 1 + assert adjusted >= prior + + +@given(amounts, percentages, percentages) +def test_adjustment_is_monotone_in_the_percentage(prior, first, second): + low, high = sorted((first, second)) + assert adjust_la_amount_for_inflation(prior, low) <= adjust_la_amount_for_inflation( + prior, high + ) + + +@given(amounts) +def test_zero_percentage_keeps_the_amount(prior): + assert adjust_la_amount_for_inflation(prior, 0) == prior + + +@given( + st.floats(min_value=50, max_value=1_000), + st.floats(min_value=0.8, max_value=1.3), +) +def test_percentage_is_the_rounded_nonnegative_change(prior_december, ratio): + december = prior_december * ratio + cpi = synthetic_cpi({"2029-12-01": prior_december, "2030-12-01": december}) + percentage = get_la_cpi_u_percentage_increase(cpi, 2030) + assert percentage >= 0 + assert round(percentage * 10) == pytest.approx(percentage * 10) + change = 100 * (december / prior_december - 1) + assert percentage == pytest.approx(max(change, 0), abs=0.05 + 1e-9) + + +@given( + st.floats(min_value=50, max_value=1_000), + st.floats(min_value=0.8, max_value=1.3), + st.floats(min_value=0.8, max_value=1.3), +) +def test_percentage_is_monotone_in_the_december_index(prior_december, first, second): + low, high = sorted((first, second)) + + def percentage(ratio): + cpi = synthetic_cpi( + {"2029-12-01": prior_december, "2030-12-01": prior_december * ratio} + ) + return get_la_cpi_u_percentage_increase(cpi, 2030) + + assert percentage(low) <= percentage(high) diff --git a/policyengine_us/variables/gov/states/la/tax/income/exempt_income/la_retirement_exemption_person.py b/policyengine_us/variables/gov/states/la/tax/income/exempt_income/la_retirement_exemption_person.py index d41ad405a6a..70b69cf5f79 100644 --- a/policyengine_us/variables/gov/states/la/tax/income/exempt_income/la_retirement_exemption_person.py +++ b/policyengine_us/variables/gov/states/la/tax/income/exempt_income/la_retirement_exemption_person.py @@ -11,8 +11,9 @@ class la_retirement_exemption_person(Variable): defined_for = StateCode.LA def formula(person, period, parameters): - # Under La. R.S. 47:44.1, persons 65 or older may exempt up to $6,000 of annual - # retirement income (Schedule E code 06E). Public retirement benefits (LASERS, TRSLA, + # Under La. R.S. 47:44.1, persons 65 or older may exempt annual retirement income + # up to a cap (Schedule E code 06E): $6,000 through 2024, $12,000 in 2025, then + # adjusted annually for CPI-U. Public retirement benefits (LASERS, TRSLA, # federal civil service) are fully exempt under Schedule E codes 02E/03E/05E via # sources.yaml and do not consume this exemption cap. pension_income = person("taxable_private_pension_income", period) From 33ba01b64f9b64533ca725498ca940c1e2a1fc02 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 11:49:39 -0400 Subject: [PATCH 2/5] Use BLS's not seasonally adjusted Decembers and interpolate unobserved ones Review of #9791 found two problems in the Louisiana projection: - BLS reports the CPI-U December-over-December change from the not seasonally adjusted index, but the model's cpi_u is seasonally adjusted from January 2024. 2025 rounded to 2.7% by a 0.004-point margin, and 2022 and 2023 would round to 6.4% and 3.3% instead of BLS's 6.5% and 3.4%. Add gov.bls.cpi.cpi_u_nsa_december (CUUR0000SA0, 2012-2025) and use it whenever both Decembers are published. - An unobserved December read the last observation, so 2027 got only January-June 2026 inflation (2.0%). Interpolate geometrically from the last observation toward CBO's calendar-year projection points, placed halfway between June and July of their year (#9608); 2027 is now 3.1%. Also round the percentage in Decimal, raise a descriptive error if the age-65 bracket is missing, note that reforms to the single amount do not carry through, and pin half-dollar float traps and BLS's published 2013-2025 changes in tests. Co-Authored-By: Claude Opus 5.5 --- .../gov/bls/cpi/cpi_u_nsa_december.yaml | 26 +++ .../parameters/uprating_extensions.py | 88 ++++++-- .../la/tax/income/test_la_cpi_u_indexing.py | 192 +++++++++++++++--- 3 files changed, 266 insertions(+), 40 deletions(-) create mode 100644 policyengine_us/parameters/gov/bls/cpi/cpi_u_nsa_december.yaml diff --git a/policyengine_us/parameters/gov/bls/cpi/cpi_u_nsa_december.yaml b/policyengine_us/parameters/gov/bls/cpi/cpi_u_nsa_december.yaml new file mode 100644 index 00000000000..31f631fac58 --- /dev/null +++ b/policyengine_us/parameters/gov/bls/cpi/cpi_u_nsa_december.yaml @@ -0,0 +1,26 @@ +description: The Bureau of Labor Statistics publishes this December value of the Consumer Price Index for All Urban Consumers, not seasonally adjusted, from which it reports the index's December-over-December percentage change. +values: + # BLS series CUUR0000SA0 (U.S. city average, all items, 1982-84=100). + 2012-12-01: 229.601 + 2013-12-01: 233.049 + 2014-12-01: 234.812 + 2015-12-01: 236.525 + 2016-12-01: 241.432 + 2017-12-01: 246.524 + 2018-12-01: 251.233 + 2019-12-01: 256.974 + 2020-12-01: 260.474 + 2021-12-01: 278.802 + 2022-12-01: 296.797 + 2023-12-01: 306.746 + 2024-12-01: 315.605 + 2025-12-01: 324.054 +metadata: + unit: currency-USD + label: CPI-U, not seasonally adjusted, December + economy: false + reference: + - title: BLS | CPI-U, U.S. city average, all items, not seasonally adjusted (CUUR0000SA0) + href: https://data.bls.gov/timeseries/CUUR0000SA0 + - title: Consumer Price Index News Release - 2025 M12 Results + href: https://www.bls.gov/news.release/archives/cpi_01132026.htm diff --git a/policyengine_us/parameters/uprating_extensions.py b/policyengine_us/parameters/uprating_extensions.py index ca02375d4e8..94c19429e83 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -5,7 +5,7 @@ from typing import Optional, Tuple from policyengine_us.model_api import * -from policyengine_core.periods import instant +from policyengine_core.periods import Instant, instant LONG_RUN_CBO_INCOME_BY_SOURCE_PARAMETERS = ( @@ -203,7 +203,45 @@ def extend_or_ctc_parameters(parameters: ParameterNode, end_year: int) -> None: ) -def get_la_cpi_u_percentage_increase(cpi: Parameter, year: int) -> float: +def get_projected_cpi_u_for_month(cpi: Parameter, month: Instant) -> float: + """CPI-U for a month, interpolated toward CBO's projections if unobserved. + + The model's CPI-U series holds BLS monthly observations, then CBO's + calendar-year averages as projection points at February instants (see + ``get_average_for_12_months_ending_august`` and #9608). A calendar-year + average sits halfway between June and July of its year. So an + unobserved month is interpolated geometrically, by month, between the + last observation and the next projection point placed there, or between + two such points; past the last point the index holds flat. As in + ``get_average_for_12_months_ending_august``, only non-February instants + identify the end of the observed series. + """ + last_observation = max( + instant(value.instant_str) + for value in cpi.values_list + if not value.instant_str.endswith("-02-01") + ) + if month <= last_observation: + return cpi(month) + + def month_index(date: Instant) -> float: + return 12 * date.year + date.month - 1 + + anchors = [(month_index(last_observation), cpi(last_observation))] + sorted( + (12 * int(value.instant_str[:4]) + 5.5, value.value) + for value in cpi.values_list + if value.instant_str.endswith("-02-01") + and instant(value.instant_str) > last_observation + ) + target = month_index(month) + for (start, start_level), (end, end_level) in zip(anchors, anchors[1:]): + if target <= end: + share = (target - start) / (end - start) + return start_level * (end_level / start_level) ** share + return anchors[-1][1] + + +def get_la_cpi_u_percentage_increase(cpi: ParameterNode, year: int) -> float: """The CPI-U percentage increase for calendar ``year``, as BLS reports it. La. R.S. 47:294(B) and 47:44.1(A) multiply the prior year's amount by @@ -219,14 +257,23 @@ def get_la_cpi_u_percentage_increase(cpi: Parameter, year: int) -> float: decimal place, as BLS reports it. A fall in the index is not an increase, so the percentage is never negative. - The model's CPI-U series is seasonally adjusted from January 2024, and - after its last monthly observation it holds annual projection points at - February instants. The index is read at December 1 of each year: the - December observation once BLS publishes it, otherwise the latest - observation or projection point at or before that date. + ``cpi`` is the ``gov.bls.cpi`` node. BLS reports the change from the not + seasonally adjusted index, so the published Decembers in + ``cpi_u_nsa_december`` are used when both years have one. Otherwise + both Decembers come from the model's CPI-U series (seasonally adjusted + from January 2024), through ``get_projected_cpi_u_for_month``. """ - change = cpi(f"{year}-12-01") / cpi(f"{year - 1}-12-01") - 1 - return max(math.floor(1_000 * change + 0.5) / 10, 0) + december = cpi.cpi_u_nsa_december + published = {int(value.instant_str[:4]) for value in december.values_list} + if {year - 1, year} <= published: + current = december(f"{year}-12-01") + prior = december(f"{year - 1}-12-01") + else: + current = get_projected_cpi_u_for_month(cpi.cpi_u, instant(f"{year}-12-01")) + prior = get_projected_cpi_u_for_month(cpi.cpi_u, instant(f"{year - 1}-12-01")) + change = 100 * (Decimal(repr(current)) / Decimal(repr(prior)) - 1) + percentage = change.quantize(Decimal("0.1"), rounding=ROUND_HALF_UP) + return float(max(percentage, 0)) def adjust_la_amount_for_inflation(prior_amount: float, percentage: float) -> float: @@ -253,25 +300,36 @@ def extend_la_cpi_u_indexed_amounts(parameters: ParameterNode, end_year: int) -> 200% of the single amount (R.S. 47:294(A)(2)), so it doubles the rounded single amount rather than being adjusted on its own. Amounts encoded in the YAML take precedence for their own years. + + Like the other projections in this module, the values are fixed when + the parameter tree is built. A reform that changes the single amount + does not carry through to the other filing statuses or later years. """ la = parameters.gov.states.la.tax.income - cpi = parameters.gov.bls.cpi.cpi_u + cpi = parameters.gov.bls.cpi standard = la.deductions.standard.amount - retirement_cap = next( + retirement_caps = [ bracket.amount for bracket in la.exempt_income.retirement.cap.brackets if bracket.threshold(f"{end_year}-01-01") == 65 - ) - for parameter in (standard.SINGLE, retirement_cap): + ] + if len(retirement_caps) != 1: + raise ValueError( + "Expected one age-65 bracket in the Louisiana retirement income " + f"exemption cap; found {len(retirement_caps)}." + ) + percentages = {} + for parameter in (standard.SINGLE, retirement_caps[0]): first_projected_year = 1 + max( int(value.instant_str[:4]) for value in parameter.values_list ) for year in range(first_projected_year, end_year + 1): + if year - 1 not in percentages: + percentages[year - 1] = get_la_cpi_u_percentage_increase(cpi, year - 1) parameter.update( period=f"year:{year}-01-01:1", value=adjust_la_amount_for_inflation( - parameter(f"{year - 1}-01-01"), - get_la_cpi_u_percentage_increase(cpi, year - 1), + parameter(f"{year - 1}-01-01"), percentages[year - 1] ), ) parameter.update( diff --git a/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/test_la_cpi_u_indexing.py b/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/test_la_cpi_u_indexing.py index b99c9b6089c..b2703313649 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/test_la_cpi_u_indexing.py +++ b/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/test_la_cpi_u_indexing.py @@ -16,16 +16,18 @@ from hypothesis import strategies as st from policyengine_core.parameters import Parameter, ParameterNode +from policyengine_core.periods import instant from policyengine_us.parameters.uprating_extensions import ( adjust_la_amount_for_inflation, extend_la_cpi_u_indexed_amounts, get_la_cpi_u_percentage_increase, + get_projected_cpi_u_for_month, ) from policyengine_us.system import system PARAMETERS = system.parameters -CPI_U = PARAMETERS.gov.bls.cpi.cpi_u +CPI = PARAMETERS.gov.bls.cpi LA = PARAMETERS.gov.states.la.tax.income STANDARD = LA.deductions.standard.amount LAST_PUBLISHED_YEAR = 2026 @@ -40,7 +42,7 @@ def retirement_cap(parameters, year): def test_reproduces_rib_26_019_from_2025_amounts(): """The projection method recomputes every RIB 26-019 amount.""" - percentage = get_la_cpi_u_percentage_increase(CPI_U, 2025) + percentage = get_la_cpi_u_percentage_increase(CPI, 2025) assert percentage == 2.7 single = adjust_la_amount_for_inflation(12_500, percentage) assert single == 12_838 @@ -67,8 +69,47 @@ def test_other_readings_do_not_reproduce_rib_26_019(): assert adjust_la_amount_for_inflation(25_000, 2.7) == 25_675 -def synthetic_cpi(values): - return Parameter("cpi_u", data=values) +def test_percentages_match_bls_published_december_changes(): + """The CPI-U 12-month changes BLS reported for each December.""" + published = { + 2013: 1.5, + 2014: 0.8, + 2015: 0.7, + 2016: 2.1, + 2017: 2.1, + 2018: 1.9, + 2019: 2.3, + 2020: 1.4, + 2021: 7.0, + 2022: 6.5, + 2023: 3.4, + 2024: 2.9, + 2025: 2.7, + } + for year, percentage in published.items(): + assert get_la_cpi_u_percentage_increase(CPI, year) == percentage, year + + +def synthetic_cpi(cpi_u, nsa_december=None): + """A gov.bls.cpi node; the 1900 placeholder December matches no test year.""" + return SimpleNamespace( + cpi_u=Parameter("cpi_u", data=cpi_u), + cpi_u_nsa_december=Parameter( + "cpi_u_nsa_december", data=nsa_december or {"1900-12-01": 1} + ), + ) + + +def test_published_not_seasonally_adjusted_decembers_take_precedence(): + cpi = synthetic_cpi( + cpi_u={"2029-12-01": 200, "2030-12-01": 205.2, "2031-12-01": 211}, + nsa_december={"2029-12-01": 200, "2030-12-01": 205.4}, + ) + # Both Decembers published: 205.4 / 200 = 2.7%, not the model series' 2.6%. + assert get_la_cpi_u_percentage_increase(cpi, 2030) == 2.7 + # December 2031 is unpublished, so both Decembers come from the model + # series rather than mixing the two: 211 / 205.2 = 2.83%. + assert get_la_cpi_u_percentage_increase(cpi, 2031) == 2.8 def test_percentage_is_december_over_december_not_annual_average(): @@ -84,6 +125,7 @@ def test_percentage_is_december_over_december_not_annual_average(): [ (205.32, 2.7), # 2.66% rounds up (205.28, 2.6), # 2.64% rounds down + (205.3, 2.7), # exactly 2.65% rounds half up (200, 0), (195, 0), # a fall in the index is not an increase ], @@ -91,24 +133,64 @@ def test_percentage_is_december_over_december_not_annual_average(): def test_percentage_rounds_to_one_decimal_and_never_falls_below_zero( december, percentage ): - cpi = synthetic_cpi({"2029-12-01": 200, "2030-12-01": december}) + cpi = synthetic_cpi( + cpi_u={"1900-01-01": 1}, + nsa_december={"2029-12-01": 200, "2030-12-01": december}, + ) assert get_la_cpi_u_percentage_increase(cpi, 2030) == percentage -def test_unobserved_december_reads_latest_observation_or_projection_point(): - cpi = synthetic_cpi( - { +def test_unobserved_decembers_interpolate_toward_calendar_year_projections(): + cpi_u = Parameter( + "cpi_u", + data={ "2030-12-01": 205, # Monthly observations end in June 2031. "2031-06-01": 210, - # Annual projection point for 2032. + # CBO calendar-year averages for 2032 and 2033. "2032-02-01": 220, - } + "2033-02-01": 231, + }, ) - # December 2031 reads the June 2031 observation: 210 / 205 = 2.44%. - assert get_la_cpi_u_percentage_increase(cpi, 2031) == 2.4 - # December 2032 reads the 2032 projection point: 220 / 210 = 4.76%. - assert get_la_cpi_u_percentage_increase(cpi, 2032) == 4.8 + + def level(month): + return get_projected_cpi_u_for_month(cpi_u, instant(month)) + + # Observed months read the series. + assert level("2031-06-01") == 210 + # December 2031 is 6 of the 12.5 months from June 2031 to the 2032 + # average, placed between June and July 2032. + assert level("2031-12-01") == pytest.approx(210 * (220 / 210) ** (6 / 12.5)) + # December 2032 is 5.5 of the 12 months between the 2032 and 2033 + # averages. + assert level("2032-12-01") == pytest.approx(220 * (231 / 220) ** (5.5 / 12)) + # Past the last projection point the index holds flat. + assert level("2033-12-01") == 231 + assert level("2040-12-01") == 231 + + cpi = SimpleNamespace( + cpi_u=cpi_u, + cpi_u_nsa_december=Parameter("nsa", data={"1900-12-01": 1}), + ) + # 2031: 214.742 / 205 = 4.75%. 2032: 224.975 / 214.742 = 4.77%. + # 2033: 231 / 224.975 = 2.68%. 2034: 231 / 231. + expected = {2031: 4.8, 2032: 4.8, 2033: 2.7, 2034: 0} + for year, percentage in expected.items(): + assert get_la_cpi_u_percentage_increase(cpi, year) == percentage, year + + +def test_first_projected_year_gets_a_full_year_of_projected_inflation(): + """December 2026 is interpolated, not carried flat from June 2026.""" + cpi_u = CPI.cpi_u + last_observation = max( + instant(value.instant_str) + for value in cpi_u.values_list + if not value.instant_str.endswith("-02-01") + ) + if last_observation >= instant("2026-12-01"): + pytest.skip("December 2026 is observed") + december_2026 = get_projected_cpi_u_for_month(cpi_u, instant("2026-12-01")) + assert cpi_u(last_observation) < december_2026 < cpi_u("2027-02-01") def synthetic_parameters(single_values): @@ -161,14 +243,14 @@ def doubled(values): { "2025-12-01": 100, "2026-12-01": 102, - # Projection points; December 2027 and 2028 read them. - "2027-02-01": 105, + "2027-12-01": 105, + # Calendar-year 2028 projection; December 2028 is past it. "2028-02-01": 108, } ) parameters = SimpleNamespace( gov=SimpleNamespace( - bls=SimpleNamespace(cpi=SimpleNamespace(cpi_u=cpi)), + bls=SimpleNamespace(cpi=cpi), states=SimpleNamespace( la=SimpleNamespace(tax=SimpleNamespace(income=income)) ), @@ -192,7 +274,11 @@ def test_extension_chains_from_published_amounts_and_doubles_the_single_amount() # 12,570 x 1.029 = 12,934.53. # 2029: 108 / 105 = 2.86%, so 2.9%. 13,475 x 1.029 = 13,865.775; # 12,935 x 1.029 = 13,310.115. - expected = {2027: (13_095, 12_570), 2028: (13_475, 12_935), 2029: (13_866, 13_310)} + expected = { + 2027: (13_095, 12_570), + 2028: (13_475, 12_935), + 2029: (13_866, 13_310), + } for year, (single, cap) in expected.items(): assert standard.SINGLE(f"{year}-01-01") == single, year assert standard.SEPARATE(f"{year}-01-01") == single, year @@ -232,7 +318,7 @@ def test_projected_standard_deduction_follows_47_294(): for year in projected_years(): single = STANDARD.SINGLE(f"{year}-01-01") assert single == adjust_la_amount_for_inflation( - previous, get_la_cpi_u_percentage_increase(CPI_U, year - 1) + previous, get_la_cpi_u_percentage_increase(CPI, year - 1) ), year assert single == int(single), year assert single >= previous, year @@ -248,7 +334,7 @@ def test_projected_retirement_cap_follows_47_44_1(): for year in projected_years(): cap = retirement_cap(PARAMETERS, year) assert cap == adjust_la_amount_for_inflation( - previous, get_la_cpi_u_percentage_increase(CPI_U, year - 1) + previous, get_la_cpi_u_percentage_increase(CPI, year - 1) ), year assert cap == int(cap), year assert cap >= previous, year @@ -258,6 +344,23 @@ def test_projected_retirement_cap_follows_47_44_1(): assert la.exempt_income.retirement.cap.calc(np.array([64]))[0] == 0 +@pytest.mark.parametrize( + "prior, percentage, adjusted", + [ + # Half-dollar results that binary floating point misrounds: + # prior x (1 + p / 100) gives 12,837 here. + (12_500, 2.7, 12_838), + # prior x (100 + p) / 100 gives 10,311 here. + (10_250, 0.6, 10_312), + # Both float forms give 20,725 here. + (20_500, 1.1, 20_726), + (12_000, 2.7, 12_324), + ], +) +def test_adjustment_rounds_half_dollars_up_exactly(prior, percentage, adjusted): + assert adjust_la_amount_for_inflation(prior, percentage) == adjusted + + # One-decimal percentages, as BLS reports them. percentages = st.integers(min_value=0, max_value=300).map(lambda tenths: tenths / 10) amounts = st.integers(min_value=0, max_value=1_000_000) @@ -288,14 +391,22 @@ def test_zero_percentage_keeps_the_amount(prior): assert adjust_la_amount_for_inflation(prior, 0) == prior +def nsa_cpi(prior_december, december): + return synthetic_cpi( + cpi_u={"1900-01-01": 1}, + nsa_december={"2029-12-01": prior_december, "2030-12-01": december}, + ) + + @given( st.floats(min_value=50, max_value=1_000), st.floats(min_value=0.8, max_value=1.3), ) def test_percentage_is_the_rounded_nonnegative_change(prior_december, ratio): december = prior_december * ratio - cpi = synthetic_cpi({"2029-12-01": prior_december, "2030-12-01": december}) - percentage = get_la_cpi_u_percentage_increase(cpi, 2030) + percentage = get_la_cpi_u_percentage_increase( + nsa_cpi(prior_december, december), 2030 + ) assert percentage >= 0 assert round(percentage * 10) == pytest.approx(percentage * 10) change = 100 * (december / prior_december - 1) @@ -311,9 +422,40 @@ def test_percentage_is_monotone_in_the_december_index(prior_december, first, sec low, high = sorted((first, second)) def percentage(ratio): - cpi = synthetic_cpi( - {"2029-12-01": prior_december, "2030-12-01": prior_december * ratio} + return get_la_cpi_u_percentage_increase( + nsa_cpi(prior_december, prior_december * ratio), 2030 ) - return get_la_cpi_u_percentage_increase(cpi, 2030) assert percentage(low) <= percentage(high) + + +@given( + st.floats(min_value=100, max_value=500), + st.floats(min_value=1.0, max_value=1.1), + st.floats(min_value=1.0, max_value=1.1), + # A February instant would read as a projection point. + st.integers(min_value=1, max_value=11).filter(lambda month: month != 2), +) +def test_interpolated_months_lie_between_their_anchors( + observed, first_growth, second_growth, last_month +): + first_point = observed * first_growth + second_point = first_point * second_growth + cpi_u = Parameter( + "cpi_u", + data={ + f"2030-{last_month:02d}-01": observed, + "2031-02-01": first_point, + "2032-02-01": second_point, + }, + ) + previous = observed + for year, month in [(2030, m) for m in range(last_month + 1, 13)] + [ + (2031, m) for m in range(1, 13) + ]: + level = get_projected_cpi_u_for_month(cpi_u, instant(f"{year}-{month:02d}-01")) + assert observed <= level * (1 + 1e-12) + assert level <= second_point * (1 + 1e-12) + # Non-decreasing when the anchors are. + assert level >= previous * (1 - 1e-12) + previous = level From 498b7762364bbe5a97b7bd193c65b22e04956e09 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 12:32:16 -0400 Subject: [PATCH 3/5] Never anchor CPI-U interpolation on a February in the last observed year Round-2 review of #9791: when monthly CPI-U data ends at February Y, the February instant holds an observation but was read as CBO's calendar-year Y average anchored halfway through Y, moving later projected percentages by about 0.4 points until the next refresh. Only Februaries in years after the last observation's year are now anchors, so no branch reads an instant a refresh could turn from projection into observation. Also add a tripwire that cpi_u_nsa_december keeps up with the monthly series, test the missing age-65 bracket error, note that cpi_u.yaml's "# YYYY value" comments are tax-year labels, and mention the NSA series in the changelog. Co-Authored-By: Claude Opus 5.5 --- changelog.d/la-2026-rib-26-019.fixed.md | 2 +- .../parameters/uprating_extensions.py | 28 ++++++--- .../la/tax/income/test_la_cpi_u_indexing.py | 57 ++++++++++++++++++- 3 files changed, 75 insertions(+), 12 deletions(-) diff --git a/changelog.d/la-2026-rib-26-019.fixed.md b/changelog.d/la-2026-rib-26-019.fixed.md index a63a78a60d2..2749a32f6f9 100644 --- a/changelog.d/la-2026-rib-26-019.fixed.md +++ b/changelog.d/la-2026-rib-26-019.fixed.md @@ -1 +1 @@ -Use Louisiana's official 2026 standard deduction ($12,838 single or separate, $25,676 joint, head of household or surviving spouse) and age-65 retirement income exemption ($12,324) from LDR Revenue Information Bulletin 26-019, and project both after 2026 with La. R.S. 47:294(B) and 47:44.1(A)'s annual CPI-U adjustment. +Use Louisiana's official 2026 standard deduction ($12,838 single or separate, $25,676 joint, head of household or surviving spouse) and age-65 retirement income exemption ($12,324) from LDR Revenue Information Bulletin 26-019, and project both after 2026 with La. R.S. 47:294(B) and 47:44.1(A)'s annual CPI-U adjustment, using BLS's not seasonally adjusted December CPI-U (new gov.bls.cpi.cpi_u_nsa_december) where published. diff --git a/policyengine_us/parameters/uprating_extensions.py b/policyengine_us/parameters/uprating_extensions.py index 94c19429e83..7fa71ed3d3b 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -208,13 +208,21 @@ def get_projected_cpi_u_for_month(cpi: Parameter, month: Instant) -> float: The model's CPI-U series holds BLS monthly observations, then CBO's calendar-year averages as projection points at February instants (see - ``get_average_for_12_months_ending_august`` and #9608). A calendar-year - average sits halfway between June and July of its year. So an - unobserved month is interpolated geometrically, by month, between the - last observation and the next projection point placed there, or between - two such points; past the last point the index holds flat. As in - ``get_average_for_12_months_ending_august``, only non-February instants - identify the end of the observed series. + ``get_average_for_12_months_ending_august`` and #9608; the "# YYYY + value" comments in cpi_u.yaml label the tax year a point feeds, not its + calendar year). A calendar-year average sits halfway between June and + July of its year. So an unobserved month is interpolated geometrically, + by month, between the last observation and the next projection point + placed there, or between two such points; past the last point the index + holds flat. + + As in ``get_average_for_12_months_ending_august``, only non-February + instants identify the end of the observed series, and no branch reads + an instant a refresh could have turned from projection into + observation: a February in the last observation's year is never an + anchor. When observations end in January, the February that follows may + still hold that year's projection; it is skipped, and the months after + January interpolate toward the next year's point. """ last_observation = max( instant(value.instant_str) @@ -231,7 +239,7 @@ def month_index(date: Instant) -> float: (12 * int(value.instant_str[:4]) + 5.5, value.value) for value in cpi.values_list if value.instant_str.endswith("-02-01") - and instant(value.instant_str) > last_observation + and int(value.instant_str[:4]) > last_observation.year ) target = month_index(month) for (start, start_level), (end, end_level) in zip(anchors, anchors[1:]): @@ -261,7 +269,9 @@ def get_la_cpi_u_percentage_increase(cpi: ParameterNode, year: int) -> float: seasonally adjusted index, so the published Decembers in ``cpi_u_nsa_december`` are used when both years have one. Otherwise both Decembers come from the model's CPI-U series (seasonally adjusted - from January 2024), through ``get_projected_cpi_u_for_month``. + from January 2024), through ``get_projected_cpi_u_for_month``; mixing + the two would add December's seasonal factor (about 0.6% in 2025) to + the change. """ december = cpi.cpi_u_nsa_december published = {int(value.instant_str[:4]) for value in december.values_list} diff --git a/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/test_la_cpi_u_indexing.py b/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/test_la_cpi_u_indexing.py index b2703313649..fea5e523bc3 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/test_la_cpi_u_indexing.py +++ b/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/test_la_cpi_u_indexing.py @@ -193,7 +193,52 @@ def test_first_projected_year_gets_a_full_year_of_projected_inflation(): assert cpi_u(last_observation) < december_2026 < cpi_u("2027-02-01") -def synthetic_parameters(single_values): +def last_observed_december(cpi_u): + observed = [ + instant(value.instant_str) + for value in cpi_u.values_list + if not value.instant_str.endswith("-02-01") + ] + last = max(observed) + return last.year if last.month == 12 else last.year - 1 + + +def test_nsa_decembers_are_current_with_the_monthly_series(): + """Refreshing cpi_u past a December needs that December's NSA value.""" + newest_nsa = max( + int(value.instant_str[:4]) for value in CPI.cpi_u_nsa_december.values_list + ) + assert newest_nsa >= last_observed_december(CPI.cpi_u), ( + "Add the latest not seasonally adjusted December CPI-U (CUUR0000SA0) to " + "gov/bls/cpi/cpi_u_nsa_december.yaml" + ) + + +@pytest.mark.parametrize("february", [190, 201.5, 260]) +def test_february_in_the_last_observed_year_is_never_an_anchor(february): + """Observations ending in January (or a February a refresh observed). + + The February instant may hold that year's projection or, after a + refresh, an observation; neither may act as a calendar-year anchor, so + its value cannot move any later month. + """ + cpi_u = Parameter( + "cpi_u", + data={ + "2030-12-01": 200, + "2031-01-01": 201, + "2031-02-01": february, + "2032-02-01": 211, + }, + ) + # December 2031 is 11 of the 17.5 months from January 2031 to the 2032 + # average, placed between June and July 2032. + assert get_projected_cpi_u_for_month(cpi_u, instant("2031-12-01")) == pytest.approx( + 201 * (211 / 201) ** (11 / 17.5) + ) + + +def synthetic_parameters(single_values, retirement_age=65): """A synthetic tree with the structure extend_la_cpi_u_indexed_amounts reads.""" def doubled(values): @@ -223,7 +268,7 @@ def doubled(values): "amount": {"values": {"2021-01-01": 0}}, }, { - "threshold": {"values": {"2001-01-01": 65}}, + "threshold": {"values": {"2001-01-01": retirement_age}}, "amount": { "values": { "2001-01-01": 6_000, @@ -291,6 +336,14 @@ def test_extension_chains_from_published_amounts_and_doubles_the_single_amount() assert retirement("2040-01-01") == 13_310 +def test_extension_requires_the_age_65_retirement_bracket(): + parameters, _ = synthetic_parameters( + {"2025-01-01": 12_500, "2026-01-01": 12_838}, retirement_age=60 + ) + with pytest.raises(ValueError, match="age-65 bracket"): + extend_la_cpi_u_indexed_amounts(parameters, 2029) + + def test_extension_derives_other_statuses_from_an_encoded_single_amount(): """A later published single amount is kept and the others follow it.""" parameters, income = synthetic_parameters( From 0240a16348da7e965df64b5dc0451178a638e7ad Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 12:42:49 -0400 Subject: [PATCH 4/5] Mirror the Axiom companion cases for RIB 26-019 in Louisiana tests Add 17 end-to-end, boundary and retirement-exemption cases to the Louisiana integration tests that mirror the companion tests proposed in TheAxiomFoundation/rulespec-us#1493, with expectations from the same RIB 26-019 amounts: federal AGI 50,000 for every filing status, the 12,838 / 25,676 boundaries (25,676 rejects 25,000 x 1.027), the 12,324 cap at, below and above, and retirement income flowing through la_agi to tax, including a public pension exempted under sources.yaml that does not use the R.S. 47:44.1 amount. Also clarify that the February anchor rule only matters when CPI-U observations end in January. Co-Authored-By: Claude Opus 5.5 --- .../parameters/uprating_extensions.py | 7 +- .../gov/states/la/tax/income/integration.yaml | 337 ++++++++++++++++++ 2 files changed, 341 insertions(+), 3 deletions(-) diff --git a/policyengine_us/parameters/uprating_extensions.py b/policyengine_us/parameters/uprating_extensions.py index 7fa71ed3d3b..c9f376ce2fc 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -220,9 +220,10 @@ def get_projected_cpi_u_for_month(cpi: Parameter, month: Instant) -> float: instants identify the end of the observed series, and no branch reads an instant a refresh could have turned from projection into observation: a February in the last observation's year is never an - anchor. When observations end in January, the February that follows may - still hold that year's projection; it is skipped, and the months after - January interpolate toward the next year's point. + anchor. That only matters when observations end in January (any later + month already follows that February): the February may then hold that + year's projection or an observation, so it is skipped, and the months + after January interpolate toward the next year's point. """ last_observation = max( instant(value.instant_str) diff --git a/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/integration.yaml index 10e60591b9a..e6b989f4cd2 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/integration.yaml @@ -231,3 +231,340 @@ la_taxable_income: 27_162 # 27,162 * 3% la_income_tax_before_refundable_credits: 814.86 + +# The cases below mirror the Axiom companion tests proposed in +# TheAxiomFoundation/rulespec-us#1493, with expectations from the same +# ground truth: LDR Revenue Information Bulletin 26-019's 2026 amounts +# ($12,838 / $25,676 standard deduction, $12,324 retirement exemption) and +# R.S. 47:32(A)'s 3% rate. +# --- Standard deduction end to end from federal AGI (Axiom: *_federal_agi_50000_end_to_end_*) --- +- name: 2026 single, federal AGI 50,000, RIB 26-019 end to end + period: 2026 + input: + state_code: LA + filing_status: SINGLE + adjusted_gross_income: 50_000 + tax_unit_itemizes: false + output: + la_agi: 50_000 + la_standard_deduction: 12_838 + # 50,000 - 12,838 + la_taxable_income: 37_162 + # 37,162 x 0.03 + la_income_tax_before_non_refundable_credits: 1_114.86 + +- name: 2026 married filing separately, federal AGI 50,000, RIB 26-019 end to end + period: 2026 + input: + state_code: LA + filing_status: SEPARATE + adjusted_gross_income: 50_000 + tax_unit_itemizes: false + output: + la_standard_deduction: 12_838 + la_taxable_income: 37_162 + la_income_tax_before_non_refundable_credits: 1_114.86 + +- name: 2026 joint, federal AGI 50,000, RIB 26-019 end to end + period: 2026 + input: + people: + head: + is_tax_unit_head: true + spouse: + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + adjusted_gross_income: 50_000 + tax_unit_itemizes: false + households: + household: + members: [head, spouse] + state_code: LA + output: + la_standard_deduction: 25_676 + # 50,000 - 25,676 + la_taxable_income: 24_324 + # 24,324 x 0.03 + la_income_tax_before_non_refundable_credits: 729.72 + +- name: 2026 head of household, federal AGI 50,000, RIB 26-019 end to end + period: 2026 + input: + state_code: LA + filing_status: HEAD_OF_HOUSEHOLD + adjusted_gross_income: 50_000 + tax_unit_itemizes: false + output: + la_standard_deduction: 25_676 + la_taxable_income: 24_324 + la_income_tax_before_non_refundable_credits: 729.72 + +- name: 2026 surviving spouse, federal AGI 50,000, RIB 26-019 end to end + period: 2026 + input: + state_code: LA + filing_status: SURVIVING_SPOUSE + adjusted_gross_income: 50_000 + tax_unit_itemizes: false + output: + la_standard_deduction: 25_676 + la_taxable_income: 24_324 + la_income_tax_before_non_refundable_credits: 729.72 + +# --- Boundaries (Axiom: single_federal_agi_1283{8,9}_*, joint_federal_agi_2567{6,7}_*) --- +- name: 2026 single, federal AGI equal to the RIB 26-019 deduction + period: 2026 + input: + state_code: LA + filing_status: SINGLE + adjusted_gross_income: 12_838 + tax_unit_itemizes: false + output: + la_taxable_income: 0 + la_income_tax_before_non_refundable_credits: 0 + +- name: 2026 single, federal AGI one dollar above the RIB 26-019 deduction + period: 2026 + input: + state_code: LA + filing_status: SINGLE + adjusted_gross_income: 12_839 + tax_unit_itemizes: false + output: + la_taxable_income: 1 + la_income_tax_before_non_refundable_credits: 0.03 + +- name: 2026 joint, federal AGI 25,676 leaves nothing (2 x 12,838, not 25,000 x 1.027 = 25,675) + period: 2026 + input: + people: + head: + is_tax_unit_head: true + spouse: + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + adjusted_gross_income: 25_676 + tax_unit_itemizes: false + households: + household: + members: [head, spouse] + state_code: LA + output: + la_taxable_income: 0 + la_income_tax_before_non_refundable_credits: 0 + +- name: 2026 joint, federal AGI one dollar above the RIB 26-019 deduction + period: 2026 + input: + people: + head: + is_tax_unit_head: true + spouse: + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + adjusted_gross_income: 25_677 + tax_unit_itemizes: false + households: + household: + members: [head, spouse] + state_code: LA + output: + la_taxable_income: 1 + la_income_tax_before_non_refundable_credits: 0.03 + +# --- Age-65 retirement exemption, R.S. 47:44.1(A) (Axiom: age_65_* / under_65_* / joint_return_*) --- +- name: 2026 aged head, retirement income below the RIB 26-019 amount + period: 2026 + input: + people: + person1: + age: 66 + is_tax_unit_head: true + taxable_private_pension_income: 10_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: LA + output: + la_retirement_exemption_person: 10_000 + +- name: 2026 aged head, retirement income equal to the RIB 26-019 amount + period: 2026 + input: + people: + person1: + age: 66 + is_tax_unit_head: true + taxable_private_pension_income: 12_324 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: LA + output: + la_retirement_exemption_person: 12_324 + +- name: 2026 aged head, retirement income one dollar above the RIB 26-019 amount + period: 2026 + input: + people: + person1: + age: 66 + is_tax_unit_head: true + taxable_private_pension_income: 12_325 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: LA + output: + la_retirement_exemption_person: 12_324 + +- name: 2026 head under 65 gets no retirement exemption + period: 2026 + input: + people: + person1: + age: 64 + is_tax_unit_head: true + taxable_private_pension_income: 20_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: LA + output: + la_retirement_exemption_person: 0 + +- name: 2026 aged head and aged spouse each have their own amount + period: 2026 + input: + people: + person1: + age: 66 + is_tax_unit_head: true + taxable_private_pension_income: 20_000 + person2: + age: 65 + is_tax_unit_spouse: true + taxable_private_pension_income: 5_000 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + households: + household: + members: [person1, person2] + state_code: LA + output: + # min(20,000, 12,324) and min(5,000, 12,324) + la_retirement_exemption_person: [12_324, 5_000] + +# --- Retirement exemption end to end (Axiom: *_end_to_end_with_retirement_exemption*) --- +- name: 2026 aged single, federal AGI 50,000 with 20,000 private pension + period: 2026 + input: + people: + person1: + age: 66 + is_tax_unit_head: true + taxable_private_pension_income: 20_000 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + adjusted_gross_income: 50_000 + tax_unit_itemizes: false + households: + household: + members: [person1] + state_code: LA + output: + la_retirement_exemption_person: 12_324 + la_agi_exempt_income: 12_324 + # 50,000 - 12,324 + la_agi: 37_676 + # 37,676 - 12,838 + la_taxable_income: 24_838 + # 24,838 x 0.03 + la_income_tax_before_non_refundable_credits: 745.14 + +- name: 2026 aged single, exempt public pension does not use the 47:44.1 amount + period: 2026 + input: + people: + person1: + age: 66 + is_tax_unit_head: true + taxable_public_pension_income: 30_000 + taxable_private_pension_income: 5_000 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + adjusted_gross_income: 50_000 + tax_unit_itemizes: false + households: + household: + members: [person1] + state_code: LA + output: + la_retirement_exemption_person: 5_000 + # 30,000 public pension (sources.yaml) + 5,000 under 47:44.1 + la_agi_exempt_income: 35_000 + la_agi: 15_000 + # 15,000 - 12,838 + la_taxable_income: 2_162 + # 2,162 x 0.03 + la_income_tax_before_non_refundable_credits: 64.86 + +- name: 2026 aged joint couple, federal AGI 80,000 with 20,000 and 15,000 pensions + period: 2026 + input: + people: + person1: + age: 66 + is_tax_unit_head: true + taxable_private_pension_income: 20_000 + person2: + age: 65 + is_tax_unit_spouse: true + taxable_private_pension_income: 15_000 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + adjusted_gross_income: 80_000 + tax_unit_itemizes: false + households: + household: + members: [person1, person2] + state_code: LA + output: + la_retirement_exemption_person: [12_324, 12_324] + # 12,324 + 12,324 + la_agi_exempt_income: 24_648 + # 80,000 - 24,648 + la_agi: 55_352 + # 55,352 - 25,676 + la_taxable_income: 29_676 + # 29,676 x 0.03 + la_income_tax_before_non_refundable_credits: 890.28 From f9a82d605ae7a3a9d0cbc7f51e6f31cf716c596f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 12:53:23 -0400 Subject: [PATCH 5/5] Pin half-up dollar rounding against banker's rounding The mutation run at 498b776 found that switching the dollar rounding to half-even survived: each deterministic half-dollar case (12,837.50, 10,311.50, 20,725.50) rounds up to an even dollar, so banker's rounding agrees with half-up on all of them, and the Hypothesis property only catches it when it draws an exact half. Add 10,100 x 1.005 = 10,150.50, which rounds up to 10,151 but to the even 10,150 under half-even (and under float prior x (1 + p / 100)). Also note that the retirement end-to-end cases set federal AGI directly. Co-Authored-By: Claude Opus 5.5 --- .../baseline/gov/states/la/tax/income/integration.yaml | 2 ++ .../gov/states/la/tax/income/test_la_cpi_u_indexing.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/integration.yaml index e6b989f4cd2..22d6c01cc29 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/integration.yaml @@ -479,6 +479,8 @@ la_retirement_exemption_person: [12_324, 5_000] # --- Retirement exemption end to end (Axiom: *_end_to_end_with_retirement_exemption*) --- +# These set adjusted_gross_income directly, so they test the Louisiana +# exemption and tax path, not that pensions flow into federal AGI. - name: 2026 aged single, federal AGI 50,000 with 20,000 private pension period: 2026 input: diff --git a/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/test_la_cpi_u_indexing.py b/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/test_la_cpi_u_indexing.py index fea5e523bc3..1c80cf8b9b3 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/test_la_cpi_u_indexing.py +++ b/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/test_la_cpi_u_indexing.py @@ -407,6 +407,11 @@ def test_projected_retirement_cap_follows_47_44_1(): (10_250, 0.6, 10_312), # Both float forms give 20,725 here. (20_500, 1.1, 20_726), + # Each case above rounds up to an even dollar, which banker's + # rounding (half to even) would also give. 10,150.50 rounds up to + # 10,151, not to the even 10,150 (prior x (1 + p / 100) gives 10,150 + # too). + (10_100, 0.5, 10_151), (12_000, 2.7, 12_324), ], )