Skip to content

Measure labour supply responses against the reform's baseline - #2277

Merged
juaristi22 merged 9 commits into
mainfrom
fix-lsr-baseline-2268
Oct 15, 2026
Merged

juaristi22 merged 9 commits into
mainfrom
fix-lsr-baseline-2268

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #2268

axiom: n/a: behavioural response machinery, no policy rule change

What was wrong

relative_wage_change, relative_income_change and employment_income_behavioral_response took the baseline from simulation.get_branch("baseline"), with the comment "Already created by default". That is policyengine-core's convention. policyengine_uk.Simulation.__init__ keeps the baseline as a separate simulation in simulation.baseline and leaves simulation.branches empty, so get_branch("baseline") made a new branch of the reform simulation, with the reform's parameters. Every response was zero, whatever the reform and the elasticities. The issue's case (£40,000 of earnings, basic rate 20% → 30%, elasticities 0.25 and −0.05) gave 0 for all three variables on main 3173baa89.

What changed

  • policyengine_uk/utils/measurement_branches.py (new): measurement_branch(simulation, name, period, inputs) makes a uniquely named branch with a cloned tax-benefit system and neutralises every behavioural response there. It then drops the values the branched simulation had already calculated, keeping its inputs, and sets the held inputs. The branch is not kept in simulation.branches.
  • policyengine_uk/utils/labour_supply_responses.py (new): the response opens one pair of branches per simulation and period, of the reform and of simulation.baseline, both at the reform's earnings and gains before responses. The relative changes it asks for read the same pair.
  • The three labour supply formulas use it. The relative changes return 0 where a simulation has no baseline.
  • policyengine_uk/utils/capital_gains.py: measure_mtr uses the same helper. It already measured on simulation.baseline, so Elasticity-based labour supply responses compare a reform with a copy of itself #2268 did not affect it, but it had the two defects below.
  • The three marginal rate variables (marginal_tax_rate, marginal_tax_rate_wrt_employer_cost, marginal_tax_rate_on_capital_gains) hold every behavioural response at the simulation's values in the branches where they step an input: held_behavioural_responses and hold_behavioural_responses in measurement_branches.py.
  • A branch that shares its parent's tax-benefit system takes the parent's labour supply response (response_source in labour_supply_responses.py). Such a branch changes inputs, not policy.
  • income_elasticity_lsr and substitution_elasticity_lsr return 0 without measuring when their elasticity is 0.

Two more defects found while checking the issue's other call sites

  1. A measurement branch read the results its simulation had already calculated. A branch starts from every value its simulation holds. Values calculated at the simulation's own inputs, or after a response, then stood in for values at the inputs set on the branch. Executed counterexamples on main's code, all gone after the fix:
    • Labour supply (with the baseline lookup fixed but no purge): the reform's 2026 response was −1,218.20 calculated alone and −1,164.16 with the baseline calculated first, under a scenario that removes economic assumptions. Deleting the response and calculating it again gave −1,171.23 instead of −1,218.23.
    • Capital gains (£30,000 of earnings and £22,000 of gains in 2025, CGT at income tax rates in 2026, elasticity 1): deleting the response and calculating it again flipped it from −4,766.83 to +1,191.71. Under a scenario that removes economic assumptions, calculating the baseline first moved it from 709.68 to 1,157.90.
  2. Each response's measurement recomputed the other response. The labour supply branches left the capital gains response live, so lower realised gains counted as lost income. The capital gains rate is measured by stepping gains up in branches, and those branches recomputed the labour supply response against a baseline held at gains before the step. On a 16-person in-memory dataset with both responses on, the capital gains response went from −150,569 to −554,679 and CGT revenue from £166,825 to £10,761. Main never showed this only because the labour supply response was always zero there. Every measurement branch now neutralises all three response variables. Each response is measured from the position before any response, and its value does not depend on whether the other is switched on.

Three more found in review (15 October)

  1. Marginal rate branches measured the labour supply response again. The three marginal rate variables step an input in a branch that shares the simulation's system and recalculate every formula there. With the response now live, that 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 of 0.25 and -0.05, the response inside the gains branch hit the wage change bound (-£9,921, against -£1,406 on the reform), and marginal_tax_rate_on_capital_gains came out at 502% instead of 24%. The same thing happens on main for the capital gains response, with a gains elasticity set, in the pay-rise branches: marginal_tax_rate was 830% and 1,164% instead of 28% and 48% for gains near the basic rate limit.
  2. Helper branches measured the whole population again. The Pension Credit branch of the UC non-dependant exemption fixes the UC housing element at zero. It measured the response again, with that element held on the reform's side only: +£5,743 inside the branch against -£754 on the reform, for a renting UC family with a £22,000 earner. That also opened a second measurement pair, roughly doubling a reform's household_net_income time with elasticities set.
  3. Terms with a zero elasticity still measured. policyengine.py asks for both terms on every UK simulation, so on current law each produced its zeros from a pair of branches.

Invariants (stated and tested)

For any earnings and elasticities (Hypothesis, policyengine_uk/tests/test_labour_supply_response_baseline.py):

  1. With no policy change (a scenario that sets only the elasticities, or a simulation without a scenario whose baseline is its own clone), the relative wage change, relative income change and 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 − 0.28) and the relative income by the income tax rise over baseline net income. The substitution term is then negative and the income term positive, and the response is their sum.
  3. Zero elasticities give a zero response and leave employment income unchanged.
  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 results, branches and tax-benefit system unchanged, and keeps the response live in the reform.
  6. The response does not depend on calculation order: calculating the baseline first, recalculating after deleting the response or after reset_calculations, and calculating periods in any order give the same values as single-period runs.
  7. Setting the capital gains elasticity leaves the labour supply response unchanged, and setting the labour supply elasticities leaves the capital gains response unchanged.
  8. A marginal rate is the rate at fixed behaviour: it equals the rate of a simulation without the response whose inputs sit where the response put them (test_marginal_rates_hold_behavioural_responses.py).
  9. A measurement branch measures from inputs alone, and a branch with its parent's rules, or a term with a zero elasticity, measures nothing (test_measurement_branches.py).

Plus a guard that every *_behavioural_response / *_behavioral_response variable is in BEHAVIOURAL_RESPONSES, and the issue's case checked by hand: relative wage change −0.10 / 0.72 = −5/36. The relative income change is −£2,743 / £32,145.055, where £32,145.055 is £40,000 less £5,486 income tax, £2,194.40 employee NI, the £174.50 TV licence and £0.045 of modelled expected stamp duty. The response is 40,000 × (0.25 × −5/36 − 0.05 × −2,743/32,145.055) = −£1,218.23.

Mutation checks:

  • Taking the baseline from get_branch("baseline") again fails 6 tests in test_labour_supply_response_baseline.py.
  • Disabling the purge fails test_a_measurement_branch_drops_what_its_simulation_calculated on policyengine-core 3.33.2 (the lock), where a branch's held inputs already drop the values that depend on them. On 3.32.27 (the floor), it also fails the 4 order and recalculation tests (2 labour supply, 2 capital gains).
  • Removing the hold fails the 2 capital gains cases in test_marginal_rates_hold_behavioural_responses.py and test_holding_the_responses_keeps_a_branch_from_measuring_them_again. Removing both the hold and response_source fails all 5 marginal rate cases, because a branch that shares its parent's rules also takes the labour supply response through response_source.
  • Removing response_source fails test_a_branch_with_its_parents_rules_takes_the_parents_response. Removing the zero-elasticity shortcut fails both cases of test_a_term_with_a_zero_elasticity_measures_nothing.

What moves

This changes model output for any reform run with non-zero gov.simulation.labour_supply_responses elasticities. Both default to 0, so default runs are unchanged, and the zero-elasticity shortcut keeps them from measuring anything. With them set, employment income now responds, and so does everything downstream: income tax, NI, benefits, net income and budgetary impact. Before, the response was always zero.

Size, from a 16-person in-memory dataset (11 households: single earners at £12k–£130k, a couple with two children, a renting lone parent, a pensioner; weights 1; 2026). Illustrative elasticities 0.25 / −0.05. This is not a population estimate; no survey microdata were run. The table was computed at c441d40, on main 3173baa89, before main was merged in. The 15 October commits leave these totals unchanged. On a separate set of synthetic households, both reforms give identical total response, income tax and gov_balance changes before and after those commits, both on today's main.

Reform Total response, main → fix Income tax change, main → fix Budgetary impact, main → fix
Basic rate 20% → 22% £0 → −£1,089.85 +£4,307.31 → +£4,116.47 +£4,286.33 → +£3,801.39
Personal allowance £12,570 → £15,000 £0 → +£880.14 −£5,241.20 → −£5,333.78 −£5,031.55 → −£4,661.34

Under the basic rate rise, basic rate earners respond negatively. Higher and additional rate earners respond positively: their marginal rate is unchanged, so only the income effect acts.

The response is measured against simulation.baseline, which is current law whatever policy a comparison treats as its baseline. With elasticities set on the reform side only, a reform compared with another reform also responds to the comparison's baseline policy. The docs now say so, with an example. Whether policyengine.py should set the elasticities on both sides of a comparison belongs on PolicyEngine/policyengine.py#602.

Capital gains: on the same dataset with CGT at income tax rates and elasticity 1, a fresh run gives the same capital gains response as main (−£150,569.02) with or without the labour supply elasticities. The capital gains response changes only where main read stale cached values: a recalculation after deleting the response, or the baseline calculated first under a scenario that changes the data.

Release

fix-lsr-baseline-2268.fixed.md plus fix-lsr-baseline-2268.added.md (the measurement_branches helpers) make this a minor release. Anyone who sets the labour supply elasticities sees outputs move, from a response that was always zero. Merging publishes this release on its own, before #2419's.

Not changed

  • measure_mtr holds the reform's gains in the baseline measurement but not its earnings, as before. Under a scenario that changes the data (removing economic assumptions, say), the baseline's uprated earnings can move its marginal capital gains into the higher band. A rate rise can then show a positive realisation response (the +709.68 case above). Whether to hold earnings there too is a methodology choice, left as it was. The same applies to the labour supply measurement, which holds only earnings and gains; the docs say so.
  • The purge stays a local helper rather than core's Simulation.drop_computed_arrays() (core 3.33.0 and later). Core's version also marks the calculation frames in progress as changed, and a measurement runs inside its simulation's response calculation, so switching deserves its own check of what that does to caching. The core floor stays at 3.32.27, and the measurement, order and purge tests pass on it.
  • Splitting measurement_branch into its own PR: not done. Make a retention-rate elasticity of 1 the default CGT realisation response #2419 imports it and merges after this.
  • subsample() leaves simulation.baseline at full size (Give UK simulations a working subsample #2265).

Tests run

On the head, with policyengine-core 3.33.2 (the lock):

uv run pytest policyengine_uk/tests/test_labour_supply_response_baseline.py policyengine_uk/tests/test_labour_supply_response_formulas.py policyengine_uk/tests/test_labour_supply_dynamics_properties.py policyengine_uk/tests/test_behavioral_responses.py policyengine_uk/tests/test_capital_gains_responses.py policyengine_uk/tests/test_capital_gains_tax_components.py policyengine_uk/tests/test_marginal_rate_precision.py policyengine_uk/tests/test_marginal_rates_hold_behavioural_responses.py policyengine_uk/tests/test_measurement_branches.py policyengine_uk/tests/test_process_time_reforms.py policyengine_uk/tests/code_health -n 4

Result: 2,516 passed, 1 skipped.

  • On policyengine-core 3.32.27 (the floor), the labour supply, capital gains response, behavioural response, marginal rate hold, measurement branch and two code-health files give 99 passed.
  • The full non-microsimulation suite at the merge with main (before the 15 October fixes) gave 5,979 passed, 72 skipped and 1 xfailed, with 3 errors, all from main's labour supply code-health test, which b98eae8 fixes.
  • ruff format --check . and ruff check . pass.
  • Not run locally: the microsimulation tests (survey data). CI runs the full suite.

Review

Vahid reviewed 276cfcd on 15 October. c156a1f and 03ec1e8 address it: the fragment, the docs on the baseline and the held inputs, the hold test, and this description.

🤖 Generated with Claude Code

Reproducer: policyengine_uk/tests/test_labour_supply_response_baseline.py::test_reform_raising_the_basic_rate_responds_by_hand_calculated_amounts, policyengine_uk/tests/test_measurement_branches.py::test_a_measurement_branch_drops_what_its_simulation_calculated, policyengine_uk/tests/test_marginal_rates_hold_behavioural_responses.py::test_marginal_tax_rate_holds_the_capital_gains_response

MaxGhenis and others added 2 commits October 10, 2026 17:43
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@juaristi22

Copy link
Copy Markdown
Collaborator

Three notes from #2419, the CGT retention-rate default, which is stacked on this PR. Merge order is tracked in the checklist on PolicyEngine/policyengine.py#602.

@juaristi22 juaristi22 reopened this Oct 15, 2026
Maria Juaristi and others added 5 commits October 15, 2026 12:22
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…sponse

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@juaristi22

Copy link
Copy Markdown
Collaborator

@MaxGhenis I've pushed fixes to this branch so it can merge. They came from a review done alongside #2419, which stacks on this PR. Each is its own commit, so any you disagree with can be reverted on its own.

  1. Main merged in (baf08ed). There were no textual conflicts. The lock moves policyengine-core from 3.32.27 to 3.33.2.
  2. CI (b98eae8).
    • Main added test_labour_supply_parameters_are_read.py, whose fixture looks for the lsr_measurement branch. This PR no longer keeps that branch, so the fixture now checks that the formula measured a nonzero response instead. It does that only after its early returns too. Those were today's 3 test errors.
    • The dynamics.md example is wrapped for ruff format, which Lint failed on.
  3. Marginal rates measured the labour supply response again (a26b06f).
    • 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 system, and recalculate every formula there. Now that the response is measured against the baseline, that branch measured it again. It did so at the stepped input, against a baseline without the step, and with the step undone inside the measurement's own marginal rate branches.
    • Example: £40,000 of earnings, £22,000 of gains, a 30% basic rate, and elasticities of 0.25 and -0.05. The response inside the gains branch hit the wage change bound (-£9,921, against -£1,406 on the reform), and marginal_tax_rate_on_capital_gains came out at 502% instead of 24%.
    • With employer NI price incidence on, the two earnings rates gave 94.2% and 94.9% instead of 95.5% and 96.1%.
    • The fix: held_behavioural_responses and hold_behavioural_responses (utils/measurement_branches.py) hold every live response at the simulation's values in all three variables. This generalises the gains-only hold marginal_tax_rate_on_capital_gains already had.
    • It also fixes the same problem for the capital gains response, which exists on main: the marginal tax rate came out at 830% and 1,164% instead of 28% and 48% for gains near the basic rate limit.
    • test_marginal_rates_hold_behavioural_responses.py has 5 tests, and all of them fail without the hold.
  4. A direct test of the purge (same commit). On core 3.33, a branch's held inputs already drop the values that depend on them, so the order tests pass without drop_calculated_values. A branch with no inputs, though, still reads its simulation's post-response net income. test_measurement_branches.py checks that directly, and fails without the purge.
  5. Helper branches (edda2d8). A branch that shares its parent's system changes inputs, not policy. The Pension Credit branch of the UC non-dependant exemption is one example. Such a branch now takes the parent's response through response_source.
    • Before, it measured the whole population again, with the UC housing element held at zero on the reform's side only: +£5,743 inside the branch, against -£754 on the reform, for a renting UC family with a £22,000 earner.
    • It also opened a second measurement pair, which roughly doubled a reform's household_net_income time with elasticities set.
    • The new test fails without the change.
  6. Terms with a zero elasticity (276cfcd). income_elasticity_lsr and substitution_elasticity_lsr now return 0 without measuring when their elasticity is 0. policyengine.py asks for both terms on every UK simulation. The test fails without the change.

The changelog fragment now mentions 3, 5 and 6. Every figure above comes from synthetic households.

Not changed, for you to decide:

  • drop_calculated_values could become core's Simulation.drop_computed_arrays() (core 3.33.0 and later), with the floor raised to match.
  • The response opens its pair even when both terms are already cached, for example after only the response is deleted.
  • The docs could say that apply_dynamics and the elasticity response are alternatives: combined, the response is counted twice.
  • On main, with employee_incidence below 1, marginal_tax_rate counts the pay step as an employer cost passed on through prices. With the response live, that drives a response of about -£9,200 on £40,000. It may be worth an issue.
  • subsample() leaves simulation.baseline at full size (my note on Give UK simulations a working subsample #2265).
  • The PR body is out of date in places. It says the results are pending and that ruff format --check passes. It also says disabling the purge fails the order tests, which no longer holds on core 3.33.2 (point 4).

Tests: with the fixes, the labour supply, capital gains, behavioural response, marginal rate precision and code-health tests, plus the two new files, give 2,444 passed and 1 skipped (Python 3.13, core 3.33.2). ruff format --check . and ruff check . are clean. CI runs the full suite on this push.

#2419 merges this branch and uses the general hold in place of its own gains-only one.

🤖 Generated with Claude Code

juaristi22 pushed a commit that referenced this pull request Oct 15, 2026
#2277 (fix-lsr-baseline-2268) now holds every behavioural response in the
branches of the three marginal rate variables, through
held_behavioural_responses and hold_behavioural_responses. This branch
used its own gains-only pair for the same purpose; the conflicts in the
three variables resolve onto #2277's helpers, keeping this branch's
tax-only rate on gains, and the gains-only pair is removed.

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

@vahid-ahmadi vahid-ahmadi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MaxGhenis review of 276cfcdeb (the whole PR, including the five commits @juaristi22 pushed on 15 Oct).

Fine once the PR body matches the head (point 3). The fix is right, default runs are unchanged and no slower, and CI is green at this head.

Is the bug fixed correctly?

Yes. On main, get_branch("baseline") made a branch of the reform, so both sides had the reform's rules and every response was zero. The PR now measures against simulation.baseline, which Simulation.__init__ builds as a separate current-law simulation on the same data (simulation.py:361-370). Both measurement branches take the reform's employment_income_before_lsr and capital_gains_before_response, so they differ in policy alone. I ran the dynamics.md example: it prints -1,218.23, which matches the hand calculation in the body (40,000 x (0.25 x -5/36 - 0.05 x -2,743/32,145.06)). Pointing the baseline branch back at the reform fails 6 tests in test_labour_supply_response_baseline.py.

Findings

  1. should: the release type. .github/bump_version.py:34-38 maps fixed and changed to patch. Only added/removed give a minor and breaking a major. So fix-lsr-baseline-2268.fixed.md ships as a patch. versioning.yaml publishes on every push to main that adds fragments, so this PR's merge publishes its own release before #2419's. Default outputs don't move (point "What moves" below). But anyone who already sets the labour supply elasticities sees employment income, tax, benefits and net income change, from a response that was always zero. A patch is defensible on defaults alone. If you want it to be a minor, an .added fragment for the new measurement_branch / hold_behavioural_responses helpers would do it, since #2419 imports one of them. Either way, a separate release for this PR means the labour supply change and the CGT default reach users as two releases, not one.

  2. should: say what "the reform's baseline" is. simulation.baseline is always current law (scenario=None, simulation.py:362). policyengine.py builds each side of a comparison as its own Microsimulation (tax_benefit_models/uk/model.py:243), each with its own current-law baseline. So when the baseline policy is itself a reform and the elasticities are set on the reform side only, the reform's response includes the response to the baseline policy. Single earner on £30,000, 2026, elasticities 0.25 / -0.05:

    • policy A (basic rate 22%) with elasticities: response -187.37;
    • A plus B (personal allowance £13,000) with elasticities: -193.06;
    • B alone with elasticities: -5.17.

    Scored against a static A, the A+B reform shows a response of -193.06 rather than about -5.7. The CGT response already works this way on main. The labour supply one couldn't show it, because it was always zero. I'd add one sentence to docs/book/usage/dynamics.md:168, after "its baseline (simulation.baseline)", saying that's current law whatever the comparison's baseline policy is. Whether policyengine.py should apply the same elasticities to both sides belongs on PolicyEngine/policyengine.py#602 or #621, not here.

  3. should: the PR body is out of date.

    • "Results: pending" and "Independent review ... in progress" are still there.
    • It says disabling the purge fails the 4 order and recalculation tests. On core 3.33.2 (the lock) only test_measurement_branches.py::test_a_measurement_branch_drops_what_its_simulation_calculated fails without it. I checked by making drop_calculated_values return at once: 1 failed, 35 passed across the labour supply, CGT and measurement branch files.
    • The Reproducer: line names test_capital_gains_responses.py::test_response_is_the_same_on_recalculation_near_a_band_edge and ::test_response_does_not_depend_on_calculating_the_baseline_first, which now pass without the purge. check_fix_pr.py only checks the named tests exist, so CI won't notice. I'd name test_a_measurement_branch_drops_what_its_simulation_calculated and test_reform_raising_the_basic_rate_responds_by_hand_calculated_amounts instead.
    • The table under "What moves" predates the marginal-rate hold and the helper-branch change. Worth rerunning or saying which commit it's from.
  4. nit: the hold and response_source now overlap for labour supply. At this head, removing hold_behavioural_responses from all three marginal rate variables fails 2 of the 5 tests in test_marginal_rates_hold_behavioural_responses.py (the capital gains ones). Removing it and the response_source shortcut fails all 5. A pay-rise branch shares its parent's system, so response_source (edda2d8) already gives it the parent's labour supply response. The comment's "all of them fail without the hold" was true at a26b06f, before edda2d8. Keeping both is fine. The test just no longer isolates the hold for the labour supply cases.

  5. nit: core floor and drop_calculated_values. pyproject.toml:27 allows core 3.32.27, but CI runs the lock's 3.33.2, where branch inputs already drop dependent values. So the order guarantees that matter most on 3.32.x aren't tested there. Switching to core's Simulation.drop_computed_arrays() (the open item in the comment) would mean raising the floor to 3.33.0, which removes the question. It also turns off macro-cache reads on the branch and marks calculation frames as changed, which the local helper (utils/measurement_branches.py:26-48) doesn't do.

  6. nit: only earnings and gains are held (utils/labour_supply_responses.py:10). If a scenario changes other inputs (a data modifier, or removing economic assumptions), the baseline branch keeps the baseline's own values for those. The difference then flows into relative_income_change as if it were policy. This is the same methodology choice the body leaves open for measure_mtr. One line in the docs would cover it.

The measurement branch mechanism

  • Naming and caching. A unique name is taken, the branch is created with clone_system=True, the clone is checked, and the branch is removed from simulation.branches at once (measurement_branches.py:106-109). Nothing accumulates. On main, lsr_measurement stayed in branches for the life of the simulation. The pair is shared by the two terms of one response through _open_measurements, keyed by simulation and period, and released in a finally.
  • Memory. Core 3.33 branches copy an array only when they first read it, and the pair lives only for one response calculation. I didn't measure peak memory on a full dataset.
  • Recursion. Every behavioural response is neutralised in measurement branches, so a measurement never recomputes a response. held_behavioural_responses skips neutralised responses, so the marginal rate branches inside a measurement hold nothing. response_source stops at the first ancestor with a different system.
  • CGT and marginal rate branches. measure_mtr now uses the same helper, and the three marginal rate variables hold every live response at the simulation's values. That is the right definition: a marginal rate at fixed behaviour.

What moves

  • Default runs: nothing. The labour supply and CGT elasticities all default to 0 on this branch (badr_elasticity is 1.4, but only applies when separate_badr_elasticity is true, and that defaults to false). A default and a reform-without-elasticities run give identical household_net_income, marginal_tax_rate and response to main (8-person synthetic household). Timing, median of 10 runs, alternated twice: no scenario 1.68/1.25s on main against 1.37/1.26s here; a basic rate reform without elasticities 2.62/2.37s against 2.40/2.42s. That's within noise. The zero-elasticity shortcut (276cfcd) is what keeps it so: policyengine.py asks for both terms on every UK run.
  • Runs that set the labour supply elasticities now get a non-zero response, and everything downstream of employment income moves. That covers Python and API users who set them, and any app dynamic score that passes them. I couldn't confirm what the web app passes (a code search of policyengine-api found no reference). With elasticities set, my reform run took 3.3s against 4.5s on main, because main kept and reused branches that measured nothing.
  • Marginal rates with a CGT elasticity set change too, because the pay-rise branches now hold the realisation response. The comment gives the 830% and 1,164% cases. This matters more once #2419 makes the CGT elasticity non-zero by default.

@juaristi22's commits

All five look right to me, and each is its own commit if you disagree with one.

  • baf08ed4 merges main with no conflicts. b98eae87 rewrites main's code-health fixture to assert a non-zero response instead of the lsr_measurement branch. That's a stronger check: on main the branch existed but the response was zero.
  • a26b06f2 (hold every response in marginal rate branches): the capital gains tests fail without it. See point 4 for the labour supply ones.
  • edda2d88 (response_source): test_a_branch_with_its_parents_rules_takes_the_parents_response fails without it.
  • 276cfcde (zero elasticity, no measurement): both test_a_term_with_a_zero_elasticity_measures_nothing cases fail without it.

Could measurement_branch be split out? (PolicyEngine/policyengine.py#602)

From the code, yes. utils/measurement_branches.py imports only numpy. utils/capital_gains.py and utils/labour_supply_responses.py import it, not the reverse. A split PR would carry measurement_branches.py, the capital_gains.py change, the three marginal rate holds, and the purge, hold and CGT tests. Everything labour-supply-specific would stay here. Listing employment_income_behavioral_response in BEHAVIOURAL_RESPONSES is harmless on main, where it is always zero. #2419's note that its rule-comparison shortcut is exact because measurement branches neutralise every response would move with the helper. Point 1 already gets the main benefit, two separate releases, so splitting is optional. It's your call.

What I checked

  • Fresh worktree at 276cfcdeb with its own uv venv (core 3.33.2, Python 3.13).
  • pytest (not microsimulation) on the 10 touched or related test files: test_labour_supply_response_baseline, test_marginal_rates_hold_behavioural_responses, test_measurement_branches, test_capital_gains_responses, test_process_time_reforms, test_behavioral_responses, test_labour_supply_response_formulas, test_labour_supply_dynamics_properties, and the two code-health files. 169 passed.
  • Reverts at this head: baseline lookup back to the reform (6 fail); purge off (1 fails); hold off (2 of 5 fail); hold and response_source off (5 of 5 fail); response_source off (1 fails); zero-elasticity shortcut off (2 fail).
  • ruff check and ruff format --check on the 17 changed Python files, plus ruff format --check .: clean.
  • The dynamics.md example, the reform-on-reform probe and the timing comparison against origin/main (1a31ef1fd), all on synthetic households.
  • CI: all checks green at 276cfcdeb, including the four test shards and YAML.
  • Not checked: the microsimulation tests and anything on survey data, peak memory, behaviour on core 3.32.27, or what the web app passes for UK labour supply elasticities.

🤖 Generated with Claude Code

Maria Juaristi and others added 2 commits October 15, 2026 14:27
… a fragment

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 <noreply@anthropic.com>
…ter 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 <noreply@anthropic.com>
@juaristi22
juaristi22 marked this pull request as ready for review October 15, 2026 13:43
@juaristi22

Copy link
Copy Markdown
Collaborator

Thanks @vahid-ahmadi. Changes are in c156a1f and 03ec1e8, and the description now matches the head. The reform-on-reform figures reproduce exactly.

  1. The release type. Added fix-lsr-baseline-2268.added.md for the measurement_branches helpers, so this is a minor release. Anyone who sets the labour supply elasticities sees outputs move. The description's new "Release" section says it publishes on its own, before Make a retention-rate elasticity of 1 the default CGT realisation response #2419's.

  2. What "the baseline" is. dynamics.md now says the following, using your £30,000 example:

    • simulation.baseline is current law, whatever policy a comparison treats as its baseline.
    • With elasticities set on the reform side only, the reform's response also answers the baseline policy.
    • Setting the elasticities on both sides isolates the reform's own response.

    The policyengine.py question is noted in the description as belonging on Adopt CenTax’s retention-rate elasticity of 1.0 as the default for UK CGT modelling policyengine.py#602.

  3. The description.

    • Results and review are filled in.
    • The purge claim now says what fails on each core version.
    • The Reproducer: line names your two tests, plus test_marginal_tax_rate_holds_the_capital_gains_response for the defect the review found.
    • The "What moves" table says which commit it's from (c441d40, on main 3173baa89). On a separate set of synthetic households, both of its reforms give identical total response, income tax and gov_balance changes before and after the 15 October commits.
  4. The overlap between the hold and response_source. You're right; my earlier "all of them fail without the hold" was only true before edda2d8.

    • A new test, test_holding_the_responses_keeps_a_branch_from_measuring_them_again, uses a branch with its own copy of the rules, emptied as the marginal rate variables empty theirs. response_source can't apply to such a branch, so the test isolates the hold for every response: it measures again unless the responses are held, and fails without the hold.
    • The marginal rate tests' docstring now says which mechanism each case covers.
  5. The core floor. I kept the local purge. Core's drop_computed_arrays() also marks the calculation frames in progress as changed, and a measurement runs inside its simulation's response calculation, so switching deserves its own look at caching. It's under "Not changed". Instead, I ran the tests on core 3.32.27 (the floor). The labour supply, capital gains response, behavioural response, hold, measurement branch and two code-health files give 99 passed.

    • With the purge disabled on 3.32.27, the 4 order and recalculation tests fail as well as the direct purge test. So Max's original claim holds at the floor, and the description says so.
    • That run also showed that core 3.32 refuses a labour supply term requested before the response (requires_computation_after). The zero-elasticity test now asks for the response first, as the formula does, and it still fails without the shortcut.
  6. Only earnings and gains are held. dynamics.md has a line on it, and so does the description's "Not changed".

Splitting measurement_branch out: not done. #2419 imports it and merges after this, so a split wouldn't change the order, and this PR already gets its own release.

Tests at the head (core 3.33.2): the labour supply, capital gains, behavioural response, marginal rate, measurement branch, process-time and code-health tests give 2,516 passed and 1 skipped. CI runs the full suite.

🤖 Generated with Claude Code

@juaristi22

Copy link
Copy Markdown
Collaborator

@MaxGhenis, a heads-up: I'm taking this the rest of the way to merge, and here's why.

What I've done:

  • The 15 October commits are each their own commit on top of your c441d40, so you can revert any you disagree with:
  • Your fix itself, measuring against simulation.baseline with the purge and the neutralisation, is unchanged.
  • I've updated the description to match the head and marked the PR ready for review.

Left for you, as listed under "Not changed":

🤖 Generated with Claude Code

juaristi22 pushed a commit that referenced this pull request Oct 15, 2026
Brings in c156a1f and 03ec1e8 from fix-lsr-baseline-2268: the docs on
what the labour supply response is measured against, the added fragment
for the measurement_branches helpers, and the test that isolates the
behavioural response hold.

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

@vahid-ahmadi vahid-ahmadi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MaxGhenis @juaristi22 re-review of 03ec1e883 (the two commits since my review of 276cfcdeb: c156a1f5 and 03ec1e88).

Looks right, fine to approve. All six points from the first review are dealt with, the reproducer tests fail on main's code and pass here, and CI is green at this head.

The earlier points

  1. Release type: resolved. changelog.d/fix-lsr-baseline-2268.added.md sits next to the .fixed one. .github/bump_version.py:34-38 returns minor when any fragment is added, so this ships as a minor release. The body's new "Release" section says it publishes on its own, before #2419's.
  2. What the baseline is: resolved. docs/book/usage/dynamics.md:178-187 says simulation.baseline is current law whatever the comparison's baseline, and that setting the elasticities on both sides isolates the reform's own response. The figures match my probe: -187.37 for the 22% basic rate, -193.06 with the £13,000 allowance added, a difference of 5.69.
  3. PR body: resolved.
    • Results and the review section are filled in.
    • The purge claim now says what fails on each core version.
    • The Reproducer: line names three tests. At this head they pass (4 cases). On main's source, with only the new measurement_branches.py module added so the files import, all 4 fail: the response is 0, the purge test fails, and marginal_tax_rate comes out at 830% and 1,164% against 28% and 48%. On main exactly as it is, they fail at import, since the module is new. "Fix evidence" passes.
    • The "What moves" table now says it was computed at c441d40a.
  4. Hold and response_source overlap: resolved. test_holding_the_responses_keeps_a_branch_from_measuring_them_again (test_measurement_branches.py:179) uses a branch with its own copy of the rules, so response_source can't apply and the hold is isolated. The marginal rate test docstring says which mechanism each case covers.
  5. Core floor: resolved. Keeping the local purge, with the reason under "Not changed", is fine. I installed core 3.32.27 in my venv and ran the labour supply baseline, capital gains response, behavioural response, hold, measurement branch and two code-health files: 95 passed, none failed.
  6. Only earnings and gains are held: resolved. dynamics.md:188-192 says so, and so does "Not changed".

One new point

  1. nit: no test pins the hold in marginal_tax_rate_on_capital_gains (variables/gov/hmrc/capital_gains_tax/marginal_tax_rate_on_capital_gains.py:48). I removed that one line and kept everything else.
    • The rates don't move. On six cases (£20,000 and £40,000 of earnings; £13,000, £22,000 and £60,000 of gains; CGT at 20/40/45% with elasticity 1), the rate equals that of a static simulation at the post-response gains, with or without the line. The labour supply tests in test_marginal_rates_hold_behavioural_responses.py and the rest of the related tests still pass (175 passed, 1 skipped).
    • That's because a branch that recomputes the realisation response gets the same value: its gains before the response are unchanged, and the measurement branches are neutralised and purged. The labour supply response comes through response_source.
    • What the line does buy is speed. Without it, one marginal_tax_rate_on_capital_gains call opens 4 capital gains measurement branches, and takes a median of 1.69s against 0.14s with it (both elasticities set, £40,000 earnings, £22,000 gains).
    • So it's worth keeping. A short test, using the same monkeypatch recording test_measurement_branches.py already uses, that checks no measurement branch opens during that call would stop it being removed as dead code later. Not needed for this PR.

What I checked

  • Fresh worktree at 03ec1e883 with its own uv venv (core 3.33.2, Python 3.13).
  • pytest on the labour supply, capital gains, behavioural response, marginal rate precision, marginal rate hold, measurement branch, process-time and code-health tests: 2,516 passed, 1 skipped.
  • The three Reproducer: tests on the head, on main's source with the helper module added, and on main's source as it is.
  • The hold mutation in point 7, with a rate probe and a timing probe.
  • The core 3.32.27 run in point 5.
  • ruff check . and ruff format --check .: clean.
  • CI: every check green at 03ec1e883, including the four Python shards, YAML and Fix evidence.
  • Not checked: the microsimulation tests or anything on survey data, and peak memory.

🤖 Generated with Claude Code

@juaristi22
juaristi22 added this pull request to the merge queue Oct 15, 2026
Merged via the queue into main with commit 4efafc0 Oct 15, 2026
15 of 16 checks passed
@juaristi22
juaristi22 deleted the fix-lsr-baseline-2268 branch October 15, 2026 15:24
MaxGhenis added a commit that referenced this pull request Oct 15, 2026
…ards

Brings in #2277, #2391, #2402, #2404, #2405, #2407, #2411, #2413, #2415,
#2418, #2419 and #2420. No conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 15, 2026
…2277) into uc-hb-split-shared-cap-lha-rules

No conflicts; no benefit cap, LHA, UC or HB file changed on main.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Elasticity-based labour supply responses compare a reform with a copy of itself

3 participants