Skip to content

Count U.S. savings bonds in the SCF half of bond_assets - #1136

Draft
MaxGhenis wants to merge 2 commits into
mainfrom
scf-savings-bonds
Draft

MaxGhenis wants to merge 2 commits into
mainfrom
scf-savings-bonds

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

What changes

The scf_wealth stage mapped the SSI countable-resource leaf bond_assets to SCF summary-extract bond alone. It now reads bond + savbnd.

  • The Fed's extract macro defines BOND as "total bonds, not including bond funds or savings bonds" (bulletin.macro.txt line 1497). U.S. savings bonds are the separate SAVBND = X3902 (line 1825).
  • Archived us-data commit 42ed5d45 mapped bond_assets to bond and kept savbnd as scf_savings_bonds, a construction-only component of its net-worth reconciliation. Microcosm builds none of those components (net_worth is the direct networth anchor). So on main an SCF-drawn household's savings bonds reach no SSI leaf.

Why this is a fix toward the cited source, not a methodology choice

  • PolicyEngine-US 2.2.1 (the version uv.lock pins; read from the PyPI wheel):
    • bond_assets has documentation "Value of bonds and government securities. Imputed from SIPP TVAL_BOND." Its reference is POMS SI 01140.240 and SI 01140.250.
    • gov.ssa.ssi.eligibility.resources.countable lists bond_assets, and its reference list includes "SSA POMS SI 01140.240 - U.S. Savings Bonds".
  • POMS SI 01140.240 (TN 90, 09-26) treats savings bonds as resources. Series EE and I bonds issued on or after 2/1/2003 are not resources during their 12-month mandatory retention period, and "are resources (not income) as of the first moment of the thirteenth month".
  • SIPP 2023, the other half of the 50/50 blend, already includes savings bonds.
    • pu2023_schema.json varnum 4196 defines TVAL_BOND as "Person-level sum of value of other interest-earning assets (TJSGOVSVAL TJOGOVSVAL TOGOVSVAL TJSMCBDVAL TJOMCBDVAL TOMCBDVAL)".
    • Varnum 369 defines EOWN_GOVS as "Owned any government securities during the reference period [such as savings bonds, T-Bills, T-Bonds, T-Notes, and government sponsored enterprise (GSE) credit instruments such as Fannie Mae]".
    • The 2023 instrument asks AST1A, "Did ... own any U.S. Government savings bonds or U.S. Government securities?", and records one combined balance or market value.
    • The instrument's only savings-bond wording is in this AST1A family.

No double counting. In PE-US 2.2.1, no formula adds bond_assets to another input that holds savings bonds:

  • net_worth, which this stage fills from SCF networth and which includes savbnd, is read by no formula.
  • Microcosm leaves spm_unit_assets unset.
  • The SCF components of the three leaves are distinct; see the invariants below.

Who reads bond_assets. In PE-US 2.2.1 the change reaches more than SSI. bond_assets also feeds:

  • the SNAP asset test (gov.usda.snap.asset_test.sources, 7 CFR 273.8(c)(1));
  • spm_unit_cash_assets, which many state TANF and local general-assistance resource tests read;
  • Missouri TANF countable resources, directly.

Those consumers were not probed separately.

Measurement limits. These affect amounts, not which leaf savings bonds belong to, and they also apply on main:

  • The SCF records face values, both for savings bonds (X3902) and for every component of bond.
  • SIPP asks balance or market value including interest. POMS counts other bonds at current market value (SI 01140.250) and savings bonds at redemption value (SI 01140.240).
  • The SCF 2022 savings-bond face total ($301B) exceeds Treasury's total U.S. Savings Securities outstanding across all holders ($166.3B on 2022-09-30 and $173.5B on 2022-12-31; MSPD table 1). So the SCF half overstates savings-bond amounts relative to redemption value.
  • Neither survey identifies bonds still inside the retention period.

Valuing savbnd at an estimated redemption value would be a separate methodology change. It is tracked as a follow-up and not done here.

Size of the effect

SCF 2022 donor (pinned rscfp2022.dta, survey-weighted, 131.3M households; wgt already sums to all households across the five implicates):

households with a nonzero target weighted total (face value)
bond (before) 1.11% $1,398B
savbnd 6.43% $301B
bond + savbnd (after) 7.36% $1,699B
  • The weighted median of savbnd among holders is $2,000, which is the SSI individual resource limit.
  • Among households with under $10k in financial assets, 0.0% hold bond and 1.08% hold savbnd.
  • Crossing the $2,000 line (other leaves held fixed; probes/ssi_threshold_flips.py):
    • All households: savbnd moves 0.24% from at or below $2,000 to above it.
    • Households with SCF income under $15k: 0.24%.
    • Households with SCF income under $25k: 0.13%.
    • The low-income cells rest on one and two SCF families, so they are indicative only.
    • Crossings are rare because few low-income households hold savings bonds (1.95% under $15k, 1.30% under $25k), and 71–88% of those who do already exceed $2,000 in the other leaves.
  • SIPP comparison. 6.4% of SIPP reference persons have TVAL_BOND > 0, and 8.3% of households have THVAL_BOND > 0. Among the rows the stage trains on (its allocation-flag mask), the reference-person share is 4.1%.
  • What each half delivers. In the stage probe below, the SCF half delivers 0.18–0.19% of persons with bonds before the fix and 2.1–2.3% after. The SIPP half delivers about 1.4%, by implication from the blend. The two halves are much closer after the fix, but not equal.

Stage probe. This used the origin/main scf_wealth and sipp_financial_assets code, the real SCF extract, and the real pu2023.csv (streamed from the Census zip and SHA-256 checked against the pin). Recipients were CPS ASEC 2023 persons (146,133), with seeds 0, 1 and 2, comparing main with this PR's first commit:

metric bond only bond + savbnd
bond_assets weighted person nonzero share (the gate's metric; band 0.001–0.12) 0.80–0.82% 1.79–1.86%
bond carrier records (unweighted record share; eCPS parity reference 0.034, also a record share) ~1,110 (0.76%) 2,456–2,583 (1.7–1.8%)
top-100 weighted mass share of bond_assets on this sample 0.85–0.95 0.77–0.82
persons with nonzero SSI countable resources 37.3–37.4% unchanged at 4 decimals
CPS SSI recipients with countable resources > $2,000 16.30–17.32% +0.06 to +0.07 pp
SSI-recipient household heads with countable resources > $2,000 29.6–31.5% +0.11 to +0.13 pp
  • The bank and stock draws are value-identical (np.array_equal) between arms in every seed. Only bond_assets changes.
  • Bond dollar totals on this uncalibrated sample are dominated by a few tail draws ($163–523B for bond-only across seeds), so the probe gives no reliable dollar delta.
  • These are probes, not a release build. Released numbers move only through a rebuild, the release gates and a deliberate publish.
  • Round 2 changed only docstrings, tests and pins. The imputation code is the same as in the probed commit.

Invariants

CI tests in tests/engine_free/us/test_us_scf_wealth.py:

  1. Distinct FIN constituents. No SCF component feeds two SSI leaves, and every component is one of the ten disjoint constituents of the Fed's FIN (FIN=LIQ+CDS+NMMF+STOCKS+BOND+RETQLIQ+SAVBND+CASHLI+OTHMA+OTHFIN, macro line 2177). The leaves use five of the ten. This test is static.
  2. Sum rule (Hypothesis, 200 examples). With components that are sentinels or nonnegative, each leaf equals the sum of its sentinel-cleaned components and is finite and nonnegative.
  3. Zero floor (Hypothesis). With signed non-sentinel components, each leaf equals max(sum, 0) and is nonnegative.
  4. Savings bonds are additive and isolated (Hypothesis, nonnegative or sentinel components). bond_assets equals the bond-only target plus cleaned savbnd exactly, never falls below it, and leaves bank_account_assets and stock_assets unchanged. With a negative non-sentinel bond, the floor could absorb savings bonds; the SCF extract has none.
  5. Sentinels are zero (Hypothesis). Replacing any component value with -1, -7, -8 or -9 gives the same targets as 0.
  6. Required columns follow the map. A missing component raises the documented ValueError for every component, including savbnd.

Checked once on the pinned extract (a probe, not a CI test, because CI has no SCF data):

  • FIN equals the sum of its ten constituents exactly on all 22,975 rows.
  • Every constituent is nonnegative.
  • So the new leaves never exceed fin (0 rows).

Mutation check. Each of four mutants was killed by the targeted tests:

  • Revert to bond-only: 4 failures.
  • Keep savbnd sentinels: 3 failures.
  • Double count nmmf into bond: 3 failures.
  • Remove the zero floor: 1 failure, from the floor test.

Pins

Any byte edit to scf_wealth.py moves the seed-kernel attestation; this is the same cascade as #1129, per docs/us-spm-role-stage.md §5. These values are at fd4381fbf:

pin main this PR
EXPECTED_HASHES["seed_protocol"] b4afa376… 62b2155f…
EXPECTED_HASHES["seed_map"] 0fa116d9… 767ac1c4…
US spec_sha256 (test_us_multispine_pool_tool.py) d1df6b31… d70f33e4…
loader golden (test_spec_engine_loader.py) 6273f82f… 8d578ce2…
docs/evidence/spec-engine/us-f0-coverage.json 8 hash fields regenerated
  • The manifest (source_stages.json, sources.yaml) declares no SCF component map, so it does not move.
  • The stage still lists the archive's construction-only scf_* outputs, including scf_savings_bonds. The module docstring now says that scf_savings_bonds must leave that partition if those components are ever built.
  • main has since gained Export Microcosm schema metadata for Orrery #888 (graph and Orrery only). Recomputed against it with this PR's files overlaid, the four pins are unchanged and spec_engine_coverage.py --check passes.

How this was verified

  • CI on the first commit. All 10 checks were green, including engine-us, which checks the US spec_sha256 pin.
  • Local runs. These used origin/main code served from git objects, with this PR's files overlaid, because free disk was under the 40 GB floor when the work started. The interpreter was another worktree's venv, used read-only; its third-party versions match uv.lock.
    • Unpatched, the method reproduces every committed pin, and spec_engine_coverage.py --check passes against the committed JSON.
    • At fd4381fbf, tests/engine_free/us/test_us_scf_wealth.py and tests/engine_free/shared/test_spec_engine_loader.py give 52 passed.
    • spec_engine_coverage.py --check passes (42184/42184 fields, 41/41 inventory checks).
    • ruff check and ruff format --check are clean.

Review

Round 1 was an in-session multi-lens review (code and tests, source fidelity, sizing), with an adversarial verifier on each finding. It found no code defect. Its prose and test findings are fixed in fd4381fbf:

  • the double-counting claim rescoped;
  • the SSI-threshold sentence given a defined population, a script and the actual reason;
  • the tail-register note corrected;
  • the probe-versus-test labels fixed;
  • the non-SSI consumers named;
  • the face-value scope stated;
  • the zero-floor test and the test rename;
  • the pending Correct the SCF bond description in scf_wealth's docstring #1129 changelog fragment amended;
  • the eCPS record-share label.

Round 2 goes to an independent review.

Release-path notes (no action in this PR)

  • QRF tail register. tools/route_a/route_a.env.example (lines 44-45), which the Route A README tells operators to copy, passes the d490 tail register (SHA fa576099…). That register waives bond_assets (share 0.766 to 0.773, under d450), and the waiver expires at the next certified build. If this change lowers bond_assets' top-100 share to 0.75 or below on the export, as the probe suggests it may, the release gate fails on a stale exclusion (gates.py stale-exclusion branch; build_us_fiscal_refresh_release.py help: "Stale entries fail the gate"). The operator then re-measures and drops the entry, which moves the pinned SHA. The register's own text cites Max's d490 pre-authorization for a re-measured register with "same or fewer columns".
  • SSI take-up prior basis. Route A pins a pre-change SSI take-up prior-basis artifact (route_a.sh SSI_SHA). The shift among SSI recipients is small (+0.06 to +0.07 pp above $2,000 in the probe), but the next release should note it.
  • Benchmark comparability. Issue SIPP+SCF blend + SSI-aware refit: land the asset magnitudes for the Build J re-certification #374's dense-native acceptance benchmark was measured under the bond-only mapping. savbnd moves households across both the $2,000/$3,000 and $10,000/$20,000 limits, and the net effect on the $10k/$20k reform was not measured.

Not in this PR

  • CDs. The CD gap in bank_account_assets is parallel but separate. SIPP TVAL_BANK includes CDs, while SCF liq excludes them (cds, $841B). However, PE-US bank_account_assets documents only checking, savings and money-market accounts and cites only POMS SI 01140.200, so the cited source does not settle it. It is tracked as a follow-up.
  • Savings-bond valuation. Valuing savbnd at redemption value rather than face value is tracked as a follow-up.

axiom: n/a: microsim input imputation (SCF donor mapping); no policy rule changes

Evidence (probe scripts and outputs, sources, mutants, review findings): ~/reviews/microcosm-scf-savings-bonds/

🤖 Generated with Claude Code

MaxGhenis and others added 2 commits October 7, 2026 11:59
The scf_wealth stage mapped the SSI countable-resource leaf bond_assets to
SCF summary-extract `bond` alone. The Fed's extract macro defines BOND as
"total bonds, not including bond funds or savings bonds" (bulletin.macro.txt
line 1497); savings bonds are the separate SAVBND = X3902. The archived
pipeline at 42ed5d45 kept savbnd as scf_savings_bonds, a construction-only
net-worth component that microcosm does not build, so an SCF-drawn
household's savings bonds reached no SSI leaf.

PolicyEngine-US 2.2.1 documents bond_assets as "Value of bonds and
government securities. Imputed from SIPP TVAL_BOND." and cites POMS
SI 01140.240 (U.S. Savings Bonds), as does its SSI countable-resource list.
SIPP 2023 TVAL_BOND, the other half of the 50/50 blend, sums government
securities and municipal/corporate bonds, and the 2023 SIPP data dictionary
defines government securities as "such as savings bonds, T-Bills, T-Bonds,
T-Notes" (EOWN_GOVS). The SCF side now reads bond + savbnd.

- SCF_FINANCIAL_ASSET_TARGET_COMPONENTS["bond_assets"] = ("bond", "savbnd").
- The loader's required columns now derive from the components map, so a
  missing savbnd raises the documented ValueError rather than a KeyError.
- Target construction moves into _scf_financial_asset_targets so its
  invariants can be property-tested in memory.
- Docstrings no longer claim exact parity with 42ed5d45 and say why.
- Tests: the raw fixture gains savbnd (drawn last, so other columns keep
  their values); the loader test checks bond + savbnd row for row; a
  parametrized test requires every component column; a static test checks
  that the leaves partition disjoint FIN constituents; Hypothesis tests
  check the sum rule, that savings bonds add exactly to bond_assets and
  nothing else, and that sentinels count as zero.
- Re-pin the seed-kernel attestation that any byte edit to scf_wealth.py
  moves: EXPECTED_HASHES seed_protocol and seed_map, the loader golden, the
  US spec_sha256, and docs/evidence/spec-engine/us-f0-coverage.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The in-session review of 35a7429 found no code defect but several
overclaims and gaps in prose, plus one untested branch:
- Test the zero floor: a Hypothesis test with signed non-sentinel
  components checks each leaf equals max(sum, 0) and is nonnegative (the
  floor-removal mutant now fails it). The savings-bond additivity test
  says why it draws only nonnegative values.
- Rename the static test to what it checks: the leaves draw on distinct
  FIN constituents (five of the ten). It no longer claims a partition,
  and the "never exceed FIN" note now states its premise.
- Docstring: face value applies to savbnd and to every component of
  bond, while SIPP asks balance or market value and POMS uses market value
  (SI 01140.250) or redemption value (SI 01140.240); liq includes prepaid
  cards and is at least $1 for account holders; the eCPS reference shares
  behind the gate bands are unweighted record shares.
- Changelog: the new fragment names the non-SSI consumers of bond_assets
  in PolicyEngine-US 2.2.1 (SNAP asset test, spm_unit_cash_assets, MO
  TANF). The pending #1129 fragment no longer says this stage does not
  read savbnd.
- Re-pin the seed-kernel attestation for the new scf_wealth.py bytes.

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

This branch has not been deployed

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

1 participant