-
Notifications
You must be signed in to change notification settings - Fork 205
Description
Summary
When both labor supply responses (LSR) and capital gains (CG) behavioral responses are enabled simultaneously, the simulation produces nonsensical results — e.g., $8.6 trillion in federal tax revenue impact for a single year, compared to $6.2B (LSR only) or $21.9B (CG only).
Root cause
LSR and CG behavioral responses each create simulation branches to measure marginal tax rates, but they don't neutralize each other's responses in their measurement branches. This creates a compounding feedback loop:
The branch nesting problem
- LSR creates
lsr_measurementandbaseline_lsr_measurementbranches, neutralizingemployment_income_behavioral_responseandself_employment_income_behavioral_response - Inside those branches,
relative_income_changecomputeshousehold_net_income, which depends onlong_term_capital_gains long_term_capital_gains=long_term_capital_gains_before_response+capital_gains_behavioral_responsecapital_gains_behavioral_responseis NOT neutralized in the LSR branches, so it firescapital_gains_behavioral_responsecallsrelative_capital_gains_mtr_change, which creates its OWN sub-branches (baseline_cgr_measurement,cgr_measurement)- Inside those,
marginal_tax_rate_on_capital_gainscreates FURTHER branches (adult_1_cg_rise,adult_2_cg_rise)
The result is deeply nested branches where each behavioral response computes MTRs in a context where the other response's variables have been partially neutralized, producing incorrect MTR measurements. These wrong MTRs feed into wrong behavioral responses, which compound into the $8.6T result.
Specifically
- LSR measurement branches see CG-adjusted
household_net_income(because CG response isn't neutralized), so the income change and wage change used for LSR are wrong - CG response inside LSR branches computes MTRs in a context where employment income responses are neutralized, getting wrong CG MTRs
- The responses amplify each other instead of being independent
Reproduction
from policyengine_core.reforms import Reform
from policyengine_us import Microsimulation
DATE_RANGE = "2020-01-01.2100-12-31"
# Any reform that changes taxes
reform = Reform.from_dict({
"gov.contrib.congress.watca.in_effect": {DATE_RANGE: True},
"gov.contrib.congress.watca.surtax.in_effect": {DATE_RANGE: True},
}, country_id="us")
# Enable both LSR and CG responses
lsr = Reform.from_dict({
"gov.simulation.labor_supply_responses.elasticities.income": {DATE_RANGE: -0.05},
"gov.simulation.labor_supply_responses.elasticities.substitution.all": {DATE_RANGE: 0.25},
}, country_id="us")
cg = Reform.from_dict({
"gov.simulation.capital_gains_responses.elasticity": {DATE_RANGE: -0.62},
}, country_id="us")
sim_baseline = Microsimulation()
sim_reform = Microsimulation(reform=(reform, lsr, cg))
# This produces ~$8.6T instead of a reasonable ~$15-25B
tax_baseline = sim_baseline.calculate("income_tax", period=2026, map_to="household")
tax_reform = sim_reform.calculate("income_tax", period=2026, map_to="household")
print(f"Impact: ${float((tax_reform - tax_baseline).sum()) / 1e9:.1f}B")Individual responses work correctly:
- LSR only: $6.2B (reasonable)
- CG only: $21.9B (reasonable)
- LSR + CG: $8,651.3B (broken)
Proposed fix
Each behavioral response's measurement branches should neutralize ALL other behavioral responses, not just their own. Specifically:
- In
labor_supply_behavioral_response.formula, addcapital_gains_behavioral_responseto the list of neutralized variables in the measurement branches - In
relative_capital_gains_mtr_change.formula, addemployment_income_behavioral_responseandself_employment_income_behavioral_responseto the list of neutralized variables in the measurement branches
This makes the responses additive (each measured against the static baseline) rather than compounding.
Related issues
- Applying behavioral responses to empty reform causes nonzero change #5504 — behavioral responses breaking with empty reform
- Behavioral responses breaking in web app #5654 — behavioral responses breaking in web app
- Childcare subsidy programs should use *_before_lsr income variables to avoid LSR circular dependency #7024 — childcare subsidy LSR circular dependency
- Bug — LSR flips sign when baseline earnings < 0 #6053 — LSR sign flip with negative earnings