Repository navigation
Apply the standard plan's $50 minimum once across a borrower's Direct and Parent PLUS loans - #10599
Conversation
…LUS loans 34 CFR 685.208(b)(1) gives Direct Subsidized, Unsubsidized and PLUS borrowers one standard plan, and (b)(1)(ii) sets its minimum on the borrower's payments. standard_repayment_payment and parent_plus_standard_repayment_payment each apply it, so a borrower with both loan types got two. Add combined_standard_repayment_payment, which adds the two level payments before one minimum, except for a parent PLUS consolidation loan under (b)(2), a separate plan with its own minimum. The existing variables keep their values, sharing the amortized amounts through helpers. Fixes #10567. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bound, agreement, one-loan-type and monotonicity properties all hold for the sum of the two existing variables, so a minimum on each loan type passed the generated cases. Compare unconsolidated rows with min(max(Direct + parent PLUS level payments, 50), total balance), written in the test from 685.208(b)(1). Point the register entries at #10599 and rewrap a documentation line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Ready for hub review HEAD: Ownership check before starting: the branch returned 404, and searching open PRs for "minimum standard" and "parent plus" found no PR for this change. Item steps
Expected values (6%, f = .005 / (1 − 1.005^−120) = .01110205)
Tests
Mutants (one process per test file, code restored after each)
Judgment calls
Follow-up issue I'd proposeModel the plan each loan type is repaid under, so a borrower's standard payment total reads one variable. Today a caller picks between Generated by Claude Code |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #10599 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 1 4 +3
Lines 15 77 +62
=========================================
+ Hits 15 77 +62
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 code, the tests and the numbers hold up. The one blocker is mechanical: the branch now conflicts with FindingsP1. Merge conflict with
|
| Case | Inputs | D | P | C | Arithmetic |
|---|---|---|---|---|---|
| 1 | 1,000 Direct, 20,000 PLUS | 50 | 222.04 | 233.14 | 11.1021 + 222.0410 = 233.1431 |
| 2 | 1,000, 1,000 | 50 | 50 | 50 | 22.20 is below 50, so 50 |
| 3 | 5,000, 5,000 | 55.51 | 55.51 | 111.02 | 2 × 55.5103 |
| 4 | 1,000, 20,000, consolidated | 50 | 143.29 | 193.29 | 20,000 + min(1,000, 20,000) = 21,000, in the 20,000–40,000 tier, so 20 years; 20,000 × f(240) = 143.2862; 50 + 143.29 |
| 5 | 30, 10 | 30 | 10 | 40 | 0.44 is raised to 50, then capped at the 40 balance |
| 6 | 0, 20,000 | 0 | 222.04 | 222.04 | 20,000 × f(120) |
| 7 | 20,000, 0 | 222.04 | 0 | 222.04 | 20,000 × f(120) |
| 8 | 1,000, 20,000, new loan | 0 | 0 | 0 | gate |
All eight match the brief's table and the YAML. For Case 4, I checked the tier against the model's consolidation_repayment_period.yaml (thresholds 0 / 7,500 / 10,000 / 20,000 / 40,000 / 60,000, giving 10 / 12 / 15 / 20 / 25 / 30 years). The $21,000 total falls in the fourth band, which is (D) if the regulation letters the bands in that order. I couldn't read the regulation's table itself (see below). The register example "max($11.10 + $143.29, $50) = $154.39" also checks: 11.1021 + 143.2862 = 154.3883.
Tests rerun, one process each, at the head:
combined_standard_repayment_payment.yaml: 8 passed;standard_repayment_payment.yaml: 7 passed;parent_plus_standard_repayment_payment.yaml: 10 passed;gov/ed/student_loans/integration.yaml: 3 passed;test_combined_standard_repayment_property.py: 2 passed, 10.0 s inside pytest, 55 s wall including the model import, peak RSS 0.88 GiB (local VM, not CI). This matches the hand-off. It needs thedevextra: withouthypothesisinstalled it reports1 skipped. CI does run it, since the Rest Python shards passed;code_health/test_source_conflicts.py: 103 passed.
The property tests.
-
Differential: the reference is written from the law, not copied from the code. It has its own
amortized(), its own constants 120 / 50, and its own tier table, and it doesn't import the helpers. It is the property that separates one minimum from D + P, as the hand-off says. -
Monotonicity restriction: skipping variants whose consolidation term changes hides no defect of this PR. Witness: a consolidated $15,000 Parent PLUS loan at 6%, with Direct rising from $4,999 to $5,001.
- The consolidation total crosses $20,000, so the term goes from 15 to 20 years.
- C = D + P falls from 55.50 + 126.58 = 182.08 to 55.52 + 107.46 = 162.99.
- That drop is the tier cliff in the regulation's term table. It is inherited unchanged from
parent_plus_standard_repayment_paymentonmain.
Where the loan isn't consolidated, the formula is monotone, so the restriction removes nothing there.
-
Boundaries: the generated balances (0, 1–6,000 and 1–80,000, at rates 0–8%) reach both sides of the ~$4,121–$6,000 crossover where a level payment equals $50. Zero balances come up often, and consolidation totals reach the 60,000 tier. Totals below $50 are rare in generation; YAML Case 5 pins that region.
Mutants. I ran each on a scratch edit, with one process per test file, cleared .hypothesis before each property run, and restored the file after each one. git status was clean at the end.
| Mutant | New YAML | Property file |
|---|---|---|
| Two minimums (D + P on every row) | Cases 1, 2 fail | test_one_standard_plan_minimum and ..._hand_case fail |
| No balance bound | Case 5 fails | test_one_standard_plan_minimum fails |
| One minimum for consolidated loans too | Case 4 fails | test_one_standard_plan_minimum fails |
| Drop the new-loan gate | Case 8 fails | test_one_standard_plan_minimum fails |
New: the minimum applied after the balance bound, max_(min_(amortized, total), 50) |
Case 5 fails | test_one_standard_plan_minimum fails |
Readers. I grepped policyengine_us/ (variables, parameters, programs.yaml) for each variable.
combined_standard_repayment_payment: no reader.standard_repayment_paymentandparent_plus_standard_repayment_payment: the only formula reader of either is the new variable. Other mentions are documentation strings and comments infederal_student_loan_interest_rate.py,federal_parent_plus_loan_balance.py,tiered_standard_repayment_payment.pyandincome_driven_repayment_standard_payment.py.- Tests: they appear in the three standard plan YAML files,
integration.yaml,tiered_standard_repayment_payment.yamland the property file. - Household totals: nothing in the model sums D + P, so I have no household-total double count to witness inside the model. Any downstream caller that adds the two variables still gets two minimums. The documentation now points those callers to the combined variable.
- Partner tests: nothing under
tests/policy/baseline/partners/reads any of the three variables.
Baseline impact. This diff can't move any existing output: the two existing variables are bit-identical on the grid, and the new variable has no readers. So no population check is needed beyond what the hub already plans. impact: pending (hub) is present.
Body and documentation.
Fixes #10567is present.- The
axiom:line is 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, which I fetched and compared. - The body's table, invariants and test counts match my runs.
- Each sentence of the new documentation describes the code I read: the (b)(1) sum-then-minimum, the (b)(2) sum, FFEL excluded, mixed plans not modelled, and the new-loan zero. The same holds for the two pointer sentences.
- Neither the new documentation nor the
maindocumentation ofvariables/gov/ed/student_loans/describes summing the two variables. - The changelog fragment is a top-level
changelog.d/standard-minimum-per-borrower.added.md.
What the brief asked for that the PR doesn't do: nothing. Every numbered step of item 2 is present. The extra differential property is an addition beyond the brief.
Register. pe_us.follows: gap-fill is the best of the guard's five values (statute, conflicting-source, gap-fill, not-modelled, unknown).
- The question is which reading of the regulation applies, and the brief and the entries say the statute sets no minimum, so
statuteandconflicting-sourcedon't fit. - The file's legend describes gap-fill as "the code follows the form". Here the code follows a regulation, which the project treats as law. So the label is a slight stretch, but nothing better exists.
- I couldn't fetch 1087e(d)(1)(A) to confirm its silence myself.
- It adds
us:idr-10-year-cap-before-spousal-prorationwithprogram: student-loans,follows: gap-fill,state: ruled, so both PRs introduce thestudent-loansslug, and they use it consistently. - Its entry sits near line 10398 and this PR's near 10723.
git merge-treeshows that Apply the IBR and PAYE 10-year cap before spousal proration #10570 merges cleanly with this PR and with currentmain. - Results: Apply the IBR and PAYE 10-year cap before spousal proration #10570 changes the IBR and PAYE payment formulas and
income_driven_repayment_standard_payment/_balance_share. This PR touches none of those, andincome_driven_repayment_standard_paymentreads neither standard plan variable (it callsamortized_monthly_payment, which this PR leaves untouched). So neither PR changes the other's results. - Order: none is needed. This PR can land first once its conflict with
mainis fixed. - Apply the 10-year cap before the spousal proration in IBR and PAYE #10602: an open PR on the same IDR change as Apply the IBR and PAYE 10-year cap before spousal proration #10570. It also merges cleanly with this PR.
Scope. Nothing under .github/ or policyengine_us/tests/policy/baseline/partners/ is changed. An open-PR search for each changed file name (standard_repayment_payment, parent_plus_standard_repayment_payment, combined_standard_repayment_payment, student_loan_amortization_helpers) finds only this PR.
CI at 66644b2655.
- The latest Pull request run is 41499431913: completed / success, with all 35 of 35 jobs successful, including Household API Partners, Microsimulation, all baseline and contrib shards, and Rest Python shards 1–8.
- An earlier run, 41488335352, failed in Rest Python shard 2 on
tests/core/test_trustees_2026_economic_assumptions.py::test_the_tree_has_the_expected_readers(SSI parameter readers). That failure is unrelated to this diff, and it passed on the rerun at the same head. - Because of the merge conflict, CI won't run again until
mainis merged in.
What I couldn't check
-
Sources. This session's network refused ecfr.gov, govinfo.gov, law.cornell.edu, uscode.house.gov, federalregister.gov and web.archive.org (proxy 403 / DNS refusal). So I couldn't quote or confirm:
- 34 CFR 685.208(b)(1), (b)(1)(ii), (b)(2), (b)(2)(ii) or the (b)(2)(iii) balance table;
- 20 U.S.C. 1087e(d)(1) or (d)(7).
The (b)(1)(ii) quote in the body comes from the brief and The standard plan's $50 minimum applies once to a borrower, but Direct and Parent PLUS payments each apply it (34 CFR 685.208(b)(1)(ii)) #10567, and the hand-off says it wasn't verified either. I checked Case 4's term only against the model's own parameter file.
-
The new-loan gate. I can't say from the statute whether "new borrower" in (d)(7)(A) means the borrower or each loan. The code applies a person-level flag (
has_federal_student_loan_made_on_or_after_2026_07_01), which fits a borrower-level reading, and the same gate exists onmainin both existing variables. -
Downstream callers. I couldn't check whether any API or app caller outside this repository sums the two existing variables.
-
Population data. No microsimulation or population-data check: the hub runs the impact pair.
Generated by Claude Code
Both sides added entries to source_conflicts/candidates.yaml at the same point. Keep all three: the file sorts by id, so this PR's two standard plan entries go before us:student-loan-interest-joint-claimed-spouse. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 2017 and 2025 CFR editions carry the same "$50 per month" sentence at 685.208(b)(2), and the model's minimum_payment parameter starts in 2016, so the question doesn't begin with the 2026 text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
US + core hub merge audit, us#10599 at
|
Adds
combined_standard_repayment_payment, the standard plan payment for a borrower with all of their Direct and parent PLUS loans on the standard plan, with one $50 minimum across both where both are under 34 CFR 685.208(b)(1).Fixes #10567
The law
The minimum applies once per plan, not once across everything a borrower repays. A parent PLUS loan repaid by such a consolidation loan sits under (b)(2) and keeps its own minimum, so a borrower with Direct Loans under (b)(1) and that consolidation loan owes a minimum on each plan. This narrows the law section in #10567, which reads "one minimum on all of a borrower's standard plan payments".
What the variable does
min(max(direct_amortized + parent_plus_amortized, $50), direct_balance + parent_plus_balance).federal_parent_plus_loan_is_consolidated):standard_repayment_payment + parent_plus_standard_repayment_payment.The amortized amounts come from two helpers in
student_loan_amortization_helpers.py, which the two existing standard plan formulas now call, so the three standard plan variables share one copy of the term and rate logic. Not modeled: FFEL loans, and a borrower holding Direct Loans on one plan and parent PLUS on another, for whom the two existing variables stay the right read.Numbers
One single filer in Ohio, 2026, 6%. 120-month factor .005 / (1 − 1.005^−120) = .0111021.
standard_repayment_paymentparent_plus_standard_repayment_paymentcombined_standard_repayment_paymentThe consolidated row runs 20 years, because $20,000 + min($1,000, $20,000) = $21,000 falls in (b)(2)(iii)(D): .005 / (1 − 1.005^−240) × $20,000 = $143.29.
Invariants
Checked by
policyengine_us/tests/test_combined_standard_repayment_property.py(Hypothesis, 10 examples of up to 8 borrowers, each in five variants, one vectorized simulation per example), with D, P and C the Direct, parent PLUS and combined variables:max(D, P) ≤ C ≤ D + P, andC ≤the total balance.C = D + Pwhere both amortized amounts reach the minimum, and where the parent PLUS loan is consolidated.What doesn't change
standard_repayment_paymentandparent_plus_standard_repayment_paymentkeep their values; their formulas now call the shared helpers with the same arithmetic. The parent PLUS payment keeps its own minimum for borrowers on RAP, IBR, PAYE or SAVE. No income-driven reader changes, and no existing output moves.Tests
Run locally, one process each:
standard_repayment/combined_standard_repayment_payment.yaml(new): 8 passed.standard_repayment/standard_repayment_payment.yaml: 7 passed;parent_plus_standard_repayment_payment.yaml: 10 passed, both unchanged.test_combined_standard_repayment_property.py: 2 passed; pytest reports 7.1 s, 2.1 s of it the 10 Hypothesis examples; about 50 s wall with interpreter start and model import; peak RSS 0.88 GiB. Shrinking is off, so a failure reports the example as drawn.code_health/test_source_conflicts.py: 103 passed.The property test adds a fifth property beside the four above: where the parent PLUS loan isn't consolidated, C equals an independently written
min(max(Direct level + parent PLUS level, $50), total balance). The four listed properties all hold for D + P, so without it only the hand case caught a minimum on each loan type.Mutants, one process per test file, code restored after each:
Register:
source_conflicts/candidates.yamlgainsus:standard-plan-minimum-per-borrowerandus:standard-plan-minimum-consolidated(stateruled,pe_us.follows: gap-fill). No# Source conflictmarker until the shared register holds the entries.impact: none. No formula reads the new variable, and the review ran both existing variables against main on a 3,584-row grid with a maximum difference of 0.0, so no baseline output can move.
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