From d2250877f6184942e35a43d054e9fcd171222b4a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 15:12:21 -0400 Subject: [PATCH 1/7] Fill CBO total AGI for 2015-2018 with IRS SOI actuals calibration.gov.cbo.income_by_source.adjusted_gross_income had a 2015 value of $9.03T, cited to a Tax Foundation page whose table is tax-year 2013 AGI excluding dependent filers, and no values for 2016-2018, so the series and its per-capita sibling (the default uprating index for dollar inputs) held that figure through 2018 and then jumped 31.7% into 2019. Replace 2015 and add 2016-2018 with IRS SOI Publication 1304 Table 1.1, all returns, adjusted gross income less deficit: $10.210T, $10.226T, $11.010T and $11.643T. CBO values from 2019 on are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../fix-cbo-agi-2016-2018-soi.fixed.md | 1 + .../calibration/gov/cbo/income_by_source.yaml | 29 ++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 changelog.d/fix-cbo-agi-2016-2018-soi.fixed.md diff --git a/changelog.d/fix-cbo-agi-2016-2018-soi.fixed.md b/changelog.d/fix-cbo-agi-2016-2018-soi.fixed.md new file mode 100644 index 00000000000..3d6f5cfe917 --- /dev/null +++ b/changelog.d/fix-cbo-agi-2016-2018-soi.fixed.md @@ -0,0 +1 @@ +Fill total AGI in calibration.gov.cbo.income_by_source.adjusted_gross_income for 2015-2018 with IRS Statistics of Income actuals. The 2015 value was tax-year 2013 AGI excluding dependent filers and was held flat through 2018, so dollar inputs dated 2018 or earlier came out 13% to 29% too high when uprated to 2019 or later. diff --git a/policyengine_us/parameters/calibration/gov/cbo/income_by_source.yaml b/policyengine_us/parameters/calibration/gov/cbo/income_by_source.yaml index 74ae47aba21..19fef25c9bc 100644 --- a/policyengine_us/parameters/calibration/gov/cbo/income_by_source.yaml +++ b/policyengine_us/parameters/calibration/gov/cbo/income_by_source.yaml @@ -1,4 +1,4 @@ -description: Historical data and projections of detailed adjusted gross income (AGI) by source (2019-2036) +description: Historical data and projections of detailed adjusted gross income (AGI) by source (2019-2036), with IRS Statistics of Income actuals for total AGI in 2015-2018 metadata: propagate_metadata_to_children: true unit: currency-USD @@ -19,12 +19,33 @@ adjusted_gross_income: metadata: label: Total AGI values: + # IRS Statistics of Income actuals, all returns: adjusted gross income + # less deficit. 2015-01-01: - value: 9_033_840_000_000 + value: 10_210_310_102_000 metadata: reference: - - title: Tax Foundation - href: https://taxfoundation.org/data/all/federal/summary-latest-federal-income-tax-data-2015-update/ + - title: IRS SOI Tax Stats - Publication 1304 Table 1.1, tax year 2015, adjusted gross income less deficit + href: https://www.irs.gov/pub/irs-soi/15in11si.xls + 2016-01-01: + value: 10_225_938_479_000 + metadata: + reference: + - title: IRS SOI Tax Stats - Publication 1304 Table 1.1, tax year 2016, adjusted gross income less deficit + href: https://www.irs.gov/pub/irs-soi/16in11si.xls + 2017-01-01: + value: 11_009_900_155_000 + metadata: + reference: + - title: IRS SOI Tax Stats - Publication 1304 Table 1.1, tax year 2017, adjusted gross income less deficit + href: https://www.irs.gov/pub/irs-soi/17in11si.xls + 2018-01-01: + value: 11_643_439_106_000 + metadata: + reference: + - title: IRS SOI Tax Stats - Publication 1304 Table 1.1, tax year 2018, adjusted gross income less deficit + href: https://www.irs.gov/pub/irs-soi/18in11si.xls + # CBO from 2019. 2019-01-01: 11_962_000_000_000 2020-01-01: 12_650_000_000_000 2021-01-01: 14_791_339_000_000 From 8583be56bebcc40287c0c7540fa37d231b764ffb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:06:25 -0400 Subject: [PATCH 2/7] Guard the default uprating series against missing years The CBO AGI total, which every dollar input without its own series follows per capita, must have a value for every year from its first to its last: a missing year holds the previous value, so the per-capita index drifts down with population and then jumps. The check fails on main (2016-2018 missing) and passes with the SOI fill. Co-Authored-By: Claude Opus 5.5 --- .../tests/core/test_uprating_series.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/policyengine_us/tests/core/test_uprating_series.py b/policyengine_us/tests/core/test_uprating_series.py index fb7d4e7aec6..5167af52f49 100644 --- a/policyengine_us/tests/core/test_uprating_series.py +++ b/policyengine_us/tests/core/test_uprating_series.py @@ -123,6 +123,22 @@ def test_series_is_defined_across_the_projection_window(series): assert _value(series, year) not in (None, 0) +def test_default_series_has_a_value_for_every_year(): + """A year with no value holds the previous one, so the per-capita index + drifts down with population and then jumps when values resume. CBO AGI + once had nothing for 2016 to 2018, so inputs dated 2018 came out 29% too + high by 2019.""" + total = get_parameter(system.parameters, _authored_series(DEFAULT)) + years = sorted( + { + int(value.instant_str[:4]) + for value in total.values_list + if value.instant_str.endswith("-01-01") + } + ) + assert years == list(range(years[0], years[-1] + 1)) + + @pytest.mark.parametrize("year", (2025, 2030, 2035, 2036)) def test_unemployment_compensation_grows_with_cbo_unemployment_outlays(year): soi = "calibration.gov.irs.soi.unemployment_compensation" From aeabda50bc760d6c1fd17adc352bd7f8f0e9135b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 17:42:30 -0400 Subject: [PATCH 3/7] Name CBO as the 2019-2036 source; note in-window changes in changelog The node description now says the by-source series are CBO's, with SOI actuals for total AGI in 2015-2018. The changelog also covers inputs projected to a year within 2016-2018, which the old flat series grew up to 12% too little. Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-cbo-agi-2016-2018-soi.fixed.md | 2 +- .../parameters/calibration/gov/cbo/income_by_source.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.d/fix-cbo-agi-2016-2018-soi.fixed.md b/changelog.d/fix-cbo-agi-2016-2018-soi.fixed.md index 3d6f5cfe917..6fb7c5c3abe 100644 --- a/changelog.d/fix-cbo-agi-2016-2018-soi.fixed.md +++ b/changelog.d/fix-cbo-agi-2016-2018-soi.fixed.md @@ -1 +1 @@ -Fill total AGI in calibration.gov.cbo.income_by_source.adjusted_gross_income for 2015-2018 with IRS Statistics of Income actuals. The 2015 value was tax-year 2013 AGI excluding dependent filers and was held flat through 2018, so dollar inputs dated 2018 or earlier came out 13% to 29% too high when uprated to 2019 or later. +Fill total AGI in calibration.gov.cbo.income_by_source.adjusted_gross_income for 2015-2018 with IRS Statistics of Income actuals. The 2015 value was tax-year 2013 AGI excluding dependent filers and was held flat through 2018, so dollar inputs dated 2018 or earlier came out 13% to 29% too high when uprated to 2019 or later, and up to 12% too low when uprated to a year within 2016-2018. diff --git a/policyengine_us/parameters/calibration/gov/cbo/income_by_source.yaml b/policyengine_us/parameters/calibration/gov/cbo/income_by_source.yaml index 19fef25c9bc..67939c85410 100644 --- a/policyengine_us/parameters/calibration/gov/cbo/income_by_source.yaml +++ b/policyengine_us/parameters/calibration/gov/cbo/income_by_source.yaml @@ -1,4 +1,4 @@ -description: Historical data and projections of detailed adjusted gross income (AGI) by source (2019-2036), with IRS Statistics of Income actuals for total AGI in 2015-2018 +description: Historical data and projections of detailed adjusted gross income (AGI) by source from CBO (2019-2036), with IRS Statistics of Income actuals for total AGI in 2015-2018 metadata: propagate_metadata_to_children: true unit: currency-USD From 79f8a56588440b3aec74dbeec940804eeebd0c59 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 23:51:31 +0000 Subject: [PATCH 4/7] Update IRMAA lookback cases for the SOI-filled AGI index The IRMAA two-year lookback from #10106 deflates benefit-year income by the per-capita AGI index this branch fills for 2015-2018, so its 2016 cases move (500,000 -> 503,654.40, 100,000 -> 100,730.88, 10,000 -> 10,073.09) and the docstring's account of the 2016-2018 gap no longer holds. New 2018 and 2019 benefit-year cases pin the largest moves, where the deflator went from 1.014 to 0.890 and from 0.765 to 0.932. The changelog now states the lookback effect. Co-Authored-By: Claude Opus 5.5 --- .../fix-cbo-agi-2016-2018-soi.fixed.md | 2 +- .../medicare_irmaa_magi_two_years_prior.yaml | 50 +++++++++++++++---- ...enior_household_2015_2016_integration.yaml | 7 +-- .../medicare_irmaa_magi_two_years_prior.py | 11 ++-- 4 files changed, 51 insertions(+), 19 deletions(-) diff --git a/changelog.d/fix-cbo-agi-2016-2018-soi.fixed.md b/changelog.d/fix-cbo-agi-2016-2018-soi.fixed.md index 6fb7c5c3abe..34d8172520d 100644 --- a/changelog.d/fix-cbo-agi-2016-2018-soi.fixed.md +++ b/changelog.d/fix-cbo-agi-2016-2018-soi.fixed.md @@ -1 +1 @@ -Fill total AGI in calibration.gov.cbo.income_by_source.adjusted_gross_income for 2015-2018 with IRS Statistics of Income actuals. The 2015 value was tax-year 2013 AGI excluding dependent filers and was held flat through 2018, so dollar inputs dated 2018 or earlier came out 13% to 29% too high when uprated to 2019 or later, and up to 12% too low when uprated to a year within 2016-2018. +Fill total AGI in calibration.gov.cbo.income_by_source.adjusted_gross_income for 2015-2018 with IRS Statistics of Income actuals. The 2015 value was tax-year 2013 AGI excluding dependent filers and was held flat through 2018, so dollar inputs dated 2018 or earlier came out 13% to 29% too high when uprated to 2019 or later, and up to 12% too low when uprated to a year within 2016-2018. Medicare IRMAA's two-year lookback deflates income by the same per-capita index, so a household entered for a single benefit year from 2016 to 2020 now gets that year's income times 1.007, 0.942, 0.890, 0.932 or 0.929, instead of 1.009, 1.016, 1.014, 0.765 or 0.721. diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml index bcda1dddd8a..3567882899e 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml @@ -2,11 +2,17 @@ # holds no input for that year or any earlier one, the MAGI of the earliest # year with inputs (no earlier than 2015, the first modeled year) stands in, # deflated to the year two years prior by -# calibration.gov.cbo.income_by_source.adjusted_gross_income_per_capita (CBO +# calibration.gov.cbo.income_by_source.adjusted_gross_income_per_capita (total # AGI divided by population). That index starts in 2015 and is held at its -# 2015 value before then: -# 2015: 9,033,840,000,000 / 326,078,000 = 27,704.54 -# 2016: 9,033,840,000,000 / 328,964,000 = 27,461.49 +# 2015 value before then. Total AGI is IRS SOI Publication 1304 Table 1.1, all +# returns, adjusted gross income less deficit, for 2015 to 2018 +# (https://www.irs.gov/pub/irs-soi/15in11si.xls to 18in11si.xls), and CBO's +# series from 2019: +# 2015: 10,210,310,102,000 / 326,078,000 = 31,312.48 +# 2016: 10,225,938,479,000 / 328,964,000 = 31,085.28 +# 2017: 11,009,900,155,000 / 331,211,000 = 33,241.35 +# 2018: 11,643,439,106,000 / 333,525,000 = 34,910.24 +# 2019: 11,962,000,000,000 / 335,329,000 = 35,672.43 # 2022: 14,821,800,000,000 / 339,654,000 = 43,637.94 # 2023: 15,347,400,000,000 / 342,835,000 = 44,766.14 # 2024: 16,685,900,000,000 / 346,588,000 = 48,143.33 @@ -19,7 +25,7 @@ age: 66 employment_income: 500_000 output: - # 500,000 x 27,704.54 (2013, held at 2015) / 27,704.54 (2015). + # 500,000 x 31,312.48 (2013, held at 2015) / 31,312.48 (2015). medicare_irmaa_magi_two_years_prior: 500_000 - name: Case 2, 2016 benefit year with only 2016 inputs uses 2016 income. @@ -29,8 +35,8 @@ age: 66 employment_income: 500_000 output: - # 500,000 x 27,704.54 (2014, held at 2015) / 27,461.49 (2016). - medicare_irmaa_magi_two_years_prior: 504_425.34 + # 500,000 x 31,312.48 (2014, held at 2015) / 31,085.28 (2016). + medicare_irmaa_magi_two_years_prior: 503_654.40 - name: Case 3, 2015 benefit year counts 2013 income that is provided. period: 2015 @@ -87,10 +93,36 @@ is_medicare_eligible: true medicare_enrolled: true output: - # 100,000 x 27,704.54 / 27,461.49, under the first threshold of 103,001. - medicare_irmaa_magi_two_years_prior: 100_885.07 + # 100,000 x 31,312.48 / 31,085.28, under the first threshold of 103,001. + medicare_irmaa_magi_two_years_prior: 100_730.88 income_adjusted_part_d_premium_surcharge: 0 +- name: Case 8, 2018 benefit year with only 2018 inputs deflates 2018 income to 2016. + period: 2018 + # MAGI is stored as float32, so allow for its rounding. + absolute_error_margin: 0.1 + input: + age: 66 + employment_income: 500_000 + output: + # 500,000 x 31,085.28 (2016) / 34,910.24 (2018). Both years are IRS SOI + # totals; with 2016-2018 missing, the index was held at its 2015 value and + # this came to 506,932.37. + medicare_irmaa_magi_two_years_prior: 445_217.19 + +- name: Case 9, 2019 benefit year with only 2019 inputs deflates 2019 income to 2017. + period: 2019 + # MAGI is stored as float32, so allow for its rounding. + absolute_error_margin: 0.1 + input: + age: 66 + employment_income: 500_000 + output: + # 500,000 x 33,241.35 (2017, IRS SOI) / 35,672.43 (2019, CBO). With + # 2016-2018 missing, 2017 was held at the 2015 value and this came to + # 382,300.59. + medicare_irmaa_magi_two_years_prior: 465_924.93 + - name: A 2025 household with only 2024 inputs has IRMAA set on deflated 2024 income. period: 2025 absolute_error_margin: 0.01 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/senior_household_2015_2016_integration.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/senior_household_2015_2016_integration.yaml index c940dcd51f4..3f957d71f35 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/senior_household_2015_2016_integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/senior_household_2015_2016_integration.yaml @@ -32,7 +32,7 @@ members: [person] state_code: TX output: - # 10,000 x 27,704.54 (2013, held at 2015) / 27,704.54 (2015). + # 10,000 x 31,312.48 (2013, held at 2015) / 31,312.48 (2015). medicare_irmaa_magi_two_years_prior: 10_000 income_tax: 0 ssi: 4_306 @@ -52,8 +52,9 @@ members: [person] state_code: TX output: - # 10,000 x 27,704.54 (2014, held at 2015) / 27,461.49 (2016). - medicare_irmaa_magi_two_years_prior: 10_088.51 + # 10,000 x 31,312.48 (2014, held at 2015) / 31,085.28 (2016), from IRS + # SOI total AGI over population (see medicare_irmaa_magi_two_years_prior.yaml). + medicare_irmaa_magi_two_years_prior: 10_073.09 income_tax: 0 ssi: 4_306 snap: 192 diff --git a/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py index 16fc9d61c1d..f3729387983 100644 --- a/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py +++ b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py @@ -59,12 +59,11 @@ class medicare_irmaa_magi_two_years_prior(Variable): "gov.simulation.medicare_irmaa_uses_current_year_income but " "deflated. Provide this value, or inputs for the year two years " "prior, to hold it fixed. " - "CBO AGI has no values for 2016 to 2018 and is held at its 2015 " - "value, so the index drifts down through 2018 and then rises 31% " - "from 2017 to 2019 and 39% from 2018 to 2020: a household entered " - "for 2016, 2017 or 2018 alone gets slightly more than that year's " - "income, and one entered for 2019 or 2020 alone gets that year's " - "income times 0.765 or 0.721. " + "Total AGI in that index is IRS Statistics of Income actuals for " + "2015 to 2018 and CBO's series from 2019. The index begins in 2015 " + "and is held at its 2015 value before then, so a household entered " + "for 2016 alone gets that year's income deflated by per-capita AGI " + "growth from 2015 to 2016 only. " "When inputs reach back to 2013 or 2014 and the year two years prior " "is one of those years, its income cannot be computed: only income " "provided for it counts, and the rest is zero." From 2acbee077d3559f36e65edc4e4db9149c053e9d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 23:51:31 +0000 Subject: [PATCH 5/7] Pin default-series uprating across the filled AGI years Household cases show a default-uprated input crossing the SOI-to-CBO splice (2018 to 2019: x1.0218, was x1.3170) and staying inside the SOI years (2015 to 2018: x1.1149, was x0.9777). Property tests check that an input moves by the index ratio for any pair of years from 2015 to 2036, and that the index never moves more than 25% in a year, which a missing year would break (the 2016-2018 gap made 2018 to 2019 +31.7%). The guard docstring now names the IRMAA lookback as a reader of the series. Co-Authored-By: Claude Opus 5.5 --- .../core/test_default_uprating_properties.py | 81 +++++++++++++++++++ .../tests/core/test_uprating_series.py | 4 +- .../charitable/charitable_cash_donations.yaml | 33 ++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 policyengine_us/tests/core/test_default_uprating_properties.py create mode 100644 policyengine_us/tests/policy/baseline/household/expense/charitable/charitable_cash_donations.yaml diff --git a/policyengine_us/tests/core/test_default_uprating_properties.py b/policyengine_us/tests/core/test_default_uprating_properties.py new file mode 100644 index 00000000000..68af965b197 --- /dev/null +++ b/policyengine_us/tests/core/test_default_uprating_properties.py @@ -0,0 +1,81 @@ +"""Property tests: dollar inputs that follow the default uprating series. + +Inputs with no series of their own follow +calibration.gov.cbo.income_by_source.adjusted_gross_income_per_capita, total +AGI divided by population. Total AGI is IRS SOI Publication 1304 Table 1.1 +(all returns, adjusted gross income less deficit) for 2015 to 2018 and CBO's +series from 2019. + +1. An input dated year X and read in a later year Y equals the input times + index(Y) / index(X), for any pair of years from 2015 to 2036, and that one + factor applies to every household. Reason: every projected input on the + default series rests on it, and test_simulation_uprating_tracks_national_ + totals checks it only from 2024, for the SOI employment series. +2. The index never moves more than 25% from one year to the next. Reason: a + year with no value holds the year before, so the per-capita index drifts + down with population and jumps when values resume. Missing 2016 to 2018 + made 2018 to 2019 a 31.7% jump; with every year filled, the largest move is + 16.5%, from 2020 to 2021. +""" + +import hypothesis +import pytest +from hypothesis import strategies as st +from policyengine_core.parameters.operations.get_parameter import get_parameter + +from policyengine_us import Simulation +from policyengine_us.system import system + +DEFAULT = "calibration.gov.cbo.income_by_source.adjusted_gross_income_per_capita" +# A dollar input with no formula and no series of its own. +INPUT = "charitable_cash_donations" +FIRST_YEAR = 2015 +LAST_YEAR = 2036 +MAX_ANNUAL_MOVE = 0.25 + + +def _index(year): + return get_parameter(system.parameters, DEFAULT)(f"{year}-01-01") + + +def test_the_input_follows_the_default_series(): + assert system.variables[INPUT].uprating == DEFAULT + + +@hypothesis.settings(max_examples=15, deadline=None, derandomize=True) +@hypothesis.given( + years=st.lists( + st.integers(FIRST_YEAR, LAST_YEAR), min_size=2, max_size=2, unique=True + ).map(sorted), + amounts=st.lists( + st.floats(0, 10_000_000, allow_nan=False, allow_infinity=False), + min_size=1, + max_size=4, + ), +) +def test_an_input_grows_by_the_ratio_of_the_index(years, amounts): + dated, read = years + simulation = Simulation( + situation={ + "people": { + f"person_{i}": {INPUT: {dated: amount}} + for i, amount in enumerate(amounts) + }, + "households": { + f"household_{i}": {"members": [f"person_{i}"]} + for i in range(len(amounts)) + }, + } + ) + factor = _index(read) / _index(dated) + projected = simulation.calculate(INPUT, read) + # Inputs are stored as float32. + assert projected.tolist() == pytest.approx( + [amount * factor for amount in amounts], rel=1e-6, abs=1e-3 + ) + + +@pytest.mark.parametrize("year", range(FIRST_YEAR + 1, LAST_YEAR + 1)) +def test_the_index_moves_less_than_a_quarter_in_a_year(year): + move = _index(year) / _index(year - 1) - 1 + assert abs(move) < MAX_ANNUAL_MOVE, f"{year - 1} to {year}: {move:+.1%}" diff --git a/policyengine_us/tests/core/test_uprating_series.py b/policyengine_us/tests/core/test_uprating_series.py index bf07648faa9..ce7870a8be0 100644 --- a/policyengine_us/tests/core/test_uprating_series.py +++ b/policyengine_us/tests/core/test_uprating_series.py @@ -154,7 +154,9 @@ def test_default_series_has_a_value_for_every_year(): """A year with no value holds the previous one, so the per-capita index drifts down with population and then jumps when values resume. CBO AGI once had nothing for 2016 to 2018, so inputs dated 2018 came out 29% too - high by 2019.""" + high by 2019, and Medicare IRMAA's lookback, which deflates income by the + same index, put a household entered for 2019 alone at 0.765 of its + income instead of 0.932.""" total = get_parameter(system.parameters, _authored_series(DEFAULT)) years = sorted( { diff --git a/policyengine_us/tests/policy/baseline/household/expense/charitable/charitable_cash_donations.yaml b/policyengine_us/tests/policy/baseline/household/expense/charitable/charitable_cash_donations.yaml new file mode 100644 index 00000000000..36c2206a1a7 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/expense/charitable/charitable_cash_donations.yaml @@ -0,0 +1,33 @@ +# charitable_cash_donations has no uprating series of its own, so it follows the +# default, calibration.gov.cbo.income_by_source.adjusted_gross_income_per_capita +# (total AGI divided by population). An input dated in one year and read in a +# later year is multiplied by the ratio of that index between the two years. +# Total AGI is IRS SOI Publication 1304 Table 1.1, all returns, adjusted gross +# income less deficit, for 2015 to 2018, and CBO's series from 2019: +# 2015: 10,210,310,102,000 / 326,078,000 = 31,312.48 +# (https://www.irs.gov/pub/irs-soi/15in11si.xls) +# 2018: 11,643,439,106,000 / 333,525,000 = 34,910.24 +# (https://www.irs.gov/pub/irs-soi/18in11si.xls) +# 2019: 11,962,000,000,000 / 335,329,000 = 35,672.43 + +- name: A 2018 donation read in 2019 grows with per-capita AGI across the SOI-to-CBO splice. + period: 2019 + absolute_error_margin: 0.01 + input: + charitable_cash_donations: + 2018: 10_000 + output: + # 10,000 x 35,672.43 / 34,910.24. When 2016-2018 had no values and 2018 + # held the 2015 value, this came to 13,170.09. + charitable_cash_donations: 10_218.33 + +- name: A 2015 donation read in 2018 grows with per-capita AGI within the SOI years. + period: 2018 + absolute_error_margin: 0.01 + input: + charitable_cash_donations: + 2015: 10_000 + output: + # 10,000 x 34,910.24 / 31,312.48. When 2018 held the 2015 value, this + # came to 9,776.72. + charitable_cash_donations: 11_148.99 From a4814e2dc4ca29f8e9f9a675b436b51bbcfa3e92 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:16:03 +0000 Subject: [PATCH 6/7] Correct how the old AGI values are described; drop the property file An independent review found that the comments on the old IRMAA and donation results said the index was held at its 2015 value. It was the total that was held, so the per-capita index fell with population, which is what the quoted numbers reflect. The module comment and changelog now say the index is SOI for 2015-2018 and that only inputs on the default series were affected, and the guard docstring separates the gap's share of the error (14%, 0.864) from the wrong 2015 level's. The Hypothesis file computed its expected factor from the same parameter core reads, so it passed on main as well, and its 25% bound does not detect a gap once the 2015 level is right. The guard and the hand-computed YAML cases cover what it claimed, so it is removed. Co-Authored-By: Claude Opus 5.5 --- .../fix-cbo-agi-2016-2018-soi.fixed.md | 2 +- .../core/test_default_uprating_properties.py | 81 ------------------- .../tests/core/test_uprating_series.py | 10 ++- .../medicare_irmaa_magi_two_years_prior.yaml | 12 +-- .../charitable/charitable_cash_donations.yaml | 9 ++- .../medicare_irmaa_magi_two_years_prior.py | 8 +- 6 files changed, 23 insertions(+), 99 deletions(-) delete mode 100644 policyengine_us/tests/core/test_default_uprating_properties.py diff --git a/changelog.d/fix-cbo-agi-2016-2018-soi.fixed.md b/changelog.d/fix-cbo-agi-2016-2018-soi.fixed.md index 34d8172520d..ccdb108eae9 100644 --- a/changelog.d/fix-cbo-agi-2016-2018-soi.fixed.md +++ b/changelog.d/fix-cbo-agi-2016-2018-soi.fixed.md @@ -1 +1 @@ -Fill total AGI in calibration.gov.cbo.income_by_source.adjusted_gross_income for 2015-2018 with IRS Statistics of Income actuals. The 2015 value was tax-year 2013 AGI excluding dependent filers and was held flat through 2018, so dollar inputs dated 2018 or earlier came out 13% to 29% too high when uprated to 2019 or later, and up to 12% too low when uprated to a year within 2016-2018. Medicare IRMAA's two-year lookback deflates income by the same per-capita index, so a household entered for a single benefit year from 2016 to 2020 now gets that year's income times 1.007, 0.942, 0.890, 0.932 or 0.929, instead of 1.009, 1.016, 1.014, 0.765 or 0.721. +Fill total AGI in calibration.gov.cbo.income_by_source.adjusted_gross_income for 2015-2018 with IRS Statistics of Income actuals. The 2015 value was tax-year 2013 AGI excluding dependent filers and was held flat through 2018, so dollar inputs on the default uprating series (those with no series of their own, such as charitable donations) dated 2018 or earlier came out 13% to 29% too high when uprated to 2019 or later, and up to 12% too low when uprated to a year within 2016-2018. Medicare IRMAA's two-year lookback deflates income by the same per-capita index, so a household entered for a single benefit year from 2016 to 2020 now gets that year's income times 1.007, 0.942, 0.890, 0.932 or 0.929, instead of 1.009, 1.016, 1.014, 0.765 or 0.721. diff --git a/policyengine_us/tests/core/test_default_uprating_properties.py b/policyengine_us/tests/core/test_default_uprating_properties.py deleted file mode 100644 index 68af965b197..00000000000 --- a/policyengine_us/tests/core/test_default_uprating_properties.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Property tests: dollar inputs that follow the default uprating series. - -Inputs with no series of their own follow -calibration.gov.cbo.income_by_source.adjusted_gross_income_per_capita, total -AGI divided by population. Total AGI is IRS SOI Publication 1304 Table 1.1 -(all returns, adjusted gross income less deficit) for 2015 to 2018 and CBO's -series from 2019. - -1. An input dated year X and read in a later year Y equals the input times - index(Y) / index(X), for any pair of years from 2015 to 2036, and that one - factor applies to every household. Reason: every projected input on the - default series rests on it, and test_simulation_uprating_tracks_national_ - totals checks it only from 2024, for the SOI employment series. -2. The index never moves more than 25% from one year to the next. Reason: a - year with no value holds the year before, so the per-capita index drifts - down with population and jumps when values resume. Missing 2016 to 2018 - made 2018 to 2019 a 31.7% jump; with every year filled, the largest move is - 16.5%, from 2020 to 2021. -""" - -import hypothesis -import pytest -from hypothesis import strategies as st -from policyengine_core.parameters.operations.get_parameter import get_parameter - -from policyengine_us import Simulation -from policyengine_us.system import system - -DEFAULT = "calibration.gov.cbo.income_by_source.adjusted_gross_income_per_capita" -# A dollar input with no formula and no series of its own. -INPUT = "charitable_cash_donations" -FIRST_YEAR = 2015 -LAST_YEAR = 2036 -MAX_ANNUAL_MOVE = 0.25 - - -def _index(year): - return get_parameter(system.parameters, DEFAULT)(f"{year}-01-01") - - -def test_the_input_follows_the_default_series(): - assert system.variables[INPUT].uprating == DEFAULT - - -@hypothesis.settings(max_examples=15, deadline=None, derandomize=True) -@hypothesis.given( - years=st.lists( - st.integers(FIRST_YEAR, LAST_YEAR), min_size=2, max_size=2, unique=True - ).map(sorted), - amounts=st.lists( - st.floats(0, 10_000_000, allow_nan=False, allow_infinity=False), - min_size=1, - max_size=4, - ), -) -def test_an_input_grows_by_the_ratio_of_the_index(years, amounts): - dated, read = years - simulation = Simulation( - situation={ - "people": { - f"person_{i}": {INPUT: {dated: amount}} - for i, amount in enumerate(amounts) - }, - "households": { - f"household_{i}": {"members": [f"person_{i}"]} - for i in range(len(amounts)) - }, - } - ) - factor = _index(read) / _index(dated) - projected = simulation.calculate(INPUT, read) - # Inputs are stored as float32. - assert projected.tolist() == pytest.approx( - [amount * factor for amount in amounts], rel=1e-6, abs=1e-3 - ) - - -@pytest.mark.parametrize("year", range(FIRST_YEAR + 1, LAST_YEAR + 1)) -def test_the_index_moves_less_than_a_quarter_in_a_year(year): - move = _index(year) / _index(year - 1) - 1 - assert abs(move) < MAX_ANNUAL_MOVE, f"{year - 1} to {year}: {move:+.1%}" diff --git a/policyengine_us/tests/core/test_uprating_series.py b/policyengine_us/tests/core/test_uprating_series.py index ce7870a8be0..4cf30aa13b8 100644 --- a/policyengine_us/tests/core/test_uprating_series.py +++ b/policyengine_us/tests/core/test_uprating_series.py @@ -153,10 +153,12 @@ def test_series_is_defined_across_the_projection_window(series): def test_default_series_has_a_value_for_every_year(): """A year with no value holds the previous one, so the per-capita index drifts down with population and then jumps when values resume. CBO AGI - once had nothing for 2016 to 2018, so inputs dated 2018 came out 29% too - high by 2019, and Medicare IRMAA's lookback, which deflates income by the - same index, put a household entered for 2019 alone at 0.765 of its - income instead of 0.932.""" + once held a tax-year 2013 total for 2015 and had nothing for 2016 to + 2018. Inputs on the default series dated 2018 came out 29% too high by + 2019, and Medicare IRMAA's lookback, which deflates income by the same + index, put a household entered for 2019 alone at 0.765 of its income + instead of 0.932. With the right 2015 total, the gap alone gives 14% and + 0.864.""" total = get_parameter(system.parameters, _authored_series(DEFAULT)) years = sorted( { diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml index 3567882899e..51a80ec99ef 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml @@ -106,8 +106,10 @@ employment_income: 500_000 output: # 500,000 x 31,085.28 (2016) / 34,910.24 (2018). Both years are IRS SOI - # totals; with 2016-2018 missing, the index was held at its 2015 value and - # this came to 506,932.37. + # totals. When total AGI had no 2016-2018 values, its 2015 value (tax-year + # 2013 AGI excluding dependent filers) was held, so the index fell with + # population (27,461.49 in 2016, 27,085.95 in 2018) and this came to + # 506,932.37. medicare_irmaa_magi_two_years_prior: 445_217.19 - name: Case 9, 2019 benefit year with only 2019 inputs deflates 2019 income to 2017. @@ -118,9 +120,9 @@ age: 66 employment_income: 500_000 output: - # 500,000 x 33,241.35 (2017, IRS SOI) / 35,672.43 (2019, CBO). With - # 2016-2018 missing, 2017 was held at the 2015 value and this came to - # 382,300.59. + # 500,000 x 33,241.35 (2017, IRS SOI) / 35,672.43 (2019, CBO). When total + # AGI for 2017 held the old 2015 value, the index was 27,275.18 and this + # came to 382,300.59. medicare_irmaa_magi_two_years_prior: 465_924.93 - name: A 2025 household with only 2024 inputs has IRMAA set on deflated 2024 income. diff --git a/policyengine_us/tests/policy/baseline/household/expense/charitable/charitable_cash_donations.yaml b/policyengine_us/tests/policy/baseline/household/expense/charitable/charitable_cash_donations.yaml index 36c2206a1a7..5a967a8396c 100644 --- a/policyengine_us/tests/policy/baseline/household/expense/charitable/charitable_cash_donations.yaml +++ b/policyengine_us/tests/policy/baseline/household/expense/charitable/charitable_cash_donations.yaml @@ -17,8 +17,8 @@ charitable_cash_donations: 2018: 10_000 output: - # 10,000 x 35,672.43 / 34,910.24. When 2016-2018 had no values and 2018 - # held the 2015 value, this came to 13,170.09. + # 10,000 x 35,672.43 / 34,910.24. When total AGI for 2018 held the old 2015 + # value, the index was 27,085.95 and this came to 13,170.09. charitable_cash_donations: 10_218.33 - name: A 2015 donation read in 2018 grows with per-capita AGI within the SOI years. @@ -28,6 +28,7 @@ charitable_cash_donations: 2015: 10_000 output: - # 10,000 x 34,910.24 / 31,312.48. When 2018 held the 2015 value, this - # came to 9,776.72. + # 10,000 x 34,910.24 / 31,312.48. When total AGI held the old 2015 value + # through 2018, the index was 27,704.54 in 2015 and 27,085.95 in 2018, and + # this came to 9,776.72. charitable_cash_donations: 11_148.99 diff --git a/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py index f3729387983..8b80b330d04 100644 --- a/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py +++ b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py @@ -7,10 +7,10 @@ TAX_UNIT_ADDITIONS, ) -# Per-capita CBO AGI: the series that extends a single-year dataset's dollar -# inputs to later years when they have no series of their own -# (DEFAULT_MICRODATA_UPRATING, divided by population). Deflating by it runs -# that projection backward. +# Per-capita total AGI (IRS SOI for 2015-2018, CBO from 2019): the series that +# extends a single-year dataset's dollar inputs to later years when they have +# no series of their own (DEFAULT_MICRODATA_UPRATING, divided by population). +# Deflating by it runs that projection backward. LOOKBACK_DEFLATOR = ( "calibration.gov.cbo.income_by_source.adjusted_gross_income_per_capita" ) From 574fe07eb5166ad3ac4013e357b0c700c27f0203 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:27:41 +0000 Subject: [PATCH 7/7] Say the 2016-only IRMAA lookback lands slightly above income Per-capita AGI fell from 2015 to 2016, so for a household entered for 2016 alone the lookback multiplies income by about 1.007. "Deflated" read as a reduction; the docstring now says the result is slightly more than that year's income, and why. Co-Authored-By: Claude Opus 5.5 --- .../eligibility/medicare_irmaa_magi_two_years_prior.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py index 8b80b330d04..e3ac8b03010 100644 --- a/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py +++ b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py @@ -62,8 +62,9 @@ class medicare_irmaa_magi_two_years_prior(Variable): "Total AGI in that index is IRS Statistics of Income actuals for " "2015 to 2018 and CBO's series from 2019. The index begins in 2015 " "and is held at its 2015 value before then, so a household entered " - "for 2016 alone gets that year's income deflated by per-capita AGI " - "growth from 2015 to 2016 only. " + "for 2016 alone gets that year's income times the index for 2015 " + "over the index for 2016, slightly more than that income because " + "per-capita AGI fell from 2015 to 2016. " "When inputs reach back to 2013 or 2014 and the year two years prior " "is one of those years, its income cannot be computed: only income " "provided for it counts, and the rest is zero."