Skip to content

Apply the standard plan's $50 minimum once across a borrower's Direct and Parent PLUS loans - #10599

Merged
MaxGhenis merged 5 commits into
mainfrom
standard-minimum-per-borrower
Nov 3, 2026
Merged

MaxGhenis merged 5 commits into
mainfrom
standard-minimum-per-borrower

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

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

  • 34 CFR 685.208(b)(1) gives one standard plan to Direct Subsidized, Unsubsidized and PLUS borrowers, and (b)(1)(ii) says "A borrower's payments under this repayment plan are at least $50 per month, except that a borrower's final payment may be less than $50."
  • 685.208(b)(2) is a separate standard plan for a Direct Consolidation Loan that entered repayment on or after July 1, 2006, with its own minimum in (b)(2)(ii).

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

  • Parent PLUS not consolidated: min(max(direct_amortized + parent_plus_amortized, $50), direct_balance + parent_plus_balance).
  • Parent PLUS consolidated (federal_parent_plus_loan_is_consolidated): standard_repayment_payment + parent_plus_standard_repayment_payment.
  • Zero for a borrower with a Direct Loan made on or after July 1, 2026, as in both existing variables.

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.

Direct Parent PLUS Consolidated standard_repayment_payment parent_plus_standard_repayment_payment combined_standard_repayment_payment
$1,000 $20,000 no $50.00 $222.04 $233.14
$1,000 $1,000 no $50.00 $50.00 $50.00
$5,000 $5,000 no $55.51 $55.51 $111.02
$1,000 $20,000 yes $50.00 $143.29 $193.29
$30 $10 no $30.00 $10.00 $40.00
$0 $20,000 no $0 $222.04 $222.04
$20,000 $0 no $222.04 $0 $222.04
$1,000 $20,000, new loan no $0 $0 $0

The 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:

  • Bound: max(D, P) ≤ C ≤ D + P, and C ≤ the total balance.
  • Agreement: C = D + P where both amortized amounts reach the minimum, and where the parent PLUS loan is consolidated.
  • One loan type: with one balance zero, C equals the other variable.
  • Monotonicity: C never falls as either balance rises, while the consolidation term holds still.

What doesn't change

standard_repayment_payment and parent_plus_standard_repayment_payment keep 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:

Mutant New YAML Property tests
Two minimums (D + P on every row) Cases 1 and 2 fail Hypothesis test (differential) and hand case fail; caught with an empty example database too
No balance bound Case 5 fails Hypothesis test fails
One minimum for consolidated loans too Case 4 fails Hypothesis test fails
No new-loan gate Case 8 fails Hypothesis test fails

Register: source_conflicts/candidates.yaml gains us:standard-plan-minimum-per-borrower and us:standard-plan-minimum-consolidated (state ruled, pe_us.follows: gap-fill). No # Source conflict marker 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

…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>
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Ready for hub review

HEAD: 66644b26553636d8e2251fa41bf71daa38e372d0 (draft)

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

  1. Existing variables kept. standard_repayment_payment and parent_plus_standard_repayment_payment give the same values: their YAML files pass unchanged (7 and 10). Their formulas now call the shared helpers, which hold the same arithmetic that used to sit inline.
  2. New variable. policyengine_us/variables/gov/ed/student_loans/standard_repayment/combined_standard_repayment_payment.py:
    • unconsolidated: one minimum, :53-56;
    • consolidated: the sum of the two variables, :59-65;
    • new-loan gate, :69-72.
      The amortized amounts come from direct_standard_amortized_payment and parent_plus_standard_amortized_payment in student_loan_amortization_helpers.py:23-60. The documentation names (b)(1), (b)(1)(ii), (b)(2) and (b)(2)(ii), and says what isn't modeled: FFEL loans, and Direct Loans and parent PLUS loans on different plans.
  3. Pointers. One sentence in each existing variable's documentation (standard_repayment_payment.py:19-21, parent_plus_standard_repayment_payment.py:35-38). A search of variables/gov/ed/student_loans/ found no other text describing a sum of the two.
  4. YAML. tests/policy/baseline/gov/ed/student_loans/standard_repayment/combined_standard_repayment_payment.yaml has eight cases: the seven table rows and a new-loan case giving 0. Every value matched the table when recomputed by hand.
  5. Property tests. policyengine_us/tests/test_combined_standard_repayment_property.py covers the four listed properties and a fifth, explained under judgment calls.
  6. Mutants. All four killed; table below.
  7. Register. source_conflicts/candidates.yaml:10726-10789 adds us:standard-plan-minimum-consolidated and us:standard-plan-minimum-per-borrower (program student-loans, state ruled, pe_us.follows: gap-fill, prs → Apply the standard plan's $50 minimum once across a borrower's Direct and Parent PLUS loans #10599). Neither has a marker, and test_source_conflicts.py passes (103).
  8. Changelog. changelog.d/standard-minimum-per-borrower.added.md, in Stop rebuilding dated parameter values for each pinned system #10550's shape.
  9. Body. Written in the item's order, with Fixes #10567, impact: pending (hub) and Count FFEL debt in student loan proration and caps, use the latest return's AGI, and add the parent PLUS standard payment #10415's axiom: line copied exactly.

Expected values (6%, f = .005 / (1 − 1.005^−120) = .01110205)

Case Direct, PLUS D P C Arithmetic
1 1,000, 20,000 50 222.04 233.14 11.1021 + 222.0410 = 233.1431 ≥ 50, ≤ 21,000
2 1,000, 1,000 50 50 50 11.10 + 11.10 = 22.20 → 50
3 5,000, 5,000 55.51 55.51 111.02 55.5103 × 2 = 111.0205
4 1,000, 20,000 consolidated 50 143.29 193.29 20,000 + min(1,000, 20,000) = 21,000 → 20 years; 20,000 × .005 / (1 − 1.005^−240) = 143.2862; 50 + 143.29
5 30, 10 30 10 40 0.333 + 0.111 = 0.44 → 50 → capped at 40
6 0, 20,000 0 222.04 222.04 20,000 × f
7 20,000, 0 222.04 0 222.04 20,000 × f
8 1,000, 20,000, new loan 0 0 0 1087e(d)(1), (d)(7)(A) gate

Tests

  • New YAML: 8 passed. standard_repayment_payment.yaml: 7 passed. parent_plus_standard_repayment_payment.yaml: 10 passed.
  • Property test: 2 passed. Pytest reports 7.1 s, 2.1 s of it the 10 Hypothesis examples of up to 8 borrowers × 5 variants. Wall time is about 50 s with interpreter start and model import, and peak RSS is 0.88 GiB (local, not CI). It runs in the Rest Python remaining group, picked up automatically, and the Makefile is unchanged.
  • code_health/test_source_conflicts.py: 103 passed. ruff format and ruff check are clean on the changed Python files.

Mutants (one process per test file, code restored after each)

Mutant New YAML Property file
Two minimums (D + P on every row) Cases 1, 2 fail Hypothesis test (differential property) and hand case fail; caught again with an empty example database
No balance bound Case 5 fails Hypothesis test fails
One minimum for consolidated loans too Case 4 fails Hypothesis test fails
Drop the new-loan gate Case 8 fails Hypothesis test fails

Judgment calls

  • A fifth property. The four listed properties all hold for D + P. So on the first mutant run, only the hand case caught "two minimums". I added a differential property: on unconsolidated rows, C equals min(max(Direct level + parent PLUS level, 50), total), written in the test from the law. After that the Hypothesis test catches it too.
  • Monotonicity. Monotonicity is checked only where the consolidation term (computed in the test from (b)(2)(iii)) stays the same, since a longer term can lower the payment.
  • Shrinking off. Hypothesis shrinking is off, as in Apply the IBR and PAYE 10-year cap before spousal proration #10570, because each example is a simulation.
  • pe_us.follows: gap-fill. The file has no sibling student loan entries; Apply the IBR and PAYE 10-year cap before spousal proration #10570 adds the first, also gap-fill. 20 U.S.C. 1087e(d)(1)(A) sets no minimum, and the regulation fills it. program: student-loans is a new slug.
  • Changelog type added. The change adds a variable and moves no existing output.
  • Documentation wording. The new variable's documentation and the two pointer sentences say "one minimum where both are under 685.208(b)(1)", so they don't claim one minimum for the consolidated case.
  • Sources not re-fetched. This session's network refuses ecfr.gov, law.cornell.edu, govinfo.gov and web.archive.org. So the (b)(1)(ii) quote 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 is not verified against the source in this session.
  • CI. CI hasn't run: the Pull request run at the head (41487221909) is completed / skipped, because the PR is a draft.

Follow-up issue I'd propose

Model the plan each loan type is repaid under, so a borrower's standard payment total reads one variable. Today a caller picks between combined_standard_repayment_payment and the two separate variables by knowing the borrower's plans. A per-person plan input, or an FFEL extension of the combined variable, would let household totals pick the right one.


Generated by Claude Code

@MaxGhenis
MaxGhenis marked this pull request as ready for review November 2, 2026 17:08
@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 (3ced1cc).
⚠️ Report is 151 commits behind head on main.

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     
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.

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Ready for hub review
VERDICT: REQUEST_CHANGES
66644b2
METHODOLOGY_CHOICE: yes

The code, the tests and the numbers hold up. The one blocker is mechanical: the branch now conflicts with main in source_conflicts/candidates.yaml, so it can't merge as it stands. After main is merged in and the conflict resolved as shown below, the only further check this needs is a quick look at that merge commit. The head is still 66644b2655, so nothing has changed since the hand-off.

Findings

P1. Merge conflict with main in source_conflicts/candidates.yaml

  • Where: source_conflicts/candidates.yaml:10726 (the two new entries).
  • Failure scenario: Bar only the claimed spouse from the student loan interest deduction (26 U.S.C. 221(c)) #10577 (12a633cb, now on main) inserted us:student-loan-interest-joint-claimed-spouse at the same point, right after us:ssi-spousal-deeming-.... GitHub reports the PR as mergeable_state: dirty, and the PR can't merge until this is resolved.
  • What I ran:
    $ git merge-tree --write-tree --name-only 66644b2655 origin/main      # origin/main = d4d39178
    source_conflicts/candidates.yaml
    CONFLICT (content): Merge conflict in source_conflicts/candidates.yaml
    <<<<<<< HEAD
    - id: us:standard-plan-minimum-consolidated
    - id: us:standard-plan-minimum-per-borrower
    =======
    - id: us:student-loan-interest-joint-claimed-spouse
    >>>>>>> origin/main
    
  • Fix: merge origin/main with a normal merge commit and keep all three entries. The file's ids are in sorted order (sort -c passes on main), so the PR's two entries (us:standard-...) go directly before us:student-loan-interest-joint-claimed-spouse. Then rerun policyengine_us/tests/code_health/test_source_conflicts.py.

P3. The register entries start in different years

  • Where: source_conflicts/candidates.yaml. us:standard-plan-minimum-consolidated has years.from: 2006, while us:standard-plan-minimum-per-borrower has years.from: 2026.
  • Failure scenario: the per-borrower question is the same one that would arise under the pre-2026 standard plan. The model's minimum_payment parameter has held $50 since 2016-07-01, and it cites "34 CFR 685.208(b) (as of November 17, 2016)". The variable computes for those years too, but the entry says the conflict starts in 2026.
  • Caveat: I couldn't fetch the 2016 text in this session, so I haven't confirmed that the older paragraph carries the same "a borrower's payments" wording.
  • Fix: either set from: 2016, matching the parameter's first value, or add a note explaining why 2026 is the start (for example, that the entry reads only the 2026 text of (b)(1)).

P3. The body overstates "the term and rate logic exists once"

  • Where: PR body, "What the variable does".
  • What's wrong: income_driven_repayment_standard_payment.py:44-47 still computes p.repayment_period * MONTHS_IN_YEAR and calls amortized_monthly_payment itself. The 10-year term logic therefore exists twice in the folder. The claim holds only for the three standard plan variables.
  • Replacement text: "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."

Checked and sound

The defect reproduces at main. I ran a 3,584-row grid in one simulation at cd06476e. For $1,000 Direct and $20,000 Parent PLUS at 6%, main gives D = 50.00 and P = 222.04, so the two variables sum to $272.04. For $1,000 of each, it gives 50 + 50 = $100. Under the ruling the right totals are $233.14 and $50.

The existing variables are unchanged. I ran the same 3,584-row grid at the head and at main, in separate processes with PYTHONPATH pointed at each tree. The grid crossed:

  • Direct balance: 16 values, including 40, 4,503 and 4,504 (around the $50 crossover), and the 7,499 / 10,000 / 19,999 / 20,000 / 39,999 / 40,000 / 60,000 tier edges;
  • Parent PLUS balance: 7 values;
  • FFEL balance: 0 and 5,000;
  • rate: 0, 3%, 6% and 8%;
  • consolidated flag and new-loan flag: both values each;
  • periods: 2026-01 and 2027-06.

The maximum absolute difference was 0.0 for both standard_repayment_payment and parent_plus_standard_repayment_payment in both periods. Reading the code agrees: the two helpers hold the removed inline lines verbatim, and the minimum, the balance bound and the gate stay in each formula.

The rule matches the two rulings.

  • Not consolidated: combined_standard_repayment_payment.py:53-56 computes min_(max_(direct_amortized + parent_plus_amortized, 50), direct_balance + parent_plus_balance). That is us:standard-plan-minimum-per-borrower exactly.
  • Consolidated: :59-65 returns D + P, which is us:standard-plan-minimum-consolidated.
  • New-loan gate: :69-72 applies it, and it is idempotent with the gates already inside D and P.
  • Edge cases: with the flag true and a zero Parent PLUS balance, the result is D. With a zero Direct balance, it is P.
  • Periods and filing status: the variable is per person per month and reads yearly inputs through period.this_year, so every month and year the change reaches uses the same rule. Filing status and spouses don't enter the formula, so joint filers are treated the same as single filers.
  • Monotonicity: I found no input where the code departs from the rulings. Where the loan isn't consolidated, every operation is monotone in each balance.

Hand values, computed before reading the YAML. f(120) = .005 / (1 − 1.005^−120) = .01110205 and f(240) = .00716431.

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 the dev extra: without hypothesis installed it reports 1 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_payment on main.

    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_payment and parent_plus_standard_repayment_payment: the only formula reader of either is the new variable. Other mentions are documentation strings and comments in federal_student_loan_interest_rate.py, federal_parent_plus_loan_balance.py, tiered_standard_repayment_payment.py and income_driven_repayment_standard_payment.py.
  • Tests: they appear in the three standard plan YAML files, integration.yaml, tiered_standard_repayment_payment.yaml and 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 #10567 is 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 main documentation of variables/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 statute and conflicting-source don'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.

#10570.

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 main is 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 on main in 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

MaxGhenis and others added 2 commits November 2, 2026 18:51
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>
@MaxGhenis
MaxGhenis merged commit 72de20e into main Nov 3, 2026
36 checks passed
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

US + core hub merge audit, us#10599 at 3ced1cc06efd033e29d894ad50ff623187009ebd (squash): Apply the standard plan's $50 minimum once across a borrower's Direct and Parent PLUS loans.

  • Review: cloud review r1 at 66644b2655 found one blocker, the conflict with main in the register, and two P3 notes, and otherwise passed: the 3,584-row differential against main gave 0.0, five mutants died, and CI was 35 of 35. The review said only the merge needed a further look. The hub resolved the conflict by the API (82762cd72b, same 8 files, file still sorted), read the 2017 and 2025 CFR editions and moved the per-borrower entry's start to 2016 (4996e9337f), and applied the body note. Rulings us:standard-plan-minimum-per-borrower and us:standard-plan-minimum-consolidated are logged.
  • Branch update: GitHub's branch update merged main 6fa4eaf6d3 into the reviewed head 4996e9337f; each file's changes match the reviewed head's.
  • Gates checked live at merge:
    • gh pr checks exits 0;
    • the latest Pull request run at the head concluded success;
    • MERGEABLE;
    • not a draft;
    • no reviewer's latest review requests changes;
    • the semantic-overlap guard;
    • head pinned.
  • Impact: none. No formula reads the new variable, and both existing variables match main at every point of the review's grid, since their formulas now call helpers holding the removed lines verbatim. The hub withdrew the impact pair for that reason.
  • 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

@MaxGhenis
MaxGhenis deleted the standard-minimum-per-borrower branch November 3, 2026 04:29
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.

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))

2 participants