Repository navigation
Conversation
…d cap IBR at the couple's standard payment - Add federal_ffel_loan_balance and count eligible FFEL debt in the income-driven repayment balance share (34 CFR 685.209(b)(5), (g)(1)(i)-(ii)) and in the Repayment Assistance Plan's spousal share ((g)(3)(i)). - Add income_driven_repayment_adjusted_gross_income, the AGI on the most recent return (20 U.S.C. 1087e(q)(4)(A)), defaulting to current-year AGI, and use it for RAP, IBR and SAVE. - Cap IBR at the couple's combined 10-year standard payment on eligible Direct and FFEL loans before prorating (685.209(f)(2)-(3), (e)(2)(i)). - Document balance timing, the active-repayment assumption and the tax-dependent restriction. - Add the rulespec-us#1455 companion cases. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e threshold Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Add parent_plus_standard_repayment_payment, the 10-year standard payment on federal_parent_plus_loan_balance under 34 CFR 685.208(b)(1), through the shared amortization helper with the standard plan's $50 minimum and balance cap. It is kept apart from standard_repayment_payment so the income-driven caps stay on the loans those plans repay (#10411 item 6). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #10415 +/- ##
==========================================
Coverage 100.00% 100.00%
==========================================
Files 4 21 +17
Lines 76 328 +252
Branches 2 1 -1
==========================================
+ Hits 76 328 +252
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:
|
Drop CPI-U uprating from federal_student_loan_balance, federal_parent_plus_loan_balance and federal_ffel_loan_balance, and exempt them from the default dollar-input uprating in default_uprating.py. A loan stock is not a price- or income-indexed flow, and repayment is not rolled forward, so with auto carry-over a later year reads the stored balance unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…strictions branch Resolve the IBR payment and eligibility documentation, the programs.yaml IBR note and the standard payment test numbering against #10152. Keep the July 1, 2026 loan bar in IBR, and apply it to the parent PLUS 10-year payment too: the tiered plan repays those borrowers' parent PLUS loans, so counting them under the 10-year plan as well would double the payment. PAYE now picks up the FFEL proration through the shared balance share. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…and ICR - PAYE and ICR discretionary income and ICR's income percentage factor read income_driven_repayment_adjusted_gross_income. - PAYE caps at the couple's standard payment on eligible Direct and FFEL loans before prorating (685.209(f)(2), (g)(1)), as IBR does, and keeps the FFEL proration through the shared balance share. - Add PAYE cases for spouses at 0% and 12%, a binding couple's cap, a spouse's FFEL debt, and the latest-return AGI for PAYE and ICR. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
test_float_dollar_inputs_have_long_run_upraters requires every float dollar input to carry an uprater. The loan balances that default_uprating.py now holds at their dataset value are the deliberate exception, so the check skips NO_DEFAULT_UPRATING_VARIABLES. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
PavelMakarchuk
left a comment
There was a problem hiding this comment.
Program Review
PR #10415, "Count FFEL debt in spousal proration, use the latest return's AGI, and cap IBR at the couple's standard payment" (DTrim99). Round 1, full review.
IDs: C = critical, SA = should address, G = suggestion.
Paths: V = policyengine_us/variables/gov/ed/student_loans/, T = policyengine_us/tests/policy/baseline/gov/ed/student_loans/. "Standard payment file" = T/income_driven_repayment/income_driven_repayment_standard_payment.yaml. Line numbers refer to the reviewed head. PDF links use the file page number, not the printed page number.
Source Documents
- Reviewed head SHA: cb6363a
- Mode: full
- Scope: the diff from the merge base
aa8596e3d8: 42 files, +1,530/-114 (20 variables, 17 YAML test files, the uprating tool and its test,programs.yaml, the changelog, one methodology page). No parameter changes. - Regulations: 34 CFR 685.209, eCFR versions of 2024-12-31, 2025-06-01, 2026-05-01 and 2026-07-01. The
/current/anchors resolve to the 2026-07-01 text. The 2026-01 test periods fall under the earlier text, and the cited paragraphs ((b)(5), (e)(2)(i), (f)(2)(ii), (f)(3)(ii), (g)(1)) say the same in both. Also 685.208 (2025-06-01, 2026-07-01), 685.220(i) (2026-10-01), 685.221 (IBR before 2024, read at 2022-01-01), and the FFEL rules 682.215 and 682.209 (2026-07-01). - Statutes: 20 U.S.C. 1098e and 20 U.S.C. 1087e (OLRC release point 119-134 and LII, current through Pub. L. 119-21); 20 U.S.C. 1078(b)(1)(L) with its LII notes; 26 U.S.C. 6103(l)(13).
- PDF: Pub. L. 119-21 (page 274, 1087e(q)(4); pages 276-278, sec. 82001(f) and its effective date). RISE final rule, FR 2026-08556 (pages 71, 79-80, 98-99, 123). SAVE final rule, FR 2023-13112 (page 82). ICR factors, FR 2026-11540 (pages 2, 4). All text layers were clean.
- Other rulemaking: the RAP proposed rule at 91 FR 4284-85, the 2009 IBR rule at 74 FR 55972, the 2012 PAYE rule at 77 FR 66088, the 2008 rule at 73 FR 63232, and ED's Income-Driven Repayment Plan Request form (OMB 1845-0102), Table 1.
- Companion spec: TheAxiomFoundation/rulespec-us#1455 (cases C1-C20 and E1-E7, in MaxGhenis's comment of 2026-10-23).
- Data: policyengine-us-data main
42ed5d45(archived), microcosm main5810fce, draft PRs microcosm#1246 and microcosm#1357, us-student-debt#9. - Year: target 2026. The tests cover 2023-2027.
Branch Status
- The branch is 7 commits ahead and 4 behind
main(9ee14cdb43). The 4 commits change no student-loan file. They add an Iowa LIHEAP entry nearprograms.yaml:1060, far from this PR's hunks at lines 1609-1660. GitHub reported the merge state as UNKNOWN at review time. - #10453, #10400 and #10119 (open) also edit
test_uprating_extensions.py, in other functions. The hunks do not overlap. - CI passes 34 of 34 at the head.
What the PR gets right
- FFEL proration. The IBR, PAYE and SAVE share, own Direct / the couple's (Direct + FFEL), correctly reduces 685.209(g)(1)(i) times (g)(1)(ii). The RAP share follows the literal (g)(3)(i) text, and the head-or-spouse gate is what (g)(3)(i) and (e)(2)(i) require. The #10411 examples reproduce ($62.50, $86.06, $50.19 and $111.02, $25.09).
- Latest-return AGI. One overridable variable feeds all six consumers, and each consumer has its own override case. The $16.67 example reproduces.
- Own FFEL in the cap amount is right (1098e(b)(6) covers loans "under part B or D"), and so is the order: lesser-of, proration, rounding, small-payment rule, Direct-balance cap.
- Parent PLUS for unconsolidated loans: $333.06, the $50 minimum, the final payment, and the July 2026 bar with no double count against the tiered $253.16.
- Uprating exemption: documented; $31,626, $33,271 and $30,000 reproduce; the carry-forward YAML cases catch every change to it.
- Tests and code: 35 expected values recomputed by hand, all correct; 26 of 26 sampled rulespec-us#1455 cases match. No hard-coded values; correct entities and periods;
ruffclean.
Critical (Must Fix)
C1. IBR and PAYE cap each spouse at the couple's combined 10-year payment, but the law caps each borrower at their own.
-
Where:
V/income_driven_repayment/income_driven_repayment_standard_payment.py:9, 11-26, 31, 53-54(TaxUnit sum over the head and spouse, cited to (e)(2)(i));V/income_based_repayment/income_based_repayment_payment.py:12-20, 50-63;V/pay_as_you_earn/pay_as_you_earn_payment.py:12-16, 50-66. -
Evidence (law):
- 20 U.S.C. 1098e(b)(6)(A), which the IBR variable cites: when the payment "for all loans made to the borrower under part B or D" exceeds the 10-year amount under 1087e(d)(1)(A), "the maximum monthly payment required to be paid for all loans made to the borrower ... shall be the standard monthly repayment amount". Pub. L. 119-21 sec. 82001(f) rewrote this paragraph with effect from 2025-07-04 for every borrower in repayment (#page=276, pages 276-278), so it governs the 2026 test periods.
- 685.209(e)(2)(i), the PR's reference for the spouse sum, includes the spouse's eligible debt only "for the purposes of adjusting the borrower's monthly payment amount as described in paragraph (g)". It does not bring the spouse's loans into (f).
- (f)(2)(ii) and (f)(3)(ii): "What the borrower would have paid on a 10-year standard repayment plan based on the eligible loan balances". A standard plan never repays a spouse's loans.
- 685.209 uses the same amount as one borrower's whole payment: (l)(9)(i) (a borrower who does not recertify pays it) and (j)(2)(ii) (interest capitalizes when a borrower pays it). With a couple cap times a share, no spouse can ever pay that amount.
- The FFEL rule for the same statute, 682.215(d)(1)(i), uses "the amount of the borrower's eligible loans", as did 685.221(d)(1)(i) and the 2012 PAYE text. ED's request form (Table 1) and the 2009 rule use the spouse's loans only in the old partial-financial-hardship entry test and the proration, never in the cap.
- No ED preamble applies a combined cap. The RISE preamble repeats that the capped payment is "what the borrower would have paid under a standard 10-year repayment plan" (#page=79, pages 79-80).
-
Evidence (runs): with $30,000 for each spouse at 0% and 12%, the own standard payments are $250 and $430.41. IBR Cases 17-18 and PAYE Cases 10-11 give person1 $300 and $340.21, above the $250 maximum (table below). A probe with $40,000 and $1,000 at 6% and AGI $300,000 gives [482.03, 12.05] instead of [444.08, 50]: the small-balance spouse's $50 minimum moves onto the other spouse. The comments in IBR Cases 17-18 and PAYE Cases 10-11 state the per-borrower result ("250 + 300", "250 + 400") and then pin the other one.
-
Why critical: a mismatch with a clear statutory rule, a value mismatch confirmed by runs, and a reference ((e)(2)(i)) that does not corroborate the rule. The docs disclose the couple cap but not that a spouse can pay above their own standard payment. Documentation does not save a choice that contradicts a clear rule.
-
The order argument does not change this. The (f)-then-(g) order says nothing about whose loans the cap covers, and (e)(2)(i) limits the spouse's debt to (g). 1098e(d) (a separate filer is computed on their own debt) implies that joint filers combine debt, and the (g) proration already does that. Issue #10411 item 4 asserts the couple cap without a citation; please correct it there too. The pre-PR order (prorate, then the own cap) was right. It only left out the borrower's own FFEL loans, which this PR correctly adds.
-
Fix (minimal): make the standard payment a per-person amount and apply it after the proration.
In
V/income_driven_repayment/income_driven_repayment_standard_payment.py:
- entity = TaxUnit
+ entity = Person
- "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(e)(2)(i)",
+ "https://www.law.cornell.edu/uscode/text/20/1098e#b_6_A",
- def formula(tax_unit, period, parameters):
+ def formula(person, period, parameters):
p = parameters(period).gov.ed.student_loans.standard_repayment
- person = tax_unit.members
...
- is_head_or_spouse = person("is_tax_unit_head_or_spouse", period.this_year)
- return tax_unit.sum(where(is_head_or_spouse, standard_payment, 0))
+ return standard_paymentIn V/income_based_repayment/income_based_repayment_payment.py:50-63, and the same in V/pay_as_you_earn/pay_as_you_earn_payment.py:50-66 with p.pay_as_you_earn.rate in place of rate:
# 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.
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)
# (keep the existing comment on rounding to whole cents)
prorated_payment = np.round(
min_(
rate * discretionary_income * balance_share,
standard_payment * direct_fraction,
),
2,
)Then delete the later balance = person(...) line (IBR :70, PAYE :73).
Expected values that move. A run of this fix on the full 186-case student-loan folder moves exactly these 8 cases, and all 186 pass with the new values:
| File, case | Head | Fixed rule |
|---|---|---|
IBR Case 17 at :423 (Case 18 after SA3) |
[300, 300] | [250, 300] |
IBR Case 18 at :453 (Case 19 after SA3) |
[340.21, 340.21] | [250, 400] |
| PAYE Case 10 | [300, 300] | [250, 300] |
| PAYE Case 11 | [340.21, 340.21] | [250, 400] |
| Standard payment file Case 2 | 680.41 | [250, 430.41] |
| Standard payment file Case 3 | 444.08 | [111.02, 333.06] |
| Standard payment file Case 4 | 383.06 | [333.06, 50] |
| Standard payment file Case 5 | 222.04 | [222.04, 166.53] |
The couple totals fall from $600 to $550 (Case 17) and from $680.42 to $650 (Case 18). Unchanged: standard payment file Cases 1 and 6, Parent PLUS Cases 1 and 4, IBR Cases 8 and 19-21, PAYE Cases 3, 4 and 12.
- Other edits for C1:
- Names with "capped together" or "couple's combined": IBR Cases 17-18, PAYE Cases 4 and 10-11. Standard payment file Case 2 becomes "each spouse has their own standard payment at their own rate" and Case 5 "each person's standard payment covers only their own loans" (a dependent's IBR and PAYE stay $0 through
income_driven_repayment_eligible). - Comments: new arithmetic in IBR Cases 17-18 and PAYE Cases 10-11 (for example "300 each; min(300, 250) = 250 and min(300, 430.41) = 300"); "couple's cap" wording in IBR Cases 19-20 and PAYE Case 12 (values unchanged). IBR
:195-196and PAYE:81-82become correct again. - Headers (IBR
:1-8, PAYE:1-8, standard payment file:1-5), docs (income_driven_repayment_standard_payment.py:11-26, IBR:12-20, PAYE:12-19), the changelog and PR body item 4: the cap is the borrower's own 10-year payment on their Direct and FFEL loans, and the Direct Loans carry the (g)(1)(ii) part of it.
- Names with "capped together" or "couple's combined": IBR Cases 17-18, PAYE Cases 4 and 10-11. Standard payment file Case 2 becomes "each spouse has their own standard payment at their own rate" and Case 5 "each person's standard payment covers only their own loans" (a dependent's IBR and PAYE stay $0 through
C2. The latest-return AGI variable cites RAP's definition for four plans that it does not govern, and it omits their definition.
- Where:
V/income_driven_repayment/income_driven_repayment_adjusted_gross_income.py:9-21(docs) and:23-28(references);T/income_driven_repayment/income_driven_repayment_adjusted_gross_income.yaml:1-2; PR body item 2. - Evidence:
- The docs say that RAP and the IBR, SAVE, PAYE and ICR plans use as income what 1087e(q)(4)(A) defines. But 1087e(q)(4) opens "Definitions In this subsection:", and subsection (q) is RAP.
- The definition that covers every IDR plan is 685.209(b)(10): "Income means either— (i) The borrower's and, if applicable, the spouse's Adjusted Gross Income (AGI) as reported to the Internal Revenue Service; or (ii) ... alternative documentation". It has the same text, unnumbered, in the version for the 2026-01 tests. No file in the PR cites it.
- The latest-return timing for those plans rests on (b)(10)(i) with (l)(1)-(3) and (l)(6), and on 1098e(c)(2)(A)(i) and 1087e(e)(8)(A)(i). 26 U.S.C. 6103(l)(13)(A) leaves the tax year to ED.
- The variable cites (e)(1)(i), which covers RAP, REPAYE, IBR and PAYE. ICR, which also reads the variable, is (e)(1)(ii).
- Why critical: the review criteria treat missing or non-corroborating references and incorrect citations as critical. The docs name an authority that does not apply to four of the five plans, and the governing definition is missing. No value changes, so the fix is small.
- Fix: references and docs. The docs below also fix the microsimulation sentence (in a microsimulation, inputs given only for 2024 still produce 2025 AGI of $51,984.86) and state the direction of the default.
documentation = (
"Adjusted gross income on the most recent federal income tax return "
"available when the Department of Education sets the monthly payment. "
"For the Repayment Assistance Plan, 20 U.S.C. 1087e(q)(4)(A) defines it "
"as adjusted gross income 'for the most recent taxable year'. For the "
"Income-Based Repayment, SAVE, Pay As You Earn and Income-Contingent "
"Repayment plans, 34 CFR 685.209(b)(10)(i) defines income as adjusted "
"gross income as reported to the IRS, which the Department takes from "
"the latest return it obtains (685.209(l)(1)-(3), (l)(6)). Whose income "
"counts follows 685.209(e)(1)(i), and (e)(1)(ii) for ICR. A borrower "
"whose income fell can document current income instead. When not "
"provided, this defaults to the current year's adjusted gross income, "
"because household inputs usually cover only the simulated year. The "
"default overstates payments for borrowers whose income rose since "
"their last return. An explicit input overrides it."
)
definition_period = YEAR
reference = (
"https://www.law.cornell.edu/uscode/text/20/1087e#q_4_A",
"https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(b)(10)",
"https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(e)(1)(i)",
"https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(e)(1)(ii)",
"https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(l)(4)",
"https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(l)(6)",
)Make the same split in the test header ("1087e(q)(4)(A) for RAP; 34 CFR 685.209(b)(10) for IBR, SAVE, PAYE and ICR") and in PR body item 2.
C3. The new Parent PLUS payment amortizes consolidated Parent PLUS loans over 10 years, but their standard plan runs 10 to 30 years.
- Where:
V/standard_repayment/parent_plus_standard_repayment_payment.py:11-15, 31, 36-44(alwaysp.repayment_period, 10 years; only reference 685.208(b)(1));V/federal_parent_plus_loan_balance.py:9-14(the input includes "Direct Consolidation Loans that repaid such loans", and the PR adds that this payment "repays them under the standard plan"). - Evidence:
- 685.208(b)(1) covers Direct Consolidation Loan borrowers only if they "entered repayment before July 1, 2006".
- 685.208(b)(2) is the standard plan "for Direct Consolidation Loan borrowers entering repayment on or after July 1, 2006". Under (b)(2)(iii), the term follows the total of the consolidation loan and the borrower's other student loans: under $7,500, 10 years; $7,500, 12; $10,000, 15; $20,000, 20; $40,000, 25; $60,000 or more, 30.
- 685.220(i)(2)(ii) applies that rule to these consolidations, and (i)(3)(i) limits the other loans counted to the consolidation amount. The text before 2026-07-01 was the same (685.208(c) and (j)).
- Values at 6%: $30,000 gives $333.06 in the model and $214.93 over 20 years, $118.13 (55%) too high. $15,000 gives $166.53 against $126.58, $50,000 gives $555.10 against $322.15, and $80,000 gives $888.16 against $479.64.
- Why critical: a value mismatch, confirmed on the code path, in a variable this PR adds, for a loan class its own input docs include. It contradicts a clear rule, and "Not modeled" lists only consolidated Parent PLUS under ICR. The same gap in
standard_repayment_paymenton main is older code (see Out of scope). - Fix:
- Add a Person, YEAR boolean input
federal_parent_plus_loan_is_consolidated(default false): true when the Parent PLUS balance is a Direct Consolidation Loan that entered repayment from 2006-07-01 to 2026-06-30. - Add
gov.ed.student_loans.standard_repayment.consolidation_repayment_period, asingle_amountscale with thresholds 0, 7,500, 10,000, 20,000, 40,000 and 60,000 and amounts 10, 12, 15, 20, 25 and 30 years, from 2006-07-01. Cite 685.208(b)(2)(iii), 685.220(i)(2)(ii) and (i)(3)(i). The defaultright=Falsematches "equal to or greater than". - In the formula, use that term when the flag is true. Base the tier on the Parent PLUS balance plus the person's other education loans (
federal_student_loan_balance + federal_ffel_loan_balance), with the other loans capped at the Parent PLUS balance. - Tests at 6%, consolidated, no other loans: $30,000 gives 214.93; $19,999.99 gives 168.77 (15 years); $20,000 gives 143.29 (20 years); $59,999.99 gives 386.58 (25 years); $60,000 gives 359.73 (30 years). Unconsolidated $30,000 stays 333.06.
- Fix the docs in both files to match. Docs alone do not clear this item, because the value stays wrong for an input the variable says it covers.
- Add a Person, YEAR boolean input
Should Address
SA1. Five FFEL and excepted-loan edge cases in the proration and RAP have no test.
Each change below passes all 186 cases at the head (except (c), caught once by accident), on a path this PR adds or makes reachable. The five cases pass at the head and under the C1 fix, fail on the change they pin, and do not depend on the couple cap.
- (a) A tax dependent's FFEL loans.
V/income_driven_repayment/income_driven_repayment_balance_share.py:36-38andV/repayment_assistance_plan/repayment_assistance_plan_payment.py:41keep a dependent's FFEL out of the couple's balance ((e)(2)(i)). Without the gate, a parent with $20,000 Direct and a dependent with $15,000 FFEL gets share 0.571 instead of 1. - (b) A spouse's Parent PLUS loans. (b)(5)(i)-(ii) exclude them from eligible loans. Adding them to the couple's balance changes Case 8 below from 1 to 0.4, and RAP from 550 to 220.
- (c) Married filing separately with FFEL.
income_driven_repayment_balance_share.py:19-20states the rule, but only IBR Case 21 catches a share summed over the marital unit. It catches it by accident: its two single filers share one auto-built marital unit. - (d) RAP eligibility of an FFEL-only borrower.
V/repayment_assistance_plan/repayment_assistance_plan_eligible.py:25-33is unchanged and correct ((c)(6), (d)(4)). Counting FFEL there changes the eligibility flag and the applicable payment ($0 to $250). The payment stays $0 only because of the Direct-balance cap, so the two safeguards hide each other. - (e) RAP final-payment cap.
V/repayment_assistance_plan/repayment_assistance_plan_payment.py:58caps at the Direct balance only, as (q)(4)(B)(iii) and (d)(4) require. A cap on Direct + FFEL turns January's $100 into $250. - Why should address: missing edge cases for already-tested variables.
- Fix: append to
T/income_driven_repayment/income_driven_repayment_balance_share.yaml(Case 7 pins (c); Case 8 pins (a) and (b)):
- name: Case 7, married filing separately counts only the person's own Direct and FFEL loans.
period: 2026
absolute_error_margin: 0.0001
input:
people:
person1:
age: 40
federal_student_loan_balance: 10_000
person2:
age: 38
federal_student_loan_balance: 20_000
federal_ffel_loan_balance: 30_000
tax_units:
tax_unit1:
members: [person1]
filing_status: SEPARATE
tax_unit2:
members: [person2]
filing_status: SEPARATE
marital_units:
marital_unit:
members: [person1, person2]
output:
# Separate returns: the spouse's income is not used, so the spouse's loans
# are not included either (685.209(e)(1)(i)(A), (e)(2)(i)) and there is no
# (g)(1)(i) proration. person1: 10,000 / 10,000 = 1. person2, (g)(1)(ii)
# alone: 20,000 / (20,000 + 30,000) = 0.4.
income_driven_repayment_balance_share: [1, 0.4]
- name: Case 8, a spouse's Parent PLUS loans and a tax dependent's FFEL loans do not dilute the head's share.
period: 2026
absolute_error_margin: 0.0001
input:
people:
person1:
age: 50
is_tax_unit_head: true
is_tax_unit_spouse: false
federal_student_loan_balance: 20_000
person2:
age: 48
is_tax_unit_head: false
is_tax_unit_spouse: true
federal_parent_plus_loan_balance: 30_000
person3:
# An adult relative claimed as a dependent, with FFEL loans made
# before the FFEL Program stopped new loans on June 30, 2010.
age: 40
is_tax_unit_head: false
is_tax_unit_spouse: false
federal_ffel_loan_balance: 15_000
tax_units:
tax_unit:
members: [person1, person2, person3]
filing_status: JOINT
output:
# Parent PLUS loans are not eligible loans (685.209(b)(5)(i)-(ii)), and
# only a spouse's loans join the borrower's (685.209(e)(2)(i)), so the
# couple's eligible balance is person1's 20,000: 20,000 / 20,000 = 1.
income_driven_repayment_balance_share: [1, 0, 0]Append to T/repayment_assistance_plan/repayment_assistance_plan_payment.yaml (Case 24 pins (a) and (b); Case 25 pins (e)):
- name: Case 24, a spouse's Parent PLUS loans and a tax dependent's FFEL loans do not dilute the borrower's share.
period: 2027-01
absolute_error_margin: 0.01
input:
people:
person1:
age: 50
is_tax_unit_head: true
is_tax_unit_spouse: false
federal_student_loan_balance: 20_000
person2:
age: 48
is_tax_unit_head: false
is_tax_unit_spouse: true
federal_parent_plus_loan_balance: 30_000
person3:
# An adult relative claimed as a dependent (FFEL loans before 2010).
age: 40
is_tax_unit_head: false
is_tax_unit_spouse: false
federal_ffel_loan_balance: 15_000
tax_units:
tax_unit:
members: [person1, person2, person3]
filing_status: JOINT
adjusted_gross_income: 90_000
output:
# 8% x 90,000 = 7,200 / 12 = 600; 600 - 1 x 50 = 550. Parent PLUS loans
# are not eligible loans (685.209(b)(5)(i)-(ii)), and only a spouse's loans
# join the borrower's (685.209(e)(2)(i), (g)(3)(i)), so person1's share is
# 20,000 / 20,000 = 1.
repayment_assistance_plan_eligible: [true, false, false]
repayment_assistance_plan_applicable_monthly_payment: 550
repayment_assistance_plan_payment: [550, 0, 0]
- name: Case 25, the final-payment cap uses the Direct Loan balance, not FFEL loans the plan cannot repay.
period: 2027
absolute_error_margin: 0.01
input:
people:
person1:
age: 40
federal_student_loan_balance: 100
federal_ffel_loan_balance: 30_000
tax_units:
tax_unit:
members: [person1]
filing_status: SINGLE
adjusted_gross_income: 60_000
output:
# 5% x 60,000 = 3,000 / 12 = 250. The plan repays only the 100 of Direct
# Loans (685.209(d)(4)), so January pays the 100 left to repay
# ((q)(4)(B)(iii)) and later months pay 0.
repayment_assistance_plan_payment:
2027-01: 100
2027-02: 0
2027: 100Append to T/repayment_assistance_plan/repayment_assistance_plan_eligible.yaml (pins (d)):
- name: Case 6, a borrower with only FFEL loans is not eligible.
period: 2027-01
input:
people:
person1:
age: 40
federal_ffel_loan_balance: 30_000
tax_units:
tax_unit:
members: [person1]
output:
# The plan repays only Direct Loans (685.209(c)(6), (d)(4); 20 U.S.C.
# 1087e(q)(1)(A)). FFEL loans count only in the spousal proration
# (685.209(b)(5)(ii), (g)(3)(i)).
repayment_assistance_plan_eligible: falseSA2. Four IBR and PAYE edge cases have no test.
Each change below passes all 186 cases at the head, except that one unrelated Parent PLUS assertion catches (b). The four cases pass at the head and under the C1 fix, and each fails on its change at both.
- (a) A spouse barred by a loan made on or after July 1, 2026 still counts in the proration. Their Direct Loans stay eligible loans ((b)(5) has no date limit); (d)(5) only stops the plan from repaying them. Dropping the barred spouse from the denominator doubles person1's IBR from $166.96 to $333.91 at the head ($333.06 under C1).
- (b) Joint filers where only one spouse borrowed, the most common married-borrower case. Without the cap at the eligible balance (
income_driven_repayment_standard_payment.py:50-52), the non-borrower adds $50 and IBR and PAYE become $383.06 instead of $333.06. Only a Parent PLUS assertion catches this today, by chance. - (c) The final cap at the Direct balance (IBR
:71, PAYE:74) never binds in a test. Without it, a $8 balance pays $10. - (d) PAYE's cent rounding (
V/pay_as_you_earn/pay_as_you_earn_payment.py:66). Removingnp.roundpasses every case, because PAYE Case 9 is exactly 5.0. The docs (:26-30) describe the choice that $4.996 becomes $10. - Why should address: missing edge cases for already-tested variables. (c) and (d) predate the PR, but the PR rewrote these lines and its docs now state the behavior, so they sit at the low end.
- Fix: append to the standard payment file (pins (b)). The first output assumes C1's Person entity. Before the fix it is the scalar
333.06.
- name: Case 7, joint filers where only one spouse borrowed are capped at that spouse's standard payment.
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
tax_units:
tax_unit:
members: [person1, person2]
filing_status: JOINT
adjusted_gross_income: 150_000
households:
household:
members: [person1, person2]
state_code: TX
output:
# The spouse has no eligible loans, so their standard payment is
# min(max(0, 50), 0) = 0, not the 50 dollar minimum. IBR 15% and PAYE 10%
# of (150,000 - 32,460) / 12 are 1,469.25 and 979.50, above 333.06.
income_driven_repayment_standard_payment: [333.06, 0]
income_based_repayment_payment: [333.06, 0]
pay_as_you_earn_payment: [333.06, 0]Append to T/income_based_repayment/income_based_repayment_payment.yaml after the SA3 renumbering (Case 23 pins (c) for both plans; Case 24 pins (a)):
- name: Case 23, a Direct Loan balance under 10 dollars caps the 10 dollar minimum payment.
period: 2026-01
absolute_error_margin: 0.01
input:
people:
person1:
age: 35
federal_student_loan_balance: 8
federal_student_loan_interest_rate: 0.06
tax_units:
tax_unit:
members: [person1]
filing_status: SINGLE
adjusted_gross_income: 100_000
households:
household:
members: [person1]
state_code: TX
output:
# Standard payment min(max(0.09, 50), 8) = 8. IBR min(950.75, 8) and PAYE
# min(633.83, 8) give 8, which 685.209(g)(1)(iii)(B) raises to 10; the
# 8 dollar Direct Loan balance caps both at 8.
income_based_repayment_payment: 8
pay_as_you_earn_payment: 8
- name: Case 24, a spouse barred by a loan made on or after July 1, 2026 still counts in the proration.
period: 2027-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
has_federal_student_loan_made_on_or_after_2026_07_01: true
tax_units:
tax_unit:
members: [person1, person2]
filing_status: JOINT
adjusted_gross_income: 60_000
households:
household:
members: [person1, person2]
state_code: TX
output:
# The spouse's Direct Loans stay eligible loans (685.209(b)(5)); (d)(5)
# only stops the plan from repaying them. 1.5 x 22,191.28 (2027 guideline
# for two) = 33,286.92; (60,000 - 33,286.92) / 12 = 2,226.09. IBR 15% =
# 333.91 and PAYE 10% = 222.61, each x 30,000 / 60,000; the spouse owes 0.
income_based_repayment_payment: [166.96, 0]
pay_as_you_earn_payment: [111.30, 0]Append to T/pay_as_you_earn/pay_as_you_earn_payment.yaml (pins (d)):
- name: Case 13, a payment within half a cent below 5 dollars rounds to 5 and rises to 10.
period: 2026-01
absolute_error_margin: 0.001
input:
people:
person1:
age: 35
federal_student_loan_balance: 30_000
federal_student_loan_interest_rate: 0.06
tax_units:
tax_unit:
members: [person1]
filing_status: SINGLE
adjusted_gross_income: 24_539.52
households:
household:
members: [person1]
state_code: TX
output:
# 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: 10If you later drop the round-before-threshold choice, pin the rounding with PAYE Case 1's inputs and absolute_error_margin: 0.001 instead (expected 133.83).
SA3. Two IBR cases are named "Case 17".
- Where:
T/income_based_repayment/income_based_repayment_payment.yaml:400("Case 17, a payment one dollar of income below 5 dollars is zero.", from #10152) and:423(this PR's "Case 17, spouses with different interest rates ..."), then Cases 18-21. - Evidence: the merge
b59f7ab4a5kept both numberings. #10152 inserted a new Case 15 and shifted its old 15-16 to 16-17, while this PR had already numbered its cases 17-21. No comment or body text refers to these case numbers. - Why should address: test naming, created by this PR. The effect is cosmetic, because the runner prints full names, so it sits at the low end.
- Fix: renumber the PR's cases to 18-22 before you add SA2's Cases 23-24:
sed -i '' -e '423s/- name: Case 17,/- name: Case 18,/' -e '453s/- name: Case 18,/- name: Case 19,/' -e '481s/- name: Case 19,/- name: Case 20,/' -e '510s/- name: Case 20,/- name: Case 21,/' -e '540s/- name: Case 21,/- name: Case 22,/' policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.yamlSuggestions
G1. Statute links. The uscode.house.gov view.xhtml?req=granuleid:... links return HTTP 404 today, and the PR adds two more (income_driven_repayment_adjusted_gross_income.py:24, parent_plus_standard_repayment_payment.py:30). Both also cite the whole of 1087e. For new references, use paragraph links: https://uscode.house.gov/us/usc/t20/s1087e/#q-4-A or https://www.law.cornell.edu/uscode/text/20/1087e#q_4_A, and (d)(1)(A) for the Parent PLUS payment. About 64 repository files use the old form, so a repo-wide fix belongs in a separate PR.
G2. Paragraph cites to add or tighten.
V/federal_ffel_loan_balance.py: add#p-685.209(g)(3), and (f)(2)(ii)/(f)(3)(ii) after C1; the docs rely on both.V/income_driven_repayment/income_driven_repayment_eligible.py:19-22: the docs cite (c)(2)-(5), the tuple only (d)(1)-(2). Add (c)(3)-(c)(5), and (d)(3) for ICR loans.- "20 U.S.C. 1087e(d)(6)-(7)" (Parent PLUS
:45, older IBR and PAYE lines): the borrower-level bar is in the (d)(1) chapeau and (d)(7)(A). V/federal_student_loan_interest_rate.py: the docs apply the rate to FFEL and Parent PLUS, the references cover Direct Loans. Cite 34 CFR 682.202(a), or call one rate per person a simplification, as the PR body does.
G3. AGI default. C2's text states the direction. Also say that the RAP dependent count and family size come from current-year inputs, so an AGI override from an earlier return mixes years. The size, for one year of model AGI growth (3.7%): RAP +$22.09 a month on average (up to +$47.59), IBR up to +$22.31. See Out of scope for the dataset follow-up.
G4. Record why the RAP share differs from the IBR share. Case 22 pins the literal (g)(3)(i) reading: $125 here against $31.25 under an IBR-like reading. Cite the proposed rule (91 FR 4284-85) and the RISE preamble (#page=71), which copy the IBR (g)(1)(i) approach "to provide consistency with IBR" and do not discuss FFEL. Revisit the reading if ED publishes servicing guidance.
G5. Shared helpers. The couple's Direct + FFEL balance is computed in income_driven_repayment_balance_share.py, repayment_assistance_plan_payment.py and the standard payment. min_(max_(amortized, minimum), balance) appears in four files, and the round, small-payment and cap block in IBR, PAYE and SAVE. One helper each would keep the rules in step.
G6. Uprating exemption guard. tools/default_uprating.py:106-107 says "Loan balances", but mortgage and auto loan balances stay uprated. Write "Federal student loan balances". Add a pytest assertion that each name in NO_DEFAULT_UPRATING_VARIABLES exists, has uprating is None and is a STOCK. Today, removing a name from the set leaves all 141 pytest cases passing, and only the YAML carry-forward cases catch it.
G7. Two doc sentences. Copy PAYE's sentence on the $4.995 rounding band (pay_as_you_earn_payment.py:26-30) into the IBR docs, which run the same code. State in the standard payment docs that one $50 minimum covers the Direct and FFEL loans together (see the FFEL $600 item under Investigated and cleared).
G8. programs.yaml. The SAVE note (:1636) omits "tax-dependent borrowers treated as ineligible", which the other IDR notes now carry. No entry names standard_repayment_payment or the new parent_plus_standard_repayment_payment. Consider a standard_repayment entry.
G9. Changelog type. The PR adds four public variables. An added fragment (changelog.d/student-loan-idr-restrictions.added.md) would make the release a minor bump. The fixed fragment is also defensible.
G10. Marital units in two test groups. RAP base payment Cases 10-16 (E1-E7) and IBR Case 21 list two unrelated single filers without marital_units. The builder puts both in one two-person marital unit. The values do not change, but give each person their own marital unit so the cases model what their names say.
G11. The July 1, 2026 bar works by year. The Parent PLUS payment extends #10152's pattern: a flag set for 2026 moves all of 2026 to the tiered plan, which then pays from January. The two plans never both pay. Document this, or gate the plans with a parameter in effect from 2026-07-01, as RAP's in_effect does.
G12. A one-unit couple forced to SEPARATE. If a user sets filing_status: SEPARATE on a one-unit couple, the spouse's income and loans count (RAP [500, 0] instead of [100, 0]). The model never builds this unit itself. Gating the spouse's balance on a joint return would make the (e)(2)(i) condition explicit.
Investigated and cleared
- A separate FFEL minimum of $600 a year (34 CFR 682.209(c)(1)(i)). Rejected. It applies per borrower across that borrower's FFEL holders and yields to an income-based plan; the statute's couple-level minimum was struck in 1992 (Pub. L. 102-325 sec. 416(d)). The (f)(2)(ii)/(f)(3)(ii) amount is one 10-year plan on all eligible loans (682.215(d)(1)(i) counts Direct and FFEL together) with one $50 minimum (685.208(b)(1)(ii)). $2,000 Direct and $2,000 FFEL at 6% give a $50 standard payment and $25 IBR, at the head and under the C1 fix.
- Cites: (b)(6) on
federal_ffel_loan_balanceis the excepted-consolidation definition the docs exclude. The (c)(2)-(5) and (c)(6) cites in the eligibility docs are accurate: no eligibility paragraph excludes tax dependents. - RAP: the literal (g)(3)(i) share in Case 22 follows the text ((b)(5) defines eligible loans for (g); (g)(3) has no (g)(1)(ii) step). The head-or-spouse gate is required, because an FFEL-only spouse's debt counts. ICR stays unprorated, because (g)(1) covers only (f)(1)-(3).
- Married filing separately as two tax units is correct (IBR Case 9, PAYE Case 7, RAP integration Case 2).
- The current-year AGI default is a documented proxy with an input override and the
clean_vehicle_credit_prior_year_magiprecedent; its population effect today is nil. A split AGI read inside the RAP base payment is not plausible: oneagiread feeds both the rate and the $10,000 test (V/repayment_assistance_plan/repayment_assistance_plan_base_payment.py:27-30). - Uprating exemption design: core has no variable-level "no uprating" flag, so a name set in
default_uprating.pyfits. The three balances are the only float USD stocks without an uprater.student_loan_interestkeeps CPI-U, which is right for a flow. /current/eCFR anchors against 2026-01 test periods: same substance in both versions.- PR-body figures reproduce ($62.50, $16.67, $86.06, $50.19, $111.02, $25.09, $333.06, $253.16, $31,626, $33,271, $30,000). The $300 + $300 and $340.21 figures in item 4 are C1.
- Partner contract tests: none touched.
Out of scope / follow-up
- Direct consolidations on main.
standard_repayment_paymentamortizes the Direct Consolidation Loans infederal_student_loan_balanceover 10 years. Those that entered repayment after 2006-06-30 use the (b)(2) term (same class as C3). Open an issue. - Item 3, tax-dependent borrowers (C17): deferred by the PR to a follow-up that moves the plan calculations to a per-borrower unit.
- AGI in microsimulation. When microcosm#1246 and microcosm#1357 write the balances, dataset runs will have prior-year AGI. Then read
adjusted_gross_incomeatperiod.last_yearwhensimulation.is_over_dataset, and keep the current-year default for households. From then on, the default would be a biased microsimulation default. - Downstream targets. PolicyEngine/chronicle maps FSA FY2026 targets to
federal_student_loan_balance; without uprating, its target year must match the dataset year. - Other debts (mortgage and auto loan balances) remain uprated. That is a separate modeling decision.
Validation Summary
| Check | Result |
|---|---|
| CI | 34/34 pass at cb6363a7fa |
| Local tests | 17 changed YAML files 146/146; full student-loan folder (23 files) 186/186; test_uprating_extensions.py 141/141 |
| Partner contract tests | None touched |
| References | 20 hrefs added; all 33 eCFR anchors resolve; PDF pages correct. Non-corroborating or missing: C1, C2, C3. house.gov links 404 (G1) |
| Regulatory | FFEL proration, AGI wiring, order of operations, Parent PLUS bar: correct. Couple cap (C1) and consolidation term (C3): wrong |
| Code patterns | No hard-coded values; vectorized; entities and periods correct; ruff clean; changelog present and top level (G9) |
| Tests | 35 hand checks, all correct. 26/26 sampled rulespec-us#1455 cases match; C17 deferred |
| Mutation | About 110 mutants. Each PR behavior change is pinned (for example, dropping FFEL from the IBR/PAYE/SAVE denominator fails 7 cases; the old per-person cap fails 4). The survivors are the SA1 and SA2 gaps. Each proposed case fails on its mutant |
| C1 fix run | The per-borrower cap moves exactly the 8 cases in the C1 table; 186/186 with the new values. The 9 new cases pass at the head and under the fix |
| Microsimulation | No dataset writes the three balances (policyengine-us-data 42ed5d45, microcosm 5810fce). Every payment is $0 in microdata today, so the population effect is nil. microcosm#1246 and microcosm#1357 are drafts that wait on this PR |
| Branch | 7 ahead, 4 behind main; no overlap in student-loan files; GitHub merge state UNKNOWN |
| PR body | axiom: rulespec-us#1455 queued is present (process note only, not counted). Items 2 and 4 need updates for C2 and C1 |
| Other reviews | None on the PR |
Review Severity: REQUEST_CHANGES
Three critical items (C1-C3) and three should-address items (SA1-SA3) are open. Under Pavel's criteria, any open C or SA means REQUEST_CHANGES.
Next Steps
To auto-fix issues: run the fix-pr workflow for this PR.
- C1 (run SA3's line-number command first if you use it): make
income_driven_repayment_standard_paymenta Person amount and cap IBR and PAYE after the proration. Update the 8 expected values in the C1 table, the names, comments, headers, docs and references, the changelog and PR body item 4. Correct #10411 item 4. - C2: replace the AGI variable's docs and references as given, and update its test header and PR body item 2.
- C3: add the consolidation flag and the (b)(2)(iii) term scale, use them in
parent_plus_standard_repayment_payment, add the tier tests, and fix the docs of both files. Open an issue for the same gap instandard_repayment_payment. - SA3: renumber IBR Cases 17-21 (from
:423) to 18-22. - SA1: add the five cases (balance share Cases 7-8, RAP payment Cases 24-25, RAP eligibility Case 6).
- SA2: add the four cases (standard payment file Case 7 with the fixed-rule value, IBR Cases 23-24, PAYE Case 13).
- Take the suggestions that you agree with (G1-G12).
- Run
make format. Then runpolicyengine-core test policyengine_us/tests/policy/baseline/gov/ed/student_loans -c policyengine_us(195 cases plus the C3 tests) andpytest policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py.
Brings in #10453 (poverty guideline uprating), which also edits test_uprating_extensions.py and programs.yaml in other places; both merged automatically. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqCGPgxDukji8Bhs57c5rH
…idated parent PLUS loans by amount Address the review of #10415: - 20 U.S.C. 1098e(b)(6)(A) caps the payment "for all loans made to the borrower" at that borrower's 10-year standard payment, and 34 CFR 685.209(e)(2)(i) brings a spouse's loans into the (g) proration only. income_driven_repayment_standard_payment is now a Person amount on the person's own Direct and FFEL loans; IBR and PAYE prorate first and cap at the Direct Loan part of that amount. - income_driven_repayment_adjusted_gross_income cites 685.209(b)(10) for the IBR, SAVE, PAYE and ICR plans and keeps 1087e(q)(4)(A) for RAP. - A Direct Consolidation Loan that repaid parent PLUS loans and entered repayment on or after July 1, 2006 repays over 10 to 30 years (685.208(b)(2)(iii); 685.220(i)(2)(ii), (i)(3)(i)): new input federal_parent_plus_loan_is_consolidated and parameter standard_repayment.consolidation_repayment_period. - New FFEL, Parent PLUS, dependent, MFS, barred-spouse, final-payment and rounding edge cases; IBR cases renumbered 18-22. - Paragraph-level statute links, tighter cites, a guard test for the uprating exemption, programs.yaml notes, and an added changelog type. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqCGPgxDukji8Bhs57c5rH
|
@PavelMakarchuk thanks for the review. Everything is in fa6ac3a, which also merges main through 14c6413e4a (#10453's poverty guideline uprating; no student loan file overlaps, and both shared files merged cleanly). CriticalC1, per-borrower cap: fixed. 1098e(b)(6)(A) ("for all loans made to the borrower under part B or D") settles it, and (e)(2)(i) keeps the spouse's debt in (g).
Unchanged values, rewritten comments: IBR 8 and 20-22, PAYE 3, 4 (now "each spouse's own standard payment caps..."; 783.60 and 195.90 capped at 444.08 and 111.02) and 12. Names, headers, docs, C2, AGI references: fixed. The docs now split the sources: 1087e(q)(4)(A) for RAP, and 685.209(b)(10)(i) for IBR, SAVE, PAYE and ICR, with (l)(1)-(3), 1098e(c)(2)(A)(i), (e)(1)(i), (e)(1)(ii) for ICR, and (l)(6). They state the direction of the default (it overstates payments when income rose) and, from G3, that family size and RAP's dependent count stay current-year. References: 1087e#q_4_A, 1098e#c_2_A_i, (b)(10), (e)(1)(i), (e)(1)(ii), (l)(1), (l)(6). The test header and PR item 2 match. No values change. C3, consolidated Parent PLUS term: fixed. New Person input
Docs in both files updated. The same gap in Should addressSA1, five FFEL and excepted-loan cases: added as proposed. Balance share Case 7 ([1, 0.4]) and Case 8 ([1, 0, 0]); RAP payment Case 24 (eligible [true, false, false], A = 8% × 90,000 / 12 − 50 = 550, P [550, 0, 0]) and Case 25 (January 100, February 0, 2027 total 100); RAP eligibility Case 6 (FFEL-only borrower not eligible; I cited 1087e(d)(1)(F), (q)(1) rather than (q)(1)(A)). SA2, four IBR and PAYE cases: added.
SA3, duplicate Case 17: fixed. This PR's IBR cases are now 18-22, and the new ones 23-24. Suggestions
Other
🤖 Generated with Claude Code |
#10392 subtracts the 2021-2023 partial pension, annuity, and IRA income exemption in mt_agi_indiv and mt_agi_joint rather than through mt_subtractions, so a Hypothesis-drawn pensioner with no other Montana adjustment reported Montana AGI below federal AGI and failed the pooling property. Require both exemption amounts to be zero for that property and for the seeded grid's matching guard. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Part of #10411. This PR addresses items 1, 2, 5 and 6 and documents item 3. Item 4 asked for a couple's cap; the law caps each borrower, so this PR keeps per-borrower caps and adds the borrower's own FFEL loans to them (correction posted on #10411). #10152 (PAYE, ICR, tiered standard plan, July 2026 loan bar) merged first, so this PR also carries the FFEL and AGI changes into PAYE and ICR, and applies the July 2026 bar to the Parent PLUS payment.
What changed and why
1. FFEL debt in spousal proration (34 CFR 685.209(b)(5), (e)(2)(i), (g)(1)(i)–(ii), (g)(3)(i)).
federal_ffel_loan_balance(Person, YEAR, not uprated; see item 5): eligible FFEL loans under (b)(5)(ii), excluding Federal PLUS loans to parents and excepted consolidation loans. The Direct Loan plans don't repay these loans; the balance enters the proration, the borrower's own IBR and PAYE cap, and the term of a consolidated Parent PLUS loan.income_driven_repayment_balance_share): (g)(1)(i) times (g)(1)(ii) is (own Direct + own FFEL) / couple's (Direct + FFEL) × own Direct / (own Direct + own FFEL), which simplifies to own Direct / couple's (Direct + FFEL). The couple is the head and spouse of a joint tax unit.repayment_assistance_plan_payment): (g)(3)(i) share = (own Direct + own FFEL) / couple's (Direct + FFEL). (g)(3) has no FFEL adjustment like (g)(1)(ii), and RAP can't repay FFEL loans ((d)(4)), so the code follows the literal formula: a borrower's own FFEL loans raise their share of the payment on their Direct Loans. Case 22 inrepayment_assistance_plan_payment.yamlpins this. The proposed rule (91 FR 4284-85) and the final rule's preamble (91 FR 23838) copied the IBR proration "to provide consistency with IBR" without discussing FFEL loans; the variable documentation records this.2. AGI year.
income_driven_repayment_adjusted_gross_income(TaxUnit, YEAR). For RAP, 20 U.S.C. 1087e(q)(4)(A) defines AGI "for the most recent taxable year". For IBR, SAVE, PAYE and ICR, 34 CFR 685.209(b)(10)(i) defines income as AGI as reported to the IRS, which the Department takes from the latest return it obtains (685.209(l)(1)-(3); 20 U.S.C. 1098e(c)(2)(A)(i)). Whose income counts follows 685.209(e)(1)(i), and (e)(1)(ii) for ICR.clean_vehicle_credit_prior_year_magipattern:adds = ["adjusted_gross_income"], so it defaults to the current year's AGI, because household inputs usually cover only the simulated year. The default overstates payments for borrowers whose income rose since their last return. An explicit input overrides it; family size and RAP's dependent count still come from the current year.repayment_assistance_plan_base_payment;income_contingent_repayment_income_percentage_factor.4. Each borrower's own standard payment caps IBR and PAYE (20 U.S.C. 1098e(b)(6)(A); 34 CFR 685.209(e)(2)(i), (f)(2)(ii), (f)(3)(ii), (g)(1)).
income_driven_repayment_standard_payment(Person, MONTH): the 10-year standard payment on the person's own Direct and FFEL loans at theirfederal_student_loan_interest_rate, with one $50 minimum for both and a cap at the eligible balance.P_i = min(rate_i × DI × share_i, S_i × Direct_i / (Direct_i + FFEL_i)), then round to the cent before the (g)(1)(iii) small-payment test, apply that test, cap at the borrower's own Direct balance and apply Add Pay As You Earn, Income-Contingent Repayment and tiered standard repayment payments, and the July 1, 2026 loan bar #10152's July 2026 bar. rate_i stays each spouse's own (10% for new borrowers, 15% otherwise).student_loan_amortization_helpers.py, the same file Add Pay As You Earn, Income-Contingent Repayment and tiered standard repayment payments, and the July 1, 2026 loan bar #10152 added.5. Documentation.
federal_student_loan_balance,federal_parent_plus_loan_balanceandfederal_ffel_loan_balanceno longer declare CPI-U uprating.policyengine_us/tools/default_uprating.pyalso exempts them from the default dollar-input uprating (AGI per capita) through a smallNO_DEFAULT_UPRATING_VARIABLESset;_is_float_dollar_inputis unchanged for every other variable. A new test intest_uprating_extensions.pychecks that each exempt name exists, declares no uprater and is a stock.upratingremoved alone (the default AGI-per-capita uprating applies); $30,000 in 2025 and 2026 with the exemption, for a dataset-backedMicrosimulationand a householdSimulation.income_driven_repayment_eligibleandrepayment_assistance_plan_eligibledocument this as a modeling restriction. The code treats these borrowers as ineligible ($0), but the law doesn't exclude them, and RAP's $10 minimum would apply (companion case C17).programs.yamlnotes are updated to match.6. Parent PLUS standard payment (34 CFR 685.208(b)(1)-(2); 685.220(i)(2)(ii), (i)(3)(i)).
parent_plus_standard_repayment_payment(Person, MONTH): the standard payment onfederal_parent_plus_loan_balanceat the person'sfederal_student_loan_interest_rate, with the $50 minimum and the cap at the balance. $30,000 at 6% over 10 years: $333.06; the code gave $0 before.federal_parent_plus_loan_is_consolidatedand parametergov.ed.student_loans.standard_repayment.consolidation_repayment_period(thresholds $0, $7,500, $10,000, $20,000, $40,000, $60,000; 10, 12, 15, 20, 25, 30 years). $30,000 consolidated at 6%: $214.93 over 20 years.standard_repayment_payment, so the Direct Loan standard payment and the income-driven caps stay on the loans those plans can repay.standard_repayment_payment(code on main) is Standard repayment: Direct Consolidation Loans that entered repayment after June 30, 2006 use the 10-to-30-year term #10485.Scope
Companion cases (TheAxiomFoundation/rulespec-us#1455, #1711)
Expected values are hand-computed from the law with fractional cents and
absolute_error_margin: 0.01. RAP cases are labelledrulespec-us#1455 Cn/Enin their names. The IBR, PAYE and Parent PLUS cases for this head are posted as dispatch payloads on #1455 and #1711.repayment_assistance_plan_base_payment.yamlCases 1–4, 6–8repayment_assistance_plan_base_payment.yamlCase 9repayment_assistance_plan_base_payment.yamlCases 10–16repayment_assistance_plan_payment.yamlCase 6repayment_assistance_plan_payment.yamlCase 20repayment_assistance_plan_payment.yamlCase 10repayment_assistance_plan_payment.yamlCase 16repayment_assistance_plan_payment.yamlCase 14repayment_assistance_plan_payment.yamlCase 17repayment_assistance_plan/integration.yamlCase 4repayment_assistance_plan_payment.yamlCase 21repayment_assistance_plan/integration.yamlCase 2repayment_assistance_plan_payment.yamlCase 12repayment_assistance_plan_payment.yamlCase 15Other new cases:
Testing
Head fa6ac3a, locally (Python 3.11, policyengine-core 3.33), one file group per process:
test_uprating_extensions.pyandtest_programs_registry.py: 943 passed, 10 skipped.parameters.py,test_parameter_yaml_keys.py, reference URL tests,variable_names.py,test_uprating_placement.py,test_no_group_variables_in_person_adds.py,test_source_conflicts.py): 269 passed, 1 failed. The failure,test_source_conflicts.py::test_unscannable_files_fail_closed, is a Python 3.11 tokenizer difference in the scanner's self-test; that test file andsource_conflicts/are identical to main, and this PR touches no source-conflict file. CI runs a newer Python.core/test_per_capita_uprating.py,core/test_uprating_series.py,core/test_pre_first_modeled_year_uprated_inputs.py,test_system_import.py,microsimulation/data/test_extend_single_year_dataset.py) passed, and the $30,000 balance read unchanged in 2025 and 2026.No partner test files changed.
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
https://claude.ai/code/session_01LqCGPgxDukji8Bhs57c5rH