Repository navigation
Measure labour supply responses against the reform's baseline - #2277
Conversation
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>
|
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.
|
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>
|
@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.
The changelog fragment now mentions 3, 5 and 6. Every figure above comes from synthetic households. Not changed, for you to decide:
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). #2419 merges this branch and uses the general hold in place of its own gains-only one. 🤖 Generated with Claude Code |
#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
left a comment
There was a problem hiding this comment.
@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
-
should: the release type.
.github/bump_version.py:34-38mapsfixedandchangedto patch. Onlyadded/removedgive a minor andbreakinga major. Sofix-lsr-baseline-2268.fixed.mdships as a patch.versioning.yamlpublishes 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.addedfragment for the newmeasurement_branch/hold_behavioural_responseshelpers 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. -
should: say what "the reform's baseline" is.
simulation.baselineis always current law (scenario=None,simulation.py:362). policyengine.py builds each side of a comparison as its ownMicrosimulation(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. -
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_calculatedfails without it. I checked by makingdrop_calculated_valuesreturn at once: 1 failed, 35 passed across the labour supply, CGT and measurement branch files. - The
Reproducer:line namestest_capital_gains_responses.py::test_response_is_the_same_on_recalculation_near_a_band_edgeand::test_response_does_not_depend_on_calculating_the_baseline_first, which now pass without the purge.check_fix_pr.pyonly checks the named tests exist, so CI won't notice. I'd nametest_a_measurement_branch_drops_what_its_simulation_calculatedandtest_reform_raising_the_basic_rate_responds_by_hand_calculated_amountsinstead. - The table under "What moves" predates the marginal-rate hold and the helper-branch change. Worth rerunning or saying which commit it's from.
-
nit: the hold and
response_sourcenow overlap for labour supply. At this head, removinghold_behavioural_responsesfrom all three marginal rate variables fails 2 of the 5 tests intest_marginal_rates_hold_behavioural_responses.py(the capital gains ones). Removing it and theresponse_sourceshortcut fails all 5. A pay-rise branch shares its parent's system, soresponse_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. -
nit: core floor and
drop_calculated_values.pyproject.toml:27allows 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'sSimulation.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. -
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 intorelative_income_changeas if it were policy. This is the same methodology choice the body leaves open formeasure_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 fromsimulation.branchesat once (measurement_branches.py:106-109). Nothing accumulates. On main,lsr_measurementstayed inbranchesfor 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 afinally. - 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_responsesskips neutralised responses, so the marginal rate branches inside a measurement hold nothing.response_sourcestops at the first ancestor with a different system. - CGT and marginal rate branches.
measure_mtrnow 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_elasticityis 1.4, but only applies whenseparate_badr_elasticityis true, and that defaults to false). A default and a reform-without-elasticities run give identicalhousehold_net_income,marginal_tax_rateand 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.
baf08ed4merges main with no conflicts.b98eae87rewrites main's code-health fixture to assert a non-zero response instead of thelsr_measurementbranch. 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_responsefails without it.276cfcde(zero elasticity, no measurement): bothtest_a_term_with_a_zero_elasticity_measures_nothingcases 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
276cfcdebwith 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_sourceoff (5 of 5 fail);response_sourceoff (1 fails); zero-elasticity shortcut off (2 fail). ruff checkandruff format --checkon the 17 changed Python files, plusruff format --check .: clean.- The
dynamics.mdexample, the reform-on-reform probe and the timing comparison againstorigin/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
… 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>
|
Thanks @vahid-ahmadi. Changes are in c156a1f and 03ec1e8, and the description now matches the head. The reform-on-reform figures reproduce exactly.
Splitting 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 |
|
@MaxGhenis, a heads-up: I'm taking this the rest of the way to merge, and here's why.
What I've done:
Left for you, as listed under "Not changed":
🤖 Generated with Claude Code |
vahid-ahmadi
left a comment
There was a problem hiding this comment.
@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
- Release type: resolved.
changelog.d/fix-lsr-baseline-2268.added.mdsits next to the.fixedone..github/bump_version.py:34-38returns minor when any fragment isadded, so this ships as a minor release. The body's new "Release" section says it publishes on its own, before #2419's. - What the baseline is: resolved.
docs/book/usage/dynamics.md:178-187sayssimulation.baselineis 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. - 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 newmeasurement_branches.pymodule added so the files import, all 4 fail: the response is 0, the purge test fails, andmarginal_tax_ratecomes 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.
- Hold and
response_sourceoverlap: 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, soresponse_sourcecan't apply and the hold is isolated. The marginal rate test docstring says which mechanism each case covers. - 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.
- Only earnings and gains are held: resolved.
dynamics.md:188-192says so, and so does "Not changed".
One new point
- 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.pyand 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_gainscall 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
monkeypatchrecordingtest_measurement_branches.pyalready uses, that checks no measurement branch opens during that call would stop it being removed as dead code later. Not needed for this PR.
- 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
What I checked
- Fresh worktree at
03ec1e883with its own uv venv (core 3.33.2, Python 3.13). pyteston 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 .andruff 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
Fixes #2268
axiom: n/a: behavioural response machinery, no policy rule change
What was wrong
relative_wage_change,relative_income_changeandemployment_income_behavioral_responsetook the baseline fromsimulation.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 insimulation.baselineand leavessimulation.branchesempty, soget_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 main3173baa89.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 insimulation.branches.policyengine_uk/utils/labour_supply_responses.py(new): the response opens one pair of branches per simulation and period, of the reform and ofsimulation.baseline, both at the reform's earnings and gains before responses. The relative changes it asks for read the same pair.policyengine_uk/utils/capital_gains.py:measure_mtruses the same helper. It already measured onsimulation.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.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_responsesandhold_behavioural_responsesinmeasurement_branches.py.response_sourceinlabour_supply_responses.py). Such a branch changes inputs, not policy.income_elasticity_lsrandsubstitution_elasticity_lsrreturn 0 without measuring when their elasticity is 0.Two more defects found while checking the issue's other call sites
Three more found in review (15 October)
marginal_tax_rate_on_capital_gainscame 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_ratewas 830% and 1,164% instead of 28% and 48% for gains near the basic rate limit.household_net_incometime with elasticities set.Invariants (stated and tested)
For any earnings and elasticities (Hypothesis,
policyengine_uk/tests/test_labour_supply_response_baseline.py):reset_calculations, and calculating periods in any order give the same values as single-period runs.test_marginal_rates_hold_behavioural_responses.py).test_measurement_branches.py).Plus a guard that every
*_behavioural_response/*_behavioral_responsevariable is inBEHAVIOURAL_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:
get_branch("baseline")again fails 6 tests intest_labour_supply_response_baseline.py.test_a_measurement_branch_drops_what_its_simulation_calculatedon 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).test_marginal_rates_hold_behavioural_responses.pyandtest_holding_the_responses_keeps_a_branch_from_measuring_them_again. Removing both the hold andresponse_sourcefails all 5 marginal rate cases, because a branch that shares its parent's rules also takes the labour supply response throughresponse_source.response_sourcefailstest_a_branch_with_its_parents_rules_takes_the_parents_response. Removing the zero-elasticity shortcut fails both cases oftest_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_responseselasticities. 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 andgov_balancechanges before and after those commits, both on today's main.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.mdplusfix-lsr-baseline-2268.added.md(themeasurement_brancheshelpers) 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_mtrholds 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.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.measurement_branchinto 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()leavessimulation.baselineat full size (Give UK simulations a working subsample #2265).Tests run
On the head, with policyengine-core 3.33.2 (the lock):
Result: 2,516 passed, 1 skipped.
ruff format --check .andruff check .pass.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