Skip to content

Align Child Benefit claim exports and opt-out draws with the UK model - #1107

Merged
juaristi22 merged 5 commits into
mainfrom
fix/child-benefit-registered-claims
Oct 6, 2026
Merged

juaristi22 merged 5 commits into
mainfrom
fix/child-benefit-registered-claims

Conversation

@juaristi22

@juaristi22 juaristi22 commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Child Benefit payment flags previously erased registered opt-outs from the claim population, preventing charge-relief reforms from restoring their payments. This PR preserves registered claims when the installed engine supports opt-outs, complementing the independent claims gate merged in PolicyEngine/policyengine-uk#2140 for #2065. Older engines retain the existing payment-flag export.

It also addresses Vahid's latest review: the new contract's taper fallback could select families that the model still paid. The stage now reads and records the installed opt_out_charge_share, requiring a positive charge fraction at least that share. At the default share of 1, only fully charged families qualify; an insufficient pool produces an explicit weighted shortfall. Partial shares, zero-charge boundaries, shares above 1 and invalid values have regression coverage. Legacy pool selection, taper fallback and random streams remain unchanged. The module-docstring wrap is fixed, and canonical declarations, synthetic fixtures and dependent hashes are synchronized.

Validation

  • Full locked-engine UK group before formatting: 164 passed, without fail-fast; locked engine is UK 2.100.0/core 3.32.5.
  • Post-format affected reruns: 32 engine-free, 13 locked-engine and 13 opt-out-aware engine tests passed. The opt-out-aware checkout is UK fdad84ca3fd2a81774211e888dc81565a64d4b22/core 3.32.16; tests check synthetic exported-dataset paid families, children and cash at shares 1, 0.5, 0 and 1.1, plus charge-relief restoration.
  • Synthetic integration before formatting: 2 passed, covering 39 stages and 812 households with zero uploads. Formatting changed only tests/helpers; production code and contract pins remained identical. Test registry, scoped Ruff and whitespace checks passed.

Head: 9ee55c08815231a512b1b1cda601252d0005a260. Remote CI: All 10 checks passed on 9ee55c0 ([CI run](https://github.com/PolicyEngine/microcosm/actions/runs/37327907801))..

These synthetic checks supply matched adjusted net income directly. The production adapter calculates adjusted net income through a UK Microsimulation; the stage then takes its maximum over members who are not eligible children, while the UK child_benefit formula uses all members. Receipts audit draws before calibration. Population cash caseload and calibration fit therefore require measurement. This PR does not rebuild or publish population data or change dependency locks. Existing folded exports require a rebuild or an explicit migration before using the registered-claim contract.

Rollout checklist

Related follow-up: #1095.

  • Pin and record the reviewed engine revision and evaluated opt-out share.
  • Rebuild the Child Benefit stage and downstream data; migrate existing exports explicitly if rebuilding is deferred.
  • Report the eligible-pool shortage and any unmet opt-out target.
  • Measure before/after actual model-paid families, children and total Child Benefit cash on the rebuilt population.
  • Measure OBR fit separately and distinguish this change's effects from broader engine-upgrade changes before releasing data.

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

Automated review pass (Claude Code, high effort) — round 1 at 0e06c1a7

Verdict: the encoding switch is correct and well guarded. One should-fix: under the new contract the bound Child Benefit amount moves. Plus one note on timing.

Checked and correct:

  • Capability detection. "opt_out_charge_share" in parameters.gov.hmrc.child_benefit.children (child_benefit_take_up.py:268-270). The gov.hmrc.child_benefit node exists on 2.100.0 and on main, so older engines take the legacy path rather than raising. The receipt's claim_export records the encoding, the capability and the engine version string.
  • Exports. Under registered_claims, would_claim_child_benefit = claims on families with an eligible child, and child_benefit_opts_out is exported separately. Under legacy_payment it stays claims and not opted out. Benefit units with no eligible child keep their early draw in both. No flags are OR-ed, so the Enhanced FRS concern from policyengine-uk#2140 round 3 doesn't arise.
  • Draws. Claims and opt-outs use the same identity-keyed draws as before. Only the exported encoding changes.
  • Tests. test_uk_child_benefit_take_up.py (engine-free) passes 16 locally. I didn't run the engine-UK tests (no engine in my environment); the body reports them passing on 2.100.0 and on the #2140 checkout.

1. Should-fix: under registered_claims, the engine pays opted-out families inside the taper, which moves the bound obr.child_benefit amount.

  • With #2140's default opt_out_charge_share = 1, only fully charged opt-outs stay unpaid.
  • The stage fills its opt-out target from fully charged families first, but takes the remainder from inside the taper when there aren't enough. Those taper opt-outs are paid by the new engine.
  • So the baseline Child Benefit amount, which calibration binds through obr.child_benefit (uk_population_targets.json:2155), rises against the legacy encoding. The draw-level in_payment audit no longer measures what the engine pays. The doc says so, but nothing reports the size of the effect.
  • Before the first build on the new contract, either:
    • report the engine-paid families, children and amount beside the draw-level in_payment (one extra receipt line), and check obr.child_benefit still fits; or
    • draw opt-outs only among fully charged families when the engine supports the share parameter, so the draw and the engine agree.

2. Note (timing): the fix is dormant until the build's policyengine-uk pin includes #2140. #2140 is still open and unreleased, and the release builds on a locked 2.100.0, so every build keeps legacy_payment until the pin moves. That's fine and intended (the doc says so). It's worth a line on #1095, so that the engine bump that picks up #2140 also regenerates this stage and checks item 1.

Nit: the docstring's first paragraph now ends "income. This stage" mid-line after the removed clause. Rewrap it.

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

Automated review pass (Claude Code, high effort) — round 2 at 6a981144

Verdict: not yet. The two new commits only refresh hashes, so the round-1 should-fix is still open. It now matters sooner, because policyengine-uk#2140 is merged and released.

Item Status Evidence
Should-fix: taper opt-outs are paid under the new encoding Open assign_child_benefit_opt_outs (child_benefit_take_up.py:667-700) still fills the shortfall from the taper pool, whatever supports_opt_out says. Under registered_claims those families keep would_claim_child_benefit. With policyengine-uk's default opt_out_charge_share of 1, the engine pays them, because only fully charged families stay opted out. So once the encoding switches, the paid caseload rises by the taper pool's opted-out mass, and so does the amount obr.child_benefit calibrates against, while the receipt's "paid" count (claims & ~opt_out, :452) understates what the engine pays. Fix, either: draw only from fully_charged when supports_opt_out is true (recording the shortfall); or report the engine-paid caseload next to the stage count in the receipt and confirm the Child Benefit rows still fit.
Nit: docstring wrap Open The module docstring still breaks at "income. This stage" (:6).
New: refreshed hashes Fine 4d03a7a4 updates the coverage manifest's source_manifest_sha256 entries. 6a981144 updates the synthetic UK graph's Child Benefit contract hash in the parity fixture. Both follow from the stage change.

The capability check still matches policyengine-uk. #2140 merged on 5 October (fdad84ca) and ships in 2.121.0. policyengine_uk/parameters/gov/hmrc/child_benefit/opt_out_charge_share.yaml is the name this PR tests for ("opt_out_charge_share" in parameters.gov.hmrc.child_benefit.children, :268-269), and child_benefit again uses defined_for = "would_claim_child_benefit". That is the semantics registered_claims assumes.

Still dormant. uv.lock pins policyengine-uk 2.100.0, so releases keep the legacy encoding until the build moves to ≥ 2.121.0. That move would bring about twenty releases of benefit changes with it, so it belongs on #1095 with its own measurement arm. The taper fix above should land before it, so that the switch doesn't move the Child Benefit fit unannounced.

Ran locally at this head: test_uk_child_benefit_take_up.py and test_uk_release_input_coverage_manifest.py (31 passed). CI: integration-uk, lint, select-countries and wheels are green; engine-free and engine-uk/us were still running.

@juaristi22 juaristi22 changed the title Preserve registered Child Benefit claims in UK exports Align Child Benefit claim exports and opt-out draws with the UK model Oct 5, 2026
@juaristi22

juaristi22 commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator Author

@vahid-ahmadi Both points in your latest review are addressed in 9ee55c08815231a512b1b1cda601252d0005a260.

The opt-out draw follows your option (a), using the installed opt_out_charge_share: candidates need a positive charge fraction at least that share. With the default share of 1, the draw selects only fully charged families and records an explicit shortfall when that pool is insufficient. Partial shares, the zero-charge boundary, shares above 1 and invalid values are covered. Legacy selection, taper fallback and random streams are preserved. I also rewrapped the module docstring and synchronized the stage declarations, fixtures and dependent hashes.

Local validation passed the full UK group (164 tests) and synthetic integration (2 tests). After formatting tests/helpers, affected reruns passed 32 engine-free, 13 locked-engine and 13 opt-out-aware engine tests; production code and contract pins were unchanged. The synthetic dataset cases check actual model-paid families, children and cash at shares 1, 0.5, 0 and 1.1. Remote CI: All 10 checks passed on 9ee55c0 ([CI run](https://github.com/PolicyEngine/microcosm/actions/runs/37327907801))..

The registered-claim export complements the gate merged in UK#2140. I added the rollout checklist to this PR, linked to #1095: pin the engine, rebuild or explicitly migrate exports, report shortages, and measure population model-paid counts/cash and OBR fit separately from broader upgrade effects. Production adjusted net income comes from UK Microsimulation, but the stage's maximum excludes eligible children while child_benefit uses all members. Receipts precede calibration; synthetic tests supply matched income directly and do not establish population fit. No population data was rebuilt or published.

@juaristi22
juaristi22 marked this pull request as ready for review October 5, 2026 15:20

@vahid-ahmadi vahid-ahmadi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review pass (Claude Code, high effort) — round 3 (final) at 9ee55c08

Verdict: approve. The round-2 should-fix and the nit are both closed, CI is 10/10 green, and the one new point below is a note for the engine, not a change here.

Item Status Evidence
Opt-outs drawn from the taper under the new encoding Closed Under an opt-out-aware model, candidates now need children, a positive charge fraction, and a fraction of at least the installed opt_out_charge_share (child_benefit_take_up.py:686-700), which mirrors the engine's suppression rule. With the default share of 1 only fully charged families qualify, the taper pool is empty, and any gap is recorded as pool_shortfall_families / pool_exhausted. The receipt also records the share used.
Edge cases Covered test_new_opt_out_pool_matches_positive_charge_share_boundaries pins shares 1, 0.5, 0 and 1.1, including the charge-start boundary (share 0 still pays a family exactly at £60,000). test_opt_out_capability_requires_a_finite_numeric_share refuses None, NaN, inf, strings and booleans. test_new_contract_reports_shortfall_without_paid_taper_spillover checks that the shortfall is reported instead of spilling into paid taper families.
Legacy path unchanged Confirmed The new filter sits behind thresholds.supports_opt_out. The pool loop, rates and draws are as before, so the locked 2.100.0 build draws exactly as at 6a981144. The fixture, source-stage and spec changes are the rule and notes text only.
Docstring Closed The broken line at the top is rejoined, and the opt-out paragraph now describes both contracts.
Rollout checklist Present It's in the PR body, linked to #1095: pin the engine, rebuild or migrate the exports, report shortfalls, and measure paid counts and cash against the OBR row separately from the wider upgrade. Worth copying a one-line item onto #1095 itself so it's tracked there after merge.

Locally, the engine-free test_uk_child_benefit_take_up.py passes (32). This venv has no engine, so the locked-engine and opt-out-aware engine tests come from CI's engine-uk job, which is green.

On the income-maximum mismatch you flagged. The stage takes the highest adjusted net income over members who aren't eligible children (:431-435). policyengine-uk's child_benefit and CB_HITC take it over all benefit-unit members, so a qualifying young person's income counts there. The stage is the one that follows the law: the charge falls on the claimant or partner with the higher adjusted net income (ITEPA 2003 s.681B–681D), and a child's income never triggers it. The mismatch only bites for a qualifying young person in non-advanced education whose own adjusted net income is above £60,000, in which case the stage doesn't treat the family as charged, so it never opts out, while the engine charges it on the young person's income. That's vanishingly rare in the FRS, so it doesn't need a change here. A note in the docs, plus a policyengine-uk follow-up to read is_claimant_or_partner in both variables, would close it properly.

@juaristi22
juaristi22 merged commit 581b569 into main Oct 6, 2026
10 checks passed
juaristi22 added a commit that referenced this pull request Oct 7, 2026
The commits, where the plan changed during implementation (C5 reads the
child tab too; C6 puts one identity-keyed draw on the root stage; B1 mirrors
the engine's formula rather than handing the flag to it; B2 leaves the UC
take-up population to Max's branch; B4 is rebased on #1081, which merged
first), and the measurement: arm E (value-identical LCFS, 7/7 gates), arm
B0 (the engine bump empties two sparse UC payment bands; #1107's rollout
checks), arm B2 and the head arm, which also takes the West Midlands 12,570
to 15,000 income-tax cell past the 25% bound. Both diagnoses are recorded:
the regional bands leave out other investment income, and policyengine-uk
2.122.2's corrections move every award that supported the two UC bands. X
defers the cell and Y excludes the bands; replaying the target-fit gate on
the head arm's evidence then passes. Review round 1 on #1121: the rebase
onto #1081, the battery clock CI tripped on, and R1's access-fund evidence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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