From 7e4df637e5194ec8755ba8679051ed89fa298f4c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:00:29 +0000 Subject: [PATCH 1/3] Apply the standard plan's $50 minimum once across Direct and parent PLUS 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 --- .../standard-minimum-per-borrower.added.md | 1 + .../combined_standard_repayment_payment.yaml | 210 +++++++++++++++++ ...st_combined_standard_repayment_property.py | 222 ++++++++++++++++++ .../combined_standard_repayment_payment.py | 72 ++++++ .../parent_plus_standard_repayment_payment.py | 30 +-- .../standard_repayment_payment.py | 13 +- .../student_loan_amortization_helpers.py | 41 ++++ source_conflicts/candidates.yaml | 62 +++++ 8 files changed, 622 insertions(+), 29 deletions(-) create mode 100644 changelog.d/standard-minimum-per-borrower.added.md create mode 100644 policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/combined_standard_repayment_payment.yaml create mode 100644 policyengine_us/tests/test_combined_standard_repayment_property.py create mode 100644 policyengine_us/variables/gov/ed/student_loans/standard_repayment/combined_standard_repayment_payment.py diff --git a/changelog.d/standard-minimum-per-borrower.added.md b/changelog.d/standard-minimum-per-borrower.added.md new file mode 100644 index 00000000000..5353ce01c1c --- /dev/null +++ b/changelog.d/standard-minimum-per-borrower.added.md @@ -0,0 +1 @@ +Apply the standard repayment plan's $50 minimum once across a borrower's Direct and parent PLUS loans in a new combined_standard_repayment_payment, as 34 CFR 685.208(b)(1)(ii) sets it, while a consolidated parent PLUS loan under 685.208(b)(2) keeps its own minimum. diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/combined_standard_repayment_payment.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/combined_standard_repayment_payment.yaml new file mode 100644 index 00000000000..838b3c8032f --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/combined_standard_repayment_payment.yaml @@ -0,0 +1,210 @@ +# One $50 minimum on all of a borrower's payments under the standard repayment +# plan (34 CFR 685.208(b)(1)(ii)), with Direct and parent PLUS loans amortized +# separately and added: min(max(direct x f + parent PLUS x f, 50), direct + +# parent PLUS). f is the 120-month factor at 6 percent: +# .005 / (1 - 1.005 ^ -120) = .0111021. A parent PLUS Direct Consolidation Loan +# that entered repayment on or after July 1, 2006 repays under 685.208(b)(2), a +# separate plan with its own (b)(2)(ii) minimum, so its payment is added to the +# Direct Loan payment as is. + +- name: Case 1, a small Direct balance and a large parent PLUS balance share one 50 dollar minimum. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_student_loan_balance: 1_000 + federal_parent_plus_loan_balance: 20_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + households: + household: + members: [person1] + state_code: OH + output: + # 1,000 x .0111021 = 11.10 and 20,000 x .0111021 = 222.04. Separately: + # max(11.10, 50) = 50 and 222.04. Together: max(11.10 + 222.04 = 233.14, + # 50) = 233.14, under the 21,000 balance. + standard_repayment_payment: 50 + parent_plus_standard_repayment_payment: 222.04 + combined_standard_repayment_payment: 233.14 + +- name: Case 2, two small balances owe one 50 dollar minimum. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_student_loan_balance: 1_000 + federal_parent_plus_loan_balance: 1_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + households: + household: + members: [person1] + state_code: OH + output: + # 11.10 + 11.10 = 22.20, below 50, so one minimum of 50, under the 2,000 balance; + # each separate variable is 50. + standard_repayment_payment: 50 + parent_plus_standard_repayment_payment: 50 + combined_standard_repayment_payment: 50 + +- name: Case 3, payments above the minimum add up as before. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_student_loan_balance: 5_000 + federal_parent_plus_loan_balance: 5_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + households: + household: + members: [person1] + state_code: OH + output: + # 5,000 x .0111021 = 55.51 each, both above 50: 55.51 + 55.51 = 111.02. + standard_repayment_payment: 55.51 + parent_plus_standard_repayment_payment: 55.51 + combined_standard_repayment_payment: 111.02 + +- name: Case 4, a consolidated parent PLUS loan keeps its own minimum under 685.208(b)(2). + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_student_loan_balance: 1_000 + federal_parent_plus_loan_balance: 20_000 + federal_student_loan_interest_rate: 0.06 + federal_parent_plus_loan_is_consolidated: true + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + households: + household: + members: [person1] + state_code: OH + output: + # Consolidation total 20,000 + min(1,000, 20,000) = 21,000, in the 20,000 to + # 40,000 tier: 20 years. 20,000 x .005 / (1 - 1.005 ^ -240) = 143.29. Direct + # Loans under (b)(1): max(11.10, 50) = 50. Two plans, two minimums: + # 50 + 143.29 = 193.29. + standard_repayment_payment: 50 + parent_plus_standard_repayment_payment: 143.29 + combined_standard_repayment_payment: 193.29 + +- name: Case 5, balances below the minimum cap the payment at the total balance. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_student_loan_balance: 30 + federal_parent_plus_loan_balance: 10 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + households: + household: + members: [person1] + state_code: OH + output: + # 30 x .0111021 + 10 x .0111021 = 0.44, raised to 50, then capped at the + # 40 total balance. Separately 30 and 10. + standard_repayment_payment: 30 + parent_plus_standard_repayment_payment: 10 + combined_standard_repayment_payment: 40 + +- name: Case 6, parent PLUS loans alone match the parent PLUS variable. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_student_loan_balance: 0 + federal_parent_plus_loan_balance: 20_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + households: + household: + members: [person1] + state_code: OH + output: + # 20,000 x .0111021 = 222.04; no Direct Loans. + standard_repayment_payment: 0 + parent_plus_standard_repayment_payment: 222.04 + combined_standard_repayment_payment: 222.04 + +- name: Case 7, Direct Loans alone match the Direct Loan variable. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_student_loan_balance: 20_000 + federal_parent_plus_loan_balance: 0 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + households: + household: + members: [person1] + state_code: OH + output: + # 20,000 x .0111021 = 222.04; no parent PLUS loans. + standard_repayment_payment: 222.04 + parent_plus_standard_repayment_payment: 0 + combined_standard_repayment_payment: 222.04 + +- name: Case 8, a Direct Loan made on or after July 1, 2026 moves the borrower to the tiered plan. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_student_loan_balance: 1_000 + federal_parent_plus_loan_balance: 20_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] + filing_status: SINGLE + households: + household: + members: [person1] + state_code: OH + output: + # 20 U.S.C. 1087e(d)(1), (d)(7)(A): the borrower repays under the tiered + # standard plan, so each standard plan payment is 0. + standard_repayment_payment: 0 + parent_plus_standard_repayment_payment: 0 + combined_standard_repayment_payment: 0 diff --git a/policyengine_us/tests/test_combined_standard_repayment_property.py b/policyengine_us/tests/test_combined_standard_repayment_property.py new file mode 100644 index 00000000000..f64c75e30e3 --- /dev/null +++ b/policyengine_us/tests/test_combined_standard_repayment_property.py @@ -0,0 +1,222 @@ +"""Hypothesis checks: one standard plan minimum per borrower. + +34 CFR 685.208(b)(1) puts Direct Subsidized, Unsubsidized and PLUS loans on +one standard plan, and (b)(1)(ii) sets its $50 minimum on the borrower's +payments. combined_standard_repayment_payment adds the Direct and parent PLUS +level payments before one minimum applies, unless the parent PLUS balance is +a Direct Consolidation Loan under 685.208(b)(2), a separate plan with its own +minimum. With D the Direct variable (standard_repayment_payment), P the parent +PLUS variable (parent_plus_standard_repayment_payment) and C the combined +payment, for generated balances, rates and consolidation flags: + +1. Bound: max(D, P) <= C <= D + P, and C <= the total balance. +2. Agreement: C = D + P where both level payments reach the minimum, and + where the parent PLUS loan is consolidated. +3. One loan type: with one balance zero, C equals the other variable. +4. Monotonicity: C never falls as either balance rises, while the + consolidation loan's repayment period holds still. + +Each generated borrower is simulated in five variants (as drawn, with a larger +Direct balance, with a larger parent PLUS balance, with no Direct Loans and +with no parent PLUS loans) in one vectorized simulation per example. +""" + +import pytest + +from policyengine_us import Simulation + +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") + +MONTH = "2026-01" +YEAR = 2026 +# Written from the law, not read from the parameter tree. +STANDARD_MONTHS = 120 # 685.208(b)(1)(i) +STANDARD_MINIMUM = 50 # 685.208(b)(1)(ii) +# 685.208(b)(2)(iii): years by the consolidation loan plus other education +# loans, counted up to the consolidation loan's amount. +CONSOLIDATION_TIERS = ((60_000, 30), (40_000, 25), (20_000, 20), (10_000, 15)) +CONSOLIDATION_TIERS += ((7_500, 12), (0, 10)) +TOLERANCE = 0.011 + + +def amortized(balance, annual_rate, months): + monthly_rate = annual_rate / 12 + if monthly_rate == 0: + return balance / months + return balance * monthly_rate / (1 - (1 + monthly_rate) ** -months) + + +def consolidation_years(loans): + if not loans["consolidated"]: + return 10 + total = loans["parent_plus"] + min( + loans["direct"] + loans["ffel"], loans["parent_plus"] + ) + return next(years for floor, years in CONSOLIDATION_TIERS if total >= floor) + + +borrower = st.fixed_dictionaries( + { + "direct": st.one_of(st.just(0), st.integers(1, 6_000), st.integers(1, 80_000)), + "parent_plus": st.one_of( + st.just(0), st.integers(1, 6_000), st.integers(1, 80_000) + ), + "ffel": st.one_of(st.just(0), st.integers(1, 30_000)), + "rate": st.sampled_from([0, 0.03, 0.06, 0.08]), + "consolidated": st.booleans(), + "new_loan": st.sampled_from([False, False, False, True]), + "direct_rise": st.integers(1, 20_000), + "parent_plus_rise": st.integers(1, 20_000), + } +) + + +def variants(loans): + return { + "base": loans, + "direct_rise": dict(loans, direct=loans["direct"] + loans["direct_rise"]), + "parent_plus_rise": dict( + loans, parent_plus=loans["parent_plus"] + loans["parent_plus_rise"] + ), + "no_direct": dict(loans, direct=0), + "no_parent_plus": dict(loans, parent_plus=0), + } + + +def build(borrowers): + """One situation holding every variant of every borrower.""" + people, tax_units, households = {}, {}, {} + order = [] + for n, loans in enumerate(borrowers): + for variant, values in variants(loans).items(): + name = f"b{n}_{variant}" + people[name] = { + "age": {YEAR: 50}, + "federal_student_loan_balance": {YEAR: values["direct"]}, + "federal_parent_plus_loan_balance": {YEAR: values["parent_plus"]}, + "federal_ffel_loan_balance": {YEAR: values["ffel"]}, + "federal_student_loan_interest_rate": {YEAR: values["rate"]}, + "federal_parent_plus_loan_is_consolidated": { + YEAR: values["consolidated"] + }, + "has_federal_student_loan_made_on_or_after_2026_07_01": { + YEAR: values["new_loan"] + }, + } + tax_units[name] = { + "members": [name], + "filing_status": {YEAR: "SINGLE"}, + } + households[name] = {"members": [name], "state_code": {YEAR: "OH"}} + order.append((n, variant)) + situation = { + "people": people, + "tax_units": tax_units, + "households": households, + "marital_units": {k: {"members": v["members"]} for k, v in households.items()}, + "spm_units": {k: {"members": v["members"]} for k, v in households.items()}, + "families": {k: {"members": v["members"]} for k, v in households.items()}, + } + simulation = Simulation(situation=situation) + results = {} + for variable in ( + "standard_repayment_payment", + "parent_plus_standard_repayment_payment", + "combined_standard_repayment_payment", + ): + values = simulation.calculate(variable, MONTH) + results[variable] = {key: float(v) for key, v in zip(order, values)} + return results + + +def check(borrowers): + results = build(borrowers) + problems = [] + for n, loans in enumerate(borrowers): + cases = variants(loans) + + def get(variable, variant): + return results[variable][(n, variant)] + + for variant, values in cases.items(): + d = get("standard_repayment_payment", variant) + p = get("parent_plus_standard_repayment_payment", variant) + c = get("combined_standard_repayment_payment", variant) + label = f"b{n} {variant} {values}: D={d} P={p} C={c}" + # 1. Bound. + if c > d + p + TOLERANCE: + problems.append(f"{label}: above D + P") + if c < max(d, p) - TOLERANCE: + problems.append(f"{label}: below max(D, P)") + if c > values["direct"] + values["parent_plus"] + TOLERANCE: + problems.append(f"{label}: above the total balance") + # 2. Agreement. + direct_level = amortized(values["direct"], values["rate"], STANDARD_MONTHS) + parent_plus_level = amortized( + values["parent_plus"], + values["rate"], + consolidation_years(values) * 12, + ) + both_reach_minimum = ( + direct_level >= STANDARD_MINIMUM + and parent_plus_level >= STANDARD_MINIMUM + ) + if (both_reach_minimum or values["consolidated"]) and abs( + c - (d + p) + ) > TOLERANCE: + problems.append(f"{label}: differs from D + P") + # 3. One loan type. + if values["direct"] == 0 and abs(c - p) > TOLERANCE: + problems.append(f"{label}: no Direct Loans but C != P") + if values["parent_plus"] == 0 and abs(c - d) > TOLERANCE: + problems.append(f"{label}: no parent PLUS loans but C != D") + # 4. Monotonicity, where the consolidation period holds still. + base = get("combined_standard_repayment_payment", "base") + for variant in ("direct_rise", "parent_plus_rise"): + if consolidation_years(cases[variant]) != consolidation_years( + cases["base"] + ): + continue + if get("combined_standard_repayment_payment", variant) < base - TOLERANCE: + problems.append(f"b{n} {variant}: falls as the balance rises") + assert not problems, "\n".join(problems) + + +@hypothesis.settings( + max_examples=10, + deadline=None, + # Each example is a simulation of up to 40 people. Shrinking a failure + # would run hundreds more, so a failing example is reported as drawn. + phases=[ + hypothesis.Phase.explicit, + hypothesis.Phase.reuse, + hypothesis.Phase.generate, + ], + suppress_health_check=[ + hypothesis.HealthCheck.too_slow, + hypothesis.HealthCheck.data_too_large, + ], +) +@hypothesis.given(st.lists(borrower, min_size=1, max_size=8)) +def test_one_standard_plan_minimum(borrowers): + check(borrowers) + + +def test_one_standard_plan_minimum_hand_case(): + # 1,000 Direct and 20,000 parent PLUS at 6%: 11.10 + 222.04 = 233.14, + # where the two variables each apply a minimum: 50 + 222.04. + loans = { + "direct": 1_000, + "parent_plus": 20_000, + "ffel": 0, + "rate": 0.06, + "consolidated": False, + "new_loan": False, + "direct_rise": 1, + "parent_plus_rise": 1, + } + results = build([loans]) + combined = results["combined_standard_repayment_payment"][(0, "base")] + assert combined == pytest.approx(233.14, abs=0.01) + check([loans]) diff --git a/policyengine_us/variables/gov/ed/student_loans/standard_repayment/combined_standard_repayment_payment.py b/policyengine_us/variables/gov/ed/student_loans/standard_repayment/combined_standard_repayment_payment.py new file mode 100644 index 00000000000..1d195b93b99 --- /dev/null +++ b/policyengine_us/variables/gov/ed/student_loans/standard_repayment/combined_standard_repayment_payment.py @@ -0,0 +1,72 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.ed.student_loans.student_loan_amortization_helpers import ( + direct_standard_amortized_payment, + parent_plus_standard_amortized_payment, +) + + +class combined_standard_repayment_payment(Variable): + value_type = float + entity = Person + label = "Combined Direct and parent PLUS standard repayment plan monthly payment" + documentation = ( + "Monthly payment for a borrower with all of their Direct Loans " + "(federal_student_loan_balance) and parent PLUS loans " + "(federal_parent_plus_loan_balance) on the standard repayment plan. " + "34 CFR 685.208(b)(1) puts Direct Subsidized, Unsubsidized and PLUS " + "loans on one 10-year plan, and (b)(1)(ii) sets its $50 minimum on " + "the borrower's payments, so the two level payments are added before " + "one minimum applies, and the total is no more than the two balances. " + "A Direct Consolidation Loan that repaid parent PLUS loans and " + "entered repayment on or after July 1, 2006 " + "(federal_parent_plus_loan_is_consolidated) repays under 685.208(b)(2), " + "a separate plan with its own minimum in (b)(2)(ii), so for that " + "borrower this is the sum of standard_repayment_payment and " + "parent_plus_standard_repayment_payment. FFEL loans are not " + "included. A borrower who holds Direct Loans on one plan and parent " + "PLUS loans on another, such as an income-driven plan for the Direct " + "Loans, is not modeled here; the two separate variables stay the " + "right read for them. A borrower with a Direct Loan made on or after " + "July 1, 2026 cannot use these plans and repays under the tiered " + "standard plan (tiered_standard_repayment_payment), so this is zero " + "for them." + ) + unit = USD + definition_period = MONTH + reference = ( + "https://www.ecfr.gov/current/title-34/section-685.208#p-685.208(b)(1)", + "https://www.ecfr.gov/current/title-34/section-685.208#p-685.208(b)(2)", + "https://www.law.cornell.edu/uscode/text/20/1087e#d_1_A", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.ed.student_loans.standard_repayment + direct_balance = person("federal_student_loan_balance", period.this_year) + parent_plus_balance = person( + "federal_parent_plus_loan_balance", period.this_year + ) + amortized_payment = direct_standard_amortized_payment( + person, period, parameters + ) + parent_plus_standard_amortized_payment(person, period, parameters) + # 685.208(b)(1)(ii): one minimum on all of the borrower's payments + # under the plan, except a final payment below it. + one_plan_payment = min_( + max_(amortized_payment, p.minimum_payment), + direct_balance + parent_plus_balance, + ) + # 685.208(b)(2): a consolidation loan repays under its own plan, with + # its own (b)(2)(ii) minimum. + is_consolidated = person( + "federal_parent_plus_loan_is_consolidated", period.this_year + ) + two_plan_payment = person("standard_repayment_payment", period) + person( + "parent_plus_standard_repayment_payment", period + ) + payment = where(is_consolidated, two_plan_payment, one_plan_payment) + # 20 U.S.C. 1087e(d)(1) and (d)(7)(A) and 34 CFR 685.208(b)(1)-(2) + # limit these plans to borrowers with no Direct Loan made on or after + # July 1, 2026. + has_new_loan = person( + "has_federal_student_loan_made_on_or_after_2026_07_01", period.this_year + ) + return where(has_new_loan, 0, payment) diff --git a/policyengine_us/variables/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.py b/policyengine_us/variables/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.py index 3db5e618ef9..14f5ecf596f 100644 --- a/policyengine_us/variables/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.py +++ b/policyengine_us/variables/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.py @@ -1,6 +1,6 @@ from policyengine_us.model_api import * from policyengine_us.variables.gov.ed.student_loans.student_loan_amortization_helpers import ( - amortized_monthly_payment, + parent_plus_standard_amortized_payment, ) @@ -32,7 +32,10 @@ class parent_plus_standard_repayment_payment(Variable): "so this is zero for them. The flag is yearly, so a loan made in the " "second half of 2026 moves all of 2026 to the tiered plan. Repayment " "of consolidated parent PLUS loans under the Income-Contingent " - "Repayment plan is not modeled." + "Repayment plan is not modeled. For a borrower with both Direct and " + "parent PLUS loans on the standard plan, " + "combined_standard_repayment_payment gives the total, with one " + "minimum where both are under 34 CFR 685.208(b)(1)." ) unit = USD definition_period = MONTH @@ -46,27 +49,8 @@ class parent_plus_standard_repayment_payment(Variable): def formula(person, period, parameters): p = parameters(period).gov.ed.student_loans.standard_repayment balance = person("federal_parent_plus_loan_balance", period.this_year) - annual_rate = person("federal_student_loan_interest_rate", period.this_year) - # 685.208(b)(2)(iii) and 685.220(i)(2)(ii), (i)(3)(i): a consolidation - # loan's term follows its amount plus the borrower's other education - # loans, counted up to the consolidation loan's amount. - other_loans = add( - person, - period.this_year, - ["federal_student_loan_balance", "federal_ffel_loan_balance"], - ) - consolidation_total = balance + min_(other_loans, balance) - is_consolidated = person( - "federal_parent_plus_loan_is_consolidated", period.this_year - ) - repayment_period = where( - is_consolidated, - p.consolidation_repayment_period.calc(consolidation_total), - p.repayment_period, - ) - number_of_payments = repayment_period * MONTHS_IN_YEAR - amortized_payment = amortized_monthly_payment( - balance, annual_rate, number_of_payments + amortized_payment = parent_plus_standard_amortized_payment( + person, period, parameters ) # As in standard_repayment_payment, the minimum does not apply to a # final payment below it. diff --git a/policyengine_us/variables/gov/ed/student_loans/standard_repayment/standard_repayment_payment.py b/policyengine_us/variables/gov/ed/student_loans/standard_repayment/standard_repayment_payment.py index 69c914e461d..5e0de9c312c 100644 --- a/policyengine_us/variables/gov/ed/student_loans/standard_repayment/standard_repayment_payment.py +++ b/policyengine_us/variables/gov/ed/student_loans/standard_repayment/standard_repayment_payment.py @@ -1,6 +1,6 @@ from policyengine_us.model_api import * from policyengine_us.variables.gov.ed.student_loans.student_loan_amortization_helpers import ( - amortized_monthly_payment, + direct_standard_amortized_payment, ) @@ -15,7 +15,10 @@ class standard_repayment_payment(Variable): "balance. The payment amortizes the current balance, not the balance " "when the loans entered repayment. A borrower with a Direct Loan made " "on or after July 1, 2026 cannot use this plan and repays under the " - "tiered standard plan instead (tiered_standard_repayment_payment)." + "tiered standard plan instead (tiered_standard_repayment_payment). " + "For a borrower with both Direct and parent PLUS loans on the " + "standard plan, combined_standard_repayment_payment gives the total, with one " + "minimum where both are under 34 CFR 685.208(b)(1)." ) unit = USD definition_period = MONTH @@ -28,10 +31,8 @@ class standard_repayment_payment(Variable): def formula(person, period, parameters): p = parameters(period).gov.ed.student_loans.standard_repayment balance = person("federal_student_loan_balance", period.this_year) - annual_rate = person("federal_student_loan_interest_rate", period.this_year) - number_of_payments = p.repayment_period * MONTHS_IN_YEAR - amortized_payment = amortized_monthly_payment( - balance, annual_rate, number_of_payments + amortized_payment = direct_standard_amortized_payment( + person, period, parameters ) # The minimum does not apply to a final payment below it. payment = min_(max_(amortized_payment, p.minimum_payment), balance) diff --git a/policyengine_us/variables/gov/ed/student_loans/student_loan_amortization_helpers.py b/policyengine_us/variables/gov/ed/student_loans/student_loan_amortization_helpers.py index bae45d6fcc8..afdbc551027 100644 --- a/policyengine_us/variables/gov/ed/student_loans/student_loan_amortization_helpers.py +++ b/policyengine_us/variables/gov/ed/student_loans/student_loan_amortization_helpers.py @@ -18,3 +18,44 @@ def amortized_monthly_payment(balance, annual_rate, number_of_payments): where=monthly_rate > 0, ) return balance * payment_per_dollar + + +def direct_standard_amortized_payment(person, period, parameters): + """Level monthly payment on the person's Direct Loans over the 10-year + standard repayment plan (34 CFR 685.208(b)(1)), before the plan minimum + and the balance bound.""" + p = parameters(period).gov.ed.student_loans.standard_repayment + balance = person("federal_student_loan_balance", period.this_year) + annual_rate = person("federal_student_loan_interest_rate", period.this_year) + number_of_payments = p.repayment_period * MONTHS_IN_YEAR + return amortized_monthly_payment(balance, annual_rate, number_of_payments) + + +def parent_plus_standard_amortized_payment(person, period, parameters): + """Level monthly payment on the person's parent PLUS loans under the + standard repayment plan, before the plan minimum and the balance bound: + 10 years (34 CFR 685.208(b)(1)), or 10 to 30 years for a Direct + Consolidation Loan that entered repayment on or after July 1, 2006 + (685.208(b)(2)).""" + p = parameters(period).gov.ed.student_loans.standard_repayment + balance = person("federal_parent_plus_loan_balance", period.this_year) + annual_rate = person("federal_student_loan_interest_rate", period.this_year) + # 685.208(b)(2)(iii) and 685.220(i)(2)(ii), (i)(3)(i): a consolidation + # loan's term follows its amount plus the borrower's other education + # loans, counted up to the consolidation loan's amount. + other_loans = add( + person, + period.this_year, + ["federal_student_loan_balance", "federal_ffel_loan_balance"], + ) + consolidation_total = balance + min_(other_loans, balance) + is_consolidated = person( + "federal_parent_plus_loan_is_consolidated", period.this_year + ) + repayment_period = where( + is_consolidated, + p.consolidation_repayment_period.calc(consolidation_total), + p.repayment_period, + ) + number_of_payments = repayment_period * MONTHS_IN_YEAR + return amortized_monthly_payment(balance, annual_rate, number_of_payments) diff --git a/source_conflicts/candidates.yaml b/source_conflicts/candidates.yaml index 1cea80fc061..8e07db0f8b0 100644 --- a/source_conflicts/candidates.yaml +++ b/source_conflicts/candidates.yaml @@ -10723,6 +10723,68 @@ candidates: state: unreviewed note: 'The open PR follows POMS rather than the broader regulatory reading; no statutory comparison or specific ruling is supplied. Code not yet on main: ssi_spousal_deeming_child_allocations, ssi_ineligible_child_allocation.' +- id: us:standard-plan-minimum-consolidated + program: student-loans + years: + from: 2006 + type: calendar + question: Does a borrower with Direct Loans on the 10-year standard plan and a Direct Consolidation Loan that repaid + parent PLUS loans owe one $50 minimum across both, or a minimum on each? + sources: + - kind: statute + citation: 20 U.S.C. 1087e(d)(1)(A) + url: https://www.law.cornell.edu/uscode/text/20/1087e#d_1_A + - kind: regulation + citation: 34 CFR 685.208(b)(1)(ii) and (b)(2)(ii) + url: https://www.ecfr.gov/current/title-34/section-685.208#p-685.208(b)(2) + readings: + statute: The statute provides a standard repayment plan and sets no minimum payment. + other: 'Per plan: a consolidation loan that entered repayment on or after July 1, 2006 repays under (b)(2), a separate + plan with its own (b)(2)(ii) minimum, so the borrower owes a minimum on each plan. Per borrower: one minimum + across all of the borrower''s standard plan payments.' + example: A borrower at 6% with $1,000 of Direct Loans and a $20,000 consolidated parent PLUS loan repaid over 20 + years pays $50.00 + $143.29 = $193.29 with a minimum on each plan, against max($11.10 + $143.29, $50) = $154.39 + with one minimum. + pe_us: + follows: gap-fill + code: + - policyengine_us/variables/gov/ed/student_loans/standard_repayment/combined_standard_repayment_payment.py + prs: [] + state: ruled + note: combined_standard_repayment_payment adds standard_repayment_payment and parent_plus_standard_repayment_payment + where federal_parent_plus_loan_is_consolidated is true. This narrows the per-borrower reading in + us:standard-plan-minimum-per-borrower to borrowers whose loans are all under (b)(1). +- id: us:standard-plan-minimum-per-borrower + program: student-loans + years: + from: 2026 + type: calendar + question: Does a borrower with both Direct Loans and unconsolidated parent PLUS loans on the 10-year standard plan + owe one $50 minimum on all of those payments, or a minimum on each loan type? + sources: + - kind: statute + citation: 20 U.S.C. 1087e(d)(1)(A) + url: https://www.law.cornell.edu/uscode/text/20/1087e#d_1_A + - kind: regulation + citation: 34 CFR 685.208(b)(1) and (b)(1)(ii) + url: https://www.ecfr.gov/current/title-34/section-685.208#p-685.208(b)(1) + readings: + statute: The statute provides a standard repayment plan and sets no minimum payment. + other: '(b)(1) gives Direct Subsidized, Unsubsidized and PLUS borrowers one plan, and (b)(1)(ii) says "A borrower''s + payments under this repayment plan are at least $50 per month", so one minimum covers all of them. The other + reading applies the minimum to each loan type''s payment.' + example: A borrower at 6% with $1,000 of Direct Loans and $20,000 of parent PLUS loans pays max($11.10 + $222.04, + $50) = $233.14 with one minimum, against $50.00 + $222.04 = $272.04 with a minimum on each. + pe_us: + follows: gap-fill + code: + - policyengine_us/variables/gov/ed/student_loans/standard_repayment/combined_standard_repayment_payment.py + prs: [] + state: ruled + note: standard_repayment_payment and parent_plus_standard_repayment_payment keep their own minimums, since a borrower + whose Direct Loans are on an income-driven plan repays parent PLUS loans alone on the standard plan; + combined_standard_repayment_payment applies one minimum for a borrower with both on the standard plan. Issue + 10567 reports the double minimum. - id: us:surviving-spouse-child-gross-income-test program: income-tax years: From 66644b26553636d8e2251fa41bf71daa38e372d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:01:58 +0000 Subject: [PATCH 2/3] Check combined standard payments against a one-minimum reference 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 --- .../test_combined_standard_repayment_property.py | 14 ++++++++++++++ .../standard_repayment_payment.py | 4 ++-- source_conflicts/candidates.yaml | 6 ++++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/policyengine_us/tests/test_combined_standard_repayment_property.py b/policyengine_us/tests/test_combined_standard_repayment_property.py index f64c75e30e3..3aede6cb72c 100644 --- a/policyengine_us/tests/test_combined_standard_repayment_property.py +++ b/policyengine_us/tests/test_combined_standard_repayment_property.py @@ -15,6 +15,10 @@ 3. One loan type: with one balance zero, C equals the other variable. 4. Monotonicity: C never falls as either balance rises, while the consolidation loan's repayment period holds still. +5. Differential: where the parent PLUS loan is not consolidated, C equals + min(max(Direct level payment + parent PLUS level payment, 50), total + balance), written here independently of the model. The first four hold + for D + P too, so this one is what catches a minimum on each loan type. Each generated borrower is simulated in five variants (as drawn, with a larger Direct balance, with a larger parent PLUS balance, with no Direct Loans and @@ -166,6 +170,16 @@ def get(variable, variant): c - (d + p) ) > TOLERANCE: problems.append(f"{label}: differs from D + P") + # 5. Differential: one minimum, written here from the law. + if not values["consolidated"]: + total = values["direct"] + values["parent_plus"] + reference = min( + max(direct_level + parent_plus_level, STANDARD_MINIMUM), total + ) + if values["new_loan"]: + reference = 0 + if abs(c - reference) > TOLERANCE: + problems.append(f"{label}: differs from one minimum {reference}") # 3. One loan type. if values["direct"] == 0 and abs(c - p) > TOLERANCE: problems.append(f"{label}: no Direct Loans but C != P") diff --git a/policyengine_us/variables/gov/ed/student_loans/standard_repayment/standard_repayment_payment.py b/policyengine_us/variables/gov/ed/student_loans/standard_repayment/standard_repayment_payment.py index 5e0de9c312c..9b906043ef9 100644 --- a/policyengine_us/variables/gov/ed/student_loans/standard_repayment/standard_repayment_payment.py +++ b/policyengine_us/variables/gov/ed/student_loans/standard_repayment/standard_repayment_payment.py @@ -17,8 +17,8 @@ class standard_repayment_payment(Variable): "on or after July 1, 2026 cannot use this plan and repays under the " "tiered standard plan instead (tiered_standard_repayment_payment). " "For a borrower with both Direct and parent PLUS loans on the " - "standard plan, combined_standard_repayment_payment gives the total, with one " - "minimum where both are under 34 CFR 685.208(b)(1)." + "standard plan, combined_standard_repayment_payment gives the total, " + "with one minimum where both are under 34 CFR 685.208(b)(1)." ) unit = USD definition_period = MONTH diff --git a/source_conflicts/candidates.yaml b/source_conflicts/candidates.yaml index 8e07db0f8b0..447679466b5 100644 --- a/source_conflicts/candidates.yaml +++ b/source_conflicts/candidates.yaml @@ -10749,7 +10749,8 @@ candidates: follows: gap-fill code: - policyengine_us/variables/gov/ed/student_loans/standard_repayment/combined_standard_repayment_payment.py - prs: [] + prs: + - https://github.com/PolicyEngine/policyengine-us/pull/10599 state: ruled note: combined_standard_repayment_payment adds standard_repayment_payment and parent_plus_standard_repayment_payment where federal_parent_plus_loan_is_consolidated is true. This narrows the per-borrower reading in @@ -10779,7 +10780,8 @@ candidates: follows: gap-fill code: - policyengine_us/variables/gov/ed/student_loans/standard_repayment/combined_standard_repayment_payment.py - prs: [] + prs: + - https://github.com/PolicyEngine/policyengine-us/pull/10599 state: ruled note: standard_repayment_payment and parent_plus_standard_repayment_payment keep their own minimums, since a borrower whose Direct Loans are on an income-driven plan repays parent PLUS loans alone on the standard plan; From 4996e9337fcf52ed00b183889809224a522e7ff0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 18:51:44 -0500 Subject: [PATCH 3/3] Start the per-borrower minimum entry in 2016 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 --- source_conflicts/candidates.yaml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/source_conflicts/candidates.yaml b/source_conflicts/candidates.yaml index 008b5503c06..bfc855f2b16 100644 --- a/source_conflicts/candidates.yaml +++ b/source_conflicts/candidates.yaml @@ -10790,11 +10790,12 @@ candidates: state: ruled note: combined_standard_repayment_payment adds standard_repayment_payment and parent_plus_standard_repayment_payment where federal_parent_plus_loan_is_consolidated is true. This narrows the per-borrower reading in - us:standard-plan-minimum-per-borrower to borrowers whose loans are all under (b)(1). + us:standard-plan-minimum-per-borrower to borrowers whose loans are all under (b)(1). The 2017 and 2025 CFR editions + hold these two plans at 685.208(b) and (c). - id: us:standard-plan-minimum-per-borrower program: student-loans years: - from: 2026 + from: 2016 type: calendar question: Does a borrower with both Direct Loans and unconsolidated parent PLUS loans on the 10-year standard plan owe one $50 minimum on all of those payments, or a minimum on each loan type? @@ -10821,7 +10822,9 @@ candidates: state: ruled note: standard_repayment_payment and parent_plus_standard_repayment_payment keep their own minimums, since a borrower whose Direct Loans are on an income-driven plan repays parent PLUS loans alone on the standard plan; - combined_standard_repayment_payment applies one minimum for a borrower with both on the standard plan. Issue + combined_standard_repayment_payment applies one minimum for a borrower with both on the standard plan. The 2017 and + 2025 CFR editions carry the same sentence at 685.208(b)(2), so the question runs from the model's first + minimum_payment value in 2016. Issue 10567 reports the double minimum. - id: us:student-loan-interest-joint-claimed-spouse program: income-tax