Skip to content

UK: childcare take-up at the spring term, under-2 take-up and single-age targets behind the £100k childcare costing (policyengine-uk 2.147.2) - #1338

Draft
juaristi22 wants to merge 23 commits into
mainfrom
uk-childcare-100k-fixes
Draft

juaristi22 wants to merge 23 commits into
mainfrom
uk-childcare-100k-fixes

Conversation

@juaristi22

@juaristi22 juaristi22 commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Microcosm fixes from reconciling PolicyEngine's static cost of removing the £100,000 limit on England's working-parent hours against CenTax's (Removing the childcare cliff edge, September 2026), and from Vahid's two review rounds. The dashboard is PolicyEngine/uk-childcare-100k-threshold#8. Part of #1339.

Rebuilt on main 8b0224e (after #1333) and re-locked to policyengine-uk 2.147.2. This lands before #1344, which rebases onto it (María's ruling of 2026-10-22 on Vahid's finding B). The earlier history is replaced: the reverted SPI items 3 and 4 and their reverts are dropped, and the pin passes are regenerated once at the end.

What changes

  1. Nested childcare take-up (frs_take_up). would_claim_universal_childcare = extended OR u < r, where r is the rate among units not claiming the extended hours.
    • Independent draws let a family over £100k hold extended-yes and universal-no. Removing the limit then funded its 3-4-year-old's universal 15 hours too.
    • The take-up gate targets the nested marginal and fails on extended-without-universal.
    • The contract now refuses a nested rate whose receipt predates the conditional basis (fitter tool version 5).
  2. Copies re-draw their childcare draws (childcare_copy_take_up, a new stage after every copy stage). SPI, high-value-home and CGT copies re-draw the childcare flags and hours on their own benefit units instead of inheriting their host's.
  3. ONS single-year age targets. The UK's ages 3-6 and England's 0-6, from the mid-2025 estimates (published 1 October 2026).
  4. policyengine-uk 2.147.2 (Stop early years hours once a child starts school, and start working parent hours with the next term policyengine-uk#2570). Early-years hours stop when a child starts school, from its date of birth; working-parent hours start the term after the minimum age (9 months). Every re-derived pin, the concept coverage (311 to 314 inputs) and the cited versions follow. Other changes since 2.145.0 that move results:
  5. DfE rows at the spring term (Vahid's finding A).
    • DfE counts registrations in January by age on 31 December. Each DfE row now counts the children funded in the term that starts on 1 January of the model year, by that age (uk_runtime/dfe_spring_term.py, routed through the measure resolver).
    • It uses the engine's date-of-birth school start, not current_education.
    • The universal and early-learning measures count every child of the DfE age, as the term after the birthday does; the engine still gates these on the age on 6 October.
    • A child is counted under one DfE entitlement: early learning takes precedence at age 2, and at 3-4 a working-parent child is one that uses more than the universal 15 hours.
  6. DfE single-age rows (England, January 2025):
    • bound: working parent at 9 to 11 months and ages 1, 2 and 3, and universal nursery at age 3, beside the working parent 2-to-4, early learning and universal-only rows;
    • signed out: the age-4 cells, which the bound rows fix, plus totals, estimates and percentages, with the reasons in the accounting note;
    • the 9-to-11-months row has its own uprating hold, approved by María on 2026-10-22.
  7. Under-2 take-up, one rate per age (María's ruling: "bind them with their own take up").
    • Each child aged 0 or 1 in a claiming unit draws whether it uses the hours, at its age's rate. A non-user gets max_free_entitlement_hours_used 0; everyone else keeps the engine's 30.
    • DfE registers 44% of eligible 9-to-11-month-olds and 69% at age 1, against about 85% at 2-4. One rate fitted at 2-4, or one rate for both ages, funded the wrong number of them.
  8. Fitter tool version 6.
    • Scope: model year 2025, on the spring-term measures; six rates, the two under-2 shares included.
    • Receipt: it records each rate's estimand (Vahid's finding C: the Tax-Free Childcare rate is a fit to HMRC's two rows, not a take-up estimate for newly eligible families) and which rates sit at a bound and why.
    • Contract: it refuses a version-6 receipt without those.
    • Input guard: the fitter refuses a spine without stored birth months.
  9. A realised-take-up report (Vahid's item 3): tools/report_uk_childcare_realised_take_up.py. It gives each flag's contract rate beside its realised unweighted and weighted share at a built file's final weights. Cells under 10 source households read "<10".

The refit (tool version 6, local spine at 6aea1ec)

Rate Value
Tax-Free Childcare 0.9697
Working parent 0.8361
Universal, conditional (at its lower bound) 0
Early learning for 2-year-olds 0.6547
Under-2 usage, age 0 0.698
Under-2 usage, age 1 (at its upper bound) 1
Row, before calibration Model / DfE or HMRC
Working parent 2 to 4 1.011
Working parent 9 to 11 months 1.015
Working parent age 1 0.894
Early learning for 2-year-olds 1.000
Universal-only 1.065
HMRC TFC children / top-up 1.064 / 0.927
Diagnostic: working parent age 2 / age 3, nursery age 3 1.105 / 0.945, 1.021

The national calibration binds every row; the receipt records each residual.

Build

[Filled in after the signed national build: gates, fit, head-to-head against 6cba5d71 and a858c100, realised take-up, and the dashboard's 30-hours cost.]

For review

  • Census year. The rows bind January 2025. At model year 2025 the engine's spring term is January 2026, so the census is one birth cohort earlier. January 2026 is a follow-up (it needs a post-base-period fact).
  • Universal and 2-year-old start. The measure mirrors the term after the birthday. Universal hours and the 2-year-old offer start on the age at 6 October, not the term after the birthday policyengine-uk#2588 asks the engine to do the same.
  • Bound rows. The working parent 2-to-4 and universal-only rows stay bound, and the age-4 cells stay signed out because those rows fix them (María, 2026-10-22).
  • Unmodelled starts. The FRS records neither deferred school starts nor council-funded independent reception places, so the model undercounts 4-year-olds in nursery slightly.

Not in this PR

Tests

  • Engine-free: the touched tests pass, and tools/ci_test_plan.py verify is ok.
  • Engine: the spring-term measures are checked on hand-built households: date boundaries, claim-off families, the DfE-age-2 corner, a child outside England and a deferred start.
  • Pin pass: the compile-parity receipts, coverage manifest, H2 fixture and gate-battery pins are regenerated.

Disclosure control: the figures here are weighted national aggregates and public DfE, ONS and HMRC figures. Every fitted or diagnostic row rests on at least 10 FRS source households, and nothing rests on unit records.

🤖 Generated with Claude Code

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

@juaristi22 thanks, this is my review at 45b8f34f6.

Verdict. The six fixes are well aimed and the nesting fix (item 1) is clearly right. But this head should not be built as the release yet. The contract still carries the tool version 4 rates while the code now reads the universal rate as a conditional rate (finding 1). The pin pass is also still open (finding 2). Separately, the take-up that is supposed to fix the £100k cell is not conditioned on income anywhere in this diff. DfE and CenTax both point to take-up for newly eligible families being higher than our contract rate, not lower (finding 3). So I would not expect this PR to close the gap to CenTax through take-up. The gap more likely sits in who is eligible and what each child costs. Findings 1 and 2 must be fixed before the build. Everything else can follow.

What I ran (and what I didn't)

Findings

  1. must: the universal rate changes meaning before it is refitted. take_up_contract.json:294-306, frs_take_up.py nest_universal_childcare.

    • The code now draws universal = extended OR u < 0.4353.
    • The contract note says 0.4353 "was fitted as an unconditional rate (tool version 4) and is carried unchanged".
    • At r_e = 0.6579 the universal flag's share therefore jumps from 0.435 to 0.6579 + 0.3421 × 0.4353 = 0.807.
    • The signal gate cannot catch this, because _target_share now targets the nested marginal and so passes by construction.
    • A build at this head would overstate universal hours in the baseline and miss the DfE universal-only row. It would also move funded-hours spend (£8.80bn against DfE's £9.9bn on the 19 October release) for the wrong reason.
    • As your checklist says, this still has to land: please land the tool version 5 refit of all four rates on this branch, and add a test that fails if the receipt is version 4 while nested_under is set. The build should wait for both.
  2. must: finish the pin pass before the build. The coverage manifest, the SPI allocation reason in the support record, and the compile-parity receipts are stale (the four failures above). These are known, but they are release-blocking, and the "still to land" checklist in the PR body is still unticked.

  3. should, and it matters for what this PR can achieve: take-up is not conditioned on income, and the sources do not support a lower rate for families over £100k.

    • No change in the diff makes the extended rate depend on income. Every unit draws at one flat r_e.
    • So the drop in the over-£100k cell's would-claim (73.8% on the 19 October release) comes only from the copies' re-draw (item 5). It moves the realised share back towards r_e.
    • The refit then raises r_e, because reception children leave the DfE rows (item 2).
    • DfE's January 2026 release estimates that 91% of eligible 3-4-year-olds (388,700) and 80% of eligible 9-month-to-2-year-olds take up the working-parent entitlement.
    • CenTax's static cost also applies DfE's take-up rate by age to newly eligible parents (report, Appendix C, p. 75 and footnote 44). It does not use a lower rate.
    • Their roughly 60% "live account" share (Figure 2.1) is used only in the low-cost scenario. The 33% share at £100,000-£120,000 reflects current ineligibility, not demand.
    • If a later change lowers take-up for families over £100k to match CenTax, I don't think any source supports it, and it would go against CenTax's own method.
    • DfE notes that its eligible-population estimates should be treated with caution, particularly for 2026.
    • With a contract rate of about 0.66-0.75 against DfE's 80-91%, we already cost newly eligible families at a lower take-up than CenTax does. Yet we come out about 25% higher. That points to the eligible pool or the cost per child (finding 6), not take-up.
    • A low fitted r_e that still hits DfE's 388,700 registered children also suggests our eligible pool is larger than DfE's estimate. The test that tells these apart is the build's count of parents over £100k with a child aged 9 months to 4 and a working partner, against CenTax's "almost 200,000 potentially eligible parents" by 2030 (p. 41). Please put that count in the build receipt.
  4. should: there is no diagnostic for the concentration the SPI prior change is meant to fix.

    • The square-root shape was chosen on whole-calibration measures: rows at the weight-ratio cap, Kish ESS, and the standard error of the £125-150k band.
    • The only weight fence is global: uk_weight_ratio max/median ≤ 1151 (gates.json:823).
    • On the 19 October release, ten records carried 34% of the 30-hours cost, and the release still passed that fence.
    • Damping by GROSS4 should lower the prior of young-family copies, which the PR says are over-represented. That helps, but nothing here shows that it does.
    • Please add a receipt for the over-£100k, child-aged 0-4 cell: its Kish ESS, and the share of its weight held by its top ten source households (as a share, which is disclosure-safe). Also give the before and after numbers for the shapes you screened.
  5. should: the hours floor narrows a pool the dashboard's labour supply channel reads. spi_income.py:787.

    • The floor lifts drawn SPI pay to the twin's own hours at the minimum wage. That is right for the 30-hours and TFC minimum-income tests.
    • It also means fewer SPI-twin partners with positive hours fail the minimum-income test.
    • The dashboard's "partners into work" response (in the dashboard repo, not here) depends on how many families over £100k have a partner who fails that test. The floor will shrink that response.
    • Only a note for the release: please report the floor's added amount next to the HMRC SPI employment-income targets, so the size of the side effect on those targets is visible.
  6. should: the remaining eligibility drivers sit outside this PR, so please list them on UK: childcare take-up draws, the take-up fit pool, SPI-channel copy weights and the young-child age profile distort the £100k childcare costing #1339.

    • Reception children keep funded hours in policyengine-uk (listed under "Not in this PR"). Calibration now leaves them out of the DfE rows, but the reform still funds them. That inflates the 30-hours cost against CenTax, whose 3-4 base is DfE's, which excludes reception.
    • The only working-parent target is working_parent_children_2_to_4. The 9-23-month group, 241,500 children in DfE January 2026 (30,700 aged 9-11 months plus 210,800 aged one), is not bound. On the 19 October release we had 366k under-3s against DfE's 501,700.
    • Adding the under-2 row, at least as a diagnostic, is cheap. It would show whether one flat r_e fits both age groups, given that DfE's take-up rates differ (80% against 91%).
  7. nit: the copies' streams depend on the frame's id offset. childcare_copy_take_up.py:17-21.

    • The draws are keyed on source id + k × offset, where the offset sits above every id in the frame. They are deterministic for a given frame.
    • If the offset is computed from the frame's ids rather than fixed, any upstream change that moves the maximum id re-keys every copy's draws, which makes builds hard to compare.
    • Keying on (source benefit unit id, clone path, clone index) would make them stable. Fine as a follow-up.
  8. nit: the reason text for the age targets. The reason text for each new age row dates the request (2026-10-21) but not the data. The national 0_4/5_9 bands and the region 0_9 cells use the same ONS mid-2025 series, so the nesting is consistent. It would help to name the feed's release date in ons_single_year_ages_0_6, as the 0-2 rows do through UK local surface, consumption and high-value homes: remaining items (consolidation) #1278.

  9. should: merge order with Lock policyengine-uk 2.145.0, follow its bus fares and bind thin target rows #1333 and Draw UK QRF stages from uniforms keyed on each record's id, and tie pension contributions to pay #1332.

Expected effect on the 30-hours static cost

This can only be read as a direction, not a size:

  • Nesting (item 1): down. About £0.14bn a year, the PR's own figure.
  • Copies' re-draw (item 5): down. The over-£100k cell's realised take-up falls from about 74% towards r_e.
  • Refit without reception (item 2): up. It raises r_e for everyone, newly eligible families included.
  • Age targets (item 6): small and up. More 1- and 4-year-olds, fewer 5-year-olds.
  • SPI prior and pay floor: unclear. They depend on how the cell re-weights.

On balance I'd expect a fall of the order of £0.1-0.2bn, partly offset by the refit. I would not expect the result to reach CenTax's £0.80bn unless reception children are also removed from the reform cost.

What looks right

🤖 Generated with Claude Code

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

@juaristi22 thanks, this is my second review, at 3bb28f8f5.

Verdict. Both musts from round 1 are fixed: the four rates are refitted at tool version 5, and the pin pass is done (tests and CI green). I have no must this round. The branch merges cleanly with main, and on the a858c100 build it does what it set out to do: on CenTax's basis the dashboard now lands at £0.87bn / £0.96bn against CenTax's £0.80bn / £0.98bn. Two things should be settled before this becomes the promoted release, though neither blocks the merge. First, the universal rate now sits at its lower bound of 0, so universal take-up is exactly the extended draw. The fit is pushed there by a row that has the same 31 December age problem you used to sign out the age-4 cells (finding A). Second, a858c100 was built without #1332, which has since merged, and #1344 changes who is on UC. So the v5 rates and the receipts will need one more refit once the merge order is fixed (finding B).

Round 1 findings at this head

  1. Universal rate changed meaning before the refit: resolved. The v5 refit landed in 0b8e7ac39: extended 0.7594, targeted 0.8478, Tax-Free Childcare 0.9912, conditional universal 0. On the a858c100 file, would_claim_universal_childcare equals would_claim_extended_childcare for every benefit unit, as a rate of 0 should give. The guard test I asked for (fail if the receipt is pre-v5 while nested_under is set) is not there. Now only a nit.
  2. Pin pass: resolved. Receipts, coverage manifest, gate-battery pins and the H2 fixture are all regenerated. The four failures from round 1 now pass, and CI is green at this head.
  3. Take-up not conditioned on income: still true by design, but my round-1 point needs correcting. I compared DfE's 80-91% with the contract rate. I should have compared it with the realised, weighted rate. On a858c100, in the cell of families with a child under 5 and an adult with over £100k of pay plus self-employment income (a pay proxy, not ANI):
    • the extended draw is 0.75 unweighted, which matches the contract, so the copies' re-draw works;
    • weighted, it is 0.85, because calibration moves weight towards claimers (copies 0.88, natives 0.80; copies hold 59% of the cell's weight). The same lift shows outside the cell (0.81 for all families with a nursery-age child);
    • 0.85 sits inside DfE's 80-91%.
      So newly eligible families are now costed at about DfE's take-up, not below it. My round-1 claim that we cost them lower than CenTax does no longer holds. The useful follow-up is to record the realised weighted rate in the receipt next to the contract rate, because the weighted rate is what the reform cost actually uses.
  4. No concentration diagnostic: partly resolved, and moved. Item 3 is reverted, so this belongs on the uk-cc-spiprior PR. For the record, on a858c100 the cell above has a Kish ESS of about 130. Its ten largest source households hold 23% of its weight, from a few hundred source households in all. The dashboard reports that the ten largest records carry 27% of the 30-hours cost, down from 34%. That is better, but there is still no receipt in the build.
  5. Pay floor narrows the partners-into-work pool: moot here. Item 4 is reverted. Please carry it to the uk-cc-spipay PR.
  6. Eligibility drivers outside this PR: partly resolved. The DfE single ages bind working-parent ages 1, 2 and 3, which covers the under-2 row I asked for. Reception hours, 9-11-month-olds, DfE funding rates and ANI pension contributions are listed under "Not in this PR". Please confirm they are on UK: childcare take-up draws, the take-up fit pool, SPI-channel copy weights and the young-child age profile distort the £100k childcare costing #1339 or open as pe-uk issues. One caution on the dashboard check against DfE: the bound rows are January 2025, and the dashboard compares with January 2026 (379k vs 388.7k at 3-4, 438k vs 501.7k under 3). The under-3 gap partly reflects the September 2025 expansion, so it is not a failure of the fit.
  7. Copies' streams depend on the frame's id offset: open, nit. The docstring still keys on source id + k × offset, with the offset taken from the frame. Fine as a follow-up.
  8. Feed release date in the ONS reason text: open, nit. ons_single_year_ages_0_6 still names the mid-2025 series but not the feed's release date.
  9. Merge order: changed, see finding B.

What I got wrong in round 1. I expected the fixes to cut the 30-hours cost by about £0.1-0.2bn. As shipped it went from £1.01bn to £0.96bn, a fall of only £0.05bn, though the engine also moved from 2.132.6 to 2.134.4. The refit raised the extended rate from 0.66 to 0.76, and that offset more of the nesting gain than I allowed. My point that the remaining gap was reception, not take-up, did hold.

New findings

A. should: the universal fit is at a corner, driven by a row with the age-4 reference-date problem. take_up_contract.json (universal_childcare receipt), uk_population_targets.json (universal_only_children).

  • You signed out the age-4 DfE cells because DfE's ages are at 31 December, when about two-thirds of funded 4-year-olds are in reception. On a858c100, 52% of England's 4-year-olds are in primary. That is about what interview-date ages should give, so the model is not wrong here; the two bases differ.
  • universal_only_children (nursery universal at 3 and 4, less working parent at 3-4) and the working parent 2-to-4 row both count those same 4-year-olds outside primary. observation_basis: january_stock is only a label: nothing in the code moves ages to 31 December.
  • So the fitter is absorbing a basis mismatch. The universal rate goes to 0, universal-only still comes out 3.6% high and working parent 2-to-4 3.9% low, and calibration then fixes the rest through weights.
  • This matters for the reform, because 4-year-olds outside reception are the pool that gains hours when the limit goes.
  • Either re-reference the measure to 31 December ages, or treat universal_only_children like the age-4 cells, for example by binding the age-3 nursery row only. Then the universal rate is fitted on the same basis as the rows you kept.

B. should: refit once the merge order is fixed.

C. should: Tax-Free Childcare take-up near 1. The v5 rate is 0.9912. Weighted, it is 0.998 in the over-£100k cell. The two HMRC rows still cannot both be met: children with used accounts come out 11% high, top-up spend 14% low. So the rate is set by the spend row, not by an observed take-up. I've raised the effect on the reform's Tax-Free Childcare leg on the dashboard PR. Here I'd only ask that the receipt say plainly that the rate is not a take-up estimate for newly eligible families.

D. nit: the DfE single-age values. The four rows read the pinned feed, which I could not open, so I have not checked their values against DfE's January 2025 tables. The definitions and filters look right: reception left out from age 2, no primary filter at age 1, universal age 3 counting working-parent children.

What I ran (and what I didn't)

  • Fresh worktree at 3bb28f8f5, uv sync --all-packages --locked --extra uk.
  • Touched engine-free tests: 836 passed, 7 skipped. Engine tests for childcare targets, the take-up contract and SPI income: 23 passed. tools/ci_test_plan.py verify: ok. CI at this head: all ten checks green.
  • Merged with current main locally (clean). Engine-free suites on the merge: UK 5,949 passed and 2 failed, shared 4,110 passed, data 1,053 passed and 1 failed. All three failures come from my machine, not the PR. There is no node binary for the Orrery test, and an import-path error in one test_release.py test; both fail the same way on main alone. The third is the "engine unavailable" test, which fails because I installed the UK extra.
  • Read the a858c100 release file (sha256 checked) for weighted aggregates only: the nesting equality, realised take-up by copy and native, the cell's ESS and top-ten share, and the share of 4-year-olds in primary. No figure here rests on fewer than ten source households.
  • Not run: no build or refit (licensed data), and no dashboard rerun. The cost figures are from the dashboard PR.
  • Disclosure: this PR publishes national targets and metadata receipts only, with no small-area figures. The regenerated artefacts hold no record-level or small-cell counts.

What looks right

  • The v5 refit is honest about its corner: it keeps the bound and records the residuals instead of hiding them.
  • The copies' re-draw does what it says: unweighted draws in the high-income cell match the contract.
  • The age-4 sign-out reasoning is right and well documented. Finding A only asks for the same rule to be applied to the rows it touches.
  • Reverting items 3 and 4 for this build when they broke the property_purchases guard was the right call.

🤖 Generated with Claude Code

juaristi22 and others added 21 commits October 22, 2026 17:26
Item 6 of the £100k childcare costing fixes (#1278, #1095). With ages 0,
1 and 2 bound, calibration still moved the single years inside the 0-4
and 5-9 sex bands: on the pre-Budget combined build the UK held too many
5-year-olds and too few 4-year-olds, and England's 5-year-olds sat about
15% above ONS. England's funded childcare is costed on England's
children, so the contract now binds eleven more all-persons rows:

- ons.population.age_3 .. age_6, pinned to K02000001 like ages 0-2;
- ons.population.england_age_0 .. england_age_6, pinned to E92000001 by
  the generator's nation substring rule, counting persons in households
  with country == ENGLAND.

Each reads the ONS mid-2025 estimate by single year of age (MYE2
persons), the series the sex bands and the region 0-9 cells sum, and
counts age >= n and age < n + 1. The nested rows are consistent: the UK
rows for ages 0-4 sum to the two 0-4 sex bands, and England's rows sit
inside its nine region 0-9 cells. Every band stays bound. Wales,
Scotland and Northern Ireland get no single-year rows, because each
nation's single year rests on too few FRS households; the UK rows bind
their sum, and Scotland's age 0 keeps scotland_babies_under_1.

Registry accounting: eleven contract-only declarations. The contract has
432 targets (421), the registry scope 373 (362) and the unmapped
declarations 187 (176); the mapped targets stay at 186. The
compile-parity tool gains the rows' ledger-only rationale. The
references and membership are regenerated in the next commit, which
takes the active references from 1,322 to 1,333.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frs_take_up drew would_claim_universal_childcare and
would_claim_extended_childcare independently. policyengine-uk allocates a
working-parent child's first 15 hours to the universal entitlement when the
unit's own universal flag is false, so a family over the £100k limit drawn
extended-yes and universal-no gained universal hours only once the limit was
removed. The extended draw is unchanged; the universal flag is now
extended OR u < r, where r, the contract's universal_childcare rate, is the
rate among units not claiming the extended hours. The operation declares
nested_under (added to the assign_binary_from_rate schema) and the stage
refuses a manifest that drops it. The contract entry carries nested_under
and says the rate is conditional; its value is unchanged until the fitter
refits it. The take-up signal gate targets the nested marginal and fails on
any unit claiming the extended hours without the universal ones.

The flags were drawn before any support or CGT copy existed, so every copy
inherited its host's draws, and a cell with few hosts fixed the take-up of
all its copies. A new last spine stage, childcare_copy_take_up, re-draws
would_claim_tfc, the extended, universal (nested) and targeted flags and
maximum_extended_childcare_hours_usage for copy benefit units only (a
non-empty household clone path or a positive benunit support clone index),
through frs_take_up's derive_childcare_take_up at the same rates, seed and
salts, keyed on the copy's own benunit_id (source id plus a multiple of an
offset above every id, so unique and distinct from every native id). Native
units keep their draws; the anchored flags and would_claim_uc_childcare are
untouched. The graph declares an open surface and an isolation boundary for
it, as for the other take-up redraws; the kernel tables, spine registration,
fixture tool, roster pins (46 to 47 stages) and docs move with it. The
coverage manifest, compile-parity receipts and the H2 fixture are left for
the pin pass to regenerate.

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

The childcare fitter (tool version 5) fits universal_childcare on the nested
basis frs_take_up now draws: the universal-only row's expectation is
r_e * sum(with extended) + (1 - r_e) * r_u * sum(without extended), from the
existing forced-flag sides, with no allocated term; the targeted row keeps
its allocation side. The diagnostic draw helper nests the universal flag the
same way.

DfE's January registrations leave out children in reception, but the
working-parent 2-to-4 and universal-only rows counted them. The fitter's
basis and both calibration bindings (concept and policyengine filters) now
leave out a child whose current_education is PRIMARY, with the existing !=
predicate operator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
childcare_copy_take_up ran after private_school_attendance, which broke
test_the_stage_is_the_last_of_the_spine and left the stored attendance
imputed from the copies' inherited childcare flags: policyengine-uk's
attends_private_school ranks households on net income, and
household_benefits counts Tax-Free Childcare and the extended, universal
and targeted entitlements. The re-draw now runs after student_loans, still
after every copy stage, and private_school_attendance is the last spine
stage again. The stage-order tests, the acceptance-receipt roster helper,
the stage notes and the changelog follow. The fitter's basis docstring no
longer describes a universal allocated term, which tool version 5 dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
María's ruling (2026-10-21): bind every DfE funded-childcare count the
model can carry, even on few records, and sign out only those with a real
reason. The contract now binds seven England rows from the January 2025
census (Funded early education and childcare, headline figures):

- dfe.funded_childcare.working_parent_children_age_1 .. age_4: working
  parent registrations, is_child_receiving_extended_childcare at whole-year
  age n, outside primary education from age 2 (DfE leaves reception out);
- universal_nursery_children_age_3 and age_4: universal registrations in
  nursery, is_child_receiving_universal_childcare at age n outside primary
  education, working parent children included as in DfE's counts;
- universal_reception_children_age_4: every 4-year-old in primary
  education, no take-up flag, so the share of 4-year-olds in school is
  pinned.

The working parent 2 to 4 row stays bound beside the singles: it is the
take-up fitter's anchor, and its fact and measure are the sum of ages 2
to 4, so the rows are nested and consistent like the ONS single years
inside the sex bands. universal_only_children stays too: on the facts it
is the nursery ages 3 and 4 less the working parent ages 3 and 4.

The accounting note dfe_funded_childcare_single_ages signs out the rest
of the table with its reason: 9 to 11 months (policyengine-uk gives
whole-year age 0 no working parent hours), reception at age 3 (no FRS
3-year-old is in primary education), the totals and multi-age cells that
sum or copy bound cells, the universal all-provision cells (nursery plus
reception), DfE's eligible-children and all-children estimates (built
from population and survey-based inputs) and the registered percentages.

Registry accounting: seven contract-only declarations. The contract has
439 targets (432), the registry scope 380 (373) and the unmapped
declarations 194 (187); the mapped targets stay at 186. A new signed
in_year_snapshot hold covers the seven rows, and the compile-parity tool
gains their ledger-only rationale. The references and membership are
regenerated in the next commit (1,333 to 1,340 active references).

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

The fitter ran on a local spine built at 13307a6 (nested universal take-up, reception-age children out of the DfE rows, copies re-drawing their childcare draws; items 3 and 4 reverted), feed abf0921, model period 2024, the DfE 2-4 anchor at its 2025 vintage. Extended hours 0.7594 (0.6579 before), targeted 0.8478, Tax-Free Childcare 0.9912. The conditional universal rate stops at its lower bound of 0, so universal take-up is fully nested: the nested floor already exceeds DfE's universal-only count, because the model's interview-date ages hold more 4-year-olds outside reception than DfE's 31 December ages. Per the U2c ruling the bound is kept and the receipt records the residuals (universal-only 1.036, working parent 2-4 0.961); the national calibration binds the DfE rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…del's interview ages

María's ruling (2026-10-21): the three age-4 rows added with the DfE
single ages are signed out, not bound:
dfe.funded_childcare.working_parent_children_age_4,
universal_nursery_children_age_4 and universal_reception_children_age_4.
DfE's January census reports ages as at 31 December, when about
two-thirds of funded 4-year-olds are already in reception, while the
model's whole-year age is the age at interview spread over the survey
year, so fewer of its 4-year-olds are in primary education. Binding the
single age-4 cells would move weight between 4-year-olds in school and
in nursery because of the reference date, not the data. The working
parent 2 to 4 and universal-only rows stay bound and are unaffected.

Working parent ages 1, 2 and 3 and universal nursery age 3 stay bound.
The three rows leave the targets, scope_target_ids and unmapped
declarations; the accounting note dfe_funded_childcare_single_ages lists
them as signed-out cells with the reason above, and the kept rows'
declarations and binding notes no longer describe age-4 rows. The signed
in_year_snapshot hold María confirmed (approved_by juaristi22, approved_on
2026-10-21) now covers the four kept rows, and the compile-parity tool's
ledger-only rationale names the four, without the claim that they nest in
the 2 to 4 and universal-only rows.

Registry accounting: the contract has 436 targets (439), the registry
scope 377 (380) and the unmapped declarations 191 (194); the mapped
targets stay at 186. The references and membership were regenerated once
with tools/generate_uk_target_references.py against the same national
copy of the pinned feed abf0921 (source_fact_feed
chronicle-uk-abf0921-consumer-facts) as the previous regeneration: the
three references and their membership, geography-pin and uprating-hold
entries are gone, every other reference is byte-identical, and active
references go from 1,340 to 1,337.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fixture tool pinned 46 stages; childcare_copy_take_up makes 47.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A nested entry's rate is conditional on not holding the flag it is nested
under. The childcare fitter fits that rate from tool version 5; an earlier
receipt fitted the marginal rate. The loader now refuses a nested entry
whose receipt is older (Vahid's round-2 item 1 on #1338).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The UK lock moves from 2.145.0 to 2.147.2 (policyengine-core stays at
3.33.3). 2.147.2 stops the early years hours at school start by date of
birth and starts working-parent hours the term after the minimum age
(PolicyEngine/policyengine-uk#2570). The uprating pins' stamp, the concept
coverage (311 to 314 inputs), the coverage manifest, the cited versions and
APPROVED_UV_LOCK_SHA256 re-derive. The liquid-asset reason follows 2.147.0's
share_like_capital; the three new inputs stay at their defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DfE counts the children registered for each funded entitlement in its
January census, by age on 31 December. policyengine-uk's entitlements are
annual, and since 2.147.2 they stop at school start by date of birth and
start working-parent hours the term after the minimum age. Four person
measures (dfe_spring_term.py) classify each child at the spring term of the
model year: the DfE age, and spring-term receipt of working-parent hours,
universal hours and early learning for 2-year-olds. The universal and
2-year-old measures keep the engine's family and claim conditions but count
every child of the DfE age, as the term after the birthday does; the engine
still gates them on the age at 6 October. The resolver routes the measures,
refuses them as persisted columns, and an engine test checks them on
hand-built households (Vahid's round-2 finding A on #1338).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine allocates the universal and targeted hours from a child's gross
working-parent hours (extended_childcare_entitlement_total_per_child), not
from the net flag, so the claim condition now reads the gross hours. DfE
records each child under one entitlement: at DfE age 2 a child counted under
early learning for 2-year-olds is not counted again under working parents,
and at 3 and 4 a working-parent child is one that uses more than the
universal 15 hours, as the engine nets them. The engine tests add families
that claim only working-parent hours, the DfE-age-2 corners, the date
boundaries, a child outside England and one placed by age alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…re (tool version 6)

The fitter measures the DfE rows through dfe_spring_term_status at model
year 2025, whose spring term is the one DfE's January census counts, and
no longer reads current_education. It fits a fifth rate, the share of
children under 2 who use the hours their family's working-parent claim
gives them, against the working parent rows at 9 to 11 months and age 1:
each side's contributions from children under 2 are scaled by it, so every
row stays linear in the rates. Working parent ages 2 and 3 and universal
nursery age 3 are reported beside the fit, not fitted. The receipt records
the expectation basis, what each rate estimates (the Tax-Free Childcare
rate is a fit to HMRC's two rows, not a take-up estimate for newly eligible
families: Vahid's round-2 finding C), and which rates sit at a bound. The
fitter refuses a spine without stored birth months. The contract refuses a
version-6 receipt without its estimand, or one at its own bound without a
reason.

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

Every DfE row now reads the spring-term measures (dfe_spring_term.py)
instead of whole-year age and current_education (María's ruling of
2026-10-22 on Vahid's round-2 finding A). The working parent 9 to 11 months
row is bound, with its own in_year_snapshot hold approved by María on
2026-10-22; the age-4 cells stay signed out, as the bound 2 to 4 and
universal-only rows fix them. The accounting note states the basis, the
cohort offset of the January 2025 census, the under-2 take-up and each
sign-out's reason; the ONS single-year note names the mid-2025 release
date (Vahid's item 8). The target references and membership regenerate
against the pinned abf0921 feed: 1,338 active references, the 16 rows this
PR adds and no other change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two more tests pin the active reference count, which the 9 to 11 months row
takes from 1,337 to 1,338.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DfE registers far fewer of the youngest eligible children than of those
aged 2 to 4, so one family rate fitted at 2 to 4 funded too many of them
(María's ruling of 2026-10-22: "bind them with their own take up").
frs_take_up now draws, for each child of whole-year age 0 or 1 in a unit
claiming the working-parent hours, whether it uses them, at the contract's
extended_childcare_under_2_usage rate (provisional 0.75 until the v6
refit); a non-user gets max_free_entitlement_hours_used 0 and every other
person the engine's default of 30. childcare_copy_take_up re-draws the
copies' children on their own person ids against their own unit's flag.
The stage manifests, graph cells, export allow-lists, signal gate (share
over claiming children under 2), identity receipt and coverage manifest
follow. The fitter scales the under-2 share only on the claiming-world
sides.

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

The take-up signal gate's note now names the under-2 usage population, and
the export surface allows person.max_free_entitlement_hours_used, so the
gate policy, gates manifest, spec fingerprint and the release_cut part's
digests move; contract.py and its test mirror follow
(test_gate_battery_contract_pins recomputes them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Calibration moves weight toward the units the DfE and HMRC rows need, so the
take-up a costing uses can differ from the contract rate the spine drew at
(Vahid's round-2 review of #1338, item 3). The new engine-free tool reads a
built H5's frame and final weights and writes, for each childcare flag, the
contract rate beside the realised unweighted and weighted shares over units
with a child under 5 (all, natives and copies, and a pay-over-£100k cell),
and the under-2 usage share over claiming units' children under 2. Cells
under 10 source households read "<10". The receipt belongs in the run
tree, never in the take-up contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first v6 refit, with one under-2 usage share, left the working parent
rows at 9 to 11 months 16% over DfE and at age 1 24% under: DfE registers
44% of eligible 9 to 11 month olds against 69% at age 1, so one share
cannot carry both. The draw now uses the rate of the child's whole-year age
(extended_childcare_age_0_usage, extended_childcare_age_1_usage; provisional
0.51 and 0.81 until the refit), declared as one operation per age; the
fitter fits one share per age, each scaling its own age's contributions.
The take-up gate targets the children's weighted rate; its note, the gate
digests, the coverage manifest, the realised-take-up report and the tests
follow.

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

The fitter ran at model year 2025 on a local spine built from this branch
(6aea1ec; the fit overrides the take-up columns, the only ones the later
commits change), policyengine-uk 2.147.2, feed abf0921. Tax-Free Childcare
0.9697, working parent 0.8361, targeted 0.6547, universal 0 (at its lower
bound: the claiming families alone exceed DfE's universal-only count),
age-0 usage 0.698 and age-1 usage 1 (at its upper bound: the frame's
1-year-olds fall short of DfE's count at the 2-to-4 rate). The fitted rows
are within 2% of DfE apart from universal-only (1.065), age 1 (0.894) and
HMRC's two TFC rows (1.064 and 0.927), which calibration binds. Each
receipt records its estimand and, at a bound, its reason; every row it
carries rests on at least 10 FRS source households.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
policyengine-uk 2.146.1 applies Carer Support Payment's earnings limit
(PolicyEngine/policyengine-uk#2473), so the Carer's Allowance take-up
stage's reason for leaving Scotland closed (the engine did not apply the
limit) no longer holds. The stage still leaves Scotland closed, as before;
its text says the engine applies the limit and that opening Scotland is a
follow-up (microcosm#1319). The engine test now pins the limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22 and others added 2 commits October 23, 2026 01:05
…stage

The spine driver tests build a synthetic frs_take_up stage; it now declares the two per-age max_free_entitlement_hours_used operations the stage requires, and the person table is checked for the column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e fixture (pin pass)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@juaristi22
juaristi22 force-pushed the uk-childcare-100k-fixes branch from 3bb28f8 to 9427392 Compare October 23, 2026 00:06
@juaristi22 juaristi22 changed the title UK: fix the childcare take-up, SPI channel and age profile behind the £100k childcare costing UK: childcare take-up at the spring term, under-2 take-up and single-age targets behind the £100k childcare costing (policyengine-uk 2.147.2) Oct 23, 2026
@juaristi22

Copy link
Copy Markdown
Collaborator Author

@vahid-ahmadi Thanks for round 2. Everything is addressed on the rebuilt branch. It's on main 8b0224e and re-locked to policyengine-uk 2.147.2, which carries PolicyEngine/policyengine-uk#2570 (the issue you raised on #8 about reception and 9-11-month-olds). Could you review once more? The body has the full list; point by point:

A. The universal fit at a corner (the 31 December basis). The DfE rows are now measured at the spring term.

  • What a row counts. The children funded in the term starting 1 January of the model year, by their age on 31 December (dfe_spring_term.py).
  • School start comes from the engine's date-of-birth school start, not current_education, so the January stock label is now true of the measure.
  • The age-4 cells stay signed out because the bound 2-to-4 and universal-only rows fix them.
  • Two corrections from a critique pass:
    • a family that claims only working-parent hours is counted through the engine's gross allocation;
    • a 2-year-old counts under one DfE entitlement only.
  • The universal rate is still at 0. With nesting, the claiming families alone exceed DfE's universal-only count (ratio 1.065). The receipt now says so in at_bound_reason, and calibration binds the row.

B. Merge order. María ruled that #1338 lands first. #1344 then rebases onto it and re-pins to 2.147.2, and its session has agreed. The refit and pin pass are done on this head, so #1332 is in the input and #1344 refits after.

C. Tax-Free Childcare. The fitter's receipt now records an estimand for every rate. For Tax-Free Childcare it says the rate reproduces HMRC's two rows, trading one against the other, and is not a take-up estimate for newly eligible families.

Round-1 items:

  • 1. Guard. The contract refuses a nested rate fitted before tool version 5. It also refuses a version-6 receipt with no estimand, or at its own bound with no reason.
  • 3. Realised rate. tools/report_uk_childcare_realised_take_up.py writes each flag's contract rate beside its realised weighted share at the built file's final weights, including a pay-over-£100k cell. It reads "<10" under 10 source households. I'll post its result with the build.
  • 6. Eligibility drivers.
    • Reception and the 9-month start are in policyengine-uk 2.147.2 (#2570).
    • Pension relief in ANI (#2440), the 2029 salary-sacrifice cap (#2462) and DfE's funding rates (#2465) were already in 2.145.0.
    • On the January 2025 against January 2026 caution: the rows bind January 2025, one birth cohort before the engine's spring term. Moving to January 2026 is a follow-up.
  • 7. The copies' stream offset. Left as a follow-up, as you suggested.
  • 8. ONS release date. The note now names the mid-2025 estimates' release, 1 October 2026.

D. DfE single-age values. All bound values match DfE's published January 2025 figures exactly. The nursery cells come from the 2026 release's 2025 row; the 2025 release has no nursery column, and the four-child difference is hospital-school children with no year group.

New since round 2:

  • Under-2 take-up, one rate per age. One working-parent rate fitted at 2-4 overstated the youngest children (DfE registers 44% of eligible 9-11-month-olds and 69% at age 1). The refit gives age 0 a usage of 0.698; age 1 sits at 1, short of DfE by 11% before calibration.
  • The 9-to-11-months row is bound with its own approved hold.

The signed national build from this head is next. I'll post the gates, the fit and the costing here and on #8 when it lands.

🤖 Generated with Claude Code

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.

2 participants