Skip to content

Remove the dead obr_elasticities labour supply switch - #2413

Merged
MaxGhenis merged 4 commits into
mainfrom
remove-dead-obr-elasticities-switch
Oct 14, 2026
Merged

MaxGhenis merged 4 commits into
mainfrom
remove-dead-obr-elasticities-switch

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #2412

What

gov.simulation.labour_supply_responses.obr_elasticities ("Use the OBR's progression elasticities for the labour supply responses") is a switch that nothing reads. It has had no reader since #993 added it in October 2024, so setting it to 1 changed no output. This PR:

  1. Deletes the YAML.

  2. Registers the path in REMOVED_PARAMETERS (utils/parameters.py), the repo's registry for removed parameters. A reform or saved policy that still names it, under the current or legacy labor_supply_responses path, now fails with directions instead of a bare Could not find the parameter … error:

    The parameter gov.simulation.labour_supply_responses.obr_elasticities has been removed. Nothing read it, so setting it never changed any output. The OBR's labour supply elasticities are applied by Simulation.apply_dynamics, called on a simulation with a baseline, when gov.dynamic.obr_labour_supply_assumptions is true, which it is by default.

    The stub left at the path is hidden from the app (economy and household false). If a path is listed in REMOVED_PARAMETERS while its YAML still exists, the system now fails to build with a ValueError saying to delete the YAML. Before, that case failed at import with an AttributeError. To support this, add_removed_parameter_aliases, which was hard-wired to the State Pension age node, now places each stub under its own parent. Each entry carries its own label, and the label of the existing State Pension age stubs is unchanged. check_parameter_not_removed checks a legacy labor_supply_responses path as its labour_supply_responses equivalent.

  3. Adds a guard so dead switches cannot come back (tests/code_health/test_labour_supply_parameters_are_read.py). Every parameter under gov.simulation.labour_supply_responses and under gov.dynamic must be read by a formula or by the dynamics.

The working switch, gov.dynamic.obr_labour_supply_assumptions, and the OBR dynamics (dynamics/labour_supply.py, dynamics/progression.py) are unchanged.

Why delete rather than alias

The issue's rule was to delete unless an app still references the parameter. Nothing does. I searched for obr_elasticities under both paths at origin HEAD on 2026-10-14: policyengine-app-v2, policyengine.py, policyengine-api, policyengine-api-v2, policyengine-api-v2-alpha, policyengine-uk-data, microcosm and the archived policyengine-app (v1) returned 0 hits. The one org-wide code-search hit, OBR_ELASTICITIES_URL in uk-childcare-100k-threshold, is a JS constant for a link to an OBR PDF. Within this package, git grep finds no reader, and git log -S finds no commit that ever added one.

Saved policies are a separate surface. app-v2's parameter tree (buildParameterTree.ts) lists any parameter flagged economy or household, so a user could have set this one; search hid it because it had no label. Such a policy used to run with no effect. It now fails with the directions above, which is why the registry entry is part of this PR.

How the guard works

Over a small household it runs both labour supply paths: the elasticity-based response (employment_income_behavioral_response, with non-zero elasticities and a basic rate rise), and Simulation.apply_dynamics with the OBR assumptions on. It records every parameter read on the parameter tree itself, so a read counts whether a formula makes it through parameters(period) or the dynamics make it through sim.tax_benefit_system.parameters. It patches three hooks for the duration of the run:

  • an attribute or item read of a value on a ParameterNodeAtInstant;
  • a direct parameter(instant) call;
  • fancy indexing, which counts every leaf under the node.

Building a node at an instant counts as no read. A control parameter injected into both runs' trees must stay unread, so the guard cannot pass by counting the whole tree.

Invariants (stated, then executed)

  • Guard: every parameter under gov.simulation.labour_supply_responses and gov.dynamic is read by a formula or by the dynamics in the scenario.
  • Recorder soundness: building a node at an instant records nothing, and the injected control leaf stays unread through both runs and stays in the tree. A unit test covers each kind of read.
  • Registry (exhaustive over the finite domain, not sampled): for every path under gov, legacy aliases included, check_parameter_not_removed rejects the path exactly when a removed stub sits there, and every registered path has a stub. The walk excludes the tree's internal baseline copy, whose baseline.-prefixed stub paths the check does not reject. That gap predates this PR (the State Pension age stubs have it too), and a write that reaches one of those stubs still raises with directions through RemovedParameterNode.update.
  • Entry points: each of 13 reform entry points plus 2 Simulation constructor paths raises ValueError with directions, under both paths. Neighbouring LSR parameters stay reformable under both paths.

Mutation checks (run on scratch git archive exports)

Mutation Result
Guard test on origin/main (switch still present) fails, naming exactly ['gov.simulation.labour_supply_responses.obr_elasticities']
Removal tests at the first commit (no registry entry) 34 of 36 fail; the two that pass are the neighbouring-parameter tests
The LSR formula walks every LSR leaf (a vacuity risk) the control test fails
Add dead gov.simulation.labour_supply_responses.dead_switch and gov.dynamic.dead_dynamic_switch both guard cases fail, naming each
relative_income_change stops reading bounds.income_change the guard fails, naming bounds.income_change
Drop the legacy-path canonicalisation in check_parameter_not_removed 6 fail, including the registry invariant

Impact

No model output changes. No code read the parameter: git grep finds no reader, and the guard records no read of it on main. No microsimulation was run, because there is no number to report.

Tests run locally

  • At aca90a49c, with uv run --frozen --extra dev pytest -n 4: 131 passed across the guard, the removal tests, test_removed_state_pension_age_parameters.py and test_behavioral_responses.py.
  • Regression subset at d9706642f: the 74 non-microsimulation Python test files that touch labour supply, parameters, scenarios or metadata. 4821 passed, 68 skipped, 0 failed (75 minutes, 4 workers). Later commits add only tests and the review fixes below.
  • CI was 15/15 green at 76f6aba56.

Independent review

Every Subfleet lane was closed (Codex until about 19:30Z, Claude unavailable), so the rules' fallback reviewer, an Opus 5.5 agent, reviewed 76f6aba56: APPROVE with five nits. Its checks:

  • Stub survival: through the slow and fast construction paths, statutory and memo rebuilds, reset_parameters then process_parameters, deepcopy, pickle and capital_gains_projection.
  • Entry points: further ones beyond this PR's tests, through the real constructor.
  • Guard vacuity: nothing rebuilds the parameters during the recorded block, and 616 of 972 gov leaves are read.
  • Metadata: a replica of policyengine-api's build_parameters hides the stub.

The four code nits are fixed in aca90a49c:

  • a clear error for a registered path whose YAML still exists;
  • a docstring note that the stub reads as an empty node at an instant;
  • the message now says apply_dynamics needs a baseline;
  • the recorder uses MonkeyPatch.context().

The fifth nit was this section. The same reviewer then re-reviewed the delta and gave APPROVE at aca90a49c. It re-ran the restored-YAML case and an error raised inside the recorder block, and 131 tests passed. Reports are in ~/reviews/uk-obr-elasticities-2412/ (review-opus-76f6aba56.md, review-opus-aca90a49c.md).

Notes

Reproducer: policyengine_uk/tests/code_health/test_labour_supply_parameters_are_read.py::test_every_labour_supply_parameter_is_read

axiom: n/a: a simulation switch with no policy content; it encodes no statutory provision.

🤖 Generated with Claude Code

MaxGhenis and others added 4 commits October 14, 2026 11:45
gov.simulation.labour_supply_responses.obr_elasticities ("Use the OBR's
progression elasticities for the labour supply responses") has had no
reader since it was added in #993: setting it changed no output. The OBR
elasticities are applied by Simulation.apply_dynamics under
gov.dynamic.obr_labour_supply_assumptions, which this leaves unchanged.

Add a code-health test that runs the elasticity-based response and the
OBR dynamics on a small household, records every parameter read, and
fails if any parameter under gov.simulation.labour_supply_responses or
gov.dynamic was never read. A control parameter nothing reads must stay
unread, so the recorder cannot pass by counting the whole tree.

Fixes #2412

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Register gov.simulation.labour_supply_responses.obr_elasticities in
REMOVED_PARAMETERS, the repo's registry for removed parameters, so a
reform or saved policy that still names it (the app-v2 parameter tree
listed it) fails with a message pointing to
gov.dynamic.obr_labour_supply_assumptions instead of a bare lookup
error. The stub is hidden from the app (economy and household false).

add_removed_parameter_aliases was hard-wired to the State Pension age
node; it now places each stub under its own parent, and each entry
carries its own label. check_parameter_not_removed checks a legacy
labor_supply_responses path as its labour_supply_responses equivalent,
so the legacy alias gives the same directions at every entry point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Walk every path under gov, the legacy labor_supply_responses alias
included, and check that check_parameter_not_removed rejects a path
exactly when a removed-parameter stub sits there, and that every
registered path has a stub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- add_removed_parameter_aliases raises a ValueError telling you to
  delete the YAML when a path in REMOVED_PARAMETERS still holds a
  parameter, instead of an AttributeError at import.
- Note on RemovedParameterNode that it reads as an empty node at an
  instant, so its parent can still be read.
- The OBR elasticities message says apply_dynamics acts on a simulation
  with a baseline.
- The read recorder patches the classes with MonkeyPatch.context(), so
  it leaves no attribute behind; the recorder test checks that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 14, 2026 19:19
@MaxGhenis
MaxGhenis added this pull request to the merge queue Oct 14, 2026
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Merge audit, head aca90a49c1dd8994465162a23cf9abe9c6022617, enqueued 2026-10-14 19:47Z:

  • gh pr checks: all 14 pass (exit 0). The two cancelled check runs on this head are earlier Fix evidence runs, superseded by later passing ones after body edits. mergeable is MERGEABLE. Not a draft, and no changes requested.
  • Independent review: every Subfleet lane was closed (Codex until about 19:30Z, no Claude lane available), so an Opus 5.5 agent stood in for the GPT-6.1 Sol review.
    • At 76f6aba56: APPROVE with 5 nits. The 4 code nits are fixed in aca90a49c; the fifth was this PR body.
    • At aca90a49c: APPROVE.
    • Reports are in ~/reviews/uk-obr-elasticities-2412/.
  • Local runs:
    • 131 tests passed at aca90a49c.
    • A 74-file regression subset at d9706642f: 4821 passed, 68 skipped.
    • Six mutation checks, all caught (see the PR body).
  • No model output changes: nothing read the removed parameter.

🤖 Generated with Claude Code

Merged via the queue into main with commit 3c1eb1f Oct 14, 2026
15 of 17 checks passed
@MaxGhenis
MaxGhenis deleted the remove-dead-obr-elasticities-switch branch October 14, 2026 20:31
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>
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.

gov.simulation.labour_supply_responses.obr_elasticities is a dead switch: nothing reads it

1 participant