From cc671ad8dfe83527bf3e013fe96ddce922cda953 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 18:52:35 +0000 Subject: [PATCH 1/4] Apply the IBR and PAYE 10-year cap before spousal proration 34 CFR 685.209(f)(2)-(3) make the payment the lesser of the income-based amount and the borrower's own 10-year standard payment, and (g)(1) adjusts the amount calculated under (f), so the balance share multiplies the lesser-of result. The model prorated first and capped second. Each borrower's cap stays on their own loans. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JaKXtKF9JBv4wLzq6 --- .../income_based_repayment_payment.yaml | 187 ++++++++++-- .../pay_as_you_earn_payment.yaml | 80 +++-- ...est_student_loan_idr_cap_order_property.py | 278 ++++++++++++++++++ .../income_based_repayment_payment.py | 67 ++--- .../income_driven_repayment_balance_share.py | 5 +- ...ncome_driven_repayment_standard_payment.py | 14 +- .../pay_as_you_earn_payment.py | 72 +++-- 7 files changed, 574 insertions(+), 129 deletions(-) create mode 100644 policyengine_us/tests/test_student_loan_idr_cap_order_property.py diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.yaml index 93a9b823204..21803726095 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.yaml @@ -2,10 +2,10 @@ # discretionary income = max(AGI - 1.5 x poverty guideline, 0) # payment = lesser of # 15% (new borrowers 10%) x discretionary income / 12 -# x own Direct balance / couple's Direct and FFEL balance ((g)(1)(i)-(ii)) # and the borrower's own 10-year standard payment on their Direct and FFEL -# loans (1098e(b)(6)(A); (f)(2)(ii), (f)(3)(ii)) -# x own Direct balance / own Direct and FFEL balance ((g)(1)(ii)) +# loans (1098e(b)(6)(A); (f)(2)(ii), (f)(3)(ii)), +# times own Direct balance / couple's Direct and FFEL balance +# ((g)(1)(i)-(ii), applied to the lesser-of amount), # rounded to the cent; under 5 -> 0; 5 to under 10 -> 10; at most the # borrower's Direct balance. # 2026 guideline in the 48 states: 15,960 + 5,680 per additional person. @@ -193,10 +193,10 @@ members: [person1, person2] state_code: TX output: - # 1.5 x 21,640 = 32,460; 15% x 47,540 / 12 = 594.25. - # Shares 75% and 25%: 445.69 and 148.56, below the caps of 666.12 and - # 222.04. - income_based_repayment_payment: [445.69, 148.56] + # 1.5 x 21,640 = 32,460; 15% x 47,540 / 12 = 594.25. Own standard + # payments 666.12 and 222.04. min(594.25, 666.12) x 75% = 445.69 and + # min(594.25, 222.04) x 25% = 55.51 (685.209(f)(2)-(3), then (g)(1)). + income_based_repayment_payment: [445.69, 55.51] - name: Case 9, married filing separately excludes the spouse's income. period: 2026-01 @@ -446,10 +446,12 @@ state_code: TX output: # Issue #10411 example. 1.5 x 21,640 = 32,460; 15% x 48,000 / 12 = 600, - # split 50/50: 300 each. Standard payments: 30,000 / 120 = 250 and - # 30,000 x 0.01 / (1 - 1.01 ^ -120) = 430.41. min(300, 250) = 250 and - # min(300, 430.41) = 300 (20 U.S.C. 1098e(b)(6)(A)). - income_based_repayment_payment: [250, 300] + # with balance shares of 50% each. Standard payments: 30,000 / 120 = 250 + # and 30,000 x 0.01 / (1 - 1.01 ^ -120) = 430.4128. min(600, 250) x 50% = + # 125 and min(600, 430.4128) x 50% = 215.2064 -> 215.21 (20 U.S.C. + # 1098e(b)(6)(A); 685.209(f)(2)-(3), then (g)(1)). Capping after + # proration would give min(300, 250) = 250 and min(300, 430.41) = 300. + income_based_repayment_payment: [125, 215.21] - name: Case 19, each spouse's own standard payment caps a high income. period: 2026-01 @@ -474,9 +476,10 @@ members: [person1, person2] state_code: TX output: - # 15% x (96,460 - 32,460) / 12 = 800, split 50/50: 400 each. - # min(400, 250) = 250 and min(400, 430.41) = 400. - income_based_repayment_payment: [250, 400] + # 15% x (96,460 - 32,460) / 12 = 800, above both standard payments. + # min(800, 250) x 50% = 125 and min(800, 430.4128) x 50% = 215.21. + # Capping after proration would give 250 and 400. + income_based_repayment_payment: [125, 215.21] - name: Case 20, spouses keep their own new-borrower and other-borrower rates. period: 2026-01 @@ -503,9 +506,11 @@ state_code: TX output: # Discretionary income (92,460 - 32,460) / 12 = 5,000. Each spouse's own - # standard payment is 333.06. New borrower: min(10% x 5,000 x 50%, - # 333.06) = 250. Other borrower: min(15% x 5,000 x 50%, 333.06) = 333.06. - income_based_repayment_payment: [250, 333.06] + # standard payment is 333.0615. New borrower: min(10% x 5,000 = 500, + # 333.0615) x 50% = 166.53. Other borrower: min(15% x 5,000 = 750, + # 333.0615) x 50% = 166.53. Capping after proration would give 250 and + # 333.06. + income_based_repayment_payment: [166.53, 166.53] - name: Case 21, a spouse's FFEL debt counts in the joint proration. period: 2026-01 @@ -532,10 +537,12 @@ state_code: TX output: # IBR analogue of rulespec-us#1455 C16. 15% x (60,000 - 32,460) / 12 = - # 344.25. The borrower pays 344.25 x 10,000 / 40,000 = 86.06 - # (685.209(g)(1)(i)-(ii)), under their own standard payment of 111.02; the - # spouse has no Direct Loans and owes nothing under the plan. - income_based_repayment_payment: [86.06, 0] + # 344.25, above the borrower's own standard payment of 111.0205. The + # borrower pays min(344.25, 111.0205) x 10,000 / 40,000 = 27.7551 -> + # 27.76 (685.209(f)(2)-(3), then (g)(1)(i)-(ii)); the spouse has no Direct + # Loans and owes nothing under the plan. Capping after proration would + # give min(86.06, 111.02) = 86.06. + income_based_repayment_payment: [27.76, 0] - name: Case 22, a borrower's own FFEL loans reduce the Direct Loan payment. period: 2026-01 @@ -574,10 +581,11 @@ members: [person2] state_code: TX output: - # 685.209(g)(1)(ii): x 10,000 / 40,000 = 25%. Cap on all 40,000 of - # eligible loans: 444.08, of which the Direct Loans carry 25% = 111.02. - # person1: min(200.75 x 25%, 111.02) = 50.19. - # person2: min(950.75 x 25%, 111.02) = 111.02. + # 685.209(g)(1)(ii): x 10,000 / 40,000 = 25%. Standard payment on all + # 40,000 of eligible loans: 444.08. No spouse debt, so the order of cap + # and proration does not matter. + # person1: min(200.75, 444.08) x 25% = 50.19. + # person2: min(950.75, 444.08) x 25% = 111.02. income_based_repayment_payment: [50.19, 111.02] - name: Case 23, a Direct Loan balance under 10 dollars caps the 10 dollar minimum payment. @@ -634,8 +642,129 @@ output: # The spouse's Direct Loans stay eligible loans (685.209(b)(5)); (d)(5) # only stops the plan from repaying them. (60,000 - 1.5 x 20,000) / 12 = - # 2,500. Income-Based Repayment 15% = 375 and Pay As You Earn 10% = 250, - # each x 30,000 / 60,000 = 187.50 and 125, under the 333.06 standard - # payment; the spouse owes 0. - income_based_repayment_payment: [187.5, 0] + # 2,500. Income-Based Repayment: min(15% x 2,500 = 375, 333.0615) x + # 30,000 / 60,000 = 166.53. Pay As You Earn: min(10% x 2,500 = 250, + # 333.0615) x 50% = 125. The spouse owes 0. Capping after proration + # would give IBR min(187.50, 333.06) = 187.50. + income_based_repayment_payment: [166.53, 0] pay_as_you_earn_payment: [125, 0] + +- name: Case 25, joint filers at 6 percent pay each standard payment times their balance share. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.06 + person2: + age: 38 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + adjusted_gross_income: 80_460 + households: + household: + members: [person1, person2] + state_code: TX + output: + # 15% x (80,460 - 32,460) / 12 = 600. Each spouse's own standard payment + # is 30,000 x 0.005 / (1 - 1.005 ^ -120) = 333.0615. min(600, 333.0615) x + # 50% = 166.53 each. Capping after proration would give min(300, 333.06) + # = 300 each. + income_based_repayment_payment: [166.53, 166.53] + +- name: Case 26, a low income-based amount is prorated the same way under either order. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.06 + person2: + age: 38 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + adjusted_gross_income: 40_460 + households: + household: + members: [person1, person2] + state_code: TX + output: + # 15% x (40,460 - 32,460) / 12 = 100, below each 333.0615 standard + # payment. min(100, 333.0615) x 50% = 50 each. + income_based_repayment_payment: [50, 50] + +- name: Case 27, with 10 and 15 percent rates the cap binds on one spouse only. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.06 + is_income_based_repayment_new_borrower: true + person2: + age: 40 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + adjusted_gross_income: 68_460 + households: + household: + members: [person1, person2] + state_code: TX + output: + # Discretionary income (68,460 - 32,460) / 12 = 3,000. New borrower: + # min(10% x 3,000 = 300, 333.0615) x 50% = 150, under the cap. Other + # borrower: min(15% x 3,000 = 450, 333.0615) x 50% = 166.53, at the cap. + # Capping after proration would give 150 and min(225, 333.06) = 225. + income_based_repayment_payment: [150, 166.53] + +- name: Case 28, both spouses' FFEL loans enter the proration of each spouse's capped amount. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 10_000 + federal_ffel_loan_balance: 10_000 + federal_student_loan_interest_rate: 0.06 + person2: + age: 38 + federal_student_loan_balance: 20_000 + federal_ffel_loan_balance: 20_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + adjusted_gross_income: 80_460 + households: + household: + members: [person1, person2] + state_code: TX + output: + # 15% x (80,460 - 32,460) / 12 = 600. Own standard payments on Direct and + # FFEL loans: 20,000 -> 222.0410 and 40,000 -> 444.0820. Balance shares, + # (g)(1)(i) x (g)(1)(ii): 10,000 / 60,000 = 1/6 and 20,000 / 60,000 = 1/3. + # person1: min(600, 222.0410) / 6 = 37.0068 -> 37.01. + # person2: min(600, 444.0820) / 3 = 148.0273 -> 148.03. + # Capping after proration (cap x own Direct fraction of 50%) would give + # min(100, 111.02) = 100 and min(200, 222.04) = 200. + income_based_repayment_payment: [37.01, 148.03] diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.yaml index 2b2773a3e4a..f1f096904f5 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.yaml @@ -2,10 +2,10 @@ # discretionary income = max(AGI - 1.5 x poverty guideline, 0) # payment = lesser of # 10% x discretionary income / 12 -# x own Direct balance / couple's Direct and FFEL balance ((g)(1)(i)-(ii)) # and the borrower's own 10-year standard payment on their Direct and FFEL -# loans ((f)(2)(ii)) -# x own Direct balance / own Direct and FFEL balance ((g)(1)(ii)) +# loans ((f)(2)(ii)), +# times own Direct balance / couple's Direct and FFEL balance +# ((g)(1)(i)-(ii), applied to the lesser-of amount), # rounded to the cent; under 5 -> 0; 5 to under 10 -> 10; at most the # borrower's Direct balance. # 2026 guideline in the 48 states: 15,960 + 5,680 per additional person. @@ -79,10 +79,11 @@ members: [person1, person2] state_code: TX output: - # 1.5 x 21,640 = 32,460; 10% x 47,540 / 12 = 396.17. - # Shares 2/3 and 1/3: 264.11 and 132.06, below the caps of 444.08 and - # 222.04. - pay_as_you_earn_payment: [264.11, 132.06] + # 1.5 x 21,640 = 32,460; 10% x 47,540 / 12 = 396.17. Own standard + # payments 444.0820 and 222.0410. min(396.17, 444.08) x 2/3 = 264.11 and + # min(396.17, 222.0410) x 1/3 = 74.0137 -> 74.01. Capping after + # proration would give min(132.06, 222.04) = 132.06 for the spouse. + pay_as_you_earn_payment: [264.11, 74.01] - name: Case 4, each spouse's own standard payment caps joint filers who both borrowed. period: 2026-01 @@ -107,9 +108,12 @@ members: [person1, person2] state_code: TX output: - # 10% x (150,000 - 32,460) / 12 = 979.50; shares 80% and 20%: 783.60 and - # 195.90, above each spouse's own standard payment of 444.08 and 111.02. - pay_as_you_earn_payment: [444.08, 111.02] + # 10% x (150,000 - 32,460) / 12 = 979.50, above each spouse's own + # standard payment of 444.0820 and 111.0205. Shares 80% and 20%: + # 444.0820 x 80% = 355.2656 -> 355.27 and 111.0205 x 20% = 22.2041 -> + # 22.20. Capping after proration would give min(783.60, 444.08) = 444.08 + # and min(195.90, 111.02) = 111.02. + pay_as_you_earn_payment: [355.27, 22.20] - name: Case 5, a payment under 5 dollars becomes zero. period: 2026-01 @@ -256,10 +260,11 @@ state_code: TX output: # Issue #10411 example. 1.5 x 21,640 = 32,460; 10% x 72,000 / 12 = 600, - # split 50/50: 300 each. Standard payments: 30,000 / 120 = 250 and - # 30,000 x 0.01 / (1 - 1.01 ^ -120) = 430.41. min(300, 250) = 250 and - # min(300, 430.41) = 300. - pay_as_you_earn_payment: [250, 300] + # with balance shares of 50% each. Standard payments: 30,000 / 120 = 250 + # and 30,000 x 0.01 / (1 - 1.01 ^ -120) = 430.4128. min(600, 250) x 50% = + # 125 and min(600, 430.4128) x 50% = 215.2064 -> 215.21. Capping after + # proration would give 250 and 300. + pay_as_you_earn_payment: [125, 215.21] - name: Case 11, each spouse's own standard payment caps a high income. period: 2026-01 @@ -284,9 +289,10 @@ members: [person1, person2] state_code: TX output: - # 10% x (128,460 - 32,460) / 12 = 800, split 50/50: 400 each. - # min(400, 250) = 250 and min(400, 430.41) = 400. - pay_as_you_earn_payment: [250, 400] + # 10% x (128,460 - 32,460) / 12 = 800, above both standard payments. + # min(800, 250) x 50% = 125 and min(800, 430.4128) x 50% = 215.21. + # Capping after proration would give 250 and 400. + pay_as_you_earn_payment: [125, 215.21] - name: Case 12, a spouse's FFEL debt counts in the joint proration. period: 2026-01 @@ -312,10 +318,12 @@ members: [person1, person2] state_code: TX output: - # 10% x (61,000 - 32,460) / 12 = 237.83. The borrower pays - # 237.83 x 10,000 / 40,000 = 59.46 (685.209(g)(1)(i)-(ii)), under their own - # standard payment of 111.02; the spouse owes nothing. - pay_as_you_earn_payment: [59.46, 0] + # 10% x (61,000 - 32,460) / 12 = 237.83, above the borrower's own + # standard payment of 111.0205. The borrower pays min(237.83, 111.0205) x + # 10,000 / 40,000 = 27.7551 -> 27.76 (685.209(f)(2), then + # (g)(1)(i)-(ii)); the spouse owes nothing. Capping after proration would + # give min(59.46, 111.02) = 59.46. + pay_as_you_earn_payment: [27.76, 0] - name: Case 13, a payment within half a cent below 5 dollars rounds to 5 and rises to 10. period: 2026-01 @@ -339,3 +347,33 @@ # 24,539.52 - 23,940 = 599.52; 10% x 599.52 / 12 = 4.996, which rounds to # 5.00 and rises to 10 (the modeling choice in the documentation). pay_as_you_earn_payment: 10 + +- name: Case 14, both spouses' FFEL loans enter the proration of each spouse's capped amount. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 10_000 + federal_ffel_loan_balance: 10_000 + federal_student_loan_interest_rate: 0.06 + person2: + age: 38 + federal_student_loan_balance: 20_000 + federal_ffel_loan_balance: 20_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + adjusted_gross_income: 104_460 + households: + household: + members: [person1, person2] + state_code: TX + output: + # 10% x (104,460 - 32,460) / 12 = 600. Own standard payments 222.0410 and + # 444.0820; balance shares 1/6 and 1/3 ((g)(1)(i) x (g)(1)(ii)). + # min(600, 222.0410) / 6 = 37.01 and min(600, 444.0820) / 3 = 148.03. + pay_as_you_earn_payment: [37.01, 148.03] diff --git a/policyengine_us/tests/test_student_loan_idr_cap_order_property.py b/policyengine_us/tests/test_student_loan_idr_cap_order_property.py new file mode 100644 index 00000000000..5e6b35eaab4 --- /dev/null +++ b/policyengine_us/tests/test_student_loan_idr_cap_order_property.py @@ -0,0 +1,278 @@ +"""Hypothesis checks: the IBR and PAYE 10-year cap applies before proration. + +34 CFR 685.209(f)(2) and (f)(3) make an Income-Based Repayment or Pay As You +Earn payment the lesser of the income-based amount A = rate x discretionary +income and the borrower's own 10-year standard payment S on their Direct and +FFEL loans. 685.209(g)(1) then multiplies that lesser amount by the balance +share: own Direct / couple's (Direct + FFEL). So, with own Direct d, own FFEL +f and the couple's Direct and FFEL total T: + + P = min(A, S) x d / T + +Properties, for generated balances, interest rates, new-borrower status and +discretionary income: + +1. Differential: with no spouse debt, P equals min(A x d / (d + f), + S x d / (d + f)), written here independently of the model. +2. Identity: P equals min(A, S) x d / T, and a borrower whose spouse has no + eligible debt pays what a single filer pays at the same income. +3. Bound: P <= S x d / (d + f) and P <= A x d / T. +4. Monotonicity: P never falls as income rises, and never rises as the + spouse's balance rises. + +The model's payment also rounds to the cent, applies the small-payment rule +of 685.209(g)(1)(iii) and caps the payment at the Direct Loan balance. That +post-processing is a nondecreasing function of P, so each property is +checked on the model's payment against references passed through the same +post-processing: equalities stay equalities, and bounds and monotonicity +carry through. + +Every generated couple is simulated in five variants (as drawn, with more +income, with a larger spouse balance, with no spouse debt, and as a single +filer) in one vectorized simulation per example. +""" + +import numpy as np +import pytest + +from policyengine_us import Simulation + +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") + +MONTH = "2026-01" +YEAR = 2026 +# Written from the law, not read from the parameter tree. +IBR_RATE = 0.15 # 20 U.S.C. 1098e(b)(1); 685.209(f)(3)(i) +IBR_NEW_BORROWER_RATE = 0.10 # 20 U.S.C. 1098e(e); 685.209(f)(2)(i) +PAYE_RATE = 0.10 # 685.209(f)(2)(i) +STANDARD_MONTHS = 120 # 685.208(b)(1)(i) +STANDARD_MINIMUM = 50 # 685.208(b)(1)(ii) +SMALL_THRESHOLD = 5 # 685.209(g)(1)(iii)(A) +SMALL_MINIMUM = 10 # 685.209(g)(1)(iii)(B) +TOLERANCE = 0.011 +VARIANTS = ("base", "more_income", "larger_spouse_balance", "no_spouse_debt") + + +def standard_payment(balance, annual_rate): + """10-year level payment on the balance, at least 50, at most the balance.""" + monthly_rate = annual_rate / 12 + if monthly_rate == 0: + amortized = balance / STANDARD_MONTHS + else: + amortized = ( + balance * monthly_rate / (1 - (1 + monthly_rate) ** -STANDARD_MONTHS) + ) + return min(max(amortized, STANDARD_MINIMUM), balance) + + +def post_process(amount, direct_balance): + """Round to the cent, apply the small-payment rule, cap at the balance.""" + rounded = round(amount, 2) + if rounded < SMALL_THRESHOLD: + adjusted = 0 + else: + adjusted = max(rounded, SMALL_MINIMUM) + return min(adjusted, direct_balance) + + +def near_threshold(amount): + # A reference within a cent of the 5 dollar threshold can round to the + # other side of it than the model's floating-point result. + return abs(amount - SMALL_THRESHOLD) < 0.01 + + +borrower = st.fixed_dictionaries( + { + "direct": st.integers(1, 150_000), + "ffel": st.one_of(st.just(0), st.integers(1, 100_000)), + "rate": st.sampled_from([0, 0.03, 0.055, 0.068, 0.12]), + "new_borrower": st.booleans(), + } +) +spouse = st.fixed_dictionaries( + { + "direct": st.one_of(st.just(0), st.integers(1, 150_000)), + "ffel": st.one_of(st.just(0), st.integers(1, 100_000)), + "rate": st.sampled_from([0, 0.03, 0.055, 0.068, 0.12]), + "new_borrower": st.booleans(), + } +) +couple = st.fixed_dictionaries( + { + "borrower": borrower, + "spouse": spouse, + # Monthly discretionary income, often enough to reach the cap. + "income": st.one_of(st.integers(0, 2_000), st.integers(0, 15_000)), + "income_rise": st.integers(1, 5_000), + "spouse_direct_rise": st.integers(0, 50_000), + "spouse_ffel_rise": st.integers(0, 50_000), + } +) + + +def person_input(name, loans): + return name, { + "age": {YEAR: 40}, + "federal_student_loan_balance": {YEAR: loans["direct"]}, + "federal_ffel_loan_balance": {YEAR: loans["ffel"]}, + "federal_student_loan_interest_rate": {YEAR: loans["rate"]}, + "is_income_based_repayment_new_borrower": {YEAR: loans["new_borrower"]}, + } + + +def build(couples): + """One situation holding every variant of every couple, in a fixed order.""" + people, tax_units, households, marital_units = {}, {}, {}, {} + order = [] + + def add_unit(key, members, income, filing_status): + names = [] + for i, loans in enumerate(members): + name, values = person_input(f"{key}_p{i}", loans) + people[name] = values + names.append(name) + order.append((key, i)) + tax_units[key] = { + "members": names, + "filing_status": {YEAR: filing_status}, + "income_based_repayment_discretionary_income": {MONTH: income}, + "pay_as_you_earn_discretionary_income": {MONTH: income}, + } + households[key] = {"members": names, "state_code": {YEAR: "TX"}} + marital_units[key] = {"members": names} + + for n, c in enumerate(couples): + b, s = c["borrower"], c["spouse"] + add_unit(f"c{n}_base", [b, s], c["income"], "JOINT") + add_unit(f"c{n}_more_income", [b, s], c["income"] + c["income_rise"], "JOINT") + larger = dict( + s, + direct=s["direct"] + c["spouse_direct_rise"], + ffel=s["ffel"] + c["spouse_ffel_rise"], + ) + add_unit(f"c{n}_larger_spouse_balance", [b, larger], c["income"], "JOINT") + add_unit( + f"c{n}_no_spouse_debt", + [b, dict(s, direct=0, ffel=0)], + c["income"], + "JOINT", + ) + add_unit(f"c{n}_single", [b], c["income"], "SINGLE") + situation = { + "people": people, + "tax_units": tax_units, + "households": households, + "marital_units": marital_units, + "spm_units": {k: {"members": v["members"]} for k, v in households.items()}, + "families": {k: {"members": v["members"]} for k, v in households.items()}, + } + simulation = Simulation(situation=situation) + results = {} + for variable in ("income_based_repayment_payment", "pay_as_you_earn_payment"): + values = simulation.calculate(variable, MONTH) + results[variable] = {key: float(v) for key, v in zip(order, values)} + return results + + +def plan_rate(variable, loans): + if variable == "pay_as_you_earn_payment": + return PAYE_RATE + return IBR_NEW_BORROWER_RATE if loans["new_borrower"] else IBR_RATE + + +def check(couples): + results = build(couples) + problems = [] + for n, c in enumerate(couples): + b, s = c["borrower"], c["spouse"] + for variable, paid in results.items(): + + def p(variant, i=0): + return paid[(f"c{n}_{variant}", i)] + + for i, (own, other) in enumerate(((b, s), (s, b))): + if own["direct"] == 0: + if p("base", i) != 0: + problems.append(f"{variable} c{n}: no Direct Loans but pays") + continue + d, f = own["direct"], own["ffel"] + couple_total = d + f + other["direct"] + other["ffel"] + a = plan_rate(variable, own) * c["income"] + cap = standard_payment(d + f, own["rate"]) + share = d / couple_total + # 2. Identity. + reference = min(a, cap) * share + if not near_threshold(reference) and ( + abs(p("base", i) - post_process(reference, d)) > TOLERANCE + ): + problems.append( + f"{variable} c{n} spouse {i}: identity {p('base', i)} vs " + f"{post_process(reference, d)}" + ) + # 3. Bound. + own_fraction = d / (d + f) + for bound, label in ( + (cap * own_fraction, "standard payment x own Direct fraction"), + (a * share, "income-based amount x balance share"), + ): + if p("base", i) > post_process(bound, d) + TOLERANCE: + problems.append( + f"{variable} c{n} spouse {i}: {p('base', i)} above {label}" + ) + # 4. Monotonicity in income. + if p("more_income", i) < p("base", i) - TOLERANCE: + problems.append(f"{variable} c{n} spouse {i}: falls with income") + # The borrower (spouse 0) always has Direct Loans. + d, f = b["direct"], b["ffel"] + a = plan_rate(variable, b) * c["income"] + cap = standard_payment(d + f, b["rate"]) + fraction = d / (d + f) + # 1. Differential: main's order, which agrees with no spouse debt. + differential = min(a * fraction, cap * fraction) + if not near_threshold(differential): + for variant in ("no_spouse_debt", "single"): + if abs(p(variant) - post_process(differential, d)) > TOLERANCE: + problems.append( + f"{variable} c{n} {variant}: {p(variant)} vs " + f"{post_process(differential, d)}" + ) + # 2. Identity: no spouse debt pays what a single filer pays. + if abs(p("no_spouse_debt") - p("single")) > TOLERANCE: + problems.append(f"{variable} c{n}: joint with no spouse debt != single") + # 4. Monotonicity in the spouse's balance. + if p("larger_spouse_balance") > p("base") + TOLERANCE: + problems.append(f"{variable} c{n}: rises with the spouse's balance") + assert not problems, "\n".join(problems) + + +@hypothesis.settings( + max_examples=10, + deadline=None, + suppress_health_check=[ + hypothesis.HealthCheck.too_slow, + hypothesis.HealthCheck.data_too_large, + ], +) +@hypothesis.given(st.lists(couple, min_size=1, max_size=8)) +def test_cap_before_proration(couples): + check(couples) + + +def test_cap_before_proration_hand_case(): + # $30,000 each at 0% and 12%, $600 income-based amount: standard payments + # 250 and 430.41, so each spouse pays half: 125 and 215.21. + loans = {"ffel": 0, "new_borrower": False} + c = { + "borrower": dict(loans, direct=30_000, rate=0), + "spouse": dict(loans, direct=30_000, rate=0.12), + "income": 4_000, + "income_rise": 1, + "spouse_direct_rise": 0, + "spouse_ffel_rise": 0, + } + results = build([c]) + paid = results["income_based_repayment_payment"] + assert paid[("c0_base", 0)] == pytest.approx(125, abs=0.01) + assert paid[("c0_base", 1)] == pytest.approx(215.21, abs=0.01) + check([c]) diff --git a/policyengine_us/variables/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.py b/policyengine_us/variables/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.py index e8ab75d21b1..e17ddae6cd0 100644 --- a/policyengine_us/variables/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.py +++ b/policyengine_us/variables/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.py @@ -8,25 +8,29 @@ class income_based_repayment_payment(Variable): documentation = ( "Monthly payment owed by a borrower under the Income-Based Repayment " "plan, assuming the borrower is enrolled in the plan and in active " - "repayment, not in deferment, forbearance or default. The borrower's " - "plan rate times discretionary income is prorated to their Direct " - "Loans (income_driven_repayment_balance_share, which for joint filers " - "divides by both spouses' Direct and FFEL loans) and capped at the " - "Direct Loan part of the borrower's own 10-year standard payment " - "(income_driven_repayment_standard_payment on their Direct and FFEL " - "loans, times their Direct share of those loans). The result is " - "rounded to the cent, given the small payment adjustment and capped " - "at the borrower's Direct Loan balance. Spouses keep their own rates, " - "so a new borrower pays 10 percent and the other spouse 15 percent of " - "the same discretionary income before proration, and each spouse's " - "cap covers only their own loans. The cap uses the current balance, " - "not the balance when the borrower entered the plan. The prorated " - "payment is rounded to the cent before the small payment adjustment, " - "a modeling choice: a calculated payment just under $5, such as " - "$4.996, rounds to $5.00 and becomes $10 rather than $0 under 34 CFR " - "685.209(g)(1)(iii), which sets a payment of less than $5 to $0. A " - "borrower with a Direct Loan made on or after July 1, 2026 cannot use " - "the plan and owes nothing under it." + "repayment, not in deferment, forbearance or default. The payment is " + "the lesser of the borrower's plan rate times discretionary income " + "and the borrower's own 10-year standard payment " + "(income_driven_repayment_standard_payment, on their own Direct and " + "FFEL loans), under 34 CFR 685.209(f)(2) and (f)(3). That lesser " + "amount is then prorated to the borrower's Direct Loans under " + "685.209(g)(1) (income_driven_repayment_balance_share, which for " + "joint filers divides by both spouses' Direct and FFEL loans). The " + "result is rounded to the cent, given the small payment adjustment " + "and capped at the borrower's Direct Loan balance. Spouses keep their " + "own rates, so a new borrower pays 10 percent and the other spouse 15 " + "percent of the same discretionary income, and each spouse's " + "standard payment covers only their own loans. Because the proration " + "applies after the cap, a joint filer whose income-based amount " + "exceeds their standard payment pays that standard payment times " + "their balance share. The cap uses the current balance, not the " + "balance when the borrower entered the plan. The payment is rounded " + "to the cent before the small payment adjustment, a modeling choice: " + "a calculated payment just under $5, such as $4.996, rounds to $5.00 " + "and becomes $10 rather than $0 under 34 CFR 685.209(g)(1)(iii), " + "which sets a payment of less than $5 to $0. A borrower with a Direct " + "Loan made on or after July 1, 2026 cannot use the plan and owes " + "nothing under it." ) unit = USD definition_period = MONTH @@ -53,24 +57,16 @@ def formula(person, period, parameters): discretionary_income = person.tax_unit( "income_based_repayment_discretionary_income", period ) - # 685.209(g)(1)(i)-(ii) prorate the income-based amount to the - # borrower's Direct Loans. 20 U.S.C. 1098e(b)(6)(A) and 685.209(f)(2)(ii), - # (f)(3)(ii) cap the payment on all of the borrower's eligible loans at - # their own 10-year standard payment; the Direct Loans carry the - # (g)(1)(ii) part of that cap. + # 20 U.S.C. 1098e(b)(6)(A) and 685.209(f)(2)(ii), (f)(3)(ii) make the + # payment the lesser of the income-based amount and the borrower's own + # 10-year standard payment on their eligible loans. 685.209(g)(1) + # adjusts the payment amounts calculated under (f)(1) through (3): + # (g)(1)(i)(B) and (g)(1)(ii) multiply that lesser amount by the + # balance shares, so the proration follows the cap. balance_share = person( "income_driven_repayment_balance_share", period.this_year ) balance = person("federal_student_loan_balance", period.this_year) - eligible_balance = balance + person( - "federal_ffel_loan_balance", period.this_year - ) - direct_fraction = np.divide( - balance, - eligible_balance, - out=np.zeros_like(balance), - where=eligible_balance > 0, - ) standard_payment = person("income_driven_repayment_standard_payment", period) # Payments are set in whole cents. Rounding before the small payment # test keeps an exact $5.00 from falling below the threshold through @@ -78,10 +74,7 @@ def formula(person, period, parameters): # within half a cent below $5 to $5.00, a modeling choice described in # the documentation. prorated_payment = np.round( - min_( - rate * discretionary_income * balance_share, - standard_payment * direct_fraction, - ), + min_(rate * discretionary_income, standard_payment) * balance_share, 2, ) small_payment = p.income_driven_repayment.small_payment diff --git a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_balance_share.py b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_balance_share.py index c199aa4deaf..05ddc5cc6c8 100644 --- a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_balance_share.py +++ b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_balance_share.py @@ -17,7 +17,10 @@ class income_driven_repayment_balance_share(Variable): "plans do not repay FFEL loans. The product is the person's Direct " "Loan balance divided by the couple's combined Direct and FFEL " "balance. Married people filing separately are in separate tax units, " - "so only their own loans count." + "so only their own loans count. Under the Income-Based Repayment and " + "Pay As You Earn plans the share multiplies the lesser of the " + "income-based amount and the borrower's own 10-year standard payment, " + "since (g)(1) adjusts the amount calculated under (f)(2) and (f)(3)." ) unit = "/1" definition_period = YEAR diff --git a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.py b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.py index 83d2358d965..984cf202c34 100644 --- a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.py +++ b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.py @@ -10,8 +10,8 @@ class income_driven_repayment_standard_payment(Variable): label = "Income-driven repayment 10-year standard payment cap" documentation = ( "What the borrower would pay on the 10-year standard repayment plan " - "on their own eligible loans, the most the borrower pays under the " - "Income-Based Repayment and Pay As You Earn plans (20 U.S.C. " + "on their own eligible loans, the cap on the income-based amount under " + "the Income-Based Repayment and Pay As You Earn plans (20 U.S.C. " "1098e(b)(6)(A): 'for all loans made to the borrower under part B or " "D'). Eligible loans are Direct and FFEL loans other than parent PLUS " "loans and excepted consolidation loans, so this is the 10-year " @@ -20,8 +20,14 @@ class income_driven_repayment_standard_payment(Variable): "One standard plan minimum covers the Direct and FFEL loans together, " "and the payment is no more than the eligible balance. A joint filer's " "spouse's loans do not enter it: 34 CFR 685.209(e)(2)(i) counts them " - "only in the (g) proration. The cap uses the current balance, not the " - "balance when the borrower began paying under the plan." + "only in the (g) proration. The plans take the lesser of the " + "income-based amount and this payment (685.209(f)(2), (f)(3)) and " + "then prorate that lesser amount by " + "income_driven_repayment_balance_share (685.209(g)(1)), so a joint " + "filer whose income-based amount exceeds this payment pays this " + "payment times their balance share. The cap uses the current " + "balance, not the balance when the borrower began paying under the " + "plan." ) unit = USD definition_period = MONTH diff --git a/policyengine_us/variables/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.py b/policyengine_us/variables/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.py index 902f2100ca6..7142081e6a4 100644 --- a/policyengine_us/variables/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.py +++ b/policyengine_us/variables/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.py @@ -8,27 +8,31 @@ class pay_as_you_earn_payment(Variable): documentation = ( "Monthly payment owed by a borrower under the Pay As You Earn plan, " "assuming the borrower is enrolled in the plan and in active " - "repayment, not in deferment, forbearance or default. The plan rate " - "times discretionary income is prorated to the borrower's Direct " - "Loans (income_driven_repayment_balance_share, which for joint filers " - "divides by both spouses' Direct and FFEL loans) and capped at the " - "Direct Loan part of the borrower's own 10-year standard payment " - "(income_driven_repayment_standard_payment on their Direct and FFEL " - "loans at their own interest rate, times their Direct share of those " - "loans). The result is rounded to the cent, given the small payment " + "repayment, not in deferment, forbearance or default. The payment is " + "the lesser of the plan rate times discretionary income and the " + "borrower's own 10-year standard payment " + "(income_driven_repayment_standard_payment, on their own Direct and " + "FFEL loans at their own interest rate), under 34 CFR 685.209(f)(2). " + "That lesser amount is then prorated to the borrower's Direct Loans " + "under 685.209(g)(1) (income_driven_repayment_balance_share, which " + "for joint filers divides by both spouses' Direct and FFEL loans). " + "The result is rounded to the cent, given the small payment " "adjustment and capped at the borrower's Direct Loan balance. Each " - "spouse's cap covers only their own loans. As with the Income-Based " - "Repayment plan, the cap uses the current balance, not the balance " - "when the borrower entered the plan. A borrower with a Direct Loan " - "made on or after July 1, 2026 cannot use the plan and owes nothing " - "under it. The other limits on who can repay under the plan are not " - "modeled: being a new borrower under 34 CFR 685.209(b)(13)(i), " - "repaying under the plan on July 1, 2024, and the plan's end on June " - "30, 2028 (685.209(c)(4)). The prorated payment is rounded to the " - "cent before the small payment adjustment, a modeling choice: a " - "calculated payment just under $5, such as $4.996, rounds to $5.00 " - "and becomes $10 rather than $0 under 685.209(g)(1)(iii), which sets " - "a payment of less than $5 to $0." + "spouse's standard payment covers only their own loans. Because the " + "proration applies after the cap, a joint filer whose income-based " + "amount exceeds their standard payment pays that standard payment " + "times their balance share. As with the Income-Based Repayment plan, " + "the cap uses the current balance, not the balance when the borrower " + "entered the plan. A borrower with a Direct Loan made on or after " + "July 1, 2026 cannot use the plan and owes nothing under it. The " + "other limits on who can repay under the plan are not modeled: being " + "a new borrower under 34 CFR 685.209(b)(13)(i), repaying under the " + "plan on July 1, 2024, and the plan's end on June 30, 2028 " + "(685.209(c)(4)). The payment is rounded to the cent before the small " + "payment adjustment, a modeling choice: a calculated payment just " + "under $5, such as $4.996, rounds to $5.00 and becomes $10 rather " + "than $0 under 685.209(g)(1)(iii), which sets a payment of less than " + "$5 to $0." ) unit = USD definition_period = MONTH @@ -48,24 +52,17 @@ def formula(person, period, parameters): discretionary_income = person.tax_unit( "pay_as_you_earn_discretionary_income", period ) - # 685.209(g)(1)(i)-(ii) prorate the income-based amount to the - # borrower's Direct Loans. 685.209(f)(2)(ii), as in 20 U.S.C. - # 1098e(b)(6)(A) for the Income-Based Repayment plan, caps the payment - # on all of the borrower's eligible loans at their own 10-year standard - # payment; the Direct Loans carry the (g)(1)(ii) part of that cap. + # 685.209(f)(2)(ii), as 20 U.S.C. 1098e(b)(6)(A) does for the + # Income-Based Repayment plan, makes the payment the lesser of the + # income-based amount and the borrower's own 10-year standard payment + # on their eligible loans. 685.209(g)(1) adjusts the payment amounts + # calculated under (f)(1) through (3): (g)(1)(i)(B) and (g)(1)(ii) + # multiply that lesser amount by the balance shares, so the proration + # follows the cap. balance_share = person( "income_driven_repayment_balance_share", period.this_year ) balance = person("federal_student_loan_balance", period.this_year) - eligible_balance = balance + person( - "federal_ffel_loan_balance", period.this_year - ) - direct_fraction = np.divide( - balance, - eligible_balance, - out=np.zeros_like(balance), - where=eligible_balance > 0, - ) standard_payment = person("income_driven_repayment_standard_payment", period) # Payments are set in whole cents. Rounding before the small payment # test keeps an exact $5.00 from falling below the threshold through @@ -73,9 +70,10 @@ def formula(person, period, parameters): # below $5 to $5.00, a modeling choice described in the documentation. prorated_payment = np.round( min_( - p.pay_as_you_earn.rate * discretionary_income * balance_share, - standard_payment * direct_fraction, - ), + p.pay_as_you_earn.rate * discretionary_income, + standard_payment, + ) + * balance_share, 2, ) small_payment = p.income_driven_repayment.small_payment From faa27ad9c1ff567b26c6447364f44730d9d2e328 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 19:09:09 +0000 Subject: [PATCH 2/4] Add changelog fragment for the IBR and PAYE cap order Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JaKXtKF9JBv4wLzq6 --- changelog.d/idr-cap-before-proration.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/idr-cap-before-proration.fixed.md diff --git a/changelog.d/idr-cap-before-proration.fixed.md b/changelog.d/idr-cap-before-proration.fixed.md new file mode 100644 index 00000000000..be92e4d1d2e --- /dev/null +++ b/changelog.d/idr-cap-before-proration.fixed.md @@ -0,0 +1 @@ +Apply the Income-Based Repayment and Pay As You Earn 10-year standard payment cap before the spousal balance proration, as 34 CFR 685.209(f)(2)-(3) and (g)(1) order them. From f7b65447afdefcb02190c9bc025cb231276275f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:34:02 +0000 Subject: [PATCH 3/4] Register the IBR and PAYE cap-before-proration ruling and bound its property test Record us:idr-10-year-cap-before-spousal-proration in the source-conflict backlog: the regulation's order (cap under 685.209(f)(2)-(3), then the (g)(1) proration) against capping after proration, under 20 U.S.C. 1098e(b)(6)(A)'s maximum. Skip Hypothesis shrinking in the property test, since each example is a simulation and shrinking a failure ran for more than ten minutes. Co-Authored-By: Claude Opus 5.5 --- ...est_student_loan_idr_cap_order_property.py | 7 ++++ source_conflicts/candidates.yaml | 37 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/policyengine_us/tests/test_student_loan_idr_cap_order_property.py b/policyengine_us/tests/test_student_loan_idr_cap_order_property.py index 5e6b35eaab4..649af88f3a2 100644 --- a/policyengine_us/tests/test_student_loan_idr_cap_order_property.py +++ b/policyengine_us/tests/test_student_loan_idr_cap_order_property.py @@ -249,6 +249,13 @@ def p(variant, i=0): @hypothesis.settings( max_examples=10, deadline=None, + # Each example is a simulation of up to 40 people. Shrinking a failure + # would run hundreds more, so a failing example is reported as drawn. + phases=[ + hypothesis.Phase.explicit, + hypothesis.Phase.reuse, + hypothesis.Phase.generate, + ], suppress_health_check=[ hypothesis.HealthCheck.too_slow, hypothesis.HealthCheck.data_too_large, diff --git a/source_conflicts/candidates.yaml b/source_conflicts/candidates.yaml index 1cea80fc061..91d499f1b04 100644 --- a/source_conflicts/candidates.yaml +++ b/source_conflicts/candidates.yaml @@ -10398,6 +10398,43 @@ candidates: limit/self_only.yaml, limit/family.yaml). The statute-first rule requires changing conflicting projections; verify the statutory window substitution first, and preserve the distinction from published limits fixed through 2027.' +- id: us:idr-10-year-cap-before-spousal-proration + program: student-loans + years: + from: 2026 + type: calendar + question: For a married borrower who files jointly and whose spouse has eligible loans, is the Income-Based Repayment + or Pay As You Earn payment capped at the borrower's 10-year standard payment before or after it is prorated by + the borrower's share of the couple's loans? + sources: + - kind: statute + citation: 20 U.S.C. 1098e(b)(6)(A) + url: https://www.law.cornell.edu/uscode/text/20/1098e#b_6_A + - kind: regulation + citation: 34 CFR 685.209(e)(2)(i), (f)(2), (f)(3) and (g)(1) + url: https://www.ecfr.gov/current/title-34/section-685.209 + readings: + statute: Section 1098e(b)(6)(A) sets a maximum, the 10-year standard payment on all the borrower's loans, and says + nothing of proration between spouses. + other: 'Regulation order: (f)(2) and (f)(3) make the payment the lesser of the income-based amount and the 10-year + standard payment, and (g)(1) multiplies "the borrower''s payment amount as calculated in accordance with paragraphs + (f)(1) through (3)" by the balance share, so the share applies to the capped amount. The other reading prorates + the income-based amount first and caps each spouse''s share at their own standard payment.' + example: Spouses with $30,000 each at 0% and 12% and a $600 income-based amount have standard payments of $250.00 + and $430.41. In the regulation's order each pays half of min($600, own standard payment), $125.00 and $215.21; + capping after proration gives min($300, $250) = $250 and min($300, $430.41) = $300. + pe_us: + follows: gap-fill + code: + - policyengine_us/variables/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.py + - policyengine_us/variables/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.py + prs: + - https://github.com/PolicyEngine/policyengine-us/pull/10570 + state: ruled + note: The model follows the regulation's order, which stays under the statute's maximum. Each borrower's standard + payment covers only their own Direct and FFEL loans; (e)(2)(i) brings the spouse's loans in only for (g). A couple + whose income-based amount exceeds both standard payments pays each standard payment times the balance share, + so at equal balances and rates half their combined standard amount. - id: us:mortgage-interest-limit-ratio-rounding program: income-tax years: From 36b214cf4ffdeec6078e37de6d77646bed2e8349 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 19:16:47 -0500 Subject: [PATCH 4/4] Give the IBR and PAYE notes the new order The metadata notes still read as proration first, then the cap. The candidate's note also treated the statute's maximum as covering PAYE, which 20 U.S.C. 1098e doesn't reach. Co-Authored-By: Claude Opus 5.5 --- policyengine_us/programs.yaml | 4 ++-- source_conflicts/candidates.yaml | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/policyengine_us/programs.yaml b/policyengine_us/programs.yaml index c1bc9f0b631..4cb94f1a03b 100644 --- a/policyengine_us/programs.yaml +++ b/policyengine_us/programs.yaml @@ -1790,7 +1790,7 @@ programs: variable: income_based_repayment_payment parameter_prefix: gov.ed.student_loans.income_based_repayment verified_start_year: 2026 - notes: Monthly payment (15% or 10% for new borrowers of income above 150% of the poverty guideline on the latest return's AGI, spousal and FFEL proration, cap at the borrower's own 10-year standard payment on their current Direct and FFEL balances, small payment adjustment); zero for borrowers with a Direct Loan made on or after July 1, 2026; tax-dependent borrowers treated as ineligible; interest and forgiveness not modeled; not subtracted from household net income + notes: Monthly payment (15% or 10% for new borrowers of income above 150% of the poverty guideline on the latest return's AGI, cap at the borrower's own 10-year standard payment on their current Direct and FFEL balances, then spousal and FFEL proration, small payment adjustment); zero for borrowers with a Direct Loan made on or after July 1, 2026; tax-dependent borrowers treated as ineligible; interest and forgiveness not modeled; not subtracted from household net income - id: save_plan name: SAVE plan (counterfactual) @@ -1814,7 +1814,7 @@ programs: variable: pay_as_you_earn_payment parameter_prefix: gov.ed.student_loans.pay_as_you_earn verified_start_year: 2026 - notes: Monthly payment (10% of income above 150% of the poverty guideline on the latest return's AGI, spousal and FFEL proration, cap at the borrower's own 10-year standard payment on their current Direct and FFEL balances, small payment adjustment); tax-dependent borrowers treated as ineligible; zero for borrowers with a Direct Loan made on or after July 1, 2026; enrollment limits (new borrower, repaying under PAYE on July 1, 2024) and the June 30, 2028 end not modeled; interest and forgiveness not modeled; not subtracted from household net income + notes: Monthly payment (10% of income above 150% of the poverty guideline on the latest return's AGI, cap at the borrower's own 10-year standard payment on their current Direct and FFEL balances, then spousal and FFEL proration, small payment adjustment); tax-dependent borrowers treated as ineligible; zero for borrowers with a Direct Loan made on or after July 1, 2026; enrollment limits (new borrower, repaying under PAYE on July 1, 2024) and the June 30, 2028 end not modeled; interest and forgiveness not modeled; not subtracted from household net income - id: income_contingent_repayment name: Income-Contingent Repayment diff --git a/source_conflicts/candidates.yaml b/source_conflicts/candidates.yaml index 91d499f1b04..1e67cb1559e 100644 --- a/source_conflicts/candidates.yaml +++ b/source_conflicts/candidates.yaml @@ -10431,7 +10431,8 @@ candidates: prs: - https://github.com/PolicyEngine/policyengine-us/pull/10570 state: ruled - note: The model follows the regulation's order, which stays under the statute's maximum. Each borrower's standard + note: The model follows the regulation's order for both plans. For Income-Based Repayment it stays under the + statute's maximum; Pay As You Earn has no statutory cap. Each borrower's standard payment covers only their own Direct and FFEL loans; (e)(2)(i) brings the spouse's loans in only for (g). A couple whose income-based amount exceeds both standard payments pays each standard payment times the balance share, so at equal balances and rates half their combined standard amount.