Repository navigation
Apply the IBR and PAYE 10-year cap before spousal proration - #10570
Conversation
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwQq6JaKXtKF9JBv4wLzq6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwQq6JaKXtKF9JBv4wLzq6
|
Picking this up for the US hub, taking over from the earlier session that opened this draft. I'll post a hand-off comment here when it's ready. Generated by Claude Code |
Keep the branch current with main before finishing the item. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…roperty 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 <noreply@anthropic.com>
|
Ready for hub review HEAD: Taken over from the earlier session. The head was Item steps
Changed expected values
Tests
Mutants (one process per test file, code restored after each)
Judgment calls
Generated by Claude Code |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #10570 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 1 4 +3
Lines 15 78 +63
=========================================
+ Hits 15 78 +63
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Ready for hub review The PR's head is still FindingsP2. #10602 is a second open PR that makes the same change, and the two conflict
P3.
|
| Case | My value | YAML | Main's order |
|---|---|---|---|
| IBR 8 | 445.69, 55.51 | same | 445.69, 148.56 |
| IBR 18 | 125.00, 215.21 | same | 250, 300 |
| IBR 19 | 125.00, 215.21 | same | 250, 400 |
| IBR 20 | 166.53, 166.53 | same | 250, 333.06 |
| IBR 21 | 27.76, 0 | same | 86.06, 0 |
| IBR 24 | 166.53, 0 (spouse barred) | same | 187.50 |
| IBR 25 | 166.53, 166.53 | same | 300, 300 |
| IBR 26 | 50, 50 | same | 50, 50 |
| IBR 27 | 150.00, 166.53 | same | 150, 225 |
| IBR 28 | 37.01, 148.03 | same | 100, 200 |
| PAYE 3 | 264.11, 74.01 | same | 264.11, 132.06 |
| PAYE 4 | 355.27, 22.20 | same | 444.08, 111.02 |
| PAYE 10 | 125.00, 215.21 | same | 250, 300 |
| PAYE 11 | 125.00, 215.21 | same | 250, 400 |
| PAYE 12 | 27.76, 0 | same | 59.46, 0 |
| PAYE 14 | 37.01, 148.03 | same | 100, 200 |
| PAYE in IBR 24 | 125, 0 | same | 125 |
Standard payments: 30,000 at 0% is 250.0000, at 12% 430.4128 and at 6% 333.0615; at 6%, 10,000 is 111.0205, 20,000 is 222.0410, 40,000 is 444.0820 and 60,000 is 666.1230. These match the YAML comments and the hand-off's table. No case without spouse debt changed: IBR 22 is still 50.19 and 111.02, and integration.yaml is unchanged.
Payments can only fall. Since balance share ≤ own Direct fraction, min(A, S) × share ≤ min(A × share, S × fraction), and the post-processing is nondecreasing. So for every input the new payment is no higher than main's, and it is equal when the spouse has no eligible debt.
The defect reproduces at main. I ran the PR's two YAML files against main's formulas, on a scratch worktree at origin/main with the PR's test files copied in. IBR gave 9 failed, 19 passed (Cases 8, 18, 19, 20, 21, 24, 25, 27, 28) and PAYE 6 failed, 8 passed (Cases 3, 4, 10, 11, 12, 14). IBR 26 passes on main, as it should, because both orders give $50.
Tests at the head, one process each: IBR YAML 28 passed, PAYE YAML 14 passed, integration.yaml 3 passed, the property file 2 passed in 9.50s (about 45 s wall with imports, 0.88 GiB peak RSS, local, not CI), and code_health/test_source_conflicts.py 103 passed.
Property tests.
- The references come from the rule. The rates and the $50, $5 and $10 constants are literals, and
Sand the share are recomputed from the generated balances, not read from the model. The identity reference has the same algebraic form as the code because the rule does. - Nothing passes trivially. The borrower always has Direct debt (
direct ≥ 1). The spouse is drawn with no debt, Direct only, FFEL only or both. Rates include 0% and 12%, and income runs to 15,000 a month, so the cap binds often. A model that returned all zeros fails the identity. - The brief's five properties are all present: the differential against main's form for no spouse debt and for a single filer, the identity, both bounds, and monotonicity in income and in the spouse's balance.
- With shrinking off, a failure still prints the full drawn
couples=[…]list, with each failing couple and spouse named in the assertion message. I checked this in the logs of the main-order mutant. hypothesisis in thedevextra, and CI installs--extra dev. The file usesimportorskip, so it skips without the dev extra (it skipped for me until I synced it). In CI it ran: both tests showPASSEDin Rest Python shard 1 of run 41488313567, taking 6.57 s and 0.17 s.
Mutants. Each ran in its own process on the working tree, followed by git checkout -- policyengine_us/variables, and git status was clean after each.
| Mutant (applied to both plans) | IBR YAML | PAYE YAML | Property file | integration.yaml |
|---|---|---|---|---|
| Main's order | 9 failed | 6 failed | 2 failed | 3 passed |
| Couple's combined standard payment | 9 failed | 6 failed | 2 failed | 3 passed |
| Drop the balance share | 11 failed | 6 failed | 2 failed | 3 passed |
| Direct fraction alone in place of the share | 10 failed | 6 failed | 2 failed | 3 passed |
| Combined standard payment for the tax-unit head only (added) | 8 failed | 5 failed | 2 failed (identity) | 3 passed |
| FFEL dropped from the balance share's denominator (mine) | 3 failed | 2 failed | 1 failed, 1 passed (identity and bound) | 3 passed |
The first four match the hand-off's counts. integration.yaml survives every mutant because none of its cases has spouse debt, as the hand-off says.
Register. The id, program: student-loans (the file's first student loan item), state: ruled and pe_us.follows: gap-fill pass the guard's vocabularies (FOLLOWS, YEAR_TYPES, STATES in test_source_conflicts.py:766-768). gap-fill fits the protocol: 1098e(b)(6)(A) sets a maximum and is silent on proration, and the regulation fills that gap. years: {from: 2026, type: calendar} matches the programs registry's verified_start_year: 2026 for both plans. There's no # Source conflict marker, as the protocol requires before landing, and pe_us.prs points here.
Readers. No variable or parameter reads income_based_repayment_payment or pay_as_you_earn_payment: grep -rn over policyengine_us (tests excluded) finds only programs.yaml:1790, 1814. The model has no student-loan forgiveness, tax-on-forgiveness, plan-choice or household-total variable, and the registry notes say both payments are "not subtracted from household net income". So the only outputs that move are these two variables, and only for joint filers whose spouse holds eligible Direct or FFEL loans. Those outputs fall or stay the same.
Order with Apply the standard plan's $50 minimum once across a borrower's Direct and Parent PLUS loans #10599. Apply the standard plan's $50 minimum once across a borrower's Direct and Parent PLUS loans #10599 adds combined_standard_repayment_payment and helper functions. It doesn't change amortized_monthly_payment or anything the IDR formulas read. The only file both PRs touch is source_conflicts/candidates.yaml, and git merge-tree of the two heads is clean. Neither PR changes the other's results, so either can land first.
Main has moved 17 commits past this PR's merge base. None touches the student-loan folders, and git merge-tree against origin/main is clean.
Body lines. impact: pending (hub) is present, and the axiom: line is byte-identical to Count FFEL debt in student loan proration and caps, use the latest return's AGI, and add the parent PLUS standard payment #10415's (diffed). The PR changes nothing under .github/ or policyengine_us/tests/policy/baseline/partners/.
Paragraph cites, within the repo. The PR's mapping (PAYE and new-borrower IBR under (f)(2), other IBR under (f)(3)) agrees with the repo's own parameter references: pay_as_you_earn/rate.yaml and income_based_repayment/new_borrower_rate.yaml cite (f)(2)(i), and income_based_repayment/rate.yaml cites (f)(3)(i).
CI at the head. The PR is no longer a draft. Pull request run 41488313567 at f7b65447af completed success: 34 jobs, all success, none skipped or cancelled, including the Microsimulation suite, Household API Partners and all Baseline, Contrib and Rest shards. The earlier run 41482758189 was skipped while the PR was a draft.
What I couldn't check
- The law text. This session's network refuses ecfr.gov, govinfo.gov, uscode.house.gov, federalregister.gov, law.cornell.edu, web.archive.org, studentaid.gov and ed.gov. So I couldn't quote 34 CFR 685.209(e)(2)(i), (f)(2), (f)(3) or (g)(1), check that the body's paragraph cites land on that text, or confirm that each plan's cap is the borrower's own 10-year standard payment. I also couldn't read 20 U.S.C. 1098e(b)(6)(A) or the 91 FR 23768 preamble point the body paraphrases. The hand-off says its session couldn't reach them either, so the body's quotations remain unverified against the source. The repo holds no captured copy under
source_conflicts/sources/. A check from a machine that can reach ecfr.gov should confirm the quoted words before the register entry moves to rulespec-us. - Population impact. The microsimulation is the hub's: how many joint filers in the data have both spouses holding eligible loans, and how much the two payments fall. Because nothing reads these variables, household net income, taxes and benefits shouldn't move. The impact pair should confirm both points.
- Memory in the
remainingPython group. The new file joins it, and the Makefile records that group at 14.4 GB. I measured 0.88 GiB peak for the file alone in a fresh process. I didn't measure what it adds inside the shared process, but CI's Rest shard 1 passed at this head.
Generated by Claude Code
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 <noreply@anthropic.com>
|
US + core hub merge audit, us#10570 at
|
Income-Based Repayment and Pay As You Earn now take the lesser of the income-based amount and each borrower's own 10-year standard payment first, then prorate that lesser amount by the balance share. This reverses the order #10415 used (prorate, then cap) and keeps its per-borrower cap.
The law
Numbers
Joint filers in Texas, 2026, $30,000 of Direct Loans each, $600 income-based amount unless stated.
Sis each spouse's own 10-year standard payment.SArithmetic:
S= B × i / (1 − (1 + i)^−120), i = rate / 12, or B / 120 at 0%. Row 1: min($600, $250) × 50% = $125.00; min($600, $430.41) × 50% = $215.21. Row 5: min($300, $333.06) × 50% = $150; min($450, $333.06) × 50% = $166.53, so the cap binds on one spouse. Row 6: balance shares 10,000 / 60,000 and 20,000 / 60,000 ((g)(1)(i) × (g)(1)(ii)); min($600, $222.04) / 6 = $37.01 and min($600, $444.08) / 3 = $148.03.Consequence
A couple whose income-based amount exceeds both standard payments pays each spouse's standard payment times their balance share. At equal balances and rates the couple pays half their combined standard amount, however high their income. 20 U.S.C. 1098e(b)(6)(A) sets a maximum, and this stays under it. The Department's 2026 preamble (91 FR 23768) explains proration as avoiding counting a spouse's income twice, which doesn't reach the cap, but the regulation's text multiplies the whole amount.
A borrower with no spouse debt doesn't move: main's
min(A × d, S × d)equalsmin(A, S) × dwhen the balance share equals the Direct fractiond.Invariants
Checked by
policyengine_us/tests/test_student_loan_idr_cap_order_property.py(Hypothesis, 10 examples of up to 8 couples, each couple in five variants, one vectorized simulation per example), on the final payment, compared with references passed through the same rounding, small-payment rule and balance cap (all nondecreasing, so equalities, bounds and monotonicity carry through):min(A × d, S × d).P_i = min(A_i, S_i) × own Direct / couple's (Direct + FFEL), and a borrower whose spouse has no eligible debt pays the single-filer amount.P_i ≤ S_i × own Direct / own (Direct + FFEL)andP_i ≤ A_i × balance share.P_inever falls as income rises and never rises as the spouse's balance rises.Tests
Run locally, one process each:
income_based_repayment/income_based_repayment_payment.yaml: 28 passed.pay_as_you_earn/pay_as_you_earn_payment.yaml: 14 passed.student_loans/integration.yaml: 3 passed, unchanged (no case has spouse debt).test_student_loan_idr_cap_order_property.py: 2 passed; pytest reports 7.3 s, 1.5 s of it the 10 Hypothesis examples; about 52 s wall with interpreter start and model import; peak RSS 0.89 GiB. Shrinking is off, so a failure reports the example as drawn instead of running hundreds more simulations.code_health/test_source_conflicts.py: 103 passed.Mutants, one process per test file, code restored after each:
S× Direct fraction)integration.yamlpasses under every mutant, as expected, since none of its cases has spouse debt.Register:
source_conflicts/candidates.yamlgainsus:idr-10-year-cap-before-spousal-proration(stateruled,pe_us.follows: gap-fill). No# Source conflictmarker until the shared register holds the entry.impact: none at baseline (the population file holds no student loan balance, plan or rate input, and no variable reads these two payments)
axiom: us:statutes/20/1098e, us:statutes/20/1087e/q queued (TheAxiomFoundation/rulespec-us#1455) | us:regulations/34-cfr/685/208, us:regulations/34-cfr/685/209 queued (TheAxiomFoundation/rulespec-us#1711) | 34 CFR 685.209(f)(1) (SAVE) n/a: counterfactual of a blocked rule, not current law
🤖 Generated with Claude Code