diff --git a/changelog.d/statute-first-ca-premium-subsidy.fixed.md b/changelog.d/statute-first-ca-premium-subsidy.fixed.md new file mode 100644 index 00000000000..edcb4be4da5 --- /dev/null +++ b/changelog.d/statute-first-ca-premium-subsidy.fixed.md @@ -0,0 +1 @@ +Select California Premium Subsidy bands from exact nonnegative ACA MAGI divided by prior-year FPL under the adopted [2026 Program Design, (d)(4)](https://board.coveredca.com/meetings/2025/July%2028,%202025/CoveredCA_2026_Premium_Subsidy_Program_Design_Final.pdf#page=2): exactly 150% changes from a 3.19% contribution to 0%, while income above 150% enters the 3.19% band. Retain the truncated fraction for the rate within that band, following [FTB 3849 Table 1-2 (2020)](https://www.ftb.ca.gov/forms/2020/2020-3849-instructions.pdf#page=6) and [2021](https://www.ftb.ca.gov/forms/2021/2021-3849-instructions.pdf#page=6); [2025 Form 8962 Worksheet 2 line 4](https://www.irs.gov/pub/irs-prior/i8962--2025.pdf#page=9) likewise tests "more than" on the exact amount before truncating. diff --git a/policyengine_us/parameters/gov/states/ca/hbex/premium_subsidy/applicable_percentage/threshold.yaml b/policyengine_us/parameters/gov/states/ca/hbex/premium_subsidy/applicable_percentage/threshold.yaml index 2196012efae..63e30cf9e77 100644 --- a/policyengine_us/parameters/gov/states/ca/hbex/premium_subsidy/applicable_percentage/threshold.yaml +++ b/policyengine_us/parameters/gov/states/ca/hbex/premium_subsidy/applicable_percentage/threshold.yaml @@ -1,9 +1,14 @@ description: California sets the federal poverty line thresholds for the applicable percentage brackets under the California Premium Subsidy program. values: - # 150% is untiered in the Program Design prose but sits in the 3.19% band: - # the Explainer labels the band "150% - At or Below 165%", and under - # 26 CFR 1.36B-3(g)(2) FPL-percentage truncation to 2 decimals collapses - # [150%, 151%) to 1.50, so 150% takes the 150-165% bracket rate. + # The adopted Program Design runs its bands "100% up to 150%" (0%) and + # "Above 150% up to 165%", so an exact ratio equal to a threshold takes the + # lower band: 150% pays 0%. The Policy Explainer labels the upper band + # "150% - At or Below 165%"; the model follows the adopted design. + # Band selection uses exact nonnegative ACA MAGI divided by prior-year FPG; + # only the rate lookup uses truncated aca_magi_fraction. The Designs leave + # rounding unspecified. FTB 3849 Table 1-2 (2020/2021) tests exact income + # for "above" boundaries before truncating for lookup; Form 8962 Worksheet 2 + # line 4 likewise checks "more than" before truncation. 2026-01-01: - 1 - 1.5 diff --git a/policyengine_us/tests/code_health/test_source_conflicts.py b/policyengine_us/tests/code_health/test_source_conflicts.py index 901b7b37215..ea7b9fbdddd 100644 --- a/policyengine_us/tests/code_health/test_source_conflicts.py +++ b/policyengine_us/tests/code_health/test_source_conflicts.py @@ -152,6 +152,7 @@ "policyengine_us/variables/gov/local/ca/cc/ga/eligibility/ca_cc_general_assistance_eligible.py": 1, "policyengine_us/variables/gov/local/tax/sales/local_sales_tax.py": 1, "policyengine_us/variables/gov/states/ar/ade/oec/sra/rates/ar_sra_daily_copay.py": 1, + "policyengine_us/variables/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy_applicable_percentage.py": 1, "policyengine_us/variables/gov/states/ct/tax/income/rebate/ct_child_tax_rebate.py": 1, "policyengine_us/variables/gov/states/fl/commerce/liheap/eligibility/fl_liheap_eligible.py": 1, "policyengine_us/variables/gov/states/fl/commerce/liheap/eligibility/fl_liheap_income_limit.py": 1, diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy.yaml index 889da75d6d7..da255575843 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy.yaml @@ -9,6 +9,9 @@ # figure (spread across 12 months and re-summed by add()), matching the NM # convention. aca_magi, aca_magi_fraction, slcsp, aca_ptc and is_aca_ptc_eligible # are supplied directly so each expectation is exactly hand-verifiable. +# Prior-year tax_unit_fpg is pinned so supplied MAGI and FPL fractions agree. +# The negative-MAGI clamp case supplies eligibility and a positive rate to +# isolate the amount formula; its income fraction is correctly zero. # CA schedule: threshold [1.0, 1.5, 1.65], initial [0, 0.0319], final [0, 0.0391] # ---- 100-150% FPL band (applicable percentage 0%) ---- @@ -27,6 +30,8 @@ tax_units: tax_unit: members: [person1] + tax_unit_fpg: + 2025: 15_000 aca_magi: 21_000 aca_magi_fraction: 1.40 slcsp: 6_000 @@ -50,6 +55,8 @@ tax_units: tax_unit: members: [person1] + tax_unit_fpg: + 2025: 15_000 aca_magi: 21_000 aca_magi_fraction: 1.40 slcsp: 6_000 @@ -77,6 +84,8 @@ tax_units: tax_unit: members: [person1] + tax_unit_fpg: + 2025: 15_873.015873015873 aca_magi: 25_000 aca_magi_fraction: 1.575 slcsp: 6_000 @@ -102,6 +111,8 @@ tax_units: tax_unit: members: [person1] + tax_unit_fpg: + 2025: 15_757.575757575758 aca_magi: 26_000 aca_magi_fraction: 1.65 slcsp: 7_000 @@ -127,6 +138,8 @@ tax_units: tax_unit: members: [person1] + tax_unit_fpg: + 2025: 15_000 aca_magi: 24_000 aca_magi_fraction: 1.60 slcsp: 3_000 @@ -141,7 +154,11 @@ # ---- Negative-MAGI clamp ---- - name: Case 6, negative ACA MAGI is clamped to zero (does not inflate subsidy). - # magi_frac 1.575 -> pct 0.0355; aca_magi -10_000 clamps to 0 so the -pct*income + # Supply eligibility and pct 0.0355 to isolate the amount clamp: + # exact negative MAGI would otherwise be ineligible and select the 0% band, + # hiding a missing amount clamp. The income fraction is zero because + # aca_magi_fraction floors negative MAGI at zero before dividing by FPG. + # aca_magi -10_000 clamps to 0 so the -pct*income # term is 0 rather than +355. amount = max(0, 6_000 - 2_000 - 0.0355*0) = 4_000. # Without the clamp it would be 6_000 - 2_000 - 0.0355*(-10_000) = 4_355. period: 2026 @@ -154,8 +171,12 @@ tax_units: tax_unit: members: [person1] + tax_unit_fpg: + 2025: 10_000 aca_magi: -10_000 - aca_magi_fraction: 1.575 + aca_magi_fraction: 0 + ca_premium_subsidy_eligible: true + ca_premium_subsidy_applicable_percentage: 0.0355 slcsp: 6_000 aca_ptc: 2_000 tax_unit_is_filer: true @@ -180,6 +201,8 @@ tax_units: tax_unit: members: [person1] + tax_unit_fpg: + 2025: 15_873.015873015873 aca_magi: 25_000 aca_magi_fraction: 1.575 slcsp: 6_000 @@ -204,6 +227,8 @@ tax_units: tax_unit: members: [person1] + tax_unit_fpg: + 2024: 15_873.015873015873 aca_magi: 25_000 aca_magi_fraction: 1.575 slcsp: 6_000 @@ -228,6 +253,8 @@ tax_units: tax_unit: members: [person1] + tax_unit_fpg: + 2025: 16_265.060240963856 aca_magi: 27_000 aca_magi_fraction: 1.66 slcsp: 6_000 @@ -252,6 +279,8 @@ tax_units: tax_unit: members: [person1] + tax_unit_fpg: + 2025: 15_000 aca_magi: 21_000 aca_magi_fraction: 1.40 slcsp: 0 @@ -280,6 +309,8 @@ tax_units: tax_unit: members: [person1] + tax_unit_fpg: + 2025: 15_000 aca_magi: 15_000 aca_magi_fraction: 1.00 slcsp: 6_000 @@ -310,6 +341,8 @@ tax_units: tax_unit: members: [person1] + tax_unit_fpg: + 2025: 15_000 aca_magi: 24_000 aca_magi_fraction: 1.60 slcsp: 6_000 @@ -338,6 +371,8 @@ tax_units: tax_unit: members: [person1] + tax_unit_fpg: + 2027: 15_873.015873015873 aca_magi: 25_000 aca_magi_fraction: 1.575 slcsp: 6_000 @@ -368,6 +403,8 @@ tax_units: tax_unit: members: [person1] + tax_unit_fpg: + 2025: 16_265.060240963856 aca_magi: 27_000 aca_magi_fraction: 1.66 slcsp: 6_000 @@ -396,6 +433,8 @@ tax_units: tax_unit: members: [person1] + tax_unit_fpg: + 2025: 15_000 aca_magi: 14_850 aca_magi_fraction: 0.99 slcsp: 6_000 @@ -427,6 +466,8 @@ tax_units: tax_unit: members: [person1] + tax_unit_fpg: + 2026: 15_873.015873015873 aca_magi: 25_000 aca_magi_fraction: 1.575 slcsp: 6_000 @@ -458,6 +499,8 @@ tax_units: tax_unit: members: [person1] + tax_unit_fpg: + 2025: 15_873.015873015873 aca_magi: 25_000 aca_magi_fraction: 1.575 slcsp: 6_000 @@ -476,7 +519,8 @@ # add(is_aca_ptc_eligible) > 0, which fires on any eligible member, so the tax # unit is eligible. The amount uses tax-unit slcsp/aca_ptc (not per-member), so # the asymmetry does NOT halve or zero the payout. magi_frac 1.575 -> pct 0.0355. - # income 30_000. amount = max(0, 8_000 - 3_000 - 0.0355*30_000) = 5_000 - 1_065 = 3_935. + # Pin prior-year FPG to 20_000: income 31_500 / 20_000 = 1.575. + # amount = max(0, 8_000 - 3_000 - 0.0355*31_500) = 5_000 - 1_118.25 = 3_881.75. # Confirms the add()>0 gate + defined_for + person->tax_unit aggregation produce # the full subsidy rather than a per-eligible-member proration. period: 2026 @@ -492,7 +536,9 @@ tax_units: tax_unit: members: [person1, person2] - aca_magi: 30_000 + tax_unit_fpg: + 2025: 20_000 + aca_magi: 31_500 aca_magi_fraction: 1.575 slcsp: 8_000 aca_ptc: 3_000 @@ -503,4 +549,5 @@ state_code: CA output: ca_premium_subsidy_eligible: true - ca_premium_subsidy: 3_935 + ca_premium_subsidy_applicable_percentage: 0.0355 + ca_premium_subsidy: 3_881.75 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy_applicable_percentage.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy_applicable_percentage.yaml index b71a61c042f..a5b10f361ab 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy_applicable_percentage.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy_applicable_percentage.yaml @@ -6,17 +6,21 @@ # final: [0, 0.0391] # Bands (initial -> final over the FPL band): # 100-150% FPL: 0% (flat) -# 150-165% FPL: 3.19% -> 3.91% (interpolated linearly) -# The percentage depends only on aca_magi_fraction; every expected value is -# hand-computed inline. The variable itself has no income-band cap (out-of-band +# above 150-165% FPL: 3.19% -> 3.91% (interpolated linearly) +# The exact nonnegative MAGI / prior-year FPG ratio selects the band; the +# truncated aca_magi_fraction determines the rate within it. Supplied-fraction +# cases normalize prior-year FPG to $10,000 and supply consistent MAGI to +# isolate interpolation, including fractional percentages that a derived +# aca_magi_fraction would truncate. Every expected value is hand-computed +# inline. The variable itself has no income-band cap (out-of-band # clamping to the nearest bracket rate is fine; the ca_premium_subsidy_eligible -# gate zeroes the subsidy outside 100-165% FPL), so this file checks the raw -# interpolation, including clamps at 0.99 and 1.66. +# gate separately determines eligibility). This file +# checks raw interpolation, including clamps at 0.99 and 1.66. # ---- Knot points ---- - name: Case 1, exactly 100% FPL -> 0.00% (start of the flat 100-150% band). - # searchsorted([1.0,1.5,1.65], 1.00, "right") = 1, -1 -> bracket 0 [1.0,1.5]. + # searchsorted([1.0,1.5,1.65], 1.00, "left") = 0, -1 = -1, clipped to bracket 0 [1.0,1.5]. # initial 0, final 0 -> 0 regardless of position. period: 2026 absolute_error_margin: 0.0001 @@ -28,6 +32,9 @@ tax_units: tax_unit: members: [person1] + aca_magi: 10_000 + tax_unit_fpg: + 2025: 10_000 aca_magi_fraction: 1.00 households: household: @@ -36,9 +43,11 @@ output: ca_premium_subsidy_applicable_percentage: 0 -- name: Case 2, exactly 150% FPL -> 3.19% (start of the 150-165% ramp). - # searchsorted([1.0,1.5,1.65], 1.50, "right") = 2, -1 -> bracket 1 [1.5,1.65]. - # position (1.50-1.50)/0.15 = 0 -> initial rate 0.0319. +- name: Case 2, exactly 150% FPL -> 0% (the adopted design's "up to 150%" band). + # The 2026 Program Design's bands are "100% up to 150%" (0%) and "Above 150% + # up to 165%"; the Policy Explainer's "150% - At or Below 165%" would give + # 3.19%. The model follows the adopted design. + # searchsorted([1.0,1.5,1.65], 1.50, "left") = 1, -1 -> bracket 0 [1.0,1.5]. period: 2026 absolute_error_margin: 0.0001 input: @@ -49,16 +58,19 @@ tax_units: tax_unit: members: [person1] + aca_magi: 15_000 + tax_unit_fpg: + 2025: 10_000 aca_magi_fraction: 1.50 households: household: members: [person1] state_code: CA output: - ca_premium_subsidy_applicable_percentage: 0.0319 + ca_premium_subsidy_applicable_percentage: 0 - name: Case 3, exactly 165% FPL -> 3.91% (top of the ramp). - # searchsorted([1.0,1.5,1.65], 1.65, "right") = 3, -1 = 2, clipped to bracket 1. + # searchsorted([1.0,1.5,1.65], 1.65, "left") = 2, -1 = 1 -> bracket 1 at position 1. # bracket_start 1.5, bracket_end 1.65, position (1.65-1.5)/0.15 = 1 -> final 0.0391. period: 2026 absolute_error_margin: 0.0001 @@ -70,6 +82,9 @@ tax_units: tax_unit: members: [person1] + aca_magi: 16_500 + tax_unit_fpg: + 2025: 10_000 aca_magi_fraction: 1.65 households: household: @@ -92,6 +107,9 @@ tax_units: tax_unit: members: [person1] + aca_magi: 12_500 + tax_unit_fpg: + 2025: 10_000 aca_magi_fraction: 1.25 households: household: @@ -113,6 +131,9 @@ tax_units: tax_unit: members: [person1] + aca_magi: 15_750 + tax_unit_fpg: + 2025: 10_000 aca_magi_fraction: 1.575 households: household: @@ -134,6 +155,9 @@ tax_units: tax_unit: members: [person1] + aca_magi: 16_000 + tax_unit_fpg: + 2025: 10_000 aca_magi_fraction: 1.60 households: household: @@ -157,6 +181,9 @@ tax_units: tax_unit: members: [person1] + aca_magi: 15_100 + tax_unit_fpg: + 2025: 10_000 aca_magi_fraction: 1.51 households: household: @@ -177,6 +204,9 @@ tax_units: tax_unit: members: [person1] + aca_magi: 14_900 + tax_unit_fpg: + 2025: 10_000 aca_magi_fraction: 1.49 households: household: @@ -186,7 +216,7 @@ ca_premium_subsidy_applicable_percentage: 0 - name: Case 9, below the 100% floor (0.99) clamps to the first bracket -> 0.00%. - # searchsorted([1.0,1.5,1.65], 0.99, "right") = 0, -1 = -1, clipped to bracket 0. + # searchsorted([1.0,1.5,1.65], 0.99, "left") = 0, -1 = -1, clipped to bracket 0. # bracket 0 [1.0,1.5]: position (0.99-1.0)/0.5 = -0.02 clipped to 0; initial 0 -> 0. # The eligible gate (fpl_floor 1.0) separately zeroes the subsidy here. period: 2026 @@ -199,6 +229,9 @@ tax_units: tax_unit: members: [person1] + aca_magi: 9_900 + tax_unit_fpg: + 2025: 10_000 aca_magi_fraction: 0.99 households: household: @@ -208,7 +241,7 @@ ca_premium_subsidy_applicable_percentage: 0 - name: Case 10, above the 165% cap (1.66) clamps to the top bracket rate -> 3.91%. - # searchsorted([1.0,1.5,1.65], 1.66, "right") = 3, -1 = 2, clipped to bracket 1. + # searchsorted([1.0,1.5,1.65], 1.66, "left") = 3, -1 = 2, clipped to bracket 1. # position (1.66-1.5)/0.15 = 1.067 clipped to 1 -> final 0.0391. The eligible # gate (fpl_limit 1.65) separately zeroes the subsidy above the band, so this # confirms the schedule variable itself does not extrapolate past 3.91%. @@ -222,6 +255,9 @@ tax_units: tax_unit: members: [person1] + aca_magi: 16_600 + tax_unit_fpg: + 2025: 10_000 aca_magi_fraction: 1.66 households: household: @@ -231,7 +267,8 @@ ca_premium_subsidy_applicable_percentage: 0.0391 - name: Case 11, negative FPL fraction clamps to the first bracket -> 0.00%. - # searchsorted([1.0,1.5,1.65], -0.5, "right") = 0, -1 = -1, clipped to bracket 0. + # Negative MAGI is floored at 0 for the exact ratio; searchsorted(..., 0, + # "left") = 0, -1 = -1, clipped to bracket 0. # position (-0.5-1.0)/0.5 = -3 clipped to 0; initial 0 -> 0. Guards against a # negative aca_magi_fraction producing a spurious rate. period: 2026 @@ -244,6 +281,9 @@ tax_units: tax_unit: members: [person1] + aca_magi: -5_000 + tax_unit_fpg: + 2025: 10_000 aca_magi_fraction: -0.50 households: household: @@ -269,6 +309,9 @@ tax_units: tax_unit: members: [person1] + aca_magi: 15_750 + tax_unit_fpg: + 2026: 10_000 aca_magi_fraction: 1.575 households: household: @@ -279,10 +322,11 @@ # ---- PY2027 new 165-200% FPL tier ---- -- name: Case 13, PY2027 exactly 165% FPL -> 3.41% (start of the new 165-200% tier). - # PY2027 thresholds [1,1.5,1.65,2.0]. searchsorted(..., 1.65, "right") = 3, - # -1 = 2 -> bracket 2 [1.65,2.0], position 0 -> initial rate 0.0341. The 165% - # knot now takes the NEW tier's initial rate (2026 had 0.0391 at this point). +- name: Case 13, PY2027 exactly 165% FPL -> 3.41% from either adjacent tier. + # PY2027 thresholds [1,1.5,1.65,2.0]. searchsorted(..., 1.65, "left") = 2, + # -1 = 1 -> bracket 1 [1.5,1.65] at position 1 -> its final rate 0.0341, + # which equals the 165-200% tier's initial rate, so the rate is continuous + # at 165% (2026 had 0.0391 at this point). period: 2027 absolute_error_margin: 0.0001 input: @@ -293,6 +337,9 @@ tax_units: tax_unit: members: [person1] + aca_magi: 16_500 + tax_unit_fpg: + 2026: 10_000 aca_magi_fraction: 1.65 households: household: @@ -314,6 +361,9 @@ tax_units: tax_unit: members: [person1] + aca_magi: 18_250 + tax_unit_fpg: + 2026: 10_000 aca_magi_fraction: 1.825 households: household: @@ -336,6 +386,9 @@ tax_units: tax_unit: members: [person1] + aca_magi: 19_900 + tax_unit_fpg: + 2026: 10_000 aca_magi_fraction: 1.99 households: household: @@ -343,3 +396,159 @@ state_code: CA output: ca_premium_subsidy_applicable_percentage: 0.050517 # model computes 0.05051714 + +- name: Case 2b, a supplied fraction of 1.505 interpolates on the fraction given. + # searchsorted([1.0,1.5,1.65], 1.505, "left") = 2, -1 -> bracket 1 [1.5,1.65]. + # position (1.505-1.50)/0.15 = 0.0333 -> 0.0319 + 0.0333 x (0.0391 - 0.0319) + # = 0.03214. (A fraction computed from income is truncated to whole + # percentages first; see the next case.) + period: 2026 + absolute_error_margin: 0.0001 + input: + people: + person1: + age: 40 + is_aca_ptc_eligible: true + tax_units: + tax_unit: + members: [person1] + aca_magi: 15_050 + tax_unit_fpg: + 2025: 10_000 + aca_magi_fraction: 1.505 + households: + household: + members: [person1] + state_code: CA + output: + ca_premium_subsidy_applicable_percentage: 0.03214 + +# ---- Derived fractions around the exact 150% and truncated 151% boundaries ---- + +- name: Case 2c, income at 150.6% of FPL pays 3.19% despite truncating to 150%. + # The adopted Design (d)(4) puts exact income above 150% in the paying band. + # FTB 3849 (2020/2021), Table 1-2, decides "above" on the exact percentage + # before truncating for the rate lookup. $23,569 / 2025 one-person FPG + # $15,650 = 150.6006% selects the upper band; the truncated 1.50 fraction + # gives its starting rate 0.0319, for a $751.85 annual contribution. + period: 2026 + absolute_error_margin: 0.000001 + input: + people: + person1: + age: 40 + employment_income: + 2026: 23_569 + is_aca_ptc_eligible: true + households: + household: + members: [person1] + state_code: CA + output: + aca_magi_fraction: 1.50 + ca_premium_subsidy_applicable_percentage: 0.0319 + +- name: Case 2d, PY2026 raw income $23,475, exactly 150% remains in the 0% band. + # $23,475 / 2025 one-person FPG $15,650 = 150.0000%. + # Exact income selects the band; the derived fraction 1.50 determines + # the rate 0, for an annual contribution of $0.00. + period: 2026 + absolute_error_margin: 0.000001 + input: + people: + person1: + age: 40 + employment_income: + 2026: 23_475 + is_aca_ptc_eligible: true + households: + household: + members: [person1] + state_code: CA + output: + aca_magi_fraction: 1.50 + ca_premium_subsidy_applicable_percentage: 0 + +- name: Case 2e, PY2026 raw income $23,477, just above 150% enters the paying band. + # $23,477 / 2025 one-person FPG $15,650 = 150.0128%. + # Exact income selects the band; the derived fraction 1.50 determines + # the rate 0.0319, for an annual contribution of $748.92. + period: 2026 + absolute_error_margin: 0.000001 + input: + people: + person1: + age: 40 + employment_income: + 2026: 23_477 + is_aca_ptc_eligible: true + households: + household: + members: [person1] + state_code: CA + output: + aca_magi_fraction: 1.50 + ca_premium_subsidy_applicable_percentage: 0.0319 + +- name: Case 2f, PY2026 raw income $23,630, just below 151% keeps the starting rate. + # $23,630 / 2025 one-person FPG $15,650 = 150.9904%. + # Exact income selects the band; the derived fraction 1.50 determines + # the rate 0.0319, for an annual contribution of $753.80. + period: 2026 + absolute_error_margin: 0.000001 + input: + people: + person1: + age: 40 + employment_income: + 2026: 23_630 + is_aca_ptc_eligible: true + households: + household: + members: [person1] + state_code: CA + output: + aca_magi_fraction: 1.50 + ca_premium_subsidy_applicable_percentage: 0.0319 + +- name: Case 2g, PY2026 raw income $23,632, just above 151% uses the next truncated percentage. + # $23,632 / 2025 one-person FPG $15,650 = 151.0032%. + # Exact income selects the band; the derived fraction 1.51 determines + # the rate 0.03238, for an annual contribution of $765.20. + period: 2026 + absolute_error_margin: 0.000001 + input: + people: + person1: + age: 40 + employment_income: + 2026: 23_632 + is_aca_ptc_eligible: true + households: + household: + members: [person1] + state_code: CA + output: + aca_magi_fraction: 1.51 + ca_premium_subsidy_applicable_percentage: 0.03238 + +- name: Case 16a, PY2027 raw income $23,940, exactly 150% remains in the 0% band. + # $23,940 / 2026 one-person FPG $15,960 = 150.0000%. + # Exact income selects the band; the derived fraction 1.50 determines + # the rate 0, for an annual contribution of $0.00. + period: 2027 + absolute_error_margin: 0.000001 + input: + people: + person1: + age: 40 + employment_income: + 2027: 23_940 + is_aca_ptc_eligible: true + households: + household: + members: [person1] + state_code: CA + output: + aca_magi_fraction: 1.50 + ca_premium_subsidy_applicable_percentage: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy_eligible.yaml index c2f5969cf23..85d82596a5a 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy_eligible.yaml @@ -2,8 +2,8 @@ # Eligible when: the program is in effect (PY2026-2027), the household is in # California, at least one tax-unit member is eligible for the federal ACA # premium tax credit (which embeds on-Marketplace enrollment, the -# married-filing-separately exclusion, and the federal required-contribution -# income test), the tax unit is an applicable return filer, and ACA MAGI is at +# married-filing-separately exclusion, and the federal FPL-band income test), +# the tax unit is an applicable return filer, and ACA MAGI is at # or above the 100% FPL floor and at or below the FPL limit (1.65 in PY2026, # 1.99 in PY2027). is_aca_ptc_eligible and aca_magi_fraction are supplied # directly so the gate logic is tested in isolation from the premium-data lookup. diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/hbex/premium_subsidy/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/hbex/premium_subsidy/integration.yaml index 8a5c9e53751..de51d8869c8 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/hbex/premium_subsidy/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/hbex/premium_subsidy/integration.yaml @@ -7,11 +7,14 @@ # # To keep expected values exactly hand-verifiable and independent of the # rating-area premium lookup, aca_magi, aca_magi_fraction, slcsp (annual), -# aca_ptc and is_aca_ptc_eligible are supplied directly, and each scenario -# asserts the intermediate values so a reviewer can follow the arithmetic: +# aca_ptc and is_aca_ptc_eligible are supplied directly. Prior-year FPG is +# pinned to a synthetic $15,000 for the supplied single-person scenarios; +# Scenario 4 uses the actual 2025 four-person $32,150. Each asserts intermediate +# values so a reviewer can follow the arithmetic: # eligible = in_effect & any(is_aca_ptc_eligible) & 1.0 <= magi_frac <= 1.65 -# pct = CA schedule keyed on aca_magi_fraction -# (100-150%: 0%; 150-165%: 3.19% -> 3.91% linear) +# pct = exact nonnegative MAGI / prior-year FPG selects the CA band; +# aca_magi_fraction determines the rate within that band +# (100-150%: 0%; above 150-165%: 3.19% -> 3.91% linear) # amount = max(0, slcsp_annual - aca_ptc - pct * max(aca_magi, 0)) # result = where(eligible, amount, 0) @@ -29,6 +32,8 @@ tax_units: tax_unit: members: [person1] + tax_unit_fpg: + 2025: 15_000 aca_magi: 18_750 aca_magi_fraction: 1.25 slcsp: 6_000 @@ -43,9 +48,10 @@ ca_premium_subsidy_applicable_percentage: 0 ca_premium_subsidy: 2_200 -- name: Scenario 2, single age 40 exactly at 150% FPL (knot, pct jumps to 3.19%). - # magi_frac 1.50 -> bracket [1.5,1.65] start -> pct 0.0319. income 22_500. - # amount = max(0, 6_000 - 3_000 - 0.0319*22_500) = 6_000 - 3_000 - 717.75 = 2_282.25. +- name: Scenario 2, single age 40 exactly at 150% FPL (top of the 0% band). + # The adopted 2026 Program Design puts 150% in its "up to 150%" band, so + # pct 0 (the Policy Explainer's band would give 3.19% and $2,282.25). + # amount = max(0, 6_000 - 3_000 - 0*22_500) = 3_000. period: 2026 absolute_error_margin: 0.01 input: @@ -56,6 +62,8 @@ tax_units: tax_unit: members: [person1] + tax_unit_fpg: + 2025: 15_000 aca_magi: 22_500 aca_magi_fraction: 1.50 slcsp: 6_000 @@ -67,8 +75,8 @@ state_code: CA output: ca_premium_subsidy_eligible: true - ca_premium_subsidy_applicable_percentage: 0.0319 - ca_premium_subsidy: 2_282.25 + ca_premium_subsidy_applicable_percentage: 0 + ca_premium_subsidy: 3_000 - name: Scenario 3, single age 55 at 157.5% FPL (midpoint of the 150-165% ramp). # magi_frac 1.575 -> pct 0.0319 + 0.5*0.0072 = 0.0355. income 23_625. @@ -83,6 +91,8 @@ tax_units: tax_unit: members: [person1] + tax_unit_fpg: + 2025: 15_000 aca_magi: 23_625 aca_magi_fraction: 1.575 slcsp: 7_500 @@ -98,8 +108,10 @@ ca_premium_subsidy: 3_461.31 - name: Scenario 4, family of four at 165% FPL (top of the ramp, pct 3.91%). - # ages 45/43/15/13; magi_frac 1.65 -> pct 0.0391. income 51_500. - # amount = max(0, 18_000 - 9_000 - 0.0391*51_500) = 18_000 - 9_000 - 2_013.65 = 6_986.35. + # ages 45/43/15/13; magi_frac 1.65 -> pct 0.0391. income 53_047.50 + # equals 165% of 2025 four-person FPG $32,150. + # amount = max(0, 18_000 - 9_000 - 0.0391*53_047.50) + # = 18_000 - 9_000 - 2_074.15725 = 6_925.84. period: 2026 absolute_error_margin: 0.01 input: @@ -119,7 +131,9 @@ tax_units: tax_unit: members: [person1, person2, person3, person4] - aca_magi: 51_500 + tax_unit_fpg: + 2025: 32_150 + aca_magi: 53_047.50 aca_magi_fraction: 1.65 slcsp: 18_000 aca_ptc: 9_000 @@ -131,7 +145,7 @@ output: ca_premium_subsidy_eligible: true ca_premium_subsidy_applicable_percentage: 0.0391 - ca_premium_subsidy: 6_986.35 + ca_premium_subsidy: 6_925.84 - name: Scenario 5, single age 40 at 160% FPL where APTC covers the residual -> $0. # magi_frac 1.60 -> pct 0.0319 + (2/3)*0.0072 = 0.0367. income 24_000. @@ -148,6 +162,8 @@ tax_units: tax_unit: members: [person1] + tax_unit_fpg: + 2025: 15_000 aca_magi: 24_000 aca_magi_fraction: 1.60 slcsp: 3_000 @@ -175,6 +191,8 @@ tax_units: tax_unit: members: [person1] + tax_unit_fpg: + 2025: 15_000 aca_magi: 21_000 aca_magi_fraction: 1.40 slcsp: 6_000 @@ -201,6 +219,8 @@ tax_units: tax_unit: members: [person1] + tax_unit_fpg: + 2024: 15_000 aca_magi: 21_000 aca_magi_fraction: 1.40 slcsp: 6_000 diff --git a/policyengine_us/tests/unit/test_ca_premium_subsidy_applicable_percentage.py b/policyengine_us/tests/unit/test_ca_premium_subsidy_applicable_percentage.py new file mode 100644 index 00000000000..4aecce7c251 --- /dev/null +++ b/policyengine_us/tests/unit/test_ca_premium_subsidy_applicable_percentage.py @@ -0,0 +1,87 @@ +"""Properties of the adopted 2026 Design (d)(4) contribution bands. + +FTB 3849 Table 1-2 distinguishes exact band eligibility from the truncated +percentage used for the rate lookup. The physical-income properties use +that truncation. Continuity tests supply untruncated lookup fractions to +isolate the interpolation: the actual lookup has whole-percentage steps, +whereas the underlying bands join continuously everywhere except 150%. +""" + +import numpy as np +import pytest +from hypothesis import example, given, settings +from hypothesis import strategies as st +from policyengine_core.periods import period + +from policyengine_us.system import system + +FPG = 100_000 +VARIABLE = "ca_premium_subsidy_applicable_percentage" + + +def _rates(year, incomes, *, truncate=True): + """Call the registered formula on vectorized inputs without rebuilding US.""" + calculation_period = period(year) + incomes = np.asarray(incomes, dtype=float) + fractions = np.maximum(incomes, 0) / FPG + if truncate: + fractions = np.floor(100 * np.maximum(incomes, 0) / FPG) / 100 + inputs = { + "aca_magi": incomes, + "aca_magi_fraction": fractions, + "tax_unit_fpg": np.full(incomes.shape, FPG), + } + + def tax_unit(name, input_period): + expected_period = ( + calculation_period.last_year + if name == "tax_unit_fpg" + else calculation_period + ) + assert input_period == expected_period + return inputs[name] + + formula = system.variables[VARIABLE].get_formula(calculation_period) + return formula(tax_unit, calculation_period, system.parameters) + + +@pytest.mark.parametrize("year", [2026]) +@settings(max_examples=100, deadline=None, derandomize=True) +@given(incomes=st.lists(st.integers(-100_000, 600_000), min_size=2, max_size=40)) +@example(incomes=[149_999, 150_000, 150_001, 150_999, 151_000]) +def test_rate_never_decreases_with_exact_income(year, incomes): + rates = _rates(year, sorted(incomes)) + assert np.all(np.diff(rates) >= -1e-12) + + +@pytest.mark.parametrize("year", [2026]) +@settings(max_examples=100, deadline=None, derandomize=True) +@given(income=st.integers(-100_000, 150_000)) +@example(income=150_000) +def test_rate_is_zero_at_or_below_exactly_150_percent(year, income): + assert _rates(year, [income])[0] == 0 + + +@pytest.mark.parametrize("year, starting_rate", [(2026, 0.0319)]) +@settings(max_examples=100, deadline=None, derandomize=True) +@given(income=st.integers(150_001, 150_999)) +def test_exact_income_above_150_uses_upper_band_truncated_starting_rate( + year, starting_rate, income +): + assert _rates(year, [income])[0] == pytest.approx(starting_rate, rel=0, abs=1e-12) + + +@pytest.mark.parametrize( + "year, boundary", + [(2026, 1), (2026, 1.65)], +) +@settings(max_examples=100, deadline=None, derandomize=True) +@given(epsilon=st.floats(min_value=1e-7, max_value=1e-4)) +def test_interpolated_bands_are_continuous_except_at_150_percent( + year, boundary, epsilon +): + incomes = FPG * np.array([boundary - epsilon, boundary, boundary + epsilon]) + rates = _rates(year, incomes, truncate=False) + # Each adopted band's slope is below 0.05, so two-sided variation is + # at most 0.1 * epsilon. An additional band-boundary jump violates this. + assert np.ptp(rates) <= 0.1 * epsilon + 1e-10 diff --git a/policyengine_us/variables/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy_applicable_percentage.py b/policyengine_us/variables/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy_applicable_percentage.py index b32cc5a600a..014fc7bc446 100644 --- a/policyengine_us/variables/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy_applicable_percentage.py +++ b/policyengine_us/variables/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy_applicable_percentage.py @@ -13,24 +13,37 @@ class ca_premium_subsidy_applicable_percentage(Variable): # benchmark second lowest cost silver plan, bracket-interpolated # linearly within each federal poverty line band. "https://board.coveredca.com/meetings/2025/July%2028,%202025/CoveredCA_2026_Premium_Subsidy_Program_Design_Final.pdf#page=2", - "https://hbex.coveredca.com/stakeholders/PDFs/2026-02_StatePremiumSub_PolicyExplainer-Final.pdf#page=2", + "https://hbex.coveredca.com/data-research/Final-2027-California-Premium-Subsidy-Program-Design.pdf#page=2", + "https://hbex.coveredca.com/toolkit/webinars-briefings/downloads/OE27_Enroller_Kickoff.pdf#page=40", + "https://www.ftb.ca.gov/forms/2020/2020-3849-instructions.pdf#page=6", + "https://www.ftb.ca.gov/forms/2021/2021-3849-instructions.pdf#page=6", + "https://www.irs.gov/pub/irs-prior/i8962--2025.pdf#page=9", ) def formula(tax_unit, period, parameters): magi_frac = tax_unit("aca_magi_fraction", period) + magi = max_(tax_unit("aca_magi", period), 0) + exact_magi_frac = magi / tax_unit("tax_unit_fpg", period.last_year) p = parameters(period).gov.states.ca.hbex.premium_subsidy.applicable_percentage thresholds = np.array(p.threshold) initial_rates = np.array(p.initial) final_rates = np.array(p.final) - # Find which bracket each tax unit falls into. searchsorted returns - # the index where magi_frac would be inserted; subtract 1 to get the - # bracket index, clamped to the valid range. Rate arrays are one - # shorter than thresholds (federal/NM convention), so bracket_idx is - # always a valid left edge and bracket_idx + 1 a valid right edge. + # The Board-adopted Designs (d)(4), operative under Gov. Code + # 100800(c), put exactly 150% in the 0% band and income above 150% + # in the paying band. Use the exact ratio to select the band. + # For rounding, predecessor FTB 3849 Table 1-2 (2020/2021) tests + # "above" on exact income before truncating for the rate lookup. + # Form 8962 Worksheet 2 line 4 likewise checks "more than" before + # truncation. side="left" keeps an exact threshold in the lower + # band; clipping keeps both bracket endpoints in the rate arrays. + # The OE27 enroller example conflicts with the adopted Designs at 150%: + # it charges 2.80%, but (d)(4) starts the paying band "Above 150%". + # Follow the adopted 0% boundary; the citations above document both. + # Candidate: us-ca:premium-subsidy-150-fpl-boundary until registered. bracket_idx = clip( - np.searchsorted(thresholds, magi_frac, side="right") - 1, + np.searchsorted(thresholds, exact_magi_frac, side="left") - 1, 0, len(initial_rates) - 1, ) @@ -38,6 +51,8 @@ def formula(tax_unit, period, parameters): bracket_start = thresholds[bracket_idx] bracket_end = thresholds[bracket_idx + 1] + # Retain the truncated fraction within the band: 150.6% uses the + # 150% lookup row of the upper band (3.19% in 2026). position = clip( (magi_frac - bracket_start) / (bracket_end - bracket_start), 0, 1 ) diff --git a/source_conflicts/candidates.yaml b/source_conflicts/candidates.yaml index 8338e796c45..b51cb11f0bc 100644 --- a/source_conflicts/candidates.yaml +++ b/source_conflicts/candidates.yaml @@ -750,6 +750,51 @@ candidates: state: ruled note: 'The October 2026 statute-first ruling retains the bar; obtain the statutory URL and verify the instruction flow''s joint-return effect. Code not yet on main: ca_eitc_eligible.' +- id: us-ca:premium-subsidy-150-fpl-boundary + program: california-premium-subsidy + years: + from: 2026 + type: program + note: Covered California coverage year beginning January 1. + question: Does exactly 150% FPL remain in the zero-contribution band under the adopted Program Design or enter + the paying band illustrated by Covered California's guidance and enroller example? + sources: + - kind: statute + citation: California Government Code 100800(c), Board-adopted program design + url: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=GOV§ionNum=100800. + - kind: policy + citation: Final 2026 California Premium Subsidy Program Design, (d)(4), PDF page 2 + url: https://board.coveredca.com/meetings/2025/July%2028,%202025/CoveredCA_2026_Premium_Subsidy_Program_Design_Final.pdf#page=2 + - kind: policy + citation: Final 2027 California Premium Subsidy Program Design, (d)(4), PDF page 2 + url: https://hbex.coveredca.com/data-research/Final-2027-California-Premium-Subsidy-Program-Design.pdf#page=2 + - kind: guidance + citation: 2026 California State Premium Subsidy Policy Explainer, Table 1, PDF page 2 + url: https://hbex.coveredca.com/stakeholders/PDFs/2026-02_StatePremiumSub_PolicyExplainer-Final.pdf#page=2 + - kind: guidance + citation: 2027 Premium Subsidy Insight, Table 1, PDF page 2 + url: https://hbex.coveredca.com/stakeholders/PDFs/2026-03_StatePremiumSub2027_Insight_Final.pdf#page=2 + - kind: agency-practice + citation: OE 2027 Enroller Kick-off, exactly 150% FPL example, PDF pages 39-40 + url: https://hbex.coveredca.com/toolkit/webinars-briefings/downloads/OE27_Enroller_Kickoff.pdf#page=40 + readings: + statute: The Board-adopted Designs place income up to 150% in the 0% band and start the paying band above + 150%, excluding exactly 150% from that band. + other: The guidance labels the paying band from 150%, and the OE27 example explicitly charges 2.80% at + exactly 150% FPL. + example: The OE27 single-person example has $23,940 income at exactly 150% FPL and a 2.80% contribution; + the adopted Design's boundary instead gives a 0% contribution. + pe_us: + follows: statute + code: + - policyengine_us/parameters/gov/states/ca/hbex/premium_subsidy/applicable_percentage/threshold.yaml + - policyengine_us/variables/gov/states/ca/hbex/premium_subsidy/ca_premium_subsidy_applicable_percentage.py + prs: + - https://github.com/PolicyEngine/policyengine-us/pull/10301 + state: ruled + note: The adopted rule's explicit above-150% wording governs the conflicting training example. Recheck if + Covered California amends the Design or clarifies the boundary. PR 10301 changes the boundary; PR 10305 + handles the final 2027 schedule and eligibility limits separately. - id: us-ca:renter-credit-2025-joint-income-limit program: income-tax years: