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..2749a32f6f9 --- /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, 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/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/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..c9f376ce2fc 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -1,10 +1,11 @@ """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 * -from policyengine_core.periods import instant +from policyengine_core.periods import Instant, instant LONG_RUN_CBO_INCOME_BY_SOURCE_PARAMETERS = ( @@ -202,6 +203,170 @@ def extend_or_ctc_parameters(parameters: ParameterNode, end_year: int) -> None: ) +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; 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. 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) + 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 int(value.instant_str[:4]) > last_observation.year + ) + 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 + "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. + + ``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``; 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} + 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: + """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. + + 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 + standard = la.deductions.standard.amount + retirement_caps = [ + bracket.amount + for bracket in la.exempt_income.retirement.cap.brackets + if bracket.threshold(f"{end_year}-01-01") == 65 + ] + 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"), percentages[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 +798,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..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 @@ -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,347 @@ 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 + +# 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*) --- +# 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: + 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 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..1c80cf8b9b3 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/la/tax/income/test_la_cpi_u_indexing.py @@ -0,0 +1,519 @@ +"""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_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 = PARAMETERS.gov.bls.cpi +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, 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 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(): + 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 + (205.3, 2.7), # exactly 2.65% rounds half up + (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( + 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_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, + # CBO calendar-year averages for 2032 and 2033. + "2032-02-01": 220, + "2033-02-01": 231, + }, + ) + + 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 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): + 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": retirement_age}}, + "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, + "2027-12-01": 105, + # Calendar-year 2028 projection; December 2028 is past it. + "2028-02-01": 108, + } + ) + parameters = SimpleNamespace( + gov=SimpleNamespace( + bls=SimpleNamespace(cpi=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_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( + {"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, 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, 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 + + +@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), + # 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), + ], +) +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) + + +@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 + + +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 + 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) + 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): + return get_la_cpi_u_percentage_increase( + nsa_cpi(prior_december, prior_december * ratio), 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 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)