Skip to content

Add marginal_tax_rate_computed and fix cliff_evaluated ranking - #9653

Merged
MaxGhenis merged 5 commits into
mainfrom
mtr-uncomputed-adults
Oct 4, 2026
Merged

MaxGhenis merged 5 commits into
mainfrom
mtr-uncomputed-adults

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Refs #9652. This makes the placeholder zeros identifiable and fixes cliff_evaluated. It does not change which people get a simulated marginal tax rate; the cap default and the downstream follow-ups stay open on the issue.

Summary

The marginal tax rate variables are simulated only for the top simulation.marginal_tax_rate_adults (default 2) adults per household, ranked by market income. Everyone else, including every earner under 18, gets a 0.0 that is indistinguishable from a real zero. On the certified populace_us_2024 that covers 18.1M working-age wage earners and $436B of wages; the issue has the evidence.

  • New marginal_tax_rate_computed (bool, Person): is_adult & 1 <= adult_earnings_index <= simulation.marginal_tax_rate_adults. For formula-derived ranks it is exactly the set of people the MTR loops perturb, so consumers can filter placeholder zeros out of distributions, averages and cliff shares. (The loops test only adult_earnings_index == k; the flag also requires is_adult, which differs only if someone overrides adult_earnings_index as an input for a child.) Stored-household API calculations return it with every other yearly variable. It does not cover marginal_tax_rate_on_capital_gains, which ranks adults separately.
  • Fix cliff_evaluated. It ranked adults by age (adult_index), but cliff_gap has come from the earnings-ranked marginal_tax_rate since Fix cliff variables #5322. It is now defined as marginal_tax_rate_computed. On the certified file, the old definition disagreed with the people actually simulated for 14,042 records (16.6M weighted in each direction). Nothing in pe-us, policyengine.py, API v1 or API v2 reads it; I checked each by grep.
  • Documentation. The cap is now stated on marginal_tax_rate, marginal_tax_rate_including_health_benefits, federal_marginal_tax_rate, state_marginal_tax_rate, fica_marginal_tax_rate, the parameter, and marginal_tax_rate_on_capital_gains, whose loop is separately hardcoded to two adults ranked by capital gains. The API serves the variable strings in /us/metadata; simulation.* parameters are not served there.
  • Fixes a stale test comment that called a cap of 1 "the default value".

No marginal tax rate formula changes, so no MTR, cliff gap or labor supply number changes.

Invariants

These hold for every input whose ranks come from the model's own formulas (not an overridden adult_earnings_index), and are tested in tests/core/test_marginal_tax_rate_coverage.py, using the repo's grid pattern: 66 households varying adult count (1-5), earnings order, ties, zero earners, negative market income (self-employment losses), teen earners and children, all in one simulation per cap. The selection rule is restated there without adult_earnings_index.

  1. For every cap in {0, 1, 2, 3, 5}, each household flags exactly min(cap, adults) people. All of them are adults, and each flagged adult has at least the market income of every unflagged adult.
  2. cliff_evaluated == marginal_tax_rate_computed.
  3. marginal_tax_rate, marginal_tax_rate_including_health_benefits and federal_marginal_tax_rate are exactly 0 wherever the flag is false. These cover the three loop implementations: the net-income loop, its health-benefit copy, and compute_component_mtr.
  4. With behavioral responses off (the default), raising the cap only adds people. Going from 2 to 3 keeps every flagged person flagged, with bit-identical MTRs, and flags strictly more people. This is a differential check that a person's MTR doesn't depend on how many other adults are simulated. With labor supply responses on, the cap changes measured MTRs and so earnings and ranks, so this invariant is scoped to the no-response case.

Mutation check: restoring the old age-ranked cliff_evaluated makes invariant 2 (the equality assertion) fail at caps 1, 2 and 3. It passes at caps 0 and 5, as it should, because there every adult or no adult is flagged.

Tests

The YAML cases all use the default cap. The batched YAML runner caches one full copy of the tax-benefit system per distinct parameter override for the life of each subprocess. An earlier version of this PR added four new cap overrides to the household shard, and they raised batch 4's peak RSS from 17.6 GB to 22.7 GB (measured locally). That killed the 16 GB runner. Every case that varies the cap now lives in pytest, where each simulation is freed after its test.

  • marginal_tax_rate_computed.yaml:
    • earnings rank, not age, decides who is simulated (70/40/38/22-year-olds earning $0/$80k/$50k/$30k give [F, T, T, F]);
    • a 16-year-old earner is never simulated, even with room under the cap.
  • marginal_tax_rate.yaml: three single roommates in Texas in 2024 with wages of $90k, $60k and $30k. After the $14,600 standard deduction they fall in the 22%, 12% and 12% brackets, plus 7.65% employee FICA. Under the default cap the third earner is a placeholder 0 in marginal_tax_rate, federal_marginal_tax_rate and fica_marginal_tax_rate.
  • cliff_evaluated.yaml: the 70/40/38/22 household. The old code returns [T, T, F, F]; the fix returns [F, T, T, F].
  • test_marginal_tax_rate_coverage.py::test_roommates_match_hand_computed_rates: the roommate household at caps 2 and 3. At cap 3 the third earner gets marginal_tax_rate 0.1965, federal_marginal_tax_rate 0.12, fica_marginal_tax_rate 0.0765 and state_marginal_tax_rate 0. These hand-computed values are positive oracles for both the net-income loop and compute_component_mtr.

Options considered

NaN for uncomputed people was rejected. Under any labor supply reform it propagates into employment_income and household_net_income (0 × NaN is NaN), which makes policyengine.py economic_impact_analysis raise. It also puts bare NaN tokens in API v1 JSON and fails the simulation API's strict contract. The issue has the full comparison.

axiom: n/a: a simulation-coverage flag and documentation; no statutory provision changes.

Reviewed independently by GPT-6 Astra (subfleet review, tier hard): APPROVE with documentation and test-precision nits, all addressed in the second commit, which Astra re-reviewed and approved at dd8db7d. A third commit moves the cap-override cases from YAML to pytest to fit the household shard's memory budget. It adds no new assertions beyond the roommate cap-3 check, which was previously in YAML. It independently re-derived the roommate arithmetic and the labor supply example.

🤖 Generated with Claude Code

The marginal tax rate variables are simulated only for the top
simulation.marginal_tax_rate_adults adults per household by market
income; everyone else gets a placeholder zero that looks like a real
one. Add a flag for exactly the simulated people, define
cliff_evaluated as that flag (it ranked adults by age, while cliff_gap
comes from the earnings-ranked marginal_tax_rate), and document the cap
on every affected variable and the parameter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.82%. Comparing base (909176a) to head (6948513).
⚠️ Report is 163 commits behind head on main.

Additional details and impacted files
@@             Coverage Diff             @@
##              main    #9653      +/-   ##
===========================================
- Coverage   100.00%   92.82%   -7.18%     
===========================================
  Files            4        8       +4     
  Lines           76      223     +147     
  Branches         2        8       +6     
===========================================
+ Hits            76      207     +131     
- Misses           0       15      +15     
- Partials         0        1       +1     
Flag Coverage Δ
unittests 92.82% <100.00%> (-7.18%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

MaxGhenis and others added 2 commits September 28, 2026 15:54
Describe the ranking as market income rather than earnings, name the five
variables the flag covers and exclude the separately ranked capital gains
MTR, add federal and FICA positive oracles for the component helper, add
negative market income households to the property grid, and scope the
cap-monotonicity invariant to simulations without behavioral responses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The batched YAML runner caches one tax-benefit system copy per distinct
parameter override for the life of each subprocess. Four new
marginal_tax_rate_adults overrides raised household batch 4's peak RSS
from 17.6 GB to 22.7 GB and killed the 16 GB CI runner. Keep YAML cases
on the default cap and check the cap-3 roommate rates in pytest, where
simulations are freed per test; batch 4 now peaks at 17.7 GB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis merged commit 9143307 into main Oct 4, 2026
37 checks passed
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Merged by the US + core hub on the merge gates:

  • Head: 6948513. That is the reviewed head dab3797 plus a merge of main (picks up Fix read-only cache tests with Core 3.32.16 #9782 and Make Codecov's project status informational #9777). The PR diff is byte-identical.
  • CI: all check runs successful on this head.
  • Reviews: Astra re-review APPROVE on a335a18, and hub lander r5 (Sol) on dab3797 found no blocking defect, withholding only for unverified CI. The body states the impact from the certified file: 14,042 records' cliff_evaluated disagreement, and no MTR, cliff gap or labor supply number changes. The hub's GPT-6.1 Sol batched main-merge check approved this head (~/reviews/us-hub/lander/batch3-mainmerge-review-r2.md).
  • Admin merge: used only for the required-review rule, at the reviewed head.

@MaxGhenis
MaxGhenis deleted the mtr-uncomputed-adults branch October 4, 2026 12:10
MaxGhenis added a commit that referenced this pull request Oct 6, 2026
Conflict: tests/policy/baseline/household/marginal_tax_rate.yaml. Both
sides appended cases at the end of the file. Kept main's comment fix and
its three-roommate case from #9653, then this PR's six Ohio base/alt/rate
cases. The rate variables' docstrings from #9653 merged cleanly next to
this PR's formula changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 6, 2026
… moving inputs

Rates measured in a reformed simulation with labor supply or capital gains
elasticities now hold the responses at their simulated values. Since the
branches raise pre-response inputs (employment_income_before_lsr,
long_term_capital_gains_before_response), recomputing the responses there
scaled them with the raise: a Texas worker's $1,000 raise became $971 of
wages (rate 0.3166, fresh 0.2965), and a 20% capital gains rate read 0.3307.
create_perturbed_branch, which every earnings and capital gains rate uses,
now sets the three earnings responses, the capital gains response and the
weekly hours response to their values in the simulation; neutralized ones,
as in the behavioral response measurement branches, stay zero.

Core branches a reformed simulation's baseline while constructing it, before
the wrappers move inputs. That branch kept employment_income and
weekly_hours_worked as inputs with empty pre-response variables and the stale
input_variables, so its TANF counted no earnings and the baseline rate that
labor supply responses compare against erased pre-response wages: an Ohio
Works First parent got a -$416 response to a top-rate reform that does not
touch them. Both wrappers now branch the baseline again after the moves, as
core's subsample does.

Adds tests for both (invariants 5 and 6) and corrects the #9653 comment on
the component helper's adult loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant