Skip to content

Commit 1359aaf

Browse files
committed
Normalize SSI calibration targets for payment timing
1 parent f422d93 commit 1359aaf

7 files changed

Lines changed: 206 additions & 24 deletions

File tree

changelog.d/1109.changed

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Normalized SSI amount calibration targets for federal fiscal-year payment timing.

policyengine_us_data/db/etl_national_targets.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,13 @@
2727
get_geographic_strata,
2828
)
2929
from policyengine_us_data.utils.ssi_targets import (
30+
SSI_PAYMENT_TARGET_SOURCE,
3031
SSI_RECIPIENT_TARGET_NOTES,
3132
SSI_RECIPIENT_TARGET_SOURCE,
3233
SSI_RECIPIENT_TARGET_YEAR,
3334
SSI_RECIPIENT_TARGETS_2024,
35+
get_ssi_payment_target_notes,
36+
normalize_ssi_payment_target,
3437
)
3538
from policyengine_us_data.utils.target_variables import (
3639
target_variable_components,
@@ -767,12 +770,18 @@ def extract_national_targets(year: int = DEFAULT_YEAR):
767770
value = tax_benefit_system.parameters(
768771
time_period
769772
).calibration.gov.cbo._children[param_name]
773+
source = "CBO Budget Projections"
774+
notes = f"CBO projection for {variable_name}"
775+
if variable_name == "ssi":
776+
value = normalize_ssi_payment_target(value, time_period)
777+
source = SSI_PAYMENT_TARGET_SOURCE
778+
notes = get_ssi_payment_target_notes(time_period)
770779
cbo_targets.append(
771780
{
772781
"variable": variable_name,
773782
"value": float(value),
774-
"source": "CBO Budget Projections",
775-
"notes": f"CBO projection for {variable_name}",
783+
"source": source,
784+
"notes": notes,
776785
"year": time_period,
777786
}
778787
)

policyengine_us_data/utils/loss.py

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@
2626
)
2727
from policyengine_core.reforms import Reform
2828
from policyengine_us_data.utils.soi import pe_to_soi, get_soi, get_tracked_soi_row
29-
from policyengine_us_data.utils.ssi_targets import SSI_RECIPIENT_TARGETS_2024
29+
from policyengine_us_data.utils.ssi_targets import (
30+
SSI_RECIPIENT_TARGETS_2024,
31+
normalize_ssi_payment_target,
32+
)
3033
from policyengine_us_data.utils.target_variables import (
3134
target_variable_components,
3235
)
@@ -93,6 +96,18 @@
9396
),
9497
]
9598

99+
CBO_PROGRAMS = [
100+
"income_tax_positive",
101+
"snap",
102+
"social_security",
103+
"ssi",
104+
"unemployment_compensation",
105+
]
106+
107+
CBO_PARAM_NAME_MAP = {
108+
"income_tax_positive": "income_tax",
109+
}
110+
96111
HARD_CODED_TOTALS = {
97112
MEDICARE_PART_B_PREMIUM_VARIABLE: (
98113
get_beneficiary_paid_medicare_part_b_premiums_target(2024)
@@ -233,6 +248,16 @@ def _add_ssi_recipient_targets(loss_matrix, targets_array, sim, time_period):
233248
return targets_array, loss_matrix
234249

235250

251+
def _cbo_program_target_value(sim, variable_name: str, time_period):
252+
param_name = CBO_PARAM_NAME_MAP.get(variable_name, variable_name)
253+
value = sim.tax_benefit_system.parameters(
254+
time_period
255+
).calibration.gov.cbo._children[param_name]
256+
if variable_name == "ssi":
257+
return normalize_ssi_payment_target(value, time_period)
258+
return value
259+
260+
236261
ACA_SPENDING_TARGETS = {
237262
2024: 98e9,
238263
}
@@ -1316,30 +1341,12 @@ def build_loss_matrix(dataset: type, time_period):
13161341
# refundable credit payments in excess of liability are classified as
13171342
# outlays, not negative receipts. See: https://www.cbo.gov/publication/43767
13181343

1319-
CBO_PROGRAMS = [
1320-
"income_tax_positive",
1321-
"snap",
1322-
"social_security",
1323-
"ssi",
1324-
"unemployment_compensation",
1325-
]
1326-
1327-
# Mapping from variable name to CBO parameter name (when different)
1328-
CBO_PARAM_NAME_MAP = {
1329-
"income_tax_positive": "income_tax",
1330-
}
1331-
13321344
for variable_name in CBO_PROGRAMS:
13331345
label = f"nation/cbo/{variable_name}"
13341346
loss_matrix[label] = sim.calculate(variable_name, map_to="household").values
13351347
if any(loss_matrix[label].isna()):
13361348
raise ValueError(f"Missing values for {label}")
1337-
param_name = CBO_PARAM_NAME_MAP.get(variable_name, variable_name)
1338-
targets_array.append(
1339-
sim.tax_benefit_system.parameters(
1340-
time_period
1341-
).calibration.gov.cbo._children[param_name]
1342-
)
1349+
targets_array.append(_cbo_program_target_value(sim, variable_name, time_period))
13431350

13441351
targets_array, loss_matrix = _add_ssi_recipient_targets(
13451352
loss_matrix,

policyengine_us_data/utils/ssi_targets.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,66 @@
11
"""Shared SSI calibration targets."""
22

3+
from datetime import date, timedelta
4+
5+
6+
SSI_CBO_TARGET_SOURCE = (
7+
"https://www.cbo.gov/system/files/2026-02/51313-2026-02-ssi.xlsx"
8+
)
9+
SSI_PAYMENT_TIMING_SOURCE = (
10+
"https://www.ssa.gov/budget/assets/materials/2026/2026BST.pdf"
11+
)
12+
SSI_PAYMENT_TARGET_SOURCE = f"{SSI_CBO_TARGET_SOURCE}; {SSI_PAYMENT_TIMING_SOURCE}"
13+
14+
15+
def _as_fiscal_year(year) -> int:
16+
return int(str(year)[:4])
17+
18+
19+
def _is_first_day_federal_holiday(day: date) -> bool:
20+
is_new_years_day = day.month == 1 and day.day == 1
21+
is_labor_day = day.month == 9 and day.weekday() == 0 and 1 <= day.day <= 7
22+
return is_new_years_day or is_labor_day
23+
24+
25+
def _ssi_payment_date(year: int, month: int) -> date:
26+
day = date(year, month, 1)
27+
while day.weekday() >= 5 or _is_first_day_federal_holiday(day):
28+
day -= timedelta(days=1)
29+
return day
30+
31+
32+
def get_ssi_fiscal_year_payment_count(year) -> int:
33+
"""Return SSI monthly benefit payments counted in the federal fiscal year."""
34+
fiscal_year = _as_fiscal_year(year)
35+
start = date(fiscal_year - 1, 10, 1)
36+
end = date(fiscal_year, 9, 30)
37+
payment_count = 0
38+
39+
for calendar_year in (fiscal_year - 1, fiscal_year):
40+
for month in range(1, 13):
41+
payment_day = _ssi_payment_date(calendar_year, month)
42+
if start <= payment_day <= end:
43+
payment_count += 1
44+
45+
return payment_count
46+
47+
48+
def normalize_ssi_payment_target(value, year) -> float:
49+
"""Convert fiscal-year SSI outlays to a 12-payment-equivalent target."""
50+
payment_count = get_ssi_fiscal_year_payment_count(year)
51+
return float(value) * 12 / payment_count
52+
53+
54+
def get_ssi_payment_target_notes(year) -> str:
55+
payment_count = get_ssi_fiscal_year_payment_count(year)
56+
return (
57+
"CBO SSI total outlays normalized to a 12-payment-equivalent "
58+
"annual target for PolicyEngine's annual SSI variable; "
59+
f"FY{_as_fiscal_year(year)} has {payment_count} monthly SSI "
60+
"payments under federal budget timing"
61+
)
62+
63+
364
SSI_RECIPIENT_TARGET_YEAR = 2024
465
SSI_RECIPIENT_TARGET_SOURCE = (
566
"https://www.ssa.gov/policy/docs/statcomps/ssi_monthly/2024-12/table01.html"

tests/unit/calibration/test_loss_targets.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
_add_real_estate_tax_targets,
2929
_add_ssi_recipient_targets,
3030
_add_transfer_balance_targets,
31+
_cbo_program_target_value,
3132
_get_medicaid_national_targets,
3233
_get_aca_national_targets,
3334
_load_aca_spending_and_enrollment_targets,
@@ -39,7 +40,11 @@
3940
get_target_loss_weights,
4041
)
4142
from policyengine_us_data.db import etl_national_targets
42-
from policyengine_us_data.utils.ssi_targets import SSI_RECIPIENT_TARGETS_2024
43+
from policyengine_us_data.utils.ssi_targets import (
44+
SSI_RECIPIENT_TARGETS_2024,
45+
get_ssi_fiscal_year_payment_count,
46+
normalize_ssi_payment_target,
47+
)
4348

4449

4550
def test_legacy_loss_targets_include_aggregate_qbi_deduction():
@@ -228,6 +233,27 @@ def map_result(self, values, source_entity, target_entity, how=None):
228233
return np.asarray(values, dtype=np.float32)
229234

230235

236+
class _FakeCBOProgramTargetSimulation:
237+
def __init__(self):
238+
self.tax_benefit_system = SimpleNamespace(
239+
parameters=lambda period: SimpleNamespace(
240+
calibration=SimpleNamespace(
241+
gov=SimpleNamespace(
242+
cbo=SimpleNamespace(
243+
_children={
244+
"income_tax": 2_000.0,
245+
"snap": 1_000.0,
246+
"social_security": 3_000.0,
247+
"ssi": 57_000_000_000.0,
248+
"unemployment_compensation": 4_000.0,
249+
}
250+
)
251+
)
252+
)
253+
)
254+
)
255+
256+
231257
class _FakeCapitalGainsSimulation:
232258
def __init__(self):
233259
self.calculate_calls = []
@@ -354,6 +380,28 @@ def test_add_ssi_recipient_targets_adds_total_and_age_counts():
354380
)
355381

356382

383+
def test_ssi_payment_targets_normalize_fiscal_year_payment_timing():
384+
assert get_ssi_fiscal_year_payment_count(2024) == 11
385+
assert get_ssi_fiscal_year_payment_count(2025) == 12
386+
assert get_ssi_fiscal_year_payment_count(2028) == 13
387+
388+
assert normalize_ssi_payment_target(57_000_000_000, 2024) == pytest.approx(
389+
57_000_000_000 * 12 / 11
390+
)
391+
assert normalize_ssi_payment_target(75_400_000_000, 2028) == pytest.approx(
392+
75_400_000_000 * 12 / 13
393+
)
394+
395+
396+
def test_legacy_cbo_ssi_target_uses_12_payment_equivalent():
397+
sim = _FakeCBOProgramTargetSimulation()
398+
399+
assert _cbo_program_target_value(sim, "ssi", 2024) == pytest.approx(
400+
57_000_000_000 * 12 / 11
401+
)
402+
assert _cbo_program_target_value(sim, "snap", 2024) == 1_000.0
403+
404+
357405
def test_add_ctc_targets(monkeypatch):
358406
monkeypatch.setattr(
359407
"policyengine_us_data.utils.loss.get_national_geography_soi_target",

tests/unit/calibration/test_target_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,7 @@ def test_training_config_includes_ssi_recipient_count_targets(self):
546546
)
547547

548548
include_rules = config["include"]
549+
assert {"variable": "ssi", "geo_level": "national"} in include_rules
549550
assert {
550551
"variable": "person_count",
551552
"geo_level": "national",

tests/unit/test_etl_national_targets.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import inspect
22

33
import pandas as pd
4+
import pytest
45
from sqlalchemy import text
56
from sqlmodel import Session, select
67

@@ -19,7 +20,10 @@
1920
load_national_targets,
2021
load_state_acs_rent_targets,
2122
)
22-
from policyengine_us_data.utils.ssi_targets import SSI_RECIPIENT_TARGETS_2024
23+
from policyengine_us_data.utils.ssi_targets import (
24+
SSI_PAYMENT_TARGET_SOURCE,
25+
SSI_RECIPIENT_TARGETS_2024,
26+
)
2327

2428

2529
def test_national_targets_do_not_extract_treasury_eitc():
@@ -439,6 +443,57 @@ def test_extract_national_targets_includes_ssi_count_targets():
439443
}
440444

441445

446+
def test_extract_national_targets_normalizes_ssi_cbo_amount_target(monkeypatch):
447+
class FakeIncomeBySource:
448+
_children = {
449+
target["parameter"]: 0
450+
for target in etl_national_targets.CBO_INCOME_BY_SOURCE_TARGETS
451+
}
452+
453+
class FakeCBO:
454+
income_by_source = FakeIncomeBySource()
455+
_children = {
456+
"income_tax": 0,
457+
"snap": 0,
458+
"social_security": 0,
459+
"ssi": 57_000_000_000,
460+
"unemployment_compensation": 0,
461+
}
462+
463+
class FakeSOI:
464+
_children = {"long_term_capital_gains": 0}
465+
466+
class FakeGov:
467+
cbo = FakeCBO()
468+
irs = type("FakeIRS", (), {"soi": FakeSOI()})()
469+
470+
class FakeCalibration:
471+
gov = FakeGov()
472+
473+
class FakeParameters:
474+
def __call__(self, year):
475+
return self
476+
477+
calibration = FakeCalibration()
478+
479+
class FakeTaxBenefitSystem:
480+
parameters = FakeParameters()
481+
482+
monkeypatch.setattr(
483+
"policyengine_us.CountryTaxBenefitSystem",
484+
FakeTaxBenefitSystem,
485+
)
486+
487+
raw_targets = extract_national_targets(year=2024)
488+
ssi_target = next(
489+
target for target in raw_targets["cbo_targets"] if target["variable"] == "ssi"
490+
)
491+
492+
assert ssi_target["value"] == pytest.approx(57_000_000_000 * 12 / 11)
493+
assert ssi_target["source"] == SSI_PAYMENT_TARGET_SOURCE
494+
assert "12-payment-equivalent" in ssi_target["notes"]
495+
496+
442497
def test_load_national_targets_uses_medicaid_enrolled_for_enrollment_counts(
443498
tmp_path, monkeypatch
444499
):

0 commit comments

Comments
 (0)