From b7eb8f53bd867300e45965d71c360951a7faf914 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 24 Sep 2026 15:45:32 -0400 Subject: [PATCH 1/7] Encode the 2030 expiry of California's top income tax rates; add Prop 3 (2026) reform Under Cal. Const. art. XIII, section 36(f)(2), the 10.3%, 11.3% and 12.3% rates added by Proposition 30 and extended by Proposition 55 apply to taxable years before January 1, 2031. The baseline carried them indefinitely. From 2031 the three brackets now revert to 9.3%, matching the Legislative Analyst's Figure 1 in the 2026 voter guide. Proposition 3 on the November 3, 2026 ballot would make those rates permanent. gov.contrib.states.ca.prop3.in_effect switches on a parameter-only reform that keeps them from 2031 onward. The 1% Mental Health Services Tax is unaffected. Tests: 3 baseline boundary cases (2030 vs 2031) and 6 reform cases, expected values computed by hand from the 2031 thresholds. Co-Authored-By: Claude Opus 5.5 --- changelog.d/election-2026-ca-prop3.fixed.md | 1 + .../contrib/states/ca/prop3/in_effect.yaml | 12 +++ .../tax/income/rates/head_of_household.yaml | 5 ++ .../gov/states/ca/tax/income/rates/joint.yaml | 5 ++ .../states/ca/tax/income/rates/separate.yaml | 5 ++ .../states/ca/tax/income/rates/single.yaml | 5 ++ .../ca/tax/income/rates/surviving_spouse.yaml | 5 ++ policyengine_us/reforms/reforms.py | 3 + policyengine_us/reforms/states/ca/__init__.py | 3 + .../reforms/states/ca/prop3/__init__.py | 1 + .../states/ca/prop3/ca_prop3_reform.py | 59 +++++++++++++++ .../income/ca_income_tax_before_credits.yaml | 32 ++++++++ .../tests/policy/contrib/states/ca/prop3.yaml | 75 +++++++++++++++++++ 13 files changed, 211 insertions(+) create mode 100644 changelog.d/election-2026-ca-prop3.fixed.md create mode 100644 policyengine_us/parameters/gov/contrib/states/ca/prop3/in_effect.yaml create mode 100644 policyengine_us/reforms/states/ca/prop3/__init__.py create mode 100644 policyengine_us/reforms/states/ca/prop3/ca_prop3_reform.py create mode 100644 policyengine_us/tests/policy/contrib/states/ca/prop3.yaml diff --git a/changelog.d/election-2026-ca-prop3.fixed.md b/changelog.d/election-2026-ca-prop3.fixed.md new file mode 100644 index 00000000000..8b0258697e3 --- /dev/null +++ b/changelog.d/election-2026-ca-prop3.fixed.md @@ -0,0 +1 @@ +Encode the expiry of California's 10.3%, 11.3% and 12.3% income tax rates after tax year 2030 (Cal. Const. art. XIII, § 36(f)(2)) and add Proposition 3 (2026) as a contributed reform that makes them permanent. diff --git a/policyengine_us/parameters/gov/contrib/states/ca/prop3/in_effect.yaml b/policyengine_us/parameters/gov/contrib/states/ca/prop3/in_effect.yaml new file mode 100644 index 00000000000..d9b3fd90f71 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/states/ca/prop3/in_effect.yaml @@ -0,0 +1,12 @@ +description: California applies the Proposition 3 (2026) permanent extension of the income tax rates above 9.3% if this is true. +values: + 0000-01-01: false +metadata: + unit: bool + period: year + label: California Proposition 3 (2026) in effect + reference: + - title: California Proposition 3 (2026), Official Voter Information Guide, Legislative Analyst's analysis, Figure 1 + href: https://vig.cdn.sos.ca.gov/2026/general/pdf/prop3.pdf + - title: Cal. Const. art. XIII, § 36(f)(2) + href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CONS§ionNum=SEC.%2036.&article=XIII diff --git a/policyengine_us/parameters/gov/states/ca/tax/income/rates/head_of_household.yaml b/policyengine_us/parameters/gov/states/ca/tax/income/rates/head_of_household.yaml index d30bf8988a7..5c97698ef92 100644 --- a/policyengine_us/parameters/gov/states/ca/tax/income/rates/head_of_household.yaml +++ b/policyengine_us/parameters/gov/states/ca/tax/income/rates/head_of_household.yaml @@ -70,6 +70,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.103 + 2031-01-01: 0.093 - threshold: values: 2021-01-01: 510_303 @@ -81,6 +82,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.113 + 2031-01-01: 0.093 - threshold: values: 2021-01-01: 850_503 @@ -92,6 +94,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.123 + 2031-01-01: 0.093 metadata: threshold_unit: currency_USD @@ -110,6 +113,8 @@ metadata: href: https://www.ftb.ca.gov/forms/2024/2024-540-tax-rate-schedules.pdf - title: California Income Tax Rate Schedules for 2025 href: https://www.ftb.ca.gov/forms/2025/2025-540-tax-rate-schedules.pdf + - title: Cal. Const. art. XIII, § 36(f)(2) (rates above 9.3% apply to taxable years before January 1, 2031) + href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CONS§ionNum=SEC.%2036.&article=XIII - title: Cal. Rev. & Tax. Code § 17041 href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?sectionNum=17041.&lawCode=RTC # Index according to federal tax parameter rules until we add California CPI trend. diff --git a/policyengine_us/parameters/gov/states/ca/tax/income/rates/joint.yaml b/policyengine_us/parameters/gov/states/ca/tax/income/rates/joint.yaml index db176716116..87519bf42f5 100644 --- a/policyengine_us/parameters/gov/states/ca/tax/income/rates/joint.yaml +++ b/policyengine_us/parameters/gov/states/ca/tax/income/rates/joint.yaml @@ -70,6 +70,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.103 + 2031-01-01: 0.093 - threshold: values: 2021-01-01: 750_442 @@ -81,6 +82,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.113 + 2031-01-01: 0.093 - threshold: values: 2021-01-01: 1_250_738 @@ -92,6 +94,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.123 + 2031-01-01: 0.093 metadata: threshold_unit: currency_USD @@ -110,6 +113,8 @@ metadata: href: https://www.ftb.ca.gov/forms/2024/2024-540-tax-rate-schedules.pdf - title: California Income Tax Rate Schedules for 2025 href: https://www.ftb.ca.gov/forms/2025/2025-540-tax-rate-schedules.pdf + - title: Cal. Const. art. XIII, § 36(f)(2) (rates above 9.3% apply to taxable years before January 1, 2031) + href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CONS§ionNum=SEC.%2036.&article=XIII - title: Cal. Rev. & Tax. Code § 17041 href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?sectionNum=17041.&lawCode=RTC # Index according to federal tax parameter rules until we add California CPI trend. diff --git a/policyengine_us/parameters/gov/states/ca/tax/income/rates/separate.yaml b/policyengine_us/parameters/gov/states/ca/tax/income/rates/separate.yaml index c5e1ae6154b..943ef1ec7ad 100644 --- a/policyengine_us/parameters/gov/states/ca/tax/income/rates/separate.yaml +++ b/policyengine_us/parameters/gov/states/ca/tax/income/rates/separate.yaml @@ -70,6 +70,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.103 + 2031-01-01: 0.093 - threshold: values: 2021-01-01: 375_221 @@ -81,6 +82,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.113 + 2031-01-01: 0.093 - threshold: values: 2021-01-01: 625_369 @@ -92,6 +94,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.123 + 2031-01-01: 0.093 metadata: threshold_unit: currency_USD @@ -110,6 +113,8 @@ metadata: href: https://www.ftb.ca.gov/forms/2024/2024-540-tax-rate-schedules.pdf - title: California Income Tax Rate Schedules for 2025 href: https://www.ftb.ca.gov/forms/2025/2025-540-tax-rate-schedules.pdf + - title: Cal. Const. art. XIII, § 36(f)(2) (rates above 9.3% apply to taxable years before January 1, 2031) + href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CONS§ionNum=SEC.%2036.&article=XIII - title: Cal. Rev. & Tax. Code § 17041 href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?sectionNum=17041.&lawCode=RTC # Index according to federal tax parameter rules until we add California CPI trend. diff --git a/policyengine_us/parameters/gov/states/ca/tax/income/rates/single.yaml b/policyengine_us/parameters/gov/states/ca/tax/income/rates/single.yaml index 356ddee849b..1d5f15f7edd 100644 --- a/policyengine_us/parameters/gov/states/ca/tax/income/rates/single.yaml +++ b/policyengine_us/parameters/gov/states/ca/tax/income/rates/single.yaml @@ -70,6 +70,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.103 + 2031-01-01: 0.093 - threshold: values: 2021-01-01: 375_221 @@ -81,6 +82,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.113 + 2031-01-01: 0.093 - threshold: values: 2021-01-01: 625_369 @@ -92,6 +94,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.123 + 2031-01-01: 0.093 metadata: threshold_unit: currency_USD @@ -110,6 +113,8 @@ metadata: href: https://www.ftb.ca.gov/forms/2024/2024-540-tax-rate-schedules.pdf - title: California Income Tax Rate Schedules for 2025 href: https://www.ftb.ca.gov/forms/2025/2025-540-tax-rate-schedules.pdf + - title: Cal. Const. art. XIII, § 36(f)(2) (rates above 9.3% apply to taxable years before January 1, 2031) + href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CONS§ionNum=SEC.%2036.&article=XIII - title: Cal. Rev. & Tax. Code § 17041 href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?sectionNum=17041.&lawCode=RTC # Index according to federal tax parameter rules until we add California CPI trend. diff --git a/policyengine_us/parameters/gov/states/ca/tax/income/rates/surviving_spouse.yaml b/policyengine_us/parameters/gov/states/ca/tax/income/rates/surviving_spouse.yaml index 1553538f47b..97bb6160d18 100644 --- a/policyengine_us/parameters/gov/states/ca/tax/income/rates/surviving_spouse.yaml +++ b/policyengine_us/parameters/gov/states/ca/tax/income/rates/surviving_spouse.yaml @@ -70,6 +70,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.103 + 2031-01-01: 0.093 - threshold: values: 2021-01-01: 750_442 @@ -81,6 +82,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.113 + 2031-01-01: 0.093 - threshold: values: 2021-01-01: 1_250_738 @@ -92,6 +94,7 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.123 + 2031-01-01: 0.093 metadata: threshold_unit: currency_USD @@ -110,6 +113,8 @@ metadata: href: https://www.ftb.ca.gov/forms/2024/2024-540-tax-rate-schedules.pdf - title: California Income Tax Rate Schedules for 2025 href: https://www.ftb.ca.gov/forms/2025/2025-540-tax-rate-schedules.pdf + - title: Cal. Const. art. XIII, § 36(f)(2) (rates above 9.3% apply to taxable years before January 1, 2031) + href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CONS§ionNum=SEC.%2036.&article=XIII - title: Cal. Rev. & Tax. Code § 17041 href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?sectionNum=17041.&lawCode=RTC # Index according to federal tax parameter rules until we add California CPI trend. diff --git a/policyengine_us/reforms/reforms.py b/policyengine_us/reforms/reforms.py index 46b0754e164..ac18de31f08 100644 --- a/policyengine_us/reforms/reforms.py +++ b/policyengine_us/reforms/reforms.py @@ -143,6 +143,7 @@ create_senior_deduction_extension_reform, create_agi_surtax_reform, ) +from .states.ca.prop3.ca_prop3_reform import create_ca_prop3_reform from .states.ri.ctc.ri_ctc_reform import create_ri_ctc_reform from .states.ri.exemption.ri_exemption_reform import ( create_ri_exemption_reform_fn, @@ -491,6 +492,7 @@ def create_structural_reforms_from_parameters(parameters, period): ut_hb210_s2 = create_ut_hb210_s2_reform(parameters, period) ut_ctc = create_ut_ctc_reform(parameters, period) + ca_prop3 = create_ca_prop3_reform(parameters, period) american_worker_rebate_act = create_american_worker_rebate_act_reform( parameters, period @@ -657,6 +659,7 @@ def create_structural_reforms_from_parameters(parameters, period): ut_hb210, ut_hb210_s2, ut_ctc, + ca_prop3, additional_tax_bracket, american_worker_rebate_act, ctc_per_child_phase_out, diff --git a/policyengine_us/reforms/states/ca/__init__.py b/policyengine_us/reforms/states/ca/__init__.py index 3eb47243a2e..26d2aced072 100644 --- a/policyengine_us/reforms/states/ca/__init__.py +++ b/policyengine_us/reforms/states/ca/__init__.py @@ -1 +1,4 @@ from .ab2591 import create_ca_ab2591_reform +from .prop3.ca_prop3_reform import ( + create_ca_prop3_reform, +) diff --git a/policyengine_us/reforms/states/ca/prop3/__init__.py b/policyengine_us/reforms/states/ca/prop3/__init__.py new file mode 100644 index 00000000000..f2232776601 --- /dev/null +++ b/policyengine_us/reforms/states/ca/prop3/__init__.py @@ -0,0 +1 @@ +from .ca_prop3_reform import create_ca_prop3_reform, ca_prop3 diff --git a/policyengine_us/reforms/states/ca/prop3/ca_prop3_reform.py b/policyengine_us/reforms/states/ca/prop3/ca_prop3_reform.py new file mode 100644 index 00000000000..fe763aa540c --- /dev/null +++ b/policyengine_us/reforms/states/ca/prop3/ca_prop3_reform.py @@ -0,0 +1,59 @@ +from policyengine_us.model_api import * +from policyengine_core.periods import period as period_ +from policyengine_core.periods import instant + +# Under Cal. Const. art. XIII, § 36(f)(2), the 10.3%, 11.3% and 12.3% rates apply +# to taxable years before January 1, 2031. Proposition 3 (2026) would make them +# permanent, so the three brackets above 9.3% keep these rates from 2031 onward. +TOP_BRACKET_RATES = {6: 0.103, 7: 0.113, 8: 0.123} +FILING_STATUSES = [ + "single", + "joint", + "separate", + "surviving_spouse", + "head_of_household", +] + + +def create_ca_prop3() -> Reform: + def modify_parameters(parameters): + rates = parameters.gov.states.ca.tax.income.rates + for status in FILING_STATUSES: + schedule = getattr(rates, status) + for bracket, rate in TOP_BRACKET_RATES.items(): + schedule.brackets[bracket].rate.update( + start=instant("2031-01-01"), + stop=instant("2100-12-31"), + value=rate, + ) + return parameters + + class reform(Reform): + def apply(self): + self.modify_parameters(modify_parameters) + + return reform + + +def create_ca_prop3_reform(parameters, period, bypass: bool = False): + if bypass: + return create_ca_prop3() + + p = parameters.gov.contrib.states.ca.prop3 + + reform_active = False + current_period = period_(period) + + for i in range(5): + if p(current_period).in_effect: + reform_active = True + break + current_period = current_period.offset(1, "year") + + if reform_active: + return create_ca_prop3() + else: + return None + + +ca_prop3 = create_ca_prop3_reform(None, None, bypass=True) diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/ca_income_tax_before_credits.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/ca_income_tax_before_credits.yaml index 56b2bf12ede..bbcbbd26b6f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/ca_income_tax_before_credits.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/ca_income_tax_before_credits.yaml @@ -21,3 +21,35 @@ output: # 82_046.3 + (12.30% of (1_000_000 - 850_503)) = 100_434.131 ca_income_tax_before_credits: 100_434.131 + +- name: Single filer with $1 million in 2030 pays the 10.3%, 11.3% and 12.3% rates + period: 2030 + absolute_error_margin: 1 + input: + ca_taxable_income: 1_000_000 + filing_status: SINGLE + state_code: CA + output: + # Cal. Const. art. XIII, § 36(f)(2): the rates above 9.3% apply before January 1, 2031. + ca_income_tax_before_credits: 101_596.07 + +- name: Single filer with $1 million in 2031 pays 9.3% on all income above the 9.3% threshold + period: 2031 + absolute_error_margin: 1 + input: + ca_taxable_income: 1_000_000 + filing_status: SINGLE + state_code: CA + output: + # 2031 thresholds: 9.3% starts at 82,847.04; the three higher brackets revert to 9.3%. + ca_income_tax_before_credits: 88_942.90 + +- name: Joint filers with $2 million in 2031 pay 9.3% on all income above the 9.3% threshold + period: 2031 + absolute_error_margin: 1 + input: + ca_taxable_income: 2_000_000 + filing_status: JOINT + state_code: CA + output: + ca_income_tax_before_credits: 177_885.81 diff --git a/policyengine_us/tests/policy/contrib/states/ca/prop3.yaml b/policyengine_us/tests/policy/contrib/states/ca/prop3.yaml new file mode 100644 index 00000000000..3d5d2db5ea2 --- /dev/null +++ b/policyengine_us/tests/policy/contrib/states/ca/prop3.yaml @@ -0,0 +1,75 @@ +# California Proposition 3 (2026) keeps the 10.3%, 11.3% and 12.3% rates after 2030. +# Expected values apply the 2031 bracket thresholds by hand. + +- name: Prop 3 keeps the higher rates for a single filer with $1 million in 2031 + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + gov.contrib.states.ca.prop3.in_effect: true + ca_taxable_income: 1_000_000 + filing_status: SINGLE + state_code: CA + output: + # Baseline 2031 is 88,942.90; Prop 3 adds 1% on 84,633.30, 2% on 338,549.15 and 3% on 153,629.41. + ca_income_tax_before_credits: 101_169.10 + +- name: Prop 3 keeps the higher rates for a single filer with $2 million in 2031 + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + gov.contrib.states.ca.prop3.in_effect: true + ca_taxable_income: 2_000_000 + filing_status: SINGLE + state_code: CA + output: + ca_income_tax_before_credits: 224_169.10 + +- name: Prop 3 keeps the higher rates for joint filers with $2 million in 2031 + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + gov.contrib.states.ca.prop3.in_effect: true + ca_taxable_income: 2_000_000 + filing_status: JOINT + state_code: CA + output: + ca_income_tax_before_credits: 202_338.20 + +- name: Prop 3 does not change tax below the 10.3% threshold in 2031 + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + gov.contrib.states.ca.prop3.in_effect: true + ca_taxable_income: 300_000 + filing_status: SINGLE + state_code: CA + output: + ca_income_tax_before_credits: 23_842.90 + +- name: Prop 3 does not change 2030 tax, when the higher rates already apply + period: 2030 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + gov.contrib.states.ca.prop3.in_effect: true + ca_taxable_income: 1_000_000 + filing_status: SINGLE + state_code: CA + output: + ca_income_tax_before_credits: 101_596.07 + +- name: Prop 3 leaves the 1% Mental Health Services Tax unchanged + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + gov.contrib.states.ca.prop3.in_effect: true + ca_taxable_income: 2_000_000 + filing_status: SINGLE + state_code: CA + output: + ca_mental_health_services_tax: 10_000 From ea80e9d0ad577232990679162a1374489a6f4acc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 24 Sep 2026 15:56:33 -0400 Subject: [PATCH 2/7] Name the changelog fragment after the branch Co-Authored-By: Claude Opus 5.5 --- ...ction-2026-ca-prop3.fixed.md => ca-prop3-2030-expiry.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{election-2026-ca-prop3.fixed.md => ca-prop3-2030-expiry.fixed.md} (100%) diff --git a/changelog.d/election-2026-ca-prop3.fixed.md b/changelog.d/ca-prop3-2030-expiry.fixed.md similarity index 100% rename from changelog.d/election-2026-ca-prop3.fixed.md rename to changelog.d/ca-prop3-2030-expiry.fixed.md From 769ac916a6eae993983e332b6af667255780b3f1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 10:39:53 -0400 Subject: [PATCH 3/7] Address review of the California Prop 3 reform and 2031 rate expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Copy each bracket's 2030 rate forward instead of hard-coding the rates and bracket indexes, so user 2030 edits and schedule changes carry over. - Read activation from the dated in_effect values instead of a five-year scan, and restore the rates only in the years from 2031 to 2100 in which the toggle is true; the bypass instance stays unconditional. - Cite Cal. Const. art. XIII, § 36(f)(3) for head of household and § 36(f)(2)-(3) where both apply; add Prop 3 SEC. 4 and LAO page anchors, dated Prop 55 references, RTC § 17045 on joint and surviving spouse, and RTC § 17041 subsections; drop the stale indexing comment. - Add 2031 cases for every filing status, a 2030 head-of-household case, AMT and MHST stacking cases, and cases inside the first two surcharge brackets; move the contrib YAML to ca/prop3/ and drop redundant toggles. - Add pytest coverage of the app activation path, delayed and bounded toggles, 2030 user rates, and rate, threshold and RTC § 17045 properties. - Group the CA registration, export create_ca_prop3, and add an .added changelog fragment for the reform. Co-Authored-By: Claude Opus 5.5 --- changelog.d/ca-prop3-2030-expiry.added.md | 1 + changelog.d/ca-prop3-2030-expiry.fixed.md | 2 +- .../contrib/states/ca/prop3/in_effect.yaml | 10 +- .../tax/income/rates/head_of_household.yaml | 9 +- .../gov/states/ca/tax/income/rates/joint.yaml | 9 +- .../states/ca/tax/income/rates/separate.yaml | 7 +- .../states/ca/tax/income/rates/single.yaml | 7 +- .../ca/tax/income/rates/surviving_spouse.yaml | 9 +- policyengine_us/reforms/reforms.py | 8 +- policyengine_us/reforms/states/ca/__init__.py | 4 +- .../reforms/states/ca/prop3/__init__.py | 6 +- .../states/ca/prop3/ca_prop3_reform.py | 77 +++-- .../alternative_minimum_tax/ca_amt.yaml | 18 ++ .../income/ca_income_tax_before_credits.yaml | 60 +++- ..._income_tax_before_refundable_credits.yaml | 14 + .../tests/policy/contrib/states/ca/prop3.yaml | 75 ----- .../contrib/states/ca/prop3/ca_prop3.yaml | 157 ++++++++++ .../states/ca/prop3/test_ca_prop3_reform.py | 277 ++++++++++++++++++ 18 files changed, 624 insertions(+), 126 deletions(-) create mode 100644 changelog.d/ca-prop3-2030-expiry.added.md delete mode 100644 policyengine_us/tests/policy/contrib/states/ca/prop3.yaml create mode 100644 policyengine_us/tests/policy/contrib/states/ca/prop3/ca_prop3.yaml create mode 100644 policyengine_us/tests/policy/contrib/states/ca/prop3/test_ca_prop3_reform.py diff --git a/changelog.d/ca-prop3-2030-expiry.added.md b/changelog.d/ca-prop3-2030-expiry.added.md new file mode 100644 index 00000000000..f6cf7129c2c --- /dev/null +++ b/changelog.d/ca-prop3-2030-expiry.added.md @@ -0,0 +1 @@ +Add California Proposition 3 (2026) as a contributed reform, toggled by gov.contrib.states.ca.prop3.in_effect, that keeps the rates above 9.3% from 2031 onward. diff --git a/changelog.d/ca-prop3-2030-expiry.fixed.md b/changelog.d/ca-prop3-2030-expiry.fixed.md index 8b0258697e3..6a248088adf 100644 --- a/changelog.d/ca-prop3-2030-expiry.fixed.md +++ b/changelog.d/ca-prop3-2030-expiry.fixed.md @@ -1 +1 @@ -Encode the expiry of California's 10.3%, 11.3% and 12.3% income tax rates after tax year 2030 (Cal. Const. art. XIII, § 36(f)(2)) and add Proposition 3 (2026) as a contributed reform that makes them permanent. +Encode the expiry of California's 10.3%, 11.3% and 12.3% income tax rates after tax year 2030 in all five filing-status schedules (Cal. Const. art. XIII, § 36(f)(2)-(3)). diff --git a/policyengine_us/parameters/gov/contrib/states/ca/prop3/in_effect.yaml b/policyengine_us/parameters/gov/contrib/states/ca/prop3/in_effect.yaml index d9b3fd90f71..2df610071a2 100644 --- a/policyengine_us/parameters/gov/contrib/states/ca/prop3/in_effect.yaml +++ b/policyengine_us/parameters/gov/contrib/states/ca/prop3/in_effect.yaml @@ -1,4 +1,4 @@ -description: California applies the Proposition 3 (2026) permanent extension of the income tax rates above 9.3% if this is true. +description: California applies the Proposition 3 (2026) permanent extension of the income tax rates above 9.3% in years from 2031 onward in which this is true. values: 0000-01-01: false metadata: @@ -7,6 +7,10 @@ metadata: label: California Proposition 3 (2026) in effect reference: - title: California Proposition 3 (2026), Official Voter Information Guide, Legislative Analyst's analysis, Figure 1 - href: https://vig.cdn.sos.ca.gov/2026/general/pdf/prop3.pdf - - title: Cal. Const. art. XIII, § 36(f)(2) + href: https://vig.cdn.sos.ca.gov/2026/general/pdf/prop3.pdf#page=2 + - title: California Proposition 3 (2026), Text of Proposed Law, SEC. 4, amending Cal. Const. art. XIII, § 36(f)(2) (strikes "and before January 1, 2031,") + href: https://vig.cdn.sos.ca.gov/2026/general/pdf/prop3-text-proposed-laws.pdf#page=6 + - title: California Proposition 3 (2026), Text of Proposed Law, SEC. 4, amending Cal. Const. art. XIII, § 36(f)(2)(D), (f)(3) and (f)(3)(D) + href: https://vig.cdn.sos.ca.gov/2026/general/pdf/prop3-text-proposed-laws.pdf#page=7 + - title: Cal. Const. art. XIII, § 36(f)(2)-(3) href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CONS§ionNum=SEC.%2036.&article=XIII diff --git a/policyengine_us/parameters/gov/states/ca/tax/income/rates/head_of_household.yaml b/policyengine_us/parameters/gov/states/ca/tax/income/rates/head_of_household.yaml index 5c97698ef92..e52f46c6afc 100644 --- a/policyengine_us/parameters/gov/states/ca/tax/income/rates/head_of_household.yaml +++ b/policyengine_us/parameters/gov/states/ca/tax/income/rates/head_of_household.yaml @@ -70,6 +70,8 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.103 + # Proposition 3 (November 3, 2026 ballot) would repeal this 2031 reversion here and in + # the next two brackets; check the certified result and remove these entries if it passes. 2031-01-01: 0.093 - threshold: values: @@ -113,8 +115,9 @@ metadata: href: https://www.ftb.ca.gov/forms/2024/2024-540-tax-rate-schedules.pdf - title: California Income Tax Rate Schedules for 2025 href: https://www.ftb.ca.gov/forms/2025/2025-540-tax-rate-schedules.pdf - - title: Cal. Const. art. XIII, § 36(f)(2) (rates above 9.3% apply to taxable years before January 1, 2031) + - title: Cal. Const. art. XIII, § 36(f)(3) (rates above 9.3% apply to taxable years before January 1, 2031) href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CONS§ionNum=SEC.%2036.&article=XIII - - title: Cal. Rev. & Tax. Code § 17041 + - title: Proposition 55 (2016), Text of Proposed Law, SEC. 4, amending Cal. Const. art. XIII, § 36(f)(3) to end the rates above 9.3% before January 1, 2031 + href: https://vig.cdn.sos.ca.gov/2016/general/en/pdf/text-proposed-laws.pdf#page=16 + - title: Cal. Rev. & Tax. Code § 17041(c)(1), (h) href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?sectionNum=17041.&lawCode=RTC - # Index according to federal tax parameter rules until we add California CPI trend. diff --git a/policyengine_us/parameters/gov/states/ca/tax/income/rates/joint.yaml b/policyengine_us/parameters/gov/states/ca/tax/income/rates/joint.yaml index 87519bf42f5..1717969d2a2 100644 --- a/policyengine_us/parameters/gov/states/ca/tax/income/rates/joint.yaml +++ b/policyengine_us/parameters/gov/states/ca/tax/income/rates/joint.yaml @@ -70,6 +70,8 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.103 + # Proposition 3 (November 3, 2026 ballot) would repeal this 2031 reversion here and in + # the next two brackets; check the certified result and remove these entries if it passes. 2031-01-01: 0.093 - threshold: values: @@ -115,6 +117,9 @@ metadata: href: https://www.ftb.ca.gov/forms/2025/2025-540-tax-rate-schedules.pdf - title: Cal. Const. art. XIII, § 36(f)(2) (rates above 9.3% apply to taxable years before January 1, 2031) href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CONS§ionNum=SEC.%2036.&article=XIII - - title: Cal. Rev. & Tax. Code § 17041 + - title: Proposition 55 (2016), Text of Proposed Law, SEC. 4, amending Cal. Const. art. XIII, § 36(f)(2) to end the rates above 9.3% before January 1, 2031 + href: https://vig.cdn.sos.ca.gov/2016/general/en/pdf/text-proposed-laws.pdf#page=16 + - title: Cal. Rev. & Tax. Code § 17041(a)(1), (h) href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?sectionNum=17041.&lawCode=RTC - # Index according to federal tax parameter rules until we add California CPI trend. + - title: Cal. Rev. & Tax. Code § 17045 (joint and surviving spouse tax is twice the tax on one-half of taxable income) + href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?sectionNum=17045.&lawCode=RTC diff --git a/policyengine_us/parameters/gov/states/ca/tax/income/rates/separate.yaml b/policyengine_us/parameters/gov/states/ca/tax/income/rates/separate.yaml index 943ef1ec7ad..310316ae8af 100644 --- a/policyengine_us/parameters/gov/states/ca/tax/income/rates/separate.yaml +++ b/policyengine_us/parameters/gov/states/ca/tax/income/rates/separate.yaml @@ -70,6 +70,8 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.103 + # Proposition 3 (November 3, 2026 ballot) would repeal this 2031 reversion here and in + # the next two brackets; check the certified result and remove these entries if it passes. 2031-01-01: 0.093 - threshold: values: @@ -115,6 +117,7 @@ metadata: href: https://www.ftb.ca.gov/forms/2025/2025-540-tax-rate-schedules.pdf - title: Cal. Const. art. XIII, § 36(f)(2) (rates above 9.3% apply to taxable years before January 1, 2031) href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CONS§ionNum=SEC.%2036.&article=XIII - - title: Cal. Rev. & Tax. Code § 17041 + - title: Proposition 55 (2016), Text of Proposed Law, SEC. 4, amending Cal. Const. art. XIII, § 36(f)(2) to end the rates above 9.3% before January 1, 2031 + href: https://vig.cdn.sos.ca.gov/2016/general/en/pdf/text-proposed-laws.pdf#page=16 + - title: Cal. Rev. & Tax. Code § 17041(a)(1), (h) href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?sectionNum=17041.&lawCode=RTC - # Index according to federal tax parameter rules until we add California CPI trend. diff --git a/policyengine_us/parameters/gov/states/ca/tax/income/rates/single.yaml b/policyengine_us/parameters/gov/states/ca/tax/income/rates/single.yaml index 1d5f15f7edd..38695aeff12 100644 --- a/policyengine_us/parameters/gov/states/ca/tax/income/rates/single.yaml +++ b/policyengine_us/parameters/gov/states/ca/tax/income/rates/single.yaml @@ -70,6 +70,8 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.103 + # Proposition 3 (November 3, 2026 ballot) would repeal this 2031 reversion here and in + # the next two brackets; check the certified result and remove these entries if it passes. 2031-01-01: 0.093 - threshold: values: @@ -115,6 +117,7 @@ metadata: href: https://www.ftb.ca.gov/forms/2025/2025-540-tax-rate-schedules.pdf - title: Cal. Const. art. XIII, § 36(f)(2) (rates above 9.3% apply to taxable years before January 1, 2031) href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CONS§ionNum=SEC.%2036.&article=XIII - - title: Cal. Rev. & Tax. Code § 17041 + - title: Proposition 55 (2016), Text of Proposed Law, SEC. 4, amending Cal. Const. art. XIII, § 36(f)(2) to end the rates above 9.3% before January 1, 2031 + href: https://vig.cdn.sos.ca.gov/2016/general/en/pdf/text-proposed-laws.pdf#page=16 + - title: Cal. Rev. & Tax. Code § 17041(a)(1), (h) href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?sectionNum=17041.&lawCode=RTC - # Index according to federal tax parameter rules until we add California CPI trend. diff --git a/policyengine_us/parameters/gov/states/ca/tax/income/rates/surviving_spouse.yaml b/policyengine_us/parameters/gov/states/ca/tax/income/rates/surviving_spouse.yaml index 97bb6160d18..6c8255ac033 100644 --- a/policyengine_us/parameters/gov/states/ca/tax/income/rates/surviving_spouse.yaml +++ b/policyengine_us/parameters/gov/states/ca/tax/income/rates/surviving_spouse.yaml @@ -70,6 +70,8 @@ brackets: uprating: gov.states.ca.cpi rate: 2021-01-01: 0.103 + # Proposition 3 (November 3, 2026 ballot) would repeal this 2031 reversion here and in + # the next two brackets; check the certified result and remove these entries if it passes. 2031-01-01: 0.093 - threshold: values: @@ -115,6 +117,9 @@ metadata: href: https://www.ftb.ca.gov/forms/2025/2025-540-tax-rate-schedules.pdf - title: Cal. Const. art. XIII, § 36(f)(2) (rates above 9.3% apply to taxable years before January 1, 2031) href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CONS§ionNum=SEC.%2036.&article=XIII - - title: Cal. Rev. & Tax. Code § 17041 + - title: Proposition 55 (2016), Text of Proposed Law, SEC. 4, amending Cal. Const. art. XIII, § 36(f)(2) to end the rates above 9.3% before January 1, 2031 + href: https://vig.cdn.sos.ca.gov/2016/general/en/pdf/text-proposed-laws.pdf#page=16 + - title: Cal. Rev. & Tax. Code § 17041(a)(1), (h) href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?sectionNum=17041.&lawCode=RTC - # Index according to federal tax parameter rules until we add California CPI trend. + - title: Cal. Rev. & Tax. Code § 17045 (joint and surviving spouse tax is twice the tax on one-half of taxable income) + href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?sectionNum=17045.&lawCode=RTC diff --git a/policyengine_us/reforms/reforms.py b/policyengine_us/reforms/reforms.py index ac18de31f08..61fcd3050de 100644 --- a/policyengine_us/reforms/reforms.py +++ b/policyengine_us/reforms/reforms.py @@ -143,7 +143,6 @@ create_senior_deduction_extension_reform, create_agi_surtax_reform, ) -from .states.ca.prop3.ca_prop3_reform import create_ca_prop3_reform from .states.ri.ctc.ri_ctc_reform import create_ri_ctc_reform from .states.ri.exemption.ri_exemption_reform import ( create_ri_exemption_reform_fn, @@ -291,6 +290,9 @@ from .states.ca.ab2591 import ( create_ca_ab2591_reform, ) +from .states.ca.prop3 import ( + create_ca_prop3_reform, +) from .states.ga.sb520 import ( create_ga_sb520_reform, ) @@ -492,7 +494,6 @@ def create_structural_reforms_from_parameters(parameters, period): ut_hb210_s2 = create_ut_hb210_s2_reform(parameters, period) ut_ctc = create_ut_ctc_reform(parameters, period) - ca_prop3 = create_ca_prop3_reform(parameters, period) american_worker_rebate_act = create_american_worker_rebate_act_reform( parameters, period @@ -564,6 +565,7 @@ def create_structural_reforms_from_parameters(parameters, period): parameters, period ) ca_ab2591 = create_ca_ab2591_reform(parameters, period) + ca_prop3 = create_ca_prop3_reform(parameters, period) ga_sb520 = create_ga_sb520_reform(parameters, period) hi_hb2306_cdcc = create_hi_hb2306_cdcc_reform(parameters, period) nc_eitc = create_nc_eitc_reform(parameters, period) @@ -659,7 +661,6 @@ def create_structural_reforms_from_parameters(parameters, period): ut_hb210, ut_hb210_s2, ut_ctc, - ca_prop3, additional_tax_bracket, american_worker_rebate_act, ctc_per_child_phase_out, @@ -715,6 +716,7 @@ def create_structural_reforms_from_parameters(parameters, period): tx_rebate, al_hb527_overtime_deduction, ca_ab2591, + ca_prop3, ga_sb520, hi_hb2306_cdcc, nc_eitc, diff --git a/policyengine_us/reforms/states/ca/__init__.py b/policyengine_us/reforms/states/ca/__init__.py index 26d2aced072..3fa280d85cc 100644 --- a/policyengine_us/reforms/states/ca/__init__.py +++ b/policyengine_us/reforms/states/ca/__init__.py @@ -1,4 +1,2 @@ from .ab2591 import create_ca_ab2591_reform -from .prop3.ca_prop3_reform import ( - create_ca_prop3_reform, -) +from .prop3 import create_ca_prop3_reform diff --git a/policyengine_us/reforms/states/ca/prop3/__init__.py b/policyengine_us/reforms/states/ca/prop3/__init__.py index f2232776601..cd68b87c7ee 100644 --- a/policyengine_us/reforms/states/ca/prop3/__init__.py +++ b/policyengine_us/reforms/states/ca/prop3/__init__.py @@ -1 +1,5 @@ -from .ca_prop3_reform import create_ca_prop3_reform, ca_prop3 +from .ca_prop3_reform import ( + create_ca_prop3, + create_ca_prop3_reform, + ca_prop3, +) diff --git a/policyengine_us/reforms/states/ca/prop3/ca_prop3_reform.py b/policyengine_us/reforms/states/ca/prop3/ca_prop3_reform.py index fe763aa540c..6d97e8d3240 100644 --- a/policyengine_us/reforms/states/ca/prop3/ca_prop3_reform.py +++ b/policyengine_us/reforms/states/ca/prop3/ca_prop3_reform.py @@ -1,11 +1,14 @@ from policyengine_us.model_api import * -from policyengine_core.periods import period as period_ from policyengine_core.periods import instant -# Under Cal. Const. art. XIII, § 36(f)(2), the 10.3%, 11.3% and 12.3% rates apply -# to taxable years before January 1, 2031. Proposition 3 (2026) would make them -# permanent, so the three brackets above 9.3% keep these rates from 2031 onward. -TOP_BRACKET_RATES = {6: 0.103, 7: 0.113, 8: 0.123} +# Cal. Const. art. XIII, § 36(f)(2) (single, separate, and through RTC § 17045 +# joint and surviving spouse) and § 36(f)(3) (head of household) apply the +# rates above 9.3% to taxable years before January 1, 2031. Proposition 3 +# (2026), SEC. 4, strikes that end date, so the rates in effect for 2030 keep +# applying from 2031 onward. +LAST_PRE_SUNSET_YEAR = instant("2030-01-01") +SUNSET = instant("2031-01-01") +HORIZON_END = instant("2100-12-31") FILING_STATUSES = [ "single", "joint", @@ -15,17 +18,47 @@ ] -def create_ca_prop3() -> Reform: +def _active_windows(in_effect) -> list: + """Return the (start, stop) instants within SUNSET..HORIZON_END where the + dated values of ``in_effect`` are true. + + ``values_list`` holds the user's dated overrides in reverse chronological + order; each value applies from its instant until the next later one. + """ + windows = [] + later_start = None + for value_at_instant in in_effect.values_list: + start = instant(value_at_instant.instant_str) + if value_at_instant.value: + window_start = max(start, SUNSET) + window_stop = HORIZON_END + if later_start is not None: + window_stop = min(later_start.offset(-1, "day"), HORIZON_END) + if window_start <= window_stop: + windows.append((window_start, window_stop)) + later_start = start + return sorted(windows) + + +def create_ca_prop3(respect_in_effect: bool = False) -> Reform: def modify_parameters(parameters): + if respect_in_effect: + windows = _active_windows(parameters.gov.contrib.states.ca.prop3.in_effect) + else: + windows = [(SUNSET, HORIZON_END)] rates = parameters.gov.states.ca.tax.income.rates + # Snapshot every bracket's 2030 and 2031 rates before modifying any, + # so each window restores the pre-sunset rate, including any rate the + # user set for 2030. + restorations = [] for status in FILING_STATUSES: - schedule = getattr(rates, status) - for bracket, rate in TOP_BRACKET_RATES.items(): - schedule.brackets[bracket].rate.update( - start=instant("2031-01-01"), - stop=instant("2100-12-31"), - value=rate, - ) + for bracket in getattr(rates, status).brackets: + pre_sunset_rate = bracket.rate(LAST_PRE_SUNSET_YEAR) + if bracket.rate(SUNSET) != pre_sunset_rate: + restorations.append((bracket.rate, pre_sunset_rate)) + for rate, pre_sunset_rate in restorations: + for start, stop in windows: + rate.update(start=start, stop=stop, value=pre_sunset_rate) return parameters class reform(Reform): @@ -36,22 +69,14 @@ def apply(self): def create_ca_prop3_reform(parameters, period, bypass: bool = False): + # The bypass instance, used by YAML `reforms:` tests, restores the rates + # for 2031-2100 regardless of in_effect. Otherwise the reform applies only + # in the years from 2031 onward in which in_effect is true. if bypass: return create_ca_prop3() - p = parameters.gov.contrib.states.ca.prop3 - - reform_active = False - current_period = period_(period) - - for i in range(5): - if p(current_period).in_effect: - reform_active = True - break - current_period = current_period.offset(1, "year") - - if reform_active: - return create_ca_prop3() + if _active_windows(parameters.gov.contrib.states.ca.prop3.in_effect): + return create_ca_prop3(respect_in_effect=True) else: return None diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/alternative_minimum_tax/ca_amt.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/alternative_minimum_tax/ca_amt.yaml index 55d4814432b..18cdb085986 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/alternative_minimum_tax/ca_amt.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/alternative_minimum_tax/ca_amt.yaml @@ -52,3 +52,21 @@ households: {household: {members: [head, spouse], state_code: CA}} output: ca_amt: 0 + +- name: The 2031 rate expiry exposes AMT for a single filer with $1 million of taxable income + period: 2031 + absolute_error_margin: 1 + input: + state_code: CA + filing_status: SINGLE + ca_taxable_income: 1_000_000 + ca_amti: 1_400_000 + ca_amt_exemption: 0 + ca_non_refundable_credits: 0 + output: + # Regular tax falls to 88,942.90 once the rates above 9.3% expire (see + # ca_income_tax_before_credits.yaml). Tentative minimum tax is .07 x 1,400,000 = 98,000, + # so AMT is 98,000 - 88,942.90 = 9,057.10 and total tax is 98,000. + ca_income_tax_before_credits: 88_942.90 + ca_amt: 9_057.10 + ca_income_tax_before_refundable_credits: 98_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/ca_income_tax_before_credits.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/ca_income_tax_before_credits.yaml index bbcbbd26b6f..c53e439957b 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/ca_income_tax_before_credits.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/ca_income_tax_before_credits.yaml @@ -11,7 +11,7 @@ ca_income_tax_before_credits: 464.61 -- name: Single filing, income in 3rd tax bracket +- name: Head of household with $1 million in 2021 reaches the 12.3% bracket period: 2021 absolute_error_margin: 1 input: @@ -22,6 +22,13 @@ # 82_046.3 + (12.30% of (1_000_000 - 850_503)) = 100_434.131 ca_income_tax_before_credits: 100_434.131 +# 2030-2031 cases: thresholds are the FTB 2025 schedules uprated by gov.states.ca.cpi, +# a factor of f2030 = 193 / 172.797 for 2030 and f2031 = 196.85 / 172.797 for 2031. +# Single and separate tax at the 9.3% threshold is 3,201.97f (head of household 3,616.45f). +# Joint and surviving spouse tax is twice the single tax on half the income (RTC § 17045). +# The higher rates apply before January 1, 2031 (Cal. Const. art. XIII, § 36(f)(2)-(3)). +# These are also the reform-off controls for contrib/states/ca/prop3/ca_prop3.yaml. + - name: Single filer with $1 million in 2030 pays the 10.3%, 11.3% and 12.3% rates period: 2030 absolute_error_margin: 1 @@ -30,7 +37,8 @@ filing_status: SINGLE state_code: CA output: - # Cal. Const. art. XIII, § 36(f)(2): the rates above 9.3% apply before January 1, 2031. + # 3,201.97f + .093 x (1,000,000 - 72,724f) + .01 x (1,000,000 - 371,479f) + # + .01 x (1,000,000 - 445,771f) + .01 x (1,000,000 - 742,953f), f = f2030 ca_income_tax_before_credits: 101_596.07 - name: Single filer with $1 million in 2031 pays 9.3% on all income above the 9.3% threshold @@ -41,7 +49,7 @@ filing_status: SINGLE state_code: CA output: - # 2031 thresholds: 9.3% starts at 82,847.04; the three higher brackets revert to 9.3%. + # 3,201.97f + .093 x (1,000,000 - 72,724f), f = f2031; the 9.3% threshold is 82,847.04. ca_income_tax_before_credits: 88_942.90 - name: Joint filers with $2 million in 2031 pay 9.3% on all income above the 9.3% threshold @@ -52,4 +60,50 @@ filing_status: JOINT state_code: CA output: + # 2 x 88,942.90, the single tax on $1 million. + ca_income_tax_before_credits: 177_885.81 + +- name: Separate filer with $1 million in 2031 pays 9.3% on all income above the 9.3% threshold + period: 2031 + absolute_error_margin: 1 + input: + ca_taxable_income: 1_000_000 + filing_status: SEPARATE + state_code: CA + output: + # Same schedule as single: 3,201.97f + .093 x (1,000,000 - 72,724f), f = f2031. + ca_income_tax_before_credits: 88_942.90 + +- name: Surviving spouse with $2 million in 2031 pays 9.3% on all income above the 9.3% threshold + period: 2031 + absolute_error_margin: 1 + input: + ca_taxable_income: 2_000_000 + filing_status: SURVIVING_SPOUSE + state_code: CA + output: + # Treated as a joint return (RTC § 17045): 2 x 88,942.90. ca_income_tax_before_credits: 177_885.81 + +- name: Head of household with $1.5 million in 2031 pays 9.3% on all income above the 9.3% threshold + period: 2031 + absolute_error_margin: 1 + input: + ca_taxable_income: 1_500_000 + filing_status: HEAD_OF_HOUSEHOLD + state_code: CA + output: + # 3,616.45f + .093 x (1,500,000 - 98,990f), f = f2031. + ca_income_tax_before_credits: 133_132.32 + +- name: Head of household with $1.5 million in 2030 pays the 10.3%, 11.3% and 12.3% rates + period: 2030 + absolute_error_margin: 1 + input: + ca_taxable_income: 1_500_000 + filing_status: HEAD_OF_HOUSEHOLD + state_code: CA + output: + # 3,616.45f + .093 x (1,500,000 - 98,990f) + .01 x (1,500,000 - 505,208f) + # + .01 x (1,500,000 - 606,251f) + .01 x (1,500,000 - 1,010,417f), f = f2030 + ca_income_tax_before_credits: 154_557.25 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/ca_income_tax_before_refundable_credits.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/ca_income_tax_before_refundable_credits.yaml index f3c6cbf2c59..47636263adc 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/ca_income_tax_before_refundable_credits.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/ca_income_tax_before_refundable_credits.yaml @@ -32,3 +32,17 @@ output: # (50000 - 1000) + 2000 + 5000 = 56000 ca_income_tax_before_refundable_credits: 56_000 + +- name: The 1% mental health services tax stacks on the 2031 regular tax + period: 2031 + absolute_error_margin: 1 + input: + state_code: CA + filing_status: SINGLE + ca_taxable_income: 2_000_000 + ca_non_refundable_credits: 0 + ca_amt: 0 + output: + # Regular tax 3,201.97f + .093 x (2,000,000 - 72,724f) = 181,942.90 (f = 196.85 / 172.797), + # plus mental health services tax .01 x (2,000,000 - 1,000,000) = 10,000. + ca_income_tax_before_refundable_credits: 191_942.90 diff --git a/policyengine_us/tests/policy/contrib/states/ca/prop3.yaml b/policyengine_us/tests/policy/contrib/states/ca/prop3.yaml deleted file mode 100644 index 3d5d2db5ea2..00000000000 --- a/policyengine_us/tests/policy/contrib/states/ca/prop3.yaml +++ /dev/null @@ -1,75 +0,0 @@ -# California Proposition 3 (2026) keeps the 10.3%, 11.3% and 12.3% rates after 2030. -# Expected values apply the 2031 bracket thresholds by hand. - -- name: Prop 3 keeps the higher rates for a single filer with $1 million in 2031 - period: 2031 - absolute_error_margin: 1 - reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 - input: - gov.contrib.states.ca.prop3.in_effect: true - ca_taxable_income: 1_000_000 - filing_status: SINGLE - state_code: CA - output: - # Baseline 2031 is 88,942.90; Prop 3 adds 1% on 84,633.30, 2% on 338,549.15 and 3% on 153,629.41. - ca_income_tax_before_credits: 101_169.10 - -- name: Prop 3 keeps the higher rates for a single filer with $2 million in 2031 - period: 2031 - absolute_error_margin: 1 - reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 - input: - gov.contrib.states.ca.prop3.in_effect: true - ca_taxable_income: 2_000_000 - filing_status: SINGLE - state_code: CA - output: - ca_income_tax_before_credits: 224_169.10 - -- name: Prop 3 keeps the higher rates for joint filers with $2 million in 2031 - period: 2031 - absolute_error_margin: 1 - reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 - input: - gov.contrib.states.ca.prop3.in_effect: true - ca_taxable_income: 2_000_000 - filing_status: JOINT - state_code: CA - output: - ca_income_tax_before_credits: 202_338.20 - -- name: Prop 3 does not change tax below the 10.3% threshold in 2031 - period: 2031 - absolute_error_margin: 1 - reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 - input: - gov.contrib.states.ca.prop3.in_effect: true - ca_taxable_income: 300_000 - filing_status: SINGLE - state_code: CA - output: - ca_income_tax_before_credits: 23_842.90 - -- name: Prop 3 does not change 2030 tax, when the higher rates already apply - period: 2030 - absolute_error_margin: 1 - reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 - input: - gov.contrib.states.ca.prop3.in_effect: true - ca_taxable_income: 1_000_000 - filing_status: SINGLE - state_code: CA - output: - ca_income_tax_before_credits: 101_596.07 - -- name: Prop 3 leaves the 1% Mental Health Services Tax unchanged - period: 2031 - absolute_error_margin: 1 - reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 - input: - gov.contrib.states.ca.prop3.in_effect: true - ca_taxable_income: 2_000_000 - filing_status: SINGLE - state_code: CA - output: - ca_mental_health_services_tax: 10_000 diff --git a/policyengine_us/tests/policy/contrib/states/ca/prop3/ca_prop3.yaml b/policyengine_us/tests/policy/contrib/states/ca/prop3/ca_prop3.yaml new file mode 100644 index 00000000000..324fa8ecc43 --- /dev/null +++ b/policyengine_us/tests/policy/contrib/states/ca/prop3/ca_prop3.yaml @@ -0,0 +1,157 @@ +# California Proposition 3 (2026) keeps the 10.3%, 11.3% and 12.3% rates after 2030. +# +# The `reforms:` key applies the bypass instance of the reform (`ca_prop3`), which restores +# the rates for 2031-2100 whatever gov.contrib.states.ca.prop3.in_effect says, so these cases +# do not set the toggle. The app path, which reads the dated toggle, is tested in +# test_ca_prop3_reform.py. The reform-off controls are the 2030 and 2031 cases in +# baseline/gov/states/ca/tax/income/ca_income_tax_before_credits.yaml. +# +# Expected values apply the 2031 thresholds by hand: the FTB 2025 schedules uprated by +# f = 196.85 / 172.797. The baseline single and separate 2031 tax is +# 3,201.97f + .093 x (income - 72,724f); Prop 3 adds .01 x (income - t) above each of +# t = 371,479f, 445,771f and 742,953f. Head of household uses 3,616.45f, 98,990f and +# t = 505,208f, 606,251f and 1,010,417f. Joint and surviving spouse tax is twice the single +# tax on half the income (RTC § 17045). + +- name: Prop 3 keeps the higher rates for a single filer with $1 million in 2031 + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + ca_taxable_income: 1_000_000 + filing_status: SINGLE + state_code: CA + output: + # Baseline 2031 is 88,942.90; Prop 3 adds 1% on 84,633.30, 2% on 338,549.15 and 3% on 153,629.41. + ca_income_tax_before_credits: 101_169.10 + +- name: Prop 3 keeps the higher rates for a separate filer with $1 million in 2031 + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + ca_taxable_income: 1_000_000 + filing_status: SEPARATE + state_code: CA + output: + # Same schedule as single: 88,942.90 + 12,226.20. + ca_income_tax_before_credits: 101_169.10 + +- name: Prop 3 keeps the higher rates for joint filers with $2 million in 2031 + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + ca_taxable_income: 2_000_000 + filing_status: JOINT + state_code: CA + output: + # 2 x 101,169.10, the single tax on $1 million. + ca_income_tax_before_credits: 202_338.20 + +- name: Prop 3 keeps the higher rates for a surviving spouse with $2 million in 2031 + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + ca_taxable_income: 2_000_000 + filing_status: SURVIVING_SPOUSE + state_code: CA + output: + # Treated as a joint return: 2 x 101,169.10. + ca_income_tax_before_credits: 202_338.20 + +- name: Prop 3 keeps the higher rates for a head of household with $1.5 million in 2031 + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + ca_taxable_income: 1_500_000 + filing_status: HEAD_OF_HOUSEHOLD + state_code: CA + output: + # Baseline 133,132.32 + .01 x (1,500,000 - 575,531.95) + .01 x (1,500,000 - 690,639.94) + # + .01 x (1,500,000 - 1,151,065.04) = 153,959.95. + ca_income_tax_before_credits: 153_959.95 + +- name: Prop 3 applies only the 10.3% rate inside the first surcharge bracket in 2031 + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + ca_taxable_income: 450_000 + filing_status: SINGLE + state_code: CA + output: + # Baseline 37,792.90 + .01 x (450,000 - 423,188.14) = 38,061.02. + ca_income_tax_before_credits: 38_061.02 + +- name: Prop 3 applies the 10.3% and 11.3% rates inside the second surcharge bracket in 2031 + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + ca_taxable_income: 600_000 + filing_status: SINGLE + state_code: CA + output: + # Baseline 51,742.90 + .01 x (600,000 - 423,188.14) + .01 x (600,000 - 507,821.44) = 54,432.81. + ca_income_tax_before_credits: 54_432.81 + +- name: Prop 3 does not change tax below the 10.3% threshold in 2031 + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + ca_taxable_income: 300_000 + filing_status: SINGLE + state_code: CA + output: + # 3,201.97f + .093 x (300,000 - 72,724f), the same as the baseline. + ca_income_tax_before_credits: 23_842.90 + +- name: Prop 3 does not change 2030 tax, when the higher rates already apply + period: 2030 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + ca_taxable_income: 1_000_000 + filing_status: SINGLE + state_code: CA + output: + # Same as the 2030 baseline case. + ca_income_tax_before_credits: 101_596.07 + +- name: Prop 3 removes the AMT that the 2031 rate expiry exposes + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + ca_taxable_income: 1_000_000 + filing_status: SINGLE + state_code: CA + ca_amti: 1_400_000 + ca_amt_exemption: 0 + ca_non_refundable_credits: 0 + output: + # Tentative minimum tax .07 x 1,400,000 = 98,000 is below the 101,169.10 regular tax, + # so AMT is 0 (the baseline AMT is 9,057.10; see alternative_minimum_tax/ca_amt.yaml). + ca_income_tax_before_credits: 101_169.10 + ca_amt: 0 + ca_income_tax_before_refundable_credits: 101_169.10 + +- name: Prop 3 regular tax and the 1% mental health services tax stack for a single filer with $2 million in 2031 + period: 2031 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.states.ca.prop3.ca_prop3 + input: + ca_taxable_income: 2_000_000 + filing_status: SINGLE + state_code: CA + ca_non_refundable_credits: 0 + ca_amt: 0 + output: + # Regular tax 181,942.90 + .01 x (2,000,000 - 423,188.14) + .01 x (2,000,000 - 507,821.44) + # + .01 x (2,000,000 - 846,370.59) = 224,169.10, plus MHST .01 x 1,000,000 = 10,000. + ca_income_tax_before_credits: 224_169.10 + ca_income_tax_before_refundable_credits: 234_169.10 diff --git a/policyengine_us/tests/policy/contrib/states/ca/prop3/test_ca_prop3_reform.py b/policyengine_us/tests/policy/contrib/states/ca/prop3/test_ca_prop3_reform.py new file mode 100644 index 00000000000..917d36b30a0 --- /dev/null +++ b/policyengine_us/tests/policy/contrib/states/ca/prop3/test_ca_prop3_reform.py @@ -0,0 +1,277 @@ +"""Tests for the 2031 expiry of California's rates above 9.3% and for the +Proposition 3 (2026) contributed reform that would repeal it. + +Cal. Const. art. XIII, § 36(f)(2) (single and separate, and through RTC +§ 17045 joint and surviving spouse) and § 36(f)(3) (head of household) apply +the 10.3%, 11.3% and 12.3% rates to taxable years before January 1, 2031. +Proposition 3, SEC. 4, strikes that end date. The YAML cases check dollar +amounts, which depend on CPI projections; these tests check properties that +do not: + +- the rate ladder of every filing status in 2030, 2031, 2035 and 2100, under + the baseline and under the reform; +- for any year and income, the reform changes no threshold and no rate at or + below 9.3%, adds exactly 1% above each of the three surcharge thresholds + from 2031 and nothing before, and keeps separate tax equal to single tax and + joint and surviving spouse tax at twice the single tax on half the income; +- the app path (``Reform.from_dict`` on ``in_effect``) activates the reform + from a 2031 start and applies it only in the years ``in_effect`` is true; +- the dated windows the reform reads from ``in_effect`` cover exactly the + instants from 2031 through 2100 at which ``in_effect`` is true; and +- the reform carries each bracket's 2030 rate forward, including a rate the + user set for 2030. +""" + +import functools + +import numpy as np +import pytest +from hypothesis import given, settings, strategies as st +from policyengine_core.parameters import ParameterNode +from policyengine_core.periods import instant +from policyengine_core.reforms import Reform + +from policyengine_us import CountryTaxBenefitSystem, Simulation +from policyengine_us.reforms.states.ca.prop3 import ( + ca_prop3, + create_ca_prop3_reform, +) +from policyengine_us.reforms.states.ca.prop3.ca_prop3_reform import ( + _active_windows, +) +from policyengine_us.system import system as baseline_system + +IN_EFFECT = "gov.contrib.states.ca.prop3.in_effect" +STATUSES = ["single", "separate", "joint", "surviving_spouse", "head_of_household"] +# RTC § 17041(a)(1) and (c)(1) rates, then the § 36(f)(2)-(3) surcharge rates. +BASE_RATES = [0.01, 0.02, 0.04, 0.06, 0.08, 0.093] +PRE_SUNSET_RATES = BASE_RATES + [0.103, 0.113, 0.123] +POST_SUNSET_RATES = BASE_RATES + [0.093, 0.093, 0.093] +SURCHARGE_BRACKETS = [6, 7, 8] +INCOME = 1_000_000 + + +@functools.lru_cache(maxsize=None) +def _bypass_system(): + # The instance YAML `reforms:` tests use; it ignores in_effect. + return CountryTaxBenefitSystem(reform=ca_prop3) + + +def _schedule(system, status, year): + return getattr( + system.parameters(f"{year}-01-01").gov.states.ca.tax.income.rates, status + ) + + +def _rates(system, status, year): + return list(_schedule(system, status, year).rates) + + +def _tax(system, status, year, income): + return float(_schedule(system, status, year).calc(np.array([float(income)]))[0]) + + +def _situation(years): + years = [str(year) for year in years] + return { + "people": {"head": {"age": {year: 45 for year in years}}}, + "tax_units": { + "tax_unit": { + "members": ["head"], + "filing_status": {year: "SINGLE" for year in years}, + "ca_taxable_income": {year: INCOME for year in years}, + } + }, + "households": { + "household": { + "members": ["head"], + "state_code": {year: "CA" for year in years}, + } + }, + } + + +def _app_simulation(in_effect_values, years, other_values=None): + # Mirrors how the app applies the toggle: a dated Reform.from_dict, with the + # default start_instant. + reform = Reform.from_dict( + {IN_EFFECT: in_effect_values, **(other_values or {})}, + country_id="us", + ) + return Simulation(situation=_situation(years), reform=reform) + + +def _assert_active_years(simulation, years, active_years): + for year in years: + # Before 2031 the baseline already applies the higher rates. + keeps_higher_rates = year < 2031 or year in active_years + expected_rates = PRE_SUNSET_RATES if keeps_higher_rates else POST_SUNSET_RATES + expected_system = _bypass_system() if keeps_higher_rates else baseline_system + for status in STATUSES: + assert _rates(simulation.tax_benefit_system, status, year) == ( + pytest.approx(expected_rates) + ), (status, year) + tax = simulation.calculate("ca_income_tax_before_credits", year)[0] + if year >= 2031: + # The reform raises tax on $1 million in every year from 2031, so + # the two references differ and the comparison identifies the + # regime. + assert _tax(_bypass_system(), "single", year, INCOME) > _tax( + baseline_system, "single", year, INCOME + ) + assert tax == pytest.approx( + _tax(expected_system, "single", year, INCOME), abs=0.01 + ), year + + +@pytest.mark.parametrize("status", STATUSES) +def test_ca_prop3_rates_all_statuses_and_years(status): + for year in [2030, 2031, 2035, 2100]: + expected_baseline = PRE_SUNSET_RATES if year < 2031 else POST_SUNSET_RATES + assert _rates(baseline_system, status, year) == pytest.approx( + expected_baseline + ), year + # Proposition 3 keeps the 2030 rates in every year through 2100. + assert _rates(_bypass_system(), status, year) == pytest.approx( + PRE_SUNSET_RATES + ), year + + +@settings(max_examples=300, deadline=None) +@given( + year=st.integers(min_value=2025, max_value=2040), + income=st.floats(min_value=0, max_value=50_000_000, allow_nan=False), +) +def test_ca_prop3_preserves_thresholds_lower_rates_and_joint_scaling(year, income): + reformed_system = _bypass_system() + for status in STATUSES: + baseline = _schedule(baseline_system, status, year) + reformed = _schedule(reformed_system, status, year) + # Proposition 3 changes no threshold and no rate at or below 9.3%. + assert list(reformed.thresholds) == list(baseline.thresholds) + assert list(reformed.rates)[:6] == list(baseline.rates)[:6] + # From 2031 it adds 1% above each surcharge threshold; before, nothing. + thresholds = list(baseline.thresholds) + expected_change = ( + sum(0.01 * max(income - thresholds[i], 0) for i in SURCHARGE_BRACKETS) + if year >= 2031 + else 0 + ) + change = _tax(reformed_system, status, year, income) - _tax( + baseline_system, status, year, income + ) + assert change >= 0 + assert change == pytest.approx(expected_change, rel=1e-9, abs=1e-6) + for system in [baseline_system, reformed_system]: + single = _tax(system, "single", year, income) + # RTC § 17041(a)(1) applies the single schedule to separate returns. + assert _tax(system, "separate", year, income) == pytest.approx( + single, rel=1e-9, abs=1e-6 + ) + # RTC § 17045: joint tax is twice the tax on half the income, and a + # surviving spouse return is treated as a joint return. + for status in ["joint", "surviving_spouse"]: + assert _tax(system, status, year, 2 * income) == pytest.approx( + 2 * single, rel=1e-9, abs=1e-6 + ) + + +def test_ca_prop3_app_activation_from_2031(): + years = [2030, 2031, 2035] + simulation = _app_simulation({"2031-01-01.2100-12-31": True}, years) + _assert_active_years(simulation, years, active_years={2031, 2035}) + assert simulation.calculate("ca_income_tax_before_credits", 2031)[ + 0 + ] == pytest.approx(101_169.10, abs=1) + # With the toggle off, 2031 tax is the baseline. + baseline = Simulation(situation=_situation(years)) + assert baseline.calculate("ca_income_tax_before_credits", 2031)[0] == pytest.approx( + 88_942.90, abs=1 + ) + + +@pytest.mark.parametrize( + "in_effect_values, active_years", + [ + # True only in 2026, before the sunset: nothing to restore. + ({"2026-01-01.2026-12-31": True}, set()), + # A delayed start leaves 2031-2032 at the baseline. + ({"2033-01-01.2100-12-31": True}, {2033, 2034, 2035}), + # True, then false in 2033-2034, then true again. + ( + {"2031-01-01.2032-12-31": True, "2035-01-01.2100-12-31": True}, + {2031, 2032, 2035}, + ), + ], +) +def test_ca_prop3_respects_delayed_and_bounded_activation( + in_effect_values, active_years +): + years = [2030, 2031, 2032, 2033, 2034, 2035] + simulation = _app_simulation(in_effect_values, years) + _assert_active_years(simulation, years, active_years) + reform = create_ca_prop3_reform( + simulation.tax_benefit_system.parameters, "2024-01-01" + ) + assert (reform is None) == (not active_years) + + +def test_ca_prop3_copies_2030_user_rates(): + simulation = _app_simulation( + {"2031-01-01.2100-12-31": True}, + [2030, 2031, 2035], + other_values={ + "gov.states.ca.tax.income.rates.single[6].rate": { + "2030-01-01.2030-12-31": 0.104 + } + }, + ) + rates = simulation.tax_benefit_system.parameters.gov.states.ca.tax.income.rates + for year in [2030, 2031, 2035]: + assert rates.single.brackets[6].rate(f"{year}-01-01") == pytest.approx(0.104) + # Other schedules keep the statutory 10.3%. + assert rates.separate.brackets[6].rate(f"{year}-01-01") == pytest.approx(0.103) + tax_2031 = simulation.calculate("ca_income_tax_before_credits", 2031)[0] + single_2031 = _schedule(_bypass_system(), "single", 2031) + bracket_6_income = single_2031.thresholds[7] - single_2031.thresholds[6] + assert tax_2031 == pytest.approx( + _tax(_bypass_system(), "single", 2031, INCOME) + 0.001 * bracket_6_income, + abs=0.01, + ) + + +# Dated toggle schedules: (start year, start month, end year or None, value). +UPDATES = st.lists( + st.tuples( + st.integers(min_value=2020, max_value=2105), + st.sampled_from([1, 7]), + st.one_of(st.none(), st.integers(min_value=0, max_value=30)), + st.booleans(), + ), + max_size=5, +) + + +@settings(max_examples=300, deadline=None) +@given(updates=UPDATES) +def test_ca_prop3_windows_match_dated_in_effect(updates): + node = ParameterNode("prop3", data={"in_effect": {"values": {"0000-01-01": False}}}) + in_effect = node.in_effect + for start_year, start_month, length, value in updates: + start = instant(f"{start_year}-{start_month:02d}-01") + stop = None if length is None else instant(f"{start_year + length}-12-31") + in_effect.update(start=start, stop=stop, value=value) + windows = _active_windows(in_effect) + # Windows are sorted, disjoint and inside 2031-01-01 through 2100-12-31. + for (start, stop), (next_start, _) in zip(windows, windows[1:]): + assert stop < next_start + for start, stop in windows: + assert instant("2031-01-01") <= start <= stop <= instant("2100-12-31") + # They cover exactly the dates from 2031 through 2100 at which the toggle + # is true, checked on the 1st of January and July of each year. + for year in range(2025, 2106): + for month in [1, 7]: + date = instant(f"{year}-{month:02d}-01") + covered = any(start <= date <= stop for start, stop in windows) + expected = 2031 <= year <= 2100 and bool(in_effect(date)) + assert covered == expected, (date, windows) From 279f541dd1cfb8aea9471be8a13cf2c607187b87 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 23:08:18 -0400 Subject: [PATCH 4/7] Restore only the enacted Prop 3 sunset, keeping user rate edits The reform now reads each schedule's enacted rates from the file they were loaded from and, inside each active window, gives the 2030 rate only to the brackets the enacted sunset changes and only on intervals where the enacted 9.3% rate still applies. This fixes three cases the January 2031 snapshot got wrong: a 2031-only override equal to the 2030 rate no longer stops the restoration in later years; a bounded 2030 edit to a lower bracket is no longer extended; and explicit future rates survive on both construction paths, so applying the reform twice (as Simulation does) changes nothing. Tests: enacted schedules match the baseline in every year 2021-2101; user edits are kept the same way through CountryTaxBenefitSystem and Simulation; a Hypothesis property checks the restoration against random edits and toggle schedules, including idempotence; the window property now probes both sides of every toggle change and the horizon. Co-Authored-By: Claude Opus 5.5 --- .../states/ca/prop3/ca_prop3_reform.py | 72 +++++- .../states/ca/prop3/test_ca_prop3_reform.py | 223 ++++++++++++++++-- 2 files changed, 268 insertions(+), 27 deletions(-) diff --git a/policyengine_us/reforms/states/ca/prop3/ca_prop3_reform.py b/policyengine_us/reforms/states/ca/prop3/ca_prop3_reform.py index 6d97e8d3240..116a090495b 100644 --- a/policyengine_us/reforms/states/ca/prop3/ca_prop3_reform.py +++ b/policyengine_us/reforms/states/ca/prop3/ca_prop3_reform.py @@ -1,4 +1,7 @@ +import functools + from policyengine_us.model_api import * +from policyengine_core.parameters import load_parameter_file from policyengine_core.periods import instant # Cal. Const. art. XIII, § 36(f)(2) (single, separate, and through RTC § 17045 @@ -40,6 +43,58 @@ def _active_windows(in_effect) -> list: return sorted(windows) +@functools.lru_cache(maxsize=None) +def _enacted_schedule(file_path: str): + # The schedule as enacted, read from the file the rates were loaded from, + # so the reform can tell the statutory sunset apart from a user's edit. + return load_parameter_file(file_path) + + +def _constant_intervals(start, stop, *parameters) -> list: + """Split start..stop into the (start, stop) intervals on which every + parameter keeps one value.""" + cuts = {start} + for parameter in parameters: + for value_at_instant in parameter.values_list: + cut = instant(value_at_instant.instant_str) + if start < cut <= stop: + cuts.add(cut) + cuts = sorted(cuts) + stops = [cut.offset(-1, "day") for cut in cuts[1:]] + [stop] + return list(zip(cuts, stops)) + + +def _restore_pre_sunset_rates(rates, enacted_rates: dict, windows: list) -> None: + """Within each window, give each bracket whose enacted rate the sunset + changes its 2030 rate, wherever the enacted post-sunset rate applies. + + ``enacted_rates`` maps each filing status to its enacted schedule. Brackets + the sunset leaves unchanged keep any edit, including one bounded to 2030, + and so do intervals whose rate differs from the enacted rate, which a user + has set explicitly. Restored intervals no longer match the enacted rate, so + applying the reform again changes nothing. + """ + updates = [] + for status in FILING_STATUSES: + brackets = getattr(rates, status).brackets + for bracket, enacted_bracket in zip(brackets, enacted_rates[status].brackets): + rate = bracket.rate + enacted_rate = enacted_bracket.rate + if enacted_rate(SUNSET) == enacted_rate(LAST_PRE_SUNSET_YEAR): + continue + # The 2030 rate, including any rate the user set for 2030. + pre_sunset_rate = rate(LAST_PRE_SUNSET_YEAR) + for window_start, window_stop in windows: + for start, stop in _constant_intervals( + window_start, window_stop, rate, enacted_rate + ): + if rate(start) == enacted_rate(start): + updates.append((rate, start, stop, pre_sunset_rate)) + # Read every rate before writing any. + for rate, start, stop, value in updates: + rate.update(start=start, stop=stop, value=value) + + def create_ca_prop3(respect_in_effect: bool = False) -> Reform: def modify_parameters(parameters): if respect_in_effect: @@ -47,18 +102,11 @@ def modify_parameters(parameters): else: windows = [(SUNSET, HORIZON_END)] rates = parameters.gov.states.ca.tax.income.rates - # Snapshot every bracket's 2030 and 2031 rates before modifying any, - # so each window restores the pre-sunset rate, including any rate the - # user set for 2030. - restorations = [] - for status in FILING_STATUSES: - for bracket in getattr(rates, status).brackets: - pre_sunset_rate = bracket.rate(LAST_PRE_SUNSET_YEAR) - if bracket.rate(SUNSET) != pre_sunset_rate: - restorations.append((bracket.rate, pre_sunset_rate)) - for rate, pre_sunset_rate in restorations: - for start, stop in windows: - rate.update(start=start, stop=stop, value=pre_sunset_rate) + enacted_rates = { + status: _enacted_schedule(getattr(rates, status).file_path) + for status in FILING_STATUSES + } + _restore_pre_sunset_rates(rates, enacted_rates, windows) return parameters class reform(Reform): diff --git a/policyengine_us/tests/policy/contrib/states/ca/prop3/test_ca_prop3_reform.py b/policyengine_us/tests/policy/contrib/states/ca/prop3/test_ca_prop3_reform.py index 917d36b30a0..d8d9b2c3b51 100644 --- a/policyengine_us/tests/policy/contrib/states/ca/prop3/test_ca_prop3_reform.py +++ b/policyengine_us/tests/policy/contrib/states/ca/prop3/test_ca_prop3_reform.py @@ -17,9 +17,14 @@ - the app path (``Reform.from_dict`` on ``in_effect``) activates the reform from a 2031 start and applies it only in the years ``in_effect`` is true; - the dated windows the reform reads from ``in_effect`` cover exactly the - instants from 2031 through 2100 at which ``in_effect`` is true; and + instants from 2031 through 2100 at which ``in_effect`` is true; - the reform carries each bracket's 2030 rate forward, including a rate the - user set for 2030. + user set for 2030; and +- whatever the user edits, the reform changes only the brackets the enacted + sunset changes, only inside the active windows and only where the enacted + post-sunset rate still applies, so it keeps explicit future rates, does not + extend edits to other brackets, gives the same rates on both construction + paths and changes nothing when applied again. """ import functools @@ -37,7 +42,11 @@ create_ca_prop3_reform, ) from policyengine_us.reforms.states.ca.prop3.ca_prop3_reform import ( + LAST_PRE_SUNSET_YEAR, + SUNSET, _active_windows, + _enacted_schedule, + _restore_pre_sunset_rates, ) from policyengine_us.system import system as baseline_system @@ -240,6 +249,117 @@ def test_ca_prop3_copies_2030_user_rates(): ) +def test_ca_prop3_enacted_schedules_match_baseline_rates(): + # The reform reads each enacted schedule from the file its rates were + # loaded from. The parameter pipeline does not change rates, so the file + # and the baseline agree in every year. + rates = baseline_system.parameters.gov.states.ca.tax.income.rates + for status in STATUSES: + schedule = getattr(rates, status) + enacted = _enacted_schedule(schedule.file_path) + assert len(enacted.brackets) == len(schedule.brackets) + for year in range(2021, 2102): + date = f"{year}-01-01" + assert [bracket.rate(date) for bracket in enacted.brackets] == [ + bracket.rate(date) for bracket in schedule.brackets + ], (status, year) + + +def test_ca_prop3_keeps_user_edits_on_both_construction_paths(): + rate = "gov.states.ca.tax.income.rates.{}.rate" + reform = Reform.from_dict( + { + IN_EFFECT: {"2033-01-01.2100-12-31": True}, + # A 2030 surcharge rate is carried into the active years. + rate.format("single[6]"): {"2030-01-01.2030-12-31": 0.104}, + # A 2031-only rate equal to the 2030 rate: the sunset still applies + # in 2032, and the reform removes it from 2033. + rate.format("separate[6]"): {"2031-01-01.2031-12-31": 0.103}, + # A 2030-only edit to a bracket the sunset leaves alone is not + # extended. + rate.format("joint[1]"): {"2030-01-01.2030-12-31": 0.021}, + # Explicit rates inside the active years are kept. + rate.format("head_of_household[7]"): {"2033-01-01.2033-12-31": 0.12}, + rate.format("head_of_household[8]"): {"2035-01-01.2100-12-31": 0.15}, + }, + country_id="us", + ) + expected = { + ("single", 6): { + 2030: 0.104, + 2031: 0.093, + 2032: 0.093, + 2033: 0.104, + 2036: 0.104, + }, + ("separate", 6): { + 2030: 0.103, + 2031: 0.103, + 2032: 0.093, + 2033: 0.103, + 2036: 0.103, + }, + ("joint", 1): {2030: 0.021, 2031: 0.02, 2033: 0.02, 2036: 0.02}, + ("head_of_household", 7): {2032: 0.093, 2033: 0.12, 2034: 0.113, 2036: 0.113}, + ("head_of_household", 8): { + 2032: 0.093, + 2033: 0.123, + 2034: 0.123, + 2035: 0.15, + 2036: 0.15, + }, + ("surviving_spouse", 8): {2030: 0.123, 2032: 0.093, 2033: 0.123, 2036: 0.123}, + } + simulation = Simulation(situation=_situation([2031, 2033]), reform=reform) + # A bare system applies the structural reform once; Simulation applies it + # again after replaying the user reform. + for system in [ + CountryTaxBenefitSystem(reform=reform), + simulation.tax_benefit_system, + ]: + rates = system.parameters.gov.states.ca.tax.income.rates + for (status, index), by_year in expected.items(): + bracket_rate = getattr(rates, status).brackets[index].rate + for year, value in by_year.items(): + assert bracket_rate(f"{year}-01-01") == pytest.approx(value), ( + status, + index, + year, + ) + # 2031 tax is the baseline; 2033 tax uses the carried 10.4% rate. + assert simulation.calculate("ca_income_tax_before_credits", 2031)[ + 0 + ] == pytest.approx(_tax(baseline_system, "single", 2031, INCOME), abs=0.01) + single_2033 = _schedule(_bypass_system(), "single", 2033) + bracket_6_income = single_2033.thresholds[7] - single_2033.thresholds[6] + assert simulation.calculate("ca_income_tax_before_credits", 2033)[ + 0 + ] == pytest.approx( + _tax(_bypass_system(), "single", 2033, INCOME) + 0.001 * bracket_6_income, + abs=0.01, + ) + + +def _around(date): + return {date.offset(-1, "day"), date, date.offset(1, "day")} + + +def _toggle(updates): + """Build an in_effect parameter from dated updates; also return the dates + at which its value can change.""" + node = ParameterNode("prop3", data={"in_effect": {"values": {"0000-01-01": False}}}) + in_effect = node.in_effect + boundaries = set() + for start_year, start_month, length, value in updates: + start = instant(f"{start_year}-{start_month:02d}-01") + stop = None if length is None else instant(f"{start_year + length}-12-31") + in_effect.update(start=start, stop=stop, value=value) + boundaries |= _around(start) + if stop is not None: + boundaries |= _around(stop) + return in_effect, boundaries + + # Dated toggle schedules: (start year, start month, end year or None, value). UPDATES = st.lists( st.tuples( @@ -255,12 +375,7 @@ def test_ca_prop3_copies_2030_user_rates(): @settings(max_examples=300, deadline=None) @given(updates=UPDATES) def test_ca_prop3_windows_match_dated_in_effect(updates): - node = ParameterNode("prop3", data={"in_effect": {"values": {"0000-01-01": False}}}) - in_effect = node.in_effect - for start_year, start_month, length, value in updates: - start = instant(f"{start_year}-{start_month:02d}-01") - stop = None if length is None else instant(f"{start_year + length}-12-31") - in_effect.update(start=start, stop=stop, value=value) + in_effect, boundaries = _toggle(updates) windows = _active_windows(in_effect) # Windows are sorted, disjoint and inside 2031-01-01 through 2100-12-31. for (start, stop), (next_start, _) in zip(windows, windows[1:]): @@ -268,10 +383,88 @@ def test_ca_prop3_windows_match_dated_in_effect(updates): for start, stop in windows: assert instant("2031-01-01") <= start <= stop <= instant("2100-12-31") # They cover exactly the dates from 2031 through 2100 at which the toggle - # is true, checked on the 1st of January and July of each year. - for year in range(2025, 2106): - for month in [1, 7]: - date = instant(f"{year}-{month:02d}-01") - covered = any(start <= date <= stop for start, stop in windows) - expected = 2031 <= year <= 2100 and bool(in_effect(date)) - assert covered == expected, (date, windows) + # is true, checked on the 1st of January and July of each year and on + # each side of every date at which the toggle or the horizon changes. + probes = { + instant(f"{year}-{month:02d}-01") + for year in range(2025, 2106) + for month in [1, 7] + } + probes |= boundaries | _around(SUNSET) | _around(instant("2101-01-01")) + for date in sorted(probes): + covered = any(start <= date <= stop for start, stop in windows) + expected = SUNSET <= date <= instant("2100-12-31") and bool(in_effect(date)) + assert covered == expected, (date, windows) + + +# User edits to bracket rates: (status, bracket, start year, length in years +# or None for open-ended, rate). +EDITS = st.lists( + st.tuples( + st.sampled_from(STATUSES), + st.integers(min_value=0, max_value=8), + st.integers(min_value=2028, max_value=2040), + st.one_of(st.none(), st.integers(min_value=0, max_value=5)), + st.sampled_from([0.02, 0.021, 0.093, 0.103, 0.104, 0.113, 0.15]), + ), + max_size=6, +) + + +@settings(max_examples=150, deadline=None) +@given(edits=EDITS, updates=UPDATES) +def test_ca_prop3_restores_only_the_enacted_sunset(edits, updates): + baseline_rates = baseline_system.parameters.gov.states.ca.tax.income.rates + # A detached copy, so the edits stay out of the shared baseline tree. + rates = baseline_rates.clone() + rates.parent = None + probes = _around(LAST_PRE_SUNSET_YEAR) | _around(SUNSET) + probes |= _around(instant("2101-01-01")) + for status, index, start_year, length, value in edits: + start = instant(f"{start_year}-01-01") + stop = None if length is None else instant(f"{start_year + length}-12-31") + getattr(rates, status).brackets[index].rate.update( + start=start, stop=stop, value=value + ) + probes |= _around(start) + if stop is not None: + probes |= _around(stop) + edited = rates.clone() + in_effect, boundaries = _toggle(updates) + windows = _active_windows(in_effect) + probes |= boundaries + enacted = { + status: _enacted_schedule(getattr(rates, status).file_path) + for status in STATUSES + } + _restore_pre_sunset_rates(rates, enacted, windows) + restored = rates.clone() + _restore_pre_sunset_rates(rates, enacted, windows) + # Every interval on which the edits, windows and enacted rates are constant + # contains a probe, since the probes include both sides of every change. + probes = sorted(date for date in probes if date >= LAST_PRE_SUNSET_YEAR) + for status in STATUSES: + for index in range(9): + baseline_rate = getattr(baseline_rates, status).brackets[index].rate + edited_rate = getattr(edited, status).brackets[index].rate + changed_by_sunset = baseline_rate(SUNSET) != baseline_rate( + LAST_PRE_SUNSET_YEAR + ) + assert changed_by_sunset == (index in SURCHARGE_BRACKETS) + for date in probes: + in_window = any(start <= date <= stop for start, stop in windows) + if ( + changed_by_sunset + and in_window + and edited_rate(date) == baseline_rate(date) + ): + expected = edited_rate(LAST_PRE_SUNSET_YEAR) + else: + expected = edited_rate(date) + assert getattr(restored, status).brackets[index].rate(date) == ( + expected + ), (status, index, date) + # Applying the reform again changes nothing. + assert getattr(rates, status).brackets[index].rate(date) == ( + expected + ), (status, index, date) From 8fb31d72180f0793a1efd4313f9d5ca0cc47d045 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 06:12:13 -0400 Subject: [PATCH 5/7] Keep Prop 3 window property insensitive to shared-host delays --- .../contrib/states/ca/prop3/test_ca_prop3_reform.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/policyengine_us/tests/policy/contrib/states/ca/prop3/test_ca_prop3_reform.py b/policyengine_us/tests/policy/contrib/states/ca/prop3/test_ca_prop3_reform.py index d8d9b2c3b51..82c2b7762a9 100644 --- a/policyengine_us/tests/policy/contrib/states/ca/prop3/test_ca_prop3_reform.py +++ b/policyengine_us/tests/policy/contrib/states/ca/prop3/test_ca_prop3_reform.py @@ -31,7 +31,7 @@ import numpy as np import pytest -from hypothesis import given, settings, strategies as st +from hypothesis import HealthCheck, given, settings, strategies as st from policyengine_core.parameters import ParameterNode from policyengine_core.periods import instant from policyengine_core.reforms import Reform @@ -372,7 +372,13 @@ def _toggle(updates): ) -@settings(max_examples=300, deadline=None) +# Input generation can be descheduled on shared hosts; keep every example and +# assertion without treating that wall-clock delay as a test failure. +@settings( + max_examples=300, + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) @given(updates=UPDATES) def test_ca_prop3_windows_match_dated_in_effect(updates): in_effect, boundaries = _toggle(updates) From c2b8d61cdb79c77cbb3667eae1bbfbf773e155f6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 15:51:57 -0400 Subject: [PATCH 6/7] Review should-address 1: share deterministic Prop 3 property settings --- .../states/ca/prop3/test_ca_prop3_reform.py | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/policyengine_us/tests/policy/contrib/states/ca/prop3/test_ca_prop3_reform.py b/policyengine_us/tests/policy/contrib/states/ca/prop3/test_ca_prop3_reform.py index 82c2b7762a9..690337e58af 100644 --- a/policyengine_us/tests/policy/contrib/states/ca/prop3/test_ca_prop3_reform.py +++ b/policyengine_us/tests/policy/contrib/states/ca/prop3/test_ca_prop3_reform.py @@ -59,6 +59,15 @@ SURCHARGE_BRACKETS = [6, 7, 8] INCOME = 1_000_000 +# Input generation can be descheduled on shared hosts; keep every example and +# assertion without treating that wall-clock delay as a test failure. +# Deterministic examples keep CI failures reproducible. +PROPERTY_SETTINGS = dict( + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + @functools.lru_cache(maxsize=None) def _bypass_system(): @@ -146,7 +155,7 @@ def test_ca_prop3_rates_all_statuses_and_years(status): ), year -@settings(max_examples=300, deadline=None) +@settings(max_examples=300, **PROPERTY_SETTINGS) @given( year=st.integers(min_value=2025, max_value=2040), income=st.floats(min_value=0, max_value=50_000_000, allow_nan=False), @@ -372,13 +381,7 @@ def _toggle(updates): ) -# Input generation can be descheduled on shared hosts; keep every example and -# assertion without treating that wall-clock delay as a test failure. -@settings( - max_examples=300, - deadline=None, - suppress_health_check=[HealthCheck.too_slow], -) +@settings(max_examples=300, **PROPERTY_SETTINGS) @given(updates=UPDATES) def test_ca_prop3_windows_match_dated_in_effect(updates): in_effect, boundaries = _toggle(updates) @@ -417,7 +420,7 @@ def test_ca_prop3_windows_match_dated_in_effect(updates): ) -@settings(max_examples=150, deadline=None) +@settings(max_examples=150, **PROPERTY_SETTINGS) @given(edits=EDITS, updates=UPDATES) def test_ca_prop3_restores_only_the_enacted_sunset(edits, updates): baseline_rates = baseline_system.parameters.gov.states.ca.tax.income.rates From 2fe6a8a1b08add652aff9bb52b515984f6e3430f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 15:53:33 -0400 Subject: [PATCH 7/7] Review suggestion 3: document the enacted YAML path dependency --- policyengine_us/reforms/states/ca/prop3/ca_prop3_reform.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/policyengine_us/reforms/states/ca/prop3/ca_prop3_reform.py b/policyengine_us/reforms/states/ca/prop3/ca_prop3_reform.py index 116a090495b..b5219999a66 100644 --- a/policyengine_us/reforms/states/ca/prop3/ca_prop3_reform.py +++ b/policyengine_us/reforms/states/ca/prop3/ca_prop3_reform.py @@ -47,6 +47,8 @@ def _active_windows(in_effect) -> list: def _enacted_schedule(file_path: str): # The schedule as enacted, read from the file the rates were loaded from, # so the reform can tell the statutory sunset apart from a user's edit. + # The YAML must remain readable at this path, including after unpickling + # the system on another machine. return load_parameter_file(file_path)