From 389e5718d5355e7b7be40a8f36683a07005bd0cf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 17:43:16 -0400 Subject: [PATCH 01/14] Measure labour supply responses against the reform's baseline relative_wage_change, relative_income_change and employment_income_behavioral_response took the baseline from simulation.get_branch("baseline"). policyengine_uk.Simulation keeps its baseline as a separate simulation in simulation.baseline, so that call made a branch of the reform simulation, with the reform's parameters, and every response was zero (#2268). The formulas now measure on one pair of branches, of the reform and of simulation.baseline, opened by the response for each period. Both hold the reform's earnings and gains before responses with the response neutralised. The shared measurement_branch helper, which the capital gains response now uses too, drops what the branched simulation had already calculated, so neither response depends on calculation order or changes on recalculation. Fixes #2268 Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-lsr-baseline-2268.fixed.md | 1 + docs/book/usage/dynamics.md | 37 ++ ...test_cached_arrays_not_written_in_place.py | 8 +- .../tests/test_capital_gains_responses.py | 98 +++++ .../test_labour_supply_response_baseline.py | 413 ++++++++++++++++++ policyengine_uk/utils/capital_gains.py | 36 +- .../utils/labour_supply_responses.py | 50 +++ policyengine_uk/utils/measurement_branches.py | 83 ++++ .../employment_income_behavioral_response.py | 32 +- .../relative_income_change.py | 27 +- .../relative_wage_change.py | 25 +- 11 files changed, 735 insertions(+), 75 deletions(-) create mode 100644 changelog.d/fix-lsr-baseline-2268.fixed.md create mode 100644 policyengine_uk/tests/test_labour_supply_response_baseline.py create mode 100644 policyengine_uk/utils/labour_supply_responses.py create mode 100644 policyengine_uk/utils/measurement_branches.py diff --git a/changelog.d/fix-lsr-baseline-2268.fixed.md b/changelog.d/fix-lsr-baseline-2268.fixed.md new file mode 100644 index 0000000000..769d5896fd --- /dev/null +++ b/changelog.d/fix-lsr-baseline-2268.fixed.md @@ -0,0 +1 @@ +- Measured the elasticity-based labour supply responses against the reform's baseline simulation rather than a copy of the reform, so a reform that changes marginal rates or net income now gets a response when `gov.simulation.labour_supply_responses` elasticities are set. The labour supply and capital gains measurement branches now drop the results their simulation had already calculated, so neither response depends on what was calculated first. diff --git a/docs/book/usage/dynamics.md b/docs/book/usage/dynamics.md index d82a30084b..d4c126b78b 100644 --- a/docs/book/usage/dynamics.md +++ b/docs/book/usage/dynamics.md @@ -157,6 +157,43 @@ print(f"Difference: {(dynamics.fte_impacts.ftes - 94_000):+,.0f}") Small differences from OBR estimates can arise from data vintage, population projections, or minor methodological variations. +## Elasticity-based responses + +Separately from `apply_dynamics`, a reform can set +`gov.simulation.labour_supply_responses.substitution_elasticity` and +`gov.simulation.labour_supply_responses.income_elasticity`. Each person's +`employment_income_behavioral_response` is then their employment income before +responses times the substitution elasticity times `relative_wage_change`, plus +the income elasticity times `relative_income_change`. Both changes compare the +reform with its baseline (`simulation.baseline`), each measured with labour +supply responses switched off and at the reform's earnings and capital gains +before responses: `relative_wage_change` is the relative change in one minus +the marginal tax rate, and `relative_income_change` the relative change in +household net income. The changes are capped at +`gov.simulation.labour_supply_responses.bounds`. The response is part of +`employment_income`, so it feeds into every result that depends on it. + +```python +from policyengine_uk import Simulation + +situation = { + "people": {"person": {"age": {2025: 30}, "employment_income": {2025: 40_000}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, +} +reformed = Simulation( + situation=situation, + reform={ + "gov.hmrc.income_tax.rates.uk[0].rate": {"2025": 0.30}, + "gov.simulation.labour_supply_responses.substitution_elasticity": {"2025": 0.25}, + "gov.simulation.labour_supply_responses.income_elasticity": {"2025": -0.05}, + }, +) +# The marginal rate rises from 28% to 38%, so the wage after tax falls by 5/36, +# and net income falls by £2,743 of £32,145: a response of about -£1,218. +print(reformed.calculate("employment_income_behavioral_response", 2025)) +``` + ## Technical implementation details The dynamics calculation proceeds through several steps: diff --git a/policyengine_uk/tests/code_health/test_cached_arrays_not_written_in_place.py b/policyengine_uk/tests/code_health/test_cached_arrays_not_written_in_place.py index 374ae085ce..67c14752b1 100644 --- a/policyengine_uk/tests/code_health/test_cached_arrays_not_written_in_place.py +++ b/policyengine_uk/tests/code_health/test_cached_arrays_not_written_in_place.py @@ -562,8 +562,12 @@ def test_branching_runs_with_read_only_cache(read_only_cache, case): ) for year in YEARS: sim.calculate("household_net_income", year) + # The measurement branches are not kept; the responses show they ran. if case == "labour_supply_responses": - assert "lsr_measurement" in sim.branches + # The higher personal allowance raises both earners' net income and + # leaves their marginal rates alone, so with a negative income + # elasticity both work less. + response = sim.calculate("employment_income_behavioral_response", 2026) + assert (response[:2] < 0).all() else: - # The measurement branches are deleted; the response shows they ran. assert sim.calculate("capital_gains_behavioural_response", 2026)[0] < 0 diff --git a/policyengine_uk/tests/test_capital_gains_responses.py b/policyengine_uk/tests/test_capital_gains_responses.py index b95eaaba71..d977d73486 100644 --- a/policyengine_uk/tests/test_capital_gains_responses.py +++ b/policyengine_uk/tests/test_capital_gains_responses.py @@ -209,6 +209,104 @@ def test_pre_created_measurement_branch_cannot_poison_the_system(): assert after is before +# Gains near the top of the basic rate band, where the marginal rate depends on +# the level of gains. Inputs are for the year before, so YEAR is uprated. +BAND_EDGE_SITUATION = { + "people": { + "person": { + "age": {YEAR - 1: 45, YEAR: 46}, + "employment_income": {YEAR - 1: 30_000}, + "capital_gains": {YEAR - 1: 22_000}, + } + }, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, +} +RESPONSE_PARTS = ( + "capital_gains_behavioural_response", + "capital_gains_badr_behavioural_response", + "relative_capital_gains_retention_rate_change", + "relative_capital_gains_mtr_change", +) + + +def response_parts(sim) -> dict: + return { + variable: sim.calculate(variable, YEAR).values.copy() + for variable in RESPONSE_PARTS + } + + +def test_response_is_the_same_on_recalculation_near_a_band_edge(): + """The reform's measurement branch started from the gains the reform had + already calculated, after the response, so recalculating measured the rate + at lower gains: -4,766.83 became +1,191.71 (found with issue #2268). The + branch now measures from inputs alone.""" + sim = Microsimulation( + situation=BAND_EDGE_SITUATION, + scenario=Scenario( + parameter_changes={ + **EQUALISED_RATES, + "gov.simulation.capital_gains_responses.elasticity": {str(YEAR): 1.0}, + } + ), + ) + sim.calculate("household_net_income", YEAR) + first = response_parts(sim) + for variable in RESPONSE_PARTS: + sim.delete_arrays(variable, YEAR) + second = response_parts(sim) + + assert first["capital_gains_behavioural_response"][0] < 0 + for variable in RESPONSE_PARTS: + assert second[variable] == pytest.approx(first[variable]), variable + + +def test_response_does_not_depend_on_calculating_the_baseline_first(): + """Without economic assumptions the reform's gains stay at their 2025 + level while the baseline's grow. The baseline's measurement branch started + from the gains the baseline had calculated, so calculating the baseline + first measured its rate at its own gains: the response went from 709.68 to + 1,157.90 (found with issue #2268).""" + from policyengine_uk.scenarios.no_economic_assumptions import ( + remove_economic_assumptions, + ) + + def modify(simulation): + remove_economic_assumptions(simulation) + parameters = simulation.tax_benefit_system.parameters + for name, value in { + **{name: rates[str(YEAR)] for name, rates in EQUALISED_RATES.items()}, + "gov.simulation.capital_gains_responses.elasticity": 1.0, + }.items(): + parameters.get_child(name).update(period="year:2000:100", value=value) + simulation.tax_benefit_system.reset_parameter_caches() + + def build(): + return Microsimulation( + situation=BAND_EDGE_SITUATION, + scenario=Scenario( + simulation_modifier=modify, applied_before_data_load=True + ), + ) + + alone = build() + reform_gains = alone.calculate("capital_gains_before_response", YEAR).values + # The baseline is a Simulation, which returns arrays. + baseline_gains = alone.baseline.calculate("capital_gains_before_response", YEAR) + assert reform_gains[0] < baseline_gains[0] + expected = response_parts(alone) + + baseline_first = build() + baseline_first.baseline.calculate("household_net_income", YEAR) + baseline_first.baseline.calculate("marginal_tax_rate_on_capital_gains", YEAR) + parts = response_parts(baseline_first) + + assert expected["capital_gains_behavioural_response"][0] != 0 + for variable in RESPONSE_PARTS: + assert parts[variable] == pytest.approx(expected[variable]), variable + + def test_two_gainers_in_one_household_respond_symmetrically(): """Equal gainers get equal responses; the second adult is not dropped.""" situation = { diff --git a/policyengine_uk/tests/test_labour_supply_response_baseline.py b/policyengine_uk/tests/test_labour_supply_response_baseline.py new file mode 100644 index 0000000000..489544f891 --- /dev/null +++ b/policyengine_uk/tests/test_labour_supply_response_baseline.py @@ -0,0 +1,413 @@ +"""Elasticity-based labour supply responses against the reform's baseline. + +employment_income_behavioral_response, relative_wage_change and +relative_income_change compare the reform with its baseline, each measured +with labour supply responses neutralised and at the reform's earnings and gains +before responses. The baseline is ``simulation.baseline``, a separately built +simulation. These variables used to take a branch of the reform simulation +named ``baseline``, which carries the reform's parameters, so every response +was zero (issue #2268). + +Each simulation below holds single-person households, one per earner. For any +earnings and elasticities: + +1. With no policy change (a scenario that sets only the elasticities), the + relative wage change, the relative income change and the response are zero. +2. A rise in the basic rate, for an earner whose £1,000 marginal-rate step + stays inside the basic rate band, lowers the relative wage by + (rate rise) / (1 - baseline marginal rate) and the relative income by the + income tax rise over baseline net income. The substitution term is then + negative and the income term positive, as positive substitution and + negative income elasticities imply, and the response is their sum. +3. Zero elasticities give a zero response. +4. Scaling both elasticities by k scales the response by k and leaves the + relative changes unchanged. +5. Calculating the response leaves the baseline's own results, branches and + tax-benefit system unchanged, and keeps the response live in the reform. +6. The response does not depend on the order of calculation: calculating the + baseline first, recalculating after deleting the response, and calculating + periods in any order give the same values. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.model_api import Scenario +from policyengine_uk.scenarios.no_economic_assumptions import ( + remove_economic_assumptions, +) + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2025 +RESPONSE = "employment_income_behavioral_response" +WAGE_CHANGE = "relative_wage_change" +INCOME_CHANGE = "relative_income_change" +SUBSTITUTION_TERM = "substitution_elasticity_lsr" +INCOME_TERM = "income_elasticity_lsr" +BASIC_RATE = "gov.hmrc.income_tax.rates.uk[0].rate" +SUBSTITUTION_ELASTICITY = ( + "gov.simulation.labour_supply_responses.substitution_elasticity" +) +INCOME_ELASTICITY = "gov.simulation.labour_supply_responses.income_elasticity" + +# 2025-26: a 20% basic rate on income above the £12,570 personal allowance, +# and 8% employee National Insurance between £12,570 and £50,270. +BASELINE_BASIC_RATE = 0.20 +PERSONAL_ALLOWANCE = 12_570 +EMPLOYEE_NI_RATE = 0.08 +BASELINE_MARGINAL_RATE = BASELINE_BASIC_RATE + EMPLOYEE_NI_RATE + +PROPERTY_SETTINGS = settings( + max_examples=6, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +# Earnings whose £1,000 marginal-rate step stays above the personal allowance +# and below the higher rate threshold (£50,270). +BASIC_RATE_EARNINGS = st.integers(13_000, 49_000) +ANY_EARNINGS = st.integers(0, 200_000) +SUBSTITUTION_ELASTICITIES = st.floats(0.01, 0.5) +INCOME_ELASTICITIES = st.floats(-0.3, -0.01) + + +def earners(earnings, years=(YEAR,)): + """One single-person household per earner, aged 30 throughout.""" + people = { + f"earner_{i}": { + "age": {year: 30 for year in years}, + "employment_income": {years[0]: amount}, + } + for i, amount in enumerate(earnings) + } + return { + "people": people, + "benunits": {f"{name}_benunit": {"members": [name]} for name in people}, + "households": {f"{name}_household": {"members": [name]} for name in people}, + } + + +def changes(substitution, income, basic_rate=None, years=(YEAR,)): + parameter_changes = { + SUBSTITUTION_ELASTICITY: {str(year): substitution for year in years}, + INCOME_ELASTICITY: {str(year): income for year in years}, + } + if basic_rate is not None: + parameter_changes[BASIC_RATE] = {str(year): basic_rate for year in years} + return parameter_changes + + +def simulate(earnings, substitution, income, basic_rate=None, years=(YEAR,)): + return Simulation( + situation=earners(earnings, years), + scenario=Scenario( + parameter_changes=changes(substitution, income, basic_rate, years) + ), + ) + + +def values(simulation, variable, period=YEAR): + return np.asarray(simulation.calculate(variable, period), dtype=float) + + +def response_parts(simulation, period=YEAR): + """The response first, as the relative changes require.""" + return { + variable: values(simulation, variable, period) + for variable in ( + RESPONSE, + WAGE_CHANGE, + INCOME_CHANGE, + SUBSTITUTION_TERM, + INCOME_TERM, + ) + } + + +def test_reform_raising_the_basic_rate_responds_by_hand_calculated_amounts(): + """The case in issue #2268, checked by hand. + + £40,000 of earnings in 2025-26. Baseline income tax is 20% of £27,430 + (£40,000 less the £12,570 personal allowance), £5,486; at a 30% basic + rate it is £8,229, £2,743 more. Baseline net income is £40,000 less that + tax, employee NI of 8% of £27,430 (£2,194.40), the £174.50 TV licence and + the model's £0.045 of expected stamp duty: £32,145.055. + + The marginal rate rises from 28% to 38%, so the wage after tax falls from + 0.72 to 0.62 of pay: a relative change of -0.10 / 0.72 = -5/36. Net + income falls by 2,743 / 32,145.055. With elasticities of 0.25 and -0.05 + the response is 40,000 x (0.25 x -5/36 + -0.05 x -2,743 / 32,145.055) + = -1,388.89 + 170.66 = -£1,218.23. + """ + sim = simulate([40_000], substitution=0.25, income=-0.05, basic_rate=0.30) + parts = response_parts(sim) + + wage_change = -0.10 / 0.72 + income_change = -2_743 / 32_145.055 + substitution_term = 40_000 * 0.25 * wage_change + income_term = 40_000 * -0.05 * income_change + + assert parts[WAGE_CHANGE] == pytest.approx([wage_change], rel=1e-5) + assert parts[INCOME_CHANGE] == pytest.approx([income_change], rel=1e-5) + assert parts[SUBSTITUTION_TERM] == pytest.approx([-1_388.889], abs=0.01) + assert parts[INCOME_TERM] == pytest.approx([170.664], abs=0.01) + assert parts[RESPONSE] == pytest.approx([substitution_term + income_term], abs=0.01) + assert parts[RESPONSE] == pytest.approx([-1_218.225], abs=0.01) + # The response reaches employment income, and through it income tax. + earnings = 40_000 + parts[RESPONSE][0] + assert values(sim, "employment_income") == pytest.approx([earnings], abs=0.01) + assert values(sim, "income_tax") == pytest.approx( + [0.30 * (earnings - PERSONAL_ALLOWANCE)], abs=0.01 + ) + # The baseline keeps its tax. + assert values(sim.baseline, "income_tax") == pytest.approx([5_486]) + + +@PROPERTY_SETTINGS +@given( + earnings=st.lists(ANY_EARNINGS, min_size=1, max_size=4), + substitution=SUBSTITUTION_ELASTICITIES, + income=INCOME_ELASTICITIES, +) +def test_no_policy_change_gives_no_response(earnings, substitution, income): + """Invariant 1: elasticities alone change nothing to respond to.""" + parts = response_parts(simulate(earnings, substitution, income)) + + for variable, array in parts.items(): + np.testing.assert_array_equal(array, 0, err_msg=variable) + + +def test_simulation_without_a_scenario_gives_no_response(): + """Invariant 1 where the baseline is the simulation's own clone. + + A simulation built without a scenario keeps a clone of itself as its + baseline. Setting the elasticities on it afterwards changes no policy the + baseline measures differently. + """ + sim = Simulation(situation=earners([20_000, 40_000, 80_000])) + sim.apply_parameter_changes(changes(0.25, -0.05)) + + parts = response_parts(sim) + + assert ( + sim.tax_benefit_system.parameters( + str(YEAR) + ).gov.simulation.labour_supply_responses.substitution_elasticity + == 0.25 + ) + for variable, array in parts.items(): + np.testing.assert_array_equal(array, 0, err_msg=variable) + + +@PROPERTY_SETTINGS +@given( + earnings=st.lists(BASIC_RATE_EARNINGS, min_size=1, max_size=4), + basic_rate=st.sampled_from([0.22, 0.25, 0.30, 0.40]), + substitution=SUBSTITUTION_ELASTICITIES, + income=INCOME_ELASTICITIES, +) +def test_basic_rate_rise_gives_responses_of_the_implied_sign( + earnings, basic_rate, substitution, income +): + """Invariant 2: a basic rate rise lowers the wage and income after tax.""" + sim = simulate(earnings, substitution, income, basic_rate) + parts = response_parts(sim) + earnings = np.array(earnings, dtype=float) + + rise = basic_rate - BASELINE_BASIC_RATE + expected_wage_change = -rise / (1 - BASELINE_MARGINAL_RATE) + np.testing.assert_allclose(parts[WAGE_CHANGE], expected_wage_change, rtol=1e-4) + baseline_net_income = values(sim.baseline, "household_net_income") + tax_rise = rise * (earnings - PERSONAL_ALLOWANCE) + np.testing.assert_allclose( + parts[INCOME_CHANGE], -tax_rise / baseline_net_income, rtol=1e-4 + ) + + assert (parts[SUBSTITUTION_TERM] < 0).all() + assert (parts[INCOME_TERM] > 0).all() + np.testing.assert_allclose( + parts[SUBSTITUTION_TERM], + earnings * substitution * parts[WAGE_CHANGE], + rtol=1e-5, + ) + np.testing.assert_allclose( + parts[INCOME_TERM], earnings * income * parts[INCOME_CHANGE], rtol=1e-5 + ) + np.testing.assert_allclose( + parts[RESPONSE], + parts[SUBSTITUTION_TERM] + parts[INCOME_TERM], + rtol=1e-5, + atol=0.01, + ) + + +@PROPERTY_SETTINGS +@given( + earnings=st.lists(ANY_EARNINGS, min_size=1, max_size=4), + basic_rate=st.sampled_from([0.15, 0.25, 0.30]), +) +def test_zero_elasticities_give_no_response(earnings, basic_rate): + """Invariant 3.""" + sim = simulate(earnings, 0, 0, basic_rate) + + np.testing.assert_array_equal(values(sim, RESPONSE), 0) + np.testing.assert_allclose(values(sim, "employment_income"), earnings, rtol=1e-6) + + +# Each example builds two reforms, each with its baseline. +@settings(PROPERTY_SETTINGS, max_examples=4) +@given( + earnings=st.lists(ANY_EARNINGS, min_size=1, max_size=4), + basic_rate=st.sampled_from([0.15, 0.25, 0.30]), + substitution=SUBSTITUTION_ELASTICITIES, + income=INCOME_ELASTICITIES, + factor=st.sampled_from([0.5, 2.0, 3.0]), +) +def test_response_scales_with_the_elasticities( + earnings, basic_rate, substitution, income, factor +): + """Invariant 4: the relative changes do not depend on the elasticities.""" + base = response_parts(simulate(earnings, substitution, income, basic_rate)) + scaled = response_parts( + simulate(earnings, factor * substitution, factor * income, basic_rate) + ) + + np.testing.assert_array_equal(scaled[WAGE_CHANGE], base[WAGE_CHANGE]) + np.testing.assert_array_equal(scaled[INCOME_CHANGE], base[INCOME_CHANGE]) + # The response is stored as float32. + np.testing.assert_allclose( + scaled[RESPONSE], factor * base[RESPONSE], rtol=1e-5, atol=0.01 + ) + + +def test_response_leaves_the_baseline_unchanged(): + """Invariant 5.""" + sim = simulate([18_000, 40_000, 75_000], 0.25, -0.05, basic_rate=0.30) + baseline = sim.baseline + measured = ( + "household_net_income", + "income_tax", + "marginal_tax_rate", + "employment_income", + RESPONSE, + ) + before = {variable: values(baseline, variable) for variable in measured} + branches = set(baseline.branches) + baseline_response = baseline.tax_benefit_system.variables[RESPONSE] + reform_response = sim.tax_benefit_system.variables[RESPONSE] + + response = values(sim, RESPONSE) + # Basic rate taxpayers face a higher marginal rate and lower income, and + # work less. The higher rate taxpayer's marginal rate is unchanged, so + # only the fall in income acts, and with a negative income elasticity + # they work more. + assert (response[:2] < 0).all() + assert response[2] > 0 + + for variable in measured: + np.testing.assert_array_equal( + values(baseline, variable), before[variable], err_msg=variable + ) + assert set(baseline.branches) == branches + assert baseline.tax_benefit_system.variables[RESPONSE] is baseline_response + assert sim.tax_benefit_system.variables[RESPONSE] is reform_response + assert not reform_response.is_neutralized + # The measurement branches are not kept. + assert "lsr_measurement" not in sim.branches + assert "baseline_lsr_measurement" not in baseline.branches + + +def remove_growth_and_raise_the_basic_rate(simulation): + remove_economic_assumptions(simulation) + parameters = simulation.tax_benefit_system.parameters + for name, value in ( + (SUBSTITUTION_ELASTICITY, 0.25), + (INCOME_ELASTICITY, -0.05), + (BASIC_RATE, 0.30), + ): + parameters.get_child(name).update(period="year:2000:100", value=value) + simulation.tax_benefit_system.reset_parameter_caches() + + +def test_response_does_not_depend_on_calculating_the_baseline_first(): + """Invariant 6, where the reform's data differ from the baseline's. + + Without economic assumptions the reform's 2026 earnings stay at their + 2025 level, while the baseline's grow. A measurement branch of the + baseline started from what the baseline had calculated at its own + earnings, and read those results in place of results at the reform's: the + response was -1,218.20 calculated alone and -1,164.16 with the baseline + calculated first. + """ + + def build(): + return Simulation( + situation=earners([40_000], years=(2025, 2026)), + scenario=Scenario( + simulation_modifier=remove_growth_and_raise_the_basic_rate, + applied_before_data_load=True, + ), + ) + + alone = build() + assert values(alone, "employment_income_before_lsr", 2026) == pytest.approx( + [40_000] + ) + assert values(alone.baseline, "employment_income_before_lsr", 2026)[0] > 40_000 + expected = response_parts(alone, 2026) + + baseline_first = build() + values(baseline_first.baseline, "household_net_income", 2026) + values(baseline_first.baseline, "marginal_tax_rate", 2026) + parts = response_parts(baseline_first, 2026) + + assert expected[RESPONSE][0] < 0 + for variable, array in expected.items(): + np.testing.assert_array_equal(parts[variable], array, err_msg=variable) + + +def test_recalculation_gives_the_same_response(): + """Invariant 6 on recalculation. + + The reform's measurement branch started from what the reform had + calculated, including household net income after the response, so + deleting the response and calculating it again gave -1,171.23 instead of + -1,218.23. + """ + sim = simulate([40_000], 0.25, -0.05, basic_rate=0.30) + values(sim, "household_net_income") + first = response_parts(sim) + for variable in first: + sim.delete_arrays(variable, YEAR) + second = response_parts(sim) + sim.reset_calculations() + third = response_parts(sim) + + for variable, array in first.items(): + np.testing.assert_array_equal(second[variable], array, err_msg=variable) + np.testing.assert_array_equal(third[variable], array, err_msg=variable) + + +def test_periods_give_the_same_response_in_any_order(): + """Invariant 6 across periods, against one period per simulation.""" + years = (2025, 2026, 2027) + + def build(): + return simulate([25_000, 40_000], 0.25, -0.05, basic_rate=0.30, years=years) + + alone = {year: values(build(), RESPONSE, year) for year in years} + forwards, backwards = build(), build() + in_order = {year: values(forwards, RESPONSE, year) for year in years} + reversed_order = { + year: values(backwards, RESPONSE, year) for year in reversed(years) + } + + for year in years: + assert (alone[year] < 0).all() + np.testing.assert_array_equal(in_order[year], alone[year]) + np.testing.assert_array_equal(reversed_order[year], alone[year]) diff --git a/policyengine_uk/utils/capital_gains.py b/policyengine_uk/utils/capital_gains.py index 0ce4fcbf7c..130a9b26e6 100644 --- a/policyengine_uk/utils/capital_gains.py +++ b/policyengine_uk/utils/capital_gains.py @@ -5,6 +5,8 @@ from policyengine_core.simulations import Simulation +from policyengine_uk.utils.measurement_branches import measurement_branch + # The response, and the part of it on gains qualifying for Business Asset # Disposal Relief, which capital_gains_tax reads to keep those gains apart. @@ -22,29 +24,19 @@ def measure_mtr( ) -> np.ndarray: """Measure the capital gains MTR in a simulation, holding gains fixed. - The branch clones the tax-benefit system because it neutralises the - behavioural response variable, which would otherwise recurse back into - this measurement. Cloning keeps that neutralisation off the simulation - being measured. + The measurement branch neutralises the behavioural response and measures + from inputs alone, so the rate is taken at ``gains`` even where the + simulation has already calculated gains after a response, or at gains of + its own (see ``measurement_branch``). """ - # get_branch returns an existing branch of the requested name without - # honouring clone_system, and neutralising on a shared system would - # permanently disable the response variable for the caller. Take a name - # nothing else holds, then require the clone before touching it. - while branch_name in simulation.branches: - branch_name += "_" - branch = simulation.get_branch(branch_name, clone_system=True) - if branch.tax_benefit_system is simulation.tax_benefit_system: - raise RuntimeError( - "Capital gains MTR measurement requires a cloned tax-benefit " - "system; refusing to neutralise on the simulation's own." - ) - for variable in RESPONSE_VARIABLES: - branch.tax_benefit_system.neutralize_variable(variable) - branch.set_input("capital_gains_before_response", period, gains) - mtr = branch.populations["person"]("marginal_tax_rate_on_capital_gains", period) - del simulation.branches[branch_name] - return mtr + branch = measurement_branch( + simulation, + branch_name, + period, + RESPONSE_VARIABLES, + {"capital_gains_before_response": gains}, + ) + return branch.populations["person"]("marginal_tax_rate_on_capital_gains", period) def measure_capital_gains_mtrs(person, period) -> tuple[np.ndarray, np.ndarray]: diff --git a/policyengine_uk/utils/labour_supply_responses.py b/policyengine_uk/utils/labour_supply_responses.py new file mode 100644 index 0000000000..991ea2c4ef --- /dev/null +++ b/policyengine_uk/utils/labour_supply_responses.py @@ -0,0 +1,50 @@ +"""Branches that measure a reform and its baseline with labour supply held +fixed, for the elasticity-based labour supply responses.""" + +from contextlib import contextmanager + +from policyengine_uk.utils.measurement_branches import measurement_branch + +RESPONSE_VARIABLE = "employment_income_behavioral_response" + +# Set on both branches to the values of the simulation whose response is being +# measured, so that the two differ in policy alone. +HELD_INPUTS = ("employment_income_before_lsr", "capital_gains_before_response") + +# The pair of branches of each measurement under way, by simulation and period. +# The income and substitution terms of one response read the same pair. +_open_measurements = {} + + +@contextmanager +def measurement_branches(person, period): + """Yield the branches that measure the reform and the baseline with labour + supply held fixed, as (reform, baseline). + + Simulations hold their baseline as a separately constructed simulation + (``simulation.baseline``) rather than a branch named ``baseline``, so the + baseline is measured there. A branch of the reform simulation carries + reform parameters, and reports no change in net income or marginal rates + however large the reform. + + The pair lasts as long as the outermost use: the response opens it, and + the relative income and wage changes it then asks for read the same pair. + """ + simulation = person.simulation + key = (id(simulation), period) + if key in _open_measurements: + yield _open_measurements[key] + return + inputs = {variable: person(variable, period) for variable in HELD_INPUTS} + branches = tuple( + measurement_branch(sim, name, period, [RESPONSE_VARIABLE], inputs) + for sim, name in ( + (simulation, "lsr_measurement"), + (simulation.baseline, "baseline_lsr_measurement"), + ) + ) + _open_measurements[key] = branches + try: + yield branches + finally: + del _open_measurements[key] diff --git a/policyengine_uk/utils/measurement_branches.py b/policyengine_uk/utils/measurement_branches.py new file mode 100644 index 0000000000..b94d029972 --- /dev/null +++ b/policyengine_uk/utils/measurement_branches.py @@ -0,0 +1,83 @@ +"""Branches that measure a simulation's results at inputs set on the branch. + +Behavioural responses compare a reform with its baseline at the same inputs +before the response: the labour supply responses at the reform's earnings, and +the capital gains response at the reform's gains. Each measurement runs in a +branch of the reform simulation or of its baseline, with the response +neutralised and those inputs set. +""" + +from typing import Dict, Iterable + +import numpy as np + + +def drop_calculated_values(simulation) -> None: + """Delete the values a simulation has calculated, keeping its inputs. + + Inputs are variables without a formula, variables the simulation was built + from, and variables set since through set_input, as a scenario's + simulation modifier does to formula variables. Only variables the + simulation holds values for are visited. + """ + supplied = set(getattr(simulation, "input_variables", None) or ()) + supplied.update( + name for name, _, _ in getattr(simulation, "_user_input_keys", None) or () + ) + variables = simulation.tax_benefit_system.variables + held = { + name + for population in simulation.populations.values() + for name in getattr(population, "_holders", {}) + } + for name in held: + variable = variables.get(name) + if variable is None or name in supplied or variable.is_input_variable(): + continue + simulation.delete_arrays(name) + + +def measurement_branch( + simulation, + name: str, + period, + neutralised: Iterable[str], + inputs: Dict[str, np.ndarray], +): + """A branch of ``simulation`` with the ``neutralised`` variables + neutralised and ``inputs`` set for ``period``. + + The branch clones the tax-benefit system, so neutralising a response there + keeps it live in the simulation, and the response does not recurse back + into its own measurement. + + A branch starts from every value its simulation holds, and would read + those in place of values at the inputs set here: results the simulation + calculated at its own inputs before the response (a baseline's, if its + data differ from the reform's), or results that include a response it + calculated earlier. So the branch drops what the simulation calculated and + measures from inputs alone. Its results then do not depend on what either + simulation calculated before. + + Only the caller holds the branch: it is not kept in ``simulation.branches``, + so the next measurement starts afresh. + """ + # get_branch returns an existing branch of the requested name without + # honouring clone_system, and neutralising on a shared system would + # permanently disable the response for the simulation. Take a name nothing + # else holds, then require the clone before touching it. + while name in simulation.branches or name == simulation.branch_name: + name += "_" + branch = simulation.get_branch(name, clone_system=True) + del simulation.branches[name] + if branch.tax_benefit_system is simulation.tax_benefit_system: + raise RuntimeError( + "A measurement branch requires a cloned tax-benefit system; " + "refusing to neutralise on the simulation's own." + ) + for variable in neutralised: + branch.tax_benefit_system.neutralize_variable(variable) + drop_calculated_values(branch) + for variable, value in inputs.items(): + branch.set_input(variable, period, value) + return branch diff --git a/policyengine_uk/variables/gov/simulation/labour_supply_response/employment_income_behavioral_response.py b/policyengine_uk/variables/gov/simulation/labour_supply_response/employment_income_behavioral_response.py index e9becafc4d..e75561fc1f 100644 --- a/policyengine_uk/variables/gov/simulation/labour_supply_response/employment_income_behavioral_response.py +++ b/policyengine_uk/variables/gov/simulation/labour_supply_response/employment_income_behavioral_response.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.labour_supply_responses import measurement_branches class employment_income_behavioral_response(Variable): @@ -16,32 +17,17 @@ def formula(person, period, parameters): if lsr.income_elasticity == 0 and lsr.substitution_elasticity == 0: return 0 - measurement_branch = simulation.get_branch( - "lsr_measurement", clone_system=True - ) # A branch without LSRs - baseline_branch = simulation.get_branch("baseline").get_branch( - "baseline_lsr_measurement", clone_system=True - ) # Already created by default - # (system with LSRs) <- (system without LSRs used to calculate LSRs) # | # * -(baseline system without LSRs used to calculate LSRs) - for branch in [measurement_branch, baseline_branch]: - branch.tax_benefit_system.neutralize_variable( - "employment_income_behavioral_response" - ) - branch.set_input( - "employment_income_before_lsr", + # Both terms measure on the one pair of branches opened here. + with measurement_branches(person, period): + return add( + person, period, - person("employment_income_before_lsr", period), + [ + "income_elasticity_lsr", + "substitution_elasticity_lsr", + ], ) - - return add( - person, - period, - [ - "income_elasticity_lsr", - "substitution_elasticity_lsr", - ], - ) diff --git a/policyengine_uk/variables/gov/simulation/labour_supply_response/relative_income_change.py b/policyengine_uk/variables/gov/simulation/labour_supply_response/relative_income_change.py index b7a8d463e9..63737159fd 100644 --- a/policyengine_uk/variables/gov/simulation/labour_supply_response/relative_income_change.py +++ b/policyengine_uk/variables/gov/simulation/labour_supply_response/relative_income_change.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.labour_supply_responses import measurement_branches class relative_income_change(Variable): @@ -10,20 +11,18 @@ class relative_income_change(Variable): requires_computation_after = "employment_income_behavioral_response" def formula(person, period, parameters): - simulation = person.simulation - measurement_branch = simulation.get_branch("lsr_measurement") - baseline_branch = simulation.get_branch("baseline").get_branch( - "baseline_lsr_measurement" - ) - baseline_branch.set_input( - "capital_gains_before_response", - period, - person("capital_gains_before_response", period), - ) - measurement_person = measurement_branch.populations["person"] - baseline_person = baseline_branch.populations["person"] - baseline_net_income = baseline_person.household("household_net_income", period) - net_income = measurement_person.household("household_net_income", period) + if person.simulation.baseline is None: + return 0 # No reform, no change + with measurement_branches(person, period) as ( + measurement_branch, + baseline_branch, + ): + measurement_person = measurement_branch.populations["person"] + baseline_person = baseline_branch.populations["person"] + baseline_net_income = baseline_person.household( + "household_net_income", period + ) + net_income = measurement_person.household("household_net_income", period) income_change_bound = parameters( period ).gov.simulation.labour_supply_responses.bounds.income_change diff --git a/policyengine_uk/variables/gov/simulation/labour_supply_response/relative_wage_change.py b/policyengine_uk/variables/gov/simulation/labour_supply_response/relative_wage_change.py index 872c5d4fb1..4d12d853cc 100644 --- a/policyengine_uk/variables/gov/simulation/labour_supply_response/relative_wage_change.py +++ b/policyengine_uk/variables/gov/simulation/labour_supply_response/relative_wage_change.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.labour_supply_responses import measurement_branches class relative_wage_change(Variable): @@ -10,21 +11,17 @@ class relative_wage_change(Variable): requires_computation_after = "employment_income_behavioral_response" def formula(person, period, parameters): - simulation = person.simulation - measurement_branch = simulation.get_branch("lsr_measurement") - baseline_branch = simulation.get_branch("baseline").get_branch( - "baseline_lsr_measurement" - ) - baseline_branch.set_input( - "capital_gains_before_response", - period, - person("capital_gains_before_response", period), - ) - measurement_person = measurement_branch.populations["person"] - baseline_person = baseline_branch.populations["person"] - baseline_mtr = baseline_person("marginal_tax_rate", period) + if person.simulation.baseline is None: + return 0 # No reform, no change + with measurement_branches(person, period) as ( + measurement_branch, + baseline_branch, + ): + measurement_person = measurement_branch.populations["person"] + baseline_person = baseline_branch.populations["person"] + baseline_mtr = baseline_person("marginal_tax_rate", period) + mtr = measurement_person("marginal_tax_rate", period) baseline_wage = 1 - baseline_mtr - mtr = measurement_person("marginal_tax_rate", period) wage_rate = 1 - mtr # _c suffix for "clipped" baseline_wage_c = np.where(baseline_wage == 0, 0.01, baseline_wage) From c441d40a8c38e886ffe41bdad6e1a7a9bbf92363 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 18:06:08 -0400 Subject: [PATCH 02/14] Hold every behavioural response fixed in measurement branches With the labour supply response working, each response's measurement recomputed the other: the labour supply branches counted lower realised gains as lost income, and the branches that step gains up to measure the capital gains rate recomputed the labour supply response against a baseline held at gains before the step. On a 16-person dataset with both responses on, the capital gains response went from -150,569 to -554,679. Every measurement branch now neutralises all three response variables, so each response is measured from the position before any response and does not change when the other is switched on. Tests state that, and that every *_behavioural_response variable is in BEHAVIOURAL_RESPONSES. Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-lsr-baseline-2268.fixed.md | 2 +- docs/book/usage/dynamics.md | 6 +- .../test_labour_supply_response_baseline.py | 84 +++++++++++++++++++ policyengine_uk/utils/capital_gains.py | 10 +-- .../utils/labour_supply_responses.py | 4 +- policyengine_uk/utils/measurement_branches.py | 34 +++++--- 6 files changed, 116 insertions(+), 24 deletions(-) diff --git a/changelog.d/fix-lsr-baseline-2268.fixed.md b/changelog.d/fix-lsr-baseline-2268.fixed.md index 769d5896fd..26eb4db6ea 100644 --- a/changelog.d/fix-lsr-baseline-2268.fixed.md +++ b/changelog.d/fix-lsr-baseline-2268.fixed.md @@ -1 +1 @@ -- Measured the elasticity-based labour supply responses against the reform's baseline simulation rather than a copy of the reform, so a reform that changes marginal rates or net income now gets a response when `gov.simulation.labour_supply_responses` elasticities are set. The labour supply and capital gains measurement branches now drop the results their simulation had already calculated, so neither response depends on what was calculated first. +- Measured the elasticity-based labour supply responses against the reform's baseline simulation rather than a copy of the reform, so a reform that changes marginal rates or net income now gets a response when `gov.simulation.labour_supply_responses` elasticities are set. The labour supply and capital gains measurements now start from what their simulation was given, not what it had already calculated, and hold every behavioural response at zero, so neither response depends on what was calculated first or includes the other. diff --git a/docs/book/usage/dynamics.md b/docs/book/usage/dynamics.md index d4c126b78b..8446253500 100644 --- a/docs/book/usage/dynamics.md +++ b/docs/book/usage/dynamics.md @@ -165,9 +165,9 @@ Separately from `apply_dynamics`, a reform can set `employment_income_behavioral_response` is then their employment income before responses times the substitution elasticity times `relative_wage_change`, plus the income elasticity times `relative_income_change`. Both changes compare the -reform with its baseline (`simulation.baseline`), each measured with labour -supply responses switched off and at the reform's earnings and capital gains -before responses: `relative_wage_change` is the relative change in one minus +reform with its baseline (`simulation.baseline`), each measured with every +behavioural response switched off and at the reform's earnings and capital +gains before responses: `relative_wage_change` is the relative change in one minus the marginal tax rate, and `relative_income_change` the relative change in household net income. The changes are capped at `gov.simulation.labour_supply_responses.bounds`. The response is part of diff --git a/policyengine_uk/tests/test_labour_supply_response_baseline.py b/policyengine_uk/tests/test_labour_supply_response_baseline.py index 489544f891..08af6605c5 100644 --- a/policyengine_uk/tests/test_labour_supply_response_baseline.py +++ b/policyengine_uk/tests/test_labour_supply_response_baseline.py @@ -27,6 +27,10 @@ 6. The response does not depend on the order of calculation: calculating the baseline first, recalculating after deleting the response, and calculating periods in any order give the same values. +7. Each behavioural response is measured from the position before any + response, so setting the capital gains elasticity leaves the labour supply + response unchanged, and setting the labour supply elasticities leaves the + capital gains response unchanged. """ import numpy as np @@ -39,6 +43,8 @@ from policyengine_uk.scenarios.no_economic_assumptions import ( remove_economic_assumptions, ) +from policyengine_uk.utils.capital_gains import RESPONSE_VARIABLES +from policyengine_uk.utils.measurement_branches import BEHAVIOURAL_RESPONSES pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") @@ -411,3 +417,81 @@ def build(): assert (alone[year] < 0).all() np.testing.assert_array_equal(in_order[year], alone[year]) np.testing.assert_array_equal(reversed_order[year], alone[year]) + + +CAPITAL_GAINS_RESPONSE = "capital_gains_behavioural_response" +# Capital gains tax charged at income tax rates, with a realisation response. +CAPITAL_GAINS_REFORM = { + "gov.hmrc.cgt.basic_rate": {str(YEAR): 0.20}, + "gov.hmrc.cgt.higher_rate": {str(YEAR): 0.40}, + "gov.hmrc.cgt.additional_rate": {str(YEAR): 0.45}, +} +CAPITAL_GAINS_ELASTICITY = "gov.simulation.capital_gains_responses.elasticity" + + +def test_each_response_is_measured_before_the_other(): + """Invariant 7. + + When only each response's own variable was neutralised in its + measurement, the capital gains marginal rate, measured by stepping gains + up in branches, recomputed the labour supply response there against a + baseline held at gains before the step. The spurious change in net + income moved the capital gains rate: on a 16-person dataset the capital + gains response went from -150,569 to -554,679 when the labour supply + elasticities were set. + """ + people = { + name: { + "age": {YEAR: 45}, + "employment_income": {YEAR: earnings}, + "capital_gains": {YEAR: gains}, + } + for name, earnings, gains in ( + ("basic_rate_earner", 25_000, 10_000), + ("crossing_the_band", 45_000, 40_000), + ("higher_rate_earner", 90_000, 150_000), + ) + } + situation = { + "people": people, + "benunits": {f"{name}_benunit": {"members": [name]} for name in people}, + "households": {f"{name}_household": {"members": [name]} for name in people}, + } + + def responses(capital_gains_elasticity, substitution, income): + sim = Simulation( + situation=situation, + scenario=Scenario( + parameter_changes={ + **CAPITAL_GAINS_REFORM, + CAPITAL_GAINS_ELASTICITY: {str(YEAR): capital_gains_elasticity}, + **changes(substitution, income), + } + ), + ) + return values(sim, RESPONSE), values(sim, CAPITAL_GAINS_RESPONSE) + + labour_both, gains_both = responses(1.0, 0.25, -0.05) + labour_only, no_gains_response = responses(0.0, 0.25, -0.05) + no_labour_response, gains_only = responses(1.0, 0.0, 0.0) + + np.testing.assert_array_equal(no_gains_response, 0) + np.testing.assert_array_equal(no_labour_response, 0) + assert (gains_only < 0).all() + assert (labour_only != 0).any() + np.testing.assert_array_equal(gains_both, gains_only) + np.testing.assert_array_equal(labour_both, labour_only) + + +def test_measurements_neutralise_every_behavioural_response(): + """A response missing from BEHAVIOURAL_RESPONSES would be recomputed + inside the other responses' measurements.""" + variables = Simulation(situation=earners([0])).tax_benefit_system.variables + responses = { + name + for name in variables + if name.endswith(("behavioural_response", "behavioral_response")) + } + + assert responses == set(BEHAVIOURAL_RESPONSES) + assert set(RESPONSE_VARIABLES) <= responses diff --git a/policyengine_uk/utils/capital_gains.py b/policyengine_uk/utils/capital_gains.py index 130a9b26e6..f9bfc89d17 100644 --- a/policyengine_uk/utils/capital_gains.py +++ b/policyengine_uk/utils/capital_gains.py @@ -24,16 +24,16 @@ def measure_mtr( ) -> np.ndarray: """Measure the capital gains MTR in a simulation, holding gains fixed. - The measurement branch neutralises the behavioural response and measures - from inputs alone, so the rate is taken at ``gains`` even where the - simulation has already calculated gains after a response, or at gains of - its own (see ``measurement_branch``). + The measurement branch neutralises the behavioural responses and measures + from inputs alone, so the rate is taken at ``gains`` and at earnings + before any labour supply response, even where the simulation has already + calculated gains after a response, or at gains of its own (see + ``measurement_branch``). """ branch = measurement_branch( simulation, branch_name, period, - RESPONSE_VARIABLES, {"capital_gains_before_response": gains}, ) return branch.populations["person"]("marginal_tax_rate_on_capital_gains", period) diff --git a/policyengine_uk/utils/labour_supply_responses.py b/policyengine_uk/utils/labour_supply_responses.py index 991ea2c4ef..50e592d8b1 100644 --- a/policyengine_uk/utils/labour_supply_responses.py +++ b/policyengine_uk/utils/labour_supply_responses.py @@ -5,8 +5,6 @@ from policyengine_uk.utils.measurement_branches import measurement_branch -RESPONSE_VARIABLE = "employment_income_behavioral_response" - # Set on both branches to the values of the simulation whose response is being # measured, so that the two differ in policy alone. HELD_INPUTS = ("employment_income_before_lsr", "capital_gains_before_response") @@ -37,7 +35,7 @@ def measurement_branches(person, period): return inputs = {variable: person(variable, period) for variable in HELD_INPUTS} branches = tuple( - measurement_branch(sim, name, period, [RESPONSE_VARIABLE], inputs) + measurement_branch(sim, name, period, inputs) for sim, name in ( (simulation, "lsr_measurement"), (simulation.baseline, "baseline_lsr_measurement"), diff --git a/policyengine_uk/utils/measurement_branches.py b/policyengine_uk/utils/measurement_branches.py index b94d029972..677c6fcd09 100644 --- a/policyengine_uk/utils/measurement_branches.py +++ b/policyengine_uk/utils/measurement_branches.py @@ -1,16 +1,27 @@ """Branches that measure a simulation's results at inputs set on the branch. Behavioural responses compare a reform with its baseline at the same inputs -before the response: the labour supply responses at the reform's earnings, and -the capital gains response at the reform's gains. Each measurement runs in a -branch of the reform simulation or of its baseline, with the response -neutralised and those inputs set. +before the response: the labour supply responses at the reform's earnings and +gains, and the capital gains response at the reform's gains. Each measurement +runs in a branch of the reform simulation or of its baseline, with every +behavioural response neutralised and those inputs set. """ -from typing import Dict, Iterable +from typing import Dict import numpy as np +# Every behavioural response. Each is measured from the position before any +# response, so one response's measurement does not include another response, +# and does not recompute it inside its own branches: the labour supply +# measurement takes gains before their realisation response, and the capital +# gains measurement takes earnings before the labour supply response. +BEHAVIOURAL_RESPONSES = ( + "employment_income_behavioral_response", + "capital_gains_behavioural_response", + "capital_gains_badr_behavioural_response", +) + def drop_calculated_values(simulation) -> None: """Delete the values a simulation has calculated, keeping its inputs. @@ -41,15 +52,14 @@ def measurement_branch( simulation, name: str, period, - neutralised: Iterable[str], inputs: Dict[str, np.ndarray], ): - """A branch of ``simulation`` with the ``neutralised`` variables - neutralised and ``inputs`` set for ``period``. + """A branch of ``simulation`` with every behavioural response neutralised + and ``inputs`` set for ``period``. - The branch clones the tax-benefit system, so neutralising a response there - keeps it live in the simulation, and the response does not recurse back - into its own measurement. + The branch clones the tax-benefit system, so neutralising the responses + there keeps them live in the simulation, and no response recurses back + into a measurement. A branch starts from every value its simulation holds, and would read those in place of values at the inputs set here: results the simulation @@ -75,7 +85,7 @@ def measurement_branch( "A measurement branch requires a cloned tax-benefit system; " "refusing to neutralise on the simulation's own." ) - for variable in neutralised: + for variable in BEHAVIOURAL_RESPONSES: branch.tax_benefit_system.neutralize_variable(variable) drop_calculated_values(branch) for variable, value in inputs.items(): From 35fd0d31c9bca48d27c7027f9b65d52bde654d74 Mon Sep 17 00:00:00 2001 From: Maria Juaristi Date: Wed, 14 Oct 2026 18:37:26 +0100 Subject: [PATCH 03/14] Format the labour supply example in the dynamics docs CI's unpinned ruff formats Python inside Markdown, and the substitution elasticity line in the elasticity-based responses example was over 88 characters. Co-Authored-By: Claude Opus 5.5 --- docs/book/usage/dynamics.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/book/usage/dynamics.md b/docs/book/usage/dynamics.md index 8446253500..b98f23fe89 100644 --- a/docs/book/usage/dynamics.md +++ b/docs/book/usage/dynamics.md @@ -185,7 +185,9 @@ reformed = Simulation( situation=situation, reform={ "gov.hmrc.income_tax.rates.uk[0].rate": {"2025": 0.30}, - "gov.simulation.labour_supply_responses.substitution_elasticity": {"2025": 0.25}, + "gov.simulation.labour_supply_responses.substitution_elasticity": { + "2025": 0.25 + }, "gov.simulation.labour_supply_responses.income_elasticity": {"2025": -0.05}, }, ) From 05f1356567243816d024e6769c5d84d97ec83a28 Mon Sep 17 00:00:00 2001 From: Maria Juaristi Date: Wed, 14 Oct 2026 18:37:48 +0100 Subject: [PATCH 04/14] Measure the capital gains response only where a reform changes the rules A simulation that applies its baseline's rules (one built without a reform, every branch of one, and a reform's own baseline) now skips the marginal-rate measurement, whose answer would be zero, as does a population without gains. The check compares the two tax-benefit systems' parameter timelines and variable definitions, so rules edited after construction, as policyengine.py's modifiers edit them, are still measured. Marginal tax rates on earnings now hold the realisation response at the simulation's own values in their pay-rise branches. Measured again there, the response compared the reform's rate at the higher pay with the baseline's at the old pay, so at a band edge the pay rise counted as part of the reform. The response settings now resolve through capital_gains_response_assumption, which lets an MTR elasticity apply alone, with a warning, where the retention-rate settings keep their defaults, and reports the response a simulation applies. Part of #2396. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_rule_comparison.py | 75 +++++ policyengine_uk/utils/capital_gains.py | 309 ++++++++++++++++-- policyengine_uk/utils/rule_comparison.py | 155 +++++++++ .../capital_gains_badr_elasticity.py | 10 +- .../capital_gains_behavioural_response.py | 8 +- .../capital_gains_elasticity.py | 8 +- .../marginal_tax_rate_on_capital_gains.py | 15 +- .../relative_capital_gains_mtr_change.py | 5 + ...ive_capital_gains_retention_rate_change.py | 4 +- .../variables/household/marginal_tax_rate.py | 8 + .../marginal_tax_rate_wrt_employer_cost.py | 8 + 11 files changed, 562 insertions(+), 43 deletions(-) create mode 100644 policyengine_uk/tests/test_rule_comparison.py create mode 100644 policyengine_uk/utils/rule_comparison.py diff --git a/policyengine_uk/tests/test_rule_comparison.py b/policyengine_uk/tests/test_rule_comparison.py new file mode 100644 index 0000000000..bf294ec46f --- /dev/null +++ b/policyengine_uk/tests/test_rule_comparison.py @@ -0,0 +1,75 @@ +"""Tests for the comparison of two tax-benefit systems' rules, which decides +whether a simulation's capital gains response needs measuring at all.""" + +import pytest +from policyengine_core.parameters import Parameter + +from policyengine_uk import CountryTaxBenefitSystem +from policyengine_uk.utils.capital_gains import RESPONSE_PARAMETERS +from policyengine_uk.utils.rule_comparison import same_rules + + +@pytest.fixture(scope="module") +def system(): + return CountryTaxBenefitSystem() + + +def test_a_copy_has_the_same_rules(system): + assert same_rules(system, system.clone()) + + +def test_a_value_changed_in_another_year_differs(system): + copy = system.clone() + copy.parameters.get_child( + "gov.hmrc.income_tax.allowances.personal_allowance.amount" + ).update(period="year:2030:1", value=20_000) + + assert not same_rules(system, copy) + + +def test_a_value_edited_in_place_differs(system): + copy = system.clone() + copy.parameters.get_child("gov.hmrc.cgt.basic_rate").values_list[0].value = 0.5 + + assert not same_rules(system, copy) + + +def test_response_settings_can_be_left_out(system): + copy = system.clone() + copy.parameters.get_child( + "gov.simulation.capital_gains_responses.elasticity" + ).update(period="year:2026:1", value=2.0) + + assert not same_rules(system, copy) + assert same_rules(system, copy, RESPONSE_PARAMETERS) + + +def test_an_added_parameter_differs(system): + copy = system.clone() + copy.parameters.get_child("gov.simulation").add_child( + "extra", Parameter("gov.simulation.extra", {"values": {"2000-01-01": 1}}) + ) + + assert not same_rules(system, copy) + + +def test_a_neutralised_variable_differs(system): + copy = system.clone() + copy.neutralize_variable("child_benefit") + + assert not same_rules(system, copy) + + +def test_a_replaced_formula_differs(system): + copy = system.clone() + formulas = copy.variables["income_tax"].formulas + formulas[next(iter(formulas))] = lambda person, period, parameters: 0 + + assert not same_rules(system, copy) + + +def test_separately_loaded_systems_compare_as_different(system): + """Each load holds its own copies of the variables' classes and + formulas, so the comparison can't vouch for them: it errs towards + measuring a response, never towards skipping one.""" + assert not same_rules(system, CountryTaxBenefitSystem()) diff --git a/policyengine_uk/utils/capital_gains.py b/policyengine_uk/utils/capital_gains.py index f9bfc89d17..80ce92dbe0 100644 --- a/policyengine_uk/utils/capital_gains.py +++ b/policyengine_uk/utils/capital_gains.py @@ -1,11 +1,18 @@ """Capital gains marginal tax rates against the baseline, and the realisation response they drive.""" +import warnings +import weakref +from dataclasses import dataclass +from typing import Mapping + import numpy as np +from policyengine_core import periods from policyengine_core.simulations import Simulation from policyengine_uk.utils.measurement_branches import measurement_branch +from policyengine_uk.utils.rule_comparison import same_rules # The response, and the part of it on gains qualifying for Business Asset @@ -15,6 +22,28 @@ "capital_gains_badr_behavioural_response", ) +RESPONSE_SETTINGS = "gov.simulation.capital_gains_responses" +# The settings under RESPONSE_SETTINGS that select the response. +SETTINGS = ( + "elasticity", + "mtr_elasticity", + "separate_badr_elasticity", + "badr_elasticity", +) + +# Parameters that feed only behavioural responses, with their copies under +# the parameters' baseline. Every measurement neutralises the responses, so +# a reform to these alone cannot change a measured marginal rate. +RESPONSE_PARAMETERS = tuple( + prefix + path + for prefix in ("", "baseline.") + for path in ( + RESPONSE_SETTINGS, + "gov.simulation.labour_supply_responses", + "gov.simulation.labor_supply_responses", + ) +) + def measure_mtr( simulation: Simulation, @@ -39,12 +68,29 @@ def measure_mtr( return branch.populations["person"]("marginal_tax_rate_on_capital_gains", period) +def rules_match_baseline(simulation: Simulation) -> bool: + """Whether a simulation applies its baseline's rules. + + Settings that feed only behavioural responses are left out: the + measurement neutralises the responses, so they cannot move a rate. + """ + return same_rules( + simulation.tax_benefit_system, + simulation.baseline.tax_benefit_system, + RESPONSE_PARAMETERS, + ) + + def measure_capital_gains_mtrs(person, period) -> tuple[np.ndarray, np.ndarray]: """Return the reform and baseline capital gains MTRs for each person. Both rates are measured at the same level of gains, so the difference - reflects the reform alone. Returns two zero arrays where the simulation - has no baseline to compare against. + reflects the reform alone. Returns two zero arrays, measuring nothing, + where the simulation has no baseline to compare against or applies its + baseline's rules: the two rates would be equal, so the change is zero. + That covers a simulation built without a reform (whose baseline is a + copy of it), every branch of one, and a reform's own baseline; a + simulation whose rules were changed after it was built is measured. Simulations hold their baseline as a separately constructed simulation rather than a branch, so the baseline rate has to be measured there. A @@ -53,9 +99,18 @@ def measure_capital_gains_mtrs(person, period) -> tuple[np.ndarray, np.ndarray]: """ simulation: Simulation = person.simulation baseline = simulation.baseline - if baseline is None: + if baseline is None or rules_match_baseline(simulation): zeros = np.zeros(person.count) return zeros, zeros + baseline_count = baseline.populations["person"].count + if baseline_count != person.count: + raise ValueError( + f"The simulation has {person.count} people and its baseline " + f"{baseline_count}. The capital gains realisation response compares " + "each person's marginal rate with the baseline's, so the baseline " + "must hold the same people: subsample both, or set " + f"{RESPONSE_SETTINGS}.elasticity to 0." + ) gains = person("capital_gains_before_response", period) reform_mtr = measure_mtr(simulation, "cgr_measurement", period, gains) @@ -71,6 +126,201 @@ def badr_gains_before_response(person, period) -> np.ndarray: return np.maximum(0, np.minimum(person("capital_gains_badr", period), gains)) +class CapitalGainsResponseWarning(UserWarning): + """An MTR elasticity applies alone, its period's retention-rate + elasticities being at their defaults and so treated as zero.""" + + +@dataclass(frozen=True) +class CapitalGainsResponseAssumption: + """The realisation response a simulation applies in a period. + + ``convention`` is ``"retention"`` (gains scale by + ``((1 - t1) / (1 - t0)) ** elasticity``), ``"mtr"`` (by + ``(t1 / t0) ** mtr_elasticity``) or ``"static"`` (no response), where + ``t0`` and ``t1`` are a person's baseline and reform marginal rates on + gains. ``elasticity`` applies to gains other than those qualifying for + Business Asset Disposal Relief, and ``badr_elasticity`` to those. + ``sources`` says of each of those and of ``mtr_elasticity`` and + ``separate_badr_elasticity`` whether it is current law (``"default"``), + changed by the caller (``"override"``) or set to zero because an MTR + elasticity applies alone (``"auto_resolved"``). + """ + + period: str + convention: str + elasticity: float + badr_elasticity: float + mtr_elasticity: float + separate_badr_elasticity: bool + sources: Mapping[str, str] + mtr_auto_resolved: bool = False + warnings: tuple = () + + def to_dict(self) -> dict: + """The assumption as JSON-serialisable values.""" + return { + "period": self.period, + "convention": self.convention, + "elasticity": self.elasticity, + "badr_elasticity": self.badr_elasticity, + "mtr_elasticity": self.mtr_elasticity, + "separate_badr_elasticity": self.separate_badr_elasticity, + "sources": dict(self.sources), + "mtr_auto_resolved": self.mtr_auto_resolved, + "warnings": list(self.warnings), + } + + +def resolve_capital_gains_responses( + settings: Mapping, current_law: Mapping, period +) -> CapitalGainsResponseAssumption: + """Resolve the response settings in force in a period. + + ``settings`` and ``current_law`` give each of :data:`SETTINGS` for the + period, as set and under current law. A nonzero ``mtr_elasticity`` + selects the marginal tax rate convention, which takes no retention-rate + elasticity. Retention-rate settings still at current law then count as + zero for the period, and the assumption carries a warning; settings + changed to a nonzero retention-rate elasticity raise. A setting changed + to its current-law value can't be told apart from one left there, so it + resolves the same way. + """ + main = float(settings["elasticity"]) + mtr = float(settings["mtr_elasticity"]) + separate = bool(settings["separate_badr_elasticity"]) + badr = float(settings["badr_elasticity"]) if separate else main + changed = { + name: float(settings[name]) != float(current_law[name]) for name in SETTINGS + } + + def source(name: str) -> str: + return "override" if changed[name] else "default" + + sources = { + "elasticity": source("elasticity"), + "badr_elasticity": ( + "override" + if changed["separate_badr_elasticity"] or changed["badr_elasticity"] + else "default" + ) + if separate + else source("elasticity"), + "mtr_elasticity": source("mtr_elasticity"), + "separate_badr_elasticity": source("separate_badr_elasticity"), + } + period = str(period) + if mtr == 0: + return CapitalGainsResponseAssumption( + period=period, + convention="static" if main == 0 and badr == 0 else "retention", + elasticity=main, + badr_elasticity=badr, + mtr_elasticity=0.0, + separate_badr_elasticity=separate, + sources=sources, + ) + + if main != 0 and changed["elasticity"]: + raise ValueError( + f"{RESPONSE_SETTINGS}.elasticity and " + f"{RESPONSE_SETTINGS}.mtr_elasticity cannot both be nonzero for the " + f"same period: in {period} the elasticity is {main:g} and " + f"mtr_elasticity {mtr:g}. Set one of them to 0." + ) + badr_inherited = not changed["separate_badr_elasticity"] and not ( + separate and changed["badr_elasticity"] + ) + if badr != 0 and not badr_inherited: + raise ValueError( + f"{RESPONSE_SETTINGS}.badr_elasticity, in effect while " + "separate_badr_elasticity is true, and " + f"{RESPONSE_SETTINGS}.mtr_elasticity cannot both be nonzero for the " + f"same period: in {period} the BADR elasticity is {badr:g} and " + f"mtr_elasticity {mtr:g}. Set badr_elasticity to 0, " + "separate_badr_elasticity to false, or mtr_elasticity to 0." + ) + + messages = () + if main != 0: + sources["elasticity"] = "auto_resolved" + if badr != 0: + sources["badr_elasticity"] = "auto_resolved" + auto_resolved = main != 0 or badr != 0 + if auto_resolved: + messages = ( + f"{RESPONSE_SETTINGS}.mtr_elasticity is {mtr:g} in {period}, while " + f"the retention-rate elasticity keeps its default of {main:g}. The " + "two conventions can't be combined, so in that period the marginal " + "tax rate convention applies alone and the retention-rate " + f"elasticities count as 0. To keep this without the warning, set " + f"{RESPONSE_SETTINGS}.elasticity to 0 for the same periods; to use " + "the retention-rate default instead, set mtr_elasticity to 0. An " + "MTR elasticity is not a restatement of a retention-rate one: -0.7 " + "and 1.0 scale gains differently across a finite rate change.", + ) + return CapitalGainsResponseAssumption( + period=period, + convention="mtr", + elasticity=0.0, + badr_elasticity=0.0, + mtr_elasticity=mtr, + separate_badr_elasticity=separate, + sources=sources, + mtr_auto_resolved=auto_resolved, + warnings=messages, + ) + + +# The warnings each tax-benefit system has given, so a setting warns once per +# system and period however many simulations, branches and recalculations +# read it. Branches share their simulation's system. +_warned = weakref.WeakKeyDictionary() + + +def _resolve(simulation: Simulation, period, parameters): + """Resolve the response settings in ``parameters`` (a simulation's own, + as a formula receives them) against current law, warning where needed.""" + # The tax-benefit system module imports the variables, which import this + # module. + from policyengine_uk.tax_benefit_system import processed_parameters + + p = parameters(period).gov.simulation.capital_gains_responses + current = processed_parameters().get_child(RESPONSE_SETTINGS) + instant = periods.period(period).start + settings = {name: getattr(p, name) for name in SETTINGS} + assumption = resolve_capital_gains_responses( + settings, + {name: current.children[name](instant) for name in SETTINGS}, + period, + ) + if assumption.warnings: + key = (assumption.period, *(float(settings[name]) for name in SETTINGS)) + system = simulation.tax_benefit_system + given = _warned.setdefault(system, set()) + if key not in given: + given.add(key) + for message in assumption.warnings: + warnings.warn(message, CapitalGainsResponseWarning, stacklevel=2) + return assumption + + +def capital_gains_response_assumption( + simulation: Simulation, period +) -> CapitalGainsResponseAssumption: + """The realisation response ``simulation`` applies in ``period``. + + Resolved as the response itself resolves it, from the simulation's own + parameters, after every reform and modifier applied so far: it raises on + settings that conflict, and warns once where an MTR elasticity applies + alone. ``to_dict()`` gives the assumption to record with a run's results. + """ + period = periods.period(period) + return _resolve( + simulation, period, simulation.tax_benefit_system.get_parameters_at_instant + ) + + def realisation_factors(person, period, parameters): """Factors by which realised gains scale under a reform, as (main, BADR). @@ -81,36 +331,45 @@ def realisation_factors(person, period, parameters): The marginal tax rate convention has one elasticity for all gains. Returns None where there is no response: no baseline to compare against, - or every elasticity zero. + no elasticity in force (see capital_gains_response_assumption), or no + person with gains or losses to respond. """ - p = parameters(period).gov.simulation.capital_gains_responses - badr_elasticity = p.badr_elasticity if p.separate_badr_elasticity else p.elasticity - - if p.elasticity != 0 and p.mtr_elasticity != 0: - raise ValueError( - "gov.simulation.capital_gains_responses.elasticity and " - "gov.simulation.capital_gains_responses.mtr_elasticity " - "cannot both be nonzero for the same period." - ) - if badr_elasticity != 0 and p.mtr_elasticity != 0: - raise ValueError( - "gov.simulation.capital_gains_responses.badr_elasticity, in " - "effect while separate_badr_elasticity is true, and " - "gov.simulation.capital_gains_responses.mtr_elasticity " - "cannot both be nonzero for the same period." - ) - - if person.simulation.baseline is None: + assumption = _resolve(person.simulation, period, parameters) + if person.simulation.baseline is None or assumption.convention == "static": return None - if p.elasticity == 0 and badr_elasticity == 0 and p.mtr_elasticity == 0: + if not np.any(person("capital_gains_before_response", period) != 0): return None - if p.mtr_elasticity != 0: + if assumption.convention == "mtr": change = person("relative_capital_gains_mtr_change", period) - factor = np.exp(p.mtr_elasticity * change) + factor = np.exp(assumption.mtr_elasticity * change) return factor, factor change = person("relative_capital_gains_retention_rate_change", period) main = np.exp(person("capital_gains_elasticity", period) * change) badr = np.exp(person("capital_gains_badr_elasticity", period) * change) return main, badr + + +def held_realisation_responses(person, period) -> dict: + """The realisation responses a simulation calculated, to hold fixed in a + branch that steps one of its inputs to measure a marginal rate. + + A response measured again in such a branch would compare a rate at the + stepped input with the baseline's at the original one, and count the + step as a reform. Responses neutralised in the simulation (as in a + measurement branch) are not held. + """ + variables = person.simulation.tax_benefit_system.variables + return { + response: person(response, period) + for response in RESPONSE_VARIABLES + if not variables[response].is_neutralized + } + + +def hold_realisation_responses(branch: Simulation, period, held: dict) -> None: + """Set the responses :func:`held_realisation_responses` returned on a + branch, so the branch reads them instead of measuring them again.""" + for response, values in held.items(): + branch.set_input(response, period, values) diff --git a/policyengine_uk/utils/rule_comparison.py b/policyengine_uk/utils/rule_comparison.py new file mode 100644 index 0000000000..297c0957dd --- /dev/null +++ b/policyengine_uk/utils/rule_comparison.py @@ -0,0 +1,155 @@ +"""Whether two tax-benefit systems apply the same rules. + +A simulation built without a reform measures behavioural responses against a +copy of itself taken when it was built (its baseline). If its rules still +match that copy's, no reform separates them, so a response measured between +them is exactly zero and need not be measured. Rules change after +construction too: a scenario's modifier, ``apply_reform`` and callers such as +policyengine.py edit parameters in place, so the check compares what the two +systems hold rather than how they were built. + +The comparison errs one way only. Systems it calls the same hold the same +parameter timelines and the same variable definitions. Anything it cannot +compare counts as different, which costs a measurement but never skips one +that a reform needs. Two systems loaded separately hold separate copies of +every variable's class and formulas, so they compare as different even when +their rules agree; copies of one system (``clone``) share them. +""" + +from typing import Iterable + +import numpy as np +from policyengine_core.parameters import Parameter, ParameterNode, ParameterScale + +# Variable attributes that change what a variable calculates. The rest +# (labels, documentation, units, references) describe it. +RULE_ATTRIBUTES = ( + "formulas", + "adds", + "subtracts", + "default_value", + "defined_for", + "definition_period", + "end", + "is_neutralized", + "possible_values", + "value_type", + "uprating", + "set_input", + "calculate_output", + "max_value", + "min_value", + "requires_computation_after", + "is_period_size_independent", + "quantity_type", + "baseline_variable", +) + + +def _equal(value, other) -> bool: + """Whether two values are the same, strictly: equal and of one type.""" + if value is other: + return True + if type(value) is not type(other): + return False + if isinstance(value, np.ndarray): + return value.shape == other.shape and bool(np.array_equal(value, other)) + try: + result = value == other + except Exception: + return False + return isinstance(result, (bool, np.bool_)) and bool(result) + + +def same_parameters(tree, other, ignore: Iterable[str] = ()) -> bool: + """Whether two parameter trees hold the same timelines. + + Every parameter must have the same dated values, read from its own + ``values_list`` (so no cache is written), with the same effective times, + and scales the same brackets. Subtrees at the dotted paths in ``ignore`` + are skipped. + """ + ignore = frozenset(ignore) + stack = [("", tree, other)] + while stack: + path, node, other_node = stack.pop() + if node is other_node or path in ignore: + continue + if type(node) is not type(other_node): + return False + if isinstance(node, Parameter): + values, other_values = node.values_list, other_node.values_list + if len(values) != len(other_values): + return False + for value, other_value in zip(values, other_values): + if ( + value.instant_str != other_value.instant_str + or not _equal(value.value, other_value.value) + or value.metadata.get("effective_time") + != other_value.metadata.get("effective_time") + ): + return False + elif isinstance(node, ParameterScale): + if len(node.brackets) != len(other_node.brackets) or node.metadata.get( + "type" + ) != other_node.metadata.get("type"): + return False + stack.extend( + (f"{path}[{index}]", bracket, other_bracket) + for index, (bracket, other_bracket) in enumerate( + zip(node.brackets, other_node.brackets) + ) + ) + elif isinstance(node, ParameterNode): + if node.children.keys() != other_node.children.keys(): + return False + stack.extend( + (f"{path}.{key}" if path else key, child, other_node.children[key]) + for key, child in node.children.items() + ) + else: + return False + return True + + +def same_variables(variables: dict, other: dict) -> bool: + """Whether two systems' variables calculate the same way. + + The same names, and for each the same class, entity and + :data:`RULE_ATTRIBUTES` (formulas compared function by function). + """ + if variables.keys() != other.keys(): + return False + for name, variable in variables.items(): + other_variable = other[name] + if variable is other_variable: + continue + if type(variable) is not type(other_variable): + return False + if getattr(variable.entity, "key", None) != getattr( + other_variable.entity, "key", None + ): + return False + for attribute in RULE_ATTRIBUTES: + if not _equal( + getattr(variable, attribute, None), + getattr(other_variable, attribute, None), + ): + return False + return True + + +def same_rules(system, other, ignore_parameters: Iterable[str] = ()) -> bool: + """Whether two tax-benefit systems apply the same rules. + + The same parameter timelines, apart from the subtrees in + ``ignore_parameters``, and the same variable definitions. + """ + if system is other: + return True + try: + return same_parameters( + system.parameters, other.parameters, ignore_parameters + ) and same_variables(system.variables, other.variables) + except Exception: + return False diff --git a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_badr_elasticity.py b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_badr_elasticity.py index 472c0dacfd..24cc3a5920 100644 --- a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_badr_elasticity.py +++ b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_badr_elasticity.py @@ -1,5 +1,6 @@ from policyengine_uk.model_api import * from policyengine_core.simulations import * +from policyengine_uk.utils.capital_gains import capital_gains_response_assumption class capital_gains_badr_elasticity(Variable): @@ -10,15 +11,16 @@ class capital_gains_badr_elasticity(Variable): "Elasticity of realisations with respect to the capital gains " "retention rate, for gains qualifying for Business Asset Disposal " "Relief: gov.simulation.capital_gains_responses.badr_elasticity while " - "separate_badr_elasticity is on, otherwise the main elasticity." + "separate_badr_elasticity is on, otherwise the main elasticity; 0 in " + "a period where an MTR elasticity applies alone." ) unit = "/1" definition_period = YEAR def formula(person, period, parameters): - p = parameters(period).gov.simulation.capital_gains_responses + assumption = capital_gains_response_assumption(person.simulation, period) return where( - p.separate_badr_elasticity, - p.badr_elasticity, + assumption.separate_badr_elasticity, + assumption.badr_elasticity, person("capital_gains_elasticity", period), ) diff --git a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_behavioural_response.py b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_behavioural_response.py index 4ac5756a1e..87a5f2d770 100644 --- a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_behavioural_response.py +++ b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_behavioural_response.py @@ -13,9 +13,11 @@ class capital_gains_behavioural_response(Variable): documentation = ( "Change in realised gains under a reform to the taxation of gains, " "given an elasticity of realisations with respect to either the " - "retention rate or the marginal tax rate. Gains qualifying for " - "Business Asset Disposal Relief respond at their own elasticity " - "(capital_gains_badr_behavioural_response); the rest at the main one." + "retention rate (1 by default) or the marginal tax rate. Gains " + "qualifying for Business Asset Disposal Relief respond at their own " + "elasticity (capital_gains_badr_behavioural_response); the rest at the " + "main one. Zero where the simulation has no baseline, applies its " + "baseline's rules, or has no one with gains." ) unit = GBP definition_period = YEAR diff --git a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_elasticity.py b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_elasticity.py index 8c7e1f5b28..9cd5225260 100644 --- a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_elasticity.py +++ b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_elasticity.py @@ -1,5 +1,6 @@ from policyengine_uk.model_api import * from policyengine_core.simulations import * +from policyengine_uk.utils.capital_gains import capital_gains_response_assumption class capital_gains_elasticity(Variable): @@ -9,11 +10,12 @@ class capital_gains_elasticity(Variable): documentation = ( "Elasticity of realisations with respect to the capital gains " "retention rate, for gains other than those qualifying for Business " - "Asset Disposal Relief (see capital_gains_badr_elasticity)." + "Asset Disposal Relief (see capital_gains_badr_elasticity): " + "gov.simulation.capital_gains_responses.elasticity, or 0 in a period " + "where an MTR elasticity applies alone." ) unit = "/1" definition_period = YEAR def formula(person, period, parameters): - gov = parameters(period).gov - return gov.simulation.capital_gains_responses.elasticity + return capital_gains_response_assumption(person.simulation, period).elasticity diff --git a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/marginal_tax_rate_on_capital_gains.py b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/marginal_tax_rate_on_capital_gains.py index 51b10488ad..17ec546308 100644 --- a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/marginal_tax_rate_on_capital_gains.py +++ b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/marginal_tax_rate_on_capital_gains.py @@ -1,6 +1,9 @@ from policyengine_uk.model_api import * from policyengine_core.simulations import * -from policyengine_uk.utils.capital_gains import RESPONSE_VARIABLES +from policyengine_uk.utils.capital_gains import ( + held_realisation_responses, + hold_realisation_responses, +) from policyengine_uk.utils.marginal_rates import marginal_rate_step @@ -28,6 +31,9 @@ def formula(person, period, parameters): gains = person("capital_gains", period) step = marginal_rate_step(gains, minimum=1_000) adult_index_values = person("adult_index_cg", period) + # Hold the realisation response at this simulation's values, so the + # branch reads its schedule split instead of measuring it again. + held_responses = held_realisation_responses(person, period) for adult_index in [1, 2]: alt_simulation = simulation.get_branch(f"adult_{adult_index}_cg_rise") mask = adult_index_values == adult_index @@ -38,12 +44,7 @@ def formula(person, period, parameters): and not variable_data.is_input_variable() ): alt_simulation.delete_arrays(variable) - # Hold the realisation response at this simulation's values, so the - # branch reads its schedule split instead of measuring it again. - for response in RESPONSE_VARIABLES: - response_variable = simulation.tax_benefit_system.variables[response] - if not response_variable.is_neutralized: - alt_simulation.set_input(response, period, person(response, period)) + hold_realisation_responses(alt_simulation, period, held_responses) alt_simulation.set_input("capital_gains", period, gains + mask * step) alt_person = alt_simulation.person # Float32 rounds the higher gains, so the rise actually stored can diff --git a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/relative_capital_gains_mtr_change.py b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/relative_capital_gains_mtr_change.py index 5e097c434a..1c3249e9a7 100644 --- a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/relative_capital_gains_mtr_change.py +++ b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/relative_capital_gains_mtr_change.py @@ -7,6 +7,11 @@ class relative_capital_gains_mtr_change(Variable): value_type = float entity = Person label = "relative change in capital gains tax rate" + documentation = ( + "Log change in the marginal tax rate on gains, for the marginal tax " + "rate convention. Zero, without measuring, where the simulation has no " + "baseline or applies its baseline's rules." + ) unit = "/1" definition_period = YEAR diff --git a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/relative_capital_gains_retention_rate_change.py b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/relative_capital_gains_retention_rate_change.py index ee35879bb0..6c0ba012ab 100644 --- a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/relative_capital_gains_retention_rate_change.py +++ b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/relative_capital_gains_retention_rate_change.py @@ -10,7 +10,9 @@ class relative_capital_gains_retention_rate_change(Variable): documentation = ( "Log change in the share of a marginal pound of gains kept after tax. " "The empirical literature estimates realisation elasticities against " - "this retention rate rather than against the tax rate." + "this retention rate rather than against the tax rate. Zero, without " + "measuring, where the simulation has no baseline or applies its " + "baseline's rules." ) unit = "/1" definition_period = YEAR diff --git a/policyengine_uk/variables/household/marginal_tax_rate.py b/policyengine_uk/variables/household/marginal_tax_rate.py index ce512aeabd..d59ff62542 100644 --- a/policyengine_uk/variables/household/marginal_tax_rate.py +++ b/policyengine_uk/variables/household/marginal_tax_rate.py @@ -1,5 +1,9 @@ from policyengine_uk.model_api import * from policyengine_core.variables import Variable +from policyengine_uk.utils.capital_gains import ( + held_realisation_responses, + hold_realisation_responses, +) from policyengine_uk.utils.marginal_rates import marginal_rate_step @@ -25,6 +29,9 @@ def formula(person, period, parameters): employment_income = person("employment_income", period) step = marginal_rate_step(employment_income, p.marginal_tax_rate_delta) adult_count = p.marginal_tax_rate_adults + # Realised gains stay as this simulation calculated them: the pay rise + # is not a reform for the capital gains response to answer. + held_responses = held_realisation_responses(person, period) for adult_index in range(1, 1 + adult_count): alt_simulation = simulation.get_branch(f"adult_{adult_index}_pay_rise") mask = adult_index_values == adult_index @@ -40,6 +47,7 @@ def formula(person, period, parameters): period, employment_income + mask * step, ) + hold_realisation_responses(alt_simulation, period, held_responses) alt_person = alt_simulation.person # Float32 rounds the higher earnings, so divide by the rise stored. pay_rise = alt_person("employment_income", period) - employment_income diff --git a/policyengine_uk/variables/household/marginal_tax_rate_wrt_employer_cost.py b/policyengine_uk/variables/household/marginal_tax_rate_wrt_employer_cost.py index 6adf0f0e60..99f3a9fde4 100644 --- a/policyengine_uk/variables/household/marginal_tax_rate_wrt_employer_cost.py +++ b/policyengine_uk/variables/household/marginal_tax_rate_wrt_employer_cost.py @@ -1,5 +1,9 @@ from policyengine_uk.model_api import * from policyengine_core.variables import Variable +from policyengine_uk.utils.capital_gains import ( + held_realisation_responses, + hold_realisation_responses, +) from policyengine_uk.utils.marginal_rates import marginal_rate_step @@ -29,6 +33,9 @@ def formula(person, period, parameters): adult_count = p.marginal_tax_rate_adults baseline_employer_cost = person("employer_cost", period) baseline_net = person.household("household_net_income", period) + # Realised gains stay as this simulation calculated them: the pay rise + # is not a reform for the capital gains response to answer. + held_responses = held_realisation_responses(person, period) for adult_index in range(1, 1 + adult_count): alt_simulation = simulation.get_branch( f"adult_{adult_index}_employer_cost_mtr" @@ -46,6 +53,7 @@ def formula(person, period, parameters): period, employment_income + mask * step, ) + hold_realisation_responses(alt_simulation, period, held_responses) alt_person = alt_simulation.person new_net = alt_person.household("household_net_income", period) new_employer_cost = alt_person("employer_cost", period) From e504e72b0fc93fabbddd1c57c9ba7ed0ceee8109 Mon Sep 17 00:00:00 2001 From: Maria Juaristi Date: Wed, 14 Oct 2026 18:37:58 +0100 Subject: [PATCH 05/14] Make a retention-rate elasticity of 1 the default CGT realisation response gov.simulation.capital_gains_responses.elasticity now defaults to 1.0, the central value Advani, Lonsdale and Summers (2024, pp. 36-37) use, with 0.5 and 2.0 as sensitivities; mtr_elasticity stays 0 and separate_badr_elasticity false, so qualifying gains take 1.0 too. The parameter descriptions state the assumption's qualifications and the static recipe. Tests that checked the static default now set the elasticity they need, the MTR-convention tests set the retention-rate elasticity to 0, and new tests cover the default through every route, the explicit alternatives, legacy MTR settings, dated settings, the cases that never measure, the pay-rise branches, stability and the assumption helper. Fixes #2396. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2396.changed.md | 1 + .../badr_elasticity.yaml | 2 +- .../capital_gains_responses/elasticity.yaml | 10 +- .../mtr_elasticity.yaml | 2 +- .../separate_badr_elasticity.yaml | 2 +- ...test_cached_arrays_not_written_in_place.py | 16 +- .../test_capital_gains_response_default.py | 600 ++++++++++++++++++ .../tests/test_capital_gains_responses.py | 37 +- .../test_capital_gains_tax_components.py | 12 +- .../tests/test_fiscal_year_parameters.py | 6 +- 10 files changed, 664 insertions(+), 24 deletions(-) create mode 100644 changelog.d/2396.changed.md create mode 100644 policyengine_uk/tests/test_capital_gains_response_default.py diff --git a/changelog.d/2396.changed.md b/changelog.d/2396.changed.md new file mode 100644 index 0000000000..296f8e2764 --- /dev/null +++ b/changelog.d/2396.changed.md @@ -0,0 +1 @@ +- Made a retention-rate elasticity of 1.0 the default capital gains realisation response (`gov.simulation.capital_gains_responses.elasticity`, previously 0), the central value Advani, Lonsdale and Summers (2024, pp. 36-37) use, with 0.5 and 2.0 as sensitivities: CGT reforms are no longer costed statically unless the elasticity is set to 0, and gains qualifying for Business Asset Disposal Relief take the same 1.0. An `mtr_elasticity` set while the retention-rate settings keep their defaults now applies alone in its periods, with a `CapitalGainsResponseWarning`; retention-rate settings changed to nonzero values still raise alongside it. Simulations that apply their baseline's rules (any simulation without a reform, and a reform's own baseline) and populations without gains no longer measure marginal rates on gains, and marginal tax rates on earnings hold realised gains fixed instead of measuring the response again at the higher pay. `capital_gains_response_assumption` reports the response a simulation applies in a year. diff --git a/policyengine_uk/parameters/gov/simulation/capital_gains_responses/badr_elasticity.yaml b/policyengine_uk/parameters/gov/simulation/capital_gains_responses/badr_elasticity.yaml index 2b42ed4eed..2aa1c7dbeb 100644 --- a/policyengine_uk/parameters/gov/simulation/capital_gains_responses/badr_elasticity.yaml +++ b/policyengine_uk/parameters/gov/simulation/capital_gains_responses/badr_elasticity.yaml @@ -1,4 +1,4 @@ -description: Elasticity of realisations of gains qualifying for Business Asset Disposal Relief or Investors' Relief with respect to the capital gains retention rate. It applies to those gains only while separate_badr_elasticity is true; otherwise they take the main elasticity, as the person's other gains always do. The OBR's costing of the October 2024 rate rises used 1.4 for these gains and 3.6 for main-rate gains. +description: Elasticity of realisations of gains qualifying for Business Asset Disposal Relief or Investors' Relief with respect to the capital gains retention rate. It applies to those gains only while separate_badr_elasticity is true; otherwise they take the main elasticity (1.0 by default), as the person's other gains always do. The OBR's costing of the October 2024 rate rises used 1.4 for these gains and 3.6 for main-rate gains. Setting the main elasticity to 3.6 with separate_badr_elasticity true compares PolicyEngine's response with the OBR's elasticities, not with the OBR's full costing, which includes further effects. values: 2000-01-01: 1.4 metadata: diff --git a/policyengine_uk/parameters/gov/simulation/capital_gains_responses/elasticity.yaml b/policyengine_uk/parameters/gov/simulation/capital_gains_responses/elasticity.yaml index 7a1c20a26f..4e2387fd20 100644 --- a/policyengine_uk/parameters/gov/simulation/capital_gains_responses/elasticity.yaml +++ b/policyengine_uk/parameters/gov/simulation/capital_gains_responses/elasticity.yaml @@ -1,11 +1,11 @@ -description: Elasticity of capital gains realisations with respect to the capital gains retention rate, the share of a marginal pound of gains kept after tax. Higher values mean a given rate rise reduces realisations by more. Published estimates span roughly 0.5 to 2. +description: Elasticity of capital gains realisations with respect to the capital gains retention rate, the share of a marginal pound of gains kept after tax. A reform scales each person's realised gains by ((1 - t1) / (1 - t0)) to the power of this elasticity, where t0 and t1 are their baseline and reform marginal rates on gains, both measured at their gains before any response. PolicyEngine's central assumption is 1.0, the value Advani, Lonsdale and Summers (2024) use for the medium term, with 0.5 and 2.0, the range they consider, as sensitivities. Their 1.0 is for a package that also reforms the base, removing the uplift at death, and includes an adjustment for shifting between tax bases, so it is a modelling assumption rather than an estimate for every reform to rates alone, and adopting it does not reproduce their revenue estimates. The value applies in every period; its start date is the start of the model's timeline, not a change in law. For a static costing, set this and mtr_elasticity to 0, with separate_badr_elasticity false. values: - 2000-01-01: 0 + 2000-01-01: 1.0 metadata: unit: /1 - label: Capital gains elasticity + label: Capital gains retention-rate elasticity reference: + - title: Advani, Lonsdale and Summers (2024), Reforming Capital Gains Tax, pages 36-37 + href: https://centax.org.uk/wp-content/uploads/2024/10/AdvaniLonsdaleSummers2024_CGTReform.pdf - title: Agersnap and Zidar (2021), The tax elasticity of capital gains and revenue-maximizing rates href: https://www.aeaweb.org/articles?id=10.1257/aeri.20200535 - - title: Advani, Lonsdale and Summers (2024), Reforming Capital Gains Tax - href: https://centax.org.uk/wp-content/uploads/2024/10/AdvaniLonsdaleSummers2024_CGTReform.pdf diff --git a/policyengine_uk/parameters/gov/simulation/capital_gains_responses/mtr_elasticity.yaml b/policyengine_uk/parameters/gov/simulation/capital_gains_responses/mtr_elasticity.yaml index 110051df9c..e189fe67ba 100644 --- a/policyengine_uk/parameters/gov/simulation/capital_gains_responses/mtr_elasticity.yaml +++ b/policyengine_uk/parameters/gov/simulation/capital_gains_responses/mtr_elasticity.yaml @@ -1,4 +1,4 @@ -description: Elasticity of capital gains realisations with respect to the marginal tax rate itself. This is the auxiliary convention some analyses report alongside the retention-rate elasticity and is typically negative. +description: Elasticity of capital gains realisations with respect to the marginal tax rate itself, an auxiliary convention some analyses report alongside the retention-rate elasticity, typically negative. A reform scales realised gains by (t1 / t0) to the power of this elasticity. The two conventions differ across finite rate changes, so a value here is not a restatement of a retention-rate elasticity; for a rise from 24% to 45%, -0.7 here scales gains by 0.644, while a retention-rate elasticity of 1.0 scales them by 0.724. In a period where this is nonzero the retention-rate elasticities must be zero. Where they keep their defaults they count as zero for that period, with a warning; set gov.simulation.capital_gains_responses.elasticity to 0 to avoid it. Retention-rate elasticities changed to nonzero values raise an error. values: 2000-01-01: 0 metadata: diff --git a/policyengine_uk/parameters/gov/simulation/capital_gains_responses/separate_badr_elasticity.yaml b/policyengine_uk/parameters/gov/simulation/capital_gains_responses/separate_badr_elasticity.yaml index df04ca1526..6af0b7a7d0 100644 --- a/policyengine_uk/parameters/gov/simulation/capital_gains_responses/separate_badr_elasticity.yaml +++ b/policyengine_uk/parameters/gov/simulation/capital_gains_responses/separate_badr_elasticity.yaml @@ -1,4 +1,4 @@ -description: Whether gains qualifying for Business Asset Disposal Relief or Investors' Relief respond with gov.simulation.capital_gains_responses.badr_elasticity rather than the main elasticity. While false, all gains respond with the main elasticity. +description: Whether gains qualifying for Business Asset Disposal Relief or Investors' Relief respond with gov.simulation.capital_gains_responses.badr_elasticity rather than the main elasticity. While false, as by default, all gains respond with the main elasticity, so qualifying gains take the central 1.0 too. values: 2000-01-01: false metadata: diff --git a/policyengine_uk/tests/code_health/test_cached_arrays_not_written_in_place.py b/policyengine_uk/tests/code_health/test_cached_arrays_not_written_in_place.py index 67c14752b1..1d688833ce 100644 --- a/policyengine_uk/tests/code_health/test_cached_arrays_not_written_in_place.py +++ b/policyengine_uk/tests/code_health/test_cached_arrays_not_written_in_place.py @@ -532,11 +532,23 @@ def _earning_couple_with_gains() -> dict: "gov.hmrc.cgt.basic_rate": 0.20, "gov.hmrc.cgt.higher_rate": 0.40, }, + # The default response, read through the marginal tax rates' pay-rise + # branches as well as measured. + "capital_gains_default_response": { + "gov.hmrc.cgt.basic_rate": 0.20, + "gov.hmrc.cgt.higher_rate": 0.40, + }, } @pytest.mark.parametrize( - "case", ["marginal_rates", "labour_supply_responses", "capital_gains_responses"] + "case", + [ + "marginal_rates", + "labour_supply_responses", + "capital_gains_responses", + "capital_gains_default_response", + ], ) def test_branching_runs_with_read_only_cache(read_only_cache, case): from policyengine_uk import Simulation @@ -562,6 +574,8 @@ def test_branching_runs_with_read_only_cache(read_only_cache, case): ) for year in YEARS: sim.calculate("household_net_income", year) + if case == "capital_gains_default_response": + sim.calculate("marginal_tax_rate", year) # The measurement branches are not kept; the responses show they ran. if case == "labour_supply_responses": # The higher personal allowance raises both earners' net income and diff --git a/policyengine_uk/tests/test_capital_gains_response_default.py b/policyengine_uk/tests/test_capital_gains_response_default.py new file mode 100644 index 0000000000..a633cf8835 --- /dev/null +++ b/policyengine_uk/tests/test_capital_gains_response_default.py @@ -0,0 +1,600 @@ +"""The default capital gains realisation response (issue #2396). + +Realised gains respond to a reform at a retention-rate elasticity of 1: +G1 = G0 x ((1 - t1) / (1 - t0)), with t0 and t1 the person's baseline and +reform marginal rates measured at the same gains before the response. Gains +qualifying for Business Asset Disposal Relief take the same elasticity. + +These tests check the default against an explicit elasticity of 1 on every +route; the explicit alternatives (static, sensitivities, the marginal tax +rate convention); that a simulation without a reform, or without gains, +never measures a rate; that marginal tax rates hold realisations fixed; and +that the assumption a simulation applies can be read and recorded. +""" + +import json +import math +import warnings + +import numpy as np +import pandas as pd +import pytest + +import policyengine_uk.utils.capital_gains as capital_gains +from policyengine_uk import Microsimulation, Simulation +from policyengine_uk.data.dataset_schema import UKSingleYearDataset +from policyengine_uk.model_api import Scenario +from policyengine_uk.utils.capital_gains import ( + CapitalGainsResponseWarning, + capital_gains_response_assumption, +) + +YEAR = 2026 +RESPONSES = "gov.simulation.capital_gains_responses" +EQUALISED_RATES = { + "gov.hmrc.cgt.basic_rate": {str(YEAR): 0.20}, + "gov.hmrc.cgt.higher_rate": {str(YEAR): 0.40}, + "gov.hmrc.cgt.additional_rate": {str(YEAR): 0.45}, +} +# A rate rise from 24% to 45% on the marginal pound of gains. +CENTRAL_FACTOR = (1 - 0.45) / (1 - 0.24) + + +def single(earnings: float, gains: float, **extra) -> dict: + person = { + "age": {YEAR: 45}, + "employment_income": {YEAR: earnings}, + "capital_gains": {YEAR: gains}, + **{name: {YEAR: value} for name, value in extra.items()}, + } + return { + "people": {"person": person}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, + } + + +# £100,000 of earnings and £200,000 of gains: the marginal pound of gains is +# taxed at 24% now and at 45% with the rates equalised. +ADDITIONAL_RATE_GAINER = single(100_000, 200_000) +# £40,000 of earnings and £12,770 of gains: the gains end £500 below the top +# of the basic rate band, so a £1,000 pay rise moves the marginal pound of +# gains into the higher rate band. +BAND_EDGE_GAINER = single(40_000, 12_770) + + +def elasticities(**settings) -> dict: + return { + f"{RESPONSES}.{name}": {str(YEAR): value} for name, value in settings.items() + } + + +def build(cls, situation, changes, route): + if route == "reform": + return cls(situation=situation, reform=changes) + return cls(situation=situation, scenario=Scenario(parameter_changes=changes)) + + +def values(sim, variable, period=YEAR) -> np.ndarray: + result = sim.calculate(variable, period) + return np.asarray(getattr(result, "values", result)) + + +def realisation_factor(sim, person: int = 0, period=YEAR) -> float: + gains = values(sim, "capital_gains_before_response", period)[person] + response = values(sim, "capital_gains_behavioural_response", period)[person] + return 1 + response / gains + + +@pytest.fixture +def measurements(monkeypatch): + """Counts the capital gains marginal rates measured.""" + count = {"n": 0} + measure = capital_gains.measure_mtr + + def counted(*args, **kwargs): + count["n"] += 1 + return measure(*args, **kwargs) + + monkeypatch.setattr(capital_gains, "measure_mtr", counted) + return count + + +# The default, on every route. + + +@pytest.mark.parametrize("cls", [Simulation, Microsimulation]) +@pytest.mark.parametrize("route", ["reform", "scenario"]) +def test_the_default_is_an_explicit_elasticity_of_one(cls, route): + default = build(cls, ADDITIONAL_RATE_GAINER, EQUALISED_RATES, route) + explicit = build( + cls, + ADDITIONAL_RATE_GAINER, + {**EQUALISED_RATES, **elasticities(elasticity=1.0)}, + route, + ) + + assert realisation_factor(default) == pytest.approx(CENTRAL_FACTOR, abs=1e-5) + for variable in ("capital_gains", "capital_gains_tax", "household_net_income"): + assert list(values(default, variable)) == list(values(explicit, variable)) + + +def dataset() -> UKSingleYearDataset: + """A gainer at the additional rate, one with relief gains, a non-gainer + and a net loss.""" + ids = np.arange(1, 5) + return UKSingleYearDataset( + person=pd.DataFrame( + { + "person_id": ids, + "person_benunit_id": ids, + "person_household_id": ids, + "age": [45, 50, 45, 45], + "employment_income": [100_000.0, 200_000.0, 30_000.0, 150_000.0], + "capital_gains_before_response": [ + 200_000.0, + 500_000.0, + 0.0, + -10_000.0, + ], + "capital_gains_badr": [0.0, 250_000.0, 0.0, 0.0], + } + ), + benunit=pd.DataFrame({"benunit_id": ids}), + household=pd.DataFrame( + { + "household_id": ids, + "household_weight": [1.0, 1.0, 1.0, 1.0], + "region": "LONDON", + "tenure_type": "OWNED_OUTRIGHT", + "rent": 0.0, + "council_tax": 0.0, + } + ), + fiscal_year=YEAR, + ) + + +def test_the_default_matches_on_a_dataset(): + default = Microsimulation(dataset=dataset(), reform=EQUALISED_RATES) + explicit = Microsimulation( + dataset=dataset(), + reform={**EQUALISED_RATES, **elasticities(elasticity=1.0)}, + ) + + for variable in ("capital_gains", "capital_gains_tax", "household_net_income"): + assert list(values(default, variable)) == list(values(explicit, variable)) + response = values(default, "capital_gains_behavioural_response") + assert response[0] < 0 and response[1] < 0 + # No gains, and a net loss, which pays no tax at the margin under either + # set of rates. + assert response[2] == 0 and response[3] == 0 + + +def test_relief_gains_take_the_central_elasticity_by_default(): + sim = Simulation( + situation=single(200_000, 500_000, capital_gains_badr=250_000), + reform={ + **EQUALISED_RATES, + "gov.hmrc.cgt.badr.lifetime_limit": {str(YEAR): 0}, + }, + ) + + assert values(sim, "capital_gains_badr_elasticity")[0] == 1.0 + badr_gains = 250_000 + badr_response = values(sim, "capital_gains_badr_behavioural_response")[0] + assert 1 + badr_response / badr_gains == pytest.approx( + realisation_factor(sim), abs=1e-6 + ) + + +def test_a_rate_cut_raises_realisations(): + cut = { + "gov.hmrc.cgt.higher_rate": {str(YEAR): 0.18}, + "gov.hmrc.cgt.additional_rate": {str(YEAR): 0.18}, + } + sim = Simulation(situation=ADDITIONAL_RATE_GAINER, reform=cut) + + assert realisation_factor(sim) == pytest.approx(0.82 / 0.76, abs=1e-5) + + +# Explicit alternatives. + + +@pytest.mark.parametrize("elasticity", [0.5, 1.3, 2.0]) +def test_other_retention_rate_elasticities(elasticity): + sim = Simulation( + situation=ADDITIONAL_RATE_GAINER, + reform={**EQUALISED_RATES, **elasticities(elasticity=elasticity)}, + ) + + assert realisation_factor(sim) == pytest.approx( + CENTRAL_FACTOR**elasticity, abs=1e-5 + ) + + +def test_the_static_settings_give_no_response(): + sim = Simulation( + situation=ADDITIONAL_RATE_GAINER, + reform={ + **EQUALISED_RATES, + **elasticities( + elasticity=0, mtr_elasticity=0, separate_badr_elasticity=False + ), + }, + ) + + assert values(sim, "capital_gains_behavioural_response")[0] == 0 + assert capital_gains_response_assumption(sim, YEAR).convention == "static" + + +# The marginal tax rate convention. + + +def collect(action): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = action() + return result, [ + warning + for warning in caught + if issubclass(warning.category, CapitalGainsResponseWarning) + ] + + +def test_an_mtr_elasticity_alone_applies_with_one_warning(): + """The retention-rate default counts as zero where an MTR elasticity is set + alone, with a warning, given once however often the response is read.""" + legacy = Simulation( + situation=ADDITIONAL_RATE_GAINER, + reform={**EQUALISED_RATES, **elasticities(mtr_elasticity=-0.7)}, + ) + + def read(): + legacy.calculate("household_net_income", YEAR) + legacy.calculate("marginal_tax_rate", YEAR) + legacy.delete_arrays("capital_gains_behavioural_response", YEAR) + return realisation_factor(legacy) + + factor, caught = collect(read) + + # (45% / 24%) ** -0.7, not the retention-rate 0.55 / 0.76. + assert factor == pytest.approx(math.exp(-0.7 * math.log(0.45 / 0.24)), abs=1e-5) + assert len(caught) == 1 + assert "mtr_elasticity is -0.7 in 2026" in str(caught[0].message) + + explicit = Simulation( + situation=ADDITIONAL_RATE_GAINER, + reform={ + **EQUALISED_RATES, + **elasticities(elasticity=0, mtr_elasticity=-0.7), + }, + ) + explicit_factor, silent = collect(lambda: realisation_factor(explicit)) + assert explicit_factor == factor + assert silent == [] + + +def test_the_warning_comes_again_for_another_period(): + changes = { + f"{RESPONSES}.mtr_elasticity": {"2026-01-01.2027-12-31": -0.5}, + } + sim = Simulation(situation=ADDITIONAL_RATE_GAINER, reform=changes) + + _, first = collect(lambda: sim.calculate("capital_gains", 2026)) + _, again = collect(lambda: sim.calculate("capital_gains", 2026)) + _, second = collect(lambda: sim.calculate("capital_gains", 2027)) + assert (len(first), len(again), len(second)) == (1, 0, 1) + + +def test_conflicting_settings_raise_in_their_own_period(): + """An MTR elasticity over two years, with a retention-rate elasticity + changed in the second only: the first resolves, the second raises.""" + sim = Simulation( + situation=ADDITIONAL_RATE_GAINER, + reform={ + f"{RESPONSES}.mtr_elasticity": {"2026-01-01.2027-12-31": -0.5}, + f"{RESPONSES}.elasticity": {"2027-01-01.2027-12-31": 0.5}, + }, + ) + + collect(lambda: sim.calculate("capital_gains", 2026)) + with pytest.raises(ValueError, match=r"elasticity and .*mtr_elasticity"): + sim.calculate("capital_gains", 2027) + + +def test_an_explicit_default_elasticity_resolves_like_an_inherited_one(): + """An elasticity set to its default value of 1 can't be told apart from + the default, so alongside an MTR elasticity it resolves, with the warning, + rather than raising.""" + sim = Simulation( + situation=ADDITIONAL_RATE_GAINER, + reform={ + **EQUALISED_RATES, + **elasticities(elasticity=1.0, mtr_elasticity=-0.5), + }, + ) + + assumption, caught = collect(lambda: capital_gains_response_assumption(sim, YEAR)) + assert assumption.convention == "mtr" + assert len(caught) == 1 + + +# Dated settings. + + +def test_a_dated_elasticity_applies_only_in_its_year(): + sim = Simulation( + situation=ADDITIONAL_RATE_GAINER, + reform={f"{RESPONSES}.elasticity": {"2027-01-01.2027-12-31": 2.0}}, + ) + + assert [ + capital_gains_response_assumption(sim, year).elasticity + for year in (2026, 2027, 2028) + ] == [1.0, 2.0, 1.0] + assert values(sim, "capital_gains_elasticity", 2027)[0] == 2.0 + + +# What is never measured. + + +def test_a_simulation_without_a_reform_never_measures(measurements): + sim = Simulation(situation=BAND_EDGE_GAINER) + for variable in ( + "household_net_income", + "marginal_tax_rate", + "marginal_tax_rate_wrt_employer_cost", + "marginal_tax_rate_on_capital_gains", + ): + sim.calculate(variable, YEAR) + + assert measurements["n"] == 0 + assert values(sim, "capital_gains_behavioural_response")[0] == 0 + + +def test_a_reforms_own_baseline_never_measures(measurements): + sim = Simulation(situation=ADDITIONAL_RATE_GAINER, reform=EQUALISED_RATES) + sim.calculate("household_net_income", YEAR) + reform_measurements = measurements["n"] + sim.baseline.calculate("household_net_income", YEAR) + + assert reform_measurements == 2 + assert measurements["n"] == reform_measurements + assert values(sim.baseline, "capital_gains_behavioural_response")[0] == 0 + + +def test_no_gains_means_no_measurement(measurements): + sim = Simulation(situation=single(100_000, 0), reform=EQUALISED_RATES) + sim.calculate("household_net_income", YEAR) + + assert measurements["n"] == 0 + + +def test_a_loss_is_measured(measurements): + """A loss counts as gains to respond: only the rates say it doesn't.""" + sim = Simulation(situation=single(150_000, -10_000), reform=EQUALISED_RATES) + sim.calculate("household_net_income", YEAR) + + assert measurements["n"] == 2 + assert values(sim, "capital_gains_behavioural_response")[0] == 0 + + +def test_a_change_to_the_elasticity_alone_gives_no_response(): + sim = Simulation( + situation=ADDITIONAL_RATE_GAINER, reform=elasticities(elasticity=2.0) + ) + + assert values(sim, "capital_gains_behavioural_response")[0] == 0 + + +def test_an_unreformed_simulation_matches_a_static_one(): + """Without a reform, every result is as if the response were off, + marginal tax rates included.""" + default = Simulation(situation=BAND_EDGE_GAINER) + static = Simulation( + situation=BAND_EDGE_GAINER, + reform={f"{RESPONSES}.elasticity": {"2000-01-01.2100-12-31": 0.0}}, + ) + + for variable in ( + "capital_gains", + "capital_gains_tax", + "household_net_income", + "marginal_tax_rate", + "marginal_tax_rate_wrt_employer_cost", + "marginal_tax_rate_on_capital_gains", + ): + assert list(values(default, variable)) == list(values(static, variable)), ( + variable + ) + + +# Marginal tax rates hold realisations fixed. + + +def test_pay_rise_branches_hold_the_realisation_response(): + """A pay rise is not a reform for the response to answer. + + The marginal tax rate's pay-rise branch measured the reform's rate on + gains at the higher pay against the baseline's at the old pay. At the + band edge the extra £1,000 moves the gains into the higher rate band, so + the branch counted the move as part of the reform: the response there + was about eight times the reform's, and the marginal tax rate came out + at over 800%. The branch now reads the reform's response, so the rate + equals that of a simulation holding the realised gains fixed.""" + reform = Simulation(situation=BAND_EDGE_GAINER, reform=EQUALISED_RATES) + realised = float(values(reform, "capital_gains")[0]) + static = Simulation( + situation=single(40_000, realised), + reform={**EQUALISED_RATES, **elasticities(elasticity=0)}, + ) + + rate = values(reform, "marginal_tax_rate")[0] + assert realised < 12_770 + assert rate == pytest.approx(values(static, "marginal_tax_rate")[0], abs=1e-6) + assert 0 < rate < 1 + branch = reform.branches["adult_1_pay_rise"] + assert list(values(branch, "capital_gains_behavioural_response")) == list( + values(reform, "capital_gains_behavioural_response") + ) + + +def test_employer_cost_branches_hold_the_realisation_response(): + reform = Simulation(situation=BAND_EDGE_GAINER, reform=EQUALISED_RATES) + realised = float(values(reform, "capital_gains")[0]) + static = Simulation( + situation=single(40_000, realised), + reform={**EQUALISED_RATES, **elasticities(elasticity=0)}, + ) + + assert values(reform, "marginal_tax_rate_wrt_employer_cost")[0] == ( + pytest.approx( + values(static, "marginal_tax_rate_wrt_employer_cost")[0], abs=1e-6 + ) + ) + + +# Stability and isolation. + + +RESPONSE_PARTS = ( + "capital_gains_behavioural_response", + "capital_gains_badr_behavioural_response", + "relative_capital_gains_retention_rate_change", +) + + +def response_parts(sim) -> dict: + return {variable: list(values(sim, variable)) for variable in RESPONSE_PARTS} + + +def test_the_default_response_is_stable_across_recalculation_and_order(): + sim = Simulation(situation=BAND_EDGE_GAINER, reform=EQUALISED_RATES) + sim.calculate("household_net_income", YEAR) + sim.calculate("marginal_tax_rate", YEAR) + first = response_parts(sim) + for variable in RESPONSE_PARTS: + sim.delete_arrays(variable, YEAR) + + baseline_first = Simulation(situation=BAND_EDGE_GAINER, reform=EQUALISED_RATES) + baseline_first.baseline.calculate("household_net_income", YEAR) + baseline_first.baseline.calculate("marginal_tax_rate_on_capital_gains", YEAR) + + assert first["capital_gains_behavioural_response"][0] < 0 + assert response_parts(sim) == first + assert response_parts(baseline_first) == first + + +def test_measuring_leaves_the_baseline_and_the_system_alone(): + sim = Simulation(situation=ADDITIONAL_RATE_GAINER, reform=EQUALISED_RATES) + baseline = sim.baseline + baseline_gains = list(values(baseline, "capital_gains")) + response = sim.tax_benefit_system.variables["capital_gains_behavioural_response"] + filters = list(warnings.filters) + + sim.calculate("household_net_income", YEAR) + + assert list(values(baseline, "capital_gains")) == baseline_gains + assert sim.baseline is baseline + assert ( + sim.tax_benefit_system.variables["capital_gains_behavioural_response"] + is response + ) + assert not response.is_neutralized + assert not baseline.tax_benefit_system.variables[ + "capital_gains_behavioural_response" + ].is_neutralized + assert list(warnings.filters) == filters + + +def update_rates(sim) -> None: + parameters = sim.tax_benefit_system.parameters + for path, rates in EQUALISED_RATES.items(): + parameters.get_child(path).update( + period=f"year:{YEAR}:1", value=rates[str(YEAR)] + ) + sim.tax_benefit_system.reset_parameter_caches() + + +def test_rules_changed_after_construction_still_respond(): + """A simulation built without a reform and edited afterwards, as + policyengine.py's modifiers do, responds against the rules it was built + with.""" + sim = Simulation(situation=ADDITIONAL_RATE_GAINER) + update_rates(sim) + + assert realisation_factor(sim) == pytest.approx(CENTRAL_FACTOR, abs=1e-5) + + +def test_a_modifier_that_registers_the_baseline_as_a_branch_still_responds(): + """uk-cgt-reform's modifier registers the baseline as a branch before + editing the parameters.""" + sim = Simulation(situation=ADDITIONAL_RATE_GAINER) + sim.branches["baseline"] = sim.baseline + update_rates(sim) + + assert realisation_factor(sim) == pytest.approx(CENTRAL_FACTOR, abs=1e-5) + + +def test_parameter_changes_applied_after_construction_still_respond(): + sim = Simulation(situation=ADDITIONAL_RATE_GAINER) + sim.apply_parameter_changes(EQUALISED_RATES) + + assert realisation_factor(sim) == pytest.approx(CENTRAL_FACTOR, abs=1e-5) + + +# The assumption a simulation applies. + + +def test_the_assumption_reports_the_default(): + sim = Simulation(situation=ADDITIONAL_RATE_GAINER, reform=EQUALISED_RATES) + assumption = capital_gains_response_assumption(sim, YEAR) + + assert assumption.to_dict() == { + "period": "2026", + "convention": "retention", + "elasticity": 1.0, + "badr_elasticity": 1.0, + "mtr_elasticity": 0.0, + "separate_badr_elasticity": False, + "sources": { + "elasticity": "default", + "badr_elasticity": "default", + "mtr_elasticity": "default", + "separate_badr_elasticity": "default", + }, + "mtr_auto_resolved": False, + "warnings": [], + } + assert json.loads(json.dumps(assumption.to_dict())) == assumption.to_dict() + + +def test_the_assumption_reports_overrides_and_the_obr_comparison(): + sim = Simulation( + situation=ADDITIONAL_RATE_GAINER, + reform=elasticities(elasticity=3.6, separate_badr_elasticity=True), + ) + assumption = capital_gains_response_assumption(sim, YEAR) + + assert (assumption.elasticity, assumption.badr_elasticity) == (3.6, 1.4) + assert assumption.sources == { + "elasticity": "override", + "badr_elasticity": "override", + "mtr_elasticity": "default", + "separate_badr_elasticity": "override", + } + + +def test_the_assumption_reports_an_auto_resolved_mtr_elasticity(): + sim = Simulation( + situation=ADDITIONAL_RATE_GAINER, + reform=elasticities(mtr_elasticity=-0.7), + ) + assumption, _ = collect(lambda: capital_gains_response_assumption(sim, YEAR)) + + assert assumption.convention == "mtr" + assert (assumption.elasticity, assumption.badr_elasticity) == (0.0, 0.0) + assert assumption.mtr_auto_resolved + assert assumption.sources["elasticity"] == "auto_resolved" + assert len(assumption.warnings) == 1 diff --git a/policyengine_uk/tests/test_capital_gains_responses.py b/policyengine_uk/tests/test_capital_gains_responses.py index d977d73486..0078591cb3 100644 --- a/policyengine_uk/tests/test_capital_gains_responses.py +++ b/policyengine_uk/tests/test_capital_gains_responses.py @@ -90,25 +90,34 @@ def test_revenue_falls_short_of_the_static_estimate(): def test_zero_elasticity_leaves_gains_unchanged(): - """The default elasticity of zero keeps costings static.""" + """An elasticity of zero keeps costings static.""" sim = simulate(elasticity=0) response = sim.calculate("capital_gains_behavioural_response", YEAR).sum() assert response == 0 -def test_default_elasticities_leave_gains_unchanged(): - """Both elasticity conventions default to zero, keeping costings static.""" - sim = simulate() - response = sim.calculate("capital_gains_behavioural_response", YEAR).sum() +def test_the_default_is_a_retention_rate_elasticity_of_one(): + """With no elasticity set, gains respond as at a retention-rate elasticity + of 1 (issue #2396): a marginal rate rising from 24% to 45% scales them by + (1 - 0.45) / (1 - 0.24).""" + default = simulate() + explicit = simulate(elasticity=1.0) - assert response == 0 + gains = default.calculate("capital_gains_before_response", YEAR).values[0] + response = default.calculate("capital_gains_behavioural_response", YEAR).values + explicit_response = explicit.calculate( + "capital_gains_behavioural_response", YEAR + ).values + + assert 1 + response[0] / gains == pytest.approx(0.55 / 0.76, abs=1e-5) + assert list(response) == list(explicit_response) def test_mtr_response_matches_hand_calculated_factor(): """The MTR elasticity applies the exact marginal-rate log change.""" mtr_elasticity = -0.5 - sim = simulate(mtr_elasticity=mtr_elasticity) + sim = simulate(elasticity=0, mtr_elasticity=mtr_elasticity) gains = sim.calculate("capital_gains_before_response", YEAR).values[0] response = sim.calculate("capital_gains_behavioural_response", YEAR).values[0] @@ -121,7 +130,7 @@ def test_mtr_response_matches_hand_calculated_factor(): def test_mtr_change_clamps_zero_marginal_rate(): """A zero MTR uses the 0.001 floor instead of taking log(0).""" zero_rates = {parameter: {str(YEAR): 0.0} for parameter in EQUALISED_RATES} - sim = simulate(mtr_elasticity=-0.5, rate_changes=zero_rates) + sim = simulate(elasticity=0, mtr_elasticity=-0.5, rate_changes=zero_rates) mtr_change = sim.calculate("relative_capital_gains_mtr_change", YEAR).values[0] expected_change = math.log(0.001) - math.log(0.24) @@ -131,8 +140,10 @@ def test_mtr_change_clamps_zero_marginal_rate(): def test_both_elasticities_raise(): - """The retention-rate and MTR conventions cannot both be activated.""" - sim = simulate(elasticity=1.0, mtr_elasticity=-0.5) + """The retention-rate and MTR conventions cannot both be activated: a + retention-rate elasticity changed from its default raises alongside an + MTR elasticity.""" + sim = simulate(elasticity=0.5, mtr_elasticity=-0.5) with pytest.raises( ValueError, @@ -175,7 +186,7 @@ def test_measurement_leaves_the_response_variable_active(): def test_mtr_response_is_deterministic_across_recalculation(): """Repeated MTR measurement is idempotent and leaves the response live.""" - sim = simulate(mtr_elasticity=-0.5) + sim = simulate(elasticity=0, mtr_elasticity=-0.5) live_variable = sim.tax_benefit_system.variables[ "capital_gains_behavioural_response" ] @@ -433,9 +444,9 @@ def test_badr_elasticity_applies_to_qualifying_gains(): def test_badr_elasticity_alone_moves_only_qualifying_gains(): - """A BADR elasticity with the main elasticity left at zero still responds, + """A BADR elasticity with the main elasticity set to zero still responds, on the qualifying gains alone.""" - sim = simulate_badr(separate_badr_elasticity=True) + sim = simulate_badr(elasticity=0, separate_badr_elasticity=True) mixed = 0.5 * factor(1.4, 0.21) + 0.5 assert realisation_factors(sim) == pytest.approx( diff --git a/policyengine_uk/tests/test_capital_gains_tax_components.py b/policyengine_uk/tests/test_capital_gains_tax_components.py index 23d5687c94..d79fc42b01 100644 --- a/policyengine_uk/tests/test_capital_gains_tax_components.py +++ b/policyengine_uk/tests/test_capital_gains_tax_components.py @@ -224,6 +224,9 @@ def test_residential_rate_reform_moves_only_residential_gains(self): changes = { "gov.hmrc.cgt.residential_property.higher_rate": {str(year): 0.30}, "gov.hmrc.cgt.residential_property.additional_rate": {str(year): 0.30}, + # The marginal tax rate convention, with the retention-rate + # default switched off. + "gov.simulation.capital_gains_responses.elasticity": {str(year): 0}, "gov.simulation.capital_gains_responses.mtr_elasticity": {str(year): -0.5}, } sim = Microsimulation( @@ -304,12 +307,19 @@ class TestEqualisationReforms: "gov.hmrc.cgt.badr.lifetime_limit": {"2025": 0}, } SCHEDULES = (None, *SCHEDULE_INPUTS) + # Which schedules a reform reaches shows in the tax on gains held fixed, + # so the reforms here switch the realisation response off. + STATIC = {"gov.simulation.capital_gains_responses.elasticity": {"2025": 0}} def tax(self, schedule, changes=None): variables = {"employment_income": 100_000, "capital_gains": 100_000} if schedule is not None: variables[schedule] = 100_000 - kwargs = {"scenario": Scenario(parameter_changes=changes)} if changes else {} + kwargs = ( + {"scenario": Scenario(parameter_changes={**changes, **self.STATIC})} + if changes + else {} + ) sim = Microsimulation(situation=single_person(self.YEAR, **variables), **kwargs) return float(sim.calculate("capital_gains_tax", self.YEAR).values[0]) diff --git a/policyengine_uk/tests/test_fiscal_year_parameters.py b/policyengine_uk/tests/test_fiscal_year_parameters.py index 2dffb4c6de..a5fd2f9df5 100644 --- a/policyengine_uk/tests/test_fiscal_year_parameters.py +++ b/policyengine_uk/tests/test_fiscal_year_parameters.py @@ -196,7 +196,11 @@ def _reformed(self): "households": {"household": {"members": ["person"]}}, }, scenario=Scenario( - parameter_changes={"gov.hmrc.cgt.basic_rate": {"2025": 0.5}} + parameter_changes={ + "gov.hmrc.cgt.basic_rate": {"2025": 0.5}, + # The tax on the gains as given, without the response. + "gov.simulation.capital_gains_responses.elasticity": {"2025": 0}, + } ), ) From b07a8267d506b4ee1fe4daba4ec4d6e5eb7c1fb8 Mon Sep 17 00:00:00 2001 From: Maria Juaristi Date: Wed, 14 Oct 2026 18:37:58 +0100 Subject: [PATCH 06/14] Document the capital gains realisation response A new assumptions page describes how the response works, the default and its qualifications, when there is no response, the other assumptions and the marginal tax rate convention, the assumption helper, how to move from the static default, and the runtime. The simulations guide's revenue identity and the dynamics guide point to it. Part of #2396. Co-Authored-By: Claude Opus 5.5 --- .../assumptions/capital-gains-responses.md | 194 ++++++++++++++++++ docs/book/usage/dynamics.md | 4 + docs/book/usage/simulations.md | 2 +- 3 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 docs/book/assumptions/capital-gains-responses.md diff --git a/docs/book/assumptions/capital-gains-responses.md b/docs/book/assumptions/capital-gains-responses.md new file mode 100644 index 0000000000..0725065673 --- /dev/null +++ b/docs/book/assumptions/capital-gains-responses.md @@ -0,0 +1,194 @@ +# Capital gains realisation response + +People choose when to realise capital gains, so a change in the tax on gains +changes the gains realised. PolicyEngine UK models this realisation response +for every reform: by default, realised gains respond at a retention-rate +elasticity of 1. + +## How the response works + +A reform scales each person's realised gains by + +$$ +G_1 = G_0 \times \left(\frac{1 - t_1}{1 - t_0}\right)^{e} +$$ + +where $G_0$ is their gains before the response (`capital_gains_before_response`), +$t_0$ and $t_1$ their marginal rates on gains under the baseline's and the +reform's rules, and $e$ the elasticity. $1 - t$ is the retention rate, the +share of a marginal pound of gains kept after tax. + +- **The rates.** Each rate is `marginal_tax_rate_on_capital_gains`, a + share-weighted marginal rate across the person's schedules (main rates, + Business Asset Disposal Relief, residential property, carried interest). + Both are measured at the person's gains before the response, with every + behavioural response switched off, so their difference reflects the reform + alone. +- **The schedules.** Gains qualifying for Business Asset Disposal Relief or + Investors' Relief respond at `capital_gains_badr_elasticity`, and the rest at + `capital_gains_elasticity`, both to the same change in the person's rate. +- **The result.** `capital_gains` is the gains before the response plus + `capital_gains_behavioural_response`, and `capital_gains_tax` charges the + realised gains. + +## The default assumption + +`gov.simulation.capital_gains_responses.elasticity` is 1.0, and gains +qualifying for the relief take the same 1.0. This is PolicyEngine's central +assumption: the value Advani, Lonsdale and Summers (2024, pp. 36–37) use for +the medium term, with the range they consider, 0.5 to 2.0, as sensitivities. + +The assumption has limits: + +- **It isn't an estimate for every reform.** Their 1.0 is for a package that + also reforms the base, removing the uplift at death, and includes an + adjustment for shifting income between tax bases. Adopting it does not + reproduce their revenue estimates. +- **No income tax is added back.** Shifting between gains and income is + separate work + ([#1982](https://github.com/PolicyEngine/policyengine-uk/issues/1982), + [#1805](https://github.com/PolicyEngine/policyengine-uk/issues/1805)), and + the central value already includes a cross-base adjustment. +- **The start date is a convention.** The value applies in every period. Its + start date is the start of the model's timeline, not a change in law. + +For a marginal rate on gains rising from 24% to 45%, the default scales +realised gains by $0.55 / 0.76 \approx 0.7237$. A marginal tax rate +elasticity of $-0.7$, a different convention, gives +$(0.45 / 0.24)^{-0.7} \approx 0.6440$: the two are not interchangeable. + +```python +from policyengine_uk import Simulation + +situation = { + "people": { + "person": { + "age": {2026: 45}, + "employment_income": {2026: 100_000}, + "capital_gains": {2026: 200_000}, + } + }, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, +} +rates = { + "gov.hmrc.cgt.higher_rate": {"2026": 0.45}, + "gov.hmrc.cgt.additional_rate": {"2026": 0.45}, +} +reformed = Simulation(situation=situation, reform=rates) +# The marginal rate on the gains rises from 24% to 45%, so realised gains +# fall to 0.55 / 0.76 of £200,000, about £144,737. +print(reformed.calculate("capital_gains", 2026)) +``` + +## When there is no response + +- **No baseline.** A simulation built directly in policyengine-core (as the + YAML tests are) has no baseline to compare with. +- **No elasticity.** The static settings below switch the response off. +- **The baseline's rules.** A simulation built without a reform, every branch + of it, a reform's own baseline, and a reform that changes only the + behavioural response settings all apply their baseline's rules. Their rates + would be equal, so nothing is measured. +- **No gains.** Where no one has gains or losses, nothing is measured. + +The response answers changes in the rules. A simulation whose inputs change +while its rules don't has no response. Marginal tax rates on earnings hold +realised gains at the simulation's own values: a pay rise is not a reform for +the response to answer. + +Rules changed after a simulation is built count too. A simulation built +without a reform keeps a copy of itself, taken when it was built, as its +baseline. If a modifier then edits its parameters, it responds against that +copy. + +## Other assumptions + +Every setting is an ordinary parameter, so a reform or scenario can change it +for the years it covers. A reform dict's year key, such as `"2026"`, covers +that fiscal year only; `"2000-01-01.2100-12-31"` covers every year. + +```python +RESPONSES = "gov.simulation.capital_gains_responses" +EVERY_YEAR = "2000-01-01.2100-12-31" + +# Static: no response. +static = { + f"{RESPONSES}.elasticity": {EVERY_YEAR: 0}, + f"{RESPONSES}.mtr_elasticity": {EVERY_YEAR: 0}, + f"{RESPONSES}.separate_badr_elasticity": {EVERY_YEAR: False}, +} +# CenTax's sensitivities, or any other value. +lower = {f"{RESPONSES}.elasticity": {"2026": 0.5}} +upper = {f"{RESPONSES}.elasticity": {"2026": 2.0}} +# The OBR's elasticities: 3.6 for main-rate gains and badr_elasticity's 1.4 +# for qualifying gains. This compares elasticities, not the OBR's full +# costing, which includes further effects. +obr = { + f"{RESPONSES}.elasticity": {"2026": 3.6}, + f"{RESPONSES}.separate_badr_elasticity": {"2026": True}, +} +static_costing = Simulation(situation=situation, reform={**rates, **static}) +``` + +### The marginal tax rate convention + +`gov.simulation.capital_gains_responses.mtr_elasticity` scales gains by +$(t_1 / t_0)^{e}$ instead, and can't be combined with a retention-rate +elasticity in the same period. + +- **The default gives way.** Where the retention-rate settings keep their + defaults, an MTR elasticity applies alone in its periods and the + retention-rate elasticities count as zero. A `CapitalGainsResponseWarning` + says so, once per tax-benefit system and period. +- **Silencing the warning.** Setting the retention-rate elasticity to 0 for + the same periods gives the same results without the warning. +- **Conflicts raise.** A retention-rate elasticity changed to a nonzero value + raises a `ValueError` alongside an MTR elasticity, as does a separate + relief elasticity. +- **An explicit 1.** A retention-rate elasticity set to its default of 1 + can't be told apart from the default, so it resolves in the same way. + +```python +legacy = {f"{RESPONSES}.mtr_elasticity": {"2026": -0.7}} +# Warns: the retention-rate default counts as zero in 2026. +with_warning = Simulation(situation=situation, reform={**rates, **legacy}) +# The same results, without the warning. +explicit = {**legacy, f"{RESPONSES}.elasticity": {"2026": 0}} +without_warning = Simulation(situation=situation, reform={**rates, **explicit}) +``` + +## The assumption a simulation applies + +`capital_gains_response_assumption` resolves a simulation's settings for a +year as the response does, after every reform and modifier applied so far. +Its `to_dict()` gives the convention, the elasticities for main-rate and +qualifying gains, the MTR elasticity, the separate relief switch, and whether +each is the default, an override or set to zero for an MTR elasticity. It is +there to record with a run's results. + +```python +from policyengine_uk.utils.capital_gains import capital_gains_response_assumption + +print(capital_gains_response_assumption(reformed, 2026).to_dict()) +``` + +## Moving from the static default + +The elasticity used to default to 0, so CGT reforms were costed statically +unless a caller set an elasticity. + +- **Static results.** Set the static settings above for the years you run. +- **MTR elasticity alone.** Results are unchanged, with a warning. Set the + retention-rate elasticity to 0 to silence it. +- **Revenue.** Realised gains are part of household income. With a response, + the change in household net income no longer equals the change in revenue, + so use `gov_balance` for reforms that move the tax on gains. + +## Runtime + +A reform that changes the rules measures two marginal rates on gains per +simulation and year: one for the reform and one for its baseline. Each +recalculates household net income in branches of the simulation. Simulations +without a reform, baselines, reforms to the behavioural settings alone, and +populations without gains skip the measurement. diff --git a/docs/book/usage/dynamics.md b/docs/book/usage/dynamics.md index b98f23fe89..2f9ed684e2 100644 --- a/docs/book/usage/dynamics.md +++ b/docs/book/usage/dynamics.md @@ -196,6 +196,10 @@ reformed = Simulation( print(reformed.calculate("employment_income_behavioral_response", 2025)) ``` +Unlike these labour supply elasticities, which are zero unless a reform sets +them, capital gains respond to reforms by default: see the +[capital gains realisation response](../assumptions/capital-gains-responses.md). + ## Technical implementation details The dynamics calculation proceeds through several steps: diff --git a/docs/book/usage/simulations.md b/docs/book/usage/simulations.md index 7b54a694da..9b56ea623c 100644 --- a/docs/book/usage/simulations.md +++ b/docs/book/usage/simulations.md @@ -359,7 +359,7 @@ print(f"Additional government revenue: £{revenue_change:.1f}bn") ``` ```{note} -Revenue calculations use the accounting identity: when household net income decreases, government revenue increases by the same amount (assuming no behavioural responses). +Revenue calculations use the accounting identity: when household net income decreases, government revenue increases by the same amount. The identity holds while no behavioural response moves household income. Realised capital gains are part of household income and respond by default to reforms that change the tax on them ([capital gains realisation response](../assumptions/capital-gains-responses.md)), so for those reforms use `gov_balance` instead. ``` ```python From c6c77ef321ddd04ca7a873a38e15299e887996c5 Mon Sep 17 00:00:00 2001 From: Maria Juaristi Date: Wed, 14 Oct 2026 21:30:44 +0100 Subject: [PATCH 07/14] Measure the marginal rate on gains from capital gains tax marginal_tax_rate_on_capital_gains recalculated household net income in each branch, all of whose programmes it has to reach. Realised gains reach net income only as market income and through capital_gains_tax, so the rise in that tax per pound of marginal gains is the same rate, and its branches recalculate only what the tax depends on. On the enhanced FRS a measurement now costs about a sixth of what it did, which takes the response from about 13 to about 2 seconds per reformed simulation and year; the rate matches the old one within 0.001 for every person with gains. A test traces a net income calculation and fails if any variable other than capital_gains_tax and household_market_income reads capital_gains; another checks the rate against a direct net-income difference. Part of #2396. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2396.changed.md | 2 +- .../assumptions/capital-gains-responses.md | 14 ++++-- .../test_capital_gains_response_default.py | 50 +++++++++++++++++++ .../marginal_tax_rate_on_capital_gains.py | 34 +++++++------ 4 files changed, 81 insertions(+), 19 deletions(-) diff --git a/changelog.d/2396.changed.md b/changelog.d/2396.changed.md index 296f8e2764..220d86498e 100644 --- a/changelog.d/2396.changed.md +++ b/changelog.d/2396.changed.md @@ -1 +1 @@ -- Made a retention-rate elasticity of 1.0 the default capital gains realisation response (`gov.simulation.capital_gains_responses.elasticity`, previously 0), the central value Advani, Lonsdale and Summers (2024, pp. 36-37) use, with 0.5 and 2.0 as sensitivities: CGT reforms are no longer costed statically unless the elasticity is set to 0, and gains qualifying for Business Asset Disposal Relief take the same 1.0. An `mtr_elasticity` set while the retention-rate settings keep their defaults now applies alone in its periods, with a `CapitalGainsResponseWarning`; retention-rate settings changed to nonzero values still raise alongside it. Simulations that apply their baseline's rules (any simulation without a reform, and a reform's own baseline) and populations without gains no longer measure marginal rates on gains, and marginal tax rates on earnings hold realised gains fixed instead of measuring the response again at the higher pay. `capital_gains_response_assumption` reports the response a simulation applies in a year. +- Made a retention-rate elasticity of 1.0 the default capital gains realisation response (`gov.simulation.capital_gains_responses.elasticity`, previously 0), the central value Advani, Lonsdale and Summers (2024, pp. 36-37) use, with 0.5 and 2.0 as sensitivities: CGT reforms are no longer costed statically unless the elasticity is set to 0, and gains qualifying for Business Asset Disposal Relief take the same 1.0. An `mtr_elasticity` set while the retention-rate settings keep their defaults now applies alone in its periods, with a `CapitalGainsResponseWarning`; retention-rate settings changed to nonzero values still raise alongside it. Simulations that apply their baseline's rules (any simulation without a reform, and a reform's own baseline) and populations without gains no longer measure marginal rates on gains, and marginal tax rates on earnings hold realised gains fixed instead of measuring the response again at the higher pay. `marginal_tax_rate_on_capital_gains` now measures the rise in capital gains tax rather than the fall in household net income, which gives the same rate at about a sixth of the cost. `capital_gains_response_assumption` reports the response a simulation applies in a year. diff --git a/docs/book/assumptions/capital-gains-responses.md b/docs/book/assumptions/capital-gains-responses.md index 0725065673..bc4eb0502c 100644 --- a/docs/book/assumptions/capital-gains-responses.md +++ b/docs/book/assumptions/capital-gains-responses.md @@ -189,6 +189,14 @@ unless a caller set an elasticity. A reform that changes the rules measures two marginal rates on gains per simulation and year: one for the reform and one for its baseline. Each -recalculates household net income in branches of the simulation. Simulations -without a reform, baselines, reforms to the behavioural settings alone, and -populations without gains skip the measurement. +recalculates capital gains tax, and what it depends on, in branches of the +simulation; on the enhanced FRS that adds about two seconds per reformed +simulation and year. Simulations without a reform, baselines, reforms to the +behavioural settings alone, and populations without gains skip the +measurement. + +The marginal rate on gains is the rise in capital gains tax per pound of +marginal gains. Realised gains reach household net income only as market +income and through that tax, so it is also the share of marginal gains net +income does not keep; a test checks that no other variable reads realised +gains. diff --git a/policyengine_uk/tests/test_capital_gains_response_default.py b/policyengine_uk/tests/test_capital_gains_response_default.py index a633cf8835..ae2ffbd62f 100644 --- a/policyengine_uk/tests/test_capital_gains_response_default.py +++ b/policyengine_uk/tests/test_capital_gains_response_default.py @@ -410,6 +410,56 @@ def test_an_unreformed_simulation_matches_a_static_one(): ) +# The marginal rate on gains. + + +def _readers(node, name: str, found: set) -> None: + for child in node.children: + if child.name == name: + found.add(node.name) + _readers(child, name, found) + + +def test_realised_gains_reach_net_income_only_as_income_and_through_the_tax(): + """marginal_tax_rate_on_capital_gains counts only the rise in capital + gains tax. That is the share of marginal gains household net income does + not keep only while gains reach net income as market income and through + capital_gains_tax. This traces a net income calculation for a gainer with + relief gains; if another variable starts reading capital_gains, the rate + has to count it.""" + sim = Simulation( + situation=single(200_000, 500_000, capital_gains_badr=250_000), trace=True + ) + sim.calculate("household_net_income", YEAR) + readers = set() + for tree in sim.tracer.trees: + _readers(tree, "capital_gains", readers) + + assert readers == {"household_market_income", "capital_gains_tax"} + + +@pytest.mark.parametrize( + "earnings, gains", + [(40_000, 12_770), (80_000, 50_000), (200_000, 500_000), (15_000, 20_000)], + ids=["band-edge", "higher", "additional", "low-earner"], +) +def test_the_rate_on_gains_is_the_share_net_income_does_not_keep(earnings, gains): + """Measured from capital gains tax, the rate equals one minus the rise in + household net income per pound of marginal gains.""" + rate = values( + Simulation(situation=single(earnings, gains)), + "marginal_tax_rate_on_capital_gains", + )[0] + net = values(Simulation(situation=single(earnings, gains)), "household_net_income")[ + 0 + ] + higher = values( + Simulation(situation=single(earnings, gains + 1_000)), "household_net_income" + )[0] + + assert rate == pytest.approx(1 - (higher - net) / 1_000, abs=1e-4) + + # Marginal tax rates hold realisations fixed. diff --git a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/marginal_tax_rate_on_capital_gains.py b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/marginal_tax_rate_on_capital_gains.py index 17ec546308..b1431c9afe 100644 --- a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/marginal_tax_rate_on_capital_gains.py +++ b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/marginal_tax_rate_on_capital_gains.py @@ -10,15 +10,20 @@ class marginal_tax_rate_on_capital_gains(Variable): label = "capital gains marginal tax rate" documentation = ( - "Percent of marginal capital gains that do not increase household net " - "income. The marginal gains are added to capital_gains itself, so they " - "are shared across the person's schedules (main rates, Business Asset " - "Disposal Relief, residential property, carried interest) in proportion " - "to their pre-response shares: this is a share-weighted marginal rate. " - "The marginal gains are £1,000, or 0.1% of the gains where that is " - "larger, and the rise in net income is divided by the rise in gains as " - "stored, so that float32 rounding stays negligible at any size of gain. " - "Above £1m of gains the rate is therefore averaged over the larger step." + "Percent of marginal capital gains paid in capital gains tax. Realised " + "gains reach household net income only as market income and through " + "capital_gains_tax, so this is the share of marginal gains that does " + "not increase household net income; it leaves out the private school " + "attendance imputation, which ranks households by an income that " + "includes gains. The marginal gains are added to capital_gains itself, " + "so they are shared across the person's schedules (main rates, " + "Business Asset Disposal Relief, residential property, carried " + "interest) in proportion to their pre-response shares: this is a " + "share-weighted marginal rate. The marginal gains are £1,000, or 0.1% " + "of the gains where that is larger, and the rise in tax is divided by " + "the rise in gains as stored, so that float32 rounding stays " + "negligible at any size of gain. Above £1m of gains the rate is " + "therefore averaged over the larger step." ) entity = Person definition_period = YEAR @@ -34,6 +39,9 @@ def formula(person, period, parameters): # Hold the realisation response at this simulation's values, so the # branch reads its schedule split instead of measuring it again. held_responses = held_realisation_responses(person, period) + # The branch recalculates only what capital gains tax depends on, a + # fraction of what household net income does. + tax = person("capital_gains_tax", period) for adult_index in [1, 2]: alt_simulation = simulation.get_branch(f"adult_{adult_index}_cg_rise") mask = adult_index_values == adult_index @@ -50,12 +58,8 @@ def formula(person, period, parameters): # Float32 rounds the higher gains, so the rise actually stored can # differ from the step by up to half the gains' float32 spacing. gains_rise = alt_person("capital_gains", period) - gains - household_net_income = person.household("household_net_income", period) - household_net_income_higher_gains = alt_person.household( - "household_net_income", period - ) - increase = household_net_income_higher_gains - household_net_income - mtr_values += where(mask, 1 - increase / where(mask, gains_rise, 1), 0) + tax_rise = alt_person("capital_gains_tax", period) - tax + mtr_values += where(mask, tax_rise / where(mask, gains_rise, 1), 0) del simulation.branches[f"adult_{adult_index}_cg_rise"] return mtr_values From 0e6cfaa149bfaeb281bdadbbccb9e82c8122ebc0 Mon Sep 17 00:00:00 2001 From: Maria Juaristi Date: Thu, 15 Oct 2026 11:59:34 +0100 Subject: [PATCH 08/14] Say which way the default errs, and what else the response moves From Vahid's review of #2419: - The elasticity's description and the docs say that CenTax reach 1.0 from about 1.5 by adjusting for their package's base changes and income shifting, and expect a higher elasticity without them (p. 37), so for rate changes alone the default overstates what a rate rise raises; and that 1.0 is a medium-term value, so first-year scores likely understate the response (pp. 34-35). - A "Large rate rises" section gives the revenue-maximising rate 1/(1+e) and shows the elasticity can decide the sign of a large rise, against the ready reckoner's figures (p. 33). - "What the response moves": household net income counts gains no longer realised as income lost, and income tax band and allowance reforms move realised gains too. The changelog says both. - "When there is no response" covers a reform to a band the last pound of gains doesn't fall in, and the OBR comparison notes that pounds mix the elasticity gap with any gap in the level of gains. - An added fragment for capital_gains_response_assumption and CapitalGainsResponseWarning, so the release is a conscious minor bump rather than the default patch. Every figure comes from synthetic households. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2396.added.md | 1 + changelog.d/2396.changed.md | 2 +- .../assumptions/capital-gains-responses.md | 75 +++++++++++++++++-- docs/book/usage/simulations.md | 2 +- .../badr_elasticity.yaml | 2 +- .../capital_gains_responses/elasticity.yaml | 2 +- 6 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 changelog.d/2396.added.md diff --git a/changelog.d/2396.added.md b/changelog.d/2396.added.md new file mode 100644 index 0000000000..7a6e6b16aa --- /dev/null +++ b/changelog.d/2396.added.md @@ -0,0 +1 @@ +- Added `capital_gains_response_assumption` (`policyengine_uk.utils.capital_gains`), which reports the capital gains realisation response a simulation applies in a year (the convention, each elasticity and where it came from), and `CapitalGainsResponseWarning`, issued when an `mtr_elasticity` applies in place of the retention-rate default. diff --git a/changelog.d/2396.changed.md b/changelog.d/2396.changed.md index 220d86498e..1250694bbb 100644 --- a/changelog.d/2396.changed.md +++ b/changelog.d/2396.changed.md @@ -1 +1 @@ -- Made a retention-rate elasticity of 1.0 the default capital gains realisation response (`gov.simulation.capital_gains_responses.elasticity`, previously 0), the central value Advani, Lonsdale and Summers (2024, pp. 36-37) use, with 0.5 and 2.0 as sensitivities: CGT reforms are no longer costed statically unless the elasticity is set to 0, and gains qualifying for Business Asset Disposal Relief take the same 1.0. An `mtr_elasticity` set while the retention-rate settings keep their defaults now applies alone in its periods, with a `CapitalGainsResponseWarning`; retention-rate settings changed to nonzero values still raise alongside it. Simulations that apply their baseline's rules (any simulation without a reform, and a reform's own baseline) and populations without gains no longer measure marginal rates on gains, and marginal tax rates on earnings hold realised gains fixed instead of measuring the response again at the higher pay. `marginal_tax_rate_on_capital_gains` now measures the rise in capital gains tax rather than the fall in household net income, which gives the same rate at about a sixth of the cost. `capital_gains_response_assumption` reports the response a simulation applies in a year. +- Made a retention-rate elasticity of 1.0 the default capital gains realisation response (`gov.simulation.capital_gains_responses.elasticity`, previously 0), the central value Advani, Lonsdale and Summers (2024, pp. 36-37) use for the medium-term effect of a package that also broadens the base, with 0.5 and 2.0 as sensitivities. For a rate change alone their reasoning points to a higher value, and responses in the first year can be larger, so the default can overstate what a rate rise raises; for a large rise it can even give the opposite sign to official costings. CGT reforms are no longer costed statically unless the elasticity is set to 0, and gains qualifying for Business Asset Disposal Relief take the same 1.0. Reforms to income tax bands and allowances also move realised gains, by changing the rate gains are taxed at, so costings that include CGT move too. Household net income counts gains no longer realised as income lost, so household and distributional results for these reforms change as well as revenue. An `mtr_elasticity` set while the retention-rate settings keep their defaults now applies alone in its periods, with a `CapitalGainsResponseWarning`; retention-rate settings changed to nonzero values still raise alongside it. Simulations that apply their baseline's rules (any simulation without a reform, and a reform's own baseline) and populations without gains no longer measure marginal rates on gains, and marginal tax rates on earnings hold realised gains fixed instead of measuring the response again at the higher pay. `marginal_tax_rate_on_capital_gains` now measures the rise in capital gains tax rather than the fall in household net income, which gives the same rate at about a sixth of the cost. diff --git a/docs/book/assumptions/capital-gains-responses.md b/docs/book/assumptions/capital-gains-responses.md index bc4eb0502c..c1360be198 100644 --- a/docs/book/assumptions/capital-gains-responses.md +++ b/docs/book/assumptions/capital-gains-responses.md @@ -36,14 +36,27 @@ share of a marginal pound of gains kept after tax. `gov.simulation.capital_gains_responses.elasticity` is 1.0, and gains qualifying for the relief take the same 1.0. This is PolicyEngine's central assumption: the value Advani, Lonsdale and Summers (2024, pp. 36–37) use for -the medium term, with the range they consider, 0.5 to 2.0, as sensitivities. +the medium-term (five-year) effect of their reform package, with the range +they consider, 0.5 to 2.0, as sensitivities. The assumption has limits: -- **It isn't an estimate for every reform.** Their 1.0 is for a package that - also reforms the base, removing the uplift at death, and includes an - adjustment for shifting income between tax bases. Adopting it does not - reproduce their revenue estimates. +- **For rate changes alone it is low.** Their starting point is about 1.5, + Agersnap and Zidar's five-year estimate. They bring it down to 1.0 for + their package's base changes, removing the uplift at death and charging + gains on emigration, and because taxing gains and income alike moves + income back from gains into income tax, which they count as revenue kept. + They expect a reform without those base changes to have a higher + elasticity, and so lower revenue (p. 37). PolicyEngine adds no income tax + back, so for a reform to rates alone their reasoning points to a higher + value, such as their unadjusted 1.5. The default then overstates what a + rate rise raises. Adopting 1.0 does not reproduce their revenue estimates. +- **It is a medium-term value.** The studies behind it find larger responses + soon after a reform: about 1.6 at three to five years (Agersnap and Zidar) + and 1.8 in the first year for large gainers in Canada (Lavecchia and + Tazhitdinova), much of it retiming (pp. 34–35). Forestalling before a + reform isn't modelled either. A score for the first year after a reform is + likely to understate the response. - **No income tax is added back.** Shifting between gains and income is separate work ([#1982](https://github.com/PolicyEngine/policyengine-uk/issues/1982), @@ -81,6 +94,45 @@ reformed = Simulation(situation=situation, reform=rates) print(reformed.calculate("capital_gains", 2026)) ``` +### Large rate rises + +With this response, the revenue from a single rate on gains peaks where the +rate is $1 / (1 + e)$: 50% at the default, 40% at 1.5, 33% at 2.0 and about +22% at the 3.6 of the OBR comparison below. So for a large rate rise the +elasticity can decide whether revenue rises or falls. In the example above, +the person's capital gains tax rises by £16,502 at the default and by £6,777 +at 1.5, and falls by £20,536 at 3.6. Official costings can have the other +sign. As of 2024, HMRC's ready reckoner had a 10 percentage point rise in the +top rates raising over £5bn before behaviour, and losing £2bn a year by the +third year once behaviour is counted (Advani, Lonsdale and Summers 2024, +p. 33). + +## What the response moves + +- **Household incomes.** Realised gains are part of household market income. + So the response moves household net income as well as the tax. + - For a capital gains tax rise, net income falls by the tax plus the gains + no longer realised. Those gains are deferred, not lost: the assets are + still held. + - In the example above, the person's net income falls by £71,765 with the + response and £41,370 without it. Revenue rises by £16,502, not £41,370. + - Per-household impacts, income deciles, and winners and losers all count + the gains not realised as income lost. + + This was already true for anyone who set an elasticity; the default makes + it the published figure. For revenue, use `gov_balance` or the tax + variables, not the change in household net income. Whether distributional + measures should use gains before the response is an open methodology + question. +- **Reforms to other taxes.** Income tax bands and allowances decide which + capital gains tax rate gains fall in. So a reform to them changes the rate + on some people's gains, and their realisations respond. + - With earnings of £30,000 and gains of £20,000, cutting the basic rate + band to £20,000, or the personal allowance to £5,000, lowers realised + gains by £1,463 (7.3%). + - Costings that include capital gains tax, such as `gov_balance`, move with + that response. + ## When there is no response - **No baseline.** A simulation built directly in policyengine-core (as the @@ -91,6 +143,11 @@ print(reformed.calculate("capital_gains", 2026)) behavioural response settings all apply their baseline's rules. Their rates would be equal, so nothing is measured. - **No gains.** Where no one has gains or losses, nothing is measured. +- **No change at the margin.** The response answers the rate on a person's + last pound of gains. A reform to one band's rate does nothing for someone + whose last pound falls in another band. Raising only the higher rate to 45% + raises the example person's tax from £47,280 to £65,642 with no response, + as their last pound of gains is taxed at the unchanged additional rate. The response answers changes in the rules. A simulation whose inputs change while its rules don't has no response. Marginal tax rates on earnings hold @@ -131,6 +188,11 @@ obr = { static_costing = Simulation(situation=situation, reform={**rates, **static}) ``` +The response scales the gains already in the data, so it can't change their +level. A comparison in pounds with the OBR's costing therefore mixes the gap +between the elasticities with any gap in the level of gains and capital gains +tax. + ### The marginal tax rate convention `gov.simulation.capital_gains_responses.mtr_elasticity` scales gains by @@ -184,6 +246,9 @@ unless a caller set an elasticity. - **Revenue.** Realised gains are part of household income. With a response, the change in household net income no longer equals the change in revenue, so use `gov_balance` for reforms that move the tax on gains. +- **Household and distributional results.** They now count gains no longer + realised as income lost, and reforms to income tax bands and allowances move + realised gains too (see [What the response moves](#what-the-response-moves)). ## Runtime diff --git a/docs/book/usage/simulations.md b/docs/book/usage/simulations.md index 9b56ea623c..f457fe73b8 100644 --- a/docs/book/usage/simulations.md +++ b/docs/book/usage/simulations.md @@ -359,7 +359,7 @@ print(f"Additional government revenue: £{revenue_change:.1f}bn") ``` ```{note} -Revenue calculations use the accounting identity: when household net income decreases, government revenue increases by the same amount. The identity holds while no behavioural response moves household income. Realised capital gains are part of household income and respond by default to reforms that change the tax on them ([capital gains realisation response](../assumptions/capital-gains-responses.md)), so for those reforms use `gov_balance` instead. +Revenue calculations use the accounting identity: when household net income decreases, government revenue increases by the same amount. The identity holds while no behavioural response moves household income. Realised capital gains are part of household income and respond by default to reforms that change the tax on them, including reforms to income tax bands and allowances ([capital gains realisation response](../assumptions/capital-gains-responses.md)), so for those reforms use `gov_balance` instead. Household and distributional results for those reforms count gains no longer realised as income lost, although the gains are deferred rather than lost. ``` ```python diff --git a/policyengine_uk/parameters/gov/simulation/capital_gains_responses/badr_elasticity.yaml b/policyengine_uk/parameters/gov/simulation/capital_gains_responses/badr_elasticity.yaml index 2aa1c7dbeb..c5a34e0e6c 100644 --- a/policyengine_uk/parameters/gov/simulation/capital_gains_responses/badr_elasticity.yaml +++ b/policyengine_uk/parameters/gov/simulation/capital_gains_responses/badr_elasticity.yaml @@ -1,4 +1,4 @@ -description: Elasticity of realisations of gains qualifying for Business Asset Disposal Relief or Investors' Relief with respect to the capital gains retention rate. It applies to those gains only while separate_badr_elasticity is true; otherwise they take the main elasticity (1.0 by default), as the person's other gains always do. The OBR's costing of the October 2024 rate rises used 1.4 for these gains and 3.6 for main-rate gains. Setting the main elasticity to 3.6 with separate_badr_elasticity true compares PolicyEngine's response with the OBR's elasticities, not with the OBR's full costing, which includes further effects. +description: Elasticity of realisations of gains qualifying for Business Asset Disposal Relief or Investors' Relief with respect to the capital gains retention rate. It applies to those gains only while separate_badr_elasticity is true; otherwise they take the main elasticity (1.0 by default), as the person's other gains always do. The OBR's costing of the October 2024 rate rises used 1.4 for these gains and 3.6 for main-rate gains. Setting the main elasticity to 3.6 with separate_badr_elasticity true compares PolicyEngine's response with the OBR's elasticities, not with the OBR's full costing, which includes further effects. The response scales the gains already in the data, so a comparison in pounds also mixes the gap between the elasticities with any gap in the level of gains and capital gains tax. values: 2000-01-01: 1.4 metadata: diff --git a/policyengine_uk/parameters/gov/simulation/capital_gains_responses/elasticity.yaml b/policyengine_uk/parameters/gov/simulation/capital_gains_responses/elasticity.yaml index 4e2387fd20..251477eefc 100644 --- a/policyengine_uk/parameters/gov/simulation/capital_gains_responses/elasticity.yaml +++ b/policyengine_uk/parameters/gov/simulation/capital_gains_responses/elasticity.yaml @@ -1,4 +1,4 @@ -description: Elasticity of capital gains realisations with respect to the capital gains retention rate, the share of a marginal pound of gains kept after tax. A reform scales each person's realised gains by ((1 - t1) / (1 - t0)) to the power of this elasticity, where t0 and t1 are their baseline and reform marginal rates on gains, both measured at their gains before any response. PolicyEngine's central assumption is 1.0, the value Advani, Lonsdale and Summers (2024) use for the medium term, with 0.5 and 2.0, the range they consider, as sensitivities. Their 1.0 is for a package that also reforms the base, removing the uplift at death, and includes an adjustment for shifting between tax bases, so it is a modelling assumption rather than an estimate for every reform to rates alone, and adopting it does not reproduce their revenue estimates. The value applies in every period; its start date is the start of the model's timeline, not a change in law. For a static costing, set this and mtr_elasticity to 0, with separate_badr_elasticity false. +description: Elasticity of capital gains realisations with respect to the capital gains retention rate, the share of a marginal pound of gains kept after tax. A reform scales each person's realised gains by ((1 - t1) / (1 - t0)) to the power of this elasticity, where t0 and t1 are their baseline and reform marginal rates on gains, both measured at their gains before any response. PolicyEngine's central assumption is 1.0, the value Advani, Lonsdale and Summers (2024) use for the medium-term (five-year) effect of their reform package, with 0.5 and 2.0, the range they consider, as sensitivities. They reach 1.0 from a starting point of about 1.5 by adjusting for their package's base changes (removing the uplift at death, charging gains on emigration) and for income no longer shifted into gains, and expect a reform without those base changes to have a higher elasticity, and so lower revenue. So for a reform to rates alone, where PolicyEngine also adds no income tax back, their reasoning points to a value above 1.0, and the default overstates what a rate rise raises. Responses soon after a reform can be larger still, so a first-year score is likely to understate the response. Adopting 1.0 does not reproduce their revenue estimates. The value applies in every period; its start date is the start of the model's timeline, not a change in law. For a static costing, set this and mtr_elasticity to 0, with separate_badr_elasticity false. values: 2000-01-01: 1.0 metadata: From b98eae871fc4461633c0b499e3b3d42b3b462317 Mon Sep 17 00:00:00 2001 From: Maria Juaristi Date: Thu, 15 Oct 2026 12:23:40 +0100 Subject: [PATCH 09/14] Fix lint and main's labour supply code-health test after merging main Main added test_labour_supply_parameters_are_read.py, whose fixture checks that the formula path ran by looking for the lsr_measurement branch. This PR no longer keeps measurement branches, so the fixture now checks that the formula measured a nonzero response instead, which it also does only after its early returns. The docs example in dynamics.md is wrapped to pass ruff format. Co-Authored-By: Claude Opus 5.5 --- docs/book/usage/dynamics.md | 4 +++- .../test_labour_supply_parameters_are_read.py | 9 +++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/book/usage/dynamics.md b/docs/book/usage/dynamics.md index 8446253500..b98f23fe89 100644 --- a/docs/book/usage/dynamics.md +++ b/docs/book/usage/dynamics.md @@ -185,7 +185,9 @@ reformed = Simulation( situation=situation, reform={ "gov.hmrc.income_tax.rates.uk[0].rate": {"2025": 0.30}, - "gov.simulation.labour_supply_responses.substitution_elasticity": {"2025": 0.25}, + "gov.simulation.labour_supply_responses.substitution_elasticity": { + "2025": 0.25 + }, "gov.simulation.labour_supply_responses.income_elasticity": {"2025": -0.05}, }, ) diff --git a/policyengine_uk/tests/code_health/test_labour_supply_parameters_are_read.py b/policyengine_uk/tests/code_health/test_labour_supply_parameters_are_read.py index a3d3db9000..f3e5fa6180 100644 --- a/policyengine_uk/tests/code_health/test_labour_supply_parameters_are_read.py +++ b/policyengine_uk/tests/code_health/test_labour_supply_parameters_are_read.py @@ -173,12 +173,13 @@ def labour_supply_run(): _add_unread_control(simulation) with record_parameter_reads() as reads: - formula_sim.calculate("employment_income_behavioral_response", YEAR) + response = formula_sim.calculate("employment_income_behavioral_response", YEAR) dynamics = dynamics_sim.apply_dynamics(YEAR) - # Both paths ran in full: the formula built its measurement branch, which - # it does only after its early returns, and the dynamics returned results. - assert "lsr_measurement" in formula_sim.branches + # Both paths ran in full: the formula measured a response, which it does + # only after its early returns (its measurement branches are not kept), + # and the dynamics returned results. + assert (np.asarray(response) != 0).any() assert dynamics is not None return simulations, {canonicalize_lsr_parameter_path(read) for read in reads} From a26b06f26eebea0826298f71aa27952d96a1852a Mon Sep 17 00:00:00 2001 From: Maria Juaristi Date: Thu, 15 Oct 2026 12:32:23 +0100 Subject: [PATCH 10/14] Hold every behavioural response in marginal rate branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit marginal_tax_rate, marginal_tax_rate_wrt_employer_cost and marginal_tax_rate_on_capital_gains step an input in a branch that shares the simulation's tax-benefit system, and recalculate every formula there. With the labour supply response now measured against the baseline, the branch measured it again: at the stepped input, against a baseline without the step, and with the step undone inside the measurement's own marginal rate branches. On £40,000 of earnings and £22,000 of gains under a 30% basic rate with elasticities set, the response inside the gains branch hit the wage change bound (-£9,921 against -£1,406 on the reform) and the capital gains marginal rate came out at 502% instead of 24%. With employer NI price incidence on, the pay rise branches gave 94.2% and 94.9% instead of 95.5% and 96.1%. held_behavioural_responses reads the simulation's live responses once, and hold_behavioural_responses sets them on each branch, generalising the gains-only hold marginal_tax_rate_on_capital_gains already had. The same pay-rise problem affected the capital gains response on main (830% and 1,164% against 28% and 48%), which this also fixes. Neutralised responses, as in measurement branches, are not held. A test now checks the measurement branch's purge directly: with core 3.33, a branch's held inputs already drop dependent values, so the existing order tests pass without it, but a branch with no inputs would still read its simulation's post-response net income. Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-lsr-baseline-2268.fixed.md | 2 +- ...rginal_rates_hold_behavioural_responses.py | 130 ++++++++++++++++++ .../tests/test_measurement_branches.py | 58 ++++++++ policyengine_uk/utils/measurement_branches.py | 27 ++++ .../marginal_tax_rate_on_capital_gains.py | 16 ++- .../variables/household/marginal_tax_rate.py | 8 ++ .../marginal_tax_rate_wrt_employer_cost.py | 8 ++ 7 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 policyengine_uk/tests/test_marginal_rates_hold_behavioural_responses.py create mode 100644 policyengine_uk/tests/test_measurement_branches.py diff --git a/changelog.d/fix-lsr-baseline-2268.fixed.md b/changelog.d/fix-lsr-baseline-2268.fixed.md index 26eb4db6ea..eb04085110 100644 --- a/changelog.d/fix-lsr-baseline-2268.fixed.md +++ b/changelog.d/fix-lsr-baseline-2268.fixed.md @@ -1 +1 @@ -- Measured the elasticity-based labour supply responses against the reform's baseline simulation rather than a copy of the reform, so a reform that changes marginal rates or net income now gets a response when `gov.simulation.labour_supply_responses` elasticities are set. The labour supply and capital gains measurements now start from what their simulation was given, not what it had already calculated, and hold every behavioural response at zero, so neither response depends on what was calculated first or includes the other. +- Measured the elasticity-based labour supply responses against the reform's baseline simulation rather than a copy of the reform, so a reform that changes marginal rates or net income now gets a response when `gov.simulation.labour_supply_responses` elasticities are set. The labour supply and capital gains measurements now start from what their simulation was given, not what it had already calculated, and hold every behavioural response at zero, so neither response depends on what was calculated first or includes the other. Marginal tax rates (on earnings, on employer cost and on capital gains) hold every behavioural response at the simulation's values in the branches where they step an input, instead of measuring the responses again there. diff --git a/policyengine_uk/tests/test_marginal_rates_hold_behavioural_responses.py b/policyengine_uk/tests/test_marginal_rates_hold_behavioural_responses.py new file mode 100644 index 0000000000..adfd38f28e --- /dev/null +++ b/policyengine_uk/tests/test_marginal_rates_hold_behavioural_responses.py @@ -0,0 +1,130 @@ +"""Marginal rates hold the behavioural responses at the simulation's values. + +marginal_tax_rate, marginal_tax_rate_wrt_employer_cost and +marginal_tax_rate_on_capital_gains step an input in a branch of the +simulation, which shares its tax-benefit system, and recalculate every +variable with a formula there. A response recalculated in that branch was +measured again: at the stepped input, against a baseline without the step, +and with the step itself undone inside the measurement's own marginal rate +branches, which recalculate the stepped variable from its formula. The rate is +the change in net income at fixed behaviour, so it must equal the rate of a +simulation without the response whose inputs sit where the response put them. + +Every simulation here sets the capital gains response explicitly, so the +tests hold whatever its default is. +""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation +from policyengine_uk.model_api import Scenario + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2025 +LABOUR_SUPPLY_RESPONSE = "employment_income_behavioral_response" +CAPITAL_GAINS_RESPONSE = "capital_gains_behavioural_response" +ELASTICITIES = { + "gov.simulation.labour_supply_responses.substitution_elasticity": {str(YEAR): 0.25}, + "gov.simulation.labour_supply_responses.income_elasticity": {str(YEAR): -0.05}, +} +BASIC_RATE_RISE = {"gov.hmrc.income_tax.rates.uk[0].rate": {str(YEAR): 0.30}} +# Employers bear half of employer NI, and pass it on to prices: the price +# incidence reads baseline_employer_cost, which reads the labour supply +# response directly. +EMPLOYER_NI_INCIDENCE = { + "gov.contrib.policyengine.employer_ni.employee_incidence": {str(YEAR): 0.5} +} +CAPITAL_GAINS_REFORM = { + "gov.hmrc.cgt.basic_rate": {str(YEAR): 0.20}, + "gov.hmrc.cgt.higher_rate": {str(YEAR): 0.40}, + "gov.hmrc.cgt.additional_rate": {str(YEAR): 0.45}, +} +CAPITAL_GAINS_RESPONSES = "gov.simulation.capital_gains_responses" +STATIC_CAPITAL_GAINS = { + f"{CAPITAL_GAINS_RESPONSES}.elasticity": {str(YEAR): 0}, + f"{CAPITAL_GAINS_RESPONSES}.mtr_elasticity": {str(YEAR): 0}, +} +CAPITAL_GAINS_ELASTICITY = { + f"{CAPITAL_GAINS_RESPONSES}.elasticity": {str(YEAR): 1.0}, + f"{CAPITAL_GAINS_RESPONSES}.mtr_elasticity": {str(YEAR): 0}, +} + + +def situation(earnings, gains=0.0, food=0.0): + person = {"age": {YEAR: 45}, "employment_income": {YEAR: earnings}} + if gains: + person["capital_gains"] = {YEAR: gains} + household = {"members": ["person"]} + if food: + household["food_and_non_alcoholic_beverages_consumption"] = {YEAR: food} + return { + "people": {"person": person}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": household}, + } + + +def value(simulation, variable): + return float(np.asarray(simulation.calculate(variable, YEAR), dtype=float)[0]) + + +@pytest.mark.parametrize( + "variable, gains, food, changes", + [ + # Gains of £22,000 on £40,000 of earnings straddle the basic rate + # limit. Measured again at £1,000 more gains, the response reached the + # wage change bound (-£9,921 against -£1,406), and the capital gains + # rate came out at 502% instead of 24%. + ("marginal_tax_rate_on_capital_gains", 22_000, 0, {}), + # With employer NI price incidence on, the pay rise branches measured + # the response again at the reform's earnings after the response plus + # the step: 94.2% instead of 95.5%, and 94.9% instead of 96.1%. + ("marginal_tax_rate", 0, 10_000, EMPLOYER_NI_INCIDENCE), + ("marginal_tax_rate_wrt_employer_cost", 0, 10_000, EMPLOYER_NI_INCIDENCE), + ], +) +def test_marginal_rates_hold_the_labour_supply_response(variable, gains, food, changes): + reform = {**BASIC_RATE_RISE, **STATIC_CAPITAL_GAINS, **changes} + responding = Simulation( + situation=situation(40_000, gains, food), + scenario=Scenario(parameter_changes={**reform, **ELASTICITIES}), + ) + response = value(responding, LABOUR_SUPPLY_RESPONSE) + held = Simulation( + situation=situation(40_000 + response, gains, food), + scenario=Scenario(parameter_changes=reform), + ) + + assert response < 0 + assert value(responding, variable) == pytest.approx(value(held, variable), abs=1e-4) + + +@pytest.mark.parametrize("gains", [13_000, 22_000]) +def test_marginal_tax_rate_holds_the_capital_gains_response(gains): + """A pay rise moves gains near the basic rate limit into the higher rate. + + The pay rise branch measured the realisation response again at the higher + earnings, and the capital gains marginal rate inside that measurement + recalculated earnings without the pay rise: the marginal tax rate came out + at 830% (gains of £13,000) and 1,164% (£22,000), against 28% and 48%. + """ + responding = Simulation( + situation=situation(40_000, gains), + scenario=Scenario( + parameter_changes={**CAPITAL_GAINS_REFORM, **CAPITAL_GAINS_ELASTICITY} + ), + ) + response = value(responding, CAPITAL_GAINS_RESPONSE) + held = Simulation( + situation=situation(40_000, gains + response), + scenario=Scenario( + parameter_changes={**CAPITAL_GAINS_REFORM, **STATIC_CAPITAL_GAINS} + ), + ) + + assert response < 0 + assert value(responding, "marginal_tax_rate") == pytest.approx( + value(held, "marginal_tax_rate"), abs=1e-4 + ) diff --git a/policyengine_uk/tests/test_measurement_branches.py b/policyengine_uk/tests/test_measurement_branches.py new file mode 100644 index 0000000000..66cc127685 --- /dev/null +++ b/policyengine_uk/tests/test_measurement_branches.py @@ -0,0 +1,58 @@ +"""A measurement branch measures from inputs alone. + +A branch starts from every value its simulation holds. A measurement branch +drops what the simulation calculated, so a result the simulation calculated +with a behavioural response is recalculated without it, at the branch's +inputs, rather than read from the simulation. +""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation +from policyengine_uk.model_api import Scenario +from policyengine_uk.utils.measurement_branches import measurement_branch + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2025 +ELASTICITIES = { + "gov.simulation.labour_supply_responses.substitution_elasticity": {str(YEAR): 0.25}, + "gov.simulation.labour_supply_responses.income_elasticity": {str(YEAR): -0.05}, +} +BASIC_RATE_RISE = {"gov.hmrc.income_tax.rates.uk[0].rate": {str(YEAR): 0.30}} +STATIC_CAPITAL_GAINS = { + "gov.simulation.capital_gains_responses.elasticity": {str(YEAR): 0}, + "gov.simulation.capital_gains_responses.mtr_elasticity": {str(YEAR): 0}, +} +SITUATION = { + "people": {"person": {"age": {YEAR: 45}, "employment_income": {YEAR: 40_000}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, +} + + +def value(simulation, variable): + return float(np.asarray(simulation.calculate(variable, YEAR), dtype=float)[0]) + + +def test_a_measurement_branch_drops_what_its_simulation_calculated(): + reform = {**BASIC_RATE_RISE, **STATIC_CAPITAL_GAINS} + responding = Simulation( + situation=SITUATION, + scenario=Scenario(parameter_changes={**reform, **ELASTICITIES}), + ) + assert value(responding, "employment_income_behavioral_response") < 0 + with_response = value(responding, "household_net_income") + + branch = measurement_branch(responding, "probe", YEAR, {}) + + without_response = Simulation( + situation=SITUATION, scenario=Scenario(parameter_changes=reform) + ) + measured = value(branch, "household_net_income") + assert measured == value(without_response, "household_net_income") + assert measured != with_response + # The branch is not kept, and the simulation's own results are untouched. + assert "probe" not in responding.branches + assert value(responding, "household_net_income") == with_response diff --git a/policyengine_uk/utils/measurement_branches.py b/policyengine_uk/utils/measurement_branches.py index 677c6fcd09..bd6c901cd1 100644 --- a/policyengine_uk/utils/measurement_branches.py +++ b/policyengine_uk/utils/measurement_branches.py @@ -48,6 +48,33 @@ def drop_calculated_values(simulation) -> None: simulation.delete_arrays(name) +def held_behavioural_responses(person, period) -> dict: + """The behavioural responses a simulation calculated, to hold fixed in a + branch that steps one of its inputs to measure a marginal rate. + + The branch shares the simulation's tax-benefit system. A response + recalculated there would be measured again: at the stepped input, against + a baseline without the step, and with the step undone inside the + measurement, whose own marginal rate branches recalculate every variable + with a formula. A marginal rate is the change at fixed behaviour, so the + branch reads the simulation's responses instead. Responses neutralised in + the simulation (as in a measurement branch) are not held. + """ + variables = person.simulation.tax_benefit_system.variables + return { + response: person(response, period) + for response in BEHAVIOURAL_RESPONSES + if not variables[response].is_neutralized + } + + +def hold_behavioural_responses(branch, period, held: dict) -> None: + """Set the responses :func:`held_behavioural_responses` returned on a + branch, so the branch reads them instead of measuring them again.""" + for response, values in held.items(): + branch.set_input(response, period, values) + + def measurement_branch( simulation, name: str, diff --git a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/marginal_tax_rate_on_capital_gains.py b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/marginal_tax_rate_on_capital_gains.py index 51b10488ad..2fa7250e52 100644 --- a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/marginal_tax_rate_on_capital_gains.py +++ b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/marginal_tax_rate_on_capital_gains.py @@ -1,6 +1,9 @@ from policyengine_uk.model_api import * from policyengine_core.simulations import * -from policyengine_uk.utils.capital_gains import RESPONSE_VARIABLES +from policyengine_uk.utils.measurement_branches import ( + held_behavioural_responses, + hold_behavioural_responses, +) from policyengine_uk.utils.marginal_rates import marginal_rate_step @@ -28,6 +31,10 @@ def formula(person, period, parameters): gains = person("capital_gains", period) step = marginal_rate_step(gains, minimum=1_000) adult_index_values = person("adult_index_cg", period) + # Hold every behavioural response at this simulation's values, so each + # branch reads its schedule split, and its earnings, instead of + # measuring the responses again at the stepped gains. + held_responses = held_behavioural_responses(person, period) for adult_index in [1, 2]: alt_simulation = simulation.get_branch(f"adult_{adult_index}_cg_rise") mask = adult_index_values == adult_index @@ -38,12 +45,7 @@ def formula(person, period, parameters): and not variable_data.is_input_variable() ): alt_simulation.delete_arrays(variable) - # Hold the realisation response at this simulation's values, so the - # branch reads its schedule split instead of measuring it again. - for response in RESPONSE_VARIABLES: - response_variable = simulation.tax_benefit_system.variables[response] - if not response_variable.is_neutralized: - alt_simulation.set_input(response, period, person(response, period)) + hold_behavioural_responses(alt_simulation, period, held_responses) alt_simulation.set_input("capital_gains", period, gains + mask * step) alt_person = alt_simulation.person # Float32 rounds the higher gains, so the rise actually stored can diff --git a/policyengine_uk/variables/household/marginal_tax_rate.py b/policyengine_uk/variables/household/marginal_tax_rate.py index ce512aeabd..768867cc1b 100644 --- a/policyengine_uk/variables/household/marginal_tax_rate.py +++ b/policyengine_uk/variables/household/marginal_tax_rate.py @@ -1,6 +1,10 @@ from policyengine_uk.model_api import * from policyengine_core.variables import Variable from policyengine_uk.utils.marginal_rates import marginal_rate_step +from policyengine_uk.utils.measurement_branches import ( + held_behavioural_responses, + hold_behavioural_responses, +) class marginal_tax_rate(Variable): @@ -25,6 +29,9 @@ def formula(person, period, parameters): employment_income = person("employment_income", period) step = marginal_rate_step(employment_income, p.marginal_tax_rate_delta) adult_count = p.marginal_tax_rate_adults + # The pay rise is not a reform: each branch holds the behavioural + # responses at this simulation's values. + held_responses = held_behavioural_responses(person, period) for adult_index in range(1, 1 + adult_count): alt_simulation = simulation.get_branch(f"adult_{adult_index}_pay_rise") mask = adult_index_values == adult_index @@ -35,6 +42,7 @@ def formula(person, period, parameters): and not variable_data.is_input_variable() ): alt_simulation.delete_arrays(variable) + hold_behavioural_responses(alt_simulation, period, held_responses) alt_simulation.set_input( "employment_income", period, diff --git a/policyengine_uk/variables/household/marginal_tax_rate_wrt_employer_cost.py b/policyengine_uk/variables/household/marginal_tax_rate_wrt_employer_cost.py index 6adf0f0e60..d5d7709e6d 100644 --- a/policyengine_uk/variables/household/marginal_tax_rate_wrt_employer_cost.py +++ b/policyengine_uk/variables/household/marginal_tax_rate_wrt_employer_cost.py @@ -1,6 +1,10 @@ from policyengine_uk.model_api import * from policyengine_core.variables import Variable from policyengine_uk.utils.marginal_rates import marginal_rate_step +from policyengine_uk.utils.measurement_branches import ( + held_behavioural_responses, + hold_behavioural_responses, +) class marginal_tax_rate_wrt_employer_cost(Variable): @@ -29,6 +33,9 @@ def formula(person, period, parameters): adult_count = p.marginal_tax_rate_adults baseline_employer_cost = person("employer_cost", period) baseline_net = person.household("household_net_income", period) + # The pay rise is not a reform: each branch holds the behavioural + # responses at this simulation's values. + held_responses = held_behavioural_responses(person, period) for adult_index in range(1, 1 + adult_count): alt_simulation = simulation.get_branch( f"adult_{adult_index}_employer_cost_mtr" @@ -41,6 +48,7 @@ def formula(person, period, parameters): and not variable_data.is_input_variable() ): alt_simulation.delete_arrays(variable) + hold_behavioural_responses(alt_simulation, period, held_responses) alt_simulation.set_input( "employment_income", period, From edda2d8874a5a81659f8e6bc992a77588833bd8b Mon Sep 17 00:00:00 2001 From: Maria Juaristi Date: Thu, 15 Oct 2026 12:34:39 +0100 Subject: [PATCH 11/14] Give branches with their parent's rules the parent's labour supply response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A branch that shares its parent's tax-benefit system changes inputs, not policy. The Pension Credit branch of the UC non-dependant exemption fixes the UC housing element at zero, and measured the labour supply response again for the whole population with that element held on the reform's side only: for a renting UC family with a £22,000 earner, +£5,743 inside the branch against -£754 on the reform. Every reform with elasticities then opened two measurement pairs per period instead of one, which on 300 synthetic households doubled the reform's household_net_income time. response_source walks up to the nearest ancestor with other rules (the simulation itself, or a measurement branch), and the response formula returns that simulation's response. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_measurement_branches.py | 51 +++++++++++++++++++ .../utils/labour_supply_responses.py | 20 ++++++++ .../employment_income_behavioral_response.py | 9 +++- 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_measurement_branches.py b/policyengine_uk/tests/test_measurement_branches.py index 66cc127685..4d917aab2d 100644 --- a/policyengine_uk/tests/test_measurement_branches.py +++ b/policyengine_uk/tests/test_measurement_branches.py @@ -56,3 +56,54 @@ def test_a_measurement_branch_drops_what_its_simulation_calculated(): # The branch is not kept, and the simulation's own results are untouched. assert "probe" not in responding.branches assert value(responding, "household_net_income") == with_response + + +def test_a_branch_with_its_parents_rules_takes_the_parents_response(monkeypatch): + """The Pension Credit branch of the UC non-dependant exemption fixes the + UC housing element at zero. It changes inputs, not policy, so it takes the + simulation's labour supply response instead of measuring the whole + population again with that element held on the reform's side only.""" + from policyengine_uk.utils import labour_supply_responses + + measured_from = [] + measure = labour_supply_responses.measurement_branch + + def recording(simulation, name, period, inputs): + if name == "lsr_measurement": + measured_from.append(simulation.branch_name) + return measure(simulation, name, period, inputs) + + monkeypatch.setattr(labour_supply_responses, "measurement_branch", recording) + members = ["earner", "partner", "older_child", "younger_child"] + renting_family = { + "people": { + "earner": {"age": {YEAR: 35}, "employment_income": {YEAR: 22_000}}, + "partner": {"age": {YEAR: 33}}, + "older_child": {"age": {YEAR: 6}}, + "younger_child": {"age": {YEAR: 3}}, + }, + "benunits": {"benunit": {"members": members}}, + "households": { + "household": { + "members": members, + "rent": {YEAR: 10_800}, + "tenure_type": {YEAR: "RENT_PRIVATELY"}, + } + }, + } + responding = Simulation( + situation=renting_family, + scenario=Scenario( + parameter_changes={ + **BASIC_RATE_RISE, + **STATIC_CAPITAL_GAINS, + **ELASTICITIES, + } + ), + ) + + assert value(responding, "universal_credit") > 0 + responding.calculate("household_net_income", YEAR) + + assert value(responding, "employment_income_behavioral_response") < 0 + assert measured_from == ["default"] diff --git a/policyengine_uk/utils/labour_supply_responses.py b/policyengine_uk/utils/labour_supply_responses.py index 50e592d8b1..07157407d2 100644 --- a/policyengine_uk/utils/labour_supply_responses.py +++ b/policyengine_uk/utils/labour_supply_responses.py @@ -14,6 +14,26 @@ _open_measurements = {} +def response_source(simulation): + """The simulation whose labour supply response ``simulation`` takes. + + A branch that shares its parent's tax-benefit system changes inputs, not + policy: a helper branch that fixes one input to break a cycle (the Pension + Credit branch of the UC non-dependant exemption, say), or a marginal + rate's step. The response answers the policy, so labour supply there is + where the parent puts it. Measured again, the response would compare the + branch's inputs with a baseline that does not have them, and repeat a + measurement of the whole population. + """ + parent = getattr(simulation, "parent_branch", None) + while ( + parent is not None + and parent.tax_benefit_system is simulation.tax_benefit_system + ): + simulation, parent = parent, getattr(parent, "parent_branch", None) + return simulation + + @contextmanager def measurement_branches(person, period): """Yield the branches that measure the reform and the baseline with labour diff --git a/policyengine_uk/variables/gov/simulation/labour_supply_response/employment_income_behavioral_response.py b/policyengine_uk/variables/gov/simulation/labour_supply_response/employment_income_behavioral_response.py index e75561fc1f..543be92411 100644 --- a/policyengine_uk/variables/gov/simulation/labour_supply_response/employment_income_behavioral_response.py +++ b/policyengine_uk/variables/gov/simulation/labour_supply_response/employment_income_behavioral_response.py @@ -1,5 +1,8 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.labour_supply_responses import measurement_branches +from policyengine_uk.utils.labour_supply_responses import ( + measurement_branches, + response_source, +) class employment_income_behavioral_response(Variable): @@ -16,6 +19,10 @@ def formula(person, period, parameters): return 0 # No reform, no impact if lsr.income_elasticity == 0 and lsr.substitution_elasticity == 0: return 0 + # A branch with its parent's rules takes the parent's response. + source = response_source(simulation) + if source is not simulation: + return source.person("employment_income_behavioral_response", period) # (system with LSRs) <- (system without LSRs used to calculate LSRs) # | From 276cfcdeb8651d3978865367ec505d5032ae6fd1 Mon Sep 17 00:00:00 2001 From: Maria Juaristi Date: Thu, 15 Oct 2026 12:37:11 +0100 Subject: [PATCH 12/14] Skip measuring a labour supply term whose elasticity is zero income_elasticity_lsr and substitution_elasticity_lsr multiply a measured relative change by their elasticity, so with the elasticity at zero they are zero. They still measured the change, which opens a measurement pair: policyengine.py's UK labour supply output asks for both terms on every simulation, so on current law each produced its zeros from a pair of branches. With one elasticity set, the other term no longer computes its relative change (the wage change needs marginal rates in both branches). Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-lsr-baseline-2268.fixed.md | 2 +- .../tests/test_measurement_branches.py | 58 +++++++++++++++++++ .../income_elasticity_lsr.py | 3 + .../substitution_elasticity_lsr.py | 3 + 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/changelog.d/fix-lsr-baseline-2268.fixed.md b/changelog.d/fix-lsr-baseline-2268.fixed.md index eb04085110..77704d8a92 100644 --- a/changelog.d/fix-lsr-baseline-2268.fixed.md +++ b/changelog.d/fix-lsr-baseline-2268.fixed.md @@ -1 +1 @@ -- Measured the elasticity-based labour supply responses against the reform's baseline simulation rather than a copy of the reform, so a reform that changes marginal rates or net income now gets a response when `gov.simulation.labour_supply_responses` elasticities are set. The labour supply and capital gains measurements now start from what their simulation was given, not what it had already calculated, and hold every behavioural response at zero, so neither response depends on what was calculated first or includes the other. Marginal tax rates (on earnings, on employer cost and on capital gains) hold every behavioural response at the simulation's values in the branches where they step an input, instead of measuring the responses again there. +- Measured the elasticity-based labour supply responses against the reform's baseline simulation rather than a copy of the reform, so a reform that changes marginal rates or net income now gets a response when `gov.simulation.labour_supply_responses` elasticities are set. The labour supply and capital gains measurements now start from what their simulation was given, not what it had already calculated, and hold every behavioural response at zero, so neither response depends on what was calculated first or includes the other. Marginal tax rates (on earnings, on employer cost and on capital gains) hold every behavioural response at the simulation's values in the branches where they step an input, instead of measuring the responses again there. A branch that shares its simulation's rules, such as the Pension Credit branch of the UC non-dependant exemption, takes the simulation's labour supply response instead of measuring it again, and a labour supply term whose elasticity is zero is zero without a measurement. diff --git a/policyengine_uk/tests/test_measurement_branches.py b/policyengine_uk/tests/test_measurement_branches.py index 4d917aab2d..d44493ed63 100644 --- a/policyengine_uk/tests/test_measurement_branches.py +++ b/policyengine_uk/tests/test_measurement_branches.py @@ -107,3 +107,61 @@ def recording(simulation, name, period, inputs): assert value(responding, "employment_income_behavioral_response") < 0 assert measured_from == ["default"] + + +@pytest.mark.parametrize( + "elasticities, terms_measured", + [ + ({}, []), + # Only the income term is nonzero, so only it reads the measurement. + ( + { + "gov.simulation.labour_supply_responses.income_elasticity": { + str(YEAR): -0.05 + } + }, + ["relative_income_change"], + ), + ], +) +def test_a_term_with_a_zero_elasticity_measures_nothing( + monkeypatch, elasticities, terms_measured +): + """policyengine.py asks for both terms on every UK simulation. A term + whose elasticity is zero is zero, so it measures nothing.""" + from policyengine_uk.utils import labour_supply_responses + + opened = [] + measure = labour_supply_responses.measurement_branch + + def recording(simulation, name, period, inputs): + opened.append(name) + return measure(simulation, name, period, inputs) + + monkeypatch.setattr(labour_supply_responses, "measurement_branch", recording) + simulation = Simulation( + situation=SITUATION, + scenario=Scenario( + parameter_changes={ + **BASIC_RATE_RISE, + **STATIC_CAPITAL_GAINS, + **elasticities, + } + ), + ) + + terms = { + "income_elasticity_lsr": "relative_income_change", + "substitution_elasticity_lsr": "relative_wage_change", + } + for term in terms: + simulation.calculate(term, YEAR) + calculated = { + change + for term, change in terms.items() + if simulation.get_holder(change).get_known_periods() + } + + assert sorted(calculated) == terms_measured + if not terms_measured: + assert opened == [] diff --git a/policyengine_uk/variables/gov/simulation/labour_supply_response/income_elasticity_lsr.py b/policyengine_uk/variables/gov/simulation/labour_supply_response/income_elasticity_lsr.py index feccb02afc..8eab8e9001 100644 --- a/policyengine_uk/variables/gov/simulation/labour_supply_response/income_elasticity_lsr.py +++ b/policyengine_uk/variables/gov/simulation/labour_supply_response/income_elasticity_lsr.py @@ -11,6 +11,9 @@ class income_elasticity_lsr(Variable): def formula(person, period, parameters): lsr = parameters(period).gov.simulation.labour_supply_responses + if lsr.income_elasticity == 0: + # Zero without measuring relative_income_change, which needs a measurement. + return 0 employment_income = max_(person("employment_income_before_lsr", period), 0) income_change = person("relative_income_change", period) diff --git a/policyengine_uk/variables/gov/simulation/labour_supply_response/substitution_elasticity_lsr.py b/policyengine_uk/variables/gov/simulation/labour_supply_response/substitution_elasticity_lsr.py index a8631bc942..106c9bf3b6 100644 --- a/policyengine_uk/variables/gov/simulation/labour_supply_response/substitution_elasticity_lsr.py +++ b/policyengine_uk/variables/gov/simulation/labour_supply_response/substitution_elasticity_lsr.py @@ -11,6 +11,9 @@ class substitution_elasticity_lsr(Variable): def formula(person, period, parameters): lsr = parameters(period).gov.simulation.labour_supply_responses + if lsr.substitution_elasticity == 0: + # Zero without measuring relative_wage_change, which needs a measurement. + return 0 employment_income = max_(person("employment_income_before_lsr", period), 0) wage_change = person("relative_wage_change", period) From c156a1f5f79fd6cb68a04f4035e6e9a3499194d1 Mon Sep 17 00:00:00 2001 From: Maria Juaristi Date: Thu, 15 Oct 2026 14:27:27 +0100 Subject: [PATCH 13/14] Document what the labour supply response is measured against, and add a fragment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Vahid's review of #2277: - dynamics.md says simulation.baseline is current law whatever policy a comparison treats as its baseline, so with elasticities on the reform side only, the reform's response also answers the baseline policy (a £30,000 earner in 2026: -£187 for a 22% basic rate, -£193 with a £13,000 personal allowance too, against the -£6 the allowance adds), and that setting the elasticities on both sides isolates the reform's own. - It also says only earnings and gains are held: other inputs a scenario changes keep each simulation's value and count as policy in the relative income change. - An added fragment for the new measurement_branches helpers, so the release is a minor one: anyone who sets the labour supply elasticities sees outputs move. Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-lsr-baseline-2268.added.md | 1 + docs/book/usage/dynamics.md | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 changelog.d/fix-lsr-baseline-2268.added.md diff --git a/changelog.d/fix-lsr-baseline-2268.added.md b/changelog.d/fix-lsr-baseline-2268.added.md new file mode 100644 index 0000000000..0e14841a7d --- /dev/null +++ b/changelog.d/fix-lsr-baseline-2268.added.md @@ -0,0 +1 @@ +- Added `policyengine_uk.utils.measurement_branches`: `measurement_branch`, which measures a simulation's results at set inputs in a branch with every behavioural response switched off, and `held_behavioural_responses` and `hold_behavioural_responses`, which hold the responses fixed in a branch that steps an input to measure a marginal rate. diff --git a/docs/book/usage/dynamics.md b/docs/book/usage/dynamics.md index b98f23fe89..a501ec990f 100644 --- a/docs/book/usage/dynamics.md +++ b/docs/book/usage/dynamics.md @@ -173,6 +173,24 @@ household net income. The changes are capped at `gov.simulation.labour_supply_responses.bounds`. The response is part of `employment_income`, so it feeds into every result that depends on it. +Two things follow from measuring against `simulation.baseline`: + +- **The baseline is current law.** `simulation.baseline` is built without the + reform, whatever policy a comparison treats as its baseline. When two + simulations are compared and the elasticities are set on the reform side + only, the reform's response also answers the comparison's baseline policy. + For a single earner on £30,000 in 2026, the response to a 22% basic rate is + about -£187, and to that rate plus a £13,000 personal allowance about -£193. + Compared with the 22% rate costed without responses, the second reform shows + the whole -£193, not the -£6 or so the allowance adds. Setting the + elasticities on both sides of the comparison isolates the reform's own + response. +- **Only earnings and gains are held.** Both measurements take the reform's + earnings and capital gains before responses. Any other input the reform's + scenario changes (a data modifier, say, or removing economic assumptions) + keeps each simulation's own value, so the difference counts as policy in + `relative_income_change`. + ```python from policyengine_uk import Simulation From 03ec1e883064329bacd6f36e2aa15b89ca540546 Mon Sep 17 00:00:00 2001 From: Maria Juaristi Date: Thu, 15 Oct 2026 14:27:27 +0100 Subject: [PATCH 14/14] Isolate the behavioural response hold in a test, and run the terms after the response From Vahid's review of #2277: since response_source gives a branch that shares its parent's rules the parent's labour supply response, the marginal rate tests' labour supply cases fail only with both that and the hold removed. A new test uses a branch with its own copy of the rules, emptied as the marginal rate variables empty theirs: it measures the response again unless the responses are held, and fails without the hold. The marginal rate tests' docstring says which mechanism each case covers. The zero-elasticity test now calculates the response before the terms, as the response formula does: policyengine-core 3.32, the floor, refuses a term requested before the response. It still fails without the shortcut. The measurement, hold, order and purge tests pass on core 3.32.27 as well as 3.33.2. On 3.32.27, disabling the purge fails the four order and recalculation tests and the direct purge test. Co-Authored-By: Claude Opus 5.5 --- ...rginal_rates_hold_behavioural_responses.py | 7 ++ .../tests/test_measurement_branches.py | 66 ++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_marginal_rates_hold_behavioural_responses.py b/policyengine_uk/tests/test_marginal_rates_hold_behavioural_responses.py index adfd38f28e..2d2c7ebd91 100644 --- a/policyengine_uk/tests/test_marginal_rates_hold_behavioural_responses.py +++ b/policyengine_uk/tests/test_marginal_rates_hold_behavioural_responses.py @@ -10,6 +10,13 @@ the change in net income at fixed behaviour, so it must equal the rate of a simulation without the response whose inputs sit where the response put them. +For the labour supply response, two things now keep these branches from +measuring it again: the hold, and response_source, which gives a branch that +shares its parent's rules the parent's response. So the labour supply cases +fail only with both removed, and the capital gains cases isolate the hold in +these variables. test_measurement_branches.py isolates the hold for every +response with a branch that has its own copy of the rules. + Every simulation here sets the capital gains response explicitly, so the tests hold whatever its default is. """ diff --git a/policyengine_uk/tests/test_measurement_branches.py b/policyengine_uk/tests/test_measurement_branches.py index d44493ed63..c3d2a8c1ea 100644 --- a/policyengine_uk/tests/test_measurement_branches.py +++ b/policyengine_uk/tests/test_measurement_branches.py @@ -11,7 +11,12 @@ from policyengine_uk import Simulation from policyengine_uk.model_api import Scenario -from policyengine_uk.utils.measurement_branches import measurement_branch +from policyengine_uk.utils.measurement_branches import ( + BEHAVIOURAL_RESPONSES, + held_behavioural_responses, + hold_behavioural_responses, + measurement_branch, +) pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") @@ -154,6 +159,10 @@ def recording(simulation, name, period, inputs): "income_elasticity_lsr": "relative_income_change", "substitution_elasticity_lsr": "relative_wage_change", } + # The terms require the response first (policyengine-core 3.32 enforces + # it). With no elasticities the response returns early, but the terms are + # still asked for afterwards. + simulation.calculate("employment_income_behavioral_response", YEAR) for term in terms: simulation.calculate(term, YEAR) calculated = { @@ -165,3 +174,58 @@ def recording(simulation, name, period, inputs): assert sorted(calculated) == terms_measured if not terms_measured: assert opened == [] + + +def test_holding_the_responses_keeps_a_branch_from_measuring_them_again(monkeypatch): + """The marginal rate variables hold every response in the branches where + they step an input. A branch that shares the simulation's rules would take + the labour supply response through response_source anyway, so this uses a + branch with its own copy of the rules, which isolates the hold: emptied as + the marginal rate variables empty theirs, it measures the response again + unless the responses are held.""" + from policyengine_uk.utils import labour_supply_responses + + measured_from = [] + measure = labour_supply_responses.measurement_branch + + def recording(simulation, name, period, inputs): + if name == "lsr_measurement": + measured_from.append(simulation.branch_name) + return measure(simulation, name, period, inputs) + + monkeypatch.setattr(labour_supply_responses, "measurement_branch", recording) + responding = Simulation( + situation=SITUATION, + scenario=Scenario( + parameter_changes={ + **BASIC_RATE_RISE, + **STATIC_CAPITAL_GAINS, + **ELASTICITIES, + } + ), + ) + response = value(responding, "employment_income_behavioral_response") + held = held_behavioural_responses(responding.person, YEAR) + assert set(held) == set(BEHAVIOURAL_RESPONSES) + + def emptied_branch(name): + branch = responding.get_branch(name, clone_system=True) + variables = responding.tax_benefit_system.variables + for variable, data in variables.items(): + if ( + variable not in responding.input_variables + and not data.is_input_variable() + ): + branch.delete_arrays(variable) + return branch + + measured_from.clear() + unheld = emptied_branch("unheld") + assert value(unheld, "employment_income_behavioral_response") == response + assert measured_from == ["unheld"] + + measured_from.clear() + branch = emptied_branch("held") + hold_behavioural_responses(branch, YEAR, held) + assert value(branch, "employment_income_behavioral_response") == response + assert measured_from == []