Skip to content

Apply the 10-year cap before the spousal proration in IBR and PAYE - #10602

Closed
DTrim99 wants to merge 1 commit into
mainfrom
student-loan-cap-before-proration
Closed

DTrim99 wants to merge 1 commit into
mainfrom
student-loan-cap-before-proration

Conversation

@DTrim99

@DTrim99 DTrim99 commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

Part of #10411 (item 4)

Summary

The Income-Based Repayment (IBR) and Pay As You Earn (PAYE) payments now apply each borrower's 10-year standard payment cap before the spousal proration, as Max ruled on #10411 on 2026-10-31 (comment).

The rule

  • 34 CFR 685.209(f)(2) and (f)(3) make each borrower's payment the lesser of the income-based amount and that borrower's own 10-year standard amount.
  • (g)(1)(i)(B) then multiplies that amount by the borrower's share of the couple's eligible loans, and (g)(1)(ii) by the Direct Loan share of the borrower's own eligible loans.
  • (e)(2)(i) brings the spouse's loans into the (g) proration only, so each cap stays on the borrower's own loans.

For each borrower:

P_i = round(min(rate_i × DI, S_i) × balance_share_i, 2)

The (g)(1)(iii) small-payment rules and the Direct Loan balance cap then apply as before.

  • S_i is income_driven_repayment_standard_payment, the borrower's own 10-year payment on their Direct and FFEL loans, as Count FFEL debt in student loan proration and caps, use the latest return's AGI, and add the parent PLUS standard payment #10415 left it.
  • balance_share_i is income_driven_repayment_balance_share: own Direct balance divided by the couple's combined Direct and FFEL balance. It already includes the (g)(1)(i) × (g)(1)(ii) product.
  • Before this PR, main prorated first and then capped: min(rate × DI × balance_share, S_i × direct_fraction). The separate direct_fraction (own Direct / own Direct and FFEL) is gone from both formulas. It was a local variable with no other callers, and with the cap first, balance_share already carries the (g)(1)(ii) share.
  • Single filers, and joint filers whose spouse has no eligible loans, are unchanged. For them balance_share equals the old direct_fraction, and min(a·c, b·c) = min(a, b)·c for c > 0.

Before and after (Max's table, $30,000 each)

Interest rates Income-based amount Before (total) After (total)
0% and 12% $600 $550.00 (250 + 300) $340.21 (125.00 + 215.21)
0% and 12% $800 $650.00 (250 + 400) $340.21 (125.00 + 215.21)
6% and 6% $600 $600.00 (300 + 300) $333.06 (166.53 each)
6% and 6% $800 $666.12 (333.06 each) $333.06 (166.53 each)

Standard payments: 30,000 / 120 = 250.00 at 0%, 30,000 × 0.01 / (1 − 1.01^−120) = 430.41 at 12%, and 333.06 at 6%. All four rows are YAML cases in both the IBR and PAYE files.

Changed cases

Every couple case whose expected value changes, with the new arithmetic (2026 guideline: 1.5 × 21,640 = 32,460 for two people):

income_based_repayment_payment.yaml

Case Old New New arithmetic
8: 60,000 and 20,000 at 6%, AGI 80,000 [445.69, 148.56] [445.69, 55.51] 15% × 47,540 / 12 = 594.25; min(594.25, 666.12) × 75% = 445.69; min(594.25, 222.04) × 25% = 55.51
18: 0% and 12%, $600 (ruling row 1) [250, 300] [125, 215.21] min(600, 250) × 50%; min(600, 430.41) × 50%
19: 0% and 12%, $800 (ruling row 2) [250, 400] [125, 215.21] min(800, 250) × 50%; min(800, 430.41) × 50%
20: new borrower 10% and other borrower 15%, both 30,000 at 6% [250, 333.06] at AGI 92,460 [140, 166.53] at AGI 66,060 DI 2,800/month; min(280, 333.06) × 50% = 140; min(420, 333.06) × 50% = 166.53
21: 10,000 Direct; spouse 30,000 FFEL only [86.06, 0] [27.76, 0] 344.25 > 111.02; min(344.25, 111.02) × 10,000 / 40,000 = 27.76
24: spouse barred by a post-July 2026 loan (IBR output) [187.5, 0] [166.53, 0] 2,500 × 15% = 375; min(375, 333.06) × 50% = 166.53
25: 6% and 6%, $600 (ruling row 3, new) n/a [166.53, 166.53] min(600, 333.06) × 50%
26: 6% and 6%, $800 (ruling row 4, new) n/a [166.53, 166.53] min(800, 333.06) × 50%

Case 20's input changed. At its old AGI, both spouses now reach the cap and pay 166.53 each, so the case would no longer show that each spouse keeps their own rate. At AGI 66,060 the 10% amount stays under the cap and the 15% amount doesn't. Case 24's PAYE output (125) is unchanged, since 250 is below the 333.06 cap.

pay_as_you_earn_payment.yaml

Case Old New New arithmetic
3: 40,000 and 20,000 at 6%, AGI 80,000 [264.11, 132.06] [264.11, 74.01] 10% × 47,540 / 12 = 396.17; min(396.17, 444.08) × 2/3 = 264.11; min(396.17, 222.04) × 1/3 = 74.01
4: 40,000 and 10,000 at 6%, AGI 150,000 [444.08, 111.02] [355.27, 22.20] 979.50; min(979.50, 444.08) × 80% = 355.27; min(979.50, 111.02) × 20% = 22.20
10: 0% and 12%, $600 (ruling row 1) [250, 300] [125, 215.21] as IBR Case 18
11: 0% and 12%, $800 (ruling row 2) [250, 400] [125, 215.21] as IBR Case 19
12: 10,000 Direct; spouse 30,000 FFEL only [59.46, 0] [27.76, 0] 237.83 > 111.02; min(237.83, 111.02) × 25% = 27.76
14: 6% and 6%, $600 (ruling row 3, new) n/a [166.53, 166.53] min(600, 333.06) × 50%
15: 6% and 6%, $800 (ruling row 4, new) n/a [166.53, 166.53] min(800, 333.06) × 50%

No single-filer expected value changed. In IBR Case 22, two single filers who each hold FFEL loans, I reworded only the comment to show the new order. The values stay at [50.19, 111.02]. The student loan integration.yaml has only single and head-of-household filers and is unchanged. No partner contract tests (policyengine_us/tests/policy/baseline/partners/**) use these variables. Nothing in the model reads income_based_repayment_payment or pay_as_you_earn_payment; they aren't subtracted from net income.

Other edits

  • Updated the docstrings of income_based_repayment_payment, pay_as_you_earn_payment and income_driven_repayment_standard_payment, and added a (g)(1) reference to the last of these.
  • Updated the programs.yaml notes for both plans to put the cap before the proration.
  • Added the changelog fragment changelog.d/student-loan-cap-before-proration.fixed.md.

Source-conflict marker

There is no marker in this PR. Max's ruling id, us:idr-10-year-cap-before-spousal-proration, isn't in rulespec-us known-source-conflicts.yaml on main, and test_source_conflicts.py fails a marker that names an unregistered entry. Once the entry is registered and the vendored register is re-pinned, the deciding line in each formula (capped_amount = min_(...)) can carry # Source conflict us:idr-10-year-cap-before-spousal-proration.

Tests

Run on Modal at bb8a0d51 (this PR's head), installed as CI does (uv lock --upgrade-package policyengine-core, uv sync --locked --extra dev, Python 3.14, policyengine-core 3.34.2):

  • test_batched.py policyengine_us/tests/policy/baseline/gov/ed/student_loans --mode per-subdir --workers 1: 202 passed in 8 batches (all 26 IBR and 15 PAYE payment cases), exit 0.
  • pytest policyengine_us/tests/code_health policyengine_us/tests/test_programs_registry.py: 1366 passed, 10 skipped, exit 0.

I didn't run anything locally.

axiom: rulespec-us#1455 queued

Coordination

us-student-debt's measures pipeline pins policyengine-us at f679eedd. It marks four cap-order couples as strict expected failures for this departure (model_departures.ibr_cap_before_proration). Its counterfactual uses the same expression as this PR, min(rate × DI, income_driven_repayment_standard_payment) × income_driven_repayment_balance_share. When it bumps the pin past this PR, it will need to flip those expected failures.

🤖 Generated with Claude Code

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; (g)(1)(i)-(ii) then
prorate that amount to the borrower's Direct Loans. Each payment is now
min(rate x discretionary income, own standard payment) x balance share,
rounded to the cent before the small payment rules and the balance cap.

Single filers, and joint filers whose spouse has no eligible loans, are
unchanged. Part of #10411 (item 4).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@DTrim99
DTrim99 requested a review from MaxGhenis November 2, 2026 16:37
@codecov

codecov Bot commented Nov 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (b0a8c09) to head (bb8a0d5).
⚠️ Report is 128 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff            @@
##              main    #10602   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            1         3    +2     
  Lines           15        65   +50     
=========================================
+ Hits            15        65   +50     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@DTrim99

DTrim99 commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as superseded by #10570, merged 2026-11-03, which applies the same IBR and PAYE rule: each borrower pays round(min(rate × discretionary income, own standard payment) × balance share, 2), per 34 CFR 685.209(f)(2)–(3) and (g)(1)(i)(B). #10570 also covers this PR's test cases (Max's four couples, mixed rates, FFEL) and registers the ruling in source_conflicts/candidates.yaml. A trial merge with main left only one docstring sentence in income_driven_repayment_standard_payment and a duplicate changelog fragment, so there's nothing left to land here.

@DTrim99 DTrim99 closed this Nov 3, 2026
@DTrim99
DTrim99 deleted the student-loan-cap-before-proration branch November 3, 2026 16:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant