Skip to content

Keep emulator overrides in the marginal-rate branch, and give zero-wage single filers the full delta - #1247

Merged
MaxGhenis merged 3 commits into
mainfrom
claude/unruffled-almeida-48f809
Sep 28, 2026
Merged

MaxGhenis merged 3 commits into
mainfrom
claude/unruffled-almeida-48f809

Conversation

@MaxGhenis

Copy link
Copy Markdown
Contributor

Summary

This PR fixes two bugs in how the emulator computes frate/srate.

1. The marginal-rate branch dropped the emulator's overrides

PolicyEngineRunner._compute_marginal_rates perturbs wages in sim.get_branch("mtr_wage_perturbation") and deletes every cached array not in sim.input_variables. policyengine-core builds input_variables once, in Simulation.__init__, from the dataset's known periods, and PE-US's Microsimulation.__init__ reassigns it once more after moving wages to employment_income_before_lsr. The overrides _build_configured_sim applies afterwards with sim.set_input were not on that list:

SSI, SNAP, TANF and WIC were on the list, because the dataset already supplies them. The branch deleted each missing override, so it reverted to what PE-US computes without it: its formula, or its default for a pure input (the rental QBID gate went back to True). frate/srate were therefore measured under different assumptions than fiitax/siitax.

Fix: a new _pin_input(sim, variable_name, value, period) calls set_input and registers the name in sim.input_variables. Every override in _build_configured_sim and _zero_one_time_rebates goes through it. The branch construction moved into _wage_perturbation_branch.

I registered the names in input_variables rather than keeping a private skip-set on purpose. Other branch builders use the same delete-everything-not-in-input_variables pattern:

  • core's Simulation.derivative
  • PE-US's marginal_tax_rate, marginal_tax_rate_including_health_benefits and marginal_tax_rate_on_capital_gains
  • PE-US's SSA revenue variables

None of these is reached by the emulator today, but they now keep the overrides too.

2. Zero-wage filers with no spouse got half the perturbation

This one was found by the differential invariant below. For a tax unit with no wages, the runner gave the head 0.5 and the spouse 0.5 of the $100 delta, but the rate still divides by the full $100. With no spouse, half the delta went nowhere, which halved frate and srate. core/marginal_rates.compute_marginal_rates_single had the same split. A lone head now gets the whole delta in both paths.

Before / after

Setup: PE-US 2.11.2, policyengine-core 3.32.6, 2024 records. "Old" is origin/main's runner and "new" is this branch. taxsim35 is the bundled resources/taxsim35/taxsim35-osx.exe (cdate-2025Aug23). The newer taxsimtest build prints frate/srate as 0 (question to Dan queued in d242).

record (SOI state) cause taxsim35 frate / srate old new
MD single $80k (21) MD local tax lost 22 / 4.75 22 / 2253.01 22 / 4.75
MD MFJ $100k (21) MD local tax lost 12 / 4.75 12 / 2678.73 12 / 4.75
MD MFJ $60k+$40k, 2 deps, itemizer (21) MD local tax lost 12 / 4.75 12 / 2448.45 12 / 4.75
CA single $60k + $20k otherprop (5) rental QBID gate lost 22 / 9.30 -858 / 9.30 22 / 9.30
MN single $30k, $12k rent (24) MN CRP lost 12 / 5.35 12 / 1253.35 12 / 6.35
ME single 70, $20k, $12k rent (20) ME rent utilities lost 10 / 0.00 10 / -266 10 / 4.00
--disable-salt MD single $80k, $20k mortgage, $4k property tax (21) SALT = 0 lost 20.95 / 4.75 (see below) -1291.23 / 1607.62 22 / 4.75
--disable-salt NY single $84k, $37k mortgage (33) SALT = 0 lost 11.34 / 5.50 -470.46 / 5.50 12 / 5.50
OH single, $50k interest, no wages (36) half delta 12 / 2.75 measuring 12 / 2.75
MA single 69, $4.3k interest, $4.3k rent, no wages (22) half delta (+ MA supplement lost) 0 / 10.00 measuring 0 / 10.00

The remaining MN and ME srate gaps (6.35 vs 5.35, 4.00 vs 0.00) predate this change and are unrelated. siitax for those records differs from this older taxsim35 build too: MN 456.44 vs -791.56, ME -3150 vs -880.

The originally reported case, MD single at exactly $100k (srate 2983.49), was $2,902.74 of county tax plus 80.75 from MD's personal-exemption step. After this PR it reports 80.75, which is just the step. PE's $100 difference crosses the step: exemptions fall from $3,200 at FAGI $100,000 to $1,600 at $100,100, so MD tax rises $80.75. TAXSIM reports 5.05. Its docs say it applies "a linear interpolation along the steps" and retries the difference downward when a state rate exceeds 25%. That is a separate methodology gap, split into its own task, so the tests stay off the steps.

Invariants

These hold for every input and are tested:

  1. Differential: branch = rebuild. For every tax unit, frate = 100 · (income_tax(w + share·$100) − income_tax(w)) / $100, and likewise srate with state_income_tax. The perturbed-wage sim is built independently by _build_configured_sim, so it re-applies every override. The shares follow TAXSIM's weighted-average earnings rule, written out independently of the runner in the test: proportional to wages, else 50/50 for a couple, else all to the head. Notches do not break this, because both sims cross them the same way.
  2. Override retention. Every variable set with set_input after the Microsimulation is built is registered in sim.input_variables and has the same value in the MTR branch as in the base sim.
  3. Delta conservation. Each tax unit's wage perturbation sums to exactly $100.
  4. Base outputs unchanged. Registering the names changes nothing outside the MTR branch. A review agent diffed every non-MTR output column (37 per record set) between this branch and a patched old _pin_input that only calls set_input, across 17 records in both venvs, and found them identical.

An eCPS 200-per-state main-vs-branch cell diff is running in a second session; results will be added here.

Does the same issue affect anything else?

  • Rebate-free twin (rebate_free_sim_factory): not affected. It is a fresh _build_configured_sim, so every override is re-applied, and it is never branched. It feeds only srebate and v40.
  • Single-household path (compute_marginal_rates_single, compute_srebate_single): not affected by bug 1. It builds a new Simulation from a deep copy of the situation, which carries its overrides, and re-applies --disable-salt. It did have bug 2, fixed here.
  • PE-US formulas reached during the base calculation clone and set_input but delete nothing, and a branch clones the parent's holder storage with the overrides. This covers itemizing / not-itemizing, no_salt in ctc_limiting_tax_liability, DE/VA EITC refundability, ID aged/disabled, and Medicaid for SSI state supplement.
  • Substring delete loops. The AL 2021 federal-deduction and NY ny_ctc_pre_2024* / ny_eitc branches delete by name substring (ctc, cdcc, eitc). That does match ny_additional_ctc and ny_supplemental_eitc, but only in those private branches, which compute federal CTC/EITC and never read the NY payments. The pre-ARPA ny_eitc path runs only for TY 2021.
  • Core Simulation.derivative, PE-US marginal_tax_rate* and SSA revenue variables use the same stale-list pattern. The emulator doesn't reach them, and this change protects them anyway.

Tests

The new tests/test_mtr_branch_overrides.py has 8 tests:

  • test_md_srate_is_state_only_bracket_rate: MD single $80k has srate 4.75 and frate 22; MD MFJ $100k has srate 4.75 and frate 12.
  • test_rental_qbid_gate_holds_in_mtr_branch: the CA otherprop record has frate 22.
  • test_disable_salt_itemizer_rates: under --disable-salt, the MD itemizer has frate 22 and srate 4.75.
  • test_zero_wage_single_gets_full_delta: OH gives 12 / 2.75 and MA gives 0 / 10.00.
  • test_single_household_path_zero_wage_single: export_household on the OH record gives 12 / 2.75.
  • test_expectations_match_taxsim35: re-runs the bundled binary so the constants cannot drift from it. It runs on macOS only, because the unix/windows taxsim35 builds carry no build stamp to confirm they are the same build.
  • test_every_override_survives_mtr_branch: one configured sim, with --disable-salt and --assume-w2-wages, whose chunk triggers every override. A Microsimulation subclass records every post-construction set_input. The test asserts invariants 2 and 3, and that SALT is 0 in the real branch. It also catches any future override added with a bare sim.set_input.
  • test_mtr_equals_finite_difference_of_rebuilt_sim: invariant 1 as a Hypothesis property. It draws random chunks across AR, CA, ME, MD, MA, MN, NY, OH and TX, both filing statuses, zero and positive wages, itemizing, rent, otherprop, pass-through and interest income, years 2022–2024, and both flags. It is seeded with a fixed chunk covering every override path, the zero-wage cases and the MD step. It runs derandomized for CI and adds hypothesis to the CI install step.

On origin/main's runner the behavioral tests fail: srate 2253.01, frate -858, frate -1291.23, "overrides not registered", and the halved zero-wage rates. The binary cross-check passes either way.

Local results on both CI Python versions are being filled in.

Ground truth (evidence rules 1, 4, 5)

The expected rates come from the bundled TAXSIM-35 binary and the official schedules, never from the fix.

TAXSIM-35 (resources/taxsim35/taxsim35-osx.exe, cdate-2025Aug23), CSV on stdin:

taxsimid,year,state,fiitax,siitax,fica,frate,srate
1.,2024,21,9442.11,3467.25,12240.00,22.00,4.75   # MD single $80k
2.,2024,21,8032.24,4134.63,15300.00,12.00,4.75   # MD MFJ $100k
4.,2024,5,9442.11,3315.39,9180.00,22.00,9.30     # CA $60k + $20k otherprop
3.,2024,21,6833.90,2455.50,12240.00,20.95,4.75   # MD itemizer (TAXSIM deducts state tax at the margin)
9.,2024,36,4016.12,960.20,.00,12.00,2.75         # OH single, $50k interest, no wages
6.,2024,22,.00,-710.57,.00,.00,10.00             # MA low-income elderly renter (#1031)

taxsimtest-osx.exe (cd2026081819) reports these v-columns:

record v36 MD taxable income v41 "State Bracket Rate" v18 federal taxable income
MD single $80k $74,100 4.75 —
MD MFJ $100k $88,150 4.75 $70,800
MD itemizer $52,800 4.75 —
CA otherprop — — $65,400

Maryland 2024 Resident Tax Booklet, p. 14 (PDF p. 22), heading "MARYLAND TAX COMPUTATION WORKSHEET SCHEDULES" (pdftotext -layout), link:

Tax Rate Schedule I
For taxpayers filing as Single, Married Filing Separately, or as Dependent Taxpayers. [...]
$3,000 $100,000 $90.00 plus 4.75% of excess over $3,000

Tax Rate Schedule II
For taxpayers Married Filing Jointly, Head of Household, or for Qualifying Surviving Spouse. [...]
$3,000 $150,000 $90.00 plus 4.75% of excess over $3,000

IRS Rev. Proc. 2023-34, §3.01 "Tax Rate Tables. For taxable years beginning in 2024", link:

Table 1, Section 1(j)(2)(A), Married Individuals Filing Joint Returns and Surviving Spouses (PDF p. 5):
Over $23,200 but not over $94,300 — $2,320 plus 12% of the excess over $23,200

Table 3, Section 1(j)(2)(C), Unmarried Individuals (other than Surviving Spouses and Heads of Households) (PDF p. 6):
Over $11,600 but not over $47,150 — $1,160 plus 12% of the excess over $11,600
Over $47,150 but not over $100,525 — $5,426 plus 22% of the excess over $47,150

For the --disable-salt itemizer the federal expectation is the Table 3 rate. --disable-salt keeps state income tax out of federal Schedule A in both the base sim and the branch. Federal itemized deductions are then the fixed $24,000 of mortgage and property tax, and federal taxable income is $56,000, which PE computes and which sits in the 22% row. TAXSIM's own frate here (20.95) deducts state tax at the margin, so it is not the comparison. run()'s docstring says TAXSIM does not deduct state income tax federally, but both bundled binaries do. That is split into a separate task.

Out of scope, split into tasks

  • The MD exemption-step notch: implement TAXSIM's downward retry or step interpolation.
  • Recheck --disable-salt and run()'s docstring against the binaries' federal state-tax deduction.

axiom: n/a: emulator marginal-rate plumbing (branch override retention, perturbation shares); no policy rule changes.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
policyengine-taxsim Ready Ready Preview Sep 27, 2026 10:41am UTC

Request Review

MaxGhenis added a commit that referenced this pull request Sep 26, 2026
…is to the hatch env

Review nits on #1247: the skipif marker sat on the _taxsim35 helper, where
pytest ignores it, so non-macOS runs fell through to the OSError skip; move it
to test_expectations_match_taxsim35. The property tests import hypothesis, so
list it in the default hatch environment alongside pytest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review September 26, 2026 02:56
…ge single filers the full delta

Squash of PR #1247 onto main after #1249 (state-0 SALT). Every post-build
set_input override in _build_configured_sim and _zero_one_time_rebates now goes
through _pin_input, which also registers the variable in sim.input_variables,
so the wage-perturbation branch keeps it. #1249's zero_salt SALT input is
pinned the same way; its keep= argument still threads into the branch helper.
A lone zero-wage head now receives the whole wage perturbation in both the
runner and core.marginal_rates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Integration review nit: since #1249, _build_configured_sim takes zero_salt
explicitly instead of reading self.disable_salt, so the property test's
disable_salt=True cases were silently exercising ordinary SALT. Pass
zero_salt=runner.disable_salt (the generated states never include state 0,
so this is exactly what production passes for these records).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_pin_input registers every override in sim.input_variables, including the
state-0 / --disable-salt SALT zeroing that #1249 kept through a keep
argument, so keep and the zero_salt pass-through to
_extract_vectorized_results no longer do anything. #1249 already fixed
the --disable-salt frate, so the changelog and test docstrings no longer
claim it; the MD itemizer's srate (1607.62 on main) is what this PR fixes.
Record the stack the before values were measured on.

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

Copy link
Copy Markdown
Contributor Author

Merge audit (2026-09-28 open-PR sweep): gh pr checks exit 0 at head 5ae3931; mergeable=MERGEABLE; not draft; no CHANGES_REQUESTED. Independent review: GPT-6 Astra via Subfleet (job11), verdict APPROVE: Gates pass: 11 passing checks, MERGEABLE, not draft, no change requests; publish/versioning are intentionally skipped on PRs.; _pin_input registers overrides without mutating a shared list, so the marginal-rate branch retains them. Both paths correctly allocate the full perturbation to a zero-wage filer without a spouse.; Tests cover external rate expectations, override retention, perturbation conservation and independently rebuilt simulations; Hypothesis is installed in CI. No blocking regressi. Repo rules: Org requires passing CI and immediate pre-merge recheck; repo requires external-ground-truth tests and towncrier fragments; squash is allowed and matches recent merges.

@MaxGhenis
MaxGhenis merged commit 163560d into main Sep 28, 2026
13 checks passed

This branch was successfully deployed

1 active deployment
Preview — 5ae3931d Deployed Sep 27, 2026 by vercel[bot]
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