Repository navigation
Draw is_apprentice for employees aged 21 to 24 on the UK spine (#1372) - #1373
Conversation
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
left a comment
There was a problem hiding this comment.
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_ratewith the identity-keyed seed 0 uniforms, salted by output, as the marriage allowance and Scottish Child Payment draws do. - The
take_up_contract.jsonentry 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.yamlandsource_stages.jsonmove together, with the graph cell and an export allowlist entry liketax_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_SHAREin 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_wagepays the apprentice rate wheneveris_apprenticeis 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_wagefeeds 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_apprenticedirectly, notminimum_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.jsonwith a CSV hash. Our newer entries take publisher facts from Chronicle:tax_free_childcare_spend_routed_sharecites 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_employerhas 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:
- rebase onto main and regenerate the pins;
- add the DfE apprenticeship series (and the other nations' series) to Chronicle, then vendor them from the pinned feed;
- 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;
- settle what
is_apprenticemeans in policyengine-uk with PolicyEngine/policyengine-uk#2505 first, so the minimum wage and the childcare income tests don't move; - measure it on a local spine before it merges, and line up the dashboard's next bundle adoption with it.
Aggregates only.
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>
|
@juaristi22 thanks for picking this up. Review at Verdict: the sizing work is a clear step forward and I'd keep it. One must remains: the flag is still the same Your 25 Oct points, one by one
Must
Should
Other readers of the flag. For completeness, What I ran
Aggregates only. 🤖 Generated with Claude Code |
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>
#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>
…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>
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>
|
@vahid-ahmadi thanks. Your review is addressed at M1, the flag cutting the minimum wage. Fixed in policyengine-uk and pinned here:
S1, the relief check.
S2. The module docstring and the operation's eligibility note now say what the flag does to 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.
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
left a comment
There was a problem hiding this comment.
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). Untilc07f86fd7,frs_person_drawsdid not declareemployment_income, so the receipt had noimplied_reliefanduk_stage_frs_person_draws_apprenticesfailed. - At
ab7c777e2the compiled node's person inputs wereage,employment_statusand the shape anchormax_free_entitlement_hours_used. A synthetic smoke spine (tools/build_uk_frs_spine.py --synthetic-fixture-dir ... --smoke) recordedimplied_relief: nulland "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:328skips 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.pyand the shared stochastic fixture). Optional.
3. Nit: receipt_counts is not one of the stage's helper modules
uc_take_up,carers_allowance_take_upandfrs_annual_income_spellslist 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_wageandminimum_wage_categoryboth go throughqualifies_for_apprentice_minimum_wage(under 19, or the first year), and the employer threshold readsis_apprenticeunder 25. So M1 holds. - Relief formula: for a 22-year-old employee it equals the engine's
ni_class_1_employerwith 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 leavesminimum_wageunchanged (£11.44 in 2024, £12.21 in 2025). - Rate: (252,960 − 97,240) / 4,221,504 = 0.036887, and
rate_atgives 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_idis valid. - Pins: the gate-battery triple matches the live battery,
APPROVED_UV_LOCK_SHA256is 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, andtest_uk_apprentice_draw.pypasses there.
Not checked
- NIC-NS1's £370m for 2024-25: I did not open HMRC's table.
- Licensed data: the spine at
c07f86fd7that 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.
|
Update: the licensed spine at
Aggregates only. |
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_apprenticefrom setting the apprentice minimum wage for later-year apprentices (PolicyEngine/policyengine-uk#2683).Summary
Adds
is_apprenticeto 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_drawsdraws the flag for employees aged 21 to 24 only. Employees are people whoseemployment_statusfromfrs_employmentis FT_EMPLOYED or PT_EMPLOYED. Everyone else keeps the engine default, False.apprentice_stock_19_to_24holds 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.is_apprentice) until their weight is nearest the target. This isuc_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.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.uk_stage_frs_person_draws_apprentices(stage_health, checkapprentice_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.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.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.employment_status. The stage refuses a frame without it rather than flag non-employees. It also refuses a frame that already storesis_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:
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 draw.The floors in the three
pyproject.tomlfiles 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_apprenticewith the walk to the target, its receipt and implied relief,apprentice_relief_schedulewith the engine guard, and the stage'scheckpoint_metadata.uk/take_up_contract.json:apprentice_stock_19_to_24, with its source and derivation.uk/spec/sources.yamlanduk/source_stages.json: theassign_binary_from_rateoperation 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, andemployment_statusandemployment_incomeas 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.jsonanduk_runtime/terminal_gates.py:person.is_apprenticejoins the export surface's allowed extra columns. Liketax_free_childcare_spend_routed_share, it is a genuine model input the incumbent does not carry.microcosm-frameadapters/policyengine_uk_concepts.py: the engine version, and the engine-defaults entry foris_in_first_year_of_apprenticeship.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-datacontract.pyand its tests: the gate in the battery map and the spine certification scope, plus the moved digests.tests/engine_free/uk/test_uk_apprentice_draw.py, including the node's reads, and the gate's tests intest_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:
ruff checkandruff format --checkare clean on the edited files, andtools/ci_test_plan.py verifyis 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) atab7c777e2, at the stage's weights, rounded:On that spine
uk_stage_frs_person_draws_apprenticesblocked 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 declareemployment_income.c07f86fd7declares it. The spine atc07f86fd7builds 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)
is_in_first_year_of_apprenticeshipstays 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.obr.ni_employerhas been a measured diagnostic, not a fitted target, since 16 October. Whatever the flag relieves comes straight off employer NICs.🤖 Generated with Claude Code