From 017236ef0d1bb10acd1895f8a25324694b95c29c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 22:27:31 -0400 Subject: [PATCH 1/5] Work in progress: tie the UC deduction rate to its flag Saved from a build that stopped when the machine rebooted. The next build verifies this work and finishes it. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2739.fixed.md | 1 + docs/book/validation/uc-deductions.md | 4 + .../universal_credit/uc_deductions.yaml | 171 ++++++ .../universal_credit/uc_fixed_deductions.yaml | 6 + .../test_uc_advance_repayment_properties.py | 2 + .../test_uc_deduction_flag_properties.py | 570 ++++++++++++++++++ policyengine_uk/tests/test_uc_deductions.py | 4 + .../test_uc_tax_free_childcare_choice.py | 5 +- .../deductions/_deduction_amounts.py | 17 +- .../deductions/uc_deduction_rate.py | 13 +- .../deductions/uc_has_deduction.py | 22 +- .../deductions/uc_latent_deduction_rate.py | 4 +- 12 files changed, 809 insertions(+), 10 deletions(-) create mode 100644 changelog.d/2739.fixed.md create mode 100644 policyengine_uk/tests/test_uc_deduction_flag_properties.py diff --git a/changelog.d/2739.fixed.md b/changelog.d/2739.fixed.md new file mode 100644 index 0000000000..2ad81607b5 --- /dev/null +++ b/changelog.d/2739.fixed.md @@ -0,0 +1 @@ +Universal Credit deductions count a supplied latent deduction rate only where `uc_has_deduction` is true, as the rate's own formula does. Under a protected floor reform, the flag reads the award left after the benefit cap as the floor limits it. diff --git a/docs/book/validation/uc-deductions.md b/docs/book/validation/uc-deductions.md index fa5ebb53a8..c018555aa5 100644 --- a/docs/book/validation/uc-deductions.md +++ b/docs/book/validation/uc-deductions.md @@ -158,3 +158,7 @@ A cap of 1 − *x* is equivalent to a protected minimum floor at *x* of the stan Statutory parameters — the cap, the protected floor, the minimum payable penny, the abolition switches — live under `gov.dwp.universal_credit.deductions`. The calibrated distributions live under `gov.simulation.uc_deductions`: they describe the world, not the law. The assignment formulas are an explicit fallback. The end state imputes `uc_latent_deduction_rate` and `uc_deduction_combination` at dataset build, at which point the model consumes them as plain inputs and the fallback retires; raw-FRS users keep working through the fallback until then. Assignment uses deterministic splitmix64 hashes of `benunit_id`, reproducible across runs and machines, and overridable by datasets or situations through `uc_deduction_random_draw` and `uc_deduction_type_random_draw`. Every simulation built from data gets the hashed draws, including a constituency or local authority filtered from the national data, and a benefit unit gets the same draw there as in the national run. Household situations get no deductions unless set explicitly. + +`uc_has_deduction` decides which benefit units have deductions: a unit claiming Universal Credit, with an award left after the benefit cap, whose incidence draw falls below its region's incidence. The latent rate counts only for those units, whether the formula assigns it or a dataset or situation supplies it, so a supplied rate follows the same rule as the formula. A dataset that stores latent rates therefore needs the draws that assign them, and Microcosm stores both. To give a household situation deductions, set `uc_has_deduction` to true with `uc_latent_deduction_rate` and `uc_deduction_combination`. Alternatively, supply the advance cash (`uc_advance_repayment_amount`) or the rate itself (`uc_deduction_rate`). Those two describe a known deduction, so they apply whatever the flag says. + +A protected floor reform limits the benefit cap to the floor allowance, so a family that the cap takes to nil under current law can keep an award. The flag reads the award left after the cap as the floor limits it. Such a family can therefore have deductions, which the floor meets first, as for any other family. Under current law the floor is zero, and the flag reads the award left after the full cap. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_deductions.yaml index 48fdec83e2..563556c54d 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_deductions.yaml @@ -32,6 +32,7 @@ universal_credit_pre_benefit_cap: 6_000 uc_benefit_cap_reduction: 0 uc_latent_deduction_rate: 0.25 + uc_has_deduction: true uc_deduction_combination: ADVANCE_ONLY households: household: @@ -56,6 +57,7 @@ universal_credit_pre_benefit_cap: 6_000 uc_benefit_cap_reduction: 0 uc_latent_deduction_rate: 0.25 + uc_has_deduction: true uc_deduction_combination: ADVANCE_ONLY households: household: @@ -79,6 +81,7 @@ universal_credit_pre_benefit_cap: 6_000 uc_benefit_cap_reduction: 0 uc_latent_deduction_rate: 0.28 + uc_has_deduction: true uc_deduction_combination: GOVERNMENT_ONLY households: household: @@ -102,6 +105,7 @@ universal_credit_pre_benefit_cap: 500 uc_benefit_cap_reduction: 0 uc_latent_deduction_rate: 0.25 + uc_has_deduction: true uc_deduction_combination: ADVANCE_ONLY households: household: @@ -165,6 +169,7 @@ would_claim_uc: false would_claim_uc_at_legacy_closure: true uc_latent_deduction_rate: 0.25 + uc_has_deduction: true uc_deduction_combination: ADVANCE_ONLY households: household: @@ -179,3 +184,169 @@ uc_benefit_cap_reduction_after_floor: 27_355.24 uc_deductions: 1_130.58 universal_credit: 20_146.82 + +- name: A supplied latent rate alone gives a household situation no deductions + # The latent rate counts only where uc_has_deduction is true (#2739). A + # household situation's incidence draw defaults to 1.0, so the flag is + # false unless set. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + would_claim_uc: true + universal_credit_pre_benefit_cap: 6_000 + uc_benefit_cap_reduction: 0 + uc_latent_deduction_rate: 0.25 + uc_deduction_combination: ADVANCE_ONLY + households: + household: + members: person + output: + uc_has_deduction: false + uc_deduction_rate: 0 + uc_deductions: 0 + universal_credit: 6_000 + +- name: A supplied latent rate gives no deductions without the flag under a protected floor + # The example from #2739. The benefit cap takes the whole £1,000 award, so + # an 85% floor binds. With the flag false, the latent rate counts as zero, + # and the cap reduction takes the whole floor allowance: + # 0.15 x 400.14 x 12 = 720.25, leaving 1,000 - 720.25 = 279.75. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.dwp.universal_credit.deductions.protected_floor: 0.85 + people: + person: + age: 30 + benunits: + benunit: + members: person + would_claim_uc: true + universal_credit_pre_benefit_cap: 1_000 + uc_benefit_cap_reduction: 1_000 + uc_latent_deduction_rate: 0.30 + uc_deduction_combination: ADVANCE_ONLY + uc_has_deduction: false + households: + household: + members: person + output: + uc_maximum_reduction: 720.25 + uc_deduction_rate: 0 + uc_deductions: 0 + uc_benefit_cap_reduction_after_floor: 720.25 + universal_credit: 279.75 + +- name: The same family with the flag set repays its deductions first under the floor + # Demand is the 15% cap on 25% plus a 5% last resort excess: 0.20 x 4,801.68 + # = 960.34. The floor allowance of 720.25 limits it, and the cap reduction + # takes nothing more. The award is 279.75 either way. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.dwp.universal_credit.deductions.protected_floor: 0.85 + people: + person: + age: 30 + benunits: + benunit: + members: person + would_claim_uc: true + universal_credit_pre_benefit_cap: 1_000 + uc_benefit_cap_reduction: 1_000 + uc_latent_deduction_rate: 0.30 + uc_deduction_combination: ADVANCE_ONLY + uc_has_deduction: true + households: + household: + members: person + output: + uc_deduction_rate: 0.20 + uc_deductions: 720.25 + uc_benefit_cap_reduction_after_floor: 0 + universal_credit: 279.75 + +- name: Under a protected floor a family the cap takes to nil can be flagged for deductions + # An incidence draw of 0.1 falls below London's incidence (0.467 x 0.94). + # The 85% floor limits the cap to 720.25, which leaves 279.75 of the + # £1,000 award for deductions to come from, so the flag is true. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.dwp.universal_credit.deductions.protected_floor: 0.85 + people: + person: + age: 30 + benunits: + benunit: + members: person + would_claim_uc: true + universal_credit_pre_benefit_cap: 1_000 + uc_benefit_cap_reduction: 1_000 + uc_deduction_random_draw: 0.1 + uc_latent_deduction_rate: 0.30 + uc_deduction_combination: ADVANCE_ONLY + households: + household: + members: person + output: + uc_has_deduction: true + uc_deductions: 720.25 + uc_benefit_cap_reduction_after_floor: 0 + universal_credit: 279.75 + +- name: Under current law the cap leaves that family no award and no deductions + # Without a floor the cap takes the whole award, so the flag is false. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + would_claim_uc: true + universal_credit_pre_benefit_cap: 1_000 + uc_benefit_cap_reduction: 1_000 + uc_deduction_random_draw: 0.1 + uc_latent_deduction_rate: 0.30 + uc_deduction_combination: ADVANCE_ONLY + households: + household: + members: person + output: + uc_has_deduction: false + uc_deductions: 0 + uc_benefit_cap_reduction_after_floor: 1_000 + universal_credit: 0 + +- name: An explicitly supplied advance amount applies whatever the flag says + # uc_advance_repayment_amount describes a known advance, so the flag does + # not gate it. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + would_claim_uc: true + universal_credit_pre_benefit_cap: 6_000 + uc_benefit_cap_reduction: 0 + uc_advance_repayment_amount: 360 + uc_has_deduction: false + households: + household: + members: person + output: + uc_deductions: 360 + universal_credit: 5_640 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_fixed_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_fixed_deductions.yaml index 4446602377..f339be05e8 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_fixed_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_fixed_deductions.yaml @@ -34,6 +34,7 @@ would_claim_uc: true universal_credit_pre_benefit_cap: 6_000 uc_latent_deduction_rate: 0.05 + uc_has_deduction: true uc_deduction_combination: THIRD_PARTY_ONLY gov.dwp.universal_credit.standard_allowance.amount.SINGLE_OLD: 440 output: @@ -48,6 +49,7 @@ universal_credit_pre_benefit_cap: 6_000 uc_advance_repayment_amount: 600 uc_latent_deduction_rate: 0.05 + uc_has_deduction: true uc_deduction_combination: THIRD_PARTY_ONLY gov.dwp.universal_credit.standard_allowance.amount.SINGLE_OLD: 440 output: @@ -86,6 +88,7 @@ would_claim_uc: true universal_credit_pre_benefit_cap: 6_000 uc_latent_deduction_rate: 0.1 + uc_has_deduction: true uc_deduction_combination: ADVANCE_ONLY gov.dwp.universal_credit.standard_allowance.amount.SINGLE_OLD: 440 output: @@ -103,6 +106,7 @@ would_claim_uc: true universal_credit_pre_benefit_cap: 6_000 uc_latent_deduction_rate: 0.1 + uc_has_deduction: true uc_deduction_combination: ADVANCE_ONLY uc_advance_repayment_amount: 0 output: @@ -124,6 +128,7 @@ universal_credit_pre_benefit_cap: 6_000 uc_benefit_cap_reduction: 0 uc_latent_deduction_rate: 0.1 + uc_has_deduction: true uc_deduction_combination: ADVANCE_ONLY uc_advance_repayment_amount: 0 missing: @@ -132,6 +137,7 @@ universal_credit_pre_benefit_cap: 6_000 uc_benefit_cap_reduction: 0 uc_latent_deduction_rate: 0.1 + uc_has_deduction: true uc_deduction_combination: ADVANCE_ONLY households: zero_household: diff --git a/policyengine_uk/tests/test_uc_advance_repayment_properties.py b/policyengine_uk/tests/test_uc_advance_repayment_properties.py index f5445df4f6..285619941d 100644 --- a/policyengine_uk/tests/test_uc_advance_repayment_properties.py +++ b/policyengine_uk/tests/test_uc_advance_repayment_properties.py @@ -40,6 +40,8 @@ def situation(amounts, year=YEAR, age=30, latent=0.1, combination="ADVANCE_ONLY" "uc_benefit_cap_reduction": {year: 0}, "uc_latent_deduction_rate": {year: latent}, "uc_deduction_combination": {year: combination}, + # The latent rate counts only for a unit with the flag (#2739). + "uc_has_deduction": {year: True}, } if amount is not None: unit["uc_advance_repayment_amount"] = {year: amount} diff --git a/policyengine_uk/tests/test_uc_deduction_flag_properties.py b/policyengine_uk/tests/test_uc_deduction_flag_properties.py new file mode 100644 index 0000000000..a8ccb81396 --- /dev/null +++ b/policyengine_uk/tests/test_uc_deduction_flag_properties.py @@ -0,0 +1,570 @@ +"""A supplied UC latent deduction rate follows the deduction flag (#2739). + +uc_latent_deduction_rate's formula returns zero where uc_has_deduction is +false. uc_deduction_rate applies the same rule to a rate that a dataset or +situation supplies, and so does the advance estimate built from that rate. +uc_has_deduction reads the award the benefit cap leaves; a protected floor +reform limits the cap to the floor allowance (uc_maximum_reduction, infinite +under current law). ``MAIN_RULE`` re-adds origin/main's flag, rate and +advance formulas at 7ed52038a, verbatim, so one dataset gives both rules. + +Invariants, each over generated benefit units and explicit examples: + +1. Flag gate. Where uc_has_deduction is false, uc_deductions is zero, for + any supplied latent rate, combination, benefit cap reduction and floor. + The documented exception: an explicitly supplied + uc_advance_repayment_amount describes a known advance and applies + whatever the flag says. +2. Substitution. A supplied latent rate gives the same deductions, applied + cap reduction and award as where(uc_has_deduction, rate, 0), the rate + the formula's rule leaves. +3. The flag only gates disagreeing rates. For a claimant whose incidence + draw assigns deductions, uc_deductions equals allocate_uc_reductions on + the ungated demand, under any floor: the flag is false only where the + allocation gives nothing. +4. Accounting. For a claimant with an award, uc_deductions plus + uc_benefit_cap_reduction_after_floor plus universal_credit equals the + award before the cap. +5. Current law. With no floor, a dataset without stored attributes (the + formula path) gives the same flag, rates, deductions, applied cap and + award as origin/main's rule, bit for bit. +6. Floor. Under a floor, the formula path moves only the split between + deductions and the applied cap for families the cap takes to nil; their + award rises by less than the 12p a year the penny rule keeps. +7. Stored attributes. A dataset that stores the draws and, for every unit + the draw assigns (claimant or not), the formula's latent rate and + combination, as Microcosm does, gives the formula path's flag, + deductions, applied cap and award under any floor. +""" + +import numpy as np +import pandas as pd +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st +from policyengine_core.reforms import Reform + +from policyengine_uk import Microsimulation, Simulation +from policyengine_uk.data import UKMultiYearDataset, UKSingleYearDataset +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.universal_credit.deductions._deduction_amounts import ( + deduction_type_shares, + reference_standard_allowance, +) +from policyengine_uk.variables.gov.dwp.universal_credit.deductions._reduction_allocation import ( + allocate_uc_reductions, +) + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2025 +# Single claimant 25 or over, 2025-26: £400.14 a month. +STANDARD_ALLOWANCE = 400.14 * 12 +PENNY_A_YEAR = 0.01 * 12 +COMBINATIONS = [ + "NONE", + "ADVANCE_ONLY", + "THIRD_PARTY_ONLY", + "GOVERNMENT_ONLY", + "ADVANCE_AND_GOVERNMENT", + "ADVANCE_AND_THIRD_PARTY", + "THIRD_PARTY_AND_GOVERNMENT", + "ALL_THREE", +] +REGIONS = ["LONDON", "NORTH_EAST", "SCOTLAND", "WALES", "SOUTH_EAST"] +# Households in situations live in London by default, whose incidence is +# 0.467 x 0.94 = 0.439: a draw below 0.4 assigns deductions, one above 0.45 +# does not. +ASSIGNING_DRAW = st.floats(0, 0.4, allow_nan=False) +NON_ASSIGNING_DRAW = st.floats(0.45, 1, exclude_max=True, allow_nan=False) +DEDUCTION_OUTPUTS = ( + "uc_deductions", + "uc_benefit_cap_reduction_after_floor", + "universal_credit", +) + + +def floor_reform(floor): + if not floor: + return None + return { + "gov.dwp.universal_credit.deductions.protected_floor": { + f"{YEAR}-01-01.{YEAR}-12-31": floor + } + } + + +def values(sim, variable): + return np.asarray(sim.calculate(variable, YEAR)) + + +# Situations: each generated unit is a single claimant aged 30 in its own +# household, with its award and gross cap reduction supplied. A population +# either supplies every unit's flag or every unit's incidence draw: core +# fills the units a partly supplied input leaves out with its default, so a +# flag supplied for one unit would set the others' to false. + +cash = st.integers(0, 2_000_000).map(lambda pennies: pennies / 100) +unit_strategy = st.fixed_dictionaries( + { + "pre_cap": cash, + "gross_cap": st.integers(0, 2_500_000).map(lambda pennies: pennies / 100), + "latent": st.floats(0, 0.4, allow_nan=False), + "combination": st.sampled_from(COMBINATIONS), + "flag": st.booleans(), + "draw": st.one_of(ASSIGNING_DRAW, NON_ASSIGNING_DRAW), + "claims": st.booleans(), + "advance": st.one_of(st.none(), cash.map(lambda amount: amount / 10)), + } +) +floor_strategy = st.one_of(st.just(0.0), st.floats(0.5, 1.2, allow_nan=False)) + + +def situation(units, drawn, latent=None, flag=None): + """A situation of ``units`` that supplies each unit's incidence draw if + ``drawn`` and its flag otherwise. ``latent`` replaces every unit's latent + rate; ``flag`` supplies every unit's flag.""" + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + person = f"person_{i}" + people[person] = {"age": {YEAR: 30}} + benunit = { + "members": [person], + "would_claim_uc": {YEAR: unit["claims"]}, + "universal_credit_pre_benefit_cap": {YEAR: unit["pre_cap"]}, + "uc_benefit_cap_reduction": {YEAR: unit["gross_cap"]}, + "uc_latent_deduction_rate": { + YEAR: unit["latent"] if latent is None else float(latent[i]) + }, + "uc_deduction_combination": {YEAR: unit["combination"]}, + } + if flag is not None: + benunit["uc_has_deduction"] = {YEAR: flag} + elif drawn: + benunit["uc_deduction_random_draw"] = {YEAR: unit["draw"]} + else: + benunit["uc_has_deduction"] = {YEAR: unit["flag"]} + if unit["advance"] is not None: + benunit["uc_advance_repayment_amount"] = {YEAR: unit["advance"]} + benunits[f"benunit_{i}"] = benunit + households[f"household_{i}"] = {"members": [person]} + return {"people": people, "benunits": benunits, "households": households} + + +def family(**overrides): + return { + "pre_cap": 6_000.0, + "gross_cap": 0.0, + "latent": 0.25, + "combination": "ADVANCE_ONLY", + "flag": False, + "draw": 0.9, + "claims": True, + "advance": None, + **overrides, + } + + +# The example from #2739: the cap takes the whole award, the flag is false, +# and a supplied rate of 30% gave £720.25 under an 85% floor. +ISSUE_EXAMPLE = family(pre_cap=1_000.0, gross_cap=1_000.0, latent=0.30, draw=0.1) +EXAMPLE_UNITS = [ + ISSUE_EXAMPLE, + # Supplied flags: the issue's family flagged by hand, known advance cash + # without the flag (the documented exception), and a non-claimant + # flagged by hand. Drawn: the issue's family, which a floor leaves an + # award, and a rate whose draw assigns nothing. + family(pre_cap=1_000.0, gross_cap=1_000.0, latent=0.30, flag=True), + family(advance=360.0), + family(latent=0.30, combination="GOVERNMENT_ONLY", flag=True, claims=False), + family(combination="ALL_THREE"), +] + + +@settings( + max_examples=8, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + units=st.lists(unit_strategy, min_size=1, max_size=5), + drawn=st.booleans(), + floor=floor_strategy, +) +@example(units=EXAMPLE_UNITS, drawn=False, floor=0.85) +@example(units=EXAMPLE_UNITS, drawn=True, floor=0.85) +@example(units=EXAMPLE_UNITS, drawn=False, floor=0.0) +@example(units=EXAMPLE_UNITS, drawn=True, floor=0.0) +def test_the_flag_gates_a_supplied_latent_rate(units, drawn, floor): + reform = floor_reform(floor) + sim = Simulation(situation=situation(units, drawn), reform=reform) + flag = values(sim, "uc_has_deduction").astype(bool) + deductions = values(sim, "uc_deductions") + supplied_advance = np.array([unit["advance"] is not None for unit in units]) + claims = values(sim, "claims_universal_credit").astype(bool) + pre_cap = values(sim, "universal_credit_pre_benefit_cap") + + # 1. No deductions without the flag, unless known advance cash applies. + assert np.all(deductions[~flag & ~supplied_advance] == 0) + + # 2. A supplied rate gives what the rate the flag leaves gives. + latent = np.array([unit["latent"] for unit in units]) + substituted = Simulation( + situation=situation(units, drawn, latent=np.where(flag, latent, 0)), + reform=reform, + ) + np.testing.assert_array_equal(values(substituted, "uc_has_deduction"), flag) + for variable in DEDUCTION_OUTPUTS: + np.testing.assert_allclose( + values(sim, variable), values(substituted, variable), rtol=0, atol=1e-6 + ) + + # 3. For a claimant the draw assigns, the flag removes nothing that the + # allocation of its ungated demand would give. + if drawn: + ungated = Simulation( + situation=situation(units, drawn, flag=True), reform=reform + ) + demand = values(ungated, "uc_deduction_rate") * values( + ungated, "uc_standard_allowance" + ) + expected, _ = allocate_uc_reductions( + pre_cap, + values(sim, "uc_benefit_cap_reduction"), + demand, + values(sim, "uc_maximum_reduction"), + PENNY_A_YEAR, + ) + assigned = np.array([unit["draw"] < 0.4 for unit in units]) & claims + np.testing.assert_allclose( + deductions[assigned], expected[assigned], rtol=0, atol=1e-3 + ) + + # 4. The components sum to the award before the cap. + with_award = claims & (pre_cap > 0) + total = ( + deductions + + values(sim, "uc_benefit_cap_reduction_after_floor") + + values(sim, "universal_credit") + ) + np.testing.assert_allclose( + total[with_award], pre_cap[with_award], rtol=0, atol=0.01 + ) + + +def test_the_issue_example_takes_no_deductions_without_the_flag(): + # Reproducer for #2739: a supplied latent rate and an 85% floor gave + # £720.25 of deductions to a unit whose flag is false. + sim = Simulation( + situation=situation([ISSUE_EXAMPLE], drawn=False), reform=floor_reform(0.85) + ) + assert not values(sim, "uc_has_deduction")[0] + assert values(sim, "uc_deductions")[0] == 0 + assert values(sim, "uc_benefit_cap_reduction_after_floor")[0] == pytest.approx( + 0.15 * STANDARD_ALLOWANCE, abs=0.01 + ) + assert values(sim, "universal_credit")[0] == pytest.approx( + 1_000 - 0.15 * STANDARD_ALLOWANCE, abs=0.01 + ) + + +# Datasets: hashed draws, so the flag, rate and combination come from their +# formulas unless stored. + + +def _main_rule(): + """origin/main's uc_has_deduction, uc_deduction_rate and + uc_advance_repayment_amount formulas at 7ed52038a, verbatim.""" + + def estimated_advance_repayment_amount(benunit, period, parameters): + p = parameters(period).gov.simulation.uc_deductions + advance_share, _, _ = deduction_type_shares(benunit, period, parameters) + latent = max_(benunit("uc_latent_deduction_rate", period), 0) + return ( + min_(latent, p.calibration_cap) + * advance_share + * reference_standard_allowance(benunit, period, parameters) + ) + + class uc_has_deduction(Variable): + def formula(benunit, period, parameters): + p = parameters(period).gov.simulation.uc_deductions + d = p.latent_rate_distribution + incidence = ( + d.UNDER_5 + + d.AT_5 + + d.FIVE_TO_10 + + d.AT_10 + + d.TEN_TO_15 + + d.AT_15 + + d.FIFTEEN_TO_20 + + d.AT_20 + + d.TWENTY_TO_25 + + d.AT_25 + + d.OVER_25 + ) + region = benunit.household("region", period) + adjusted_incidence = clip( + incidence * p.region_incidence_factor[region], 0, 1 + ) + award = max_( + benunit("universal_credit_pre_benefit_cap", period) + - benunit("uc_benefit_cap_reduction", period), + 0, + ) + on_uc = ( + benunit("claims_universal_credit", period) + & (award > 0) + & benunit("uc_award_carries_deductions", period) + ) + draw = benunit("uc_deduction_random_draw", period) + return on_uc & (draw < adjusted_incidence) + + class uc_advance_repayment_amount(Variable): + def formula(benunit, period, parameters): + return estimated_advance_repayment_amount(benunit, period, parameters) + + class uc_deduction_rate(Variable): + def formula(benunit, period, parameters): + law = parameters(period).gov.dwp.universal_credit.deductions + calibration_cap = parameters( + period + ).gov.simulation.uc_deductions.calibration_cap + _, third_share, government_share = deduction_type_shares( + benunit, period, parameters + ) + remaining_share = third_share * ( + not law.abolish.third_party + ) + government_share * (not law.abolish.government) + allowance = benunit("uc_standard_allowance", period) + latent = benunit("uc_latent_deduction_rate", period) + other_rate = min_(latent, calibration_cap) * remaining_share + advance = benunit("uc_advance_repayment_amount", period) + advance = where( + advance >= 0, + advance, + estimated_advance_repayment_amount(benunit, period, parameters), + ) + if law.abolish.advance: + advance = 0 + advance_rate = np.divide( + max_(advance, 0), + allowance, + out=np.zeros_like(allowance), + where=allowance > 0, + ) + above_cap_excess = max_(latent - calibration_cap, 0) + return min_(advance_rate + other_rate, law.cap) + above_cap_excess + + class MAIN_RULE(Reform): + def apply(self): + for variable in ( + uc_has_deduction, + uc_advance_repayment_amount, + uc_deduction_rate, + ): + self.update_variable(variable) + + return MAIN_RULE + + +MAIN_RULE = _main_rule() + +data_unit_strategy = st.fixed_dictionaries( + { + "pre_cap": st.integers(0, 2_000_000).map(lambda pennies: pennies / 100), + # Gross cap as a share of the award, so many units are capped to nil. + "cap_share": st.one_of( + st.just(0.0), st.floats(0, 1.5, allow_nan=False), st.just(1.0) + ), + "region": st.sampled_from(REGIONS), + "claims": st.booleans(), + } +) + + +def tables(units, **benunit_columns): + n = len(units) + ids = np.arange(1, n + 1) + pre_cap = np.array([unit["pre_cap"] for unit in units]) + person = pd.DataFrame( + { + "person_id": ids * 10, + "person_benunit_id": ids, + "person_household_id": ids, + "age": np.full(n, 30), + } + ) + benunit = pd.DataFrame( + { + "benunit_id": ids, + "would_claim_uc": np.array([unit["claims"] for unit in units]), + "universal_credit_pre_benefit_cap": pre_cap, + "uc_benefit_cap_reduction": pre_cap + * np.array([unit["cap_share"] for unit in units]), + **benunit_columns, + } + ) + household = pd.DataFrame( + { + "household_id": ids, + "household_weight": np.full(n, 1_000.0), + "region": np.array([unit["region"] for unit in units]), + } + ) + return UKMultiYearDataset( + datasets=[ + UKSingleYearDataset( + person=person, benunit=benunit, household=household, fiscal_year=YEAR + ) + ] + ) + + +# Every region, a range of awards, and families the cap takes to nil, so the +# hashed draws assign some of each. +DATA_EXAMPLE = [ + dict( + pre_cap=float(600 + 450 * i), + cap_share=[0.0, 0.5, 1.0, 1.2][i % 4], + region=REGIONS[i % len(REGIONS)], + claims=i % 7 != 3, + ) + for i in range(24) +] +COMPARED = ( + "uc_has_deduction", + "uc_latent_deduction_rate", + "uc_deduction_combination", + "uc_advance_repayment_amount", + "uc_deduction_rate", + *DEDUCTION_OUTPUTS, +) + +data_settings = settings( + max_examples=2, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) + + +@data_settings +@given(units=st.lists(data_unit_strategy, min_size=1, max_size=24)) +@example(units=DATA_EXAMPLE) +def test_current_law_formula_path_matches_main(units): + head = Microsimulation(dataset=tables(units)) + main = Microsimulation(dataset=tables(units), reform=MAIN_RULE) + for variable in COMPARED: + np.testing.assert_array_equal( + np.asarray(head.calculate(variable, YEAR)), + np.asarray(main.calculate(variable, YEAR)), + err_msg=variable, + ) + + +@data_settings +@given( + units=st.lists(data_unit_strategy, min_size=1, max_size=24), + floor=st.floats(0.5, 1.2, allow_nan=False), +) +@example(units=DATA_EXAMPLE, floor=0.85) +def test_a_floor_moves_only_the_split_for_families_capped_to_nil(units, floor): + head = Microsimulation(dataset=tables(units), reform=floor_reform(floor)) + main = Microsimulation( + dataset=tables(units), reform=(floor_reform(floor), MAIN_RULE) + ) + pre_cap = values(head, "universal_credit_pre_benefit_cap") + gross_cap = values(head, "uc_benefit_cap_reduction") + capped_to_nil = gross_cap >= pre_cap + changed = values(head, "uc_has_deduction") != values(main, "uc_has_deduction") + # Only families the cap takes to nil gain the flag. + assert np.all(capped_to_nil[changed]) + assert np.all(values(head, "uc_has_deduction")[changed]) + for variable in ("uc_deductions", "uc_benefit_cap_reduction_after_floor"): + np.testing.assert_array_equal( + values(head, variable)[~changed], values(main, variable)[~changed] + ) + gain = values(head, "universal_credit") - values(main, "universal_credit") + assert np.all(gain >= -1e-3) + assert np.all(gain <= PENNY_A_YEAR + 1e-3) + np.testing.assert_allclose(gain[~changed], 0, atol=1e-6) + + +def microcosm_style(units, floor): + """The formula path, and a dataset storing its draws with a latent rate + and combination for every unit the draw assigns, claimant or not. The + dataset comes as a function, since building a simulation encodes its + enum columns in place.""" + formula = Microsimulation(dataset=tables(units), reform=floor_reform(floor)) + draw = values(formula, "uc_deduction_random_draw") + parameters = formula.tax_benefit_system.parameters(f"{YEAR}-01-01") + p = parameters.gov.simulation.uc_deductions + d = p.latent_rate_distribution + incidence = ( + d.UNDER_5 + + d.AT_5 + + d.FIVE_TO_10 + + d.AT_10 + + d.TEN_TO_15 + + d.AT_15 + + d.FIFTEEN_TO_20 + + d.AT_20 + + d.TWENTY_TO_25 + + d.AT_25 + + d.OVER_25 + ) + factor = np.array( + [getattr(p.region_incidence_factor, unit["region"]) for unit in units] + ) + assigned = draw < np.clip(incidence * factor, 0, 1) + every_assigned = Microsimulation(dataset=tables(units, uc_has_deduction=assigned)) + columns = dict( + uc_deduction_random_draw=draw, + uc_deduction_type_random_draw=values(formula, "uc_deduction_type_random_draw"), + uc_latent_deduction_rate=values(every_assigned, "uc_latent_deduction_rate"), + # Microsimulation results carry enum names, as a dataset stores them. + uc_deduction_combination=values(every_assigned, "uc_deduction_combination"), + ) + return formula, lambda: tables(units, **columns) + + +@data_settings +@given( + units=st.lists(data_unit_strategy, min_size=1, max_size=24), + floor=floor_strategy, +) +@example(units=DATA_EXAMPLE, floor=0.85) +@example(units=DATA_EXAMPLE, floor=0.0) +def test_stored_attributes_give_the_formula_path(units, floor): + formula, stored_dataset = microcosm_style(units, floor) + stored = Microsimulation(dataset=stored_dataset(), reform=floor_reform(floor)) + for variable in ("uc_has_deduction", *DEDUCTION_OUTPUTS): + np.testing.assert_allclose( + values(stored, variable), + values(formula, variable), + rtol=0, + atol=1e-6, + err_msg=variable, + ) + + +def test_lowering_incidence_reaches_stored_rates(): + # A dataset that stores latent rates still answers a cut in incidence: + # the reformed flag drops some units, and their stored rates no longer + # count. On origin/main the stored rates kept every deduction. + _, stored_dataset = microcosm_style(DATA_EXAMPLE, 0.0) + stored = Microsimulation(dataset=stored_dataset()) + cut = { + f"gov.simulation.uc_deductions.region_incidence_factor.{region}": { + f"{YEAR}-01-01.{YEAR}-12-31": 0.3 + } + for region in REGIONS + } + reformed = Microsimulation(dataset=stored_dataset(), reform=cut) + before = values(stored, "uc_deductions") > 0 + after = values(reformed, "uc_deductions") > 0 + flag = values(reformed, "uc_has_deduction").astype(bool) + assert np.all(flag[after]) + assert after.sum() < before.sum() diff --git a/policyengine_uk/tests/test_uc_deductions.py b/policyengine_uk/tests/test_uc_deductions.py index 216dd1c00e..704b2f429a 100644 --- a/policyengine_uk/tests/test_uc_deductions.py +++ b/policyengine_uk/tests/test_uc_deductions.py @@ -24,6 +24,10 @@ def make_situation(**benunit_overrides): "uc_benefit_cap_reduction": {YEAR: 0}, **benunit_overrides, } + if "uc_latent_deduction_rate" in benunit: + # A latent rate counts only for a unit flagged as having deductions + # (#2739); these units describe families that have them. + benunit.setdefault("uc_has_deduction", {YEAR: True}) return { "people": {"person": {"age": {YEAR: 30}}}, "benunits": {"benunit": benunit}, diff --git a/policyengine_uk/tests/test_uc_tax_free_childcare_choice.py b/policyengine_uk/tests/test_uc_tax_free_childcare_choice.py index bc3d896ef7..695b39d681 100644 --- a/policyengine_uk/tests/test_uc_tax_free_childcare_choice.py +++ b/policyengine_uk/tests/test_uc_tax_free_childcare_choice.py @@ -18,8 +18,11 @@ "2027-01-01.2100-12-31": 0.50 } } -# A 15% latent deduction rate, all advance repayments, as a dataset supplies. +# A 15% latent deduction rate, all advance repayments, as a dataset supplies, +# with the incidence draw that assigns it: the rate counts only where +# uc_has_deduction is true (#2739), and 0.2 falls below London's incidence. DEDUCTION = { + "uc_deduction_random_draw": {YEAR: 0.2}, "uc_latent_deduction_rate": {YEAR: 0.15}, "uc_deduction_combination": {YEAR: "ADVANCE_ONLY"}, } diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/_deduction_amounts.py b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/_deduction_amounts.py index 71016bd19a..952f0d1c13 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/_deduction_amounts.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/_deduction_amounts.py @@ -3,6 +3,21 @@ from policyengine_uk.model_api import * +def assigned_latent_deduction_rate(benunit, period): + """The latent rate of a unit flagged as having deductions, else zero. + + uc_latent_deduction_rate's own formula already returns zero without the + flag. This applies the same rule to a rate a dataset or situation + supplies, so a supplied rate cannot give deductions to a unit whose + uc_has_deduction is False (#2739). + """ + return where( + benunit("uc_has_deduction", period), + benunit("uc_latent_deduction_rate", period), + 0, + ) + + def deduction_type_shares(benunit, period, parameters): """Split the legacy total by its existing marginal-mean approximation.""" m = parameters(period).gov.simulation.uc_deductions.mean_monthly_amount_by_type @@ -66,7 +81,7 @@ def estimated_advance_repayment_amount(benunit, period, parameters): """Convert the legacy advance share to same-year unreformed cash.""" p = parameters(period).gov.simulation.uc_deductions advance_share, _, _ = deduction_type_shares(benunit, period, parameters) - latent = max_(benunit("uc_latent_deduction_rate", period), 0) + latent = max_(assigned_latent_deduction_rate(benunit, period), 0) return ( min_(latent, p.calibration_cap) * advance_share diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deduction_rate.py b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deduction_rate.py index d1b5539560..a91a34a0ed 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deduction_rate.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deduction_rate.py @@ -1,5 +1,6 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.dwp.universal_credit.deductions._deduction_amounts import ( + assigned_latent_deduction_rate, deduction_type_shares, estimated_advance_repayment_amount, ) @@ -11,9 +12,13 @@ class uc_deduction_rate(Variable): "Deductions as a fraction of the current Universal Credit standard allowance, " "after removing abolished ordinary debt types and applying the cap. " "Advance demand is held in cash through an allowance reform; the rate " - "therefore adjusts when the allowance changes. An explicitly supplied " - "rate retains its existing precedence in the final award. The legacy " - "above-cap excess is unchanged." + "therefore adjusts when the allowance changes. The latent rate counts " + "only where uc_has_deduction is true, whether the formula computes it " + "or a dataset or situation supplies it. An explicitly supplied " + "advance amount, or an explicitly supplied rate here, describes a " + "known deduction and applies whatever the flag says. An explicitly " + "supplied rate retains its existing precedence in the final award. " + "The legacy above-cap excess is unchanged." ) entity = BenUnit definition_period = YEAR @@ -31,7 +36,7 @@ def formula(benunit, period, parameters): not law.abolish.third_party ) + government_share * (not law.abolish.government) allowance = benunit("uc_standard_allowance", period) - latent = benunit("uc_latent_deduction_rate", period) + latent = assigned_latent_deduction_rate(benunit, period) other_rate = min_(latent, calibration_cap) * remaining_share advance = benunit("uc_advance_repayment_amount", period) # A partially supplied input array contains -1 for omitted units. diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_has_deduction.py b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_has_deduction.py index 089d65665a..129a41a8d0 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_has_deduction.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_has_deduction.py @@ -7,7 +7,14 @@ class uc_has_deduction(Variable): "Whether this benefit unit has money deducted from its Universal " "Credit award to repay debts (advances, government debt or third " "party deductions). Assigned from the DWP-published incidence of " - "deductions among UC households, adjusted by region." + "deductions among UC households, adjusted by region, to a unit " + "claiming UC with an award left after the benefit cap. A protected " + "floor reform limits the cap to the floor allowance, so a family the " + "cap takes to nil under current law can keep an award there. A " + "latent deduction rate counts only where this is true, including a " + "rate a dataset or situation supplies: to give a household situation " + "deductions, set this to true with the rate. Explicitly supplied " + "advance amounts and deduction rates apply whatever this says." ) entity = BenUnit definition_period = YEAR @@ -34,9 +41,18 @@ def formula(benunit, period, parameters): ) region = benunit.household("region", period) adjusted_incidence = clip(incidence * p.region_incidence_factor[region], 0, 1) + # The award deductions can come from: what the benefit cap leaves, + # where a protected floor limits the cap to the floor allowance + # (uc_maximum_reduction). Under current law the floor is zero, the + # allowance infinite, and this is the award after the gross cap. A + # family the gross cap takes to nil keeps an award under a binding + # floor, and allocate_uc_reductions meets deductions from it first. + cap_reduction = min_( + benunit("uc_benefit_cap_reduction", period), + benunit("uc_maximum_reduction", period), + ) award = max_( - benunit("universal_credit_pre_benefit_cap", period) - - benunit("uc_benefit_cap_reduction", period), + benunit("universal_credit_pre_benefit_cap", period) - cap_reduction, 0, ) on_uc = ( diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_latent_deduction_rate.py b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_latent_deduction_rate.py index e95bdfc1cd..c8de4430e7 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_latent_deduction_rate.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_latent_deduction_rate.py @@ -24,7 +24,9 @@ class uc_latent_deduction_rate(Variable): "same distribution under the higher caps then in force. Demand is " "held fixed as the cap moves, so cuts below the 15% cap overstate " "the constrained population while cap increases toward 25% stay " - "inside the observed window." + "inside the observed window. The rate is conditional on " + "uc_has_deduction: uc_deduction_rate counts it only where that flag " + "is true, so a supplied rate follows the same rule as this formula." ) entity = BenUnit definition_period = YEAR From d143bcf7777a0003b369e4659640b33ca064c074 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 22:47:53 -0400 Subject: [PATCH 2/5] Test stored deduction rates against main and incidence reforms Add two tests for a dataset that stores latent rates, as Microcosm does. One shows deductions, the applied cap and the award match origin/main under current law and any floor. The other shows a cut in incidence drops stored rates while a rise adds none. The docs now name every condition the flag checks and say what the floor limits. Co-Authored-By: Claude Opus 5.5 --- docs/book/validation/uc-deductions.md | 4 +- .../test_uc_deduction_flag_properties.py | 74 +++++++++++++++---- .../deductions/uc_has_deduction.py | 5 +- 3 files changed, 63 insertions(+), 20 deletions(-) diff --git a/docs/book/validation/uc-deductions.md b/docs/book/validation/uc-deductions.md index c018555aa5..27b3c7b1ea 100644 --- a/docs/book/validation/uc-deductions.md +++ b/docs/book/validation/uc-deductions.md @@ -159,6 +159,6 @@ Statutory parameters — the cap, the protected floor, the minimum payable penny The assignment formulas are an explicit fallback. The end state imputes `uc_latent_deduction_rate` and `uc_deduction_combination` at dataset build, at which point the model consumes them as plain inputs and the fallback retires; raw-FRS users keep working through the fallback until then. Assignment uses deterministic splitmix64 hashes of `benunit_id`, reproducible across runs and machines, and overridable by datasets or situations through `uc_deduction_random_draw` and `uc_deduction_type_random_draw`. Every simulation built from data gets the hashed draws, including a constituency or local authority filtered from the national data, and a benefit unit gets the same draw there as in the national run. Household situations get no deductions unless set explicitly. -`uc_has_deduction` decides which benefit units have deductions: a unit claiming Universal Credit, with an award left after the benefit cap, whose incidence draw falls below its region's incidence. The latent rate counts only for those units, whether the formula assigns it or a dataset or situation supplies it, so a supplied rate follows the same rule as the formula. A dataset that stores latent rates therefore needs the draws that assign them, and Microcosm stores both. To give a household situation deductions, set `uc_has_deduction` to true with `uc_latent_deduction_rate` and `uc_deduction_combination`. Alternatively, supply the advance cash (`uc_advance_repayment_amount`) or the rate itself (`uc_deduction_rate`). Those two describe a known deduction, so they apply whatever the flag says. +`uc_has_deduction` decides which benefit units have deductions: a unit claiming Universal Credit, with an award left after the benefit cap that carries deductions (`uc_award_carries_deductions`), whose incidence draw falls below its region's incidence. The latent rate counts only for those units, whether the formula assigns it or a dataset or situation supplies it, so a supplied rate follows the same rule as the formula. A dataset that stores latent rates therefore needs the draws that assign them, and Microcosm stores both. On such a dataset, a reform that lowers incidence drops the stored rates of the units the flag no longer assigns. One that raises it flags more units, but the dataset stores no rate for them, so it adds no deductions. To give a household situation deductions, set `uc_has_deduction` to true with `uc_latent_deduction_rate` and `uc_deduction_combination`. Alternatively, supply the advance cash (`uc_advance_repayment_amount`) or the rate itself (`uc_deduction_rate`). Those two describe a known deduction, so they apply whatever the flag says. -A protected floor reform limits the benefit cap to the floor allowance, so a family that the cap takes to nil under current law can keep an award. The flag reads the award left after the cap as the floor limits it. Such a family can therefore have deductions, which the floor meets first, as for any other family. Under current law the floor is zero, and the flag reads the award left after the full cap. +A protected floor reform limits what the benefit cap and deductions together take to the floor allowance (`uc_maximum_reduction`), so a family that the cap takes to nil under current law can keep an award. The flag reads the award left after the cap as the floor limits it. Such a family can therefore have deductions, which the floor meets first, as for any other family. Under current law the floor is zero, and the flag reads the award left after the full cap. diff --git a/policyengine_uk/tests/test_uc_deduction_flag_properties.py b/policyengine_uk/tests/test_uc_deduction_flag_properties.py index a8ccb81396..f9d696c4de 100644 --- a/policyengine_uk/tests/test_uc_deduction_flag_properties.py +++ b/policyengine_uk/tests/test_uc_deduction_flag_properties.py @@ -6,7 +6,8 @@ uc_has_deduction reads the award the benefit cap leaves; a protected floor reform limits the cap to the floor allowance (uc_maximum_reduction, infinite under current law). ``MAIN_RULE`` re-adds origin/main's flag, rate and -advance formulas at 7ed52038a, verbatim, so one dataset gives both rules. +advance formulas as of 7ed52038a, less their comments, so one dataset gives +both rules. Invariants, each over generated benefit units and explicit examples: @@ -35,6 +36,12 @@ the draw assigns (claimant or not), the formula's latent rate and combination, as Microcosm does, gives the formula path's flag, deductions, applied cap and award under any floor. +8. Stored attributes against main. On that dataset, deductions, the applied + cap and the award equal origin/main's under current law and under any + floor. +9. Incidence reforms on stored rates. A cut in incidence drops the + deductions of the units the flag no longer assigns. A rise flags more + units, but the dataset stores no rate for them, so it adds none. """ import numpy as np @@ -275,7 +282,8 @@ def test_the_issue_example_takes_no_deductions_without_the_flag(): def _main_rule(): """origin/main's uc_has_deduction, uc_deduction_rate and - uc_advance_repayment_amount formulas at 7ed52038a, verbatim.""" + uc_advance_repayment_amount formulas as of 7ed52038a, less their + comments.""" def estimated_advance_repayment_amount(benunit, period, parameters): p = parameters(period).gov.simulation.uc_deductions @@ -550,21 +558,55 @@ def test_stored_attributes_give_the_formula_path(units, floor): ) -def test_lowering_incidence_reaches_stored_rates(): - # A dataset that stores latent rates still answers a cut in incidence: - # the reformed flag drops some units, and their stored rates no longer - # count. On origin/main the stored rates kept every deduction. - _, stored_dataset = microcosm_style(DATA_EXAMPLE, 0.0) - stored = Microsimulation(dataset=stored_dataset()) - cut = { +@data_settings +@given( + units=st.lists(data_unit_strategy, min_size=1, max_size=24), + floor=floor_strategy, +) +@example(units=DATA_EXAMPLE, floor=0.85) +@example(units=DATA_EXAMPLE, floor=0.0) +def test_stored_attributes_give_mains_deductions(units, floor): + # Under a floor, main's flag stays false for an assigned family the cap + # takes to nil, but its stored rate gave it deductions anyway; head's + # flag is true there, so the same rate counts. + _, stored_dataset = microcosm_style(units, floor) + head = Microsimulation(dataset=stored_dataset(), reform=floor_reform(floor)) + main = Microsimulation( + dataset=stored_dataset(), + reform=(floor_reform(floor), MAIN_RULE) if floor else MAIN_RULE, + ) + for variable in DEDUCTION_OUTPUTS: + np.testing.assert_allclose( + values(head, variable), + values(main, variable), + rtol=0, + atol=1e-6, + err_msg=variable, + ) + + +def incidence_reform(factor): + return { f"gov.simulation.uc_deductions.region_incidence_factor.{region}": { - f"{YEAR}-01-01.{YEAR}-12-31": 0.3 + f"{YEAR}-01-01.{YEAR}-12-31": factor } for region in REGIONS } - reformed = Microsimulation(dataset=stored_dataset(), reform=cut) - before = values(stored, "uc_deductions") > 0 - after = values(reformed, "uc_deductions") > 0 - flag = values(reformed, "uc_has_deduction").astype(bool) - assert np.all(flag[after]) - assert after.sum() < before.sum() + + +def test_stored_rates_follow_a_cut_in_incidence_but_not_a_rise(): + # On origin/main the stored rates kept every deduction whatever the + # incidence. + _, stored_dataset = microcosm_style(DATA_EXAMPLE, 0.0) + stored = Microsimulation(dataset=stored_dataset()) + before = values(stored, "uc_deductions") + flagged = values(stored, "uc_has_deduction").astype(bool) + + cut = Microsimulation(dataset=stored_dataset(), reform=incidence_reform(0.3)) + after = values(cut, "uc_deductions") > 0 + assert np.all(values(cut, "uc_has_deduction").astype(bool)[after]) + assert after.sum() < (before > 0).sum() + + rise = Microsimulation(dataset=stored_dataset(), reform=incidence_reform(2.0)) + assert values(rise, "uc_has_deduction").astype(bool).sum() > flagged.sum() + np.testing.assert_array_equal(values(rise, "uc_deductions"), before) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_has_deduction.py b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_has_deduction.py index 129a41a8d0..b8b7e2d526 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_has_deduction.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_has_deduction.py @@ -9,8 +9,9 @@ class uc_has_deduction(Variable): "party deductions). Assigned from the DWP-published incidence of " "deductions among UC households, adjusted by region, to a unit " "claiming UC with an award left after the benefit cap. A protected " - "floor reform limits the cap to the floor allowance, so a family the " - "cap takes to nil under current law can keep an award there. A " + "floor reform limits what the cap and deductions together take to " + "the floor allowance, so a family the cap takes to nil under current " + "law can keep an award there. A " "latent deduction rate counts only where this is true, including a " "rate a dataset or situation supplies: to give a household situation " "deductions, set this to true with the rate. Explicitly supplied " From d98c5b7f407838e6597b6646b82f9dde3d9e9805 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 22:55:28 -0400 Subject: [PATCH 3/5] Pin the flag false where a floor leaves a capped family no award MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An 85% floor allowance of £720.25 exceeds a £500 award that the cap takes whole, so the floor does not bind and the family keeps no award for deductions to come from. Co-Authored-By: Claude Opus 5.5 --- .../universal_credit/uc_deductions.yaml | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_deductions.yaml index 563556c54d..a0360415ef 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_deductions.yaml @@ -327,6 +327,36 @@ uc_benefit_cap_reduction_after_floor: 1_000 universal_credit: 0 +- name: A floor that leaves a family the cap takes to nil no award leaves it without the flag + # The 85% floor allowance, 0.15 x 400.14 x 12 = 720.25, exceeds the £500 + # award, so the cap still takes all of it and the floor does not bind. + # Nothing is left for deductions to come from. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.dwp.universal_credit.deductions.protected_floor: 0.85 + people: + person: + age: 30 + benunits: + benunit: + members: person + would_claim_uc: true + universal_credit_pre_benefit_cap: 500 + uc_benefit_cap_reduction: 1_000 + uc_deduction_random_draw: 0.1 + uc_latent_deduction_rate: 0.30 + uc_deduction_combination: ADVANCE_ONLY + households: + household: + members: person + output: + uc_maximum_reduction: 720.25 + uc_has_deduction: false + uc_deductions: 0 + uc_benefit_cap_reduction_after_floor: 500 + universal_credit: 0 + - name: An explicitly supplied advance amount applies whatever the flag says # uc_advance_repayment_amount describes a known advance, so the flag does # not gate it. From e17612ec37b5cf5f8a0069ced80eb3075c84ff20 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 23:15:55 -0400 Subject: [PATCH 4/5] Tie the deduction flag to the allocation's award in a property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For a claimant the incidence draw assigns, uc_has_deduction now must hold exactly where an award remains after the cap reduction that allocate_uc_reductions applies with no deductions, under any floor. A new example family has a £500 award the cap takes whole, below an 85% floor's £720.25 allowance. Co-Authored-By: Claude Opus 5.5 --- .../test_uc_deduction_flag_properties.py | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/policyengine_uk/tests/test_uc_deduction_flag_properties.py b/policyengine_uk/tests/test_uc_deduction_flag_properties.py index f9d696c4de..6a535438bd 100644 --- a/policyengine_uk/tests/test_uc_deduction_flag_properties.py +++ b/policyengine_uk/tests/test_uc_deduction_flag_properties.py @@ -26,22 +26,26 @@ 4. Accounting. For a claimant with an award, uc_deductions plus uc_benefit_cap_reduction_after_floor plus universal_credit equals the award before the cap. -5. Current law. With no floor, a dataset without stored attributes (the +5. The flag's award. For a claimant whose incidence draw assigns + deductions, uc_has_deduction holds exactly where an award remains after + the cap reduction that allocate_uc_reductions applies to the same family + with no deductions, under any floor. +6. Current law. With no floor, a dataset without stored attributes (the formula path) gives the same flag, rates, deductions, applied cap and award as origin/main's rule, bit for bit. -6. Floor. Under a floor, the formula path moves only the split between +7. Floor. Under a floor, the formula path moves only the split between deductions and the applied cap for families the cap takes to nil; their award rises by less than the 12p a year the penny rule keeps. -7. Stored attributes. A dataset that stores the draws and, for every unit +8. Stored attributes. A dataset that stores the draws and, for every unit the draw assigns (claimant or not), the formula's latent rate and combination, as Microcosm does, gives the formula path's flag, deductions, applied cap and award under any floor. -8. Stored attributes against main. On that dataset, deductions, the applied +9. Stored attributes against main. On that dataset, deductions, the applied cap and the award equal origin/main's under current law and under any floor. -9. Incidence reforms on stored rates. A cut in incidence drops the - deductions of the units the flag no longer assigns. A rise flags more - units, but the dataset stores no rate for them, so it adds none. +10. Incidence reforms on stored rates. A cut in incidence drops the + deductions of the units the flag no longer assigns. A rise flags more + units, but the dataset stores no rate for them, so it adds none. """ import numpy as np @@ -180,11 +184,14 @@ def family(**overrides): # Supplied flags: the issue's family flagged by hand, known advance cash # without the flag (the documented exception), and a non-claimant # flagged by hand. Drawn: the issue's family, which a floor leaves an - # award, and a rate whose draw assigns nothing. + # award, a rate whose draw assigns nothing, and the family below. family(pre_cap=1_000.0, gross_cap=1_000.0, latent=0.30, flag=True), family(advance=360.0), family(latent=0.30, combination="GOVERNMENT_ONLY", flag=True, claims=False), family(combination="ALL_THREE"), + # The cap takes this family's whole £500 award, and an 85% floor's + # £720.25 allowance exceeds it: the floor does not bind, nothing is left. + family(pre_cap=500.0, gross_cap=1_000.0, latent=0.30, draw=0.1), ] @@ -236,18 +243,25 @@ def test_the_flag_gates_a_supplied_latent_rate(units, drawn, floor): demand = values(ungated, "uc_deduction_rate") * values( ungated, "uc_standard_allowance" ) + gross_cap = values(sim, "uc_benefit_cap_reduction") + max_reduction = values(sim, "uc_maximum_reduction") expected, _ = allocate_uc_reductions( - pre_cap, - values(sim, "uc_benefit_cap_reduction"), - demand, - values(sim, "uc_maximum_reduction"), - PENNY_A_YEAR, + pre_cap, gross_cap, demand, max_reduction, PENNY_A_YEAR ) assigned = np.array([unit["draw"] < 0.4 for unit in units]) & claims np.testing.assert_allclose( deductions[assigned], expected[assigned], rtol=0, atol=1e-3 ) + # 5. The flag marks an assigned claimant exactly where the cap, as + # the allocation applies it with no deductions, leaves an award. + _, cap_alone = allocate_uc_reductions( + pre_cap, gross_cap, np.zeros_like(pre_cap), max_reduction, PENNY_A_YEAR + ) + np.testing.assert_array_equal( + flag[assigned], (pre_cap - cap_alone)[assigned] > 0 + ) + # 4. The components sum to the award before the cap. with_award = claims & (pre_cap > 0) total = ( From 16d06d8c241184203f86a21f35c67951ec9d90ce Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 02:16:24 -0400 Subject: [PATCH 5/5] Pin the deduction flag's known-input exceptions and draw boundary Co-Authored-By: Claude Opus 5.5 --- docs/book/validation/uc-deductions.md | 2 +- .../test_uc_deduction_flag_properties.py | 63 ++++++++++++++++++- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/docs/book/validation/uc-deductions.md b/docs/book/validation/uc-deductions.md index 27b3c7b1ea..5917393217 100644 --- a/docs/book/validation/uc-deductions.md +++ b/docs/book/validation/uc-deductions.md @@ -159,6 +159,6 @@ Statutory parameters — the cap, the protected floor, the minimum payable penny The assignment formulas are an explicit fallback. The end state imputes `uc_latent_deduction_rate` and `uc_deduction_combination` at dataset build, at which point the model consumes them as plain inputs and the fallback retires; raw-FRS users keep working through the fallback until then. Assignment uses deterministic splitmix64 hashes of `benunit_id`, reproducible across runs and machines, and overridable by datasets or situations through `uc_deduction_random_draw` and `uc_deduction_type_random_draw`. Every simulation built from data gets the hashed draws, including a constituency or local authority filtered from the national data, and a benefit unit gets the same draw there as in the national run. Household situations get no deductions unless set explicitly. -`uc_has_deduction` decides which benefit units have deductions: a unit claiming Universal Credit, with an award left after the benefit cap that carries deductions (`uc_award_carries_deductions`), whose incidence draw falls below its region's incidence. The latent rate counts only for those units, whether the formula assigns it or a dataset or situation supplies it, so a supplied rate follows the same rule as the formula. A dataset that stores latent rates therefore needs the draws that assign them, and Microcosm stores both. On such a dataset, a reform that lowers incidence drops the stored rates of the units the flag no longer assigns. One that raises it flags more units, but the dataset stores no rate for them, so it adds no deductions. To give a household situation deductions, set `uc_has_deduction` to true with `uc_latent_deduction_rate` and `uc_deduction_combination`. Alternatively, supply the advance cash (`uc_advance_repayment_amount`) or the rate itself (`uc_deduction_rate`). Those two describe a known deduction, so they apply whatever the flag says. +`uc_has_deduction` decides which benefit units have deductions: a unit claiming Universal Credit, with an award left after the benefit cap that carries deductions (`uc_award_carries_deductions`), whose incidence draw falls below its region's incidence. The latent rate counts only for those units, whether the formula assigns it or a dataset or situation supplies it, so a supplied rate follows the same rule as the formula. A dataset that stores latent rates therefore needs the draws that assign them, and Microcosm stores both. On such a dataset, a reform that lowers incidence drops the stored rates of the units the flag no longer assigns. One that raises it flags more units, but the dataset stores no rate for them, so it adds no deductions. A unit that keeps the flag keeps its stored rate, while the formula rescales each draw by the new incidence, so stored rates don't cost an incidence reform in full. To give a household situation deductions, set `uc_has_deduction` to true with `uc_latent_deduction_rate` and `uc_deduction_combination`. Alternatively, supply the advance cash (`uc_advance_repayment_amount`) or the rate itself (`uc_deduction_rate`). Those two describe a known deduction, so they apply whatever the flag says. A protected floor reform limits what the benefit cap and deductions together take to the floor allowance (`uc_maximum_reduction`), so a family that the cap takes to nil under current law can keep an award. The flag reads the award left after the cap as the floor limits it. Such a family can therefore have deductions, which the floor meets first, as for any other family. Under current law the floor is zero, and the flag reads the award left after the full cap. diff --git a/policyengine_uk/tests/test_uc_deduction_flag_properties.py b/policyengine_uk/tests/test_uc_deduction_flag_properties.py index 6a535438bd..77a004c2c2 100644 --- a/policyengine_uk/tests/test_uc_deduction_flag_properties.py +++ b/policyengine_uk/tests/test_uc_deduction_flag_properties.py @@ -13,9 +13,9 @@ 1. Flag gate. Where uc_has_deduction is false, uc_deductions is zero, for any supplied latent rate, combination, benefit cap reduction and floor. - The documented exception: an explicitly supplied - uc_advance_repayment_amount describes a known advance and applies - whatever the flag says. + The documented exceptions: an explicitly supplied + uc_advance_repayment_amount or uc_deduction_rate describes a known + deduction and applies whatever the flag says. 2. Substitution. A supplied latent rate gives the same deductions, applied cap reduction and award as where(uc_has_deduction, rate, 0), the rate the formula's rule leaves. @@ -290,6 +290,63 @@ def test_the_issue_example_takes_no_deductions_without_the_flag(): ) +def known_deduction_situation(age=30, **benunit_inputs): + """One claimant with a £6,000 award and no cap, in a household in London, + who supplies only ``benunit_inputs`` towards deductions.""" + return { + "people": {"person": {"age": {YEAR: age}}}, + "benunits": { + "benunit": { + "members": ["person"], + "would_claim_uc": {YEAR: True}, + "universal_credit_pre_benefit_cap": {YEAR: 6_000.0}, + "uc_benefit_cap_reduction": {YEAR: 0.0}, + **{name: {YEAR: value} for name, value in benunit_inputs.items()}, + } + }, + "households": {"household": {"members": ["person"]}}, + } + + +def test_a_supplied_deduction_rate_applies_whatever_the_flag_says(): + sim = Simulation( + situation=known_deduction_situation( + uc_has_deduction=False, uc_deduction_rate=0.1 + ) + ) + assert not values(sim, "uc_has_deduction")[0] + assert values(sim, "uc_deductions")[0] == pytest.approx( + 0.1 * STANDARD_ALLOWANCE, abs=0.01 + ) + + +def test_a_draw_equal_to_the_incidence_assigns_no_deductions(): + # Situations default to a draw of 1, and a factor of 3 clips London's + # incidence to 1. + sim = Simulation( + situation=known_deduction_situation(), reform=incidence_reform(3.0) + ) + assert not values(sim, "uc_has_deduction")[0] + assert values(sim, "uc_deductions")[0] == 0 + + +def test_a_floor_uses_a_young_claimants_standard_allowance(): + # £316.98 a month under 25, and an 85% floor limits what the cap and + # deductions take to 15% of that year's allowance, met from deductions + # first. + situation_under_25 = known_deduction_situation( + age=24, + uc_deduction_random_draw=0.1, + uc_latent_deduction_rate=0.25, + uc_deduction_combination="ADVANCE_ONLY", + ) + benunit = situation_under_25["benunits"]["benunit"] + benunit["universal_credit_pre_benefit_cap"] = {YEAR: 600.0} + benunit["uc_benefit_cap_reduction"] = {YEAR: 600.0} + sim = Simulation(situation=situation_under_25, reform=floor_reform(0.85)) + assert values(sim, "uc_deductions")[0] == pytest.approx(0.15 * 3_803.76, abs=0.01) + + # Datasets: hashed draws, so the flag, rate and combination come from their # formulas unless stored.