Skip to content

Key IRS SOI AGI-by-size calibration amounts to tax year 2020 and fix SOI citations - #9604

Merged
MaxGhenis merged 3 commits into
mainfrom
fix-soi-agi-year-keying
Oct 6, 2026
Merged

MaxGhenis merged 3 commits into
mainfrom
fix-soi-agi-year-keying

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Rekeys the 18 amounts in calibration/gov/irs/soi/agi/total_agi.yaml from 2015-01-01 to 2020-01-01. They are IRS SOI Table 1.1 values for tax year 2020, but keyed at 2015 they were uprated by calibration.gov.cbo.income_by_source.adjusted_gross_income, which put the 2020 amounts 40.03% above the published table, and every later year 40.03% above the value uprated from the published 2020 base. 40.03% is the index's 2020/2015 ratio, 12.650T / 9.034T. Its 2015 value is sourced to the Tax Foundation, its 2019–2022 values to the historical years of CBO's revenue-projection workbooks, and its 2023+ values to CBO's February 2026 projections.
  • Fixes both files' citations. total_agi cited "IRS SOI PUF 2015 (20in11.xls)" with no href, although the values aren't from the PUF, aren't for 2015, and the file is 20in11si.xls. number_of_returns had the right href and year key (TY2021, 21in11si.xls), but its title named 20in11si.xls.
  • Adds tests that pin each scale to the tax year of its table, check that uprating starts from that year, and check that earlier years still evaluate.

Builds on #9599 (merged as squash 38d6775), which removed the duplicated $10,000,000 or more bracket from total_agi.yaml. main is merged into this branch, so the diff is this PR's own change. Its diff against main is byte-identical to the reviewed commit's (a8b1884..52e3c67). #9599's final test_parameter_scale_thresholds.py passes with this PR's split keying (thresholds at 2015-01-01, amounts at 2020-01-01).

Which SOI year each file holds

I downloaded 20in11si.xls ("Table 1.1 ... Tax Year 2020 (Filing Year 2021)") and 21in11si.xls ("... Tax Year 2021 (Filing Year 2022)"). I compared every bracket against the "Size of adjusted gross income" rows from "$1 under $5,000" through "$10,000,000 or more", using exact integer equality (SOI money amounts are in thousands):

File Column TY2020 TY2021 Keyed at (after this PR)
total_agi.yaml 3, "Adjusted gross income less deficit" 18/18 0/18 2020-01-01
number_of_returns.yaml 1, "Number of returns" 0/18 18/18 2021-01-01 (unchanged)

Both files are linked from the IRS page SOI Tax Stats - Individual Statistical Tables by Size of Adjusted Gross Income, which the new comment in total_agi.yaml cites, as number_of_returns.yaml already did.

Instants before 2020

The thresholds stay keyed at 2015-01-01, as in number_of_returns.yaml, where the amounts start in 2021. I read the two steps that decide what the scale returns before 2020:

  • uprate_parameter (policyengine-core 3.30.2, the version in uv.lock) grows values only forward, from the parameter's latest dated value. It raises if the uprating parameter is undefined at that instant; the AGI index has a 2020-01-01 value.
  • CountryTaxBenefitSystem.__init__ then calls backdate_parameters(self.parameters, first_instant="2015-01-01") (policyengine_us/system.py), which extends each parameter's earliest value back to 2015-01-01 at that same value.

So 2015–2019 return the published TY2020 values, the same treatment number_of_returns already gets for 2015–2020. Before 2015 there are no thresholds, so the scale is empty, as before this PR. Loaded values of the "$1 under $5,000" bracket:

Instant Before After
2014-01-01 empty scale empty scale
2015-01-01 24,087,842,000 24,087,842,000
2019-01-01 31,895,491,397 24,087,842,000
2020-01-01 33,729,975,437 24,087,842,000 (published TY2020)
2021-01-01 39,439,644,359 28,165,330,972 (×1.16928, the index's 2021/2020 ratio)
2025-01-01 47,562,998,328 33,966,523,068

Every bracket moves by the same ratio. SOI publishes Table 1.1 for 2015–2019 too, so per-year values could replace the flat backdate later; that is outside this fix.

Reforms to these scales (pre-existing, not changed here). CountryTaxBenefitSystem.__init__ applies reforms before backdate_parameters. For any parameter whose first value falls after 2015, that ordering lets a reform starting at the first value's date carry back to 2015. A reform bounded to an earlier year can also leave a gap before the first value. number_of_returns already behaves this way on main. A reform setting [0].amount for year:2021:1 also sets 2015–2020, and one setting [2].amount for year:2019:1 leaves 2020 with 17 brackets. This PR adds total_agi to the parameters affected. Nothing reforms these calibration targets, and fixing the ordering would change reform semantics across the repo (see the #9075 comment in system.py), so it is fixed separately in #9628 (draft, "Backdate parameters before applying reforms").

Consumers

No code reads calibration.gov.irs.soi.agi.*, so this changes no model output. I re-checked on 2026-09-24:

  • policyengine-us (upstream/main 60a4805): no variable, formula, uprating path or MICRODATA_UPRATING_OVERRIDES entry references soi.agi. The tests that take the whole calibration.gov.irs.soi node (test_uprating_extensions.py, test_per_capita_uprating.py, contrib/ssa/test_trustees_core_thresholds.py) name other SOI parameters explicitly.
  • microcosm (main f338ae066): the pe-us adapter treats calibration.gov.irs.soi.* as national totals, but only for variables' uprating paths and the pe-us overrides, and neither names soi.agi. Its irs_soi/agi calibration target is its own registry entry.
  • policyengine-us-data (archived; main 42ed5d45c5): loss.py's SOI aggregate targets read only soi.long_term_capital_gains.
  • policyengine.py, policyengine-api, policyengine-api-v2, chronicle: no references. Chronicle's total_agi fields come from its own SOI ETL.
  • GitHub code search, org:PolicyEngine and org:TheAxiomFoundation, for "soi.agi", "agi.total_agi", "agi.number_of_returns", "soi/agi" and "irs.soi" agi: the only hit that names these parameters is an inventory CSV, ai-inequality/public/data/uprating_analysis.csv. The other hits are unrelated identifiers (us.soi.agi.v1 in calibration-diagnostics, irs_soi.agi in chronicle) or other SOI parameters.

axiom: n/a: calibration target data (IRS SOI statistics), not a policy rule.

Tests

  • policyengine_us/tests/test_parameter_files.py::test_soi_agi_bands_are_keyed_to_their_table_1_1_tax_year reads each YAML and checks that every one of the 18 brackets has an amount keyed to its table's tax year. It also checks the lowest and highest bracket against the published cells, and that the reference href is that year's in11si.xls with the tax year named in the title.
  • policyengine_us/tests/core/test_soi_agi_band_keying.py loads the system and checks three things for each scale. The amounts at the table's tax year must equal the YAML values for that date, un-uprated. The amounts five years after the latest dated value must equal that value times the uprating index's growth. And every year from 2015 up to the first dated value must return the scale at that first date. The tests select values by date, so adding another SOI year later won't break them.

Locally:

  • uv run pytest policyengine_us/tests/test_parameter_scale_thresholds.py policyengine_us/tests/test_parameter_files.py policyengine_us/tests/test_system_import.py policyengine_us/tests/core/test_soi_agi_band_keying.py: 22 passed.
  • With the two YAML files reverted to their pre-PR state, the new tests fail 2 of 7: the YAML keying check and total_agi's tax-year check, because the old file has no 2020 amounts. The other 5 pass on both trees. Those are the three number_of_returns cases, whose keying was already right, and two total_agi cases. The uprating check compares growth ratios, so it holds under either key. The pre-first-value check has no years to test when the first value is in 2015.
  • make format: no changes; ruff check: clean.

🤖 Generated with Claude Code

…esholds

The 2024 Arkansas low-income tax table for head of household and surviving
spouse filers with two or more dependents keyed its $92 row at $24,200, the
threshold of the $104 row. ParameterScale._get_at_instant passes each bracket
to add_bracket, which sums the amounts of equal thresholds, so the 2024 scale
became [(0, 0), (24200, 196), (24300, 116), ...]: AGI of $24,177-$24,200 got
$0 instead of $92 and $24,201-$24,300 got $196 instead of $104.

The official table (2024 AR1000F/AR1000NR instructions, PDF page 28) reads
$0-$24,176 -> $0 and $24,177-$24,200 -> $92. The tables are evaluated with
right=True, so the threshold is the upper bound of the $0 row: 24_176. The
2024 reference anchor now points at PDF page 28.

calibration/gov/irs/soi/agi/total_agi repeated its $10,000,000-or-more
bracket, which doubled the top bucket when loaded. Its 18 values match IRS
SOI Table 1.1 for tax year 2020 (20in11si.xls, column 3), where the top
bucket appears once. No code reads this parameter.

Add YAML tests for AGI of $24,100-$24,401 for both filing statuses and a
repo-level test that fails when any parameter scale has two brackets with
the same finite threshold at any instant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
calibration.gov.irs.soi.agi.total_agi holds IRS SOI Table 1.1 values for
tax year 2020 (20in11si.xls, column 3), matching all 18 brackets, but
keyed them at 2015-01-01. uprate_parameter grows values forward from the
latest dated value, so uprating by
calibration.gov.cbo.income_by_source.adjusted_gross_income put the 2020
amounts 40.03% above the published table (the index's 2020/2015 ratio)
and every later year 40.03% above its value uprated from the published
2020 base. Rekey the amounts to 2020-01-01; thresholds stay at
2015-01-01, and backdate_parameters extends the 2020 values back to 2015
as it already does for number_of_returns.

Correct both citations: total_agi cited "IRS SOI PUF 2015 (20in11.xls)"
with no href; number_of_returns matches tax year 2021 (21in11si.xls) on
all 18 brackets and its href was right, but its title named 20in11si.xls.

Add tests pinning each scale's amounts at its table's tax year, checking
that uprating runs from the latest dated value, and that 2015 up to the
first dated value still evaluates. They select values by date, so a
later SOI year can be added without rewriting them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis merged commit 0865b61 into main Oct 6, 2026
35 checks passed
@MaxGhenis
MaxGhenis deleted the fix-soi-agi-year-keying branch October 6, 2026 02:45
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Merged by the US + core hub on its merge gates. Audit:

  • Head: 21a770565add2fb9edbb23d750586469367faebc.
  • Independent review: Opus 5.5 on Subfleet, ~/reviews/us-hub/9604/review-r1.md. APPROVE at this head with no P1 or P2 findings; five P3 notes, none blocking.
    • The reviewer traced uprate_parameter (core 3.32.15) and backdate_parameters and recomputed the body's uprating arithmetic.
    • Its consistency checks fit TY2020 for total_agi (brackets sum to about $12.87T) and TY2021 for number_of_returns.
    • It was a static review. The new keying tests ran in CI at this head.
  • CI: every check passed at this head.
  • Impact: the body shows that no code reads calibration.gov.irs.soi.agi.*, in PE-US or in Microcosm, so no model output changes. The fix corrects the calibration target values, which were 40.03% too high for 2020 onward.
  • Decisions: none. P3 follow-ups: pre-2020 values are flat at the TY2020 level, and the reform-before-backdate order is tracked in Backdate parameters before applying reforms #9628.

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