From a6222614b3a198041e54e6881824da56e37cc151 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 24 Oct 2026 13:15:15 -0400 Subject: [PATCH 1/5] Add the 2027 ACA applicable percentage table (Rev. Proc. 2026-26) Rev. Proc. 2026-26 section 3.01 sets the premium tax credit applicable percentage table for taxable years beginning in 2027. Before this, initial.yaml and final.yaml stopped at 2026, so 2027 reused the 2026 table (6.60% instead of 6.78% at 200% FPL). Co-Authored-By: Claude Opus 5.5 --- .../aca-ptc-2027-applicable-percentage.fixed.md | 1 + .../aca/required_contribution_percentage/final.yaml | 11 ++++++++++- .../aca/required_contribution_percentage/initial.yaml | 11 ++++++++++- .../required_contribution_percentage/threshold.yaml | 4 +++- 4 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 changelog.d/aca-ptc-2027-applicable-percentage.fixed.md diff --git a/changelog.d/aca-ptc-2027-applicable-percentage.fixed.md b/changelog.d/aca-ptc-2027-applicable-percentage.fixed.md new file mode 100644 index 00000000000..0fe021f0726 --- /dev/null +++ b/changelog.d/aca-ptc-2027-applicable-percentage.fixed.md @@ -0,0 +1 @@ +Add the 2027 premium tax credit applicable percentage table from Rev. Proc. 2026-26, so 2027 no longer reuses the 2026 table. diff --git a/policyengine_us/parameters/gov/aca/required_contribution_percentage/final.yaml b/policyengine_us/parameters/gov/aca/required_contribution_percentage/final.yaml index da40e2b5427..59f07007aa8 100644 --- a/policyengine_us/parameters/gov/aca/required_contribution_percentage/final.yaml +++ b/policyengine_us/parameters/gov/aca/required_contribution_percentage/final.yaml @@ -13,7 +13,9 @@ metadata: - title: IRS Revenue Procedure 2019-29 - Applicable percentage table for 2020 href: https://www.irs.gov/pub/irs-drop/rp-19-29.pdf#page=3 - title: IRS Revenue Procedure 2025-25 - Applicable percentage table for 2026 - href: https://www.irs.gov/pub/irs-drop/rp-25-25.pdf + href: https://www.irs.gov/pub/irs-drop/rp-25-25.pdf#page=3 + - title: IRS Revenue Procedure 2026-26 - Applicable percentage table for 2027 + href: https://www.irs.gov/pub/irs-drop/rp-26-26.pdf#page=3 values: # Original ACA: 6 brackets 2015-01-01: @@ -59,3 +61,10 @@ values: - 0.0844 # 200-250% - 0.0996 # 250-300% - 0.0996 # 300-400%: flat 9.96% + 2027-01-01: + - 0.0215 # 0-133%: flat 2.15% + - 0.043 # 133-150% + - 0.0678 # 150-200% + - 0.0866 # 200-250% + - 0.1022 # 250-300% + - 0.1022 # 300-400%: flat 10.22% diff --git a/policyengine_us/parameters/gov/aca/required_contribution_percentage/initial.yaml b/policyengine_us/parameters/gov/aca/required_contribution_percentage/initial.yaml index 74805496be5..ce3460458f4 100644 --- a/policyengine_us/parameters/gov/aca/required_contribution_percentage/initial.yaml +++ b/policyengine_us/parameters/gov/aca/required_contribution_percentage/initial.yaml @@ -13,7 +13,9 @@ metadata: - title: IRS Revenue Procedure 2019-29 - Applicable percentage table for 2020 href: https://www.irs.gov/pub/irs-drop/rp-19-29.pdf#page=3 - title: IRS Revenue Procedure 2025-25 - Applicable percentage table for 2026 - href: https://www.irs.gov/pub/irs-drop/rp-25-25.pdf + href: https://www.irs.gov/pub/irs-drop/rp-25-25.pdf#page=3 + - title: IRS Revenue Procedure 2026-26 - Applicable percentage table for 2027 + href: https://www.irs.gov/pub/irs-drop/rp-26-26.pdf#page=3 values: # Original ACA: 6 brackets 2015-01-01: @@ -59,3 +61,10 @@ values: - 0.066 # 200-250% - 0.0844 # 250-300% - 0.0996 # 300-400%: flat 9.96% + 2027-01-01: + - 0.0215 # 0-133%: flat 2.15% + - 0.0323 # 133-150% + - 0.043 # 150-200% + - 0.0678 # 200-250% + - 0.0866 # 250-300% + - 0.1022 # 300-400%: flat 10.22% diff --git a/policyengine_us/parameters/gov/aca/required_contribution_percentage/threshold.yaml b/policyengine_us/parameters/gov/aca/required_contribution_percentage/threshold.yaml index 537e6c0bbb5..0aa76208635 100644 --- a/policyengine_us/parameters/gov/aca/required_contribution_percentage/threshold.yaml +++ b/policyengine_us/parameters/gov/aca/required_contribution_percentage/threshold.yaml @@ -6,7 +6,9 @@ metadata: - title: 26 U.S. Code ยง 36B(b)(3)(A) - Refundable credit for coverage under a qualified health plan href: https://www.law.cornell.edu/uscode/text/26/36B#b_3_A - title: IRS Revenue Procedure 2025-25 - Applicable percentage table for 2026 - href: https://www.irs.gov/pub/irs-drop/rp-25-25.pdf + href: https://www.irs.gov/pub/irs-drop/rp-25-25.pdf#page=3 + - title: IRS Revenue Procedure 2026-26 - Applicable percentage table for 2027 + href: https://www.irs.gov/pub/irs-drop/rp-26-26.pdf#page=3 values: 2015-01-01: - 0 From 95ad29a6016e629f02eeb2daa2d6b44ba902c848 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 24 Oct 2026 14:01:31 -0400 Subject: [PATCH 2/5] Test the 2027 applicable percentage table at its edges and as properties YAML cases pin aca_required_contribution_percentage at every 2027 band edge and at interior points, and derive aca_ptc in 2027 from the table, including two cases that derive the poverty-line fraction from income and the 2026 HHS guideline. Expected values come from Rev. Proc. 2026-26 section 3.01. The Hypothesis test checks every year's table from 2018 through 2027: the percentage equals the table at each edge, stays within and is continuous in each tier, never decreases with income, and the required contribution never decreases with income. Co-Authored-By: Claude Opus 5.5 --- ...ired_contribution_percentage_properties.py | 178 ++++++++++++++++++ .../policy/baseline/gov/aca/ptc/aca_ptc.yaml | 145 ++++++++++++++ .../aca_required_contribution_percentage.yaml | 130 +++++++++++++ 3 files changed, 453 insertions(+) create mode 100644 policyengine_us/tests/core/test_aca_required_contribution_percentage_properties.py diff --git a/policyengine_us/tests/core/test_aca_required_contribution_percentage_properties.py b/policyengine_us/tests/core/test_aca_required_contribution_percentage_properties.py new file mode 100644 index 00000000000..910b4ce70fe --- /dev/null +++ b/policyengine_us/tests/core/test_aca_required_contribution_percentage_properties.py @@ -0,0 +1,178 @@ +"""Properties of the ACA applicable percentage table. + +`aca_required_contribution_percentage` is the applicable percentage of +26 U.S.C. 36B(b)(3)(A): within each income tier of the table it rises "on a +sliding scale in a linear manner" from the tier's initial percentage to its +final percentage (26 CFR 1.36B-3(g)(1)). The IRS indexes the table each year; +Rev. Proc. 2026-26 sets it for 2027. Hypothesis draws incomes as fractions of +the poverty line from 0 to 400 percent. Each drawn example is one simulation, +calculated for every year from 2018 through 2027 against that year's table. +For every year: + +1. At each tier's lower edge the percentage equals the tier's initial + percentage, and at exactly 400 percent it equals the top tier's final + percentage. +2. Within a tier the percentage is continuous: it stays between the tier's + initial and final percentages, it reaches the final percentage just below + the next edge, and two incomes in one tier differ by no more than the tier's + slope times the distance between them. +3. The percentage never decreases as income rises from 0 to 400 percent, + including across tier edges. +4. The required contribution (household income times the percentage) never + decreases as income rises, so for a fixed benchmark premium the credit never + rises with income up to 400 percent (checked for 2026 and 2027, from income + in dollars through the poverty-line fraction). + +Why Python: the YAML cases pin the 2027 table at chosen points; this checks +every year's table at its knots and at drawn incomes, so an edge assigned to the +wrong tier, an interpolation that runs past a tier's final percentage, or a new +table row entered in the wrong order fails wherever it falls. Cost, measured +locally (macOS, shared host): about 6 s of test calls, 12 s in pytest and 36 s +wall time including the model import, peak memory footprint 1.7 GB. It runs in +the `core` group of the rest Python tests. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +YEARS = tuple(range(2018, 2028)) +CONTRIBUTION_YEARS = (2026, 2027) +# The model holds incomes and percentages in float32 (about seven significant +# digits), so edges are approached from 1e-5 away and percentages compared to +# within 1e-6. +EPSILON = 1e-5 +TOLERANCE = 1e-6 +# Every tier edge in any year's table, approached from both sides. +EDGES = (0.0, 1.33, 1.5, 2.0, 2.5, 3.0, 4.0) +KNOTS = sorted( + { + min(max(edge + offset, 0.0), 4.0) + for edge in EDGES + for offset in (-EPSILON, 0.0, EPSILON) + } +) + +fractions = st.lists( + st.one_of( + st.floats(0, 4, allow_nan=False, allow_infinity=False), + # Whole percents, as aca_magi_fraction produces from income. + st.integers(0, 400).map(lambda percent: percent / 100), + ), + min_size=1, + max_size=60, +) + + +def build(tax_unit_inputs, years): + people, tax_units, households = {}, {}, {} + groups = {"marital_units": {}, "spm_units": {}, "families": {}} + for index, inputs in enumerate(tax_unit_inputs): + person, members = f"person_{index}", [f"person_{index}"] + people[person] = { + "age": {year: 40 for year in years}, + "is_aca_ptc_eligible": {year: True for year in years}, + } + tax_units[f"tax_unit_{index}"] = { + "members": members, + **{name: {year: value for year in years} for name, value in inputs.items()}, + } + households[f"household_{index}"] = { + "members": members, + "state_code": {year: "TX" for year in years}, + } + for name in groups: + groups[name][f"{name}_{index}"] = {"members": members} + return Simulation( + situation={ + "people": people, + "tax_units": tax_units, + "households": households, + **groups, + } + ) + + +def table(simulation, year): + p = simulation.tax_benefit_system.parameters( + f"{year}-01-01" + ).gov.aca.required_contribution_percentage + return np.array(p.threshold), np.array(p.initial), np.array(p.final) + + +@settings( + max_examples=8, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(fractions.map(lambda drawn: sorted(KNOTS + drawn))) +def test_applicable_percentage_table(points): + simulation = build([{"aca_magi_fraction": x} for x in points], YEARS) + x = np.array(points) + # The fractions the model sees, after float32 storage. + seen = x.astype(np.float32).astype(float) + for year in YEARS: + thresholds, initial, final = table(simulation, year) + rate = np.asarray( + simulation.calculate("aca_required_contribution_percentage", year) + ) + # Property 1. + for tier, edge in enumerate(thresholds[:-1]): + np.testing.assert_allclose( + rate[x == edge], initial[tier], atol=TOLERANCE, err_msg=str(year) + ) + np.testing.assert_allclose( + rate[x == thresholds[-1]], final[-1], atol=TOLERANCE, err_msg=str(year) + ) + # Property 2. + tier = np.clip( + np.searchsorted(thresholds, seen, side="right") - 1, 0, len(initial) - 1 + ) + slope = (final - initial) / np.diff(thresholds) + assert (rate >= initial[tier] - TOLERANCE).all(), year + assert (rate <= final[tier] + TOLERANCE).all(), year + for upper, top, rise in zip(thresholds[1:], final, slope): + below = rate[x == upper - EPSILON] + assert (top - below >= -TOLERANCE).all(), year + assert (top - below <= rise * EPSILON + TOLERANCE).all(), year + same_tier = tier[1:] == tier[:-1] + step = np.abs(np.diff(rate))[same_tier] + bound = (slope[tier][1:] * np.diff(seen))[same_tier] + TOLERANCE + assert (step <= bound).all(), year + # Property 3. + assert (np.diff(rate) >= -TOLERANCE).all(), year + + +@settings( + max_examples=6, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given( + st.lists(st.integers(0, 70_000), min_size=2, max_size=60).map(sorted), + st.integers(1_000, 20_000), +) +def test_required_contribution_rises_with_income(incomes, slcsp): + simulation = build( + [ + {"aca_magi": income, "slcsp": slcsp, "tax_unit_is_filer": True} + for income in incomes + ], + CONTRIBUTION_YEARS, + ) + income = np.array(incomes, dtype=float) + for year in CONTRIBUTION_YEARS: + fraction = np.asarray(simulation.calculate("aca_magi_fraction", year)) + rate = np.asarray( + simulation.calculate("aca_required_contribution_percentage", year) + ) + credit = np.asarray(simulation.calculate("aca_ptc_before_filing_gate", year)) + within = fraction <= 4 + contribution = (income * rate)[within] + # Property 4. + assert (np.diff(contribution) >= -1e-6).all(), year + assert (np.diff(credit[within]) <= 1e-6).all(), year diff --git a/policyengine_us/tests/policy/baseline/gov/aca/ptc/aca_ptc.yaml b/policyengine_us/tests/policy/baseline/gov/aca/ptc/aca_ptc.yaml index 2214672a9e5..ea6ec85cef9 100644 --- a/policyengine_us/tests/policy/baseline/gov/aca/ptc/aca_ptc.yaml +++ b/policyengine_us/tests/policy/baseline/gov/aca/ptc/aca_ptc.yaml @@ -276,3 +276,148 @@ output: is_aca_ptc_eligible: [false] aca_ptc: 0 + +# 2027 cases derive aca_required_contribution_percentage from the Rev. Proc. +# 2026-26 section 3.01 table. Household income is a multiple of the 2026 HHS +# poverty guideline ($15,960 for one person), the prior-year guideline that +# applies to 2027 coverage. +- name: 2027 ACA PTC at 100 percent FPL uses the flat 2.15 percent. + absolute_error_margin: 0.01 + period: 2027 + input: + is_aca_ptc_eligible: true + slcsp: 8_256 + aca_magi: 15_960 + aca_magi_fraction: 1.00 + tax_unit_is_filer: true + output: + aca_required_contribution_percentage: 0.0215 + aca_ptc: 7_912.86 # 8_256 - 0.0215 * 15_960 = 8_256 - 343.14 + +- name: 2027 ACA PTC at 133 percent FPL uses the 3.23 percent initial percentage. + absolute_error_margin: 0.01 + period: 2027 + input: + is_aca_ptc_eligible: true + slcsp: 8_256 + aca_magi: 21_226.80 + aca_magi_fraction: 1.33 + tax_unit_is_filer: true + output: + aca_required_contribution_percentage: 0.0323 + aca_ptc: 7_570.37 # 8_256 - 0.0323 * 21_226.80 = 8_256 - 685.63 + +- name: 2027 ACA PTC at 150 percent FPL uses 4.3 percent. + absolute_error_margin: 0.01 + period: 2027 + input: + is_aca_ptc_eligible: true + slcsp: 8_256 + aca_magi: 23_940 + aca_magi_fraction: 1.50 + tax_unit_is_filer: true + output: + aca_required_contribution_percentage: 0.043 + aca_ptc: 7_226.58 # 8_256 - 0.043 * 23_940 = 8_256 - 1_029.42 + +- name: 2027 ACA PTC at 200 percent FPL uses 6.78 percent (Covered California OE27 example). + absolute_error_margin: 0.01 + period: 2027 + input: + is_aca_ptc_eligible: true + slcsp: 8_256 + aca_magi: 31_920 + aca_magi_fraction: 2.00 + tax_unit_is_filer: true + output: + aca_required_contribution_percentage: 0.0678 + aca_ptc: 6_091.82 # 8_256 - 0.0678 * 31_920 = 8_256 - 2_164.18 + +- name: 2027 ACA PTC at 275 percent FPL interpolates to 9.44 percent. + absolute_error_margin: 0.01 + period: 2027 + input: + is_aca_ptc_eligible: true + slcsp: 8_256 + aca_magi: 43_890 + aca_magi_fraction: 2.75 + tax_unit_is_filer: true + output: + aca_required_contribution_percentage: 0.0944 + aca_ptc: 4_112.78 # 8_256 - 0.0944 * 43_890 = 8_256 - 4_143.22 + +- name: 2027 ACA PTC at 350 percent FPL uses the flat 10.22 percent. + absolute_error_margin: 0.01 + period: 2027 + input: + is_aca_ptc_eligible: true + slcsp: 8_256 + aca_magi: 55_860 + aca_magi_fraction: 3.50 + tax_unit_is_filer: true + output: + aca_required_contribution_percentage: 0.1022 + aca_ptc: 2_547.11 # 8_256 - 0.1022 * 55_860 = 8_256 - 5_708.89 + +- name: 2027 ACA PTC floors at zero when the 10.22 percent contribution exceeds the benchmark. + absolute_error_margin: 0.01 + period: 2027 + input: + is_aca_ptc_eligible: true + slcsp: 6_000 + aca_magi: 62_244 + aca_magi_fraction: 3.90 + tax_unit_is_filer: true + output: + aca_required_contribution_percentage: 0.1022 + aca_ptc: 0 # 0.1022 * 62_244 = 6_361.34 > 6_000 + +- name: 2027 ACA PTC derives 200 percent FPL from income and the 2026 poverty guideline. + absolute_error_margin: 0.01 + period: 2027 + input: + people: + person1: + age: 40 + is_aca_ptc_eligible: true + tax_units: + tax_unit: + members: [person1] + aca_magi: 31_920 + slcsp: 8_256 + tax_unit_is_filer: true + households: + household: + members: [person1] + state_code: TX + output: + aca_magi_fraction: 2.00 # 31_920 / 15_960 (2026 guideline, one person) + aca_required_contribution_percentage: 0.0678 + aca_ptc: 6_091.82 # 8_256 - 0.0678 * 31_920 + +- name: 2027 ACA PTC for a married couple at 250 percent FPL uses 8.66 percent. + absolute_error_margin: 0.01 + period: 2027 + input: + people: + person1: + age: 40 + is_aca_ptc_eligible: true + person2: + age: 40 + is_aca_ptc_eligible: true + tax_units: + tax_unit: + members: [person1, person2] + aca_magi: 54_100 + slcsp: 15_000 + tax_unit_is_filer: true + filing_status: JOINT + households: + household: + members: [person1, person2] + state_code: TX + output: + aca_magi_fraction: 2.50 # 54_100 / 21_640 (2026 guideline, two people) + aca_required_contribution_percentage: 0.0866 + aca_ptc: 10_314.94 # 15_000 - 0.0866 * 54_100 = 15_000 - 4_685.06 diff --git a/policyengine_us/tests/policy/baseline/gov/aca/ptc/aca_required_contribution_percentage.yaml b/policyengine_us/tests/policy/baseline/gov/aca/ptc/aca_required_contribution_percentage.yaml index 2bb9eedbbe6..0516ccdc7c5 100644 --- a/policyengine_us/tests/policy/baseline/gov/aca/ptc/aca_required_contribution_percentage.yaml +++ b/policyengine_us/tests/policy/baseline/gov/aca/ptc/aca_required_contribution_percentage.yaml @@ -197,3 +197,133 @@ aca_magi_fraction: 4.01 output: aca_required_contribution_percentage: 0 + +# 2027: Rev. Proc. 2026-26 section 3.01 table (initial -> final percentage): +# under 133% 2.15 -> 2.15; 133-150% 3.23 -> 4.3; 150-200% 4.3 -> 6.78; +# 200-250% 6.78 -> 8.66; 250-300% 8.66 -> 10.22; 300-400% 10.22 -> 10.22. +- name: 2027 ACA contribution percentage is flat 2.15 percent at 100 percent FPL. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 1.0 + output: + aca_required_contribution_percentage: 0.0215 + +- name: 2027 ACA contribution percentage is still 2.15 percent at 132 percent FPL. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 1.32 + output: + aca_required_contribution_percentage: 0.0215 + +- name: 2027 ACA contribution percentage jumps to 3.23 percent at 133 percent FPL. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 1.33 + output: + aca_required_contribution_percentage: 0.0323 + +- name: 2027 ACA contribution percentage interpolates within the 133-150 percent band. + # 3.23 + (1.40 - 1.33) / (1.50 - 1.33) * (4.3 - 3.23) = 3.6706%, which + # 26 CFR 1.36B-3(g)(1) rounds to 3.67%. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 1.40 + output: + aca_required_contribution_percentage: 0.0367 + +- name: 2027 ACA contribution percentage is 4.3 percent at 150 percent FPL. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 1.50 + output: + aca_required_contribution_percentage: 0.043 + +- name: 2027 ACA contribution percentage at the midpoint of the 150-200 percent band. + # 4.3 + 0.5 * (6.78 - 4.3) = 5.54%. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 1.75 + output: + aca_required_contribution_percentage: 0.0554 + +- name: 2027 ACA contribution percentage at 199 percent FPL, the top of the 150-200 percent band. + # 4.3 + (1.99 - 1.50) / (2.00 - 1.50) * (6.78 - 4.3) = 6.7304%, which + # 26 CFR 1.36B-3(g)(1) rounds to 6.73%. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 1.99 + output: + aca_required_contribution_percentage: 0.0673 + +- name: 2027 ACA contribution percentage is 6.78 percent at 200 percent FPL. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 2.00 + output: + aca_required_contribution_percentage: 0.0678 + +- name: 2027 ACA contribution percentage at the midpoint of the 200-250 percent band. + # 6.78 + 0.5 * (8.66 - 6.78) = 7.72%. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 2.25 + output: + aca_required_contribution_percentage: 0.0772 + +- name: 2027 ACA contribution percentage is 8.66 percent at 250 percent FPL. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 2.50 + output: + aca_required_contribution_percentage: 0.0866 + +- name: 2027 ACA contribution percentage at the midpoint of the 250-300 percent band. + # 8.66 + 0.5 * (10.22 - 8.66) = 9.44%. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 2.75 + output: + aca_required_contribution_percentage: 0.0944 + +- name: 2027 ACA contribution percentage is 10.22 percent at 300 percent FPL. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 3.00 + output: + aca_required_contribution_percentage: 0.1022 + +- name: 2027 ACA contribution percentage is flat 10.22 percent at 350 percent FPL. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 3.50 + output: + aca_required_contribution_percentage: 0.1022 + +- name: 2027 ACA contribution percentage is 10.22 percent at 400 percent FPL. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 4.0 + output: + aca_required_contribution_percentage: 0.1022 + +- name: 2027 ACA contribution percentage is zero above 400 percent FPL. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 4.01 + output: + aca_required_contribution_percentage: 0 From 05310b244c66afa553868f9f22c7dce9daf3af76 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 24 Oct 2026 14:03:22 -0400 Subject: [PATCH 3/5] Guard the property test against a table edge it does not probe Co-Authored-By: Claude Opus 5.5 --- ...est_aca_required_contribution_percentage_properties.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/policyengine_us/tests/core/test_aca_required_contribution_percentage_properties.py b/policyengine_us/tests/core/test_aca_required_contribution_percentage_properties.py index 910b4ce70fe..19b46d05407 100644 --- a/policyengine_us/tests/core/test_aca_required_contribution_percentage_properties.py +++ b/policyengine_us/tests/core/test_aca_required_contribution_percentage_properties.py @@ -24,9 +24,9 @@ in dollars through the poverty-line fraction). Why Python: the YAML cases pin the 2027 table at chosen points; this checks -every year's table at its knots and at drawn incomes, so an edge assigned to the -wrong tier, an interpolation that runs past a tier's final percentage, or a new -table row entered in the wrong order fails wherever it falls. Cost, measured +every year's table at its edges and at drawn incomes, so a mistyped edge, an +interpolation that runs past a tier's final percentage, or a new table row +entered in the wrong order fails wherever it falls. Cost, measured locally (macOS, shared host): about 6 s of test calls, 12 s in pytest and 36 s wall time including the model import, peak memory footprint 1.7 GB. It runs in the `core` group of the rest Python tests. @@ -116,6 +116,8 @@ def test_applicable_percentage_table(points): seen = x.astype(np.float32).astype(float) for year in YEARS: thresholds, initial, final = table(simulation, year) + # Every edge is probed; a new or mistyped edge must be added to EDGES. + assert set(thresholds) <= set(EDGES), (year, thresholds) rate = np.asarray( simulation.calculate("aca_required_contribution_percentage", year) ) From aa7c81400f32e39226dd729818cb90fb63cee53a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 24 Oct 2026 16:15:44 -0400 Subject: [PATCH 4/5] Let the property test accept the regulation's rounding 26 CFR 1.36B-3(g)(1) rounds the interpolated applicable percentage to the nearest hundredth of a percent. The pairwise slope bound now allows one rounding step (0.0001), so the test stays valid if the model adopts that rounding. The contribution test also keeps four fixed incomes in distinct tiers, so no example compares only equal incomes. Co-Authored-By: Claude Opus 5.5 --- ...required_contribution_percentage_properties.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/policyengine_us/tests/core/test_aca_required_contribution_percentage_properties.py b/policyengine_us/tests/core/test_aca_required_contribution_percentage_properties.py index 19b46d05407..6d7874c17c5 100644 --- a/policyengine_us/tests/core/test_aca_required_contribution_percentage_properties.py +++ b/policyengine_us/tests/core/test_aca_required_contribution_percentage_properties.py @@ -15,7 +15,9 @@ 2. Within a tier the percentage is continuous: it stays between the tier's initial and final percentages, it reaches the final percentage just below the next edge, and two incomes in one tier differ by no more than the tier's - slope times the distance between them. + slope times the distance between them, plus one rounding step. The + regulation interpolates linearly and then rounds to the nearest hundredth + of a percent, so the bound holds whether or not the model rounds. 3. The percentage never decreases as income rises from 0 to 400 percent, including across tier edges. 4. The required contribution (household income times the percentage) never @@ -45,6 +47,10 @@ # within 1e-6. EPSILON = 1e-5 TOLERANCE = 1e-6 +# 26 CFR 1.36B-3(g)(1) rounds the interpolated percentage to the nearest +# hundredth of a percent, which can widen a step between two incomes by up to +# 0.0001. +ROUNDING = 1e-4 # Every tier edge in any year's table, approached from both sides. EDGES = (0.0, 1.33, 1.5, 2.0, 2.5, 3.0, 4.0) KNOTS = sorted( @@ -142,7 +148,7 @@ def test_applicable_percentage_table(points): assert (top - below <= rise * EPSILON + TOLERANCE).all(), year same_tier = tier[1:] == tier[:-1] step = np.abs(np.diff(rate))[same_tier] - bound = (slope[tier][1:] * np.diff(seen))[same_tier] + TOLERANCE + bound = (slope[tier][1:] * np.diff(seen))[same_tier] + ROUNDING + TOLERANCE assert (step <= bound).all(), year # Property 3. assert (np.diff(rate) >= -TOLERANCE).all(), year @@ -155,7 +161,10 @@ def test_applicable_percentage_table(points): suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], ) @given( - st.lists(st.integers(0, 70_000), min_size=2, max_size=60).map(sorted), + # Fixed incomes in four tiers, so every example compares distinct incomes. + st.lists(st.integers(0, 70_000), max_size=60).map( + lambda drawn: sorted([10_000, 25_000, 40_000, 60_000, *drawn]) + ), st.integers(1_000, 20_000), ) def test_required_contribution_rises_with_income(incomes, slcsp): From 83c7d8cd1a97604afc65ea4f2e634158f82be1e2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 24 Oct 2026 16:53:50 -0400 Subject: [PATCH 5/5] Bound the applicable percentage to its interpolation line at every income Replace the pairwise step bound with a pointwise one: at each drawn income the percentage is within half a rounding step (0.00005) of the tier's straight line. That is the regulation's statement, interpolation then rounding to the nearest hundredth of a percent, and it implies the pairwise bound. A bow of up to 0.00008 that keeps endpoints and order passed the pairwise bound and fails this one; rounding still passes. Co-Authored-By: Claude Opus 5.5 --- ...ired_contribution_percentage_properties.py | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/policyengine_us/tests/core/test_aca_required_contribution_percentage_properties.py b/policyengine_us/tests/core/test_aca_required_contribution_percentage_properties.py index 6d7874c17c5..df356d4cd2a 100644 --- a/policyengine_us/tests/core/test_aca_required_contribution_percentage_properties.py +++ b/policyengine_us/tests/core/test_aca_required_contribution_percentage_properties.py @@ -12,14 +12,15 @@ 1. At each tier's lower edge the percentage equals the tier's initial percentage, and at exactly 400 percent it equals the top tier's final percentage. -2. Within a tier the percentage is continuous: it stays between the tier's - initial and final percentages, it reaches the final percentage just below - the next edge, and two incomes in one tier differ by no more than the tier's - slope times the distance between them, plus one rounding step. The - regulation interpolates linearly and then rounds to the nearest hundredth - of a percent, so the bound holds whether or not the model rounds. +2. Within a tier the percentage is the straight line from the tier's initial + to its final percentage, which is continuous, rounded to the nearest + hundredth of a percent (26 CFR 1.36B-3(g)(1)). So it stays between the + tier's initial and final percentages, it reaches the final percentage just + below the next edge, and at every income it is within half a rounding step + of the line. The bound holds whether or not the model rounds. 3. The percentage never decreases as income rises from 0 to 400 percent, - including across tier edges. + including across tier edges. Before rounding, its only jumps are at tier + edges; rounding adds steps of 0.01 percentage point. 4. The required contribution (household income times the percentage) never decreases as income rises, so for a fixed benchmark premium the credit never rises with income up to 400 percent (checked for 2026 and 2027, from income @@ -48,8 +49,7 @@ EPSILON = 1e-5 TOLERANCE = 1e-6 # 26 CFR 1.36B-3(g)(1) rounds the interpolated percentage to the nearest -# hundredth of a percent, which can widen a step between two incomes by up to -# 0.0001. +# hundredth of a percent, which moves it off the line by up to half of 0.0001. ROUNDING = 1e-4 # Every tier edge in any year's table, approached from both sides. EDGES = (0.0, 1.33, 1.5, 2.0, 2.5, 3.0, 4.0) @@ -146,10 +146,8 @@ def test_applicable_percentage_table(points): below = rate[x == upper - EPSILON] assert (top - below >= -TOLERANCE).all(), year assert (top - below <= rise * EPSILON + TOLERANCE).all(), year - same_tier = tier[1:] == tier[:-1] - step = np.abs(np.diff(rate))[same_tier] - bound = (slope[tier][1:] * np.diff(seen))[same_tier] + ROUNDING + TOLERANCE - assert (step <= bound).all(), year + line = initial[tier] + slope[tier] * (seen - thresholds[tier]) + assert (np.abs(rate - line) <= ROUNDING / 2 + TOLERANCE).all(), year # Property 3. assert (np.diff(rate) >= -TOLERANCE).all(), year