Skip to content

Liquid assets sit on the lowest-line person, against the stage's declared is_household_head carry; 13.1M SSI-relevant adults test at $0 resources by construction #1215

Open

Description

@MaxGhenis

What is wrong

In the certified US frame (populace-us-2024-spm-20260909, H5 SHA-256 6496cc43…; Build P cae8640), each household's liquid assets (bank_account_assets, stock_assets, bond_assets) sit entirely on one person, the household's lowest-A_LINENO member. All 52,637 rows with a nonzero leaf are that person; every other member holds $0.

Two problems follow.

1. The selector contradicts the stage's own declaration. us/source_stages.json (:1135–1145) declares the carry as head_carry with condition: is_household_head. The runtime (scf_wealth.py _household_head_mask, :670–691 at cae8640, :673–694 on main; SIPP's equivalent in sipp_financial_assets.py:561–577) sorts by household, A_LINENO (missing → 9999) and row order and takes the first. is_household_head is P_SEQ == 1 (relationship_inputs.py). They disagree in 243 households. The docstring's "carried by the CPS reference person" holds only where line 1 is the reference person: 370 carriers have A_EXPRRP outside {1, 2} (for the 2022 and 2023 income years the loader synthesizes A_EXPRRP, assigning 1/2 only to line 1), and 123 carriers are under 18. In household 884, a 71-year-old wife on line 1 carries $17,500 while the line-2 reference person carries nothing.

2. One vector per household, on one person, for an engine that reads person ownership. The SCF draw describes a primary economic unit (the dominant person or couple and their financially dependent members). The SIPP leaves read TVAL_BANK, TVAL_STMF and TVAL_BOND, which are person-level values (THVAL_* are the household totals). Both are drawn once per household, conditioned on the carrier's characteristics, and placed on the carrier. PolicyEngine-US's ssi_countable_resources adds exactly these three leaves, and meets_ssi_resource_test uses the marital unit's sum for a joint claim and the person's own value otherwise (ssi_claim_is_joint requires two ABD heads or spouses in the marital unit). So everyone outside the carrier's marital unit has $0 countable resources by construction:

Outside the carrier's marital unit Weighted people Share of group With a positive vector elsewhere in the household
Adults 18+ 63.66M 24.2% 56.03M
Aged 65+ 5.70M 9.3% 4.92M
SSI-relevant adults (65+, meets_ssi_disability_criteria or is_blind) 13.06M 17.3% 11.45M

They include 1.15M people 65+ coded as the reference person's parent, all living with an adult-child carrier, and 5.64M SSI-relevant adult children of the reference person. A rough income screen (annual Social Security plus earnings under $12,336 = 12 × ($943 + $20 + $65)) leaves 6.41M SSI-relevant adults outside the carrier's unit, 3.41M of them in households whose vector exceeds $2,000. These are exposure counts. Whether a given person's SSI eligibility changes depends on rules this screen omits, and on who in the household owns what.

Within a marital unit, a claimant whose spouse is not aged, blind or disabled files an individual claim. The PolicyEngine-US releases pinned with this frame (1.764.6 at build, 2.2.1 in policyengine.py's bundle) count only the claimant's own resources then, so a carrier spouse leaves the claimant at $0. PolicyEngine-US main now deems an ineligible spouse's resources, and parents' resources to children under 18 (policyengine-us#10040, merged 2026-10-11; 20 CFR 416.1202). No rule deems resources between adults outside a marital unit, so the adult children, parents and other relatives counted above test at $0 under every engine version.

Proposed fix

  • Now, small: make the runtime honor its declaration. Select the carrier with is_household_head, or change the declaration to the rule actually intended, and add a test that the declared condition and the runtime selector agree. This moves 243 households' vectors. It does not reach the ownership problem, and 104 is_household_head persons are still under 18.
  • The real fix needs consistent donor and recipient units: draw SIPP person-level leaves per adult (with spouse dependence), or draw per marital unit and allocate within it; map SCF's economic unit to CPS economic units and give independent adults their own draw. An SPM-unit draw alone would leave 12.15M of the 13.06M exposed SSI-relevant adults sharing the carrier's unit, so allocation within the unit is still needed. INT_VAL and DIV_VAL can guide ownership (4.22M exposed SSI-relevant adults report interest or dividends while holding $0 in the frame) but do not determine balances. Changing the draw unit changes aggregate assets, so it needs validation against SCF and SIPP totals, and a refit.

#1184 (imputed savings put SSI reporters over the limit) is a related symptom of the same stage; #1216 covers the stage's missing provenance record.

Downstream

Every SSI resource-test result on this frame: microcosm-dynamics Registration 19 and 20, the SSI asset-limit analyses (PolicyEngine/ssi-asset-limits), and policyengine.org SSI results through PolicyEngine-US's default dataset. The bias runs toward people passing the resource test, so asset-limit reforms add fewer new eligibles among adults living with relatives than they would with assets placed on owners.

Reproduce

The carrier fact: on the person table, compare A_LINENO == min(A_LINENO) per household with the nonzero-leaf mask and with is_household_head. Exposure: people with age >= 18 whose person_marital_unit_id differs from the carrier's, weighted by household_weight.

Found by read-only structural reads of the frame while registering microcosm-dynamics Registration 19.

Activity

  1. MaxGhenis commented on Oct 12, 2026

    @MaxGhenis
    ContributorAuthor

    #1221 makes the SIPP/SCF asset stage carry on the declared is_household_head (both blend halves, and the net-worth conditioning that reads the same mask). Review of it found one more stage with its own lowest-A_LINENO selector: sipp_vehicles.py (~line 728) conditions vehicle predictors on the lowest-line person, while assets and housing now use the declared head. The archived vehicle allocator (policyengine-us-data 42ed5d45, utils/asset_imputation.py ~548) preferred is_household_head. Vehicle outputs are household-grain, so this changes the donor profile only; worth aligning in a follow-up. The ownership problem in this issue (one household vector on one person) is unchanged by #1221.

  2. MaxGhenis commented on Oct 23, 2026

    @MaxGhenis
    ContributorAuthor

    The auto-loan leaves of this stage have the same unit question. PolicyEngine/policyengine-us#10406 (draft) changes how policyengine-us reads qualified_passenger_vehicle_loan_interest. It used to be split across a household's tax units by member count: core's group-to-group add() divides by household size. Now the whole amount goes to the tax unit holding is_household_head, which matches this stage's declared head_carry for the liquid-asset leaves. No entity change is needed here, because the household column keeps working.

    Two facts for the unit fix, measured on the certified frame (6496cc43…):

    1. The predictor person. The auto-loan draw conditions on its own reference person, not on is_household_head. scf_auto_loans._scf_reference_person_mask picks the one adult, or the man of a married pair, or otherwise the oldest adult. There are 5,930 multi-tax-unit households with positive QPVLI (10.8M weighted, holding 31.3% of the $12.16bn weighted 2024 total). In those, that person is in the head's tax unit in 70.5% of records (72.3% weighted by QPVLI).
    2. The income unit. _recipient_household_predictor_table sums recipient income over the whole CPS household. The SCF donor's wageinc, intdivinc and ssretinc describe the primary economic unit, which leaves out financially independent household members (2022 codebook, "Unit of analysis").

    If the stage later emits per-person loans, policyengine-us now has a person input, qualified_passenger_vehicle_loan_interest_person. It counts for the tax unit head and spouse, following 26 U.S.C. 163(h)(4)(B)(i): "indebtedness incurred by the taxpayer".

    Scripts: reference_vs_head.py and data_stats.py (read-only, pandas on the H5), kept with the policyengine-us PR's evidence.

  3. added a commit that references this issue on Nov 11, 2026
  4. MaxGhenis commented on Nov 11, 2026

    @MaxGhenis
    ContributorAuthor

    The education-debt draw on main (#1073) starts from the same lowest-line person. Under the stage's default recipient definition, scf_education_debt._householder_couple_positions (:1390–1421 at 3f46d5958) takes each household's lowest A_LINENO as the householder, then picks the man of a mixed-sex couple or the older partner of a same-sex couple. The draw conditions its predictors on that person and sums income over that person's couple and children. When the line-1 person falls outside the is_household_head person's couple, those predictors describe a different couple from the one that carries #1221's assets. I haven't counted those households. Align it in the same follow-up as sipp_vehicles.py above, starting from is_household_head the way #1221's household_head_carrier_mask does.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions