From b4a01a0415e4f6da01734699909e4b04799b15ef Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 18:37:27 -0400 Subject: [PATCH 1/4] Carry ASEC reported SSI receipt as receives_ssi in the US build PolicyEngine-US rules that key on actual SSI receipt read (ssi > 0) | receives_ssi, but no US release carries receives_ssi, so every such rule depends on modeled take-up and imputed attributes alone. - cps_carried.reported_ssi_receipt: positive SSI_VAL, excluding a respondent whose only RESNSSI reasons are on behalf of a disabled or blind child (the child's payee). Refuses positive amounts without the reason columns. Broadcast to all months like the other receipt inputs. - Release input coverage: receives_ssi is a hard requirement with no reviewed exclusion; the gate also checks every marked person reports a positive SSI_VAL and carries takes_up_ssi_if_eligible. - Stacked ACS chain: the column joins the source_operator_cps_carried transfer family (surface 134 -> 135, early targets 48 -> 49); spec bundle, engine ABI lock and pool contracts regenerated with the repo's tools. - Tests: examples, Hypothesis properties for the producer and the consistency check, and coverage-contract tests. - Evidence: measured effect on the pinned default with PolicyEngine-US main and #10145 (docs/evidence/us-reported-ssi-receipt). Co-Authored-By: Claude Opus 5.5 --- changelog.d/us-reported-ssi-receipt.added.md | 1 + .../us-reported-ssi-receipt/README.md | 98 + .../income_fail_by_channel.py | 69 + .../measure_receives_ssi.py | 409 + .../summary_aggregates.json | 9246 +++++++++++++++++ docs/us-multispine-operator-ordering.md | 8 +- docs/us-reported-ssi-receipt.md | 79 + .../microcosm/build/us/engine_abi.lock.json | 2 +- .../us/release_input_coverage_manifest.json | 10 +- .../src/microcosm/build/us/spec/battery.yaml | 5 + .../src/microcosm/build/us/spec/catalogs.yaml | 10 + .../microcosm/build/us/spec/imputation.yaml | 11 + .../microcosm/build/us_runtime/__init__.py | 12 + .../microcosm/build/us_runtime/cps_carried.py | 85 +- .../build/us_runtime/multispine_pool.py | 6 +- .../us_runtime/release_input_coverage.py | 134 +- .../build/us_runtime/stacked_spine.py | 17 +- .../us/test_release_input_coverage.py | 144 + .../us/test_spec_engine_typed_closure.py | 2 +- .../us/test_us_reported_ssi_receipt.py | 274 + .../engine_free/us/test_us_stacked_spine.py | 39 +- .../microcosm_build/release_input_coverage.py | 35 + ...uild_us_release_input_coverage_manifest.py | 19 +- tools/us_bundle_generation/core.py | 4 +- tools/us_bundle_generation/imputation.py | 4 +- 25 files changed, 10671 insertions(+), 52 deletions(-) create mode 100644 changelog.d/us-reported-ssi-receipt.added.md create mode 100644 docs/evidence/us-reported-ssi-receipt/README.md create mode 100644 docs/evidence/us-reported-ssi-receipt/income_fail_by_channel.py create mode 100644 docs/evidence/us-reported-ssi-receipt/measure_receives_ssi.py create mode 100644 docs/evidence/us-reported-ssi-receipt/summary_aggregates.json create mode 100644 docs/us-reported-ssi-receipt.md create mode 100644 packages/microcosm-build/tests/engine_free/us/test_us_reported_ssi_receipt.py diff --git a/changelog.d/us-reported-ssi-receipt.added.md b/changelog.d/us-reported-ssi-receipt.added.md new file mode 100644 index 0000000000..3de2bf3d64 --- /dev/null +++ b/changelog.d/us-reported-ssi-receipt.added.md @@ -0,0 +1 @@ +Carry ASEC reported SSI receipt into the US base build as the `receives_ssi` person input (`cps_carried.reported_ssi_receipt`: positive `SSI_VAL`, excluding respondents who receive SSI only on behalf of a child), and require it in the US release input-coverage gate with no reviewed exclusion. The gate also checks that every marked person reports a positive `SSI_VAL` and carries `takes_up_ssi_if_eligible`. The published `populace-us-2024-spm-20260915` default carries no `receives_ssi` and now fails the gate on it. The stacked ACS chain transfers the column with the other CPS-carried inputs, moving its declared surface from 134 to 135 targets. diff --git a/docs/evidence/us-reported-ssi-receipt/README.md b/docs/evidence/us-reported-ssi-receipt/README.md new file mode 100644 index 0000000000..8aafb5dbc0 --- /dev/null +++ b/docs/evidence/us-reported-ssi-receipt/README.md @@ -0,0 +1,98 @@ +# Reported SSI receipt on the pinned US default: measured effect + +Run 2026-10-18 with `measure_receives_ssi.py` (in this folder). +`summary_aggregates.json` holds the aggregates; it records no household-level +values. + +## Setup + +- **Data:** `populace_us_2024.h5`, sha256 `6496cc43…aee84`. This is + PolicyEngine-US's `DEFAULT_DATASET` (`populace-us-2024-spm-20260909`; the + same bytes as `-20260915`). +- **Model:** PolicyEngine-US `origin/main` at `fab18b7d6b` and + PolicyEngine/policyengine-us#10145 at `f450d4ab00`, with policyengine-core + 3.33.2. The two differ only in Colorado, so the tables below use the + #10145 head. +- **Variants:** each adds person columns to an in-memory copy of the dataset: + + | Variant | Change | + |---|---| + | `base` | None; `receives_ssi` takes the engine default `False`. | + | `receipt` | `receives_ssi` as `cps_carried.reported_ssi_receipt` defines it (positive `SSI_VAL`, excluding on-behalf-of-child-only payees). | + | `receipt_asec_only` | The same, on ASEC rows only; PUF tax-detail clones stay `False`. | + | `receipt_any` | `SSI_VAL > 0` without the payee exclusion. | + | `criteria_anchor` | Diagnostic: `meets_ssi_disability_criteria = True` for under-65 own-reason reporters on the PUF channel; no receipt. | + | `receipt_criteria_anchor` | `receipt` plus `criteria_anchor`. | + | `receipt_assets0` | Diagnostic upper bound: `receipt`, with no liquid assets for anyone in a reporter's household. | + +- **Households simulated:** every household with a reporter (any row with + `SSI_VAL > 0`) plus every Colorado household (2,541 households and 7,764 + rows). + +`receives_ssi` is a person input, and the static model has no interaction +between households, so households without a reporter cannot change. The +weighted deltas below are therefore the national deltas. The run checks this +on Colorado's non-reporter households, whose deltas are exactly zero on every +aggregate except the `medicaid` dollar value. That value spreads a fixed +total over enrollees, so it is not reported. Colorado totals are complete; the +main-branch Colorado supplement reproduces the US hub's full-dataset $233.22M. + +The variants change inputs only; nothing is recalibrated. In a rebuild the +SSI take-up priors are re-solved against SSA counts, so the federal SSI +changes in the two diagnostic variants overstate a rebuilt effect. + +## Survey reporters versus modeled SSI (2026, `base`) + +| | Weighted people | Modeled SSI | +|---|---:|---:| +| Own-reason SSI reporters | 5.15M | 2.79M paid; $28.4B against $49.1B reported | +| On-behalf-of-child payees (excluded) | 0.45M | 0.16M paid $1.5B of their own | + +In January 2026, 2.35M own-reason reporters receive no modeled payment: + +| First failing test | Weighted people | +|---|---:| +| Not aged, blind or disabled (under 65 with no `meets_ssi_disability_criteria`) | 1.03M | +| Countable income at or above the benefit | 0.78M | +| Resource test | 0.45M (median countable resources $9,087) | +| Not aged, blind or disabled (other) | 0.09M | + +By channel, ASEC rows anchor `meets_ssi_disability_criteria` for every +under-65 reporter (2.48M of 2.48M). PUF tax-detail clones do not (0.82M of +1.95M), although they carry the reporter's `SSI_VAL` and the take-up anchor. +Among reporters who fail the income test, ASEC rows have a median countable +income of $1,750 a month (464,000 people, 65% with Social Security). PUF +clones have $5,265 a month (316,000 people), from PUF-imputed incomes +(`income_fail_by_channel.py`). + +## Effect of adding the column (2026, change from `base`) + +| Measure | `receipt` | `receipt_asec_only` | `receipt_any` | `criteria_anchor` | `receipt_criteria_anchor` | `receipt_assets0` | +|---|---:|---:|---:|---:|---:|---:| +| Federal `ssi` | $0 | $0 | $0 | +$6,240M | +$6,240M | +$1,927M | +| `household_net_income` | +$339M | +$141M | +$454M | +$5,444M | +$5,729M | +$2,066M | +| `snap` | +$261M | +$97M | +$310M | −$607M | −$397M | +$48M | +| `early_head_start` | +$1,156M | +$433M | +$1,329M | +$268M | +$1,156M | +$1,156M | +| `head_start` | $0 | $0 | +$93M | $0 | $0 | $0 | +| `free_school_meals` | +$63M | +$43M | +$121M | $0 | +$63M | +$65M | +| `aca_ptc` | −$863M | −$220M | −$923M | −$201M | −$786M | −$863M | +| `medicaid_enrolled` (people) | +1,408k | +369k | +1,576k | +350k | +1,393k | +1,443k | +| `is_ssi_recipient_for_medicaid` (people) | +2,228k | +761k | +2,510k | +566k | +2,205k | +2,316k | +| `mi_ssp` | +$6.6M | $0 | +$9.5M | $0 | +$5.0M | +$3.3M | +| `wa_ssp` | +$7.6M | +$0.3M | +$7.6M | +$10.6M | +$18.2M | +$13.2M | +| `wy_ssp` | +$0.3M | +$0.3M | +$0.3M | $0 | +$0.3M | +$0.3M | +| `co_state_supplement` | $0 | $0 | $0 | $0 | $0 | $0 | + +`ca_state_supplement`, `ma_state_supplement`, `nj_ssp`, `pa_ssp` and `me_ssp` +move only in the diagnostic variants. The 2024 deltas have the same signs. +`summary_aggregates.json` has both years for both commits. + +## Colorado + +Main pays Colorado's AND-CS supplement ($233.22M in 2026) to 11 records. None +of them belongs to a household with a reported SSI amount: they are +modeled-eligible people with `takes_up_ssi_if_eligible = False`. #10145 pays +$0 with or without `receives_ssi`. Every Colorado reporter with positive +weight who passes AND-CS's disability and age test already has modeled SSI +plus countable income equal to the $994 grant standard, so the supplement is +$0. diff --git a/docs/evidence/us-reported-ssi-receipt/income_fail_by_channel.py b/docs/evidence/us-reported-ssi-receipt/income_fail_by_channel.py new file mode 100644 index 0000000000..3e5f08275f --- /dev/null +++ b/docs/evidence/us-reported-ssi-receipt/income_fail_by_channel.py @@ -0,0 +1,69 @@ +"""Income-test failures among own SSI reporters, by support channel (2026-01). + +Usage (next to measure_receives_ssi.py): + PYTHONPATH= python income_fail_by_channel.py +""" + +import measure_receives_ssi as m +import numpy as np +import pandas as pd +from policyengine_us import Microsimulation + + +def weighted_median(values: pd.Series, weights: pd.Series) -> float | None: + if not len(values): + return None + order = np.argsort(values.to_numpy()) + cumulative = np.cumsum(weights.to_numpy()[order]) + index = np.searchsorted(cumulative, cumulative[-1] / 2) + return float(values.to_numpy()[order][index]) + + +def main() -> None: + ds, _reporter_households, _co_households = m.subset_dataset(False) + sim = Microsimulation(dataset=ds, dataset_end_year=2026) + month = "2026-01" + frame = pd.DataFrame( + { + "w": sim.calculate("person_weight", 2026).values, + "eligible": sim.calculate("is_ssi_eligible", month).values, + "uncapped": sim.calculate("uncapped_ssi", month).values, + "countable_income": sim.calculate("ssi_countable_income", month).values, + "earned": sim.calculate("ssi_earned_income", month).values, + "unearned": sim.calculate("ssi_unearned_income", month).values, + "social_security": sim.calculate("social_security", 2026).values / 12, + "employment": sim.calculate("employment_income", 2026).values / 12, + } + ) + frame["channel"] = ds.person["person_support_channel"].astype(str).to_numpy() + frame["reporter"] = m.own_ssi_receipt(ds.person) + frame["ssi_val_monthly"] = pd.to_numeric(ds.person["SSI_VAL"]).to_numpy() / 12 + reporters = frame[frame.reporter] + fail = reporters[reporters.eligible.astype(bool) & (reporters.uncapped <= 0)] + for channel, group in fail.groupby("channel"): + weight = group.w + print( + channel, + "income-test failures", + round(weight.sum()), + "of reporters", + round(reporters.loc[reporters.channel == channel, "w"].sum()), + "| weighted median monthly countable income", + round(weighted_median(group.countable_income, weight)), + "unearned", + round(weighted_median(group.unearned, weight)), + "Social Security", + round(weighted_median(group.social_security, weight)), + "reported SSI", + round(weighted_median(group.ssi_val_monthly, weight)), + "| share with Social Security", + round( + float((weight * (group.social_security > 0)).sum() / weight.sum()), 3 + ), + "| share with wages", + round(float((weight * (group.employment > 0)).sum() / weight.sum()), 3), + ) + + +if __name__ == "__main__": + main() diff --git a/docs/evidence/us-reported-ssi-receipt/measure_receives_ssi.py b/docs/evidence/us-reported-ssi-receipt/measure_receives_ssi.py new file mode 100644 index 0000000000..355c1606a7 --- /dev/null +++ b/docs/evidence/us-reported-ssi-receipt/measure_receives_ssi.py @@ -0,0 +1,409 @@ +"""Measure what survey-reported SSI receipt (`receives_ssi`) changes in PE-US. + +Reads the pinned default dataset (populace_us_2024.h5, sha256 6496cc43...) and +runs PolicyEngine-US on a household subset, twice per code version: + +* ``base``: the dataset as published (no ``receives_ssi`` column, so the + engine default ``False`` applies); +* ``receipt``: the same rows plus ``person.receives_ssi = SSI_VAL > 0``. + +Subset = every household containing an SSI reporter (any row with SSI_VAL > 0) +plus every Colorado household. ``receives_ssi`` is a person input and a static +microsimulation has no cross-household interaction, so households without a +reporter cannot change: the weighted receipt deltas over the subset equal the +national deltas. The script checks that claim on the Colorado non-reporter +households, whose delta must be exactly zero. Colorado totals are complete +because every Colorado household is in the subset. + +Usage (one code version per process): + PYTHONPATH= python measure_receives_ssi.py