Skip to content

Fix Child Benefit opt-outs, rental flags and bus fare modelling - #2140

Merged
juaristi22 merged 5 commits into
mainfrom
fix/remaining-model-gaps
Oct 5, 2026
Merged

juaristi22 merged 5 commits into
mainfrom
fix/remaining-model-gaps

Conversation

@juaristi22

@juaristi22 juaristi22 commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #2065
Fixes #2064
Fixes #1852
Fixes #1871

Changes

  • Honour charge-driven Child Benefit opt-outs: flagged claimants remain opted out while the simulated charge recovers at least opt_out_charge_share of 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 independent would_claim_child_benefit gate: 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.
  • Recognise housing-association tenants in is_renting and share the tenure predicate with benunit_is_renting.
  • Keep the unimplemented Scottish Two Child Limit Payment off in every baseline year. Existing positive tests now explicitly enable the counterfactual; new cases verify zero baseline payments in 2025 and 2026.
  • Price supplied bus journeys at Person level and sum them to the household. Add the London/other-local-bus inputs, concessionary flag, single-ticket share, regional yields and boarding translations, dated national caps, representative uncapped fare and proportional fare/ridership controls. Move loaded household totals into bus_fare_spending_reported so 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 exposes opt_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

  • Reproduced the original Child Benefit, housing-association tenure and Scottish-payment failures before applying their fixes; journey tests failed before the new inputs and parameters existed.
  • uv run --no-sync make format: passed.
  • Latest claim-contract correction: 21 affected YAML cases, 25 Child Benefit Python tests and 32 Child Benefit/HICBC program YAML cases passed. Coverage includes false-claim/true-opt-out nonclaimants at £30k and £100k across baseline and four relief reforms, plus registered opt-outs, the finite £10m taper and warning-free cliffs.
  • All seven GitHub checks pass on 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

@vahid-ahmadi

Copy link
Copy Markdown
Collaborator

Automated review pass (Claude Code, high effort) — round 1 at 432bac11 (reviewed at c8d1f89a; 432bac11 changes only a changelog line and a docstring)

Verdict: nothing blocking. The Child Benefit opt-out, the rental flags and the Scottish payment are all right in the baseline. Two should-fixes: the opt-out ignores a reform that changes the taper rather than the start threshold, and the representative uncapped fare means the £3 cap never binds. Plus a wrong changelog line.

Locally at this head, run from the worktree (import path confirmed):

  • the new and touched YAML pass: opt_out, is_renting, bus_journeys, two_child_limit_payment, 28 cases;
  • test_bus_fare_reforms.py and test_child_benefit_opt_out.py pass, 13 tests;
  • 107 existing Child Benefit, HITC, bus, tenure, LHA and maintenance-loan YAML cases pass.

CI: lint, docs and the four install jobs are green; Test was still running.

1. Should-fix: the opt-out only responds to the charge's start threshold

child_benefit.py keeps an opted-out family's payment at zero whenever max(adjusted_net_income) > phase_out_start, unless CB_HITC is neutralised. A probe at £100k of income with two children, 2026 (entitlement £2,337.40):

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.md that 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_entitlement is unchanged for opted-out families.
    • CB_HITC reads 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 a Reform or on the system, restores payment.
  • Rental flags: is_renting and benunit_is_renting now share utils/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_spending moves to bus_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.

@vahid-ahmadi

Copy link
Copy Markdown
Collaborator

Automated review pass (Claude Code, high effort) — round 2 at 27973b9c

Verdict: nothing blocking. 27973b9c closes one case of my round-1 should-fix 1, but the general case is still open.

What 27973b9c fixes. child_benefit now computes the charge fraction itself, avoiding the child_benefit → CB_HITC → child_benefit cycle, so an infinite phase_out_end (charge abolished by parameter) brings opted-out families back. A test covers it. Checking is_neutralized still covers CB_HITC being neutralised. Both are correct.

Still open: round-1 should-fix 1, partial charges. charge_applies = charge_fraction > 0 (child_benefit.py:26), so any positive charge keeps the family opted out:

  • Raise phase_out_end to £10m with the start at £60,000, and a family at £100,000 has a charge fraction of 0.004. That's a £9.41 charge on £2,337.40 of Child Benefit, and the family still forgoes the whole benefit.
  • The same happens under any reform that only widens the taper, which is a plausible reform to score.

The suggestion from round 1 stands: keep the family opted out only while the charge takes back at least a set share, e.g. charge_fraction >= gov.hmrc.child_benefit.opt_out_charge_share (default 1, i.e. fully charged). That matches how microcosm#1089's child_benefit_take_up draws opt-outs (from fully charged families). A test with the taper end at £10m would pin it.

Nit, new: phase_out_end == phase_out_start (a reform that turns the taper into a cliff) divides by zero. For incomes above the start it gives inf > 0, so the family stays opted out, which is right. At or below the start it gives nan > 0, so the family is paid. The outcomes are right but numpy warns. where(hitc.phase_out_end > hitc.phase_out_start, ..., income > hitc.phase_out_start) would make it explicit.

Unchanged from round 1: should-fix 2 (the £3 bus fare cap never binds at the representative £2.748 fare) and nit 4 (bus_pass_eligible has no default). The PR description still says "include private renters" (nit 3; the changelog is fixed).

@juaristi22
juaristi22 marked this pull request as ready for review October 5, 2026 11:19
@juaristi22

Copy link
Copy Markdown
Collaborator Author

@vahid-ahmadi I’ve addressed both review rounds in 0e41c0b:

  • Partial charges: added gov.hmrc.child_benefit.opt_out_charge_share, defaulting to 1 as an explicit behavioural assumption. Your £100k/two-child case with a £10m taper endpoint now receives £2,337.40 and pays £9.41. Tests also cover threshold boundaries, zero charges and neutralisation.
  • Cliff reforms: payment and CB_HITC share a bounded, masked-division calculation. Zero or reversed taper widths charge fully only above the start, without division warnings.
  • Representative bus fare: used your documentation option. The parameter and bus documentation explicitly state that caps at or above £2.75, including £3, do not bind the default £2.748 fare, and explain person-specific fare overrides.
  • Pass eligibility: all three journey inputs now say to supply bus_pass_eligible; an omitted flag defaults to paying fares and the model does not infer statutory eligibility.
  • Rental wording: the changelog and PR description both identify housing-association tenants and the shared rental predicate.

Checking Microcosm #1089 also exposed a necessary data-interface fix: it exports registered opt-outs with would_claim_child_benefit=false and child_benefit_opts_out=true. The model now recognises claims through either flag, so those families can resume payment under reform; genuine nonclaimants with both flags false remain unpaid. Dataset regressions cover both groups. The docs explain that legacy independently drawn flags require producer-side normalisation, and that the default full-charge threshold does not reproduce Microcosm’s possible taper fallback.

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 0e41c0bcde5c162d4ecb3b1ac30b7586500a7f89.

@vahid-ahmadi

Copy link
Copy Markdown
Collaborator

Automated review pass (Claude Code, high effort) — round 3 at 0e41c0bc

Verdict: every round-2 item is closed, but the commit adds one should-fix that changes Enhanced FRS results. I'd approve once that's handled, either here or in a matching policyengine-uk-data change that lands first.

Status of earlier items

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: false and the flag set gets £2,337.40; at 27973b9c it 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_benefit here, and have Microcosm export opted-out families with would_claim_child_benefit = true, which is the cleaner contract;
  • or land a policyengine-uk-data change first that draws child_benefit_opts_out only 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.

@vahid-ahmadi

Copy link
Copy Markdown
Collaborator

Automated review pass (Claude Code, high effort) — round 4 at fdad84ca

Verdict: approve, once the Test job is green. The round-3 should-fix is fixed, and nothing else is open in this PR. One cross-repo follow-up is needed for Microcosm exports (below), but it doesn't block this PR.

Round-3 should-fix: closed

fdad84ca restores defined_for = "would_claim_child_benefit" and drops claims = would_claim | opts_out. An opt-out flag no longer creates a claim, and the docstrings of both flags now say they are independent. Probes at this head (one adult, one child under 5, 2026):

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_for blocks 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.

@juaristi22

Copy link
Copy Markdown
Collaborator Author

@vahid-ahmadi, thanks for confirming that all UK review items are closed, subject to the Test job passing. fdad84c restores the independent claim gate: an Enhanced FRS opt-out flag cannot create a claim. Dataset regressions at £30k and £100k keep genuine nonclaimants unpaid under baseline and charge-relief scenarios, while registered opt-outs retain the reform response.

The Microcosm companion addresses the producer follow-up. It exports registered claims when the installed UK model exposes opt_out_charge_share; older engines such as locked 2.100.0 retain the existing payment flags because they ignore the separate opt-out input. Receipts identify the contract used, and the synthetic fixture and coverage pins are refreshed.

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 fdad84ca3fd2a81774211e888dc81565a64d4b22.; The companion’s current-head CI is still running on 4d03a7a40fa746bc6139a1ef6ed8df9e3ccb3e3a; its fixture and manifest repairs pass focused local tests and independent verification..

@juaristi22
juaristi22 merged commit ea5282a into main Oct 5, 2026
7 checks passed
@juaristi22
juaristi22 deleted the fix/remaining-model-gaps branch October 5, 2026 12:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment