Keep emulator overrides in the marginal-rate branch, and give zero-wage single filers the full delta - #1247
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…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>
…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>
7da8e2b to
9833587
Compare
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>
|
Merge audit (2026-09-28 open-PR sweep): |
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_ratesperturbs wages insim.get_branch("mtr_wage_perturbation")and deletes every cached array not insim.input_variables. policyengine-core buildsinput_variablesonce, inSimulation.__init__, from the dataset's known periods, and PE-US'sMicrosimulation.__init__reassigns it once more after moving wages toemployment_income_before_lsr. The overrides_build_configured_simapplies afterwards withsim.set_inputwere not on that list:--disable-saltSSI, 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/sratewere therefore measured under different assumptions thanfiitax/siitax.Fix: a new
_pin_input(sim, variable_name, value, period)callsset_inputand registers the name insim.input_variables. Every override in_build_configured_simand_zero_one_time_rebatesgoes through it. The branch construction moved into_wage_perturbation_branch.I registered the names in
input_variablesrather than keeping a private skip-set on purpose. Other branch builders use the same delete-everything-not-in-input_variablespattern:Simulation.derivativemarginal_tax_rate,marginal_tax_rate_including_health_benefitsandmarginal_tax_rate_on_capital_gainsNone 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
frateandsrate.core/marginal_rates.compute_marginal_rates_singlehad 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.
taxsim35is the bundledresources/taxsim35/taxsim35-osx.exe(cdate-2025Aug23). The newertaxsimtestbuild printsfrate/srateas 0 (question to Dan queued in d242).otherprop(5)--disable-saltMD single $80k, $20k mortgage, $4k property tax (21)--disable-saltNY single $84k, $37k mortgage (33)The remaining MN and ME srate gaps (6.35 vs 5.35, 4.00 vs 0.00) predate this change and are unrelated.
siitaxfor 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:
frate= 100 · (income_tax(w + share·$100) −income_tax(w)) / $100, and likewisesratewithstate_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.set_inputafter the Microsimulation is built is registered insim.input_variablesand has the same value in the MTR branch as in the base sim._pin_inputthat only callsset_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_sim_factory): not affected. It is a fresh_build_configured_sim, so every override is re-applied, and it is never branched. It feeds onlysrebateandv40.compute_marginal_rates_single,compute_srebate_single): not affected by bug 1. It builds a newSimulationfrom a deep copy of the situation, which carries its overrides, and re-applies--disable-salt. It did have bug 2, fixed here.set_inputbut delete nothing, and a branch clones the parent's holder storage with the overrides. This covers itemizing / not-itemizing,no_saltinctc_limiting_tax_liability, DE/VA EITC refundability, ID aged/disabled, and Medicaid for SSI state supplement.ny_ctc_pre_2024*/ny_eitcbranches delete by name substring (ctc,cdcc,eitc). That does matchny_additional_ctcandny_supplemental_eitc, but only in those private branches, which compute federal CTC/EITC and never read the NY payments. The pre-ARPAny_eitcpath runs only for TY 2021.Simulation.derivative, PE-USmarginal_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.pyhas 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 CAotherproprecord 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_householdon 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/windowstaxsim35builds carry no build stamp to confirm they are the same build.test_every_override_survives_mtr_branch: one configured sim, with--disable-saltand--assume-w2-wages, whose chunk triggers every override. AMicrosimulationsubclass records every post-constructionset_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 baresim.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 addshypothesisto 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:taxsimtest-osx.exe(cd2026081819) reports these v-columns:v36MD taxable incomev41"State Bracket Rate"v18federal taxable incomeotherpropFor the
--disable-saltitemizer the federal expectation is the Table 3 rate.--disable-saltkeeps 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
--disable-saltandrun()'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