Repository navigation
Conversation
Add the scf_education_debt runtime module. It carries federal education debt from the 2025 SCF through two weighted QRFs and allocates it to the SCF-style reference person and spouse as four person inputs: federal_student_loan_balance and federal_parent_plus_loan_balance (policyengine-us#9721), is_income_based_repayment_new_borrower and federal_student_loan_graduate_share (policyengine-us#9724). - Only loans flagged federal count; X7179 is spread over loans 1-6 in proportion to their balances; whose-education code 3 is Parent PLUS on the reference person, codes 6, 7 and -7 the reference person's own. - scf_wealth declares the two pinned 2025 files as artifacts 3 and 4 with a new scf_2025 vintage. Net worth, liquid assets and auto loans stay on 2022. - The fiscal-refresh tool runs the stage only with --scf-education-debt. The four columns join the stored-input register with the pending- engine reason the parent ids carry; the coverage manifest is left until the engine pin defines them. - Regenerate sources, vintages and calibration YAML and re-pin the frozen source_stages.json and calibration projection digests. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Weighted fit of the stage against SCF 2025: source checks against the Fed's Alternate Table 13 (19.92%, $25,500, matched exactly), a five-fold family hold-out with a predictor ablation, and the stage imputed onto the published default populace-us-2024-spm-20260915 at its weights. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Codes the CPS recipient side the way the SCF defines it: married includes cohabiting partners, children count the partner's children, and the reference person follows the householder-couple rule. These move Parent PLUS from 21.5% to about 16% of imputed federal balances (SCF 13.8%) but leave the borrower-household share at about 14.2% (SCF 18.2%). Adds diagnosis and experiment scripts and their first receipts. The predictor-shift and remedy runs still need to run (on Modal; local memory is too low); the fit receipt and PR body are not updated yet. Part of #1073. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The first host fit drew federal education debt for 14.4% of CPS households against 18.2% in SCF 2025, and gave Parent PLUS 21.5% of balances against 13.8%, while the SCF hold-out reproduced the SCF. The cause is covariate shift: the CPS host carries tax-record imputations of interest and dividends and of Social Security and pensions, which put 42% and 18% of 25-34 households above zero against 16% and 6% of SCF families. Mapping each to its SCF distribution at equal rank moves the host's borrower share most of any predictor; all together reach the hold-out level (receipts/1073-predictor-shift-diagnosis.json). - SCF_EDUCATION_DEBT_CANDIDATE_PREDICTORS keeps every predictor both sides build; SCF_EDUCATION_DEBT_PREDICTORS, used by both QRFs, leaves out the two shifted income components, each with its reason in SCF_EDUCATION_DEBT_EXCLUDED_PREDICTORS. - The recipient definition sums income over the reference person, spouse or partner and their children, the CPS counterpart of the SCF primary economic unit, on top of the SCF-matching codings. - The stage spec drift check now covers both QRFs; the stage notes, module docstring and changelog say why. - The receipt builder reports the build, the first receipt and the all-candidates setting side by side and records commit and run id. - experiments/modal_1073.py runs the heavy steps on Modal, adds generate and spec steps, and collects every tracked file a run changed. Re-pins the frozen source_stages.json digest; the generated mirrors are regenerated on Modal in a follow-up commit. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…edictors (#1073) Output of tools/generate_us_bundle_from_constants.py (run on Modal at b9f3d67, run 20261020T154855Z-b9f3d6719): both education-debt QRFs drop interest_dividend_income and social_security_pension_income, the stage notes follow the module, and the stage-asset digest moves. The Modal experiment runner now strips the Modal runtime's PYTHONPATH from step environments; inheriting it put Modal's own packages beside the venv's and the worker-identity check refused the duplicate idna. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
test_a_spouse_draw_without_a_spouse_moves_to_the_reference_person checks where a spouse's draw lands. With the synthetic donor's random incidence and a 30-household frame, the SCF-comparable predictor set draws no debt for any couple on 3 of 20 seeds (seed 11 among them), while the routing assertion holds on all 20 for both predictor sets (Modal probe run 20261020T161732Z-5d047f687). Every donor family now borrows in this test. The Modal runner gains --script for an uncommitted local probe, recorded by sha256 in the run log. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… loan register entries (#1073) - experiments/1073-scf-education-debt-receipts.md and its JSON are the receipt of Modal run 20261021T133110Z-e35c4b0f9 at e35c4b0 (build, first-receipt and all-candidates settings on the published default). - experiments/modal_1073.py gains `launch`, which spawns the run and exits, so a stopped local client cannot cancel it, and `--script` for an uncommitted probe. - test_support/microcosm_data/stored_inputs.py lists the four federal student loan inputs among the producer-bound register entries, next to the parent ids: no file examined for #1026 stores them, and test_us_stored_input_register.py binds them to their live producer. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ves (#1073) The scf_wealth education-debt declaration adds 145 configuration fields to the US bundle (141 authored, 4 resolved). Modal probe run 20261021T145336Z-b1a461f19 printed every value below from the code; none is derived by hand. It also diffed the field universe against origin/main: all 159 added and 14 removed pointers belong to exactly one claim, and no field is unclaimed or claimed twice. - field_usage: 42,329 configuration / 32,545 authored / 9,784 resolved (was 42,184 / 32,404 / 9,780); claim pins source_stages 1,845 (the two 2025 artifacts and six operations of scf_wealth), vintage_records 120 and resolved_vintage_authorities 67 (scf_2025), calibration 316 (the four person outputs in the QRF tail-concentration list), each with its observed digest; ledger mode and generation-0 effect counts in the field-usage and coverage-tool tests. - inventory_coverage: source_manifest, late_resource_semantics, tail and full_checkpoint digests; tail control fields 938 and resolved references 336. - The pool tool's bundle spec_sha256 (a6f254a8...). - experiments/modal_1073.py: the spec step first runs the bundle generator and the coverage attestation in --check mode. The coverage tool's pointer-inventory digest and the regenerated docs/evidence/spec-engine/us-f0-coverage.json follow in the next commit. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…1073) Modal probe run 20261021T150509Z-fcbd704c9, with the previous commit's field-usage and inventory pins in place, printed the coverage tool's pointer-inventory digest (37ce78d6...) and regenerated docs/evidence/spec-engine/us-f0-coverage.json with tools/spec_engine_coverage.py; --check then reported 42329/42329 configuration fields and 41/41 inventory checks. The bundle spec_sha256 stays a6f254a8.... Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#1073) test_support/microcosm_build/spec_engine_inventory_coverage.py keeps its own copy of the reviewed inventory counts. Modal run 20261021T151444Z-05856e6ca reported the two the stage moves, matching inventory_coverage.py and probe run 20261021T145336Z-b1a461f19: resolved references 336 (the scf_2025 vintage) and tail control fields 938 (the four outputs in the QRF tail-concentration list). Everything else in that run passed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Main moved the seed protocol, the compiled seed map and the bundle spec_sha256 (microcosm#104, #1124, #1301, #1332); this branch moved the source-manifest, resource-semantics, tail and full-checkpoint digests. The conflicts keep both sides' changes; the merged tree's identity values are re-derived on Modal in the next commit, and the F0 attestation is regenerated there. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Modal run 20261021T183247Z-97bb3af92 regenerated the bundle mirrors on the merge commit (no change) and re-derived every identity value there: the field-usage counts and claim pins, the ledger counts, the inventory digests and counts, and the pointer-inventory digest all match the committed values. The only moved value is the bundle spec_sha256, now 90fa3260... (main's kernel changes plus this branch's stage), which the pool tool pins. tools/spec_engine_coverage.py regenerated the attestation and --check reported 42329/42329 configuration fields and 41/41 inventory checks. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Reran build_1073_scf_education_debt_receipt.py on Modal at 2a6e65a (run 20261022T153020Z-2a6e65a73), after the merge with main brought in #1332's QRF change. Every fit figure is unchanged; only the receipt's commit, run and date move. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…docstring Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
MaxGhenis
left a comment
There was a problem hiding this comment.
Strong diagnosis: the source checks and the covariate-shift work are convincing. Before this leaves draft:
- Parent PLUS. FSA's loan-type table (chronicle#347) puts Direct Parent PLUS at $115.0B of $1,724.5B at FY2026 Q3, or 6.7%. Consolidated Parent PLUS sits inside the consolidation total, so that's a floor, but the SCF's 13.8% and the host's 16.8% are two to two and a half times it. Your docstring names one cause: an adult child's own loan is coded 3 and lands on the parent. Can you route code-3 loans to a co-resident adult child when there is one, and benchmark the result against FSA?
- IBR new borrowers. Label the 59% as an upper bound, since paid-off pre-2014 loans and consolidations dated by their newest loan both read as new. Counting Parent PLUS loans in the flag is right, because 685.209(b)(13)(ii) looks at any Direct or FFEL balance.
- Composition. Parent PLUS is the residual after the two own shares. Add a test that a donor family with no Parent PLUS rarely draws one.
- Fit. On the host, the share with debt is flatter by income than in the SCF (bottom quintile 14.3% vs 11.4%, top 14.2% vs 17.9%). Borrowers are also 22% short (21.2M vs 27.2M), and too many are aged 50 to 61 (20.8% vs 16.2%). FSA calibration fixes levels by age, state and balance, but not the income gradient. Let's talk about whether that's good enough for the distributional results.
- Reword the client's name out of the changelog fragment and the module docstring, and get CI green at the final head with the receipts rerun there.
… swap X8000 codes on loans 4 and 6 (#1073) Parent PLUS is drawn for itself before the own shares, the IBR new-borrower share is labelled an upper bound, and the receipt benchmarks Parent PLUS and borrowers against Federal Student Aid. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…1073) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Regenerated on Modal (run 20261023T143414Z-b93cc3ec0): sources.yaml, the field-usage counts and source_stages claim pin, three inventory digests, the coverage pointer-inventory digest, the bundle spec_sha256 and the F0 attestation. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… against FSA Modal run 20261023T152015Z-60255dd98 at 60255dd (tests, spec and receipt steps all passed). Parent PLUS falls from 13.8% to 9.1% of SCF federal balances and from 16.8% to 10.7% on the host; FSA's Direct Parent PLUS share is 6.7%, a floor. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d portfolio facts (#1074) A new runtime module, fsa_student_loan_targets, binds Federal Student Aid's portfolio tables (the six packages/ed_fsa packages of chronicle#347) to the SCF education-debt stage's person columns, federal_student_loan_balance and federal_parent_plus_loan_balance: the balance and borrower count of each state and DC, of five borrower age bands and of nine debt-size bands, and the Parent PLUS balance beside the balance of every other loan type. Every family binds at FY2026 Q2 (31 March 2026), the latest quarter all four bound tables publish, as a stock unaged at the 2024 build period (target aging's new declared stock_at_source_reference_date alignment). Not Reported is allocated to the reported locations, Puerto Rico and Other leave with their share, and the age, debt-size and loan-type rows bind as shares of the one in-scope control ($1,679.1B, 44.31M borrowers). Repayment plans, the portfolio summary and loan-type recipient counts stay diagnostic, each with its reason in the receipt. compile_us_fiscal_target_registry binds the facts only with student_loan_targets, which the fiscal-refresh release tool sets with --scf-education-debt; the release materializes the rows from the stored person columns, guards their restated age and balance bands, records every fact's disposition in us_source_coverage.json and keeps the opt-in family off the target-parity contract. Default compiles and releases are unchanged. The ED Federal Student Aid provider joins the calibration label registries. experiments/fit_1074_fsa_calibration.py refits the published default with and without the family (plus L2-anchored arms) on Modal through experiments/modal_1074.py, against the pinned feed plus chronicle#347's FSA rows, now built at its merge commit on Chronicle main. Squashes #1357's earlier history onto #1246's review-round head. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…chronicle#347 (#1074) Modal run 20261023T191527Z-4b6bfce9a at 4b6bfce. chronicle#347's export at its merge commit (acbedb5) is byte-identical to the PR head's (sha256 58774e11...), so the candidate feed and the test fixture are unchanged. With loans for a child's education on co-resident adult children, the host gains young borrowers (1,246 borrower records under 25, from 561) and its Parent PLUS share falls from 16.8% to 10.7%, but holds the same borrowers (21.2M) and borrower households (16.4%) at the published weights. Arm B now meets 95% of the FSA targets within 5% (92% before) and every age band; B + L2 78% (76%). Borrower households' share of weight after calibration is 33.7% (B) and 32.3% (B + L2), against 33.0% and 31.5% before and 18.2% of SCF families: the routing moves balances within households, so it does not shrink the doubling. Non-FSA capped error: B 3.62% (3.66%), B + L2 3.85% (3.83%), A unchanged at 3.30%. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Conflicts: the scf_wealth stage notes (main's source-impute citation and the education-debt rules both kept), the release tool (both new helpers kept), the stored-input register (county_fips_2023 beside the four student loan inputs), and the spec identity values, which the next commit re-derives on Modal. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Regenerated on Modal (run 20261102T162227Z-3c4cfe441): sources.yaml, the field-usage counts (42,489 configuration fields, 32,704 authored and 9,785 resolved), the source_stages and calibration claim pins, four inventory digests (source_manifest, late_resource_semantics, tail, full_checkpoint), the resolved-reference and tail-control counts (337, 942), the coverage pointer-inventory digest, the calibration projection digest and the bundle spec_sha256. The source_stages.json byte digest is the merged file's. The F0 attestation is from run 20261102T163748Z-c012b92c9 (write, then --check and the generator's --check: 42,489/42,489 configuration fields, 41/41 inventory checks). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…1074) Modal run 20261102T174003Z-88d7a950d at 88d7a95. The merge with #1246's updated head moved nothing: all 4,363 numbers of the existing arms are identical to the 4b6bfce receipt. Binding the balances with the borrower counts at 0.25, 0.1 and 0 of their weight lowers the share of households with debt from 33.7% to 32.7%, 31.7% and 30.2% (B + L2: 32.3% to 28.2%, 26.7% and 25.7%; SCF 18.2%), and the borrowers from 44.26M to 42.98M, 41.71M and 39.75M (B + L2: 42.67M to 34.11M at 0). Balance targets within 5%: 99% in B, 96% in each down-weighted arm (B + L2 94%, 88% to 87%); count targets within 5%: 92% to 43%, 31% and 17%. The solver reaches the balances with larger balances per borrower, so the bands under $10K fall short. Non-FSA capped error 3.45% to 3.50% (B 3.62%, A 3.30%); Parent PLUS 6.8% to 6.9% throughout. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
#1444 merged to main and conflicts with this branch on the spec pins. A cloud session will merge main here and re-derive them the way you did in 3297aef, so CI can run on the merged head. Please pull before you next push; if you push first, the session stops and leaves the branch alone. Main's changes since your last merge touch none of the SCF stage code, so I'd keep the receipts at 3297aef unless you see a reason to rerun them. #1357 needs the same step once this lands, so you can leave it until then. |
Brings in #1444 (9434c50). The three conflicts were pin lines only (spec_sha256 in us-f0-coverage.json and test_us_multispine_pool_tool.py; seed_map, seed_protocol and source_manifest in inventory_coverage.py). This commit keeps the branch side, including its #1073 comment in the multispine test; the next commit re-derives every pin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwQq6Jmpqc2fSZAM219btF
#1444 changes the attested kernel us_runtime/housing_inputs.py. Running the documented sequence locally (repin --legacy-only, the bundle generator, the repinner, the coverage tool) moved: - seed_map and seed_protocol in inventory_coverage.py EXPECTED_HASHES, to main's values (07fe9086..., 4ff99232...), which the merge had already carried into the coverage evidence's map/protocol/implementation digests; - the bundle spec_sha256, be81ebbc... -> 7d2aaa4d..., in test_us_multispine_pool_tool.py and both spec_sha256 fields of docs/evidence/spec-engine/us-f0-coverage.json. Nothing else moved: source_stages.json, the bundle YAML, the field-usage counts (42,489 configuration fields, 32,704 authored, 9,785 resolved), the source_manifest, late_resource_semantics, tail, full_checkpoint and take_up digests, and the resolved-reference and tail-control counts (337, 942) are unchanged. The coverage tool reports 42489/42489 configuration fields and 41/41 inventory checks. The repinner's first pass builds its inventory report against the stale seed pins and wrote covered_item_count == 40 into test_spec_engine_coverage_tool.py; a second pass restored 41, so that file is unchanged here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwQq6Jmpqc2fSZAM219btF
Brings in #1459 and #1461 (ea8f409), which landed after the first merge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwQq6Jmpqc2fSZAM219btF
engine-free failed on the merge ref with test_the_batterys_binding_identity_ adds_only_the_leaf_and_its_imports: assert 221 == (197 + 23). The battery's binding closure reaches the us_runtime package, which imports us_runtime.scf_education_debt (us_runtime/__init__.py:786), so the merged tree holds one module more than main's base of 197. Diffing the closure on main (220) and on the merge (221) shows that module as the only addition. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwQq6Jmpqc2fSZAM219btF
MaxGhenis
left a comment
There was a problem hiding this comment.
Thanks, David. The stage now routes code-3 loans to a co-resident adult child, returns loans 4 and 6 to the right partner when X8000 = 1, and gives Parent PLUS its own draw. Four things before this merges:
-
Graduate loans with no fitting child. When no listed child fits a code-3 graduate loan, the loan becomes the head's Parent PLUS (
scf_education_debt.py:874-877). The code puts all of a $9,000 graduate loan infederal_parent_plus_loan_balancewhether the household has no child, only a child aged 17, an adult child too young to have borrowed that year, or an adult grandchild. The docstring (:72-74), the body and the changelog all rule that out, since the Department of Education makes Parent PLUS loans only for "a dependent undergraduate student" (34 CFR 685.200(c)(2)(i)). Sending these loans to the head's own balance, as codes 6, 7 and -7 go, would match the rule. If you'd rather keep them as Parent PLUS, say so wherever the rule appears. The host's documented move does the same at :1587-1595. In a household without an adult child, it moves a drawn child share, graduate part included, into Parent PLUS and setschild_graduate_shareto 0 (:1608).test_us_scf_education_debt.py:199-204pins the current donor behavior (_loan(5_000, whose=3, program=13)with a head graduate share of 0), so its Parent PLUS case needs an undergraduate loan, and the graduate loan needs a case of its own. Could you add these loans' count and dollars tosource_checks.child_education_readings, and the host's moved share to the receipt, so their size shows? -
The dependent limit. The code departs from the loan limits in two ways that move loans between child and parent, and neither the docstring nor the body mentions them.
_DEPENDENT_UNDERGRADUATE_AGGREGATE_LIMIT(:390) applies $31,000 to every loan year. Congress raised the aggregate limit on a dependent undergraduate's own loans from $23,000 to $31,000 for loans disbursed from July 1, 2008 (Pub. L. 110-227). So a $25,000 loan from 2006 for a child of dependent age reads as the child's own, though it exceeds the limit a dependent undergraduate then faced. Setting the limit by loan year fixes this; the SCF records only the year, so 2008 needs a stated rule.- A dependent student whose parent can't get a PLUS loan, after adverse credit for example, may borrow up to $57,500 (34 CFR 685.203(c)(1)(iii) and (e)(2)). Congress also made some students under 24 independent (20 U.S.C. 1087vv(d)), among them married students, veterans, students with dependents, orphans, emancipated minors and unaccompanied homeless youth. The code reads any undergraduate loan over $31,000 for a child of dependent age as Parent PLUS, so a $45,000 loan from 2016 goes to the parent. Say in the docstring and body that this leans toward Parent PLUS; a reading that gives such loans to the child up to $57,500 would size it.
-
The ablation table. The body's table predates the rerun receipts. Borrower-age TVD differs in five of six rows: the receipt has 0.122, 0.020, 0.031, 0.011 and 0.038 where the body has 0.107, 0.016, 0.023, 0.015 and 0.031. Balance-band TVD differs in three rows, and the receipt's mean error for shared 8 plus household income, -4.378%, rounds to -4.4% against the body's -4.3%. Your fixes for items 1 and 2 will move the receipts again, so rebuild the table from the new receipt.
-
Stale lines in the body.
- The first line calls this a draft and keeps the four columns out of certified releases "until policyengine-us defines them", and the release plumbing section says they're "not on policyengine-us
main". policyengine-us now defines all four onmain(policyengine_us/variables/gov/ed/student_loans/), while microcosm's lock pins 2.2.1, which doesn't. The changelog's wording matches the code: the release tool runs the stage only with--scf-education-debt, because the pinned policyengine-us doesn't define the four inputs yet. - The CI head (3eb3232), the identity values and the 10/23 receipt runs predate 74837b4's rerun and the merges after it. The receipts now name 3297aef, and the next merge moves the identity values again, so a pointer to the re-pin commit may age better than copied values.
- "Borrower counts don't move" holds only net: the X8000 swap alone removes 0.31M borrowers (27.23M to 26.92M), and child routing adds them back (27.24M).
- "The 50-61 excess is gone" holds against FSA's 14.3%, while the host's 13.7% still exceeds the SCF's 11.2%.
- "0 of 7" and "0 of 3" for loans 4 and 6, and the swap's "0.3% of federal balances", appear in no committed receipt, which groups loans 4 and 6. Commit them or cut them.
- The first line calls this a draft and keeps the four columns out of certified releases "until policyengine-us defines them", and the release plumbing section says they're "not on policyengine-us
Smaller things, none blocking:
load_scf_2025_education_debt_donorchecks no digest, so a run given--scf-2025-summary-extractor--scf-2025-full-extractreads whatever file it gets (tools/build_us_fiscal_refresh_release.py:12607-12617). The X8000 swap rests on the pinned member SHA-256, so checking a local file againstSCF_2025_SUMMARY_EXTRACTandSCF_2025_FULL_EXTRACTwould keep that tie.- The SCF lists children-in-law under code 4 ("CHILD (in-law) (of R or Spouse/Partner)"), but the CPS side's
PEPAR1/PEPAR2pointers miss them. So the adult-children predictor can count a son- or daughter-in-law in a donor family but never in a host household. scf_education_debt.py:396cites 20 U.S.C. 1098e(e) for who counts as a new IBR borrower; that subsection sets their repayment terms, and 34 CFR 685.209(b)(13)(ii) holds the definition. Age 24 sits at 1087vv(d)(1), which :386, the docstring and the body cite as (d)(1)(A). The docstring's "certificate" could name program 16 (professional certification or license).
The income gradient and the borrower shortfall from round 1 don't block this PR.
The branch's last four commits merge main twice and re-derive the spec pins, and CI passes at b1218b0 (10/10). Main has moved again since, spec pin files included, so after your fixes a cloud session merges main and re-derives the pins once more, right before this lands. Please pull before you push.
…1073) A loan for a child's education that no listed child fits is Parent PLUS only when it financed undergraduate study (34 CFR 685.200(c)(2)(i)); a graduate one is now the head's own loan, as codes 6, 7 and -7 are. On the host, a child share drawn for a household without an adult child splits the same way: its graduate part joins the head's own balance, the rest is Parent PLUS. The dependent undergraduate aggregate limit follows the loan year: $17,250 before July 1, 1993, $23,000 from then (Pub. L. 102-325) and $31,000 from July 1, 2008 (Pub. L. 110-227), with 1993 and 2008 loans counted as on or after July 1. The docstring, stage notes and changelog say the over-the-limit rule leans toward Parent PLUS, and the receipt sizes a $57,500 reading. The loader checks local SCF files against the pinned member SHA-256s. The receipt counts the loans each rule moves, the X8000 swap by loan position, the host's moved child shares and the host's children-in-law. Citations: 20 U.S.C. 1087vv(d)(1), 34 CFR 685.209(b)(13)(ii), program 16. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Modal run 20261103T172225Z-78928589e at 7892858. The receipt's loans-moved table header is relabelled ("Rule") in the builder and in the committed note, which otherwise is the run's output. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The scf_wealth stage notes changed in 7892858, so the documented repin sequence ran on Modal at that commit (run 20261103T172238Z-78928589e): repin --legacy-only, the bundle generator, the repinner (twice), the coverage tool, then each tool's --check, all passing. Moved: the frozen source_stages.json digest (de488b42...), sources.yaml, the source_manifest, late_resource_semantics and full_checkpoint inventory digests, the bundle spec_sha256 (4fd75d58...) and the F0 attestation. The field-usage counts, claim pins and inventory counts did not move. ruff format turns the repinner's single-quoted literals, and 0caf490's two seed pins, into double quotes; the values are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Brings in main at 31c8939 (20 first-parent merges, 164 commits since ea8f409). The seven conflicts were pin lines only: field-usage counts in field_usage.py and its two tests, the pointer-inventory digest in tools/spec_engine_coverage.py, the bundle spec_sha256 in test_us_multispine_pool_tool.py, inventory digests in inventory_coverage.py, and the generated us-f0-coverage.json. This commit keeps the branch side, including #1073's comment in the multispine test, except seed_map and seed_protocol in inventory_coverage.py, which take main's values because only main changed the seed code. The next commit re-derives every pin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ran the documented sequence locally on the merge with main 31c8939: repin --legacy-only, the bundle generator, the repinner, the coverage tool, then the repinner once more (the second pass changed nothing). Moved: - the field-usage counts: 42,394 configuration fields (was 42,489), 32,609 authored (was 32,704); compiler_semantic 27,673, front_end_validation 349, no_generation0_effect 3,614. Resolved bindings (9,785), legacy_behavior (14,269 / 38,780) and identity_only (103) did not move. The counts are the merge base's (42,305) plus this branch's 184 minus the 95 authored fields main dropped; - the coverage pointer-inventory digest (f6832e8d...); - the bundle spec_sha256 (ce8b1865...) in test_us_multispine_pool_tool.py and both spec_sha256 fields of us-f0-coverage.json; - the F0 attestation's documentation_sha256 and claim_count (48: main's #1484 retired the catalog unit waiver and its claim). Nothing else moved: source_stages.json and its frozen digest, sources.yaml, and the inventory_coverage.py digests are unchanged. The merge commit kept main's seed_map and seed_protocol, which the coverage tool accepts. The coverage tool reports 42394/42394 configuration fields and 41/41 inventory checks. ruff format turns the repinner's single-quoted literals into double quotes; the values are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1073) The merged head's binding identity holds 223 modules: 200 without the leaf's 23. This branch added us_runtime.scf_education_debt (198, b1218b0), and main's #1342 adds microcosm.calibrate.hierarchy_labels and microcosm.diagnostics.catalogs, which the bindings reach through microcosm.calibrate's package __init__. Main fails the same assertion at 31c8939 (222 == 197 + 23); #1495, still open, counts them there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in main at 79a533c: #1495, #1481 and #1460, which landed while the 31c8939 merge was in CI preparation. The one conflict is the transport battery's module count: #1495 counts #1342's two modules in BINDING_MODULES_ADDED_AFTER_BASE, so this keeps main's form and the branch's base of 198 (us_runtime.scf_education_debt, b1218b0) in place of the hand count 200 from the previous commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks, Max. All four items and the smaller ones are in 9edde76 (four commits on top of b1218b0). I haven't merged main, so your re-pin merge can go on top. The receipts are rerun at 7892858 (Modal run 20261103T172225Z-78928589e), and the body's "Review of 2026-11-03" section has the details.
Identity values: the stage notes changed, so 9edde76 re-derives the pins with the documented repin sequence on Modal (run 20261103T172238Z-78928589e). That moved the source_stages.json digest, sources.yaml, three inventory digests, the bundle spec_sha256 and the F0 attestation. The field-usage counts and claim pins didn't move. The body now points to that commit instead of copying values. ruff format also normalized the repinner's single-quoted literals, including the two seed pins from 0caf490; the values are unchanged. Checks at 9edde76 on Modal: the stage's 52 tests and the spec step (run 20261103T180422Z-9edde76d2), plus the repinner's --check, the transport battery and ruff (run 20261103T180435Z-9edde76d2). All passed: 52 stage tests; generator and coverage --check (42,489/42,489 fields, 41/41 inventory checks); 861 engine-free, 2 release-tool and 114 engine-contract tests; 151 transport tests; ruff. GitHub CI won't run until your merge, since the PR conflicts with main. |
MaxGhenis
left a comment
There was a problem hiding this comment.
Thanks, David. The stage now sets the dependent limit by loan year, keeps a graduate loan that no listed child fits as the head's own, and splits the child share when a household without an adult child draws one. The loan-year tiers match the law: Congress applied the $23,000 limit to loans first disbursed from July 1, 1993 (Pub. L. 102-325, §432(a)(2)), and the $31,000 limit from July 1, 2008 (Pub. L. 110-227, §2(c)). Three things before this merges:
-
Two age boundaries have no test. A listed child can hold a loan once the oldest age in their band reaches 16 in the loan year (
scf_education_debt.py:857), and counts as possibly dependent while the youngest age stays under 24 (:860). No routing case sits on either boundary: change>=to>at :857, or<to<=at :860, and every donor test still passes. Two routing cases would pin them:- child code 30 (28-32 in 2025) with a 2021 undergraduate loan of $40,000, over the limit: the youngest in the band had reached 24 by 2021, so the loan stays the child's own;
- child code 25 (18-27) with a 2014 undergraduate loan of $9,000: the oldest in the band reached 16 in 2014, so the child fits and keeps the loan.
-
The limit applies to each loan, while the law caps a student's total.
scf_education_debt.py:862-865compares each loan's own amount borrowed with the aggregate limit. Two 2016 bachelor's loans of $20,000 for an only child of dependent age both read as the child's own, while one $40,000 loan reads as Parent PLUS. That leans toward the child, opposite to the lean the docstring states (:91-99), and the docstring's rule (:66-72), the stage notes, the changelog and the body leave it out. Could you say so where the rule appears, and havechild_education_loan_movescount the code-3 undergraduate loans in families where such loans sum above their year's limit while each stays under it? Summing per family when exactly one adult child fits would also close it. -
The head's IBR flag after a move has no test. When the stage moves a child share to the head, the head stays a new IBR borrower only if the loan it moves also counts as new (:1709-1712). Both tests that make the move still pass without
& child_new:test_a_childs_draw_goes_to_the_youngest_adult_childdraws only new child loans, for heads who hold nothing else, and the split test never reads the flag. Settingchild_is_ibr_new_borrower = 0andhead_is_ibr_new_borrower = 1on the split test's donors, and asserting that the head's flag comes out False, would pin it. Donor shares that sum to something other than 1 there would also test the moved-dollar columns (:1731-1737), which come out the same here with or without the division by the share sum.
Smaller things, none blocking:
- The docstring and body limit the parents-only rule to programs 10-12 and 16, while the code tests
~graduate(:843, :862-864) and so also counts any code outside 10-16 as undergraduate.np.isin(program, [10, 11, 12, 16]), or a docstring line covering the other codes, would make them agree. - The body says the receipt "checks that its host draw repeats the build's exactly".
drawn_total_matches_buildcompares only the weighted drawn total, which the first QRF draws (experiments/build_1073_scf_education_debt_receipt.py:1697-1699), while the child-share moves come from the second. Comparing the four balance columns too, or rewording, would close the gap. - Item 2(a) of the body says "No code-3 undergraduate loan borrowed before 2008 fits a listed child who could still have been dependent." The receipt shows that the tiers move no loan against a flat $31,000, and fitting loans under their year's tier, or over $31,000, would also give 0. Dropping the sentence, or counting those loans in the receipt, would close it.
- Before July 2008, a dependent student whose parent couldn't get PLUS could borrow up to $46,000 of their own (34 CFR 685.203(e)(2) in the 2007 edition), so the $57,500 reading could use $46,000 for loans from before 2008.
host_children_in_lawcounts the partner an adult child'sA_SPOUSEnames but not aPECOHABpartner (receipt builder :874, :892-900), so the 0.85% sets a lower bound.- When the draw's shares sum to a subnormal number,
drawn_total / sumsoverflows and the moved-dollar columns come out NaN (:1731-1737). The balances stay correct, and computingpart / sums * totalavoids the NaN.
The cloud session's merge of main sits on top at 6a9903a and leaves the stage, its tests and the receipt builder unchanged. Please pull before you push.
|
Ready for hub review Head: Commits on top of
|
| File | Result |
|---|---|
| engine_free/shared/test_spec_engine_field_usage.py | 14 passed (again on 6a9903aa: 14) |
| engine_free/shared/test_spec_engine_calibration_semantics.py | 12 passed |
| engine_free/shared/test_spec_engine_compiler_ir.py | 10 passed |
| engine_free/shared/test_spec_engine_loader.py | 10 passed |
| engine_free/shared/test_transport_evidence_kernels.py | 155 passed, 1 failed at f04f0307 (the count above); 156 passed at 34838725 and at 6a9903aa |
| engine_contract/us/test_spec_engine_coverage_tool.py | 7 passed |
| engine_workflow/us/test_us_multispine_pool_tool.py | 31 passed |
| engine_free/us/test_us_scf_education_debt.py | 52 passed (serial: the first -n 2 attempt was killed when the container ran out of memory) |
| engine_free/us/test_us_bundle_core_contracts.py | 17 passed |
| engine_free/us/test_us_stored_input_register.py | 28 passed |
| engine_free/us/test_us_fiscal_refresh_builder.py | 352 passed, 2 skipped (lines 4137 and 4342 need MICROCOSM_US_CHRONICLE_FACTS) |
| engine_free/us/test_us_spine_blindness.py | 504 passed |
No test file notes a ~15 GB peak, so none was skipped for memory. Apart from the transport and field-usage files, the tests ran at f04f0307/34838725, before merge 2. Merge 2 touched none of those files' modules except via main's own changes, and CI covers them.
CI at 6a9903aa: 10/10 success
lint, wheels, select-countries, integration-uk, engine-uk 3.13 and 3.14, engine-us 3.13 and 3.14, engine-free 3.13 and 3.14. That matches b1218b08c6 (10/10); there were no new failures. engine-free 3.13 was the longest job, at about 1 h 39 min.
Receipts (1073-scf-education-debt-fit.json, commit 78928589e): not rerun
The merge doesn't reach the receipt's code path. Importing experiments/build_1073_scf_education_debt_receipt.py loads 193 microcosm modules. 19 of them are in git diff ea8f409806 79a533c720: country_spec; us_runtime acs_inputs, acs_release_predictors, acs_transfer, hours_worked, immigration and ssi_disability_criteria; calibrate __init__, diagnostics and hierarchy_labels; the diagnostics package (__init__, catalogs, json_schema, schema, writer); the frame adapters policyengine_us and _policyengine_us_source_index; frame concept_mapping; and build staging_storage. All are loaded through package __init__ side effects or importers the receipt never calls:
scf_education_debt.py:196-208,1620imports gates (GateResultonly), source_manifest, eligibility_inputs, scf_auto_loans, scf_wealth, frame andfit.QRF. Their direct-import closure (47 modules) contains, of the changed files, only the calibrate and diagnostics package, throughgates.py:51-53(calibrate.registry,calibrate.solveandledger_targets, none of them changed). Main's changes there are the schema-9 diagnostics writer (calibrate/diagnostics.py:426-429,510), which the receipt never calls.source_manifest.py:114-116namesderive_hours_worked/derive_immigration_statusonly as strings, andgates.py:2292mentions immigration only in a docstring.- The receipt's host frame reads the H5 with
pandas.HDFStoreand buildsFramedirectly (experiments/build_1073_scf_education_debt_receipt.py:932-965), so it never touches the changed PolicyEngine adapter. - Its only package data is
us/source_stages.json(scf_education_debt.py:1917-1918,scf_wealth.py:288), which main didn't change. country_spec.py's one change addsmeasurement_prefittoALLOWED_GATE_FUNCTIONS(line 134).
Judgment calls
- Second merge. After I fast-forwarded to
34838725, GitHub still read Impute federal student loan balances from the 2025 SCF (#1073) #1246 as conflicting: Count the two modules #1342 added to the transport bindings' identity, and merge main's US re-pin (#1496) #1495 had landed and touched the line I hand-edited, so CI never started. I merged main again (79a533c720), re-ran the three checks plus the transport and field-usage tests (no pins moved) and fast-forwarded again. The brief named only the start head; I took "so CI can run on the merged head" as the goal. - Count edit outside the two re-pin files. The transport count was last set by
b1218b08c, not by9edde76d2or3297aefd8. I changed it because it's a count, and engine-free's--maxfail=1would otherwise have stopped the job at it. seed_map/seed_protocoltook main's side in merge 1, as described above.- Ran
test_us_scf_education_debt.pywithout xdist after the out-of-memory kill.
Generated by Claude Code
Part of #1073 (student debt pilot, PolicyEngine/us-student-debt#1). policyengine-us now defines all four columns on
main(policyengine_us/variables/gov/ed/student_loans/), but microcosm's lock pins policyengine-us 2.2.1, which doesn't. So the release tool runs the stage only with--scf-education-debt, the four columns stay out of certified releases until the pin moves, and Max approves any release.Review of 2026-11-03: what changed
The code changes are in
78928589e, on top ofb1218b08c, with no new merge of main. The receipts were rerun there (Modal run20261103T172225Z-78928589e) and committed in563ead1a4. The new stage notes moved the spec identity values, so9edde76d2re-derives them. The tests and spec checks ran on Modal at the final head; see Tests below. The body's fit and Parent PLUS figures now come from that receipt. The "before" columns are unchanged, and the diagnosis tables still cite their own receipts.1. Graduate loans for a child who doesn't fit go to the head's own balance
federal_student_loan_balance, as codes 6, 7 and -7 are, instead of the head's Parent PLUS loan. Parent PLUS loans are made only for a dependent undergraduate (34 CFR 685.200(c)(2)(i)). There are 15 such loans, pooled over the five implicates (3 in implicate 1), or 30,525 weighted loans holding $0.82B, 0.08% of SCF federal balances (source_checks.child_education_readings.loans_moved). The SCF's Parent PLUS share goes from 9.07% to 8.99%.child_graduate_sharestays zero there, since the child holds nothing. The draw records both parts (child_graduate_balance_moved_to_head,child_undergraduate_balance_moved_to_parent_plus), and the receipt sums them for the hold-out and the host (child_share_moves). In this receipt both are zero: no household without an adult child draws a child share, in the hold-out or on the host. The second QRF conditions on the number of adult children, and SCF families without one hold no child share. So the split keeps the rule without moving a dollar here. The receipt also checks that its host draw repeats the build's exactly (drawn_total_matches_build).test_graduate_share_weights_by_amount_borrowed_with_balance_fallback, the Parent PLUS case now uses an undergraduate loan, and the graduate loan has its own case (the head's own loan, graduate share 1). The routing parametrization adds three graduate cases with no fitting child: no child listed, only a minor and a grandchild, and an adult child too young then.test_a_childs_draw_without_an_adult_child_splits_by_its_graduate_sharechecks the host split.test_a_childs_draw_goes_to_the_youngest_adult_childnow expects a single parent to hold a drawn graduate child loan as their own.2. The dependent limit
(a) By loan year.
_DEPENDENT_UNDERGRADUATE_AGGREGATE_LIMITShas three tiers:The SCF records only the year, so a 1993 or 2008 loan counts as borrowed on or after July 1, since most of a year's disbursements come in the fall. That is how the IBR flag already reads 2014. Loan years go back before 1993: the earliest loan for a child is from 1989, and the earliest federal loan from 1983. There are 5 pooled loans for a child before 1993 and 52 from 1993 to 2007. Still, the tiers move no loan in the 2025 file (
loan_year_limit_child_to_parent_plus: 0 loans, against a flat $31,000). No code-3 undergraduate loan borrowed before 2008 fits a listed child who could still have been dependent. Four new routing cases pin the tiers: 2007 against 2008, and 1992 against 1993.(b) The lean toward Parent PLUS, now in the docstring, stage notes, changelog and Known limits. A dependent student whose parent can't get a PLUS loan, after adverse credit for example, may borrow up to $57,500 (34 CFR 685.203(c)(1)(iii) and (e)(2)). Some students under 24 are also independent (20 U.S.C. 1087vv(d)). The rule still reads every undergraduate loan over the dependent limit, for a child who could have been under 24, as Parent PLUS.
(c) Sized. Parent PLUS holds 94 pooled loans (19 in implicate 1) for which a listed child fits, because they exceed the dependent limit: 445,458 weighted loans, $26.18B, 2.60% of SCF federal balances. A $57,500 limit would move 46 of them (9 in implicate 1) to the child: 136,788 weighted loans, $3.97B, 0.39% of federal balances. The SCF's Parent PLUS share would go from 8.99% to 8.59%, and child borrowers from 2.35M to 2.47M. The other 48 loans exceed $57,500. The readings table in item 1 of 2026-10-22 has the full row.
3. Ablation table
Rebuilt from the new receipt (Predictor ablation, below). The share, mean, balance-band and MAE columns depend only on the first QRF, so they match the previous receipt, which the body's table predated. The first receipt's mean error, -4.378%, now reads -4.4%. The borrower-age column depends on the second QRF and moves with its refit: 0.118, 0.021, 0.010, 0.032, 0.015 and 0.035.
4. Stale lines
mainwhile the lock's 2.2.1 doesn't.source_checks.x8000_degree_check,source_checks.x8000_swap).5. Smaller items
load_scf_2025_education_debt_donorchecks each file's SHA-256 against the pinned member (summary_pin,full_pin; on by default,Nonefor fixtures). A local--scf-2025-summary-extractor--scf-2025-full-extractfile is held to the same bytes as a fetched one.test_loader_holds_local_files_to_their_pinned_member_digestscovers it.host.children_in_law).Merges with main
Squash-merge this PR. An early commit,
0cabbc999, contains text that a later commit reworded, and the commits after it carry that text until the rewording. A squash merge keeps it out ofmain's history.origin/mainwas merged in3c4cfe441(2026-11-02), then in430a98881and8537a9519(2026-11-03). The first merge's conflicts:scf_wealthstage notes: main's re-citedsource_impute.pylines and the education-debt rules, both kept;_with_scf_education_debt_stage;county_fips_2023beside the four student loan inputs;source_stages.jsondigest,sources.yaml, the field-usage counts and claim pins, the inventory digests and counts, the bundlespec_sha256and the F0 attestation. The latest re-pin is9edde76d2(review of 2026-11-03; its message lists what moved), from the documented sequence intools/repin_us_spec_engine_contracts.pyrun on Modal (20261103T172238Z-78928589e). Earlier ones are3297aefd8and0caf4907b, andb1218b08crecounts the transport battery's binding modules. A cloud session merges main and re-derives them once more right before this lands.b1218b08c(10/10). The PR now conflicts with main, so GitHub CI doesn't run on later pushes until that merge. This round's Modal runs are the check (review of 2026-11-03, above).Review of 2026-10-22: what changed
That round's receipts were rerun at its merged head (
60255dd98). The figures in this section now come from the current receipt (review of 2026-11-03), and the before columns are unchanged. FSA figures are its portfolio tables (chronicle#347): Direct Parent PLUS $115.0B of $1,724.5B at FY2026 Q3, and borrowers by age and by debt size at FY2026 Q2, which count the parent on a Parent PLUS loan.1. Parent PLUS: loans for a child go to a co-resident adult child
"Before" is the previous rule; the host's before column is the previous receipt (2a6e65a). Surveys capture about $1.0T of FSA's $1.7T, so the shares compare and the levels don't. FSA's 42.3M unduplicated recipients count the student instead of the parent.
The rule (module docstring, stage notes,
derive_scf_education_debt_targets). The 2025 screener (X7801) leaves out loans of people living elsewhere, so a code-3 loan is either a co-resident child's own loan or the parent's Parent PLUS loan. No education-loan item names the listed person, so the rule uses the household listing (persons 3-12, relationship X108...X226, age X110...X228) with the loan's year (X9332-X9337), program (X9338-X9343) and amount borrowed:The one judgment call is an undergraduate loan within the limit for a child of dependent age, which could be either; I read it as the child's own, the member the listing names. The receipt's readings table bounds it:
On the host the child's draw goes to the youngest child of the reference person or partner aged 18 or over (
PEPAR1/PEPAR2). The second QRF now conditions on the number of such children (adult_children_in_household: listing code 4 aged 18 or over on the SCF side), so a household without one rarely draws a child's share. If it does, its graduate part joins the head's own balance and the rest becomes the head's Parent PLUS loan, the SCF side's rules for a student who lives elsewhere (item 1 of 2026-11-03). In the current receipt no household does.Borrower counts barely move, net (SCF 27.23M → 27.24M, host 21.22M → 21.21M, FSA 44.9M). Two changes offset. The X8000 swap alone, which moves loans 4 and 6 to the other partner, takes the SCF from 27.23M to 26.92M, and child routing brings it back to 27.24M. A child takes over a balance a parent held, and most parents whose only federal loan was for the child stop counting as borrowers (on the SCF side, Parent PLUS holders 4.38M → 2.09M and child borrowers 0 → 2.35M). What moves is who the borrowers are, toward FSA's age profile.
2. X8000: verified, and loans 4 and 6 swapped back
I reran the degree test independently, by loan position and across all five implicates (
x8000_degree_checkin the receipt builder). The test takes completed degree loans coded 1 or 2 in families with a spouse or partner, keeps those consistent with exactly one partner's highest degree (X5931, X6111), and counts the ones consistent with the coded person:Each position fails on its own. The receipt now records them by position (
x8000_degree_check, loans4and6). In reversed families, loan 4 matches the coded person on 0 of 39 discriminating loans (0 of 7 in implicate 1), and loan 6 on 0 of 15 (0 of 3). In other families they match on 20 of 20 and 5 of 5. I also tried an age-at-borrowing test, but too few couples have a partner too young to have borrowed, so it can't tell the cases apart.The fix.
_scf_whose_education_codesswaps codes 1 and 2 of loans 4 and 6 when X8000 = 1 (SCF_WHOSE_EDUCATION_UNREVERSED_LOANS). It is tied to the pinned member SHA-256, since the codebook names no exception.test_reversed_families_swap_the_codes_of_loans_4_and_6_backcovers both codes, the other four loans, unreversed families, and code 3 staying put. The swap changes the holder of 0.28% of federal balances, $2.78B on 80 pooled loans (16 in implicate 1): 0.23% on loan 4 and 0.04% on loan 6 (source_checks.x8000_swap).3. IBR new borrowers: an upper bound
The 59.5% is now labelled an upper bound in the receipt row, the module docstring, the stage notes and the changelog. Two reasons:
Parent PLUS stays in the head's flag, citing 685.209(b)(13)(ii).
4. Composition: Parent PLUS is drawn, not left over
The second QRF now draws the Parent PLUS, adult child's, head's and spouse's shares in that order, then normalizes them to sum to one, so Parent PLUS is no longer the residual of the two own shares. A borrower drawn with no share holds its own loan, and whole dollars go to the largest share, so a zero share stays zero.
test_a_donor_family_without_parent_plus_rarely_draws_one: own debt splits between partners in fractions, and only families with a reference person 55 or older hold Parent PLUS. Under 2% of the others draw any, and a donor with no Parent PLUS never draws it.5. The fit discussion (your point 4)
This round changes who holds the debt, not how much each household has. The first QRF, its predictors and its seed are unchanged, so every household's drawn total is identical, and so is every share-with-debt figure, including the income gradient: bottom quintile 14.3% vs SCF 11.4%, top 14.2% vs 17.9%. The borrower age profile moves toward FSA, since parents' Parent PLUS balances become their children's own loans:
So the 50-61 excess over FSA is gone (13.8% against FSA's 14.3%): it came from Parent PLUS balances held by parents in that band. Against the SCF itself, the host still has too many borrowers there (13.8% against 11.2%). The income gradient and the borrower-count shortfall (21.2M against SCF 27.2M and FSA 44.9M) remain for our discussion. I haven't tuned toward either.
6. Odds and ends
scfp2022s.zipre-release (10/9): nothing in this round reads the 2022 files. The tests use synthetic fixtures, and the receipts and probes read only the 2025 files. Nothing broke.What this builds
A new US runtime module,
us_runtime/scf_education_debt.py, imputes four person inputs from the 2025 SCF:federal_student_loan_balancefederal_parent_plus_loan_balanceis_income_based_repayment_new_borrowerfederal_student_loan_graduate_shareDonor. The loader joins
rscfp2025.dtaandp25i6.dtaon(y1, yy1)across all five implicates. It refuses a file whose SHA-256 isn't the pinned member's (a local--scf-2025-*-extractfile included), a row-count drift, an unmatched implicate, or per-loan balances that do not reproduceedn_inst. Both files are declared as artifacts 3 and 4 ofscf_wealth, pinned by zip and member SHA-256 against a fresh Fed download (2026-10-12), with a newscf_2025vintage.Model. One weighted regime-gated QRF draws each household's federal education debt. A second, fit on borrower families and conditioning also on that total and the number of adult children, draws the chained composition: the Parent PLUS, adult child's, reference person's and spouse's shares in that order (normalized to sum to one), then the three graduate shares and new-borrower flags. Both condition on the same predictors (
SCF_EDUCATION_DEBT_PREDICTORS):The shared SCF interest-dividend and Social Security-pension predictors are left out. The cause section below says why, and
SCF_EDUCATION_DEBT_EXCLUDED_PREDICTORSrecords each reason.SCF_EDUCATION_DEBT_CANDIDATE_PREDICTORSkeeps the full list for the diagnostics. Educational attainment waits for Carry educational attainment as an auxiliary variable (imputation predictor + ACS-calibratable margin) #297.People (
SCF_EDUCATION_DEBT_RECIPIENT_DEFINITION). The CPS side builds each predictor the way the SCF defines it:is_marriedcounts a cohabiting partner, as SCFMARRIEDdoes.KIDScounts them.The reference person, the member its
A_SPOUSE(elsePECOHAB) pointer names, and the youngest child of either aged 18 or over receive the balances. A spouse draw in a household with no spouse or partner goes to the reference person. A child's draw in a household with no adult child splits by its drawn graduate share: the graduate part joins the reference person's own balance, and the rest is the reference person's Parent PLUS loan.Release tool.
tools/build_us_fiscal_refresh_release.pyruns the stage only with--scf-education-debt, after auto loans. That run applies a signal gate and writes a design-weight fit table (artifacts/us_scf_education_debt_fit.json). Default releases are unchanged and do not fetch the 2025 files.Rules for the reviewer to confirm
1 + X7179 / (balance on loans 1-6). That applies the family's federal share of loans 1-6 to X7179, and splits its federal part among people in the same proportions. A family with X7179 but no balance on loans 1-6 contributes nothing; the receipt counts those dollars (none in 2025).federal_student_loan_balance;federal_student_loan_balance;federal_student_loan_balance, or else the reference person'sfederal_parent_plus_loan_balance, by the rule in review item 1 of 2026-10-22. A graduate code-3 loan that no listed child fits goes to the reference person's own balance (item 1 of 2026-11-03);Known limits:
PEPAR1/PEPAR2) never count. Item 5 of 2026-11-03 sizes it.federal_student_loan_balanceexcludes until consolidated.Fit vs SCF 2025, before and after
All figures are weighted. "Hold-out" draws each SCF family from models fit on the other four of five folds, grouped by family. "Host" runs the stage with seed 0 on the published default
populace-us-2024-spm-20260915, at weights that were calibrated without these columns. The full tables, with FSA balance and age bands, race, income quintiles and medians by group, are inexperiments/1073-scf-education-debt-receipts.md(JSON inexperiments/receipts/).Source checks. The loader matches the Fed's Alternate Table 13 exactly: 19.92% of families hold education debt, with a median of $25,500. Federal loans hold 87.4% of balances on loans 1-6. X7179 holds 0.6% of all education debt.
The three host settings:
A_MARITLmarriage, own children only, whole-household income, all candidate predictors.Household figures are unchanged from the previous receipt, because the first QRF, its predictors and its seed are unchanged. The composition rows (Parent PLUS, borrowers, medians per borrower, IBR and graduate shares) move with the refit second QRF; see item 1 of 2026-11-03.
The cause: covariate shift in imputed income components
The model reproduced the SCF out of sample but drew too few borrowers on the CPS host. A QRF reproduces its donor's conditional distribution, so a gap that appears only on the host means the host's predictors are distributed differently from the donor's. The diagnosis tested this predictor by predictor (
experiments/diagnose_1073_host_fit.py,diagnose_1073_predictor_shift.py).Your whole-household income hypothesis did not hold for 25-34. CPS household income there is lower than SCF primary-economic-unit income, not higher: median $76k vs $82k, wages $62k vs $72k. Narrower CPS units are lower still.
The CPS host carries tax-record imputations of interest and dividends and of Social Security and pensions; the SCF has survey reports. The two disagree most at 25-49, where most borrowers are:
A QRF reads those values as positions in the SCF distribution, so young host households looked like older, asset-holding SCF families. Those families seldom hold education debt.
Each predictor's shift, measured directly (
experiments/receipts/1073-predictor-shift-diagnosis.json). The diagnosis maps one predictor at a time to its SCF distribution at equal rank, within five-year reference-age bins, and redraws the host's borrower share. The as-is share is 14.2% (SCF 18.2%):Codings. Three CPS predictors were also coded differently from the SCF definitions: cohabiting partners, partners' children, and the reference person (the oldest-adult rule picks a different person from the SCF ordering in 13% of households). Fixing them and the income unit moved Parent PLUS from 21.5% to 15.5% but left the borrower share at 14.5% (v4b).
Dropping the two shifted predictors on top of v4b gave the build. The recipient-definition and predictor variants are in
experiments/receipts/1073-recipient-definition-variants.json(run before this round, so their Parent PLUS column uses the previous rule):r3 shows the income unit matters for Parent PLUS: with whole-household income it goes back to 21.1%.
Remaining gaps
Borrower households are about 1.7 points short (16.4% vs 18.2%). Most of the gap is at reference ages 25-34 (28.1% vs 36.2%), with a smaller one at 35-49 (24.4% vs 27.2%).
Parent PLUS is 9.7% of the host total vs 9.0% in the SCF and 6.7% (a floor) at FSA.
Borrowers are 21.2M on the host vs 27.2M in the SCF and 44.9M at FSA, and the share with debt is flatter by income than in the SCF (bottom quintile 14.3% vs 11.4%, top 14.2% vs 17.9%).
The FSA calibration (Calibrate US federal student loan balances to Federal Student Aid portfolio targets and show them on the dashboard #1074) reweights levels by age, state and debt size. It does not fix the split between own loans and Parent PLUS, or which households hold debt.
What is still shifted at 25-34, under the build's income unit (
receipts/1073-host-fit-diagnosis.json):These are the likely next levers. I haven't tested whether either one explains the gap.
Predictor ablation (five-fold hold-out on SCF families)
From the current receipt. One seed and no repeated runs, so small differences may be noise. Balance-band TVD is over household balances.
educeducDropping the two shifted predictors costs nothing out of sample on the SCF itself. Education is still the only predictor that raises the rank correlation, by about 0.04.
How the receipts were produced
Heavy steps run on Modal through
experiments/modal_1073.py. It clones a pushed commit and runsuv sync --all-packages --extra us --frozen. It also fetches the published default (HFpolicyengine/populace-us@8ab57ffc) and the SCF 2025 zips, and checks every SHA-256. The runner saves each step's log, plus every tracked file the step changed, to a volume;collectbrings them back. Uselaunch, which spawns the run and exits, so a stopped local client cannot cancel it.experiments/1073-scf-education-debt-receipts.md+receipts/1073-scf-education-debt-fit.jsonreceipt)20261103T170646Z-b1218b08c(--script)20261022T153020Z-2a6e65a7320261023T135427Z-3f47edf23,20261023T140011Z-3f47edf23(--script)receipts/1073-predictor-shift-diagnosis.json,receipts/1073-recipient-definition-variants.json20261020T150445Z-47949111freceipts/1073-host-fit-diagnosis.jsonScope: 2025 for education debt only
Net worth, the liquid-asset leaves and auto loans still read the 2022 files; nothing they produce changes. To move the whole
scf_wealthstage to 2025 later:fetch_scf_2022_summary_extract/fetch_scf_2022_full_extract(and theirload_*callers) atSCF_2025_SUMMARY_EXTRACT/SCF_2025_FULL_EXTRACT, usingfetch_scf_public_file;source_stages.jsonand repoint thescf_2022vintage;The predictor and target code needs no change. The 2025 summary extract carries every column the shared predictor construction reads (
age,hhsex,racecl5,married,kids,wageinc,intdivinc,ssretinc, plusliq,stocks,nmmf,bond,networth). The 2025 codebook still lists the auto-loan items (X2209...X7170).racecl5andracecl4fold the new Middle Eastern or North African category into White non-Hispanic.Release plumbing and the engine contract
policyengine-us defines the four variables on
main(policyengine_us/variables/gov/ed/student_loans/), but microcosm's lock pins policyengine-us 2.2.1, which doesn't. So:US_STORED_NON_VARIABLE_COLUMNSwith the same pending-engine reasonparent_1_id/parent_2_idcarry.test_us_stored_input_register.pybinds them to their producer (US_SCF_EDUCATION_DEBT_OUTPUT_COLUMNS).test_support/microcosm_data/stored_inputs.pylists them as producer-bound, because no file examined for Published US releases store would_claim_wic, which policyengine-us 2.x ignores, so WIC take-up is 100% under policyengine.py 6.1.1 #1026 stores them. The entries leave when the pinned engine carries #9721 and #9724.assert_release_input_coverage_manifest_currentrequires every declared column to be a live engine input leaf, and arequiredcolumn would refuse every default release. I did not work around that check. The entries belong in the PR that moves the policyengine-us pin.Spec identity, re-derived from the code, never by hand. The re-pin commits are listed under Merges with main, above. The four outputs also join the QRF tail-concentration list in
calibration.yaml, which skips absent columns, andvintages.yamlgainsscf_2025.Tests
All on Modal (Linux, Python 3.14,
--extra us), since GitHub CI won't run while the PR conflicts with main:78928589e(run20261103T172212Z-78928589e), and again at the final head9edde76d2(run20261103T180422Z-9edde76d2). New since last round:test_the_dependent_limit_follows_the_loan_year;9edde76d2(run20261103T180422Z-9edde76d2):--checkmode;20261103T180435Z-9edde76d2):tools/repin_us_spec_engine_contracts.py --check, the transport battery's evidence-kernel tests,ruff check .andruff format --checkon the changed files.Every command passed. The coverage check reports 42,489/42,489 configuration fields and 41/41 inventory checks. The suites passed 861 engine-free tests, 2 release-tool tests and 114 engine-contract tests, plus 151 transport evidence-kernel tests.
Follow-ups
spec_engine/seeds.py) when it joins the default graph. Today they use a dedicatedSeedSequence([seed, 1073, step])stream.🤖 Generated with Claude Code