Skip to content

Reconcile UC deduction and benefit cap components under the protected floor - #1819

Open
vahid-ahmadi wants to merge 4 commits into
mainfrom
fix-uc-deductions-floor-reconciliation
Open

vahid-ahmadi wants to merge 4 commits into
mainfrom
fix-uc-deductions-floor-reconciliation

Conversation

@vahid-ahmadi

@vahid-ahmadi vahid-ahmadi commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes bugs in the UC deductions and protected floor code merged in #1815. Current-law results are unchanged to the bit (checked on enhanced FRS 2026, below); the fixes change how a protected-floor reform is reported, and stop a floor above 1 adding money.

Reproducer: policyengine_uk/tests/test_uc_deductions.py::TestReformLevers::test_floor_components_reconcile_with_the_reduction_applied, policyengine_uk/tests/test_uc_deductions.py::TestReformLevers::test_applied_cap_never_exceeds_the_award_lost, policyengine_uk/tests/test_uc_deductions.py::TestReformLevers::test_heavy_capping_does_not_squeeze_the_deduction, policyengine_uk/tests/test_uc_deductions.py::TestReformLevers::test_floor_changes_nothing_where_it_does_not_bind, policyengine_uk/tests/test_uc_deductions.py::TestReformLevers::test_a_floor_above_the_standard_allowance_adds_nothing, policyengine_uk/tests/test_uc_deductions.py::TestReformLevers::test_no_deductions_without_a_universal_credit_claim

axiom: n/a: current-law UC awards are unchanged (identical to main on enhanced FRS 2026); the operational deductions caps and the protected floor have no rulespec-uk module; the reg 81 amount in uk/regulations/uksi/2013/376/81.yaml is untouched

What was wrong

  1. Components did not reconcile under the floor. universal_credit limited the sum of benefit_cap_reduction and uc_deductions to the floor allowance, but neither component was rewritten. With floor 0.85, a £1,000 gross cap reduction and latent rate 0.30, they reported £960.34 and £1,000 against £720.25 actually taken.
  2. The applied cap could exceed the award lost. With pre-cap UC £500 and a £1,000 gross cap reduction, the applied cap was £720.25 under a floor (and would be £1,000 without one) against £500 actually lost.
  3. Heavy capping squeezed the deduction. Deductions were limited by the penny rule against the gross cap reduction, so on £6,000 pre-cap UC with a £5,500 or £5,900 gross cap they came out at £499.88 or £99.88, with the cap taking £220.37 or £620.37, although the floor removes most of that cap.
  4. A floor above 1 added money: (1 - floor) x standard allowance went negative, so protected_floor = 1.2 turned a £6,000 entitlement into £6,960.34.
  5. uc_deductions had no would_claim_uc guard, so a directly imputed latent rate gave deductions with no claim.

The fix

One helper, deductions/_reduction_allocation.py::allocate_uc_reductions, returns both components. uc_deductions and the new uc_benefit_cap_reduction each call it, and neither reads the other, so the graph stays acyclic. Its inputs are pre-cap UC, the gross benefit_cap_reduction, the raw deduction demand (rate x standard allowance), the floor allowance uc_maximum_reduction and the annualised penny.

  • Current law (no floor): regulation 81 of SI 2013/376 reduces the award by the cap, then Schedule 6 paragraph 3(1)(a) of SI 2013/380 stops deductions taking the amount payable below a penny. The helper does exactly main's operations, in main's order. The applied cap is the gross reduction bounded by the award.
  • Floor in force but not binding (the current-law split fits in the allowance): every value is the current-law one.
  • Floor binding: deductions are met first from the protected award, up to the allowance and leaving a penny payable, and the cap reduction absorbs whatever allowance is left, bounded by the award.

universal_credit subtracts (uc_benefit_cap_reduction + uc_deductions), which keeps main's float order. benefit_cap_reduction stays gross because Housing Benefit reads it and the UC floor does not protect HB. uc_has_deduction still reads the gross figure, so deduction incidence is unchanged.

Why deductions come first

JRF fixes the combined limit, not a priority. Their September 2026 briefing (which replaces the October 2025 one) proposes that "the sum of the 'capped amount' and any debt deductions is limited to the difference between a household's standard allowance and the level of the protected minimum floor". So the order is a modelling choice. Debt first is the one that:

  • matches the Fair Repayment Rate: the 15% deductions cap equals the allowance an 85% floor leaves, so cappable deductions never breach the floor on their own, and JRF frames the floor as extending that rate "to all deductions, including the benefit cap";
  • reproduces JRF's example (standard allowance £98 a week, a £15 deduction, an £80 cap reduction, total limited to £15), pinned by test_jrf_2026_example_keeps_a_full_rate_deduction;
  • does not depend on how heavily the household is capped. If deductions were limited by the gross cap, an award capped to a few pence would lose almost all its deduction even though the floor removes most of that cap.

The award is the same under either order; only the split differs.

Still a modelling choice: where deductions alone exceed the allowance, the floor binds on the above-cap excess too (last resort and child maintenance deductions), which current law exempts from the deductions cap. JRF does not say whether their floor exempts those. This is documented in protected_floor.yaml, uc_deductions.py and the validation page, and pinned by test_protected_floor_binds_on_the_above_cap_excess.

Parameters and references

  • deductions/cap.yaml: the 25% cap now starts on 12 April 2021, per DWP Annual Report and Accounts 2020-21, p.57 ("reduced from 30% to 25% from 12 April 2021"), now cited. There's no annual effect, because fiscal-year parameters are sampled on 30 April.
  • deductions/protected_floor.yaml: the JRF reference now points to the September 2026 briefing.
  • deductions/minimum_payable.yaml and uc_deductions.py: the one-penny rule is Schedule 6 paragraph 3(1)(a) of SI 2013/380.
  • The validation page states that the at-cap and above-cap shares are shares of all UC households. The figures are unchanged.

2026 impact (enhanced FRS 2024-25, policyengine-uk-data-private snapshot 47bbb58d, main e01f2f5ec vs this branch)

main branch
Baseline: UC / deductions / gross cap / HB / household net income £83.649bn / £1.939bn / £1.171bn / £8.482bn / £1,709.288bn identical; max absolute per-unit difference 0 on every variable
Floor 0.85: UC £84.710bn £84.710bn (identical per unit)
Floor 0.85: HB, household net income £8.482bn, £1,710.349bn identical
Floor 0.85: uc_deductions £1.939bn (not floored, so over-reported) £1.925bn (-£14.5m; 117 benefit units, 102,511 weighted, whose deductions alone exceed the allowance)
Floor 0.85: cap applied to UC not reported; gross £1.171bn uc_benefit_cap_reduction £0.125bn
Floor 0.85: reconciliation residual components over-report the loss by £1.061bn 0

The floor raises UC by £1.061bn for 330,093 benefit units (weighted) on both main and this branch. No unit in the 2026 data has a gross cap above its pre-cap award or a deduction squeezed by heavy capping, so fixes 2 and 3 move no totals there; they are covered by unit tests.

Tests

  • test_uc_deductions.py: 23 passed (10 added by this PR). The tests for fixes 2 and 3, and the not-binding test, failed on ed1154de7 before this round.
  • UC YAML (tests/policy/baseline/finance/benefit/family/universal_credit): 414 passed. Benefit cap YAML: 24 passed.
  • Full YAML suite (policyengine-core test policyengine_uk/tests/policy): 3,058 passed.
  • pytest policyengine_uk/tests -m "not microsimulation" (excluding tests/policy): 5,406 passed, 70 skipped, 2 xfailed.
  • ruff format --check and ruff check: clean. .github/check_fix_pr.py: passes.

Coordination

#2089 (open) adds a variable also called uc_benefit_cap_reduction, the reg 81 amount. Whichever lands second should rename. The suggestion on this thread is to keep #2089's name for the reg 81 amount and rename this one uc_benefit_cap_reduction_after_floor. Suggested landing order: #1819, #1820, #2076.

🤖 Generated with Claude Code

@vahid-ahmadi

Copy link
Copy Markdown
Collaborator Author

Verified the five fixes independently against the branch — all confirmed:

  • Current law bit-identical: unreformed repro returns uc_deductions=960.34, universal_credit=4,039.66, unchanged.
  • Reconciliation holds: floor 0.85 with gross cap reduction £1,000 gives 720.25 + 0.00 = 720.25 applied.
  • Floor > 1 clamped: returns £6,000, not £6,960.34.
  • No-claim guard: uc_deductions = 0.00 (was £720.25).
  • JRF worked example reproduces: SA £92.34/wk against their £92, allowance £13.85 against their £14, deduction survives whole, cap reduction absorbed to zero. That is a good independent check of the absorption ordering.

One residual worth documenting (not blocking)

uc_deductions still derives its available award from the gross benefit_cap_reduction, so where the gross reduction nearly exhausts the award the one-penny constraint bites before the floor allowance does, and the split moves:

floor 0.85, allowance = 15% of SA = 720.25, entitlement 6,000

  gross bcr    deductions   cap applied      UC     total reduction
      1,000        720.25          0.00   5,279.75         720.25
      4,000        720.25          0.00   5,279.75         720.25
      5,500        499.88        220.37   5,279.75         720.25
      5,900         99.88        620.37   5,279.75         720.25

The award is correct in every row and the components reconcile — this is strictly an attribution issue between the two components, not a UC error, so it is much smaller than the bug this PR fixes. But under the absorption ordering the deduction should survive whole at 720.25 throughout, and in the last two rows it does not.

It bites only when entitlement − gross benefit cap reduction < (1 − floor) × standard allowance, which is rare — but it is exactly the heavily-capped population that JRF-style floor analysis targets, and it would understate reported deductions there. A fix would compute the award in uc_deductions from the floor-limited cap reduction, though that risks reintroducing the cycle this PR carefully avoided.

Suggest either fixing it or noting it in the docs alongside the existing last-resort caveat.

@vahid-ahmadi

Copy link
Copy Markdown
Collaborator Author

@MaxGhenis review request — this fixes bugs in the deductions work merged in #1815.

The judgement call is the absorption ordering. The floor limits combined reductions; something has to give first. This PR makes the benefit cap reduction absorb the floor while the deduction survives, on the reasoning that under the FRR the deductions cap (15%) equals a typical floor allowance, so cappable deductions alone essentially never breach it. JRF's worked example supports this — their floor allowance is GBP 14/week and their deduction is GBP 14, so it survives whole and the GBP 59 cap reduction goes. The model reproduces that exactly (SA GBP 92.34/wk vs their GBP 92, allowance GBP 13.85 vs their GBP 14).

If you read their design differently — proportional abatement, say — that changes the split though not the award.

Three things worth your eye:

  1. benefit_cap_reduction is deliberately left gross. Flooring it in place would leak a UC reform into Housing Benefit, which the floor does not protect, and would create a cycle through the one-penny test. The new uc_benefit_cap_reduction is UC-only.
  2. A residual I found and documented (comment above): where the gross cap reduction nearly exhausts the award, the penny constraint bites before the floor allowance and the split moves, so a deduction that should survive whole does not. The award is right in every case and components reconcile — it is attribution only — but it affects exactly the heavily-capped population JRF-style analysis targets. Fix-or-document, your call.
  3. Whether the above-cap last-resort excess should be exempt from the floor. Unchanged behaviour here, documented in the parameter, the variable and the docs. JRF's briefing does not settle it.

Current law is bit-identical (verified numerically), and the gated Test job passes, so the validated statistics in #1815 are intact.

@MaxGhenis

Copy link
Copy Markdown
Collaborator

Heads-up from #2089 (each scheme its own benefit cap), stacked on #2084. #2089 adds policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_reduction.py, the UC Regs 2013 reg 81 reduction: the excess less the childcare costs element. universal_credit, uc_deductions and uc_has_deduction read it.

This PR adds a variable of the same name (the cap reduction left after the protected floor), so whichever lands second should rename one. Suggestion: keep #2089's as the reg 81 amount, and call this PR's uc_benefit_cap_reduction_after_floor, reading uc_benefit_cap_reduction.

@juaristi22

Copy link
Copy Markdown
Collaborator

Review of #1819 — Reconcile UC deduction and benefit cap components under the protected floor

Base repository: PolicyEngine/policyengine-uk
PR number: 1819
Reviewed head SHA: d287b18
Merge base SHA: 3d12d26
Mode: full
Scope: changed behavior and affected dependencies
Source evidence: Official sources are linked below; local source copies and validation artifacts were retained.
Review status: PARTIAL

Source Documents

Critical

None.

Should Address

A1 — Give the changelog fragment a configured type (OPEN)

Severity: SHOULD ADDRESS. Trigger: building the release notes with the repository's configured towncrier. Expected: this PR's five changes appear under its configured fixed/added types. Observed: new changelog.d/uc-deductions-floor-reconciliation.md:1 has no type segment and is omitted entirely from an executed python -m towncrier build --draft --version 0.0; draft output (current-pr-1819-towncrier-draft.log; retained locally) contains no reconciliation/new UC cap component entry. pyproject.toml configures breaking/added/changed/fixed/removed types. Name the fragment with a recognized segment, such as .fixed.md. This is introduced by the new file and affects release documentation, not household calculations.

Validation: one batched run of changed test_uc_deductions.py, cap scenario regression and directly relevant UC/HB YAML directories: 179 passed, pytest14.03s, bounded CLI17.46s (log (current-pr-1819-tests.log; retained locally)). Imports resolved to the exact snapshot; no dependencies installed. Code traces show YEAR benunit quantities, vectorized headroom/floors, would_claim_uc guards and no new cycle. The gross cap continues to feed HB independently.

Actual-input diagnostics use a couple aged35/33, four children aged13/11/9/7, London HA rent£30,000, zero earnings/assets, and an observed latent deduction demand0.30/ADVANCE_ONLY; UC eligibility and cap are computed. At floor0/.85/1.2, final UC is £19,769.96/£47,502.06/£48,632.64. Deduction plus applied UC cap component equals the actual lost entitlement in all three cases, and household net income changes consistently. Gross benefit cap reduction remains£27,355.24; the >1 floor adds no award above pre-cap entitlement. Diagnostic log (current-pr-1819-household-diagnostics.log; retained locally).

An actual legacy HB claimant variant supplies reported HB receipt and a no-UC-claim take-up choice; its HB eligibility is computed. The .85 UC floor leaves HB£21,277.40 and net income£25,148.45 exactly unchanged, with both UC components0. HB diagnostic (current-pr-1819-hb-diagnostic.log; retained locally). The claim choice is an input status, not forced eligibility.

Material gap: the separately requested end-to-end case with a reported UC award below the protected floor was not completed before the shared17-minute substantive deadline. The low-award/minimum-payable unit/YAML tests pass, but they do not establish that additional reported-award scenario under an active floor. No incorrect result is inferred from this missing case. Source dates/historical JRF quotation are owned by the policy role.

Snapshot clean; only RUN_ROOT scripts/logs/artifacts written. No posting.

Suggestions

None.

Evidence Gaps

  • End-to-end reported UC award below the protected floor was not completed before the shared substantive deadline.
  • Exact 30 April 2021 operational-cap commencement day is not corroborated by the acquired Budget 2021, which states April 2021.
  • The JRF URL now serves an updated/corrected 2026 briefing; the original October 2025 worked amounts and exact debt-before-cap priority remain a source-version gap.
  • Private microsimulation validation totals were not independently rerun.
  • Role checks incomplete: current-pr-1819-review-code.md; see recovered report.
  • Role checks incomplete: current-pr-1819-review-policy.md; see recovered report.

Notes

  • Real demographic capped UC and legacy HB paths executed; cap/source-date interpretation owned by policy role.
  • Current-law deductions ceiling versus operational cap separated; protected floor is a proposal.

Validation Summary

179 passed, 14.03s pytest /17.46s CLI; actual-family floor0/.85/1.2 component reconciliation and final UC/net income pass; legacy HB final award/net income unaffected; draft towncrier omits untyped fragment

Original statutory/government/JRF sources read with SHA-bound extracts. current-pr-1819-review-sources.json; current-pr-1819-review-source-cache-check.json. No duplicate calculation diagnostics; original PDF extraction via bundled pypdf. Canonical source-manifest validator exited 0.

CI at d287b18: Install + smoke-import (py3.9)=SUCCESS, Test documentation builds=SUCCESS, Install + smoke-import (py3.10)=SUCCESS, Install + smoke-import (py3.11)=SUCCESS, Install + smoke-import (py3.12)=SUCCESS, Install + smoke-import (py3.13)=SUCCESS, Install + smoke-import (py3.14)=SUCCESS, Lint=SUCCESS, Test=SUCCESS.

Detailed code, policy, source and reproduction artifacts were retained locally.

Timing

elapsed seconds: 1151.70s; setup seconds: 272.06s

Review Severity

COMMENT. Open findings: 0 critical, 1 should address, 0 suggestions.

@MaxGhenis

Copy link
Copy Markdown
Collaborator

This PR's fragment changelog.d/uc-deductions-floor-reconciliation.md has no towncrier type, so towncrier would leave it out of CHANGELOG.md and never delete it (#2314). Please rename it to uc-deductions-floor-reconciliation.<type>.md with one of breaking, added, changed, fixed or removed. Once #2324 merges, its Changelog fragments check fails this PR until the fragment has a type.

🤖 Generated with Claude Code

@vahid-ahmadi
vahid-ahmadi force-pushed the fix-uc-deductions-floor-reconciliation branch from 046e7d5 to ed1154d Compare October 12, 2026 06:25
@MaxGhenis

Copy link
Copy Markdown
Collaborator

Suggested Reproducer: line for the new Fix evidence check; add it to the description, or narrow it to the test that reproduces the bug. It lists the tests this PR adds or changes, and the check (.github/check_fix_pr.py) accepts it against this PR's diff.

Reproducer: `policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_deductions.yaml`, `policyengine_uk/tests/test_uc_deductions.py::TestReformLevers::test_protected_floor_binds_on_the_above_cap_excess`, `policyengine_uk/tests/test_uc_deductions.py::TestReformLevers::test_floor_components_reconcile_with_the_reduction_applied`, `policyengine_uk/tests/test_uc_deductions.py::TestReformLevers::test_the_benefit_cap_reduction_absorbs_the_floor_first`, `policyengine_uk/tests/test_uc_deductions.py::TestReformLevers::test_a_floor_above_the_standard_allowance_adds_nothing`, `policyengine_uk/tests/test_uc_deductions.py::TestReformLevers::test_no_deductions_without_a_universal_credit_claim`

The check isn't required yet (decision d1368); it fails fix PRs whose description names no reproducer.

🤖 Generated with Claude Code

@juaristi22 juaristi22 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed ed1154de76f4253653bdc9fb6be7662447da65c8. The 17 focused UC-deductions tests pass. I am requesting changes because the PR body fails current main’s Fix evidence checker: it has no named Reproducer: line. Adding Reproducer: policyengine_uk/tests/test_uc_deductions.py::TestReformLevers::test_floor_components_reconcile_with_the_reduction_applied makes the checker pass. The green checks on this older branch do not include that workflow.

Please also settle the protected-floor component ordering. uc_deductions.py:29-35 limits debt recovery using the gross cap before uc_benefit_cap_reduction.py reduces the applied cap. With £6,000 pre-cap UC, £5,900 gross cap, and an 85% floor, an exact-head fixture gives £99.88 debt deduction and £620.37 applied cap; the PR’s stated debt-first ordering would give £720.25 debt and £0 cap at the same final UC. Add a near-exhaustion test and implement the stated ordering, or document and justify the gross-cap debt limit as the deliberate choice. JRF specifies a combined floor but does not prescribe component priority.

Finally, deductions/cap.yaml records the 2021 25% cap from 30 April, while DWP’s ministerial answer says 12 April 2021. This does not change the annual result because the model samples 30 April, but the historical date should be corrected. Please add the required axiom: status to the description.

@MaxGhenis

Copy link
Copy Markdown
Collaborator

Independent review (GPT-6.1 Sol, UK hub, 2026-10-12): REQUEST_CHANGES at ed1154de7.

@vahid-ahmadi: the review confirms the original bugs on main and that the reconciliation identity, non-negative components, the floor bound and HB's gross reading all hold. It found:

  • one blocker: the applied UC cap can exceed the award actually lost, e.g. £720.25 applied against £500 of pre-cap UC;
  • three should-fixes: the deduction priority under heavy capping (the same case your earlier comment notes), the 30%→25% cap date (DWP gives 12 April 2021), and a real 2026 impact run;
  • two nits.

Suggested landing order after the fixes: #1819, then #1820, then #2076. Would you like to fix these yourself, or have the hub push the fixes as new commits on your branch? Say which here.

VERDICT: REQUEST_CHANGES
Head reviewed: ed1154d

  1. [blocker] The applied UC cap can exceed the actual award loss. uc_benefit_cap_reduction.py:36. A full Simulation with pre-cap UC £500, gross cap reduction £1,000, and floor 0.85 reports deductions £0, applied cap £720.25, and UC £0. Actual loss is £500. Additionally bound the applied component by max_(pre_cap_award - deductions, 0) and add low-award regressions at floors 0 and 0.85. Keep the gross HB component unchanged.

  2. [should-fix] Heavily capped awards violate the stated deduction priority. uc_deductions.py:29. For entitlement £6,000, gross cap £5,500, latent rate 0.30, and floor 0.85, deductions become £499.88 and applied cap £220.37; the documented ordering calls for £720.25 deductions and zero cap. Allocate the components through an acyclic helper using raw deduction demand and the protected award, preserving baseline and penny constraints. Add this regression. The author’s comments acknowledge it.

  3. [should-fix] The corrected historical cap date is still wrong. cap.yaml:5. DWP explicitly dates the 30%→25% reduction to 12 April 2021. Change the key, description, and PR explanation to 2021-04-12, and add the exact-date reference. Sampling annually at 30 April masks this error. DWP Annual Report, p.57.

  4. [should-fix] No real-run 2026 impact is reported. test_uc_deductions_aggregates.py:19 covers 2024/2025 baseline statistics. Run 2026 enhanced-FRS Microsimulations on the same identified dataset for main and head, at baseline and floor 0.85. Report weighted UC, deductions, gross/applied cap reductions, HB, net income, changed benefit-unit counts, and reconciliation residuals. Compare individual baseline outputs as well as totals.

  5. [nit] The named JRF test does not reproduce the worked example. test_uc_deductions.py:217 uses 10% deductions and retains 5% cap reduction. Add a separate 15% deduction case asserting the cap falls approximately to zero. Pin the original October 2025 source or update the reference and amounts: the URL now serves a September 2026 replacement briefing.

  6. [nit] Baseline outputs are not strictly bit-identical. universal_credit.py:22 reassociates float32 subtraction. With float32 inputs 6000.13, 1000.07, and 720.252, main returns 4279.8076171875; head returns 4279.80810546875. Restore pre - (cap + deductions) to preserve the previous arithmetic order.

Reading current main confirms the original component mismatch, above-one floor bug, and missing claim guard. The claim-guard reproducer additionally supplies a positive pre-cap award; latent-rate imputation alone does not trigger it when pre-cap UC is calculated normally. The 25%+5% comment correction is accurate. The 15% Fair Repayment Rate from 30 April 2025, statutory 40% ceiling, penny reference, and baseline floor zero are correctly distinguished. 2025 explanatory memorandum, Schedule 6.

For claiming units with calculated nonnegative inputs, the requested floored award identity, nonnegative components, combined floor bound, and deduction penny constraint hold. Housing Benefit still reads the gross reduction; deductions and incidence retain their gross dependencies, so no new cycle appears.

Recommended order after fixes: #1819 → #1820 → #2076; the first two can land independently. #1820 still needs its 12 October earnings-floor and pension-relievability fixes, and must preserve #1819’s floor components and gross HB cap. #2076 must merge main after both, retaining corrected individual earnings calculations, dated £846/£881 thresholds, scoped exemptions, and the UC reconciliation. Its current earnings rewrite would otherwise restore older behavior. Coordination comment.

Validation: 6 YAML cases and 17 Python tests passed serially, using the existing cloned-system fixture. Compressor occupancy was below 30 GB. The diff weakens no assertions and introduces no skips or xfails; the new tests are fixed examples, not generated property tests. No files were edited and nothing was posted.

@vahid-ahmadi

Copy link
Copy Markdown
Collaborator Author

@MaxGhenis thanks, I'll fix these myself on this branch today.

vahid-ahmadi and others added 4 commits October 12, 2026 14:32
… floor

The protected minimum floor was applied only when computing
universal_credit, so uc_deductions and benefit_cap_reduction reported
their unfloored values: a floor of 0.85 with a 1,000 GBP benefit cap
reduction and a 0.30 latent rate reported 960.34 + 1,000 GBP against a
720.25 GBP reduction actually applied.

The floor now lives in the components. uc_maximum_reduction holds the
allowance the floor leaves; uc_deductions is limited only where
deductions alone exceed it, and the new uc_benefit_cap_reduction takes
what remains. That ordering matches JRF's worked example, which
preserves the deduction and eliminates the cap reduction, and it means
the components sum to the entitlement forgone. benefit_cap_reduction
stays gross because Housing Benefit uses it and the UC floor does not
protect Housing Benefit, which also keeps it out of a dependency cycle.

Also clamps the floor allowance at zero (a floor above 1 made it
negative and added money to the award), gates uc_deductions on
would_claim_uc for the dataset-imputed migration path, and corrects the
25% cap date to 30 April 2021 and the one-penny citation to Schedule 6
paragraph 3(1)(a) of SI 2013/380.

Current-law results are unchanged: the floor is zero, so the allowance
is infinite and both components pass through unaltered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… under the protected floor

The applied UC cap reduction is bounded by the award actually lost (a
£1,000 gross reduction on a £500 award applied £720.25 under an 85% floor,
and £1,000 with none). Where the floor binds, deductions are met first
from the protected award, up to the floor allowance, and the cap
reduction absorbs the rest; previously the penny rule squeezed deductions
under the gross cap, so heavy capping moved the split. Where the floor
does not bind, and under current law, values are unchanged and
universal_credit keeps main's float order. The 25% deductions cap dates
from 12 April 2021 (DWP Annual Report 2020-21). The JRF reference moves
to the September 2026 briefing, with a test of its example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vahid-ahmadi
vahid-ahmadi force-pushed the fix-uc-deductions-floor-reconciliation branch from ed1154d to 656a0f8 Compare October 12, 2026 14:04
@vahid-ahmadi

Copy link
Copy Markdown
Collaborator Author

Thanks both. Everything is in 656a0f8, rebased onto current main (e01f2f5). The description is rewritten to match.

@juaristi22

  • Reproducer: added. It names your suggested test plus the regression tests for each fix. .github/check_fix_pr.py passes locally against the new body.
  • Component ordering: fixed in 656a0f8, implementing the stated ordering. You were right that limiting deductions with the gross cap broke it. Both components now come from one helper (deductions/_reduction_allocation.py). Where the current-law split fits inside the floor allowance, nothing changes. Where it doesn't, deductions are met first from the protected award (up to the allowance, leaving a penny payable) and the cap reduction takes what's left. Your case (£6,000 pre-cap, £5,900 gross cap, 85% floor) now gives £720.25 deductions and £0 cap, and is a test along with the £5,500 case. I agree JRF fixes the combined limit and not the priority. The September 2026 briefing says "the sum of the 'capped amount' and any debt deductions is limited to...", so the docs now call the ordering a modelling choice and give the reasons: it matches the Fair Repayment Rate, it reproduces JRF's example, and it doesn't depend on how heavily the household is capped.
  • 12 April 2021: fixed in 656a0f8. The HL54 page returned 403 to me, so I cited what I could read: DWP Annual Report and Accounts 2020-21, p.57, "reduced from 30% to 25% from 12 April 2021".
  • axiom line: added as n/a. Current-law UC is unchanged. Neither the operational deductions caps nor the floor has a rulespec-uk module, and the reg 81 amount in uk/regulations/uksi/2013/376/81.yaml is untouched.
  • A1 (changelog type): already fixed in the previous round; the fragment is uc-deductions-floor-reconciliation.fixed.md.

@MaxGhenis, hub review

  1. Applied cap above the award lost: fixed in 656a0f8. The applied cap is now bounded by the award. Your case (£500 pre-cap, £1,000 gross, floor 0.85) gives £500 applied and UC £0, at both floor 0 and floor 0.85, and benefit_cap_reduction stays at £1,000 for HB. Both cases are tests. I also added a not-binding case (£700 award, £1,000 gross cap): there the floor leaves the current-law split alone. A plain debt-first rule would have moved £699.88 from the cap to deductions where the floor never binds.
  2. Deduction priority under heavy capping: fixed in 656a0f8 with the acyclic helper, working from raw demand and the protected award. Your case (£6,000, £5,500 gross, latent 0.30, floor 0.85) gives £720.25 and £0. Neither variable reads the other. I ran a randomised check on 2m float32 cases: floor 0 matches main's deductions and UC bit for bit; under any floor the components are non-negative, the cap is at most both the gross amount and the award, the total is at most the allowance, and the penny holds; where the floor doesn't bind the values equal current law.
  3. 12 April 2021: fixed, as above, with the DWP reference.
  4. 2026 impact: run locally on enhanced FRS 2024-25 (policyengine-uk-data-private snapshot 47bbb58d, already cached, nothing downloaded), main e01f2f5 against this branch:
    • Baseline: UC £83.649bn, deductions £1.939bn, gross cap £1.171bn, HB £8.482bn, net income £1,709.288bn. These are identical per unit on every variable (max absolute difference 0, no units changed).
    • Floor 0.85: UC £84.710bn and net income £1,710.349bn, identical per unit to main. The floor adds £1.061bn for 330,093 benefit units (weighted) on both.
    • Floor 0.85, the split: deductions fall £14.5m, across 117 units (102,511 weighted) whose deductions alone exceed the allowance. On main they weren't floored. The applied cap is £0.125bn, and the reconciliation residual is 0. On main the components over-reported the loss by £1.061bn.
    • No unit in the 2026 data has a gross cap above its award or a deduction squeezed by heavy capping (deductions rise against main for 0 units). So fixes 1 and 2 move no totals there and are covered by unit tests only.
  5. JRF nit: you're right, fixed. The old URL now redirects to the 11 September 2026 briefing, which replaces the October 2025 one. Its example is £98 standard allowance, £15 deduction, £80 cap reduction, total limited to £15. The reference and docs now point to it, and test_jrf_2026_example_keeps_a_full_rate_deduction asserts the 15% deduction survives and the cap falls to about zero. It pins the example rather than a bug, so it isn't in the Reproducer line. The 10% test stays, with its comment corrected.
  6. Float order: you're right, fixed. universal_credit subtracts (cap + deductions) again. With your float32 inputs, main and this branch both give 4279.8076171875.

Tests

  • test_uc_deductions.py: 23 passed. The 5 new regression tests failed on ed1154d.
  • UC YAML: 414 passed. Benefit cap YAML: 24 passed. Full YAML suite: 3,058 passed.
  • pytest -m "not microsimulation" excluding tests/policy: 5,406 passed, 70 skipped, 2 xfailed.
  • ruff format and ruff check: clean.

The uc_benefit_cap_reduction name still clashes with #2089. Whichever lands second should rename, as on this thread. Suggested landing order is unchanged: #1819, #1820, #2076.

@juaristi22 @MaxGhenis could you re-review?

@MaxGhenis MaxGhenis added the ci-recheck Re-triggers Fix evidence without changing the PR (UK hub, 2026-10-12) label Oct 12, 2026

@juaristi22 juaristi22 left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed 656a0f8203a084c9a418123e3671e846ffe38832. The earlier low-award bound, heavy-cap priority, Reproducer, historical date and changelog issues are resolved. The 299 focused head tests pass. One merge blocker remains against current main 1a31ef1fd2c1a1e53f616df8d27c464a726e13ed:

Both new reduction components use a narrower claim guard than current main’s UC award. uc_benefit_cap_reduction.py:24 and deductions/uc_deductions.py:26 use defined_for = "would_claim_uc". Main now pays UC for claims_universal_credit, including an actual claim at legacy-benefit closure when ordinary take-up is false. Composing these components with that route removes both the cap and debt deductions from an actual claimant.

Reproduction: model year 2025, couple aged 35 and 33, children aged 13, 11, 9 and 7, prior CTC reported £3,000, London council rent of £30,000, no earnings/disability/caring, would_claim_uc:false, would_claim_uc_at_legacy_closure:true; assume the migration claim is made and there is no prior-earnings grace. Eligibility, closure claim and cap are calculated. Current main gives pre-cap UC of £48,632.64, a gross cap reduction of £27,355.24, deductions of £1,130.58 and final UC of £20,146.82. An in-memory overlay of the exact PR classes, preserving main’s claim guard, zeros both components and gives £48,632.64: £28,485.82 too much annually. Changing only the two component guards to claims_universal_credit restores main exactly.

This is a current-main composition check, not a failure on the older isolated head or a claim that a merged Git tree was tested. Please update onto main, preserve its actual-claim route, use the same predicate for both components, and add a migration regression through final UC. Migration regulation 44 supports the made-claim premise; UC regulation 81 still requires the cap. The current head’s green CI does not cover this newer claim route.

@MaxGhenis

Copy link
Copy Markdown
Collaborator

#2089 merged on 2026-10-17 as 0583e23ec. uc_benefit_cap_reduction (variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_reduction.py) is now on main as the gross UC reg 81 amount: the excess of the welfare benefits over the UC cap, with Housing Benefit counted as nil (reg 80(2A)), less the childcare costs element. universal_credit, uc_deductions and uc_has_deduction read it, and benefit_cap_reduction is now the sum of the UC and HB reductions.

When this PR next merges main, two things will fail until the floor-bounded amount here is renamed:

  • policyengine-core will refuse the second definition of uc_benefit_cap_reduction (VariableNameConflictError);
  • universal_credit.py will conflict on the line both PRs edit.

As suggested on 10/02, it could become uc_benefit_cap_reduction_after_floor, reading uc_benefit_cap_reduction as its gross input.

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

ci-recheck Re-triggers Fix evidence without changing the PR (UK hub, 2026-10-12)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants