Repository navigation
SOI and CBO wage targets bind gross employment_income; the engine taxes irs_employment_income #1191
Description
Summary
The SOI wages-and-salaries rows and the CBO wages projection calibrate gross employment_income. Both are tax-return wage series: the TY2022 SOI guide gives A00200 as Form 1040 line 1z, the feed identifies the CBO row as a tax-return projection, and W-2 box 1 leaves out traditional 401(k) and 403(b) elective deferrals. policyengine-us 2.2.1 computes the wage it taxes, irs_employment_income, as max_(0, employment_income - pre_tax_contributions). So calibration compares gross wages with tax-return wage totals, and the engine then takes the pre-tax amount off before the wage reaches AGI.
On the certified default (populace-us-2024-spm-20260915), at its own weights and under its certified engine:
| 2024 | $B |
|---|---|
employment_income, the variable the rows bind |
10,736.3 |
irs_employment_income, the wage the engine taxes |
10,451.2 |
| Difference | 285.1 |
| SOI TY2023 Table 1.4 total wages, aged to 2024 | 10,773.4 |
| CBO TY2024 wages and salaries projection | 10,832.7 |
| BEA NIPA CY2024 wages and salaries | 12,387.9 |
Taxed wages are 3.0% below the SOI TY2023 row and 3.5% below the CBO row. Gross wages are 0.3% and 0.9% below them.
Proposal, for Max's ruling. Nothing is changed here. Bind the SOI wage rows (amount and return count, national and state) and the CBO wages row to irs_employment_income, and leave the BEA NIPA row on gross employment_income_before_lsr. The archived us-data pipeline used these bindings, with a filer mask on both tax-return sources. The rebind is conditional on one audit: since #1033 the PUF channel may store wages that already exclude deferrals, and if so those wages need grossing up in the same build.
What the code does
Read at main aca40a692 with the locked engine (uv sync --all-packages --extra us gives policyengine-us 2.2.1 and policyengine-core 3.32.5).
packages/microcosm-build/src/microcosm/build/us_runtime/fiscal_targets.py:SOI_AMOUNT_MEASURE_VARIABLES["wages_salaries_amount"]andSOI_RETURN_MEASURE_VARIABLES["wages_salaries_returns"]are"employment_income"(lines 148 and 231), andSOI_VARIABLE_MAP["employment_income"]is"employment_income"(line 261).DIRECT_LEDGER_TARGETS[("cbo", "projected_amount", "wages_and_salaries")]binds"employment_income"(line 367)._BEA_WAGES_BASE_VARIABLEis"employment_income_before_lsr"(line 3229).
- policyengine-us 2.2.1:
irs_employment_incomeismax_(0, employment_income - pre_tax_contributions).pre_tax_contributionsadds the listgov.irs.gross_income.pre_tax_contributions:traditional_401k_contributions,traditional_403b_contributions,pre_tax_health_insurance_premiums,health_savings_account_payroll_contributions.gov.irs.gross_income.sourceslistsirs_employment_income, so it is the wage that enters AGI.employment_incomeaddsemployment_income_before_lsrandemployment_income_behavioral_response.- policyengine-us 1.764.6, the engine the Build P solve ran on, has the same formula and the same list.
tools/build_us_fiscal_refresh_release.py::_base_simulation_household_columnsbuilds the SOI wage rows by calculating the variableSOI_VARIABLE_MAPnames, summing persons to tax units, masking by AGI band, filing status and geography, and taking the positive part (_signed_component). The masks have no filer condition:tax_unit_is_fileris absent from the tool and from its history on main.
SOI rows already bind formula-owned variables through this path: adjusted_gross_income, taxable_income, income_tax, and since #517 (the target-side leg of #511) deductible_mortgage_interest_tax_unit, which replaced a gross input to close a concept gap. A row on irs_employment_income uses the same generic code. The SOI concept and display names stay employment_income; only the engine variable behind them changes. "Formula-owned" is otherwise an export rule (formula_owned_export_gate): such a variable may not be stored as an input column, and this proposal stores nothing new.
How the three wage sources coexist
Compiled at main from the pinned feed (consumer_facts_us_c5e5bf8.jsonl, sha256 b8543739…) with the packaged district crosswalk, target period 2024, aging on: 108 of 32,841 targets bind wages. The certified release's diagnostics hold the same 108 among 5,659. Every one is an all-AGI, all-filing-status row and none is at congressional-district level.
| Row | Source period and value | 2024 target | Variable | Summed over |
|---|---|---|---|---|
| BEA NIPA wages and salaries (A034RC) | CY2024 | $12,387.9B | employment_income_before_lsr |
all persons |
| CBO revenue projection, wages and salaries | TY2024 | $10,832.7B | employment_income |
all persons |
| SOI Table 1.4 total wages | TY2023, $10,204.1B, aged ×1.0558 | $10,773.4B | employment_income |
all tax units |
| SOI Historic Table 2 US salaries and wages | TY2022, $9,708.9B, aged ×1.1062 | $10,740.1B | employment_income |
all tax units |
| SOI Historic Table 2 state salaries and wages, 51 rows | TY2022, aged | $10,654.8B in sum | employment_income |
all tax units in the state |
| SOI returns with wages: 2 national rows, 51 state rows | TY2023, TY2022 | 128.59M and 127.71M; states sum to 127.03M | employment_income > 0 |
all tax units |
Aging runs the SOI wage rows on the CBO wages series, chained through SOI Table 1.4 for TY2022 (target_aging.py).
With no behavioral response and no negative wages, the four national dollar rows and the sum of the 51 state rows are one weighted sum: every person is in a tax unit and neither path masks filers. Both conditions hold on the release (the response sums to $0 and no stored wage is negative). The rows then ask that one number to be between $10.65T and $10.83T, and also $12.39T. In a baseline simulation they conflict by construction.
The certified release's diagnostics (calibration_diagnostics.json, sha256 870449b4…, the Build P solve it inherits) record the result, in $B:
| Row | Target | Initial | Final | Error |
|---|---|---|---|---|
| BEA NIPA wages | 12,387.9 | 12,909.6 | 10,736.3 | −13.33% |
| CBO wages | 10,832.7 | 12,909.6 | 10,736.3 | −0.89% |
| SOI TY2023 wages | 10,773.4 | 12,909.6 | 10,736.3 | −0.34% |
| SOI TY2022 wages | 10,740.1 | 12,909.6 | 10,736.3 | −0.04% |
| SOI state wages, sum of 51 | 10,654.8 | 12,909.6 | 10,736.3 | +0.77% |
All five lines share one initial and one final estimate. The NIPA miss, $1,651.6B, is the largest national dollar miss in the release.
The loss is a weighted mean of capped absolute misses, each divided by a target-based scale (microcosm.calibrate.solve). Recomputing the row weights over all 5,659 certified targets with the formula id the diagnostics record (sqrt_value_concept_budget_weighted_mape_50_50_amount_count_target_scale_cap_100pct, no family multipliers) gives 36.6 for the NIPA row and 313.3 for the tax-return dollar rows together (CBO 34.2, SOI national 34.1 and 34.1, 51 state rows 210.8). Those sums do not by themselves determine where the estimate lands: the marginal penalty is weight over scale, each state row binds its own subset, and every other row, mass conservation and the weight cap also act. The recorded outcome is that the solve landed on the tax-return rows.
Stated rationale
Neither docs/us-fact-to-target.md nor docs/calibration-target-hierarchy.md explains the wage-variable mapping. The first mentions W-2 wages only in its reform-probe guidance, and nothing else under docs/, README.md or DESIGN.md discusses the wage rows. docs/us-fact-to-target.md does say whose decision this is:
Ledger stores what a source asserted, with provenance and a
concept_alignmentclaim about the matching PolicyEngine-US concept. Everything operative — which variable a fact binds, in what mode, over which universe, whether it binds at all — is a consumer decision and lives here.
What is stated elsewhere:
- The feed. The SOI wage facts carry
relation: broad_matchtous:statutes/26/62#input.wagesoverall_individual_income_tax_returns; the Historic Table 2 fact's note says Ledger treats the measure "as a broad match to the wage input of IRC section 62 adjusted gross income". The CBO fact's note reads "This is a CBO tax-return projection row, not a general NIPA wages target." The NIPA fact's universe iscompensation_of_employees, entity person. - The NIPA row.
_bea_reference_from_fact's docstring calls it "gross wages and salaries for all workers, including nonfilers", and Chesterton's-fence audit: wire NIPA wages/proprietors, net worth, LIHEAP; fence every exclusion #405 wired it because us-data "deliberately targeted NIPA totals to capture wages that never reach tax returns". - us-data (archived,
42ed5d45), the lineage Chesterton's-fence audit: wire NIPA wages/proprietors, net worth, LIHEAP; fence every exclusion #405 follows:utils/soi.py::pe_to_soisetsdf["employment_income"] = pe("irs_employment_income"), andutils/loss.py::build_loss_matrixmasks every SOI row byis_tax_filer.CBO_INCOME_BY_SOURCE_TARGETSmaps("irs_employment_income", "employment_income")through_calculate_filer_target_values.BEA_NIPA_DIRECT_SUM_TARGETSputs NIPA wages onemployment_income_before_lsrover all households.- The precedent covers variables and universes only. us-data weighted the rows differently, giving the BEA wage rows a loss weight of 1,000 against a default of 1, and it uprated the SOI wage row with the gross
employment_income_before_lsrindex.
- This repo. The release builder has bound the SOI wage row to
employment_incomesince it was added (b8a0ef1a2, 2026-06-15). Use Ledger-backed fiscal target references #65 (0c38bc47d, 2026-06-16) carried the SOI and CBO wage mappings into the Ledger compiler, and its description gives no reason for the variable. - The sources. The SOI Historic Table 2 documentation guide for TY2022 gives A00200, "Salaries and wages amount", as
1040:1z. The IRS instructions for Form W-2 exclude traditional 401(k) and 403(b) elective deferrals from box 1.
Microcosm keeps us-data's gross NIPA binding, and binds the two tax-return sources to gross wages with no filer mask.
Measured on the certified release
populace_us_2024.h5, sha256 6496cc43…, the latest.json default, loaded with the same dataset class and county-SPM setting as the release's reform-validation factory. policyengine-us 2.2.1, period 2024, the release's weights. No release was built and nothing was recalibrated.
Three independent computations agree on the gross total: this script through the engine ($10,736,284,124,344), the stored employment_income_before_lsr column times household weight read straight from the file ($10,736,284,142,568), and the Build P diagnostics' final estimate for the wage rows ($10,736,284,148,600).
| $B | |
|---|---|
employment_income |
10,736.284 |
employment_income_before_lsr |
10,736.284 |
irs_employment_income |
10,451.169 |
pre_tax_contributions |
285.893 |
of which traditional_401k_contributions |
285.893 |
| of which 403(b), pre-tax health premiums, HSA payroll (none stored in the file) | 0 each |
| Gross minus taxed wages | 285.115 |
| Lost to the zero floor (contributions above wages) | 0.778 |
- 53.8M people have a pre-tax contribution, all of them among the 167.0M wage earners (32.2%).
- The file stores $287.8B of desired traditional 401(k) contributions; the engine's elective-deferral limit leaves $285.9B.
- The gap is 2.66% of gross wages nationally and between 1.69% and 3.98% across the 51 states (median 2.55%).
- Tax units with positive wages: 127.015M on the gross definition and 126.920M on the taxed one, so the return-count rows move by 95,000.
- With the SOI and CBO rows on
irs_employment_income, their estimate at these weights is $10,451.2B: 2.99% below SOI TY2023, 2.69% below SOI TY2022 and 3.52% below CBO.
Where the wage dollars sit, in $B:
| Person group | Gross | Taxed |
|---|---|---|
| Non-dependents in filer units | 10,595.5 | 10,311.4 |
| Dependents in filer units | 134.5 | 133.5 |
| Non-dependents in non-filer units | 6.0 | 6.0 |
| Dependents in non-filer units | 0.3 | 0.3 |
Effect on the AGI and income-tax rows
Fixed-weight counterfactual on the same file: neutralize pre_tax_contributions, which removes the subtraction from irs_employment_income. The changes include the downstream engine effects of the higher taxed wage, such as Social Security taxability, so they are not the wage component alone. Each line is the statistic the release tool computes for that SOI row. In $B:
| Row | Release | Without the subtraction | Change | 2024 target |
|---|---|---|---|---|
| AGI | 16,683.1 | 16,968.1 | +284.9 (+1.71%) | 16,619.2 (SOI TY2023, aged); 16,685.9 (CBO) |
| Taxable income | 12,956.9 | 13,240.7 | +283.8 (+2.19%) | 12,986.2 (SOI TY2023, aged) |
| Income tax before credits | 2,410.3 | 2,472.4 | +62.1 (+2.58%) | 2,524.7 (SOI TY2022, aged) |
| Income tax, positive part | 2,366.4 | 2,428.7 | +62.3 (+2.63%) | 2,358.7 (SOI TY2022, aged) |
At fixed weights the subtraction is worth $284.9B of AGI and $62.3B of the income-tax row ($63.1B of signed net income tax).
The release fits AGI (+0.4% against SOI) and the income-tax row (+0.3%) with a smaller wage component than SOI's. Its AGI holds $10,317.3B of wages (taxed wages of non-dependents) and $6,365.8B of everything else net of adjustments. SOI TY2023 aged to 2024 has $10,773.4B of wages and $5,845.8B of everything else. The model's wage component is $456.0B lower and the remainder $520.0B higher. The $456.0B is $284.1B of pre-tax subtraction from non-dependents' wages, $134.8B of dependents' gross wages and a $37.1B miss on the gross wage row. This compares aggregates: SOI's totals include dependents' own returns, so it is not a matched decomposition, and it does not show which income the solve leaned on.
What a recalibration would do was not measured. At these weights the three national tax-return targets exceed taxed wages by $289B to $382B (2.8% to 3.7%). How a solve would split that between raising wages and missing rows, and what it would do to the AGI and income-tax fits, needs a build.
Options
- Bind the tax-return wage rows to
irs_employment_income, with the source correction the wage audit calls for (recommended, conditional on that audit).SOI_VARIABLE_MAP["employment_income"]and the CBODIRECT_LEDGER_TARGETSentry change; the NIPA row stays. This matches the feed's stated concept, the wage the engine puts in AGI, and us-data's variable bindings. - Keep gross, and record the difference as a reviewed concept gap. At these weights taxed wages are $285.1B below gross, and $288.9B to $381.5B below the national tax-return targets.
- Option 1 plus a filer mask on the SOI and CBO rows, us-data's universe. Non-filer units hold $6.3B of wages on this release, so it moves the wage rows by 0.06%. It would touch every SOI row, so it is a separate question.
Things the ruling should weigh:
- PUF-channel wages may already exclude deferrals. The engine reads
employment_incomeas gross of deferrals. Thepuf_tax_detailstage readspuf_2024.h5(irs-soi-puf 1.8.0), whose archived generator setsemployment_income = E00200, the tax-return wage, and imputes pre-tax contributions separately. The support loader aliases that toemployment_income_before_lsr, which the spec lists as aprimary_puf_qrftarget for the PUF clone. No step that adds deferrals back appears in the PUF support, retirement-contribution or stacked-spine modules.- The certified release does not show this. It predates Stop the US PUF imputation from predicting income from itself (#982) #1033, and its PUF-clone wages track the same source person's ASEC wage: for the 30,488 persons on both channels the correlation is 0.9999, and where both wages are positive the ratio has median 1.000 and 5th and 95th percentiles 0.997 and 1.002. That is the self-prediction US PUF imputation predicts each income item from the recipient's own survey value, so the PUF clone half reproduces ASEC income #982 describes. ASEC asks for earnings "before deductions", so that file's wages sit at the survey level on both channels.
- Since Stop the US PUF imputation from predicting income from itself (#982) #1033 the QRF conditions on income rank and an earnings flag, never on wage levels. A build from main should therefore store donor draws of the tax-return wage on the PUF channel and then subtract imputed deferrals from them. This has not been measured: no file built after Stop the US PUF imputation from predicting income from itself (#982) #1033 was available locally.
- The PUF channel holds 68% of wages and $208.4B of the $287.8B of stored deferrals on the certified release. If the audit confirms the reading, the rebind needs a gross-up of PUF-channel wages in the same build. A rebind alone would leave the solve to cover the second subtraction through weights.
- The NIPA residual after a rebind is unmeasured. Under option 1 the gross and taxed wage rows differ only by the pre-tax subtraction, $285.1B at these weights; non-filer wages stay in both rows unless a filer mask is added. The NIPA and tax-return targets are about $1.6T apart. If the SOI TY2023 row were met on taxed wages and the subtraction kept its share of wages, gross wages would be $11.07T, 10.7% under NIPA where the release is 13.3% under. That is arithmetic under those two assumptions, and under them the NIPA gap remains. Whether a new solve can fit both rows, and with what contribution total, needs a build.
- Gross wages would depend on the pre-tax imputation. If a taxed-wage row is met, gross wages are that total plus the subtraction, so the contribution totals need an anchor. The feed carries a W-2 box 12 code D elective-deferral amount of $277.9B, a reviewed exclusion today. It is labelled TY2023 but its source file is the TY2020 Table 4.B (
20in04w2all.xlsx), so the label needs fixing before it anchors anything. The release has $285.9B of traditional 401(k) contributions at 2024. - Pre-tax health premiums are a pending change. Produce ESI employer premiums scaled to BEA B4923C from restored ASEC policyholder fields (#454) #1189 at
6f727eacwritespre_tax_health_insurance_premiums: $181.1B on the default pool before calibration, by its receipts. Review comments there (2026-10-10) find the column overlaps the other premium inputs, which the engine treats as disjoint (Document disjoint health premium inputs and correct consumer treatment policyengine-us#10046, draft), and ask for a producer-side fix, so the column may land later than the rest of that PR. When it lands, the same formula subtracts it. Its size at release weights is unmeasured and it is in no number above.
What a change would carry
Invariants, for every person with nonnegative wages and nonnegative pre-tax contributions, and so for every nonnegative weight vector:
0 ≤ irs_employment_income ≤ employment_income, so a taxed-wage row never exceeds the gross row.employment_income − irs_employment_income = min(pre_tax_contributions, employment_income).- The engine variable behind the SOI and CBO wage rows is a member of
gov.irs.gross_income.sourcesin the locked engine. That is the property this issue is about, checked against the engine so a later engine or mapping change cannot reopen the gap unnoticed.
Tests: property tests for the first two on generated frames; an engine-contract test for the third; registry tests that assert the SOI amount, SOI count and CBO bindings and fail without the wiring, beside the existing NIPA binding test; a regenerated target-parity manifest and the registry version hash logged in the release notes; and, before the first build that carries the rebind, a source-to-engine audit of the PUF-channel wage and its deferrals on the base frame. The release fit gate on Table 1.4 dollar rows allows 25% (US_SOI_TABLE_1_4_NATIONAL_DOLLAR_FIT_REQUIREMENT), so it would not catch a concept gap of this size; the binding tests are what hold it.
Reach: the ACS local-area chain builds its SOI rows through the same function (tools/build_us_acs_local_release.py calls the national tool's _materialize_target_frame), so one mapping change moves the wage rows of both release chains at their next build.
Adjacent findings, not proposed here
- SOI rows have no filer mask. They apply their AGI, filing-status and geography filters to all tax units. On this release 158.0M of 162.7M weighted tax units file, and the non-filers hold $6.3B of wages.
- Dependents' wages are in the wage rows and outside AGI.
irs_gross_incomezeroes a dependent's sources. Dependents hold $134.8B of gross wages and $133.8B of taxed wages on this release, counted in the SOI wage row and not in the unit's AGI. Bindingirs_employment_incomedoes not change that; us-data's mapping had the same property.
Reproduce
uv sync --all-packages --extra us
uv run python measure_wage_concept.py path/to/populace_us_2024.h5 out.json
uv run python raw_h5_checks.py path/to/populace_us_2024.h5measure_wage_concept.py
"""Measure the SOI-wage concept gap on a certified US release H5.
Usage: uv run python measure_wage_concept.py <populace_us_2024.h5> <out.json>
Runs the locked policyengine-us on the release exactly as the release's own
reform-validation factory does (USSingleYearDataset + county SPM selection),
then reports weighted wage concepts, their filer/dependent split, and a
fixed-weight counterfactual that removes the engine's pre-tax subtraction.
Nothing here recalibrates: every figure is at the release's own weights.
"""
import hashlib
import importlib.metadata as md
import json
import sys
import numpy as np
from policyengine_core.reforms import Reform
from policyengine_us import Microsimulation
from policyengine_us.data import USSingleYearDataset
H5, OUT = sys.argv[1], sys.argv[2]
PERIOD = 2024
SPM = {"geography_kind": "county"}
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def arr(sim, variable, map_to=None):
kwargs = {} if map_to is None else {"map_to": map_to}
return np.asarray(sim.calculate(variable, PERIOD, **kwargs), dtype=np.float64)
class NeutralizePreTaxContributions(Reform):
def apply(self):
self.neutralize_variable("pre_tax_contributions")
def simulate(reform=None):
dataset = USSingleYearDataset(file_path=H5)
if reform is None:
return Microsimulation(dataset=dataset, spm=SPM)
return Microsimulation(dataset=dataset, reform=reform, spm=SPM)
PERSON_VARS = [
"employment_income",
"employment_income_before_lsr",
"employment_income_behavioral_response",
"irs_employment_income",
"pre_tax_contributions",
"traditional_401k_contributions",
"traditional_403b_contributions",
"pre_tax_health_insurance_premiums",
"health_savings_account_payroll_contributions",
"payroll_tax_gross_wages",
]
TAX_UNIT_VARS = [
"adjusted_gross_income",
"taxable_income",
"income_tax_before_credits",
"income_tax",
]
def soi_row(variable, values):
"""A tax-unit SOI target row as the release tool materializes it.
tools/build_us_fiscal_refresh_release.py::_signed_component keeps AGI
signed and sums the positive part of every other non-loss concept.
"""
if variable == "adjusted_gross_income":
return values
return np.maximum(values, 0.0)
out = {
"h5_sha256": sha256(H5),
"period": PERIOD,
"policyengine_us": md.version("policyengine-us"),
"policyengine_core": md.version("policyengine-core"),
"units": "USD (weighted sums) unless a key says count",
}
base = simulate()
pw = arr(base, "person_weight")
tw = arr(base, "tax_unit_weight")
hw = arr(base, "household_weight")
p = {v: arr(base, v) for v in PERSON_VARS}
t = {v: arr(base, v) for v in TAX_UNIT_VARS}
dependent = arr(base, "is_tax_unit_dependent") > 0
filer_tu = arr(base, "tax_unit_is_filer") > 0
filer_p = arr(base, "tax_unit_is_filer", map_to="person") > 0
out["n"] = {
"persons": int(pw.size),
"tax_units": int(tw.size),
"households": int(hw.size),
"weighted_persons": float(pw.sum()),
"weighted_tax_units": float(tw.sum()),
"weighted_filer_tax_units": float(tw[filer_tu].sum()),
}
out["person_totals"] = {v: float((pw * p[v]).sum()) for v in PERSON_VARS}
# The calibration weights households. Reproduce the release's own wage-target
# estimate: household weight times the household's summed person wages.
hh_wages = arr(base, "employment_income", map_to="household")
out["household_weighted_employment_income"] = float((hw * hh_wages).sum())
gross = p["employment_income"]
taxed = p["irs_employment_income"]
pretax = p["pre_tax_contributions"]
out["gap"] = {
"gross_minus_taxed": float((pw * (gross - taxed)).sum()),
"pre_tax_contributions": float((pw * pretax).sum()),
"floored_at_zero": float((pw * np.maximum(pretax - gross, 0.0)).sum()),
"persons_with_wages_count": float(pw[gross > 0].sum()),
"persons_with_pre_tax_contributions_count": float(pw[pretax > 0].sum()),
"persons_with_wages_but_zero_taxed_wages_count": float(
pw[(gross > 0) & (taxed <= 0)].sum()
),
}
split = {}
for label, mask in {
"filer_nondependent": filer_p & ~dependent,
"filer_dependent": filer_p & dependent,
"nonfiler_nondependent": ~filer_p & ~dependent,
"nonfiler_dependent": ~filer_p & dependent,
}.items():
split[label] = {
"weighted_persons_count": float(pw[mask].sum()),
"employment_income": float((pw * gross)[mask].sum()),
"irs_employment_income": float((pw * taxed)[mask].sum()),
"pre_tax_contributions": float((pw * pretax)[mask].sum()),
}
out["person_split"] = split
# Return-count concept: tax units with positive summed wages, each definition.
tu_gross = arr(base, "employment_income", map_to="tax_unit")
tu_taxed = arr(base, "irs_employment_income", map_to="tax_unit")
out["tax_units_with_wages_count"] = {
"employment_income_all": float(tw[tu_gross > 0].sum()),
"irs_employment_income_all": float(tw[tu_taxed > 0].sum()),
"employment_income_filers": float(tw[(tu_gross > 0) & filer_tu].sum()),
"irs_employment_income_filers": float(tw[(tu_taxed > 0) & filer_tu].sum()),
}
out["tax_unit_totals"] = {
v: {
"all": float((tw * t[v]).sum()),
"filers": float((tw * t[v])[filer_tu].sum()),
"soi_row": float((tw * soi_row(v, t[v])).sum()),
}
for v in TAX_UNIT_VARS
}
out["wage_soi_rows"] = {
"employment_income": float((tw * np.maximum(tu_gross, 0.0)).sum()),
"irs_employment_income": float((tw * np.maximum(tu_taxed, 0.0)).sum()),
"irs_employment_income_filers": float(
(tw * np.maximum(tu_taxed, 0.0))[filer_tu].sum()
),
}
state = np.asarray(base.calculate("state_fips", PERIOD, map_to="person"))
ratios = []
for fips in np.unique(state):
m = state == fips
g = float((pw * gross)[m].sum())
if g > 0:
ratios.append(float((pw * (gross - taxed))[m].sum()) / g)
ratios = np.asarray(ratios)
out["state_gap_share_of_gross"] = {
"n_states": int(ratios.size),
"min": float(ratios.min()),
"median": float(np.median(ratios)),
"max": float(ratios.max()),
}
del base
reform = simulate(NeutralizePreTaxContributions)
rp = {v: arr(reform, v) for v in ("irs_employment_income", "pre_tax_contributions")}
rt = {v: arr(reform, v) for v in TAX_UNIT_VARS}
out["counterfactual_no_pre_tax_subtraction"] = {
"check_pre_tax_contributions": float((pw * rp["pre_tax_contributions"]).sum()),
"check_irs_employment_income": float((pw * rp["irs_employment_income"]).sum()),
"tax_unit_totals": {
v: {
"all": float((tw * rt[v]).sum()),
"filers_baseline_definition": float((tw * rt[v])[filer_tu].sum()),
"soi_row": float((tw * soi_row(v, rt[v])).sum()),
}
for v in TAX_UNIT_VARS
},
"delta_vs_baseline_all": {
v: float((tw * (rt[v] - t[v])).sum()) for v in TAX_UNIT_VARS
},
"delta_vs_baseline_soi_row": {
v: float((tw * (soi_row(v, rt[v]) - soi_row(v, t[v]))).sum())
for v in TAX_UNIT_VARS
},
}
with open(OUT, "w") as f:
json.dump(out, f, indent=1)
print(json.dumps(out, indent=1))Receipt
{
"h5_sha256": "6496cc4393d4d3c6574f76eca231de5898c803b9067645591fd5c4d3e65aee84",
"period": 2024,
"policyengine_us": "2.2.1",
"policyengine_core": "3.32.5",
"units": "USD (weighted sums) unless a key says count",
"n": {
"persons": 166321,
"tax_units": 79729,
"households": 57240,
"weighted_persons": 340077321.9414408,
"weighted_tax_units": 162699243.01165515,
"weighted_filer_tax_units": 158028871.3628717
},
"person_totals": {
"employment_income": 10736284124343.861,
"employment_income_before_lsr": 10736284124343.861,
"employment_income_behavioral_response": 0.0,
"irs_employment_income": 10451168950847.008,
"pre_tax_contributions": 285892852854.19165,
"traditional_401k_contributions": 285892852854.19165,
"traditional_403b_contributions": 0.0,
"pre_tax_health_insurance_premiums": 0.0,
"health_savings_account_payroll_contributions": 0.0,
"payroll_tax_gross_wages": 10736284124343.861
},
"household_weighted_employment_income": 10736284124343.86,
"gap": {
"gross_minus_taxed": 285115173496.85175,
"pre_tax_contributions": 285892852854.19165,
"floored_at_zero": 777673656.1649606,
"persons_with_wages_count": 167047231.44831878,
"persons_with_pre_tax_contributions_count": 53793494.52933662,
"persons_with_wages_but_zero_taxed_wages_count": 199030.7006604497
},
"person_split": {
"filer_nondependent": {
"weighted_persons_count": 240858070.1994835,
"employment_income": 10595469672143.816,
"irs_employment_income": 10311366131579.959,
"pre_tax_contributions": 284872996167.89966
},
"filer_dependent": {
"weighted_persons_count": 92248156.07496803,
"employment_income": 134488041482.55249,
"irs_employment_income": 133516775974.12506,
"pre_tax_contributions": 971265748.8792961
},
"nonfiler_nondependent": {
"weighted_persons_count": 6333683.95544584,
"employment_income": 6003145867.733171,
"irs_employment_income": 5964251704.73678,
"pre_tax_contributions": 47117676.1342971
},
"nonfiler_dependent": {
"weighted_persons_count": 637411.7115433738,
"employment_income": 323264849.7581165,
"irs_employment_income": 321791588.1884434,
"pre_tax_contributions": 1473261.2784035131
}
},
"tax_units_with_wages_count": {
"employment_income_all": 127015439.81924196,
"irs_employment_income_all": 126920004.53332293,
"employment_income_filers": 126158180.23467758,
"irs_employment_income_filers": 126065070.47186035
},
"tax_unit_totals": {
"adjusted_gross_income": {
"all": 16683143509971.34,
"filers": 16674038068348.33,
"soi_row": 16683143509971.34
},
"taxable_income": {
"all": 12956915637330.906,
"filers": 12956915637330.906,
"soi_row": 12956915637330.906
},
"income_tax_before_credits": {
"all": 2410282352102.841,
"filers": 2410282352102.841,
"soi_row": 2410282352102.841
},
"income_tax": {
"all": 2262526848576.4775,
"filers": 2262538942338.2183,
"soi_row": 2366408186429.038
}
},
"wage_soi_rows": {
"employment_income": 10736284124343.861,
"irs_employment_income": 10451168950847.008,
"irs_employment_income_filers": 10444882907554.084
},
"state_gap_share_of_gross": {
"n_states": 51,
"min": 0.016852235014282537,
"median": 0.025525670001011056,
"max": 0.03981141380658888
},
"counterfactual_no_pre_tax_subtraction": {
"check_pre_tax_contributions": 0.0,
"check_irs_employment_income": 10736284124343.861,
"tax_unit_totals": {
"adjusted_gross_income": {
"all": 16968055014616.637,
"filers_baseline_definition": 16958910678830.402,
"soi_row": 16968055014616.637
},
"taxable_income": {
"all": 13240702363715.418,
"filers_baseline_definition": 13240702363640.074,
"soi_row": 13240702363715.418
},
"income_tax_before_credits": {
"all": 2472401015450.6787,
"filers_baseline_definition": 2472401015443.1445,
"soi_row": 2472401015450.6787
},
"income_tax": {
"all": 2325642334075.5547,
"filers_baseline_definition": 2325654427837.296,
"soi_row": 2428723425505.375
}
},
"delta_vs_baseline_all": {
"adjusted_gross_income": 284911504645.29517,
"taxable_income": 283786726384.5115,
"income_tax_before_credits": 62118663347.83797,
"income_tax": 63115485499.07788
},
"delta_vs_baseline_soi_row": {
"adjusted_gross_income": 284911504645.29517,
"taxable_income": 283786726384.5115,
"income_tax_before_credits": 62118663347.83797,
"income_tax": 62315239076.337006
}
}
}raw_h5_checks.py and its output
"""Stored-column checks on a US release H5, read without the engine.
Usage: uv run python raw_h5_checks.py <populace_us_2024.h5>
Cross-checks the engine-side receipt from the stored person table: the gross
wage total, who holds 401(k) deferrals, the split by support channel, and
whether a PUF-channel person's wage tracks the same source person's ASEC wage.
"""
import sys
import numpy as np
import pandas as pd
WAGE = "employment_income_before_lsr"
DEFERRAL = "traditional_401k_contributions_desired"
with pd.HDFStore(sys.argv[1], mode="r") as store:
person = store["person"]
household = store["household"]
weight = (
household.set_index("household_id")["household_weight"]
.reindex(person["person_household_id"].to_numpy())
.to_numpy()
)
wage = person[WAGE].to_numpy(dtype=np.float64)
deferral = person[DEFERRAL].to_numpy(dtype=np.float64)
channel = person["person_support_channel"].astype(str).to_numpy()
print(f"weighted persons {weight.sum():,.0f}")
print(f"gross wages {np.sum(weight * wage):,.0f}")
print(f"rows with negative wages {int((wage < 0).sum())}")
print(f"wage earners {weight[wage > 0].sum():,.0f}")
print(f"stored desired deferrals {np.sum(weight * deferral):,.0f}")
print(f"deferrals capped at wages {np.sum(weight * np.minimum(deferral, wage)):,.0f}")
print(f"contributors {weight[deferral > 0].sum():,.0f}")
print(f"contributors with wages {weight[(deferral > 0) & (wage > 0)].sum():,.0f}")
for name in (
"traditional_403b_contributions_desired",
"pre_tax_health_insurance_premiums",
"health_savings_account_payroll_contributions",
):
print(f"stored column {name}: {name in person.columns}")
print("\nby support channel")
for name in np.unique(channel):
mask = channel == name
print(
f" {name:<16} persons {int(mask.sum()):>7,}"
f" wages {np.sum((weight * wage)[mask]) / 1e9:>9,.1f}B"
f" desired deferrals {np.sum((weight * deferral)[mask]) / 1e9:>7,.1f}B"
)
keys = ["source_year", "source_person_id"]
frame = person[keys].copy()
frame["wage"] = wage
frame["channel"] = channel
asec = frame[frame.channel == "asec"].drop_duplicates(keys).set_index(keys)
puf = frame[frame.channel == "puf_tax_detail"].drop_duplicates(keys).set_index(keys)
both = asec.join(puf, lsuffix="_asec", rsuffix="_puf", how="inner")
a = both["wage_asec"].to_numpy()
p = both["wage_puf"].to_numpy()
positive = (a > 0) & (p > 0)
print("\nsource persons on both channels")
print(
f" asec persons {len(asec):,}; puf_tax_detail persons {len(puf):,}; on both {len(both):,}"
)
print(f" correlation of the two wages {np.corrcoef(a, p)[0, 1]:.4f}")
print(f" both positive {int(positive.sum()):,}")
print(
f" median puf/asec ratio, both positive {np.median(p[positive] / a[positive]):.4f}"
)
print(
" 5th and 95th percentile of that ratio "
f"{np.percentile(p[positive] / a[positive], 5):.4f}"
f" {np.percentile(p[positive] / a[positive], 95):.4f}"
)
print(f" positive on one channel only {int(((a > 0) != (p > 0)).sum()):,}")
print(f" unweighted mean wage, asec and puf {a.mean():,.0f} {p.mean():,.0f}")weighted persons 340,077,323
gross wages 10,736,284,142,568
rows with negative wages 0
wage earners 167,047,232
stored desired deferrals 287,784,807,398
deferrals capped at wages 287,001,447,335
contributors 53,793,495
contributors with wages 53,793,495
stored column traditional_403b_contributions_desired: False
stored column pre_tax_health_insurance_premiums: False
stored column health_savings_account_payroll_contributions: False
by support channel
asec persons 66,001 wages 3,443.0B desired deferrals 79.4B
puf_tax_detail persons 100,320 wages 7,293.2B desired deferrals 208.4B
source persons on both channels
asec persons 66,001; puf_tax_detail persons 100,320; on both 30,488
correlation of the two wages 0.9999
both positive 13,996
median puf/asec ratio, both positive 1.0000
5th and 95th percentile of that ratio 0.9971 1.0019
positive on one channel only 0
unweighted mean wage, asec and puf 39,059 39,054
Activity
MaxGhenis commentedon Oct 10, 2026
PUF-channel wage audit: result
The audit this issue left open is done. In builds from current main, the pipeline stores the finalized PUF QRF wage on the PUF channel with no deferral added. The archived generator assigns that donor wage from E00200, the Form 1040 wage line. The engine then subtracts the separately imputed 401(k) deferral from it: $172.2B, 2.78% of PUF-channel wages, on the 30 September Route A release. So the rebind proposed here needs a source-side correction in the same build. A proposal follows, for Max's ruling; nothing is changed here.
The audit also turned up evidence that the PUF donor's amounts never received their 2021-to-2024 uprating. That is separate from the wage concept and is described under "Adjacent finding".
Files measured
base_populace_us_2024_puf_support.h50fb05b67…cedd2tools/build_us_puf_support_base.py --stage allat4b57d15a2, which contains #1033 (310842b98)populace_us_2024.h5, releasepopulace-us-2024-0fb05b6-4b57d15a287c-20260930T150401Z417d23ae…e1559populace_us_2024.h5,populace-us-2024-spm-202609156496cc43…aee84Both post-#1033 files hold the same 907,863 persons: 432,523 ASEC rows, their 432,523 PUF clones, and 42,817 rows of the capital-gains tail clone. Engine figures use policyengine-us 2.2.1 and the file's own weights. No release was built and nothing was recalibrated.
The files that build the PUF wage are identical at the build commit and at main
7639ef8b0:puf_support.py,puf_qrf_chain.py,puf_capital_gains_tail.py,retirement_contributions.py,cps_carried.py,acs_income_universe.py,imputation.yaml,source_stages.json, microcosm-fitqrf.pyandtools/build_us_puf_support_base.py. Other files changed since: the release tool, the calibration package, and the default ASEC pool, which is now income years 2023 to 2025 where this build pooled 2022 to 2024. Those change a new build's inputs and weights. They do not change how the PUF wage is built.The code path, at main
Paths are under
packages/microcosm-build/src/microcosm/build/unless they start withtools/. An independent trace of the same path reached the same result.The spec.
us/spec/imputation.yamllists the wage as a target of one family,primary/puf_tax_detail, with coveragepuf_clone(lines 1357-1378). Its post-draw calibration policy (from line 94) names nine targets, and the wage is not one of them.us/spec/sources.yamldeclares two operations for thepuf_tax_detailstage that do not run: anupratefrom 2015 to the build year (lines 230-232) and a QRF on wage-level predictors (lines 251-259). The base tool builds the donor directly (step 1) and callsrun_source_stageonly forcapital_gain_distributions(line 2632), andus_runtime/source_runtime.pyhas noupratehandler.Donor read.
tools/build_us_puf_support_base.py::_puf_tax_unit_donor_from_h5(line 3423) reads every array ofpuf_2024.h5as stored. The word "uprat" does not occur in that tool, inus_runtime/puf_support.pyor inus_runtime/puf_qrf_chain.py.Which array.
puf_support.py::_person_source_values(lines 4237-4255) takesemployment_income_before_lsrif the file has that array andemployment_incomeotherwise. The archived generator (policyengine-us-data42ed5d45,datasets/puf/puf.py) setspuf["employment_income"] = puf.E00200at line 649. It imputespre_tax_contributionsseparately (lines 1409-1411) and exports it (line 860), and never adds it to wages:employment_incomeappears four times in the file (lines 436, 649, 815, 1410). Microcosm does not use the donor'spre_tax_contributionsas a PUF output or to construct wages (puf_support.pylines 281-328).Tax-unit total. Person wages are summed to the tax unit (lines 1439-1440).
QRF. The staged build fits one target at a time on donor tax units with design weights and draws it for every PUF-clone tax unit (
us_runtime/puf_qrf_chain.pylines 219-225 and 363-375). The predictors are filing status, unit size, head and spouse age, head sex, dependents, an income rank share and a has-earnings flag (puf_support.pylines 270-279;us/spec/imputation.yamllines 72-80). No wage level is a predictor. The wage is the first target in the chain (puf_support.pyline 282), so nothing drawn earlier conditions it.Finalization (
puf_qrf_chain.pylines 454-490 callpuf_support.py::finalize_us_puf_tax_detail_predictions, lines 1880-2157). It normalizes the output column under the configured absence policy (lines 2080-2088 and 3482-3506) and applies three wage operations:_write_person_tax_unit_totals, lines 3782-3830).The wage is in none of the snapping, sparsifying, tail-bound or signed-mass sets (lines 349-418).
Later base stages (
tools/build_us_puf_support_base.pylines 176-201). None changes a wage. Two matter here.us_runtime/puf_capital_gains_tail.pylines 95-101, 1660-1685, 1754-1763, 1809-1831). The copies keep the wage they were copied with.retirement_contributions_post_clonehandles deferrals (us_runtime/retirement_contributions.py). On ASEC rows the five contribution leaves come from the survey'sRETCB_VALby fixed share parameters, with separate wage, self-employment and earned-income gates (lines 226-260). On PUF rows a QRF trained on those ASEC rows predicts all five leaves, and one predictor is the PUF-half wage (employment_income_before_lsr, lines 507-510). The predictions are clipped at zero, the 401(k) leaves are zeroed where the wage is zero (lines 403-416), and only the five contribution columns are written (lines 418-421).Release build.
tools/build_us_fiscal_refresh_release.pynever names the wage column. It does runwith_us_retirement_contribution_inputsagain on the loaded base when no pool frame is given (lines 13105-13110). On a cloned frame that function does not return early (retirement_contributions.pylines 542-544), so the PUF-half contributions are imputed a second time.Engine. policyengine-us 2.2.1 computes
irs_employment_income = max_(0, employment_income - pre_tax_contributions). The traditional 401(k) part is the desired amount timesmin(limit / max(total desired, 1), 1), and the limit depends on age only (elective_deferral_limit: $23,000 in 2024, plus $7,500 from age 50).No step adds a deferral or any other pre-tax amount to the wage.
What the files show
By channel
Release
417d23ae, engine run, $B:employment_incomepre_tax_contributionsirs_employment_incomeThe stored desired traditional 401(k) deferrals are $139.8B on the ASEC channel and $172.7B on the PUF channel. No 403(b), pre-tax health premium or HSA payroll column is stored, so all of
pre_tax_contributionsis the 401(k) amount. Rebuilt person by person from stored columns, both amounts differ from the engine by at most the script's tolerance: $0.01 plus two float32 units in the last place at the larger of stored wage and rebuilt pre-tax amount. All 907,863 persons on the release and all 166,321 on the certified default pass.Base frame
0fb05b67, stored columns at base weights (each channel holds 163.0M weighted persons), $B:At equal weights the PUF half holds 14.7% less wage income than the ASEC half.
The PUF wage against the same person's ASEC wage
All 432,523 source persons are on both channels.
0fb05b67,417d23ae)6496cc43)The PUF wage no longer tracks the survey wage in level. It keeps the rank, and the median ratio to the same person's ASEC wage is 0.85.
The 42,817 tail-clone persons hold exactly the wage of the PUF clone they were copied from.
At tax-unit grain (226,102 units) the Spearman correlation is 0.904 and the median ratio 0.86. Within a unit the split follows the ASEC shares exactly: over 57,335 persons in units with two or more ASEC earners, all aged 15 or over, the largest difference between the PUF and ASEC wage shares is 2e-16.
The distributions
Positive person wages at base weights (
0fb05b67), excluding the capital-gains tail clone:Wages above $1M are 3.6% of ASEC wages and 5.5% of PUF-clone-1 wages.
Against the donor's rounding grid
The donor file was not available locally (
puf_2024.h5, irs-soi-puf 1.8.0, sha2567669f5b5…d6df; the base build read it from the Modal volume) and was not downloaded. A lattice test stands in for it. PUF amounts are rounded, as the bands below show, so a processed donor wage is a rounded source-year amount times one uprating factor. The archived generator's 2015-to-2021 wage factor isget_growth("employment_income", 2015, 2021)= 1.187079295246555: growth in the SOI wage total divided by growth in the return count, from itssoi_targets.csv. On the base frame:qrf.pylines 305-363), which would produce such values. That this is their source was not established.Grid membership is a property of the amounts. It does not show that a stored total equals a donor record; that check, and a quantile comparison with the donor, need the donor file.
Base frame against release
employment_income_before_lsris identical for all 907,863 persons. The traditional 401(k) deferral differs on 30,435 PUF-channel persons and on no ASEC person, because the release build imputes the PUF-half contributions again (step 7). The comparison records the count and the largest difference, not the direction of the changes. Why only 6% of PUF-channel rows differ was not established. The base was built on Modal (Linux) and the release on macOS.Read, observed and deduced
Read in code at
7639ef8b0: no step between the donor read and the export adds a deferral or any pre-tax amount to the PUF-channel wage, and no step after QRF finalization changes it.Observed on the files above:
Deduced, not shown by the files:
employment_income = E00200and on the form definitions in the issue. The PUF carries no deferral field, so no stored column can confirm it.Not measured: what a recalibration does under any of the options.
The issue says the certified default does not show the problem, because it predates #1033 and its PUF wages track ASEC. That holds for levels, and one detail adds to it. On that file 99.2% of PUF-clone wage totals are also on the donor grid. The old forest conditioned on the survey wage (#982). Among matched positive person wages, the PUF/ASEC ratio's 5th and 95th percentiles are 0.9971 and 1.0019, and 99.1% differ by at most 1%.
Adjacent finding: evidence of missing 2021-to-2024 donor uprating
At a relative tolerance of 1e-11, 90.8% of earning PUF-clone tax units have stored wage totals on the archived generator's 2015-to-2021 grid. Three other outputs fit their own 2015-to-2021 factors: qualified dividends (1.3628; 87.9% of PUF-clone units on the grid), private pensions (1.1637; 76.7%) and IRA distributions (1.5092; 73.6%). At the 2022 or 2023 factors no unit is on the grid for any of the four.
The archived generator's 2024 branch accounts for this. It loads the 2021 arrays and runs
for variable in uprating: if variable in arrays: …(puf.pylines 1380-1396).upratingiscreate_policyengine_uprating_factors_table(), which returns a table pivoted to one column per year (utils/uprating.pyline 112). Iterating it yields years, and no year is an array name. Run unchanged against policyengine-us 2.2.1, that function returns columns 2020 to 2034, and the loop, copied verbatim, runs its body zero times. The table's own wage factor from 2021 to 2024 is 1.171 (1.251 / 1.068). The archived branch therefore leaves its loaded 2021 monetary arrays unchanged. Four stored outputs fit that reading.puf_2024.h5itself and the other donor outputs were not checked.A repair needs more than fixing the iteration. The loop body also reads a
Variablecolumn the pivoted table does not have, and the table names the wageemployment_income_before_lsrwhere the generator's arrays useemployment_income. Any Microcosm-side uprating has to start from the donor's verified price year: a 2015-to-build-year factor applied to amounts already uprated to 2021 would count that growth twice.Microcosm's source spec declares an
upratefrom 2015 to the build year for this stage, and nothing runs it (see "The spec" above).Per-return wages in the archived SOI targets grew 13.3% from 2021 to 2023. The median ratio of the PUF wage to the same person's ASEC wage is 0.85, and the ASEC half stores
WSAL_VALunchanged for income years 2022 to 2024. That gap was not decomposed. The certified default shows the same grid, but its PUF wages track the survey wage, so the level did not show.This needs a separate issue, which a follow-up will open after checking the donor file.
Proposal: store the donor wage plus what the engine subtracts
On the PUF half, store
employment_income_before_lsr = donor wage + pre_tax_contributionsso that
irs_employment_incomereturns the donor's tax-return wage and the gross wage becomes a gross concept on both halves. Five points follow from what the audit found.WSAL_VAL, and compute the stored wage from it whenever contributions are imputed. The release build imputes them again (30,435 persons changed on this build). A gross-up computed once in the base build would no longer match the final deferrals, and one recomputed from the stored gross wage would compound.US_STORED_NON_VARIABLE_COLUMNSor the stored-input gate refuses it (microcosm/data/stored_inputs.py; release tool lines 7121-7177). Formula-ownedirs_employment_incomeandpre_tax_contributionsstay out of the export (release tool lines 4269-4276). The input-coverage manifest continues to describe engine input leaves.gov.irs.gross_income.pre_tax_contributions). Today only traditional 401(k) is stored. Produce ESI employer premiums scaled to BEA B4923C from restored ASEC policyholder fields (#454) #1189's pre-tax health premiums join the same subtraction when they land.Invariants
For every PUF-channel person, and so for every nonnegative weight vector:
irs_employment_incomereproduces the carried donor wage within an explicit absolute bound for float32 rounding: one unit in the last place of the stored gross wage plus one of the pre-tax amount.pre_tax_contributions, and its 401(k) part is at most the age-specific elective-deferral limit.WSAL_VAL, as it does today on all 207,206 ASEC rows with wages in the base frame.Tests
scripts/test_gross_up_properties.py. They test a short standalone statement of the semantics inscripts/gross_up_reference.py: the finalization split by age and basis, the engine's pre-tax formula with its age-specific limit, float32 evaluation, and a second contribution draw. Seven pass. Two strict expected failures state invariants 1 and 3 for today's behaviour in the same reference, with minimal counterexamples of a $1 wage with a $1 deferral (taxed wage 0, should be 1) and a $0.50 wage with a $1 deferral (floor binds). They do not exercise production imputation or the live build, and the repository's test paths exclude them; implementation tests belong in the package suites.irs_employment_incomeequals the weighted carried donor wage, allowing the weighted sum of the per-person rounding bounds.How it interacts with the rebind
These are fixed-weight arithmetic illustrations on release
417d23ae, keeping its existing contribution inputs. Retraining the contribution model or moving the stage could change the gross total. No solve was run.The payroll-tax base reads the gross wage as well:
payroll_tax_gross_wages = max_(0, employment_income - fica_pre_tax_contributions), and the 401(k) deferral is not in that list. Today it leaves out the PUF half's deferrals, and the gross-up adds them.The rebind and the gross-up belong in one build. The uprating finding is independent of both.
Not done
Scripts, receipts and the reference tests:
docs/evidence/us-puf-wage-concept-1191/(#1195).Name the archived data repository without the retired package string
MaxGhenis commentedon Oct 12, 2026
The follow-up this issue's PUF-channel audit called for is #1248. Checked on the donor file itself, the PUF donor is a TY2021 file. Its wage column equals the archived 2015-to-2021 value on 100% of nonzero source records, and its weighted wage total is $9,078B against SOI's $9,022B for TY2021 and $10,204B for TY2023. Most other dollar columns are the same; two are at TY2015 levels, and the interest-paid columns match on 76.1%.
For the rebind and gross-up proposed here, three things follow, set out in #1248:
Allowlist the archived-code stub instead of splitting its name
Merge pull request #1195 from PolicyEngine/audit/puf-wage-deferral-1191
Pin the replayed period and repair order; authenticate every probe array
Refuse wildcard imports of PERIOD; name node values and changed stages
MaxGhenis commentedon Oct 13, 2026
Which contribution pass is final
The audit above found that the release build imputes the PUF-half retirement contributions a second time. #1255 follows that up. The release's second draw is the one that ships: on the Route A pair it changes the engine's pre-tax 401(k) amount for 30,457 PUF persons (+$3,559.5M at the release's weights). The draw moves for two reasons, both reproduced with the builder's own code: the release rescales the Social Security leaves, a predictor of the QRF, before re-running the stage; and the forest fit breaks near-tied splits differently on Linux and macOS.
The gross-up proposed here computes the stored PUF wage from the final deferrals. Point 1 above already allows for repeated passes by carrying the donor wage and recomputing the stored wage whenever contributions are imputed. #1255 proposes the other route, making the base's pass final (the release consumes base-owned stages and a gate refuses changes to them), so the gross-up could run once in the base. Evidence and tests: #1254.
Merge pull request #1254 from PolicyEngine/release-reimputation-invar…