Skip to content

Draw is_apprentice for employees aged 21 to 24 on the UK spine (#1372) - #1373

Draft
vahid-ahmadi wants to merge 4 commits into
mainfrom
apprentice-imputation
Draft

vahid-ahmadi wants to merge 4 commits into
mainfrom
apprentice-imputation

Conversation

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

Closes #1372

Summary

Adds is_apprentice to the UK spine, so the employer NICs zero rate for apprentices under 25 in policyengine-uk#2505 (SSCBA 1992 s.9B) reaches people aged 21 to 24. Today no UK dataset supplies the input, and the FRS has no apprenticeship variable (see #1372).

frs_person_draws draws the flag for employees aged 21 to 24 only. Employees are people whose employment_status from frs_employment is FT_EMPLOYED or PT_EMPLOYED. Everyone else keeps the engine default, False.

  • Rate. The new contract entry apprenticeship_participation_19_to_24 holds DfE's England apprenticeship participation per head of population aged 19 to 24 for 2019/20 to 2024/25. The 2024/25 value is 252,960 / 4,221,504 = 5,992 per 100,000. It comes from the national rows of the DfE data set, and the CSV's sha256 is recorded in the entry.
  • Draw. At the frame's household weights, the stage converts the rate to a rate among the eligible employees: p = min(1, r * W(age 21 to 24) / W(eligible)). A person is flagged when eligible and their identity-keyed uniform (seed 0, salt is_apprentice) is below p. The weighted apprentices therefore equal r times the weighted population aged 21 to 24.
  • Why only 21 to 24. Under-21s already get the s.9A zero rate. Flagging them, or people who are not employees, would also move other policyengine-uk outcomes that read is_apprentice: is_in_non_advanced_education (qualifying young person status for child benefit and Universal Credit), minimum_wage (the self-employed Universal Credit minimum income floor), Care to Learn and the 16 to 19 Bursary Fund.
  • No employment_status. The stage refuses a frame without it rather than flag non-employees. It also refuses a frame that already stores is_apprentice.

Files

  • uk_runtime/frs_person_draws.py: derive_is_apprentice, plus the output column and the frame's person weights.
  • uk/take_up_contract.json: the DfE rate series, with its source and derivation.
  • uk/spec/sources.yaml and uk/source_stages.json: an assign_binary_from_rate operation with its eligibility and rate derivation, the new output, and the stage note.
  • uk_runtime/graph.py: the typed cell, and employment_status as a stage read.
  • uk/gates.json and uk_runtime/terminal_gates.py: person.is_apprentice added to the export surface's allowed extra columns. Like tax_free_childcare_spend_routed_share, it is a genuine model input that the incumbent does not carry.
  • Tests: tests/engine_free/uk/test_uk_apprentice_draw.py is new (10 tests). The stage operation list and rate-key order in test_uk_source_stages.py are updated, and the shared stochastic fixture gains employment_status and the rate.

Tests

test_uk_apprentice_draw.py checks:

  • only employees aged 21 to 24 are flagged, across ages 18 to 30 and four statuses;
  • the weighted share over ages 21 to 24 matches the rate under random weights;
  • the conditional rate and identity keying on a four-person example, with the row order reversed;
  • the cap at one;
  • no eligible employee means no flags;
  • the refusals (no employment_status, a rate outside [0, 1], a stored flag);
  • the stage's use of household weights (p = 0.06 x 4 / 1 = 0.24);
  • the contract value for the build year.

Local runs (fresh venvs, Python 3.13):

  • Engine-free job: passes, apart from the two Orrery parser tests, which need node (not installed on this machine).
  • Engine-UK job: passes.
  • tools/run_integration_tests.sh: passes. It runs the real spine command on the synthetic fixture, so frs_person_draws reads employment_status from frs_employment.
  • ruff check .: clean.
  • tools/ci_test_plan.py verify: ok.

Pins that move with the spec, regenerated with their tools:

  • the UK gate-battery and release-cut digests (microcosm-data contract.py and the contract tests);
  • release_input_coverage_manifest.json (tools/build_uk_release_input_coverage_manifest.py);
  • the H2 UK spine parity fixture (tools/graph_uk_spine_fixture.py).

Other changes in this PR:

  • tools/verify_uk_identity_stability.py passes the household weights, so its re-derivation matches the stage.
  • The routed-share engine test finds its operation by output rather than by position.

Caveats (also in #1372)

  • England's rate is applied UK-wide. It is uniform across ages 19 to 24, and it counts everyone in learning at any point in the academic year by age at start. So it likely overstates the point-in-time share, although age at start partly offsets that. The SDS, Medr and ApprenticeshipsNI series are a follow-up.
  • This does not close most of the £1.42bn vs £2.47bn gap. HMRC's January 2026 tax relief statistics put the apprentice relief at £570m in 2025-26, including under-21 apprentices, and the under-21 relief at £1.9bn. Most of the shortfall is in the under-21 line.
  • Not measured here: the effect on a built spine, and the change in employer NICs after calibration. Both need the licensed FRS build. The OBR Class 1 employer NICs target will absorb part of it.

🤖 Generated with Claude Code

vahid-ahmadi and others added 4 commits October 23, 2026 19:55
policyengine-uk#2505 zero-rates employer NICs for relevant apprentices
under 25 (SSCBA 1992 s.9B) through the is_apprentice input, which no UK
dataset supplies: the FRS 2022-23 to 2024-25 adult tables carry no
apprenticeship variable. frs_person_draws now draws it for employees aged
21 to 24 at DfE's England apprenticeship participation per head aged 19 to
24 (5,992 per 100,000 in 2024/25), converted to a rate among the eligible
employees at the frame's household weights. Younger and older people keep
False, so is_in_non_advanced_education, minimum_wage, Care to Learn and the
16 to 19 Bursary Fund do not move for them.

The contract gains apprenticeship_participation_19_to_24, the stage spec
and graph declare the output and the employment_status read, the export
surface allows person.is_apprentice, the identity-stability tool passes the
household weights, and the gate-battery and input-coverage pins move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tation (#1372)

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

This branch has not been deployed

No deployments
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.

Impute is_apprentice on the UK spine so the apprentice employer NICs zero rate reaches 21- to 24-year-olds

1 participant