Repository navigation
Marginal tax rates are a placeholder 0 for adults past marginal_tax_rate_adults and all minors (18M workers, $436B wages) #9652
Description
Summary
The marginal tax rate variables are simulated for at most simulation.marginal_tax_rate_adults adults per household (default 2), ranked by market income. Everyone else, including every earner under 18, gets 0.0. That zero looks identical to a genuine zero MTR. So any population MTR distribution, MTR-weighted statistic, cliff share or substitution-effect labor supply response that doesn't filter these people out mixes placeholder zeros in with real rates. Because the real rates of the uncovered workers are mostly positive (median 0.22, below), the practical effect is downward. On the certified populace_us_2024, the placeholder covers 18.1 million working-age wage earners and $436 billion of their wages.
Nothing tracks this today. I searched open and closed issues and PRs in this repo and across the org. The nearest items are #1564 (align MTR and cliff computations) and a passing mention in #9086.
Mechanism (read from the code at main)
marginal_tax_rate(variables/household/marginal_tax_rate.py) starts fromnp.zeros, then loopsadult_indexfrom 1 tosimulation.marginal_tax_rate_adults. Each iteration runs one branch simulation that addsmarginal_tax_rate_delta($1,000) to the earnings of the person whoseadult_earnings_indexequals that index, and records1 - Δ household_net_income / deltafor that person only.adult_earnings_indexranks adults (age >= 18) within the household bymarket_income, descending. Non-adults get 0, so no cap setting ever reaches them.- The same loop, with the same cap, is copied in
marginal_tax_rate_including_health_benefits.py. It also lives inmarginal_tax_rate_helpers.compute_component_mtr, which backsfederal_marginal_tax_rate,state_marginal_tax_rateandfica_marginal_tax_rate. That makes five variables with the same gap. - There is a sixth, separate loop:
marginal_tax_rate_on_capital_gains(variables/gov/simulation/capital_gains_responses.py) hardcodesfor adult_index in [1, 2]over adults ranked by long-term capital gains, ignoring the parameter. cliff_evaluated("Whether this person's cliff has been simulated") is wrong. It ranks adults by age (adult_index), whilecliff_gaphas been derived from the earnings-rankedmarginal_tax_ratesince Fix cliff variables #5322. Remove unused cliff gap parameters #5342 kept the age ranking.
History: the cap was introduced at 1 in #1523 and raised to 3 in #3504. #4478, a labor supply performance PR (#4477: "we're running 7x as many simulations"), then switched the ranking to earnings and cut the cap to 2. None of these PRs discussed coverage.
Evidence
Certified populace_us_2024 (sha256 48b9d479…, policyengine.py 5.3.0 bundle, built for policyengine-us 1.764.6), period 2024. The MTR code in 1.764.6 is identical to main: the files last changed in April 2026.
| Population | Records | Weighted people | Earnings |
|---|---|---|---|
| Aged 18-64 with employment income > 0 | 71,323 | 149.5M | $9.92T |
… of whom adult_earnings_index > 2 (MTR placeholder 0) |
8,899 | 18.06M | $435.7B wages |
| Under 18 with employment income > 0 (always 0, any cap) | 2,021 | 3.46M | $26.9B wages |
Coverage of working-age wage earners by cap (weighted; earnings are wages plus self-employment income):
| Cap | Workers covered | Earnings covered | Uncovered workers | Uncovered earnings |
|---|---|---|---|---|
| 1 | 54.5% | 71.9% | 68.0M | $2.79T |
| 2 (default) | 87.9% | 95.6% | 18.1M | $437B |
| 3 | 96.9% | 99.0% | 4.6M | $97B |
| 4 | 99.4% | 99.8% | 0.9M | $17B |
| 5 | 99.9% | 99.96% | 0.16M | $3.5B |
The largest adult_earnings_index among these workers is 9, and 12 in the file overall.
Their real MTRs are not zero. With the cap raised to 9 (sim.apply_reform({"simulation.marginal_tax_rate_adults": {"2024-01-01.2024-12-31": 9}})), the median MTR of those 8,899 workers is 0.22, and only 56 of them are genuinely zero.
Runtime. Each extra adult rank is one more full branch simulation of household_net_income for every household, whether or not the household has an adult at that rank. On the certified file, ranks 1 through 6 each cost 2.7-3.2 CPU-minutes (3-5 minutes wall-clock on a loaded 18-core machine). Rank 6 perturbs 129 working-age earners and costs as much as rank 1, which perturbs 37,981. Labor supply response runs pay this in both the baseline and the reform measurement branch.
Consequences for consumers
- Labor supply responses. The substitution response scales with the relative change in
1 - marginal_tax_ratebetween baseline and reform. Uncomputed earners have MTR 0 in both, so their substitution response is exactly zero. LSR scores, their revenue effects and their decile breakdowns therefore omit third-and-later earners and all under-18 earners. Verified on a Texas household (ages 70/40/38/22, wages $0/$80k/$50k/$30k, one tax unit each) under a reform raising the 12% and 22% brackets to 17% and 27%, with substitution elasticity 0.25 and income elasticity -0.05.substitution_elasticity_lsris [0, -$1,421, -$778, 0]: the $30k third earner gets no substitution response, but still gets a $46 income-effect response, because that response does not use MTR. The aggregate size of this omission has not been quantified yet. - Cliffs.
cliff_gap = max(0, (marginal_tax_rate - 1) * delta), so uncomputed people are never on a cliff. policyengine.py'sCliffImpact(policyengine.py#376, Add codecov badge to README.md #377) and the v1 API computecliff_shareassum(is_on_cliff) / sum(is_adult). The denominator counts every adult, but the numerator can count only the top two earners, so the share is understated. cliff_evaluateddisagrees with the people actually simulated for 14,042 records on the certified file (16.6M weighted in each direction): 6.7M earners are flagged as evaluated but were never simulated, and 15.4M earners were simulated but are flagged as not evaluated. Minimal case: a Texas household with adults aged 70, 40, 38 and 22 earning $0, $80k, $50k and $30k.adult_earnings_indexis [4, 1, 2, 3], so the people simulated are [F, T, T, F], butcliff_evaluatedreturns [T, T, F, F].- Raw MTR statistics (policyengine.py
Aggregate/ChangeAggregateMEAN, MicroSeries quantiles, household JSON) include the placeholder zeros unless the caller filters them out. - policyengine.org household MTR chart. app-v2's marginal tax rate page charts the focus person's
marginal_tax_rateacross an earnings axis. In a household with three or more adults, it shows a false 0% wherever the focus person ranks third or lower by market income. For example, "you" earning $0-40k alongside roommates earning $60k and $50k. - Vendored copy. PolicyEngine/aspen-eitc-ctc vendors this formula (
scripts/reforms.py), so formula changes here won't reach it; only the parameter default would.
Options considered
| Option | Verdict | Why |
|---|---|---|
| Document the cap in every affected variable and the parameter | Adopt | It costs nothing, and the API serves variable documentation strings in /us/metadata. simulation.* parameters are not served there, so only the repo carries the parameter note. |
Companion flag marginal_tax_rate_computed |
Adopt | It is additive, changes no existing number, and reaches consumers without API changes: stored-household calculations return every yearly variable, and callers of direct calculations can request it, and policyengine.py Aggregate takes a filter_variable. cliff_evaluated was meant to be this flag, so it should be this flag. |
| Return NaN for uncomputed people | Reject | NaN propagates through the LSR chain into employment_income and household_net_income under any LSR reform, including zero earners, because 0 × NaN is NaN. That makes policyengine.py economic_impact_analysis raise (decile_analysis.py:155). It also distorts or NaNs the median and quantiles at the microdf version policyengine.py locks (1.3.0), puts bare NaN tokens into API v1 JSON, and fails the simulation API's stage-12 contract (allow_inf_nan=False in policyengine-sim-api, formerly policyengine-api-v2). Making it safe would take coordinated guards in four repos. |
| Raise the default cap | Decide separately | Cap 3 covers 96.9% of workers and 99.0% of earnings, versus 87.9% and 95.6% at cap 2, for one more branch (+50% MTR runtime). It changes published LSR and cliff numbers, so it needs its own decision and measurement. |
| Bound the loop by the largest rank present, and compute every adult in household (non-dataset) simulations | Follow-up | Household calculations would compute every adult, which fixes the app chart, and one-adult households would drop from two branches to one. Household outputs for 3+ adult households change, so this gets its own PR. |
Plan
#9653 adds marginal_tax_rate_computed, redefines cliff_evaluated as that flag (fixing the age-rank bug), documents the cap on all six MTR variables and the parameter, and adds YAML and property tests. It changes no marginal tax rate value.
Follow-ups tracked here:
- Decide the default cap (coverage and runtime tables above).
- Bound the MTR loops by the largest
adult_earnings_indexpresent, and simulate every adult in household (non-dataset) simulations. - Mask or label uncomputed points in the app-v2 MTR chart using the flag.
- Decide whether policyengine.py
CliffImpactshould divide bycliff_evaluatedinstead ofis_adult. That changes whatcliff_sharemeans, from "share of all adults" to "share of evaluated adults". - Parameterize
marginal_tax_rate_on_capital_gains(hardcoded[1, 2], its own ranking and delta). -
compute_component_mtrputs the whole self-employment delta intoself_employment_income, whilemarginal_tax_ratesplits it between SSTB and non-SSTB income. The component MTRs can therefore disagree with the total for SSTB earners. - Quantify how much the cap understates substitution responses in published LSR scores.
Related: #1564, #9086, #4477, #4478, #5322, #5342, PolicyEngine/policyengine.py#376, PolicyEngine/policyengine-app-v2#399.
Activity