Skip to content

SOI and CBO wage targets bind gross employment_income; the engine taxes irs_employment_income #1191

Open

Description

@MaxGhenis

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"] and SOI_RETURN_MEASURE_VARIABLES["wages_salaries_returns"] are "employment_income" (lines 148 and 231), and SOI_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_VARIABLE is "employment_income_before_lsr" (line 3229).
  • policyengine-us 2.2.1:
    • irs_employment_income is max_(0, employment_income - pre_tax_contributions).
    • pre_tax_contributions adds the list gov.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.sources lists irs_employment_income, so it is the wage that enters AGI.
    • employment_income adds employment_income_before_lsr and employment_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_columns builds the SOI wage rows by calculating the variable SOI_VARIABLE_MAP names, 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_filer is 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_alignment claim 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_match to us:statutes/26/62#input.wages over all_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 is compensation_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_soi sets df["employment_income"] = pe("irs_employment_income"), and utils/loss.py::build_loss_matrix masks every SOI row by is_tax_filer.
    • CBO_INCOME_BY_SOURCE_TARGETS maps ("irs_employment_income", "employment_income") through _calculate_filer_target_values.
    • BEA_NIPA_DIRECT_SUM_TARGETS puts NIPA wages on employment_income_before_lsr over 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_lsr index.
  • This repo. The release builder has bound the SOI wage row to employment_income since 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

  1. 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 CBO DIRECT_LEDGER_TARGETS entry 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.
  2. 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.
  3. 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_income as gross of deferrals. The puf_tax_detail stage reads puf_2024.h5 (irs-soi-puf 1.8.0), whose archived generator sets employment_income = E00200, the tax-return wage, and imputes pre-tax contributions separately. The support loader aliases that to employment_income_before_lsr, which the spec lists as a primary_puf_qrf target for the PUF clone. No step that adds deferrals back appears in the PUF support, retirement-contribution or stacked-spine modules.
  • 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 6f727eac writes pre_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.sources in 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_income zeroes 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. Binding irs_employment_income does 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.h5
measure_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

  1. MaxGhenis commented on Oct 10, 2026

    @MaxGhenis
    ContributorAuthor

    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

    File sha256 Built
    Base frame base_populace_us_2024_puf_support.h5 0fb05b67…cedd2 tools/build_us_puf_support_base.py --stage all at 4b57d15a2, which contains #1033 (310842b98)
    Release populace_us_2024.h5, release populace-us-2024-0fb05b6-4b57d15a287c-20260930T150401Z 417d23ae…e1559 from that base, at the same commit
    Certified default, for contrast populace_us_2024.h5, populace-us-2024-spm-20260915 6496cc43…aee84 before #1033

    Both 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-fit qrf.py and tools/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 with tools/. An independent trace of the same path reached the same result.

    The spec. us/spec/imputation.yaml lists the wage as a target of one family, primary/puf_tax_detail, with coverage puf_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.yaml declares two operations for the puf_tax_detail stage that do not run: an uprate from 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 calls run_source_stage only for capital_gain_distributions (line 2632), and us_runtime/source_runtime.py has no uprate handler.

    1. Donor read. tools/build_us_puf_support_base.py::_puf_tax_unit_donor_from_h5 (line 3423) reads every array of puf_2024.h5 as stored. The word "uprat" does not occur in that tool, in us_runtime/puf_support.py or in us_runtime/puf_qrf_chain.py.

    2. Which array. puf_support.py::_person_source_values (lines 4237-4255) takes employment_income_before_lsr if the file has that array and employment_income otherwise. The archived generator (policyengine-us-data 42ed5d45, datasets/puf/puf.py) sets puf["employment_income"] = puf.E00200 at line 649. It imputes pre_tax_contributions separately (lines 1409-1411) and exports it (line 860), and never adds it to wages: employment_income appears four times in the file (lines 436, 649, 815, 1410). Microcosm does not use the donor's pre_tax_contributions as a PUF output or to construct wages (puf_support.py lines 281-328).

    3. Tax-unit total. Person wages are summed to the tax unit (lines 1439-1440).

    4. 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.py lines 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.py lines 270-279; us/spec/imputation.yaml lines 72-80). No wage level is a predictor. The wage is the first target in the chain (puf_support.py line 282), so nothing drawn earlier conditions it.

    5. Finalization (puf_qrf_chain.py lines 454-490 call puf_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:

      • it clips the unit total at zero (lines 2016-2018);
      • it sets PUF-clone persons under 15 to zero (lines 2097-2100);
      • it splits the unit total across the unit's PUF-clone persons aged 15 and over in proportion to the ASEC wage the clone copied, or gives it to the first such person when none had wages (_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).

    6. Later base stages (tools/build_us_puf_support_base.py lines 176-201). None changes a wage. Two matter here.

      • The capital-gains tail transfer copies whole PUF households into a third clone, rewrites only their capital-gains columns and splits the household weight (us_runtime/puf_capital_gains_tail.py lines 95-101, 1660-1685, 1754-1763, 1809-1831). The copies keep the wage they were copied with.
      • retirement_contributions_post_clone handles deferrals (us_runtime/retirement_contributions.py). On ASEC rows the five contribution leaves come from the survey's RETCB_VAL by 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).
    7. Release build. tools/build_us_fiscal_refresh_release.py never names the wage column. It does run with_us_retirement_contribution_inputs again 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.py lines 542-544), so the PUF-half contributions are imputed a second time.

    8. 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 times min(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:

    Channel Weighted persons employment_income pre_tax_contributions irs_employment_income Gross minus taxed
    ASEC 153.6M 4,546.2 139.3 4,407.2 139.1 (3.06%)
    PUF 186.7M 6,188.7 172.2 6,016.5 172.2 (2.78%)
    All 340.3M 10,734.9 311.6 10,423.7 311.3 (2.90%)

    The 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_contributions is 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:

    Channel Wage earners Wages Desired deferrals Deferrals / wages Mean wage of earners
    ASEC 80.4M 5,638.3 167.0 2.96% $70,164
    PUF 79.5M 4,808.5 131.7 2.74% $60,511

    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.

    Post-#1033 (0fb05b67, 417d23ae) Certified default (6496cc43)
    Persons on both channels 432,523 30,488
    Pearson correlation 0.524 0.9999
    Spearman correlation 0.938 1.0000
    Both wages positive 198,762 13,996
    PUF / ASEC ratio: 5th, 25th, 50th, 75th, 95th percentile 0.49, 0.82, 0.85, 0.93, 1.82 0.9971, 0.9995, 1.0000, 1.0005, 1.0019
    Share within 1% 1.1% 99.1%
    ASEC earner with no PUF wage 8,444 0
    ASEC non-earner with a PUF wage 5,513 0
    Largest wage, ASEC and PUF $2,099,999 and $34,235,367 $2,099,999 and $2,118,377

    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:

    Percentile 10 25 50 75 90 95 99 99.9
    ASEC 12,000 28,000 50,000 85,000 136,547 181,000 400,000 1,099,999
    PUF clone 1 14,008 24,678 42,141 70,335 113,093 151,353 309,612 1,205,303

    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, sha256 7669f5b5…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 is get_growth("employment_income", 2015, 2021) = 1.187079295246555: growth in the SOI wage total divided by growth in the return count, from its soi_targets.csv. On the base frame:

    • 139,311 of 153,465 earning PUF-clone tax units (90.8%) store a wage total on that grid: a whole number of dollars times the factor, to a relative tolerance of 1e-11 (1e-12 gives the same count). The same test passes none of 200,000 uniform random values, no ASEC-half unit, and no PUF-clone unit at the 2015-to-2022 or 2015-to-2023 factors.
    • The implied source amounts show rounding bands: the only value under $5 is $2, every amount from $5 to $10,000 is a multiple of $10, and from $10,000 up all but one of 135,122 are multiples of $100.
    • Of the 56,629 PUF-clone units that hold a deferral, 87.5% store an on-grid wage, and for none of them is the stored wage less the deferral on the grid. Units with no deferral are on the grid 92.7% of the time.
    • The other 14,154 units each store a value no other unit has. The QRF reads its draw by interpolating between quantile-grid predictions (microcosm-fit qrf.py lines 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_lsr is 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:

    • 139,311 of 153,465 earning PUF-clone tax units (90.8%) store wage totals on the archived generator's 2015-to-2021 wage grid, including 87.5% of the units that hold a deferral. Donor membership was not checked.
    • The release build does not change the stored wage.
    • The engine subtracts $172.2B from PUF-channel wages on the release.
    • The PUF wage keeps the survey rank and has lost the survey level.

    Deduced, not shown by the files:

    • That the donor wage excludes elective deferrals. This rests on the generator's employment_income = E00200 and on the form definitions in the issue. The PUF carries no deferral field, so no stored column can confirm it.
    • That the ASEC wage includes them. This rests on the survey question's wording, quoted in the issue.
    • That the off-grid 9.2% are interpolated draws.

    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.py lines 1380-1396). uprating is create_policyengine_uprating_factors_table(), which returns a table pivoted to one column per year (utils/uprating.py line 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.h5 itself and the other donor outputs were not checked.

    A repair needs more than fixing the iteration. The loop body also reads a Variable column the pivoted table does not have, and the table names the wage employment_income_before_lsr where the generator's arrays use employment_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 uprate from 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_VAL unchanged 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_contributions

    so that irs_employment_income returns 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.

    1. Carry the donor wage as its own column, as the ASEC half carries 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.
    2. Register that column as source provenance. A new model-named column must be in US_STORED_NON_VARIABLE_COLUMNS or the stored-input gate refuses it (microcosm/data/stored_inputs.py; release tool lines 7121-7177). Formula-owned irs_employment_income and pre_tax_contributions stay out of the export (release tool lines 4269-4276). The input-coverage manifest continues to describe engine input leaves.
    3. Condition the PUF-half deferral on the concept it is trained on. Today the model learns deferrals from the ASEC wage, which includes them, and applies them to the donor wage, which excludes them. Training on the ASEC wage net of its own pre-tax amount puts both sides on the tax-return concept. No fixed point is needed, because the engine's limit depends on age and on desired amounts and never on the wage.
    4. Where it runs is a choice.
      • Smallest change: at the existing retirement-contribution stage. The stages between QRF finalization and that stage that read the wage (housing assistance, prior-year income, child support, disability, workers' compensation, childcare, adult care, energy) keep reading the net wage.
      • Cleaner: impute PUF-half contributions and gross up straight after QRF finalization, so every later consumer reads the concept its ASEC training rows use. This changes more downstream draws.
    5. Bind the gross-up to the engine's list (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:

    1. Round trip. Under baseline policy with zero behavioral response, irs_employment_income reproduces 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.
    2. Bounds. Stored wage ≥ donor wage ≥ 0. The difference equals pre_tax_contributions, and its 401(k) part is at most the age-specific elective-deferral limit.
    3. The zero floor never binds. Today it does: $31M is lost to it on the PUF half of the release.
    4. A zero donor wage means no employment. It gives a zero stored wage and no pre-tax amount. This is a modeling assumption: a return can show zero wages when exclusions offset all gross pay.
    5. Allocation is preserved. Gross-up preserves the sum of the finalized, age-eligible donor-wage allocations. For units with a person aged 15 or over, that sum equals the QRF's clipped unit total; a unit with none stores no wage, as today.
    6. Re-imputation. After contributions are imputed again, recomputing from the carried donor wage keeps invariant 1.
    7. The ASEC half is untouched. Its stored wage equals WSAL_VAL, as it does today on all 207,206 ASEC rows with wages in the base frame.
    8. Determinism. One seed gives one stored wage.

    Tests

    • Reference property tests (Hypothesis) for invariants 1 to 6, in scripts/test_gross_up_properties.py. They test a short standalone statement of the semantics in scripts/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.
    • Per-person differential test against the engine. Done here as a receipt: both reconstructed amounts pass that per-person tolerance on both files. In the repo this becomes an engine-contract test, so that a new entry in the engine's list fails until the gross-up covers it.
    • Release-level identity in the build receipts: weighted PUF irs_employment_income equals the weighted carried donor wage, allowing the weighted sum of the per-person rounding bounds.
    • The grid test as a regression check: the carried donor wage stays on the donor grid at the share measured before the stage.
    • The registry and binding tests in the issue stand.

    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.

    Gross wages, $B Taxed wages, $B
    Today 10,734.9 10,423.7
    With the gross-up 10,907.2 10,595.9
    • Rebind alone. The SOI and CBO rows would read $10,423.7B. On the PUF half that is the donor's tax-return wage less a second subtraction of $172.2B. A solve could compensate through weights or leave target residuals; that was not measured.
    • Gross-up alone. The SOI and CBO rows stay on gross wages, which rise by $172.2B, so they move further from the tax-return concept.
    • Both. The SOI and CBO rows read the ASEC wage less its pre-tax amount plus the donor wage, $10,595.9B at these weights. The NIPA row stays on gross and gains $172.2B.

    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

    • The donor file was not opened. The donor comparison is the grid test.
    • The base build's stage checkpoints are on the Modal volume. "No later stage writes the wage" rests on the code and on the finished frame.
    • No build and no recalibration.
    • The 15% gap between the halves is not decomposed.

    Scripts, receipts and the reference tests: docs/evidence/us-puf-wage-concept-1191/ (#1195).

  2. MaxGhenis commented on Oct 12, 2026

    @MaxGhenis
    ContributorAuthor

    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:

    • Order in one build: rebase the donor before the QRF, draw, gross up after the draw, bind the rows.
    • The carried donor wage becomes the rebased donor wage. The round-trip invariant is unchanged; the grid regression check moves to whole TY2015 dollars times the wage series' growth to the build year.
    • The rebase moves the donor wage by 13.3% to TY2023 before the leg to the build year. The gross-up adds the engine's subtraction, $172.2B or 2.78% of PUF-channel wages on the Route A release. The fixed-weight illustrations in this issue's audit comment hold the existing frame and weights fixed and use TY2021-level PUF wages.
  3. MaxGhenis commented on Oct 13, 2026

    @MaxGhenis
    ContributorAuthor

    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.

  4. added a commit that references this issue on Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions