Skip to content

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

Merged
juaristi22 merged 16 commits into
mainfrom
apprentice-imputation
Oct 27, 2026
Merged

juaristi22 merged 16 commits into
mainfrom
apprentice-imputation

Conversation

@vahid-ahmadi

@vahid-ahmadi vahid-ahmadi commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1372

Picked up from Vahid's draft after the review of 25 October: his two code commits are kept, main is merged in after #1400 and #1396, and the commits after the first merge size, realize and gate the draw. The branch relocks policyengine-uk 2.162.8, which stops is_apprentice from setting the apprentice minimum wage for later-year apprentices (PolicyEngine/policyengine-uk#2683).

Summary

Adds is_apprentice to the UK spine, so the employer NICs zero rate for apprentices under 25 in PolicyEngine/policyengine-uk#2505 (SSCBA 1992 s.9B) reaches people aged 21 to 24. 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 contract entry apprentice_stock_19_to_24 holds DfE's England apprentices in learning at the start of the academic year, per head of population aged 19 to 24, for 2019/20 to 2024/25. That is participation less the year's starts: 2024/25 gives (252,960 − 97,240) / 4,221,504 = 3,689 per 100,000. It comes from the national rows of the DfE data set, and the entry records the CSV's sha256.
  • Why not participation. Participation (5,992 per 100,000) counts everyone in learning at any point in the year, so it overstates how many people are apprentices at once. DfE publishes no point-in-time count. Participation less starts is the number already in learning on 1 August.
  • Draw. The target is that rate times the weighted population aged 21 to 24 at the frame's household weights. Eligible employees are flagged in the order of their identity-keyed uniform (seed 0, salt is_apprentice) until their weight is nearest the target. This is uc_take_up's rule, so the stage meets the target to within half one person's weight, not just in expectation. If the eligible employees weigh no more than the target, all of them are flagged.
  • Receipt. The stage records the rate, the population and eligible weights, the target, the flagged weight and its deviation, cap_bound, support bands, and the relief the flags imply. The relief uses the installed engine's Class 1 schedule for the build year: 13.8% between £9,100 and £50,270 on the 2024 frame, with pay standing in for NIC earnings. Any figure resting on fewer than ten households is withheld, with the figures that would give it back.
  • Gate. The release-blocking gate uk_stage_frs_person_draws_apprentices (stage_health, check apprentice_draw) holds the receipt to the contract's rate for the build year. It requires the flagged weight to lie within 5 percent of the target unless the draw is cap-bound, and the implied relief to stay at or below £370m. That is HMRC's NIC-NS1 for 2024-25, which covers every apprentice under 25, so it is a ceiling, not a target. A figure the receipt withholds is named unchecked, never failed, by María's ruling of 26 October that Build the national + local-area pair on policyengine-uk 2.151.0, and drop UK model-figure withholding #1396 carries. The gate is a population fact check, so the synthetic smoke build records its outcome without blocking.
  • Engine. From 2.162.8 policyengine-uk gives the apprentice minimum wage only under 19 or with is_in_first_year_of_apprenticeship. The build leaves that input at its default, False, and the concept mapping's engine-defaults register records it for María's signature. So the employees the stage flags keep their age-related minimum wage, and the Tax-Free Childcare, extended childcare and Childcare Offer for Wales income tests don't move. The stage refuses an installed engine without that split or without the s.9B threshold.
  • 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 outcomes that read is_apprentice: is_in_non_advanced_education (qualifying young person status for child benefit and Universal Credit), 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.

The relock to policyengine-uk 2.162.8

PolicyEngine/policyengine-uk#2684 was versioned as 2.162.7, but that release never reached PyPI because its YAML tests hit the job's time limit. 2.162.8 is the first published release with it. Moving from 2.162.3 also brings:

The floors in the three pyproject.toml files rise to 2.162.8. The uprating pins and the bonus-sacrifice anchor change only their version stamps, and the CGT projection's OBR path does not move.

Files

  • uk_runtime/frs_person_draws.py: derive_is_apprentice with the walk to the target, its receipt and implied relief, apprentice_relief_schedule with the engine guard, and the stage's checkpoint_metadata.
  • uk/take_up_contract.json: apprentice_stock_19_to_24, with its source and derivation.
  • uk/spec/sources.yaml and uk/source_stages.json: the assign_binary_from_rate operation with its eligibility and derivation, the new output, the stage note, and the 2.162.8 dependency lines.
  • uk_runtime/stage_health.py, uk/gates.json, uk_runtime/battery_bindings.py, uk_runtime/calibration_run.py: the gate, its entry, its parameter keys and its place in the spine scope.
  • uk_runtime/graph.py: the typed cell, and employment_status and employment_income as stage reads. The graph hands a stage only the cells it declares, so the relief needs the pay declared; a test holds the node's inputs to the columns the draw reads.
  • uk/gates.json and uk_runtime/terminal_gates.py: person.is_apprentice joins the export surface's allowed extra columns. Like tax_free_childcare_spend_routed_share, it is a genuine model input the incumbent does not carry.
  • microcosm-frame adapters/policyengine_uk_concepts.py: the engine version, and the engine-defaults entry for is_in_first_year_of_apprenticeship.
  • The relock: uv.lock, APPROVED_UV_LOCK_SHA256, the uprating pins and the bonus-sacrifice anchor (version stamps only), the concept coverage report, and the H2 fixture.
  • microcosm-data contract.py and its tests: the gate in the battery map and the spine certification scope, plus the moved digests.
  • Tests: tests/engine_free/uk/test_uk_apprentice_draw.py, including the node's reads, and the gate's tests in test_uk_stage_health.py. The stage operation list, the rate keys, the contract's build-year rates, the engine pins and the shared stochastic fixture are updated.

Tests

All local, on Python 3.14 with policyengine-uk 2.162.8:

  • Engine-free: 0 failures on the surface this PR and Build the national + local-area pair on policyengine-uk 2.151.0, and drop UK model-figure withholding #1396 touch: the apprentice draw and its gate, the stage health gates, the take-up contract, the source stages and coverage manifest, the country spec, the data contract and battery pins, the engine pins, the concept mappings, and the UK graph and identity tests.
  • Engine-UK: the uprating pins, the bonus-sacrifice anchor, the concept mapping, the CGT projection, the TFC routed share and the H2 UK spine parity test pass. CI runs the full tier.
  • Integration-UK: not run locally this round; CI runs it.
  • ruff check and ruff format --check are clean on the edited files, and tools/ci_test_plan.py verify is ok.

Pins regenerated with their tools: the coverage manifest, the UK gate-battery triple and the spine and release-cut certification digests (from the live battery), the uprating pins, the bonus-sacrifice anchor, the concept coverage report, and the H2 fixture, which now carries the apprentice operation.

Measured on a local spine

A licensed UK spine built locally (--no-staging) at ab7c777e2, at the stage's weights, rounded:

  • about 3.09m people aged 21 to 24, and 1.72m eligible employees;
  • a target of about 114,000 (3.6887% of the age group);
  • about 112,600 flagged, 1.2% below the target;
  • every support band 30+, and no cap-bound pool.

On that spine uk_stage_frs_person_draws_apprentices blocked the build, the only one of 47 gates to fail, because the receipt recorded no implied relief: the graph hands a stage only the cells it declares, and the stage did not declare employment_income. c07f86fd7 declares it. The spine at c07f86fd7 builds with the same draw, and all 47 gates pass. The flags imply about £218m of relief at the stage's weights and the 2024-25 schedule, 59% of NIC-NS1's £370m.

Caveats (also in #1372)

  • England's rate is applied UK-wide, uniform across ages 19 to 24.
    • Scotland's point-in-time count (SDS Modern Apprentices in training) is lower per head.
    • Medr's single-year Welsh rates fall with age, which points to a lower rate at 21 to 24.
    • Rates by nation are a follow-up, once the SDS, Medr and ApprenticeshipsNI series are in Chronicle.
  • Participation takes age at 31 August and starts take age at the start, so the two differ at the band's edges. Autumn starts also put the year's low near 1 August.
  • The draw takes no account of pay. DfE's 2023 learner survey puts the median apprentice aged 19 to 24 at £11.54 an hour, with 93% working 31 hours or more a week, which is close to the group's own pay.
  • is_in_first_year_of_apprenticeship stays False, so a flagged employee in the first year of an apprenticeship keeps the adult minimum wage rather than the apprentice rate. The relief does not read it.
  • HMRC's January 2026 tax relief statistics put the apprentice relief (NIC-NS1) at £370m in 2024-25 and £570m in 2025-26. That covers apprentices of every age under 25, under-21s included, and the under-21 relief is £1.9bn, so this does not close most of the £1.42bn vs £2.47bn gap.
  • The national solve does not absorb the change: obr.ni_employer has been a measured diagnostic, not a fitted target, since 16 October. Whatever the flag relieves comes straight off employer NICs.

🤖 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>

@juaristi22 juaristi22 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.

Review (25 October). The draw reuses our machinery, but I think it puts about twice the likely apprentice relief on 21 to 24-year-olds. It also clashes with what is_apprentice already means in policyengine-uk. I suggest we pick this PR up from here; details at the end.

Consistent with how the UK spine does things

  • It reuses assign_binary_from_rate with the identity-keyed seed 0 uniforms, salted by output, as the marriage allowance and Scottish Child Payment draws do.
  • The take_up_contract.json entry has the usual source block. Its citation states the translation openly: in-year participation, age at start, and England's rate applied UK-wide.
  • sources.yaml and source_stages.json move together, with the graph cell and an export allowlist entry like tax_free_childcare_spend_routed_share's. The coverage manifest, the H2 fixture and the gate digests are repinned.
  • It refuses missing inputs, and the tests sit in tests/engine_free/uk/.
  • I checked the FRS 2024-25 EUL variable listing too: it has no apprenticeship item. The nearest, "Undergoing education or training", is a value of LTHAN30 (the reason for working under 30 hours a week).

1. Size: the draw likely puts most of the apprentice relief on 21 to 24-year-olds

  • It flags 5.99% of the population aged 21 to 24. On the dashboard's bundle that is 3.36m people and 2.12m employees, so about 0.20m flags: roughly one in ten employees aged 21 to 24.
  • It draws them evenly among those employees, so they carry the group's average relief: £2,706 per employee in 2025-26 (PolicyEngine/uk-autumn-budget-dashboard-2026#111). That comes to about £0.54bn.
  • HMRC's £570m (Table 2, NIC-NS1, a 2025-26 forecast) covers apprentices of every age under 25, under-21s included. Tax Policy Associates assume 40% of it goes to 21 to 24-year-olds: £228m (APPRENTICE_21_24_SHARE in their code).
  • Two things push the draw up:
    • DfE's participation counts everyone in learning during the year, not at a point in time (caveat 3 in #1372);
    • the draw ignores pay, while first-year apprentices can be paid the apprentice rate: £7.55 an hour in 2025-26, against £12.21 at 21 and over.

2. is_apprentice already means something else in policyengine-uk

  • minimum_wage pays the apprentice rate whenever is_apprentice is true, at any age. The engine's own tests say the model reads it as "the apprenticeship rate applies" (regulation 5: an apprentice under 19 or in the first 12 months).
  • So each flagged 21 to 24-year-old gets £7.55 instead of £12.21. In 2.147.2, minimum_wage feeds the Tax-Free Childcare and extended childcare income tests. Those apply to employees, so flagged parents can newly meet them. That moves the eligible pools behind the childcare take-up rates fitted on the spine.
  • The UC minimum income floor reads gov.hmrc.minimum_wage.non_apprentice directly, not minimum_wage, so it isn't a reason to draw employees only. Drawing only employees is still right for s.9B.
  • PolicyEngine/policyengine-uk#2505 reads the same flag as s.9B's relevant apprentice under 25, in any year of the apprenticeship. The two readings need settling in policyengine-uk before the flag ships.

3. The DfE facts don't come through Chronicle

  • The series is typed into take_up_contract.json with a CSV hash. Our newer entries take publisher facts from Chronicle: tax_free_childcare_spend_routed_share cites its package id, and the UC take-up draw (#1344) vendors its DWP facts from the pinned feed.
  • The DfE series, and the SDS, Medr and ApprenticeshipsNI series for the other nations, need Chronicle packages first.

4. Caveat 5 is out of date

  • obr.ni_employer has been unbound on both lines since my 16 October ruling (exclusion). It is a measured diagnostic, never fitted.
  • So the national solve won't absorb the change. Once PolicyEngine/policyengine-uk#2505 is in, whatever the flag relieves goes straight into employer NICs and shows in that diagnostic.

5. No check against a published total

  • The UC take-up draw (#1344) sets a bar for draws that must reproduce a published figure:
    • it realizes DWP's counts per pool;
    • it pools until each pool rests on 30 distinct source households;
    • it writes a receipt with banded support;
    • it adds a release-blocking stage gate.
  • This draw has no published total, receipt or gate, and it hasn't been measured on a built spine.

6. Other points

  • England's rate is applied UK-wide. Scotland's published count (SDS) points to a lower share there, though on a point-in-time basis.
  • Downstream, the dashboard's employer NI measure now skips is_apprentice (PolicyEngine/uk-autumn-budget-dashboard-2026#113). So a bundle carrying this flag lowers that measure by about £0.54bn on 2025-26 earnings, even before PolicyEngine/policyengine-uk#2505. Given point 1, around £0.3bn of that would be too much.
  • The branch is 141 commits behind main and conflicts with the take-up contract and spec changes since 21 October. Every pin needs regenerating after the rebase.

Suggestion: we pick this PR up from here. @vahid-ahmadi, if that works for you, we would:

  1. rebase onto main and regenerate the pins;
  2. add the DfE apprenticeship series (and the other nations' series) to Chronicle, then vendor them from the pinned feed;
  3. size the draw against a published total, as #1344 does for UC:
    • a point-in-time basis;
    • a draw that depends on pay as well as age;
    • exact realization, a receipt and a stage gate;
    • the 21 to 24 relief checked against a share of NIC-NS1;
  4. settle what is_apprentice means in policyengine-uk with PolicyEngine/policyengine-uk#2505 first, so the minimum wage and the childcare income tests don't move;
  5. measure it on a local spine before it merges, and line up the dashboard's next bundle adoption with it.

Aggregates only.

juaristi22 and others added 4 commits October 26, 2026 15:57
Brings the branch up to f6a790c. The conflicts were only in generated
pins (the gate-battery and certification digests, the coverage manifest,
the H2 fixture), which take main's values here and are regenerated in the
pin-pass commits that follow.

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

The draw flagged DfE's in-year participation per head (5,992 per 100,000
aged 19 to 24 in 2024/25). Participation counts everyone in learning at
any point in the academic year, so the draw put about twice the likely
s.9B relief on 21 to 24-year-olds (review of 25 October).

- The contract's apprentice_stock_19_to_24 replaces
  apprenticeship_participation_19_to_24: participation less starts per
  head, the apprentices already in learning when the year began (3,689
  per 100,000 in 2024/25), from the same DfE data set and file hash.
  DfE publishes no point-in-time count.
- Eligible employees aged 21 to 24 are flagged in the order of their
  identity-keyed uniform until their weight is nearest the target
  (uc_take_up's rule), so the stage meets it to within half one person's
  weight rather than only in expectation, or flags all of them when they
  weigh no more than it.
- frs_person_draws records a receipt (rate, population and eligible
  weight, target, flagged weight, deviation, cap_bound, support bands).
  It withholds any weight resting on fewer than ten households, with the
  figures that would give it back.
- uk_stage_frs_person_draws_apprentices, a release-blocking stage-health
  gate (a population fact check), holds the receipt to the contract's
  rate and the flagged weight to within 5 percent of the target. It joins
  the spine scope, the data contract's battery map and certification
  scope, and the battery's allowed parameter keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in pass)

The stage manifest moved, so every source_manifest_sha256 moves with it;
no column changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
, pin pass)

frs_person_draws gains the is_apprentice operation, output and note. The
regeneration also carries lcfs_consumption's notes as main now has them
(5d50e87 edited them after main's last regeneration).

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

Copy link
Copy Markdown
Contributor Author

@juaristi22 thanks for picking this up. Review at aa06c5738.

Verdict: the sizing work is a clear step forward and I'd keep it. One must remains: the flag is still the same is_apprentice that sets the apprentice minimum wage, so this should not merge before policyengine-uk#2683 is settled and pinned. Not approving yet.

Your 25 Oct points, one by one

  1. Size. Addressed in part. The rate is now point-in-time: participation less starts, 3,689 per 100,000 in 2024/25 (take_up_contract.json:970-987; I checked (252,960 − 97,240) / 4,221,504 = 0.036887). Your local spine flags about 112,600. At your own £2,706 average relief per employee aged 21 to 24, that is roughly £0.30bn. That is still about a third above Tax Policy Associates' £228m, and about 53% of HMRC's full £570m. This is my arithmetic on your figures, not a measurement. The draw still ignores pay; the PR gives the learner-survey reason (median £11.54 an hour), which I accept as a caveat.
  2. is_apprentice meaning. Not addressed in code. See the must below.
  3. Chronicle. Not addressed: the DfE series is still typed in with a CSV hash (take_up_contract.json:970-990). Fine as a stated follow-up, but worth an issue number.
  4. Caveat 5. Addressed: the PR body now says obr.ni_employer is a measured diagnostic, not fitted.
  5. Check against a published total. Addressed for the count, not the relief. The draw now walks to the target exactly (frs_person_draws.py:254, _nearest_target_threshold at :293), writes a receipt that withholds anything on fewer than ten households (:284), and the release-blocking gate uk_stage_frs_person_draws_apprentices (gates.json:904, stage_health.py:4446) holds it to within 5% of the DfE target. Nothing checks the 21 to 24 relief against NIC-NS1.
  6. Other. England's rate is still applied UK-wide, now stated as a caveat. The branch is merged with main and the pins are regenerated.

Must

  • M1. The flag still cuts flagged people's minimum wage. On policyengine-uk main (2.162.5), minimum_wage.py:15-20 gives MW.apprentice whenever is_apprentice is true, and minimum_wage_category.py:27 does the same. So about 113k flagged employees go from £12.21 to £7.55 an hour, which moves the TFC, extended childcare and Wales Childcare Offer income tests (tax_free_childcare_meets_income_requirements.py:24, extended_childcare_entitlement_meets_income_requirements.py:27, wales_childcare_offer_parent_condition.py:48). Your own #2683 shows a 22-year-old parent on £8,000 flipping all three. You say very few spine parents are affected; I can't check that without a build, but the minimum_wage output itself is wrong for every flagged person, whatever the parent count. Also, on main's lock (uv.lock: policyengine-uk 2.151.0) the flag has no s.9B reader yet, so merging this before Lock policyengine-uk 2.162.3 and take the actionable #1095 items #1400 would ship only the minimum-wage side effect. Suggest two steps before merging: Lock policyengine-uk 2.162.3 and take the actionable #1095 items #1400 (or later) brings the s.9B reader, and a further bump after policyengine-uk#2683 lands (a first-year input, or a separate s.9B variable) brings the split. Lock policyengine-uk 2.162.3 and take the actionable #1095 items #1400 alone is not enough, since 2.162.3 predates #2683. Alternatively add a check that refuses the stage on a policyengine-uk without that split.

Should

  • S1. Add a relief check: report the implied 21 to 24 relief at the stage or in the PR, and at least require it to stay below NIC-NS1's £570m.
  • S2. The docstring (frs_person_draws.py:16-19) and the eligibility note (sources.yaml:1070, source_stages.json:1189) say leaving other ages False keeps minimum_wage unchanged. True, but they should also say the flag does move minimum_wage for those flagged, with a pointer to #2683.

Other readers of the flag. For completeness, care_to_learn_eligible, bursary_fund_16_to_19_in_qualifying_education and is_in_non_advanced_education also read is_apprentice, but none moves at 21 to 24. Care to Learn is for under-20s (care_to_learn/age_limit.yaml), the bursary's age test is under 19, and is_in_non_advanced_education feeds only the qualifying young person tests and the HBAI child flag, both under 20. Your PR body is right to list them as unchanged.

What I ran

  • Fresh worktree at aa06c5738, Python 3.14, main's lock: test_uk_apprentice_draw.py, test_uk_stage_health.py, test_uk_take_up_contract.py and microcosm-data test_contract.py: 383 passed, 1 skipped.
  • Read the draw, gate, contract and gate-entry diffs line by line, and the policyengine-uk readers of is_apprentice and minimum_wage at main.
  • Skimmed only: the regenerated manifest, H2 fixture and digest pins. No spine build; the 112,600 and support bands are from your PR body.
  • CI: lint, wheels, engine-uk (3.13 and 3.14), engine-us (3.14) and integration-uk pass. engine-free (3.13 and 3.14) and engine-us (3.13) are still queued.

Aggregates only.

🤖 Generated with Claude Code

juaristi22 and others added 6 commits October 26, 2026 20:36
Brings the branch up to 5810fce. The conflicts were only in generated
pins (the gate-battery and certification digests, the coverage manifest,
the H2 fixture), which take main's values here and are regenerated in the
pin pass that follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…IC-NS1 (#1373)

Vahid's review at aa06c57:

- M1: policyengine-uk before 2.162.7 gave the apprentice minimum wage to
  every flagged person, so a flagged 21 to 24-year-old lost the adult
  rate, which moves the Tax-Free Childcare, extended childcare and
  Childcare Offer for Wales income tests (policyengine-uk#2683). The
  stage now refuses an installed engine without
  qualifies_for_apprentice_minimum_wage and
  is_in_first_year_of_apprenticeship, or without the s.9B apprentice
  upper secondary threshold. The build leaves the first-year input at its
  default (False), so the flagged keep their age-related minimum wage.
- S1: the receipt records the relief the flags imply at the installed
  engine's own Class 1 schedule for the build year, with pay standing in
  for NIC earnings, and uk_stage_frs_person_draws_apprentices holds it at
  or below GBP 370m, HMRC's NIC - NS1 for 2024-25, which covers every
  apprentice under 25.
- S2: the module docstring and the operation's eligibility note say what
  the flag does to minimum_wage, with the pointer to #2683.

The two engine checks share one tax-benefit system. The pins move in the
pass that follows the re-lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #1396 ahead of its merge to main, with main's #1404 inside it.
The one conflict was the release cut's gate digests; those, the gate
battery digests and the release input coverage manifest are regenerated
on the merged tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1396 carries María's ruling of 2026-10-26 that a small-cell rule may
only flag. The apprentice gate failed a draw whose receipt printed a
figure "<10" (a population, flagged weight or implied relief resting on
fewer than ten source households). It now checks what the receipt
prints and names the rest in figures_unchecked_small_cells, as the UC
take-up, excise and gambling gates do. The gate's notes say so, which
moves the UK gate-battery and spine certification digests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's #1396 merge commit, whose tree is the #1396 head already merged
here, so nothing changes.

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

policyengine-uk#2684 (closing policyengine-uk#2683) gives the apprentice
minimum wage only under 19 or with is_in_first_year_of_apprenticeship,
so the employees frs_person_draws flags keep their age-related minimum
wage. It was versioned 2.162.7, which never reached PyPI, so the lock
moves from 2.162.3 to 2.162.8, the first published release with it, and
the floors in the three pyproject files follow.

- uv.lock moves policyengine-uk alone; APPROVED_UV_LOCK_SHA256 follows.
- The uprating pins and the bonus-sacrifice anchor change only their
  version stamps.
- The concept coverage gains is_in_first_year_of_apprenticeship, left at
  its default (False) in UK_ENGINE_DEFAULT_INPUTS for María's signature.
- The H2 spine fixture and the coverage manifest are regenerated; the H2
  fixture now carries the apprentice operation.
- The engine guard, the stage notes and the docstrings name 2.162.8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@juaristi22
juaristi22 marked this pull request as ready for review October 26, 2026 23:51
The graph gives a finite-read stage only its declared cells, and
frs_person_draws declared age and employment_status alone. On the
licensed spine at ab7c777 its receipt therefore recorded no implied
relief, and uk_stage_frs_person_draws_apprentices blocked the build. The
stage now reads employment_income too, a test holds the node's inputs
to the columns the draw needs, and the H2 fixture follows.

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

Copy link
Copy Markdown
Collaborator

@vahid-ahmadi thanks. Your review is addressed at c07f86fd7, which also merges main after #1400 and #1396.

M1, the flag cutting the minimum wage. Fixed in policyengine-uk and pinned here:

  • Separate apprentice minimum wage eligibility from NI relief policyengine-uk#2684 (closes is_apprentice sets both the apprentice minimum wage and the s.9B employer NICs relief policyengine-uk#2683) gives the apprentice minimum wage only under 19 or with the new input is_in_first_year_of_apprenticeship.
  • The branch re-locks policyengine-uk 2.162.8 and raises the floors in the three pyproject.toml files. #2684 was versioned as 2.162.7, but that release never reached PyPI, so 2.162.8 is the first one with it.
  • The build leaves the new input at its default, False. The concept mapping's engine-defaults register records it, with the error it can make, for María's signature. On 2.162.8 a flagged 22-year-old's minimum_wage stays at the adult rate (£11.44 in 2024), so the TFC, extended childcare and Childcare Offer for Wales tests don't move. Only the first-year input gives the apprentice rate.
  • The stage also refuses an installed engine without that split or without the s.9B threshold (apprentice_relief_schedule).

S1, the relief check.

  • The receipt now records the relief the flags imply. It uses the installed engine's own Class 1 schedule for the build year: 13.8% between £9,100 and £50,270 on the 2024 frame, with pay standing in for NIC earnings.
  • uk_stage_frs_person_draws_apprentices holds it at or below £370m, NIC-NS1 for 2024-25, which covers every apprentice under 25. I used 2024-25 rather than 2025-26's £570m because the 2024 frame is charged at 2024-25 rates. It's a ceiling, not a target.
  • The first licensed spine on the re-lock (ab7c777e2) recorded no relief, and the gate blocked it. The graph hands a stage only the cells it declares, and the stage didn't declare employment_income. c07f86fd7 declares it, with a test that holds the node's inputs to the columns the draw reads. The draw itself was unchanged: about 112,600 flagged, 1.2% below the target, every support band 30+.
  • A spine at c07f86fd7 is running. I'll post the implied relief and the gate's verdict here when it lands, beside your £0.30bn estimate and Tax Policy Associates' £228m.
  • Following María's ruling of 26 October, which Build the national + local-area pair on policyengine-uk 2.151.0, and drop UK model-figure withholding #1396 carries, a figure the receipt withholds for resting on fewer than ten households is named unchecked rather than failed, as in the other stage gates.

S2. The module docstring and the operation's eligibility note now say what the flag does to minimum_wage and point to policyengine-uk#2683.

3, Chronicle. Still a follow-up: the DfE series stays in the contract with its CSV's sha256. An issue for moving it into Chronicle is drafted but not filed yet. I'll link it here once it is.

What else moves with the re-lock from 2.162.3.

  • policyengine-uk#2680 (2.162.5): reported council tax reduction uprated with each household's council tax in projected years. Read at 2025 from the 2024 frame, on Lock policyengine-uk 2.162.3 and take the actionable #1095 items #1400's final spine at its prior weights, obr/council_tax's binding falls by about £0.06bn (0.13%). All of it comes from council tax reduction in England, which rises by about 4%. That move comes from #2680, not from the apprentice draw.
  • policyengine-uk#2679 (2.162.8): income-related ESA and income-based JSA reports screened separately for people outside the claimant couple.
  • policyengine-uk#2574 (2.162.7): Universal Credit advance repayments held at their cash amount under allowance reforms, with no baseline change.
  • The uprating pins and the bonus-sacrifice anchor change only their version stamps, and the CGT projection's OBR path doesn't move.

What I ran.

Aggregates only.

The 2.162.8 changelog fragment called the is_in_first_year_of_apprenticeship
default signed; the engine-defaults register records it for her
signature. The draw's fragment now also names the relief ceiling.

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

@juaristi22 juaristi22 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.

Independent review at 5d76d928f. I read the diff at ab7c777e2, then checked the two commits since. Nothing blocking is left. The one serious bug was real, and c07f86fd7 fixes it: I reproduced the failure before that commit and confirmed the fix independently.

1. Fixed in c07f86fd7: the relief check could not pass on a graph build

  • The graph gives a stage only the columns it declares (executor.py:771). Until c07f86fd7, frs_person_draws did not declare employment_income, so the receipt had no implied_relief and uk_stage_frs_person_draws_apprentices failed.
  • At ab7c777e2 the compiled node's person inputs were age, employment_status and the shape anchor max_free_entitlement_hours_used. A synthetic smoke spine (tools/build_uk_frs_spine.py --synthetic-fixture-dir ... --smoke) recorded implied_relief: null and "the receipt records no implied relief". With the read declared, the relief is a number and that failure is gone.
  • CI could not see it. The unit tests pass pay in directly, the stage tests switch the engine off, and on the synthetic build the gate is a population fact check, recorded but not blocking.

2. Suggestion: the pay read is still optional

  • frs_person_draws.py:328 skips the relief without an error when pay is missing, which is how the bug stayed hidden. The new test pins today's three reads, but a future undeclared read would fail the same quiet way.
  • The synthetic smoke records this gate as failed in any case, since the fixture's target is below one person's weight, so its record cannot show a regression.
  • Raising an error when the engine is installed and pay is missing would make CI's smoke build fail at the stage, rather than a licensed build at the gate. The cost is a pay column in the test frames that run the stage with the engine installed (test_uk_tfc_routed_share.py and the shared stochastic fixture). Optional.

3. Nit: receipt_counts is not one of the stage's helper modules

  • uc_take_up, carers_allowance_take_up and frs_annual_income_spells list it in _STAGE_HELPER_MODULES, since their receipts follow the shared rules. Without it, an edit to the support bands does not change this stage's implementation hash, so a resumed build could reuse a receipt banded under the old rule. Several other stages leave it out too.

4. Readers of the bundle

  • The engine check covers only the engine the build runs on. On policyengine-uk below 2.162.8, a bundle with this flag gives the roughly 113,000 flagged employees the apprentice minimum wage, and below 2.160 it gives them no s.9B relief. Any reader adopting the next bundle needs 2.162.8 or later in the same move.

Checked and fine

  • Engine 2.162.8: minimum_wage and minimum_wage_category both go through qualifies_for_apprentice_minimum_wage (under 19, or the first year), and the employer threshold reads is_apprentice under 25. So M1 holds.
  • Relief formula: for a 22-year-old employee it equals the engine's ni_class_1_employer with the flag off less with it on: £1,504.20 at £20,000 in 2024 and £2,250.00 in 2025. The two also agree at £8,000 and £60,000. The flag leaves minimum_wage unchanged (£11.44 in 2024, £12.21 in 2025).
  • Rate: (252,960 − 97,240) / 4,221,504 = 0.036887, and rate_at gives build year 2024 the 2024/25 value.
  • Draw: a zero target, a cap-bound pool, ties and flagging nobody are handled correctly. The stage runs before the support channels, so counting households by person_household_id is valid.
  • Pins: the gate-battery triple matches the live battery, APPROVED_UV_LOCK_SHA256 is the lock's sha256, and the coverage manifest's source hash is current. The H2 sources diff is spec text only.
  • Main: the head merges cleanly with 0e538bb01, and test_uk_apprentice_draw.py passes there.

Not checked

  • NIC-NS1's £370m for 2024-25: I did not open HMRC's table.
  • Licensed data: the spine at c07f86fd7 that the PR body mentions will show the relief against the ceiling.
  • CI at 5d76d928f: engine-free, engine-uk and engine-us are still running.

Aggregates only.

@juaristi22

Copy link
Copy Markdown
Collaborator

Update: the licensed spine at c07f86fd7 builds, and all 47 of its gates pass.

  • uk_stage_frs_person_draws_apprentices now reads the relief. The flags imply about £218m at the stage's weights and the 2024-25 Class 1 schedule, 59% of NIC-NS1's £370m. For comparison, your estimate was about £0.30bn and Tax Policy Associates' figure is £228m.
  • The draw is unchanged: about 112,600 flagged, 1.2% below the target, every support band 30+, and no figure left unchecked.

Aggregates only.

@juaristi22
juaristi22 merged commit 90ff43f into main Oct 27, 2026
10 checks passed
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

2 participants