Repository navigation
Start England childcare entitlements in the term after birthdays - #2593
Conversation
Aggregate impact on certified data: passBase Checks: 0 broken by this change, 1 failing on both sides, 0 fixed. Moves flagged for review: 35 (thresholds in Notes
Moves flagged for review
Spending by benefit and service
Caseloads
Government balance, each component counted once
Model totals
Household income totals
Mean household net income in each side's own deciles
Standard reform costings (change in gov_balance)
Standard reforms: change in each reform's target
Household net income by decileMean change in household net income per household, and as a share of the decile's base income; households stay in the base run's deciles.
No change in any year: receipts by tax head; population; poverty rates; people in poverty; inequality. Checks failing on both base and head (1)
Counted-once checkEach of 84 components was raised by £1 per unit in 2026-27, and every head of its totals was read household by household: its own head must rise by exactly £1, no other tax head may move, no other benefit may rise, and the model's own total must move by what its heads moved. A head that breaks a rule is measured again twice: with every other head held at its baseline value, and with every other head held at its value after the first £1 while the component rises again. It fails where either still shows the response. 0 failed; 0 were skipped. Provenance
Dataset: Receipts by tax head: head levels
Spending by benefit and service: head levels
Caseloads: head levels
Government balance, each component counted once: head levels
Model totals: head levels
Household income totals: head levels
Population: head levels
Poverty rates: head levels
People in poverty: head levels
Inequality: head levels
Mean household net income in each side's own deciles: head levels
Standard reform costings (change in gov_balance): head levels
Standard reforms: change in each reform's target: head levels
|
|
Aggregate probe provenance is fixed in |
|
Fixed the standalone aggregate-tool import regression by deferring the existing provenance imports until simulation operations. All 369 tests in the fresh combined compare/provenance selection passed, along with the unchanged selected outcome checker and Ruff. Resulting-head Linux CI still requires verification. |
vahid-ahmadi
left a comment
There was a problem hiding this comment.
@juaristi22 thanks, first-round review at db3761f0d. The term-after-birthday rule is right, the arithmetic in the new tests checks out, and CI is green. One should, on the PR body, so I'm leaving this as a comment rather than an approval.
What I checked
- Law. SI 2014/2147 reg 3(2)-(4) (legislation.gov.uk, current text) keys the start date to the birth period: born 1 January to 31 March, from 1 April; 1 April to 31 August, from 1 September; 1 September to 31 December, from 1 January, each following the relevant birthday. DfE statutory guidance valid from 1 April 2026 says the same for 2-year-olds (A1.2) and 3-year-olds (A1.7), keeps a 2-year-old place until the universal start (A1.4), and funds the first 15 hours under the 2-year-old offer where a child also has working-parent hours (A1.11). The model's test, birthday
<term start inutils/childcare.py:_scale_in_termanduniversal_childcare_terms, gives the same answer on every boundary: a birthday on 1 January, 1 April or 1 September waits a term, which is what the birth-period wording produces. Targeted provision running until the universal term, through the age scale's upper threshold, matches A1.4. - Funding year. Sampling the funding table once for the model year, including the following January term, is right rather than an approximation: DfE funding years run April to March.
- Test arithmetic, by hand: 190 x £6.61 = £1,255.90; 2 x 190 x £8.90 = £3,382; 570 x £6.61 = £3,767.70; 380 x £6.61 = £2,511.80; 190 x £8.90 = £1,691. All match the
birthday_terms.yamlexpectations. - Tests in a fresh venv at head:
policyengine-core test .../gov/dfe -c policyengine_uk310 passed;entitledto_scenarios.yaml3 passed; the four childcare Python modules plustests/aggregate_impact396 passed, 9 skipped. With the variables andsupplied_inputs.pyreset to the merge basea45baf5e4, 49 of the 310 dfe cases fail, including 7 of 10 in the universal and 6 of 10 in the targetedbirthday_terms.yaml, so the tests pin the change. - My own household checks, 2026, England, one earner on £30,000: a child with only
age: 2now gets £3,382 targeted (base £5,073), and a child with onlyage: 3gets £2,511.80 universal (base £3,767.70);age: 4is unchanged at £1,255.90. A 15 January 2023 birth gets all three universal terms, as reg 3(4)(a) gives. - CI at head: all checks pass, including Aggregate impact, all five Python shards, YAML and Fix evidence. The aggregate report on enhanced FRS 2024-25 shows universal spending down about £0.2bn a year, working-parent hours up about £0.23bn to £0.28bn, targeted up £22m to £49m, net about +£60m to +£86m a year, with universal caseload up about 89k. That direction fits the issue: children with October to December birthdays now get the spring term, and children with April to early October birthdays lose the terms before their start.
Fixes #2588,Reproducer:andchangelog.d/2588.fixed.mdare present.
What I did not read line by line: about 1,980 of the 2,272 added lines are tests (about 1,560) and the aggregate-impact guard (420). I read utils/childcare.py, every variable change and supplied_inputs.py in full, the provenance helpers and _restore in guard.py, and the universal birthday_terms.yaml. I skimmed the restructured counted_once loop and relied on the test run for test_aggregate_impact_childcare_provenance.py (463 lines) and test_childcare_birthday_terms.py (380 lines).
Findings
-
should: PR body,
axiom: needed. Same point as on #2595: the template keepsneededfor external contributors, and on a maintainer PR it leaves the parity step with no owner. Fix evidence accepts it, which is why CI passed. rulespec-uk has no encoding of SI 2014/2147 reg 3. TheAxiomFoundation/rulespec-uk#400 already queues reg 1(2)-(6) of the same SI, so adding reg 3(2)-(4) and guidance A1.4 and A1.11 there, or opening a newpe-parityissue, would let this readaxiom: <issue> queued. Companion cases can come straight from reg 3(4), for example a third birthday on 1 April 2026 funded from 1 September 2026. -
nit: household calculations that give only a whole age change. With the 6 April fallback birthday, every
age: 3child now gets two universal terms instead of three, and everyage: 2child two targeted terms instead of three (figures above). That follows correctly from the rule and the existing convention, and #2588 says the convention stays. But the changelog line reads as if only date-of-birth cases move. A clause such as "household situations that give only a whole age are taken to be born on 6 April, so a 3-year-old now gets two universal terms" would save someone a surprised bug report. -
nit: the legacy switch in
uses_legacy_childcare_inputsis whole-simulation: supplying any one of the five annual variables, for any person, sends every child back to the snapshot-age route. That is the natural reading of a set input, and the PR body says so, but a one-line note in the module docstring that it is all or nothing would help anyone who sets one of them in a reform.
Overlap and merge order: #2066 is in the merge queue and edits utils/aggregate_impact/guard.py and its test; this PR rewrites much of counted_once in the same file, so whichever lands second needs a real rebase, not a trivial one. The drafts #2620 (guard.py) and #2308 (supplied_inputs.py), and #2265 (supplied_inputs.py), touch the same helpers. On the Microcosm side, the spring-term mirror in uk_runtime/dfe_spring_term.py replaces the engine's age gate for universal and 2-year-old rows; once this merges it can read the engine instead, as #2588 says, as a follow-up there.
🤖 Generated with Claude Code
vahid-ahmadi
left a comment
There was a problem hiding this comment.
@juaristi22 second round at db3761f0d (no new commits since round 1). Approving.
Status of round 1
axiom: needed(was should): now a nit. Vahid has ruled that on your PRs this is a nit, so it no longer blocks. Pointing it at TheAxiomFoundation/rulespec-uk#400 later would still be nice.- Changelog wording for whole-age households: nit, unchanged, fine to leave.
- Docstring note that the legacy switch is all or nothing: nit, unchanged, fine to leave.
Correction to round 1: I said that whichever of this PR and #2066 lands second would need a real rebase in utils/aggregate_impact/guard.py. That was wrong. A trial merge of main (ab3a6a5d7) + #2066 (3a3800e24) + this PR is clean, and on that tree tests/aggregate_impact plus the four childcare Python modules give 408 passed, 9 skipped, and the dfe YAML tests 324 passed. This PR also merges cleanly on top of the rest of the current merge queue without #2066. So it can go in either order.
CI at head: all checks pass.
For whoever owns the childcare dashboard: once this merges its numbers move, mainly working-parent hours spending up about £0.23bn to £0.28bn a year on the CI aggregate report, net about +£60m to +£86m a year across the three entitlements.
🤖 Generated with Claude Code
Fixes #2588
Universal and targeted childcare awards now follow the term after the qualifying birthday, using the existing date-of-birth and age input contract. Terms start on 1 April, 1 September and 1 January; a birthday on a term's first day waits until the following term. This includes children who reach the qualifying age after the 6 October snapshot. Baseline targeted provision continues through the term before universal provision begins, so both annual awards can be positive in different terms.
A shared internal term calculation allocates legally available base hours first within working-parent provision, once in the same funded terms. It also keeps the receiving flags consistent with positive awards. Universal and targeted age parameters remain independently reformable; raising one does not extend the other across an arbitrary reform-created gap. Existing England, family eligibility, take-up, school/deferred-start and upper-age guards remain.
The model retains three equally weighted terms, its existing annual hours/rollout schedule and the existing national-average funding table sampled once for the model year, including the following January. All funded hours in a common term use the same qualifying age-band rate. These are annual valuation approximations, not provider fees or actual term lengths. The age-only whole-age fallback remains a 6 April birthday; existing 6 October date validation and data birth-month behavior are preserved.
Explicit inputs for any of these five existing annual variables retain the complete original eligibility and valuation route:
early_years_term_share_before_schoolextended_childcare_entitlement_term_shareuniversal_childcare_entitlementtargeted_childcare_entitlementextended_childcare_entitlement_total_per_childThose shares and amounts are authoritative but do not identify individual terms. Calculated caches and model-written values do not become supplied inputs. No new public scalar term-share input is introduced. Annual family eligibility does not reconstruct a child's prior take-up history. Microcosm PR #1338 remains separate producer/consumer and private-data adoption work; this PR does not claim that adoption or a private-data retrofit is complete.
The source basis is the DfE publication record, its statutory guidance valid from 1 April 2026, especially A1.2, A1.4, A1.7 and A1.11, and SI 2014/2147 regulation 3.
Validation:
policyengine-core test ... -c policyengine_ukcommand. Their explicit synthetic 31 March birthdays preserve their documented full-year universal assumptions; the EntitledTo file remains a PolicyEngine regression comparison, not an externally verified EntitledTo match. All 265 other affected YAML cases retain qualified unchanged-source/runtime pass evidence. The 23 calls overlap 21 earlier passes and are not added to the earlier 288-case batch.uv run --frozen --offline --no-sync make formatpassed Ruff formatting and lint. Formatting preserved all Python ASTs and every YAML/non-Python byte.make test/make test-ci, private survey-wide validation, encoded parity and deployed/consumer acceptance were not run. Actual current-head Linux CI remains required.Reproducer: policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/birthday_terms.yaml; policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/birthday_terms.yaml; policyengine_uk/tests/test_childcare_birthday_receiving.py::test_receiving_follows_a_positive_native_birthday_term
axiom: needed
Follow-up validation: aggregate probes now preserve genuine and model-owned registered inputs, including inherited placement and cleanup after partial writes. All 236 selected Python tests passed, including 42 new controls, and the unchanged selected outcome checker passed. Formatting preserved every Python AST and non-Python source byte. The local runtime witness includes an independently qualified empty Core template storage directory. New-head CI remains pending.
Import-isolation follow-up: the standalone aggregate tool now loads the existing input-provenance registry and helpers only during simulation operations, preserving its pre-construction model fingerprint. One fresh combined selection passed all 369 Python tests, including the complete compare module and prior provenance controls, with the unchanged selected outcome checker passing. The original import-isolation assertion and all policy formulas, guard accounting rules and input-origin semantics remain unchanged. Formatting and lint passed; resulting-head Linux CI still requires verification.