Skip to content

Re-pin the US Chronicle feed to add Federal Student Aid's loan portfolio tables - #1506

Merged
MaxGhenis merged 8 commits into
mainfrom
us-feed-ed-fsa-1103
Nov 4, 2026
Merged

MaxGhenis merged 8 commits into
mainfrom
us-feed-ed-fsa-1103

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Part of #1074. This branch started from #1345's head (ea4606d6b9), and #1345 merged into main on November 4, so this PR now targets main.

Re-pins the US Chronicle feed so it carries Federal Student Aid's loan portfolio tables, which #1357 binds as opt-in calibration targets. Every row of #1345's feed stays the same, the re-pin adds 461 ed_fsa rows, and the default compile's 33,250 targets don't change.

The Chronicle cut

41b2fb2b864a28c0eda03322b77a97ef147dfa3c, on Chronicle branch feed/us-a9026cc-ed-fsa-1103, tag microcosm-us-feed-1074-v1. It cherry-picks chronicle#347's commit 4448a7c96 onto #1345's pin a9026cc8f (tag microcosm-us-feed-1343-v1) and changes nothing else. Chronicle main doesn't contain the cut. Chronicle#347's commit also brings a Census ACS S1501 state package, which the scope leaves out. A separate session cut the commit and built the feed (Chronicle branch hub-notes/ed-fsa-feed-cut-1103 at bf3456f9, notes/ed-fsa-feed-cut-1103/). This PR uses that session's feed and scope, after these checks:

Check Result
Feed sha256 (gunzipped) 1d45a89c28177a95371b4cc4068d3657c7bf12bd8edad7c82fe25bf453f9ea07
Lines 40,642
schema_version chronicle.consumer_fact.v3 on all 40,642 rows
Scope sha256 73a39c166a8f78c8a8a1ae711515e5d2e6b76a74952e67d7783cf974a4615633
Feed with the 66 new pairs' rows dropped 40,181 lines, sha256 4d89e4a498ffd73a407821d9ce2c45837072901e0a29acf3206160d1fafd7c14 (#1345's feed)

What changes

  • us/chronicle_feed_scope.json: the notes' scope, byte for byte. It has 706 pairs: Re-pin the US Chronicle feed so three dimension values carry one label each #1345's 640 plus 66 ed_fsa pairs, all at build year 2026, over 72 package runs. No microcosm test needed a different order or format.

  • us/chronicle_feed.json:

    • source_commit;
    • the build text;
    • fact_row_count 40,642;
    • facts_sha256 1d45a89c…;
    • scope_sha256 73a39c16….

    consumer_fact_schema_versions stays ["chronicle.consumer_fact.v3"] and consumer_fact_schema_sha256 stays bdb51e2a… (see below).

  • target_parity_manifest.json and target_parity_feed_families.json, regenerated:

    • they name the new feed (consumer_facts_us_41b2fb2.jsonl) and its digest;
    • the feed now has 93 families, 11 of them new ed_fsa.* families;
    • tools/build_us_target_parity_manifest.py declares those 11 off_by_default reviewed exclusions, under one fence for the ed_fsa namespace;
    • the files count 32 compiled families and 64 reviewed exclusions, up from 53;
    • the label variants don't change. Judgment call 1 says why these files change.
  • Tests:

    • test_us_chronicle_feed.py: _CHRONICLE_COMMIT, _FACTS_SHA256, the row count, the scope's pair and run counts, and the exclusion count. _SCHEMA_SHA256 and the v3 schema-versions assertion stay the same and still hold;
    • test_release_target_parity.py: the exclusion count, plus a test that the manifest lists all 11 ed_fsa families as off_by_default reviewed exclusions behind the microcosm#1074 fence;
    • test_us_esi_premiums.py: the report chain, below.
  • experiments/us-feed-ed-fsa/: compare.json, plus added_rows.json and the script that writes it (list_added_rows.py).

  • docs/us-chronicle-feed-repin.md: a new section and the pin table. Changelog fragment: changelog.d/1074-us-feed-fsa-loan-portfolio.added.md.

Compare

experiments/us-feed-agi-age-labels/compare_feeds.py (#1345's script; follow-up 4 corrects its docstring) ran with #1345's feed as the old feed and the notes' feed as the new one, and wrote experiments/us-feed-ed-fsa/compare.json. The script compiled both feeds at 2024, as the release does.

  • Rows kept: all 40,181. compare.json lists no keys_only_in_old, counts 0 changed_rows and lists no changed_fields, so every old row keeps its key, values and labels.

  • Rows added: 461, all from source ed_fsa. They match the ed_fsa rows of the notes' ed_fsa_rows.jsonl one for one:

    Package Pairs Rows
    dl_portfolio_by_repayment_plan_fy2026_q3 36 227
    portfolio_by_age_fy2026_q2 4 24
    portfolio_by_debt_size_fy2026_q2 4 36
    portfolio_by_loan_type_fy2026_q3 12 42
    portfolio_by_location_fy2026_q2 1 108
    portfolio_summary_fy2026_q3 9 24
  • Targets:

    • 33,250 on both feeds, in the same order, with no changed field;
    • registry version b8d571e91c22 on both;
    • national_state surface: 6,102 targets, 3aa2a0830e7d, on both;
    • no compiled label variants on either.
  • Feed label variants: age_20 and age_21, as before.

  • Memory: the compile step finished without running out of memory, and the whole script took about 2 minutes.

Why the schema versions stay v3

  • The stamp. Every row of the feed, old and new, carries chronicle.consumer_fact.v3. At the cut, the exporter writes every row's stamp from one constant, CONSUMER_FACT_SCHEMA_VERSION. That constant takes its value from the last entry of CONSUMER_FACT_ROW_CONTRACTS (chronicle/consumer_contract.py:35, chronicle/epoch.py:171-176), and the tuple ends at v3. Both files match byte for byte at a9026cc and 41b2fb2b; I read them through raw.githubusercontent.com.
  • The schema digest. Chronicle computes consumer_fact_schema_sha256 as the sha256 of policyengine_chronicle/schemas/consumer_fact.v3.schema.json (policyengine_chronicle/schema.py:59-70). That file hashes to bdb51e2a… at both commits, so the digest doesn't change from Re-pin the US Chronicle feed so three dimension values carry one label each #1345's.
  • What microcosm does with the stamp, on this branch:
    • _load_fact_rows carries a row's stamp without gating on it (ledger_artifact.py:464-471);
    • fact_schema_versions only reports the stamps (:207-219);
    • the US pin loader checks only that consumer_fact_schema_versions holds a list of distinct, non-empty strings (us_runtime/chronicle_feed.py:136-143);
    • the release tool loads the bare feed with expected_facts_sha256, which checks only the file's hash;
    • validate_chronicle_feed_pin (ledger_artifact.py:257-291) would compare the list with the rows' stamps (:276, :287). It runs only on the transport kernels and the BE and NZ reference tools, never on the US release path.
  • Calibrate federal student loan balances to Federal Student Aid's portfolio (#1074) #1357's code doesn't read the stamp. I grepped fsa_student_loan_targets.py, ledger_targets.py, target_aging.py, Calibrate federal student loan balances to Federal Student Aid's portfolio (#1074) #1357's diff to fiscal_targets.py and the release tool, and its FSA test file, all at 0ff293b1a. The grep finds schema_version only in the FSA receipt's own version (fsa_student_loan_targets.py:441) and a target profile's (ledger_targets.py:756-765).

So the pin lists only v3. #1357's body expected v4 FSA rows and a pin listing both versions; that would need Chronicle's 0ec21e5 in the cut, which would restamp every row v4.

The report chain test

test_the_committed_feed_pin_banks_the_anchor follows the committed re-pin reports from #454's feed to the pin. Before this PR it refused any report that added rows (keys_only_in_new == [], old_rows == new_rows), so this re-pin would have failed it. This PR moves the per-report check into a helper, _follow_later_repin_report:

  • Rows a report may add. A report adds rows only when its entry in _LATER_ROW_KEEPING_REPIN_REPORTS names a committed listing of the added rows (added_rows.json), one source and one record-set namespace ("ed_fsa." here). The listing must name the same keys the report adds, each from that source, outside the B4923C anchor record sets and in a record set whose id starts with the namespace. new_rows must equal old_rows plus the number of added keys.
  • The other checks stay: a report must start at the previous feed, keep every key, change only labels and record_set_spec_hash, and leave the anchor record sets alone.
  • added_rows.json. compare.json doesn't record where an added row comes from, so list_added_rows.py reads the added keys back out of the new feed, with their source.source_name, record set and period.
  • Tests in each direction. One test accepts rows added from the declared source. Seventeen cases refuse, each at the check it names:
    • rows added without a declaration;
    • a row from another source;
    • a listing that misses a key, and one that names a key the report doesn't add;
    • a row in an anchor record set;
    • a listing with no source, and one with no namespace;
    • a row outside the namespace, and one whose record set holds ed_fsa. after its first component;
    • a listing of another feed, and one that repeats a key;
    • dropped rows, a wrong new row count, a value change, a wrong base, an old feed of another size, and a label change in an anchor record set.

Default compile

On this branch, which doesn't have #1357's code, the default compile turns no ed_fsa fact into a target. I ran:

  • compile_us_fiscal_target_registry(age_targets=True, packaged CD crosswalk) and apply_us_medicaid_enrollment_substitutions on the new feed;
  • the release tool's own checks, called from the tool's module: _check_committed_us_ledger_feed_pin, _check_us_esi_anchor_against_feed (no mismatch), assert_target_parity_manifest_current(registry=…) and us_release_target_parity_gate.

It gives 33,250 targets in 32 families, none of them from ed_fsa, and the parity checks pass against the regenerated resources. The pinned-feed parity tests (test_committed_artifacts_match_regeneration, test_gate_passes_on_real_compiled_registry) ran rather than skipped and pass. So the compiled families stay at 32.

Checking against #1357 (scratch branch, not pushed)

I merged #1357's head, 0ff293b1a335a29d46f1598f2039beffa242e632, into a local scratch branch off this PR.

Tests at c624e6f (with the feed in place)

I put the feed at ~/PolicyEngine/_buildh-runtime/inputs/consumer_facts_us_41b2fb2.jsonl, where tools/build_us_target_parity_manifest.py and the pinned-feed fixtures look for it, and pointed MICROCOSM_US_CHRONICLE_FACTS at it. Each file ran in its own process (-n 4, -p no:cacheprovider). Only test_us_fiscal_refresh_builder.py skipped any tests, and its 6 skips need the policyengine-us extra (:55), not the feed:

File Passed
test_us_chronicle_feed.py 28
test_release_target_parity.py 39
test_us_esi_premiums.py 159
test_us_fiscal_targets.py 174
test_us_soi_congressional_district_vintage.py 51
test_us_soi_state_agi_bands.py 29
test_us_medicare_irmaa_holdout.py 24
test_us_acs_local_sparse_cd.py 28
test_us_release_gate_preflight.py 84
test_us_spec_bundle.py 4
test_us_acs_local_release_tool.py 215
test_us_fiscal_refresh_builder.py 346, and 6 skipped

ruff check . passes and tools/ci_test_plan.py verify reports ok. No test I ran says it peaks near 15 GB. CI covers the full suites.

Merge order

  1. Re-pin the US Chronicle feed so three dimension values carry one label each #1345 merged into main on November 4.
  2. This PR now targets main.
  3. Calibrate federal student loan balances to Federal Student Aid's portfolio (#1074) #1357. Its targets bind only in a Calibrate federal student loan balances to Federal Student Aid's portfolio (#1074) #1357 build that passes --scf-education-debt (student_loan_targets=True); a default build compiles none of them.

#1357 has to resolve the seven spec-engine conflicts against main whatever the order. This PR already takes the step #1357's body lists for the re-pin: the generator declares the FSA families reviewed exclusions, and _without_opt_in_student_loan_targets keeps them off the parity contract.

Judgment calls

  1. I regenerated the parity resources. Since no ed_fsa fact compiles by default, the resources could have stayed the same, but then CI would fail:

    • test_parity_resources_and_generator_restate_the_pin (test_us_chronicle_feed.py:120-132) requires both files to restate the pin's facts_sha256;
    • the generator refuses a feed family that neither compiles nor has a fence (_exclusion_for), so it can't run on this feed without one.

    No family's status changed: 32 compiled before and after. The generator classifies the 11 new families off_by_default, the classification TargetFamily documents. This PR takes the third re-pin step in Calibrate federal student loan balances to Federal Student Aid's portfolio (#1074) #1357's body. Without the fence this PR can't pass CI, so the fence can't wait for Calibrate federal student loan balances to Federal Student Aid's portfolio (#1074) #1357.

  2. The ed_fsa fence's origin says this re-pin did not search the retired us-data pipeline for a student loan target.

  3. DEFAULT_FEED_NAME reads consumer_facts_us_41b2fb2.jsonl, following Re-pin the US Chronicle feed so three dimension values carry one label each #1345's naming.

  4. The chain test. I left the logic of Re-pin the US Chronicle feed so three dimension values carry one label each #1345's compare_feeds.py alone; follow-up 4 corrects only its docstring. The added rows' sources come from a separate listing, added_rows.json.

  5. The pin's build text still says chronicle build-consumer-artifact refuses the bare feed "at this commit". I didn't run that command at 41b2fb2b. The v3 schema file hasn't changed, and the feed holds the same 994 rows the doc gives as the reason (the concept_alignment rows with no authority: 515 from cms_medicaid, 468 from census_acs, 11 from jct).

  6. What the doc takes from the cut session's notes. It attributes three things to them, which I did not rerun: the acbedb5 export, 0ec21e5, and the 217 FSA rows the fixture lacks.

  7. Branch. No microcosm branch us-feed-ed-fsa or us-feed-ed-fsa-1103 existed, so I created us-feed-ed-fsa-1103.

Review follow-ups

An independent review of c624e6f approved this PR with four follow-ups. Commits 395d1cc..8982b8f make them:

  1. The chain test ties each listed row to a record-set namespace. Each declaration in _LATER_ROW_KEEPING_REPIN_REPORTS now names the namespace its added rows must sit in ("ed_fsa." here). _follow_later_repin_report refuses a listed row whose record_set_id doesn't start with it, after the anchor check. Before this, a listing could label an irs_soi row ed_fsa and pass. 395d1cc commits that case failing, and af45400 makes it pass. A no-namespace case covers a declaration that leaves the namespace out. 8353e34 adds cases for two checks that no case reached: a listing of another feed, and a listing that repeats a key. Both already refused.
  2. The parity test's docstring says which families bind. Only four of the 11 ed_fsa families bind under Calibrate federal student loan balances to Federal Student Aid's portfolio (#1074) #1357's opt-in: location, age, debt size and loan type. The portfolio summary and the six repayment-plan and IDR tables stay diagnostic in every build, and so do the loan-type table's recipient counts. The single namespace fence stays. Giving the seven diagnostic families their own exclusions waits for Calibrate federal student loan balances to Federal Student Aid's portfolio (#1074) #1357, which owns their reasons.
  3. The doc says how many facts bind. Calibrate federal student loan balances to Federal Student Aid's portfolio (#1074) #1357's student_loan_targets=True binds 136 of the 461 ed_fsa facts. The doc's paragraph on the anchor test now names the namespace too.
  4. compare_feeds.py's docstring says what the script does. It reports keys_only_in_old and keys_only_in_new; it doesn't require both feeds to hold the same keys.

With the feed in place at 8982b8f, test_us_esi_premiums passes 163 (159 before, plus four new cases), test_release_target_parity 39 and test_us_chronicle_feed 28. The follow-ups don't touch the pin, the scope, the feed, the parity resources or the changelog fragment.

A second independent review approved 8982b8f and found two code changes that left the later-report tests passing: matching the namespace anywhere in a record set id instead of at its start, and moving the namespace check ahead of the anchor check. A mutation run over every check in _follow_later_repin_report found six more:

  • moving the namespace check ahead of the source check;
  • deleting the old-row-count check, the changed-field anchor check, the declared-source check, the source check or the added-row anchor check.

1f25420 gives every check in the helper a name in its assertion message and has each refusing case name the check that must refuse it, through pytest.raises(..., match=...). It adds three cases: a record set that holds ed_fsa. after its first component, an old feed of another size, and a label change in an anchor record set. The helper's logic stays the same, so the new cases pass at once. Each of the eight changes now fails at least one case, under pytest's assertion rewriting and with --assert=plain. 212dd72 rewords the doc's sentence on the 136 facts.

A third independent review approved 212dd72 and found two more gaps in the cases. Each anchor case holds one record set, so a check that read only the first would still refuse it. Each refusing count exceeds the expected count and each refusing digest sorts after the expected one, so a check that compared with <= would still refuse them all. The helper reads every record set and compares with ==; #1513 adds cases for both gaps.


🤖 Generated with Claude Code

…ables

Chronicle 41b2fb2b (tag microcosm-us-feed-1074-v1) is a9026cc8f, the
#1345 pin, plus a cherry-pick of chronicle#347's 4448a7c96, which adds the
six packages/ed_fsa Federal Student Aid loan portfolio packages.

- us/chronicle_feed_scope.json: the cut session's scope, byte for byte
  (706 pairs: #1345's 640 plus 66 ed_fsa pairs at build year 2026;
  sha256 73a39c16...).
- us/chronicle_feed.json: source_commit, build text, fact_row_count 40,642,
  facts_sha256 1d45a89c..., scope_sha256. consumer_fact_schema_versions
  stays ["chronicle.consumer_fact.v3"]: every row of the feed carries v3.
  consumer_fact_schema_sha256 stays bdb51e2a...: the v3 schema file has
  the same bytes at 41b2fb2b as at a9026cc.
- Parity resources regenerated: the 11 ed_fsa feed families are
  off_by_default reviewed exclusions (a default compile binds none of the
  461 facts; #1357's --scf-education-debt opt-in binds them). Still 32
  compiled families; 64 reviewed exclusions; 93 feed families.
- experiments/us-feed-ed-fsa/compare.json (#1345's compare_feeds.py,
  baseline 4d89e4a4...): every old row kept unchanged, 461 rows added, all
  ed_fsa; the default compile's 33,250 targets are unchanged.
- The B4923C report chain test now accepts a later re-pin that adds rows
  from one declared source (added_rows.json), and nothing else.

Part of #1074.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6KfC1gXpoY2QTYwvvy
A section for the re-pin in docs/us-chronicle-feed-repin.md (the cut, the
66 pairs, the comparison, why the stamp stays v3 and the schema digest
stays bdb51e2a), the pin table and the feed's home path. Part of #1074.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6KfC1gXpoY2QTYwvvy
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Ready for hub review

Head: c624e6fe5038ee2db19ee3a19773a92b570ae945 (two commits on #1345's ea4606d6b9).

Inputs. All checks passed: the five notes files fetched at bf3456f9. The gunzipped feed is 1d45a89c…, 40,642 lines, every row chronicle.consumer_fact.v3. The scope is 73a39c16…. With the 66 new pairs dropped, the feed is 40,181 lines, 4d89e4a4…, which is #1345's feed.

The re-pin.

  • The scope is copied byte for byte; no test needed a different order or format.
  • The pin changes source_commit to 41b2fb2b, the build text, fact_row_count to 40,642, facts_sha256 and scope_sha256.
  • consumer_fact_schema_versions stays ["chronicle.consumer_fact.v3"].
  • consumer_fact_schema_sha256 is the sha256 of Chronicle's policyengine_chronicle/schemas/consumer_fact.v3.schema.json (policyengine_chronicle/schema.py:59-70). That file is bdb51e2a… at both 41b2fb2b and a9026cc, so the digest doesn't change from Re-pin the US Chronicle feed so three dimension values carry one label each #1345's.
  • In the test, _CHRONICLE_COMMIT and _FACTS_SHA256 change and _SCHEMA_SHA256 doesn't. The v3 assertion still holds.
  • I added a doc section and a changelog fragment.

Stamp readers on #1345's branch. All five checks read as the brief says:

  • ledger_artifact.py:464-471 carries the stamp without gating on it;
  • :207-219 reports it;
  • us_runtime/chronicle_feed.py:136-143 checks only the list's form;
  • validate_chronicle_feed_pin (:257-291, :276, :287) runs only in the transport kernels and the BE and NZ tools.

I found no other code that reads the stamp. #1357's FSA code has no read either: its only schema_version hits are its receipt's own version and a target profile's.

Compare. experiments/us-feed-ed-fsa/compare.json:

  • 40,181 rows kept, 0 changed;
  • 461 added, all source ed_fsa: 227, 24, 36, 42, 108 and 24 by package;
  • 33,250 targets with 0 changed, the same registry version (b8d571e91c22) and the same national_state surface (6,102 targets, 3aa2a0830e7d);
  • no out-of-memory error.

Report chain test. It used to refuse added rows. It now accepts rows added from one declared source, checked against a committed listing (added_rows.json), and nothing else. There is one accepting test and ten refusing cases.

Default compile. No ed_fsa fact compiles: 33,250 targets in 32 families. The release tool's pin, ESI-anchor and parity checks pass.

#1357 scratch merge (head 0ff293b1a, never pushed):

Tests on this branch, with the feed at ~/PolicyEngine/_buildh-runtime/inputs/consumer_facts_us_41b2fb2.jsonl and MICROCOSM_US_CHRONICLE_FACTS set:

File Passed
test_us_chronicle_feed 28
test_release_target_parity 39
test_us_esi_premiums 159
test_us_fiscal_targets 174
test_us_soi_congressional_district_vintage 51
test_us_soi_state_agi_bands 29
test_us_medicare_irmaa_holdout 24
test_us_acs_local_sparse_cd 28
test_us_release_gate_preflight 84
test_us_spec_bundle 4
test_us_acs_local_release_tool 215
test_us_fiscal_refresh_builder 346

The only skips are 6 in test_us_fiscal_refresh_builder, which need the policyengine-us extra. ruff check . and ci_test_plan.py verify both pass. No test I ran says it peaks near 15 GB. GitHub CI hasn't run, because this PR's base isn't main.

Judgment calls. The body has the full list:

  1. I regenerated the parity resources, against the brief's "stay as they are". test_us_chronicle_feed.py:120-132 requires them to restate the pin's digest, and the generator halts on an unfenced family. The 11 ed_fsa families are off_by_default exclusions. 32 families stay compiled, and exclusions go from 53 to 64.
  2. The ed_fsa fence says this re-pin did not search us-data for a student loan target.
  3. DEFAULT_FEED_NAME is consumer_facts_us_41b2fb2.jsonl.
  4. I left Re-pin the US Chronicle feed so three dimension values carry one label each #1345's compare_feeds.py unchanged and added list_added_rows.py to record the added rows' sources.
  5. The pin's "bare feed: build-consumer-artifact refuses it" wording is kept but wasn't rerun at 41b2fb2b. The same 994 rows without authority are present.
  6. The doc credits the notes for the acbedb5 export and 0ec21e5 findings.
  7. The branch is us-feed-ed-fsa-1103. Neither name the brief mentions existed.

Generated by Claude Code

…space

The listing labels the row with the declared source, ed_fsa, but its
record set is irs_soi.ty2023.table_1_4. _follow_later_repin_report takes
the source from the listing alone, so it accepts the row; the case fails
with "DID NOT RAISE" until the declaration also names a namespace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6FbAZXafYbq34jLAg3
Each entry of _LATER_ROW_KEEPING_REPIN_REPORTS now names the record-set
namespace its added rows must sit in ("ed_fsa." for the FSA re-pin), and
_follow_later_repin_report refuses a listed row whose record_set_id does
not start with it, after the anchor check. The test declares the namespace
rather than reading it from source_name, because a source's record sets
need not sit in a namespace of its name.

The outside-namespace case now passes; a no-namespace case covers a
declaration that leaves the namespace out. The listing-extra case's rows
now sit in ed_fsa. record sets, so it refuses only because the keys differ.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6FbAZXafYbq34jLAg3
_follow_later_repin_report already refuses a listing made from another
feed and a listing that repeats a key, but no case reached either check.
Two cases now do; each refuses at its own assert, so they need no
failing commit first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6FbAZXafYbq34jLAg3
…ports

- The ed_fsa parity test's docstring said the families bind in a build
  with the balance columns. Only four of the 11 do (#1357's
  FSA_BOUND_TABLES); the portfolio summary and the six repayment-plan and
  IDR tables stay diagnostic in every build, and so do the loan-type
  table's recipient counts. The single namespace fence stays; the seven
  diagnostic families' own exclusions wait for #1357.
- The re-pin doc said #1357's opt-in binds the ed_fsa facts; it binds 136
  of the 461. Its paragraph on the anchor test now names the declared
  record-set namespace.
- compare_feeds.py's docstring said both feeds must hold the same keys; the
  script reports keys_only_in_old and keys_only_in_new instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6FbAZXafYbq34jLAg3
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Ready for hub review

Head: 8982b8fcc438687aa9eb3d8ded5f638e6e11f725. It adds four commits to c624e6f as a fast-forward. The branch head was still c624e6f before I pushed.

Setup. I fetched the feed from Chronicle bf3456f9 (notes/ed-fsa-feed-cut-1103/consumer_facts.jsonl.gz) and gunzipped it to ~/PolicyEngine/_buildh-runtime/inputs/consumer_facts_us_41b2fb2.jsonl. It is sha256 1d45a89c…f9ea07, with 40,642 lines. MICROCOSM_US_CHRONICLE_FACTS pointed at it.

1. The listing's record sets (finding 1): done.

  • 395d1cc adds the outside-namespace case to test_a_later_report_refuses_rows_added_outside_its_declaration. In it, the listing labels a row ed_fsa but its record set is irs_soi.ty2023.table_1_4. The case failed with DID NOT RAISE (1 failed, 6 passed).

  • af45400 gives each declaration a third element, the namespace ("ed_fsa." for the FSA re-pin; the type is at test_us_esi_premiums.py:1114 and the entry at :1123). The chain test unpacks it (:1255) and passes it through (:1264). _follow_later_repin_report takes declared_namespace and asserts that every listed record_set_id starts with it (:1178-1183). That assert comes after the anchor check (:1175-1177).

  • 8353e34 adds listing-other-feed and listing-repeats-key (test_a_later_report_refuses_a_listing_it_cannot_follow). Both refused before this change, so they have no failing commit.

  • Where each case refuses at the head. I ran a scratch harness that calls the helper directly:

    Case Assert
    undeclared-additions :1165
    no-source :1167
    no-namespace :1168
    listing-other-feed :1169
    listing-repeats-key :1171
    listing-short, listing-extra :1172
    other-source :1174
    anchor-record-set :1175
    outside-namespace :1183
    wrong-base :1157
    dropped-row :1159
    value-change :1161
    row-count :1184
  • The review's own script on the committed files. I reran the review's scripts/chain_mutations.py against copies of the committed files in scratch:

    • Its case add-non-ed_fsa-row (listing claims ed_fsa, pin row count +1) passed at c624e6f. It now refuses at :1183.
    • The unmutated chain passes.
    • Every other mutation still refuses at the same check as before.
    • Its old two-element declarations now fail the three-element unpack with a ValueError (:1255), because the declaration's shape changed.

2. The parity test's docstring (finding 2): done (8982b8f). test_release_target_parity.py:346-357 (the docstring of the test at :345) now says that only four of the 11 families bind under #1357's opt-in: location, age, debt size and loan type (FSA_BOUND_TABLES, fsa_student_loan_targets.py:153 at 0ff293b). The portfolio summary and the six plan and IDR tables stay diagnostic in every build (FSA_DIAGNOSTIC_TABLES, :168-199), and so do the loan-type recipient counts (:29-31). I checked those three citations at 0ff293b. I also checked that the manifest's 11 ed_fsa families are exactly those 4 + 7 tables. The one namespace fence stays, and the test's asserts are unchanged.

3. The doc (finding 3): done (8982b8f). docs/us-chronicle-feed-repin.md:60-62 now says #1357's opt-in "binds any of them, 136 of the 461". The 136 comes from the review's step 7 log (logs/step7_opt_in_compile.log:10). I didn't rerun #1357's compile.

4. compare_feeds.py's docstring (finding 4): done (8982b8f). :7-9 now says that the script reports the row counts, keys_only_in_old and keys_only_in_new. That matches compare_rows (:145-146). docs/us-chronicle-feed-repin.md:440 is untouched.

5. The runs. Each file ran in its own process, with -n 4 -p no:cacheprovider and the feed in place:

File (packages/microcosm-build/tests/engine_free/…) c624e6f 8982b8f
us/test_us_esi_premiums.py 159 passed 163 passed
shared/test_release_target_parity.py 39 passed 39 passed
us/test_us_chronicle_feed.py 28 passed 28 passed

The four new tests are outside-namespace, no-namespace, listing-other-feed and listing-repeats-key. Nothing skipped or failed. At 8982b8f:

  • ruff check .: all checks passed;
  • ruff format --check on the three changed .py files: already formatted;
  • python tools/ci_test_plan.py verify: verification=ok.

6. The PR body and the changelog. I added a "Review follow-ups" section at the end of the body and changed nothing else in it. The changelog fragment says nothing these fixes change, so it is unchanged.

Judgment calls

  1. A namespace-declared assert and a no-namespace case. I added an assert that the namespace is declared (:1168) and a case for it. Without the assert, a declaration that leaves the namespace out would fail with a TypeError from startswith(None) rather than an assert. The case can't fail before the fix, because the parameter didn't exist yet, so it comes in the fix commit.
  2. listing-extra now uses ed_fsa.a and ed_fsa.b. Its record sets were a and b. With the new assert, those would also sit outside the namespace. They now sit inside it, so the case refuses only because the keys differ. It refuses at :1172 either way.
  3. The anchor-test paragraph in the doc. I also updated docs/us-chronicle-feed-repin.md:89-96, which describes what the chain test checks, so that it names the namespace. The brief didn't list it, but the fix changes what that paragraph states.
  4. Where the two coverage cases live. They are in a new parametrized test that mutates the listing, because their mutations don't fit the existing test's row-tuple parameters.
  5. The pin, the scope, the feed, the parity resources and the changelog fragment are unchanged. So are the manifest's ed_fsa classifications; splitting the seven diagnostic families waits for Calibrate federal student loan balances to Federal Student Aid's portfolio (#1074) #1357.

Generated by Claude Code

Base automatically changed from us-feed-agi-age-labels to main November 4, 2026 07:57
@MaxGhenis MaxGhenis closed this Nov 4, 2026
@MaxGhenis MaxGhenis reopened this Nov 4, 2026
MaxGhenis and others added 2 commits November 4, 2026 10:13
An independent review of 8982b8f found two code changes that left every
test in TestAnchorFeedCheck passing: matching the namespace anywhere in a
record set id instead of at its start, and moving the namespace check
ahead of the anchor check. A mutation run over every check in
_follow_later_repin_report found six more that left every later-report
test passing: moving the namespace check ahead of the source check, and
deleting the old-row-count check, the changed-field anchor check, the
declared-source check, the source check or the added-row anchor check.
In each, another check refused the case first, or no case reached the
check.

Every check in the helper now names itself in its assertion message, and
each refusing case names the check that must refuse it, through
pytest.raises(..., match=...). Three cases join them: a record set that
holds ed_fsa. after its first component, an old feed of another size,
and a label change in a B4923C record set. The helper's logic stays the
same, so the new cases pass at once. Against the eight code changes
above, each change now fails at least one case, both under pytest's
assertion rewriting and with --assert=plain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of 8982b8f found the sentence awkward: microcosm#1357's
student_loan_targets=True binds 136 of the 461 ed_fsa facts, and nothing
else binds any.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review November 4, 2026 17:59
@MaxGhenis
MaxGhenis merged commit ad67d0b into main Nov 4, 2026
11 checks passed
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

I merged 212dd72 into main as merge commit ad67d0b. All 11 CI jobs passed at that head (run 41855432535), and GitHub read the branch as mergeable and clean. Three independent reviews approved it: one of c624e6f, and two GPT-6.1 Sol reviews, of 8982b8f and 212dd72. Each of 18 changes to _follow_later_repin_report fails at least one later-report case at 212dd72, under pytest's assertion rewriting and with --assert=plain. The third review found six more that pass, and #1513 adds cases for them.

MaxGhenis added a commit that referenced this pull request Nov 4, 2026
Three PRs reached main after this PR's last CI run, which tested 17e2643 on main 0f0e777: #1506, #1485 (H2) and #1501. None of them changes a file this PR changes, and git merge-tree writes tree 79ee630 with no conflict. H2 adds an NZ coresident binding and teaches test_support's reference concepts the coresident_adult transform, which the concept-mapping differential beside this PR's encode change reads, so this merge has CI test the two together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Nov 4, 2026
Since the merge base 875506e, main gained #1473, #1506, H2 (#1485),
#1501, #1480 and #1488. The merge is clean. Two files changed on both
sides, in separate hunks: compose.py (#1488 on main's side) and
test_transport_evidence_kernels.py (H2 on main's side).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Nov 5, 2026
…losure (S2b PR 1) (#1482)

* Add binding-local views to the Axiom adapter

AxiomEngine takes an optional views sequence of AxiomView declarations:
a logical engine entity no frame entity maps to (rulespec-be's Child), one
bool person selector column, and an outside value per output. The adapter
gathers the selected person rows in table order, runs the module's program
on the logical entity, checks the engine's array against the selected count,
and scatters it back through the same mask; every other row holds the
outside value, cast through the output's graph dtype. An empty selection
makes no engine call, and a null or non-bool selector refuses.

View outputs resolve to the person entity, so callers that key on
(binding entity, variable) read them unedited. Each view's program compiles
under its own cache key, re-checks the pinned RuleSpec tree first, refuses
dense relations, and contributes its derived metadata, so a Child-only
module has metadata. The selector joins the person entity's
relation_dependencies; view_root_inputs() reports each view's roots.
axiom_engine_ref writes views only when present, so every existing
reference is unchanged. The view code stays in axiom.py, because
source_hash hashes defining files and does not follow imports.

* Partition binding-local view roots in the input closure

closure_partition no longer refuses a binding that declares views, and
BINDING_LOCAL_VIEW_MESSAGE is gone. Per binding, each view's logical entity
(Child) joins the live entities and maps to the person table, so a view's
roots partition against the closure's Child entries and the existing frame
check reads them on the person slice. Roots on that entity stay unmapped on
every other binding, and unreached_roots still reads only the binding's
engine entity, so it cannot excuse a view root.

engine_root_inputs adds each view's roots under its logical entity through
AxiomEngine.view_root_inputs(), and refuses a view binding on an injected
adapter. binding_view_entities reads a binding's logical entities for the
closure and compose.

* Parse binding-local views in the registry and check them in compose

The registry parses a binding's views block into AxiomView declarations
(logical_entity, selector {column}, outside {name: value}) and builds the
adapter with them. It refuses an empty list, views beside relations, a
binding off the person entity, a logical entity that names a mapped engine
entity, a repeated logical entity, a selector with any field but column, a
structural selector, a selector that names a binding output, and an outside
key that names no binding variable. Once the adapter is pinned, it checks
each view's program for relations in place of the binding entity's program,
requires each view's outside keys to equal the binding's variables on its
logical entity, and refuses a view input named like a root input of the
binding entity's program. _check_engine compares an injected adapter's views
with the binding's and refuses views on an injected Python adapter.

binding_view_dependencies reports each selector as a whole-table person
slice, the same columns AxiomEngine.relation_dependencies reports to the
rules kernels. Compose unions them with the relation dependencies for every
rules node, refuses a view node whose person slice omits an input the
closure encodes (other than through a graph node) or defaults on the view's
logical entity, and requires each selector on the closure producer's person
slice.

* Pin NZ-shaped and relation engine references to goldens from the base

No NZ CI test builds an axiom_engine_ref for NZ's rows: NZ's engine-free
graph injects Python toy engines. The new golden holds references generated
at main 4a36edc for NZ's six binding rows (unreached_roots included) over a
synthetic tree, and for the registry and adapter relation fixtures, all
built engine-free through build_transport_registry and axiom_engine_ref.
The test compares them byte for byte, so a reference that gains
"views": [] or "views": null without a declared view fails.

A second test, gated on POPULACE_RULESPEC_NZ, builds NZ's six real
references from the tree alone and compares them with a frozen copy the NZ
hub writes at the base.

* Add the changelog fragment for binding-local views

* Test that every rules kernel requires a view's whole selector slice

SimulateRulesKernel._require_relation_inputs reads the adapter's
relation_dependencies, which now carry each view's selector, so a node
whose person slice omits or masks the selector refuses before the engine
runs. The test pins that path for a view adapter.

* Pin view code to axiom.py and the no-view outputs to the base path

The placement lint now walks every import node in axiom.py, relative and
function-body imports included, and a second test requires AxiomView and
every view helper to carry axiom.py's module name. A helper moved into a
module axiom.py already imports (materialize, concept_mapping) would leave
the simulate.* hashes unmoved by an edit to it; the module-name test
refuses that move, which the import pin alone passes.

P4 gains an output clause: with views=None, materialize returns the same
names, order, dtypes and values, through the same engine calls, as a
reference copy of main 4a36edc's no-view path kept in the test. NZ's CI
graph injects toy engines and never runs AxiomEngine.materialize, so this
is the executable guard on view-free outputs.

* Class a view's defaulted roots as person columns in the support roster

_support_roster mapped each defaulted closure entry to a frame entity
through entity_names alone, so a defaulted Child root, which PR 2 writes on
the person table, mapped to no entity and missed the closure_default class.
_closure_defaults now maps a binding-local view's logical entity to the
person entity for entries of that binding's module. Without views the
mapping is unchanged.

* Freeze NZ's real-tree engine references at the base

test_nz_real_refs_match_their_frozen_copy compares NZ's six real
references with a frozen copy, and wrote none without a rulespec-nz
tree. This adds the copy, generated at PR 1's base (main 4a36edc) from
a git archive of rulespec-nz 8dc2507d, the commit NZ's bindings pin.

The base run froze the file, and a second base run passed against it.
At this branch's head the test passes too, so the six references keep
their bytes with views in place. The CI golden test passed at the base
as well, which checks that axiom_engine_refs.json came from the base.

* Document binding-local views in the declared-units guide

The guide said binding-local views remained separate work. It now
describes the declaration, the gather and scatter, the outside values,
the registry's refusals, the selector slice, the closure's treatment of
view roots, and the row-locality assumption that the skip-gated
real-engine differential tests.

* Union shared-path closure roots to remove binding-order dependence

* Cover both logical views to catch dropped input roots

* Validate complete outside declarations before pinning bindings

* Preserve outside value types in view equality and hashing

* Exercise view property edges across every graph dtype

* Type and stabilize real Child differential inputs

* Freeze CI reference bindings to isolate NZ edits

* Clarify view evidence, serialization and selector refusals

* Accept overrides of a view selector that a program reads

The first review of the fix round (P2) found that 0d6201a's selector
refusal also refused an override of a selector that a Person program
reads as a root input. The registry accepts that shape, variables()
lists the selector, and before 0d6201a the counterfactual bridge
applied such an override. validate_input_override now names a selector
only when no program reads it, which is exactly when the bridge refuses
the override as outside the engine's inputs, so the check changes no
override the bridge applies.

test_a_selector_that_a_program_reads_stays_overridable drives the
counterfactual bridge with a Person program that reads is_child: the
override reaches that program's input and the rows the view selects. It
fails on 0d6201a's check. The refusal test now also runs through the
bridge, which calls no program. The guide states the rule.

* Correct the two docstrings that review B flagged

`_real_inputs` said the pinned engine refuses a wrong-typed input by name.
At axiom-rules-engine 753ce7d, `src/dense.rs` reports a bool, text or date
column as a type mismatch when a program needs a number from it ("expected
decimal-compatible dense column" for an arithmetic operand, "{side} side of
comparison is not numeric" for a comparison), and neither message names the
input. The docstring now says that.

`axiom_ref_goldens.py`'s module docstring wrote `"\n"` in a non-raw string,
so it rendered a line break where the regeneration recipe needs the two
characters. It now escapes the backslash.

Two merges of main bring this branch up to date: `02265bb5` merges main `875506e8`, and `36800b14` merges main `3e7a7045` (#1473, #1506, H2 #1485, #1501, #1480 and #1488). git merged both without conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Nov 5, 2026
main re-pinned the US feed to Chronicle 41b2fb2b (#1506) and the NZ feed
onto chronicle.consumer_fact.v5 (#1507). The one conflict is in
docs/us-chronicle-feed-repin.md, where #1506's new re-pin section and
this branch's section on re-pinning onto a newer row contract sit in the
same place: both are kept, the general section first. #1506's sentence
saying the US loader carries a row's stamp unchecked now adds that the
release and target-parity tools check the stamps against the pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Nov 7, 2026
…folio (#1074) (#1357)

* Impute federal student loan balances from the 2025 SCF (#1073)

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>

* Add SCF 2025 education-debt fit receipts (#1073)

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>

* WIP: SCF-matching recipient definitions and #1073 host-fit diagnostics

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>

* Condition the education-debt QRFs on SCF-comparable predictors (#1073)

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>

* Regenerate the US stage spec for the SCF-comparable education-debt predictors (#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>

* Make the spouse-routing test independent of draw chance (#1073)

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>

* Collect the e35c4b0 receipts, add a Modal launch entrypoint, bind the 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>

* Re-derive the spec-engine identity values the education-debt stage moves (#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>

* Pin the coverage pointer inventory and regenerate the F0 attestation (#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>

* Re-pin the inventory test's resolved-reference and tail-control counts (#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>

* Re-derive the merged tree's bundle digest and F0 attestation (#1073)

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>

* Pin the #1073 fit receipt to the merged commit

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>

* Describe #1073 as the student debt pilot in the changelog and module docstring

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Route loans for a child's education to co-resident adult children and 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>

* Keep the education-debt stage operations within the sources schema (#1073)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Re-derive the spec identity values for the child-loan routing (#1073)

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>

* Rerun the #1073 receipts at the merged head and benchmark Parent PLUS 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>

* Calibrate the SCF-imputed student loan balances to Federal Student Aid 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>

* Refit the #1074 receipt on #1246's child-loan routing and the merged 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>

* Re-derive the spec identity values on the merge with main (#1073)

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>

* Rerun the #1073 receipts on the merge with main

Modal run 20261102T172355Z-3297aefd8 at 3297aef. No reported number moved:
the fit JSON differs from the 60255dd receipt only in its provenance and in
the last digit or two of 14 floats (floating-point summation order).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Add down-weighted borrower-count arms to the #1074 fit (#1074)

Six arms bind the 67 FSA balance targets at full loss weight and the 65
borrower-count targets (state, age band, debt-size band) at 0, 0.1 and 0.25
of their weight, renormalized to mean 1, with and without the L2 anchor. At 0
the counts only validate. The existing arms are unchanged and the receipt
keeps their tables; a new section compares the arms on the FSA balance and
count fit, the non-FSA surface, the share of households with debt, total
borrowers, the Parent PLUS share and the borrower age profile against FSA.

The receipt's reference-date sentence now says what the stored balances are:
the March 2026 stock written at the 2024 build period, which
policyengine-us#10415 (merged 2026-10-30) holds at its stored value in every
year. The runner and fit docstrings say the candidate keeps the 51aa40f feed,
the pin before main's cf9ece2 re-pin, which adds only the B4923C facts that
bind no target.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Refit the #1074 receipt with the down-weighted borrower-count arms (#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>

* Restamp the FSA fixture v3 and refuse a debt build with no FSA facts (#1074)

The fixture's 244 rows now carry chronicle.consumer_fact.v3, as every row
of the feed microcosm#1506 pins (Chronicle 41b2fb2b) does, so each matches
one of that feed's FSA rows byte for byte; the stamp is the only change.

The release tool refuses a --scf-education-debt build on a feed with no
ed_fsa facts before the compile (_check_us_feed_carries_fsa_facts): the
plan was empty there, so the build compiled no FSA target and raised
nothing. The us_source_coverage.json receipt reuses the check's plan.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Keep FSA loan-type rows diagnostic and refuse changed FSA tables

Both loan-type dollar rows leave the loss, so the family binds 130 targets.
Originating a Direct Consolidation Loan discharges the loans it consolidates
(34 CFR 685.220(a)), so FSA's Parent PLUS row covers only unconsolidated loans,
while the stage's column holds every loan for a child's education. Every other
family pins the total, so a target on either side of FSA's split would set the
same split.

The plan refuses a location, age or debt-size table whose rows differ from the
set FSA publishes at the reference quarter. The feed-refusal test reads _main's
syntax tree, and two Hypothesis properties pin the family totals and the band
partition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse repeated FSA location rows and restate the Parent PLUS reason

_require_reviewed_rows compared sets of row IDs, so a repeated Puerto Rico
or Other row passed and changed the location control. It now compares
sorted lists and names any repeat, and a new test repeats each of those
two rows.

FSA's notes don't say whether its Parent PLUS row leaves out loans the
Secretary paid off through consolidation (34 CFR 685.220(f)), so the
docstring and the loan-type reason no longer say it does. Every other
family pins the total, so either loan-type row would still constrain the
split.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Count the FSA targets module in the transport bindings' identity

us_runtime.fiscal_targets imports the new fsa_student_loan_targets module, so the
transport bindings' identity reaches one more module. test_the_batterys_binding_identity_adds_only_the_leaf_and_its_imports
names it in BINDING_MODULES_ADDED_AFTER_BASE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Ready the FSA fit for the 130-target rerun: L2 grid, income tables, release fit table (#1074)

Max's review of 2026-11-05, on top of #1525's diagnostic loan-type rows:

- The fit runs the review's grid: A and B at lambda 0, record L2 0.03 and
  chi-square L2 0.03 (calibrate's l2_basis), and B with the borrower counts
  at x0, at lambda 0 and record 0.03. --extra-chi-square-lambda adds A and B
  at another lambda. Every receipt table covers every arm.
- It reads FSA's Parent PLUS figure from the diagnostic loan-type facts,
  writes strict JSON (null, allow_nan=False) and refuses a feed that binds no
  FSA target.
- The receipt adds every household-income quintile for every arm, at each
  arm's own cut points and at the release arm's applied to every arm.
- A --scf-education-debt release writes us_scf_education_debt_calibrated_fit.json,
  the stage's fit table on the exported frame at its shipped weights, before
  post-export scoring; a test pins the write and its place in _main.
- A missing age counts in the youngest band (FSA_MISSING_AGE), now named,
  documented and tested.
- The doc and changelog cite 34 CFR 685.220(f) and give FSA's filing of a
  consolidated Parent PLUS loan as an inference, say the materializer groups
  the stored, unaged age, and drop the re-pin as a future step (#1506).
- The Modal runner builds the feed the commit pins by default and gains
  fit_smoke and lint steps.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Tidy the FSA receipt's signs and the missing-age note (#1074)

The receipt writes a flat Q5 - Q1 as +0.0, not -0.0, and a negative income
as -$945,807. The FSA_MISSING_AGE comment says the plan, not FSA, spreads the
"not reported" ages, and the doc's Parent PLUS share reads as a share of the
loan-type dollar rows. The fit_smoke run 20261105T214517Z-66b37d37c showed both
signs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Limit the 1074 runner's format check to this PR's files (#1074)

Run 20261105T215640Z-f83241311 passed ruff check and every test, and its
format check flagged only eleven us_runtime modules this PR leaves untouched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Say the FSA loan-type table is diagnostic in the parity manifest (#1074)

families' reviewed-exclusion reason no longer lists the loan-type table among
the tables a --scf-education-debt build binds. The reason changes in the
builder and in the eleven manifest entries alike.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Refit the #1074 receipt on main: 130 targets and the L2 grid (#1074)

Modal run 20261106T142002Z-8a9d335a7 at 8a9d335, on the feed main pins
(Chronicle 41b2fb2b, sha256 1d45a89c..., 40,642 rows, rebuilt as pinned):
6,102 non-FSA and 130 FSA targets, none dropped, every arm of the review's
grid solved. B binds 95% of the FSA targets within 5% at ESS 2,932 and 33.9%
of households with debt; B + record L2 76% at 4,484 and 32.4%; B +
chi-square L2 59% at 6,222 and 28.0%. Chi-square 0.03 moves the fit, so no
second lambda. The doc's measured limits take the new levels.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Write the calibrated SCF fit after the export cleanup (#1074)

test_us_post_export_scoring executes _main's export and cleanup statements
on their own, without args, so the calibrated fit-table write inside that
span failed it with a NameError. The write now follows the ladder cleanup,
still before any scoring or terminal gate, and the builder test pins that
order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Describe the Parent PLUS column as the stage's classification and drop the "moved" clause (#1074)

Since #1246 the stage gives a loan for a child's education to a fitting
co-resident adult child, keeps it as the head's Parent PLUS only past the
dependent limit or when no child fits an undergraduate loan, and makes a
graduate loan with no fitting child the head's own. The module docstring,
the loan-type reason and the doc now say the column holds the balances the
stage classifies as Parent PLUS. The calibrated fit table reports shares at
the shipped weights, not the FSA family's effect, so the doc and the release
tool's comment no longer say it reports what the family moved.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Max Ghenis <mghenis@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants