Skip to content

Commit f92c413

Browse files
Fix ACA required contribution percentage to be flat below 133% FPL (#7067)
* Fix ACA required contribution percentage to be flat below 133% FPL Fixes #7066 The ACA statute (26 USC 36B) specifies that the contribution percentage for household income "up to 133%" should be flat (initial=final=2%), not interpolated. The implementation was using np.interp which linearly interpolated from 0% to 133% FPL. This fix adds a threshold at 132.9999% FPL with the same rate as 0% FPL, creating the flat portion before the jump at 133%. Changes: - Add threshold at 1.329999 for 2015 and 2026 periods (null for ARPA/IRA) - Add tests verifying flat rate at 100% and 120% FPL - Add test verifying jump to 3.14% at 133% FPL 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Add changelog entry for ACA required contribution percentage fix 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Refactor ACA contribution percentage to separate thresholds and rates Splits the single required_contribution_percentage parameter into three independent parameters: - threshold.yaml: FPL bracket boundaries - initial.yaml: starting rate for each bracket - final.yaml: ending rate for each bracket This properly handles flat brackets (like 0-133% FPL where initial=final) per 26 USC 36B, rather than relying on np.interp which incorrectly interpolated across the entire range. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Update uv.lock * Update uv.lock with --upgrade * Move changelog entry to changelog_entry.yaml for CI * Use PolicyEngine conventions for vectorized operations - Replace np.clip with clip - Replace np.where with where - Replace np.minimum with min_ - Upgrade uv.lock 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 72d0336 commit f92c413

File tree

8 files changed

+238
-92
lines changed

8 files changed

+238
-92
lines changed

changelog_entry.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
- bump: patch
2+
changes:
3+
fixed:
4+
- ACA required contribution percentage now correctly handles flat brackets (e.g.,
5+
0-133% FPL) per 26 USC 36B by separating thresholds, initial rates, and final
6+
rates into independent parameters.

policyengine_us/parameters/gov/aca/required_contribution_percentage.yaml

Lines changed: 0 additions & 64 deletions
This file was deleted.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
description: Final (ending) contribution percentage for each ACA bracket.
2+
metadata:
3+
unit: /1
4+
label: ACA contribution percentage - final rate per bracket
5+
reference:
6+
- title: 26 U.S. Code § 36B(b)(3)(A) - Refundable credit for coverage under a qualified health plan
7+
href: https://www.law.cornell.edu/uscode/text/26/36B#b_3_A
8+
- title: IRS Revenue Procedure 2025-25 - Applicable percentage table for 2026
9+
href: https://www.irs.gov/pub/irs-drop/rp-25-25.pdf
10+
values:
11+
# Original ACA (2015-2020): 6 brackets
12+
2015-01-01:
13+
- 0.02 # 0-133%: flat 2%
14+
- 0.04 # 133-150%
15+
- 0.063 # 150-200%
16+
- 0.0805 # 200-250%
17+
- 0.095 # 250-300%
18+
- 0.095 # 300-400%: flat 9.5%
19+
# ARPA/IRA (2021-2025): 5 brackets, no 133% threshold
20+
2021-01-01:
21+
- 0 # 0-150%: flat 0%
22+
- 0.02 # 150-200%
23+
- 0.04 # 200-250%
24+
- 0.06 # 250-300%
25+
- 0.085 # 300-400%
26+
# Post-ARPA (2026+): return to 6 brackets
27+
2026-01-01:
28+
- 0.021 # 0-133%: flat 2.1%
29+
- 0.0419 # 133-150%
30+
- 0.066 # 150-200%
31+
- 0.0844 # 200-250%
32+
- 0.0996 # 250-300%
33+
- 0.0996 # 300-400%: flat 9.96%
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
description: Initial (starting) contribution percentage for each ACA bracket.
2+
metadata:
3+
unit: /1
4+
label: ACA contribution percentage - initial rate per bracket
5+
reference:
6+
- title: 26 U.S. Code § 36B(b)(3)(A) - Refundable credit for coverage under a qualified health plan
7+
href: https://www.law.cornell.edu/uscode/text/26/36B#b_3_A
8+
- title: IRS Revenue Procedure 2025-25 - Applicable percentage table for 2026
9+
href: https://www.irs.gov/pub/irs-drop/rp-25-25.pdf
10+
values:
11+
# Original ACA (2015-2020): 6 brackets
12+
2015-01-01:
13+
- 0.02 # 0-133%: flat 2%
14+
- 0.03 # 133-150%
15+
- 0.04 # 150-200%
16+
- 0.063 # 200-250%
17+
- 0.0805 # 250-300%
18+
- 0.095 # 300-400%: flat 9.5%
19+
# ARPA/IRA (2021-2025): 5 brackets, no 133% threshold
20+
2021-01-01:
21+
- 0 # 0-150%: flat 0%
22+
- 0 # 150-200%
23+
- 0.02 # 200-250%
24+
- 0.04 # 250-300%
25+
- 0.06 # 300-400%
26+
# Post-ARPA (2026+): return to 6 brackets
27+
2026-01-01:
28+
- 0.021 # 0-133%: flat 2.1%
29+
- 0.0314 # 133-150%
30+
- 0.0419 # 150-200%
31+
- 0.066 # 200-250%
32+
- 0.0844 # 250-300%
33+
- 0.0996 # 300-400%: flat 9.96%
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
description: FPL thresholds for ACA required contribution percentage brackets.
2+
metadata:
3+
unit: /1
4+
label: ACA contribution percentage bracket thresholds
5+
reference:
6+
- title: 26 U.S. Code § 36B(b)(3)(A) - Refundable credit for coverage under a qualified health plan
7+
href: https://www.law.cornell.edu/uscode/text/26/36B#b_3_A
8+
- title: IRS Revenue Procedure 2025-25 - Applicable percentage table for 2026
9+
href: https://www.irs.gov/pub/irs-drop/rp-25-25.pdf
10+
values:
11+
2015-01-01:
12+
- 0
13+
- 1.33
14+
- 1.50
15+
- 2.00
16+
- 2.50
17+
- 3.00
18+
- 4.00
19+
# ARPA/IRA years: no 133% threshold, brackets shift
20+
2021-01-01:
21+
- 0
22+
- 1.50
23+
- 2.00
24+
- 2.50
25+
- 3.00
26+
- 4.00
27+
# Post-ARPA: return to original structure
28+
2026-01-01:
29+
- 0
30+
- 1.33
31+
- 1.50
32+
- 2.00
33+
- 2.50
34+
- 3.00
35+
- 4.00

policyengine_us/tests/policy/baseline/gov/aca/ptc/aca_required_contribution_percentage.yaml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,35 @@
1+
- name: Flat at 100% FPL in 2026 (should be 2.1%, not interpolated)
2+
period: 2026
3+
absolute_error_margin: 0.0001
4+
input:
5+
aca_magi_fraction: 1.0
6+
output:
7+
aca_required_contribution_percentage: 0.021
8+
9+
- name: Flat at 120% FPL in 2026 (should still be 2.1%)
10+
period: 2026
11+
absolute_error_margin: 0.0001
12+
input:
13+
aca_magi_fraction: 1.2
14+
output:
15+
aca_required_contribution_percentage: 0.021
16+
17+
- name: Jump to 3.14% at 133% FPL in 2026
18+
period: 2026
19+
absolute_error_margin: 0.0001
20+
input:
21+
aca_magi_fraction: 1.33
22+
output:
23+
aca_required_contribution_percentage: 0.0314
24+
25+
- name: Flat at 100% FPL in 2019 (pre-ARPA, should be 2%)
26+
period: 2019
27+
absolute_error_margin: 0.0001
28+
input:
29+
aca_magi_fraction: 1.0
30+
output:
31+
aca_required_contribution_percentage: 0.02
32+
133
- name: aca_required_contribution_percentage unit test 1
234
period: 2022
335
absolute_error_margin: 0.00001

policyengine_us/variables/gov/aca/ptc/aca_required_contribution_percentage.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,43 @@ class aca_required_contribution_percentage(Variable):
1212
def formula(tax_unit, period, parameters):
1313
magi_frac = tax_unit("aca_magi_fraction", period)
1414
p = parameters(period).gov.aca.required_contribution_percentage
15-
return np.interp(magi_frac, p.thresholds, p.amounts)
15+
16+
# Get bracket boundaries and rates
17+
thresholds = np.array(p.threshold)
18+
initial_rates = np.array(p.initial)
19+
final_rates = np.array(p.final)
20+
21+
# Find which bracket each household falls into
22+
# searchsorted returns index where magi_frac would be inserted
23+
# subtract 1 to get the bracket index (clamped to valid range)
24+
# Note: there are len(thresholds)-1 brackets (rate arrays are 1 shorter than thresholds)
25+
bracket_idx = clip(
26+
np.searchsorted(thresholds, magi_frac, side="right") - 1,
27+
0,
28+
len(initial_rates) - 1,
29+
)
30+
31+
# Get bracket boundaries for interpolation
32+
bracket_start = thresholds[bracket_idx]
33+
# For the last bracket, use a dummy end value (won't affect result since initial=final)
34+
# Need to use min_ to avoid index errors with vectorized operations
35+
next_idx = min_(bracket_idx + 1, len(thresholds) - 1)
36+
bracket_end = where(
37+
bracket_idx < len(thresholds) - 1,
38+
thresholds[next_idx],
39+
bracket_start + 1, # Dummy value for last bracket
40+
)
41+
42+
# Calculate position within bracket (0 to 1)
43+
bracket_width = bracket_end - bracket_start
44+
position = where(
45+
bracket_width > 0,
46+
(magi_frac - bracket_start) / bracket_width,
47+
0,
48+
)
49+
position = clip(position, 0, 1)
50+
51+
# Interpolate between initial and final rates
52+
initial = initial_rates[bracket_idx]
53+
final = final_rates[bracket_idx]
54+
return initial + position * (final - initial)

uv.lock

Lines changed: 59 additions & 27 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)