Fix Child Benefit opt-outs, rental flags and bus fare modelling - #2140
Conversation
Automated review pass (Claude Code, high effort) — round 1 at
|
| Case | child_benefit |
CB_HITC |
|---|---|---|
| opted out, baseline | 0 | 0 |
| opted out, start raised to £200k | 2,337.40 | 0 |
opted out, CB_HITC neutralised |
2,337.40 | 0 |
| opted out, taper end raised to £10m | 0 | 0 |
| not opted out, taper end raised to £10m | 2,337.40 | 9.41 |
So under a reform that softens the taper, the family gives up £2,337 to avoid a £9.41 charge. The flag describes a decision driven by the charge, so the gate should use the charge share the simulated policy would apply: the taper fraction clip((ANI − start) / (end − start), 0, 1), compared with a threshold.
Microcosm draws opt-outs only from fully charged families (#1089's child_benefit_take_up). So a parameter defaulting to 1, meaning "stays opted out while fully charged", matches the data. Reforms that lower the share then bring families back in proportion. The start-threshold and neutralisation cases keep working.
2. Should-fix: with the representative uncapped fare, the £3 cap never binds
uncapped_single_fare is £2.748, below £3. So annual_capped_fare gives (uncapped £2.748):
| Fiscal year | Annualised capped fare |
|---|---|
| 2024-25 | £2.195 (£2 to December, £3 after) |
| 2025-26 | £2.748, i.e. no saving |
| 2026-27 | £2.553 (the £2 cap from January) |
| 2027-28 | £2.196 |
That reconciles the £515.7m 2024-25 grant by construction, but it means the model scores the £3 cap at zero. A reform lowering it to £2.75 also scores almost nothing, because one representative fare has no tail above the cap.
The body says this is an aggregate assumption, which is fair. But a cap reform is exactly what this lever invites. Either:
- state on the parameter and in
docs/engineering/bus-fares.mdthat caps at or above £2.75 have no effect under the default, or - carry a small fare distribution (two or three points) reconciled to the same grant.
3. Nit: the #2064 changelog and the PR body describe the wrong fix
Fixed in 432bac11: the changelog now says housing-association tenants are recognised as renting and the two flags share one tenure predicate. The PR description still says "include private renters"; worth matching it.
4. Nit: bus_pass_eligible has no statutory default
It's an input, documented as "not a pass-eligibility calculator". So a household calculator that supplies journeys but not the flag prices concessionary travel at full yield. Examples: someone over state pension age in England, over 60 in Scotland, Wales or NI, or under 22 in Scotland. A one-line note on the journey inputs saying the flag must be supplied alongside them would prevent that.
Checked and correct
- Child Benefit:
child_benefit_entitlementis unchanged for opted-out families.CB_HITCreads the benefit received (child_benefit_hitc.py:14), so it follows to zero.- A family below the start threshold with the flag set still gets paid, consistent with the flag being driven by the charge.
- Neutralising
CB_HITC, whether through aReformor on the system, restores payment.
- Rental flags:
is_rentingandbenunit_is_rentingnow shareutils/tenure.py, and all three renting tenures read true. - Two Child Limit Payment: off in every baseline year, matching the Scottish Government's 3 December 2025 statement that the regulations would not be laid. The positive tests now switch the counterfactual on explicitly.
- £2 cap for 2027: matches the DfT written statement of 22 July 2026, "Single bus fares in England to be capped at £2 for 2027", replacing the £3 cap.
- Calendar dates: the cap dates are applied by day-weighting over 6 April to 5 April (
fiscal_year_segments), so the January changes split fiscal years correctly. - Legacy data: loaded
bus_fare_spendingmoves tobus_fare_spending_reported, which keeps the CPI uprating. Legacy data and Wales keep reported spending with the age allocation. With neutral indices, nothing changes on datasets without journey inputs.
Automated review pass (Claude Code, high effort) — round 2 at
|
|
@vahid-ahmadi I’ve addressed both review rounds in 0e41c0b:
Checking Microcosm #1089 also exposed a necessary data-interface fix: it exports registered opt-outs with Validation: 31 Python tests and 30 Child Benefit/HICBC YAML cases passed after formatting; lint and documentation build passed. All 7 GitHub checks are green on |
Automated review pass (Claude Code, high effort) — round 3 at
|
| Item | Status | Evidence |
|---|---|---|
| Partial-charge opt-out | Closed | New parameter gov.hmrc.child_benefit.opt_out_charge_share (default 1), so a family stays opted out only while fully charged. Probe: with the taper end at £10m, a £100k claimant who opted out now receives £2,337.40 and pays a £9.41 charge (round 2: £0). |
| Division by zero when end == start | Closed | utils/child_benefit.py uses np.divide(..., where=width > 0), which treats a zero-width taper as a cliff, and the result is clipped to [0, 1]. Probe with end = start = £60k: £70k gets £0 (opted out, fully charged) and £50k gets £2,337.40. CB_HITC uses the same helper. |
| £3 bus cap never binds | Closed as documented | The parameter description and docs/engineering/bus-fares.md now say caps at or above £2.75, including £3, have no effect at the default £2.748 fare, and that person-specific fares are needed above a cap. |
bus_pass_eligible default |
Closed as documented | The docs and the local_bus_trips docstring tell callers to supply the flag; a missing flag means paying fares. |
| PR body "include private renters" | Closed | The body no longer says it. |
New: should-fix
claims = would_claim_child_benefit | child_benefit_opts_out pays Child Benefit to Enhanced FRS non-claimants (child_benefit.py:22; defined_for removed).
The commit treats an opt-out flag as proof of a registered claim, which is true of Microcosm's export. The Enhanced FRS doesn't draw it that way. policyengine_uk_data/datasets/frs.py:1532-1534 sets child_benefit_opts_out = random < 0.23 for every benefit unit, independently of income and of would_claim_child_benefit (take-up 0.89). The flag was harmless before this PR, because nothing read it.
At this head:
- About 23% of the Enhanced FRS's non-claimants (roughly 2–3% of eligible benefit units) now receive Child Benefit, because almost all of them are below the charge. Probe: a £30k family with
would_claim_child_benefit: falseand the flag set gets £2,337.40; at27973b9cit got £0. That raises baseline Child Benefit on the Enhanced FRS, and moves poverty and every reform that reads it. - At the same time, 23% of fully charged claimants, drawn at random, stop being paid. That's this PR's intent, but on a flag that isn't conditioned on income.
The child_benefit_opts_out docstring says legacy data "must clear opt-outs for genuine nonclaimants before loading", but the Enhanced FRS doesn't do that today, and nothing enforces it. Any of these would fix it:
- keep
claims = would_claim_child_benefithere, and have Microcosm export opted-out families withwould_claim_child_benefit = true, which is the cleaner contract; - or land a policyengine-uk-data change first that draws
child_benefit_opts_outonly among claimants charged under the baseline, and pin that dataset version in this PR's release notes; - at minimum, add a test on a non-claimant with the flag, and a changelog line saying Enhanced FRS Child Benefit totals move.
Note, not a change request
With the default share of 1, a baseline family inside the taper who opted out is now paid. Probe: £70k gets £2,337.40 and pays £1,168.70. That's consistent with the parameter. Microcosm's draw takes fully charged families first but "can use the taper for any remaining opt-out target", and those taper families will be paid in the baseline. The parameter's description already says so.
Checks
test_child_benefit_opt_out.py: 20 passed.- The Child Benefit YAML folder: 22 passed.
- CI at this head is green: Test, Lint, docs and the four install jobs.
Automated review pass (Claude Code, high effort) — round 4 at
|
| Case | child_benefit |
CB_HITC |
|---|---|---|
| £30k, non-claimant, flag set | £0 (round 3: paid) | £0 |
| £30k, claimant, flag set | £1,406.60 | £0 |
| £100k, claimant, flag set, baseline | £0 | £0 |
| £100k, claimant, flag set, taper end £10m | £1,406.60 | £5.66 |
| £100k, non-claimant, flag set, taper end £10m | £0 | £0 |
| Cliff (end = start = £60k): £70k, claimant, flag set | £0 | £0 |
| Cliff: £50k, claimant, flag set | £1,406.60 | £0 |
So Enhanced FRS non-claimants with the randomly drawn flag (frs.py:1532-1534) stay unpaid, and the baseline jump from round 3 is gone. The flag now affects only Enhanced FRS claimants who are fully charged. For them it's the intended behaviour, though it's drawn at a flat 23% rather than by income; that's a data-side choice for policyengine-uk-data, not this PR.
Tests: test_child_benefit_opt_out.py (25) and the gov/hmrc/child_benefit YAML (24) pass locally against this worktree's package. Lint passes; Test was still running at the time of review.
Follow-up outside this PR: Microcosm's export
would_claim_child_benefit's docstring now says "Datasets must export claims rather than claims excluding opt-outs." Microcosm main doesn't yet. uk_runtime/child_benefit_take_up.py (line ~108) writes would_claim_child_benefit = claims and not opted out. On Microcosm data, then:
- Baseline: unchanged and correct. Opted-out families are unpaid either way.
- Reforms that lower or remove the charge: opted-out families stay unpaid, because
defined_forblocks them before the restoration logic runs. That's the case this PR sets out to fix.
The fix is a one-line change in that stage (export would_claim_child_benefit = claims, keep child_benefit_opts_out as drawn), plus regenerating the gate and coverage pins. It should land before anyone scores a HICBC reform on a Microcosm release built after this engine version. Worth an item on microcosm#1095.
|
@vahid-ahmadi, thanks for confirming that all UK review items are closed, subject to the The Microcosm companion addresses the producer follow-up. It exports registered claims when the installed UK model exposes The remaining engine upgrade and release/rebuild follow-up belongs in Microcosm #1095. Existing folded exports still require a producer rebuild or provenance-verified migration before scoring HICBC reforms with the corrected model. This code change does not rebuild or publish population data and does not close that wider tracker. Local UK validation: 25 Python tests, 21 targeted YAML cases, 32 program YAML cases and the docs build passed. Microcosm: 23 focused tests, 95 related tests and four dataset/capability tests against each old and corrected UK model passed; one existing Chronicle-feed test skipped. The affected fixture, 39-stage smoke, graph and 67 coverage-manifest checks also pass. Current-head CI: All seven UK checks now pass on |
Fixes #2065
Fixes #2064
Fixes #1852
Fixes #1871
Changes
opt_out_charge_shareof their entitlement (default 100%). Widening the taper, raising its start or neutralising the charge can restore payment. Handle a zero-width taper without division warnings. Preserve the independentwould_claim_child_benefitgate: an opt-out flag never turns a nonclaimant into a claimant, including with Enhanced FRS's independently drawn flags. Entitlement stays available independently of payment.is_rentingand share the tenure predicate withbenunit_is_renting.bus_fare_spending_reportedso stored data cannot mask fare reforms.Bus assumptions and scope
The 2024–25 price benchmark matches Microcosm #954: passenger receipts divided by fare-paying boardings, with a separate trips-to-boardings translation. Cap changes apply only to the single-ticket share in England outside London. The history includes the £3 cap through December 2026 and the announced £2 cap for January–December 2027; it assumes no extension beyond that date.
The £2.748 representative uncapped fare reconciles the £515.7m benchmark cap grant under a 70% single-ticket share, full pass-through and no demand response. Under this default, caps at or above £2.75, including £3, have no effect. It is an explicit aggregate assumption, not a recovered fare distribution; callers can supply person-specific uncapped fares. Journey callers must also supply
bus_pass_eligible: the model does not infer statutory eligibility, and an absent flag prices the passenger as fare-paying. Legacy inputs and Wales retain reported spending with the existing age allocation. The neutral index controls are not historical forecasts. This change does not introduce demand elasticity or feed bus-spending changes into consumption, disposable-income or subsidy totals.Bus-fare documentation records the source values, formulas, calendar treatment, regional proxies and limitations.
Child Benefit uses independent claim and opt-out decisions. Registered opt-outs must have
would_claim_child_benefit=true; a false claim flag always prevents payment. This preserves Enhanced FRS nonclaimants without changing their source flags. Older Microcosm exports folded opt-outs into the claim flag and require producer-side rebuilding or migration to recover registration; the model does not infer registration from ambiguous booleans. Companion Microcosm #1107 exports independent registered claims when the installed engine exposesopt_out_charge_share, preserves the folded-payment encoding for older engines, and records the encoding and model version in stage receipts. These code changes do not rebuild or migrate existing datasets. The default full-charge opt-out threshold is a behavioural assumption, not a statutory rule.Validation
uv run --no-sync make format: passed.fdad84ca3fd2a81774211e888dc81565a64d4b22, including the full test suite, documentation, lint and Python 3.11–3.14 install/smoke checks.uv run --no-sync make documentation: passed, including the bus page in the documentation navigation.Local environment: Python 3.13, policyengine-core 3.32.16. The repository lockfile is unchanged; CI also validates against the locked dependencies.
axiom: needed