From 598c3bf555cabbaa39e83b6e612bf3cebec43502 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 13:30:41 -0400 Subject: [PATCH 1/5] Apply the UC childcare work condition's partner exceptions (reg 32) UC Regs 2013 reg 32(1)(b): the other member of a couple who is not in paid work still lets the claimant meet the work condition if they have limited capability for work, have regular and substantial caring responsibilities for a severely disabled person, or are temporarily absent from the claimant's household. Reg 32(2)(b) treats a claimant receiving SSP, SMP, SPP or maternity allowance as in paid work, and reg 32(1)(a) also accepts an offer of paid work for the claimant's limb. The condition is now pairwise: one claimant meets limb (a) and every other claimant meets limb (b). Limb (i) uses uc_limited_capability_for_WRA, (ii) is_carer_for_benefits, (iii) a new input. Adds YAML tests from the law and ADM F7011-F7015, and property tests including an exhaustive differential check against a reference transcribed from reg 32. Co-Authored-By: Claude Opus 5.5 --- .../uc-childcare-partner-exceptions.added.md | 1 + .../uc-childcare-partner-exceptions.fixed.md | 1 + .../uc_childcare_work_condition.yaml | 5 +- .../uc_childcare_work_condition_reg_32.yaml | 453 ++++++++++++++++++ ..._uc_childcare_work_condition_properties.py | 309 ++++++++++++ .../uc_childcare_treated_as_in_paid_work.py | 39 ++ .../uc_childcare_work_condition.py | 49 +- .../uc_has_offer_of_paid_work.py | 26 + .../uc_unable_to_provide_childcare.py | 42 ++ ...porarily_absent_from_claimant_household.py | 27 ++ 10 files changed, 943 insertions(+), 9 deletions(-) create mode 100644 changelog.d/uc-childcare-partner-exceptions.added.md create mode 100644 changelog.d/uc-childcare-partner-exceptions.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml create mode 100644 policyengine_uk/tests/test_uc_childcare_work_condition_properties.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_childcare_treated_as_in_paid_work.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_has_offer_of_paid_work.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_unable_to_provide_childcare.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/uc_is_temporarily_absent_from_claimant_household.py diff --git a/changelog.d/uc-childcare-partner-exceptions.added.md b/changelog.d/uc-childcare-partner-exceptions.added.md new file mode 100644 index 0000000000..5b01ecba1d --- /dev/null +++ b/changelog.d/uc-childcare-partner-exceptions.added.md @@ -0,0 +1 @@ +- Add `uc_has_offer_of_paid_work` (reg 32(1)(a)) and `uc_is_temporarily_absent_from_claimant_household` (reg 32(1)(b)(iii)) inputs, and `uc_childcare_treated_as_in_paid_work` and `uc_unable_to_provide_childcare` for the Universal Credit childcare work condition. diff --git a/changelog.d/uc-childcare-partner-exceptions.fixed.md b/changelog.d/uc-childcare-partner-exceptions.fixed.md new file mode 100644 index 0000000000..60382d9107 --- /dev/null +++ b/changelog.d/uc-childcare-partner-exceptions.fixed.md @@ -0,0 +1 @@ +- Apply the Universal Credit childcare work condition's partner exceptions and treated-as-working rules (UC Regs 2013 reg 32). A couple now meets it when one member is in paid work and the other is in paid work or unable to provide childcare because of limited capability for work, caring for a severely disabled person or temporary absence. A claimant receiving statutory sick, maternity or paternity pay or maternity allowance is treated as in paid work. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition.yaml index 55a589cd02..fb874c9956 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition.yaml @@ -200,7 +200,8 @@ output: # Reg. 32(1)(b) applies to a partner regardless of age. # https://www.legislation.gov.uk/uksi/2013/376/regulation/32 - # No partner exception is modelled: failed work condition * 5,000 * 85% - # = 0, formerly 4,250 when the partner was excluded by the age-18 flag. + # The partner neither works nor meets an exception in (b)(i)-(iii): + # failed work condition * 5,000 * 85% = 0, formerly 4,250 when the + # partner was excluded by the age-18 flag. uc_childcare_work_condition: false uc_childcare_element: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml new file mode 100644 index 0000000000..82d6e1ea00 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml @@ -0,0 +1,453 @@ +# Universal Credit Regulations 2013 reg. 32, the childcare work condition: +# https://www.legislation.gov.uk/uksi/2013/376/regulation/32 +# "(1) The work condition is met in respect of an assessment period if— +# (a) the claimant is in paid work or has an offer of paid work that is due +# to start before the end of the next assessment period; and +# (b) if the claimant is a member of a couple (whether claiming jointly or as +# a single person), the other member is either in paid work or is unable +# to provide childcare because that person— (i) has limited capability +# for work, (ii) has regular and substantial caring responsibilities for +# a severely disabled person, or (iii) is temporarily absent from the +# claimant's household." +# Reg. 32(2)(b): a claimant receiving statutory sick pay, statutory maternity +# pay, statutory paternity pay, ... or a maternity allowance is treated as in +# paid work. ADM ch. F7, F7011-F7015, agrees: +# https://assets.publishing.service.gov.uk/media/696a076c7b7f37aa8e4022d9/adm-ch-f7.pdf +# +# Where an element amount is checked, it is reg. 34 for one child in 2025-26: +# min(5,000 * 85%, 1,031.88 * 12) = 4,250. + +- name: Reg. 32(1)(b)(i) - a working claimant's partner with limited capability for work + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 35 + is_parent: true + employment_income: 15_000 + partner: + age: 34 + uc_limited_capability_for_WRA: true + child: + age: 3 + childcare_expenses: 5_000 + benunits: + benunit: + members: [claimant, partner, child] + households: + household: + members: [claimant, partner, child] + output: + # (a) the claimant works; (b) the partner does not, but has limited + # capability for work, so is unable to provide childcare. + uc_unable_to_provide_childcare: [false, true, false] + uc_childcare_work_condition: true + uc_childcare_element: 4_250 + +- name: Reg. 32(1)(b)(i) - the limited capability proxy follows is_disabled_for_benefits + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 35 + employment_income: 15_000 + partner: + age: 34 + is_disabled_for_benefits: true + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + # uc_limited_capability_for_WRA defaults to is_disabled_for_benefits. + uc_limited_capability_for_WRA: [false, true] + uc_childcare_work_condition: true + +- name: Reg. 32(1)(b)(ii) - ADM F7013 example, a partner caring full time for a disabled relative + period: 2025 + absolute_error_margin: 0.01 + input: + people: + ben: + age: 40 + is_parent: true + employment_income: 9_000 + sara: + age: 38 + care_hours: 40 + pauline: + age: 4 + childcare_expenses: 5_000 + benunits: + benunit: + members: [ben, sara, pauline] + households: + household: + members: [ben, sara, pauline] + output: + # Ben works two days a week; Sara provides full-time care, at least the + # 35 hours of reg. 30 / CA, so "both Ben and Sara satisfy the paid work + # test" (ADM F7013). + is_carer_for_benefits: [false, true, false] + uc_childcare_work_condition: true + uc_childcare_element: 4_250 + +- name: Reg. 32(1)(b)(ii) - a partner receiving Carer's Allowance + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 35 + employment_income: 15_000 + partner: + age: 34 + receives_carers_allowance: true + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + # Reg. 30(1)(a): satisfies the conditions for Carer's Allowance. + uc_unable_to_provide_childcare: [false, true] + uc_childcare_work_condition: true + +- name: Reg. 32(1)(b)(iii) - a partner temporarily absent from the claimant's household + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 35 + is_parent: true + employment_income: 15_000 + partner: + age: 34 + uc_is_temporarily_absent_from_claimant_household: true + child: + age: 3 + childcare_expenses: 5_000 + benunits: + benunit: + members: [claimant, partner, child] + households: + household: + members: [claimant, partner, child] + output: + uc_unable_to_provide_childcare: [false, true, false] + uc_childcare_work_condition: true + uc_childcare_element: 4_250 + +- name: Reg. 32(1)(b) - either joint claimant can be the one in paid work + period: 2025 + absolute_error_margin: 0 + input: + people: + head: + age: 35 + uc_limited_capability_for_WRA: true + partner: + age: 34 + employment_income: 15_000 + benunits: + benunit: + members: [head, partner] + households: + household: + members: [head, partner] + output: + # Each joint claimant is "the claimant" (WRA 2012 s. 40): the working + # partner meets (a), the head (limited capability for work) meets (b). + uc_childcare_work_condition: true + +- name: Reg. 32(1)(b) - a working partner of a nonworking claimant without an exception + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 35 + is_parent: true + employment_income: 15_000 + partner: + age: 34 + child: + age: 3 + childcare_expenses: 5_000 + benunits: + benunit: + members: [claimant, partner, child] + households: + household: + members: [claimant, partner, child] + output: + uc_unable_to_provide_childcare: [false, false, false] + uc_childcare_work_condition: false + uc_childcare_element: 0 + +- name: Reg. 32(1)(a) - the exceptions do not replace the claimant's own paid work + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 35 + uc_limited_capability_for_WRA: true + partner: + age: 34 + care_hours: 40 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + # Neither member is in paid work, so neither meets (a): ADM F7013 applies + # "where only one of the couple is in paid work". + uc_unable_to_provide_childcare: [true, true] + uc_childcare_work_condition: false + +- name: Reg. 32(1)(a) - a nonworking single claimant with limited capability for work + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 35 + uc_limited_capability_for_WRA: true + output: + # Limb (b) and its exceptions apply only to the other member of a couple. + uc_childcare_work_condition: false + +- name: Reg. 32(2)(b) - a single claimant receiving statutory maternity pay + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 30 + is_parent: true + statutory_maternity_pay: 6_000 + child: + age: 0 + childcare_expenses: 5_000 + benunits: + benunit: + members: [claimant, child] + households: + household: + members: [claimant, child] + output: + in_work: [false, false] + uc_childcare_treated_as_in_paid_work: [true, false] + uc_childcare_work_condition: true + uc_childcare_element: 4_250 + +- name: Reg. 32(2)(b) - a single claimant receiving statutory sick pay + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 30 + statutory_sick_pay: 1_500 + output: + uc_childcare_treated_as_in_paid_work: true + uc_childcare_work_condition: true + +- name: Reg. 32(2)(b) - a single claimant receiving statutory paternity pay + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 30 + statutory_paternity_pay: 400 + output: + uc_childcare_treated_as_in_paid_work: true + uc_childcare_work_condition: true + +- name: Reg. 32(2)(b) - a single claimant receiving maternity allowance + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 30 + maternity_allowance_reported: 5_000 + output: + uc_childcare_treated_as_in_paid_work: true + uc_childcare_work_condition: true + +- name: Reg. 32(2)(b) - a claimant on statutory sick pay with a partner with limited capability for work + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 35 + statutory_sick_pay: 1_500 + partner: + age: 34 + uc_limited_capability_for_WRA: true + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + # The claimant is treated as in paid work (32(2)(b)) and the partner is + # unable to provide childcare (32(1)(b)(i)). + uc_childcare_work_condition: true + +- name: Reg. 32(2)(b) - joint claimants both treated as in paid work + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 35 + statutory_sick_pay: 1_500 + partner: + age: 34 + statutory_maternity_pay: 6_000 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + # Reg. 32(2) treats "a claimant" as in paid work, and each joint claimant + # is a claimant (WRA 2012 s. 40), so each is treated as in paid work. + uc_childcare_treated_as_in_paid_work: [true, true] + uc_childcare_work_condition: true + +- name: Reg. 32(2)(b) - a partner on statutory maternity pay with a working claimant + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 35 + is_parent: true + employment_income: 15_000 + partner: + age: 34 + statutory_maternity_pay: 6_000 + child: + age: 0 + childcare_expenses: 5_000 + benunits: + benunit: + members: [claimant, partner, child] + households: + household: + members: [claimant, partner, child] + output: + uc_childcare_work_condition: true + uc_childcare_element: 4_250 + +- name: Reg. 32(1)(a) - a single claimant with an offer of paid work + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 30 + uc_has_offer_of_paid_work: true + output: + # ADM F7011 example 1: Colin has secured a job due to start before the + # end of the next assessment period. + uc_childcare_work_condition: true + +- name: Reg. 32(1)(a) - a claimant with an offer of paid work and a working partner + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 35 + uc_has_offer_of_paid_work: true + partner: + age: 34 + employment_income: 15_000 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + # The claimant meets (a) with the offer; the partner meets (b) by working. + uc_childcare_work_condition: true + +- name: Reg. 32(1)(a) - a claimant with an offer of paid work and a partner with limited capability for work + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 35 + uc_has_offer_of_paid_work: true + partner: + age: 34 + uc_limited_capability_for_WRA: true + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + uc_childcare_work_condition: true + +- name: Reg. 32(1)(a) - a claimant with an offer of paid work and a nonworking partner + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 35 + uc_has_offer_of_paid_work: true + partner: + age: 34 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + uc_childcare_work_condition: false + +- name: Reg. 32(1)(b) - an offer of paid work does not meet the other member's limb + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 35 + uc_has_offer_of_paid_work: true + partner: + age: 34 + uc_has_offer_of_paid_work: true + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + # Whichever member is taken as the claimant meets (a) with an offer, but + # (b) requires the other member to be "in paid work" or unable to provide + # childcare; an offer is neither. + uc_childcare_work_condition: false diff --git a/policyengine_uk/tests/test_uc_childcare_work_condition_properties.py b/policyengine_uk/tests/test_uc_childcare_work_condition_properties.py new file mode 100644 index 0000000000..147835ea30 --- /dev/null +++ b/policyengine_uk/tests/test_uc_childcare_work_condition_properties.py @@ -0,0 +1,309 @@ +"""Property-based tests for the Universal Credit childcare work condition. + +Universal Credit Regulations 2013 reg. 32(1): the condition is met if (a) the +claimant is in paid work or has an offer of paid work due to start before the +end of the next assessment period, and (b) for a couple, the other member is +in paid work or is unable to provide childcare because they (i) have limited +capability for work, (ii) have regular and substantial caring +responsibilities for a severely disabled person, or (iii) are temporarily +absent from the claimant's household. Reg. 32(2)(b) treats a claimant +receiving statutory sick, maternity or paternity pay or maternity allowance +as in paid work. Each joint claimant is a claimant (WRA 2012 s. 40). + +Invariants, for any generated population of single claimants and couples, +each with a dependant child or young person: + +1. Differential: the condition equals a reference transcribed from reg. 32 in + its pairwise form, (a)(A) and (b)(B), or (a)(B) and (b)(A). +2. Monotonic: giving any member an exception, a listed payment, paid work or + an offer of paid work never removes the condition. +3. A couple who are both in paid work always meet the condition. +4. Without a member meeting limb (a), no exception meets the condition. +5. Swapping the two members of a couple does not change the result. +6. A dependant's work, payments and circumstances never change the result. +7. The childcare element is zero whenever the condition fails, and never + negative. + +Every member's is_claimant_or_partner is set from its generated role, as the +FRS supplies it, and every attribute is set for every member: setting an +input for anyone makes it an input for everyone. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2025 +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# Reg. 32(2)(b) payments the model has variables for. +PAYMENTS = [ + None, + "statutory_sick_pay", + "statutory_maternity_pay", + "statutory_paternity_pay", + "maternity_allowance_reported", +] +CARE_HOURS = [0, 20, 34, 35, 50] +# Attributes that can only add a route to the condition. +WIDENING = ["works", "payment", "offer", "lcw", "carer", "absent"] + + +@st.composite +def members(draw): + # Each member has a small set of routes, so that members with exactly one + # route (an offer only, an exception only) are common. + routes = draw(st.sets(st.sampled_from(WIDENING), max_size=3)) + payments = PAYMENTS[1:] if "payment" in routes else [None] + carer_hours = [ + h for h in CARE_HOURS if (h >= MIN_CARE_HOURS) == ("carer" in routes) + ] + return dict( + works="works" in routes, + payment=draw(st.sampled_from(payments)), + offer="offer" in routes, + lcw="lcw" in routes, + care_hours=draw(st.sampled_from(carer_hours)), + absent="absent" in routes, + ) + + +@st.composite +def families(draw): + adults = draw(st.lists(members(), min_size=1, max_size=2)) + return dict( + adults=adults, + dependant=draw(members()), + dependant_age=draw(st.sampled_from([3, 17, 18])), + childcare=draw(st.sampled_from([0.0, 2_000.0, 20_000.0])), + ) + + +def person_inputs(member, age, claimant_or_partner): + inputs = { + "age": age, + "is_claimant_or_partner": claimant_or_partner, + "employment_income": 12_000.0 if member["works"] else 0.0, + "hours_worked": 0.0, + "uc_has_offer_of_paid_work": member["offer"], + "uc_limited_capability_for_WRA": member["lcw"], + "care_hours": float(member["care_hours"]), + "uc_is_temporarily_absent_from_claimant_household": member["absent"], + } + for payment in PAYMENTS[1:]: + inputs[payment] = 1_000.0 if member["payment"] == payment else 0.0 + return {name: {YEAR: value} for name, value in inputs.items()} + + +def situation(units, with_dependants=True): + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + names = [] + for j, adult in enumerate(unit["adults"]): + name = f"p{i}_{j}" + people[name] = person_inputs(adult, 30 + j, True) + names.append(name) + if with_dependants: + name = f"p{i}_dependant" + people[name] = person_inputs( + unit["dependant"], unit["dependant_age"], False + ) + people[name]["childcare_expenses"] = {YEAR: unit["childcare"]} + if unit["dependant_age"] >= 16: + people[name]["current_education"] = {YEAR: "UPPER_SECONDARY"} + names.append(name) + benunits[f"b{i}"] = {"members": names} + households[f"h{i}"] = {"members": names} + return {"people": people, "benunits": benunits, "households": households} + + +def calculate(units, with_dependants=True): + sim = Simulation(situation=situation(units, with_dependants)) + return { + variable: np.asarray(sim.calculate(variable, YEAR)) + for variable in ["uc_childcare_work_condition", "uc_childcare_element"] + } + + +def min_care_hours(): + sim = Simulation(situation={"people": {"p": {"age": {YEAR: 30}}}}) + return sim.tax_benefit_system.parameters(YEAR).gov.dwp.carers_allowance.min_hours + + +MIN_CARE_HOURS = min_care_hours() + + +def meets_claimant_limb(member): + # Reg. 32(1)(a) with reg. 32(2)(b). + return member["works"] or member["payment"] is not None or member["offer"] + + +def meets_other_member_limb(member): + # Reg. 32(1)(b); joint claimants also have reg. 32(2)(b). + unable = member["lcw"] or member["care_hours"] >= MIN_CARE_HOURS or member["absent"] + return member["works"] or member["payment"] is not None or unable + + +def reference(unit): + adults = unit["adults"] + if len(adults) == 1: + return meets_claimant_limb(adults[0]) + first, second = adults + return (meets_claimant_limb(first) and meets_other_member_limb(second)) or ( + meets_claimant_limb(second) and meets_other_member_limb(first) + ) + + +def with_member(unit, index, **changes): + adults = [dict(adult) for adult in unit["adults"]] + adults[index].update(changes) + return dict(unit, adults=adults) + + +WIDEN = { + "works": dict(works=True), + "payment": dict(payment="statutory_sick_pay"), + "offer": dict(offer=True), + "lcw": dict(lcw=True), + "carer": dict(care_hours=50), + "absent": dict(absent=True), +} + + +@PROPERTY_SETTINGS +@given(st.lists(families(), min_size=1, max_size=30)) +def test_condition_matches_the_regulation_32_reference(units): + values = calculate(units) + for i, unit in enumerate(units): + assert bool(values["uc_childcare_work_condition"][i]) == reference(unit), unit + + +def route_sets(): + return [ + {route for k, route in enumerate(WIDENING) if mask >> k & 1} + for mask in range(2 ** len(WIDENING)) + ] + + +def member_with(routes, payment, care_hours): + return dict( + works="works" in routes, + payment=payment if "payment" in routes else None, + offer="offer" in routes, + lcw="lcw" in routes, + care_hours=care_hours if "carer" in routes else 0, + absent="absent" in routes, + ) + + +def test_condition_matches_the_reference_for_every_combination_of_routes(): + # Exhaustive: every single claimant's set of routes with every listed + # payment, and every pair of route sets for a couple, the payment and the + # caring hours cycling across pairs. + sets = route_sets() + singles = [ + dict(adults=[member_with(routes, payment, MIN_CARE_HOURS)]) + for routes in sets + for payment in PAYMENTS[1:] + ] + couples = [ + dict( + adults=[ + member_with(first, PAYMENTS[1 + (i + j) % 4], MIN_CARE_HOURS), + member_with(second, PAYMENTS[1 + (i + 2 * j) % 4], 50), + ] + ) + for i, first in enumerate(sets) + for j, second in enumerate(sets) + ] + units = singles + couples + met = calculate(units, with_dependants=False)["uc_childcare_work_condition"] + expected = np.array([reference(unit) for unit in units]) + mismatches = [unit for unit, m, e in zip(units, met, expected) if m != e] + assert not mismatches, mismatches[:5] + + +@PROPERTY_SETTINGS +@given( + st.lists( + st.tuples(families(), st.integers(0, 1), st.sampled_from(WIDENING)), + min_size=1, + max_size=30, + ) +) +def test_adding_an_exception_or_work_never_removes_the_condition(cases): + units = [unit for unit, _, _ in cases] + widened = [ + with_member(unit, index % len(unit["adults"]), **WIDEN[route]) + for unit, index, route in cases + ] + before = calculate(units)["uc_childcare_work_condition"] + after = calculate(widened)["uc_childcare_work_condition"] + assert not np.any(before & ~after), [ + case for case, b, a in zip(cases, before, after) if b and not a + ] + + +@PROPERTY_SETTINGS +@given(st.lists(families(), min_size=1, max_size=30)) +def test_a_couple_both_in_paid_work_always_meets_the_condition(units): + couples = [ + dict(unit, adults=[dict(adult, works=True) for adult in unit["adults"]]) + for unit in units + if len(unit["adults"]) == 2 + ] + if couples: + assert calculate(couples)["uc_childcare_work_condition"].all() + + +@PROPERTY_SETTINGS +@given(st.lists(families(), min_size=1, max_size=30)) +def test_no_exception_meets_the_condition_without_limb_a(units): + idle = [ + dict( + unit, + adults=[ + dict(adult, works=False, payment=None, offer=False) + for adult in unit["adults"] + ], + ) + for unit in units + ] + assert not calculate(idle)["uc_childcare_work_condition"].any() + + +@PROPERTY_SETTINGS +@given(st.lists(families(), min_size=1, max_size=30)) +def test_swapping_the_members_of_a_couple_does_not_change_the_result(units): + couples = [unit for unit in units if len(unit["adults"]) == 2] + if not couples: + return + swapped = [dict(unit, adults=unit["adults"][::-1]) for unit in couples] + np.testing.assert_array_equal( + calculate(couples)["uc_childcare_work_condition"], + calculate(swapped)["uc_childcare_work_condition"], + ) + + +@PROPERTY_SETTINGS +@given(st.lists(families(), min_size=1, max_size=30)) +def test_dependants_do_not_affect_the_condition(units): + np.testing.assert_array_equal( + calculate(units)["uc_childcare_work_condition"], + calculate(units, with_dependants=False)["uc_childcare_work_condition"], + ) + + +@PROPERTY_SETTINGS +@given(st.lists(families(), min_size=1, max_size=30)) +def test_childcare_element_needs_the_work_condition(units): + values = calculate(units) + element = values["uc_childcare_element"] + assert np.all(element >= 0) + assert np.all(element[~values["uc_childcare_work_condition"]] == 0) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_childcare_treated_as_in_paid_work.py b/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_childcare_treated_as_in_paid_work.py new file mode 100644 index 0000000000..d861e20d7f --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_childcare_treated_as_in_paid_work.py @@ -0,0 +1,39 @@ +from policyengine_uk.model_api import * + + +class uc_childcare_treated_as_in_paid_work(Variable): + value_type = bool + entity = Person + label = "Treated as in paid work for the Universal Credit childcare work condition" + documentation = ( + "Regulation 32(2)(b): a claimant receiving statutory sick pay, " + "statutory maternity pay, statutory paternity pay or maternity " + "allowance is treated as in paid work for the work condition. The " + "regulation also lists statutory adoption pay, statutory shared " + "parental pay, statutory parental bereavement pay and statutory " + "neonatal care pay, which the model has no variables for. Regulation " + "32(2)(a), ceasing paid work in the current or previous assessment " + "period, is covered only as the annual in_work proxy covers it: hours " + "or earnings at any point in the year count as paid work." + ) + definition_period = YEAR + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 32(2)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/32", + ), + dict( + title="Advice for Decision Making ch. F7, para. F7015", + href="https://assets.publishing.service.gov.uk/media/696a076c7b7f37aa8e4022d9/adm-ch-f7.pdf", + ), + ] + + def formula(person, period, parameters): + statutory_payments = [ + "statutory_sick_pay", + "statutory_maternity_pay", + "statutory_paternity_pay", + "maternity_allowance", + ] + receives = [person(payment, period) > 0 for payment in statutory_payments] + return np.logical_or.reduce(receives) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_childcare_work_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_childcare_work_condition.py index 661786c65f..386d2fcf63 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_childcare_work_condition.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_childcare_work_condition.py @@ -6,16 +6,51 @@ class uc_childcare_work_condition(Variable): entity = BenUnit label = "Meets Universal Credit childcare work condition" documentation = ( - "Tests work for the claimant and partner, excluding dependants. " - "The in_work proxy means positive hours or earnings; offers of work, " - "the partner exceptions in regulation 32(1)(b), and the treated-as-working " - "rules in regulation 32(2) are not modelled." + "Regulation 32(1): the claimant is in paid work or has an offer of " + "paid work due to start before the end of the next assessment period, " + "and, for a couple, the other member is either in paid work or unable " + "to provide childcare because they have limited capability for work, " + "have regular and substantial caring responsibilities for a severely " + "disabled person, or are temporarily absent from the claimant's " + "household. Each joint claimant is a claimant, so the condition is met " + "when one member meets limb (a) and the other meets limb (b). Paid " + "work is the in_work proxy (positive hours or earnings in the year), " + "or being treated as in paid work under regulation 32(2) " + "(uc_childcare_treated_as_in_paid_work). Dependants are not tested." ) definition_period = YEAR - reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/32" + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 32", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/32", + ), + dict( + title="Advice for Decision Making ch. F7, paras. F7011-F7015", + href="https://assets.publishing.service.gov.uk/media/696a076c7b7f37aa8e4022d9/adm-ch-f7.pdf", + ), + ] def formula(benunit, period, parameters): person = benunit.members claimant = person("is_uc_claimant", period) - in_work = person("in_work", period) - return benunit.any(claimant & in_work) & benunit.all(in_work | ~claimant) + in_paid_work = person("in_work", period) | person( + "uc_childcare_treated_as_in_paid_work", period + ) + # Reg. 32(1)(a): in paid work or has an offer of paid work due to + # start before the end of the next assessment period. + meets_claimant_limb = in_paid_work | person("uc_has_offer_of_paid_work", period) + # Reg. 32(1)(b): the other member is in paid work or is unable to + # provide childcare for a reason in (i)-(iii). An offer of paid work + # satisfies limb (a) only. + meets_other_member_limb = in_paid_work | person( + "uc_unable_to_provide_childcare", period + ) + # Joint claimants are each "the claimant" (WRA 2012 s. 40), so the + # condition holds if any claimant meets limb (a) while every other + # claimant meets limb (b). A single claimant has no other member. + fails_other_member_limb = claimant & ~meets_other_member_limb + others_failing = ( + benunit.project(benunit.sum(fails_other_member_limb)) + - fails_other_member_limb + ) + return benunit.any(claimant & meets_claimant_limb & (others_failing == 0)) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_has_offer_of_paid_work.py b/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_has_offer_of_paid_work.py new file mode 100644 index 0000000000..408103f17e --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_has_offer_of_paid_work.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * + + +class uc_has_offer_of_paid_work(Variable): + value_type = bool + entity = Person + label = "Has an offer of paid work due to start before the end of the next Universal Credit assessment period" + documentation = ( + "Regulation 32(1)(a): a claimant with an offer of paid work due to " + "start before the end of the next assessment period meets the " + "claimant's limb of the childcare work condition. It does not meet " + "the other member's limb in regulation 32(1)(b). Survey data does not " + "record offers of work, so this defaults to false." + ) + definition_period = YEAR + default_value = False + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 32(1)(a)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/32", + ), + dict( + title="Advice for Decision Making ch. F7, paras. F7011-F7012", + href="https://assets.publishing.service.gov.uk/media/696a076c7b7f37aa8e4022d9/adm-ch-f7.pdf", + ), + ] diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_unable_to_provide_childcare.py b/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_unable_to_provide_childcare.py new file mode 100644 index 0000000000..153874dfda --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_unable_to_provide_childcare.py @@ -0,0 +1,42 @@ +from policyengine_uk.model_api import * + + +class uc_unable_to_provide_childcare(Variable): + value_type = bool + entity = Person + label = "Unable to provide childcare for the Universal Credit work condition" + documentation = ( + "Regulation 32(1)(b): the other member of a couple who is not in paid " + "work still lets the claimant meet the work condition if they are " + "unable to provide childcare because they (i) have limited capability " + "for work, (ii) have regular and substantial caring responsibilities " + "for a severely disabled person, or (iii) are temporarily absent from " + "the claimant's household. Limb (i) uses the model's limited " + "capability proxy, uc_limited_capability_for_WRA (it defaults to " + "is_disabled_for_benefits); limited capability for work and " + "work-related activity includes limited capability for work. Limb " + "(ii) uses is_carer_for_benefits, the regulation 30 test. Limb (iii) " + "is the input uc_is_temporarily_absent_from_claimant_household." + ) + definition_period = YEAR + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 32(1)(b)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/32", + ), + dict( + title="Universal Credit Regulations 2013 reg. 30", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/30", + ), + dict( + title="Advice for Decision Making ch. F7, para. F7013", + href="https://assets.publishing.service.gov.uk/media/696a076c7b7f37aa8e4022d9/adm-ch-f7.pdf", + ), + ] + + def formula(person, period, parameters): + return ( + person("uc_limited_capability_for_WRA", period) + | person("is_carer_for_benefits", period) + | person("uc_is_temporarily_absent_from_claimant_household", period) + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_temporarily_absent_from_claimant_household.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_temporarily_absent_from_claimant_household.py new file mode 100644 index 0000000000..4fc7fa3410 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_temporarily_absent_from_claimant_household.py @@ -0,0 +1,27 @@ +from policyengine_uk.model_api import * + + +class uc_is_temporarily_absent_from_claimant_household(Variable): + value_type = bool + entity = Person + label = "Temporarily absent from the Universal Credit claimant's household" + documentation = ( + "Whether this member of a couple is temporarily absent from the " + "claimant's household. A couple stays a couple during a temporary " + "absence unless it exceeds, or is expected to exceed, six months " + "(regulation 3(6)). The absent member is unable to provide childcare " + "for the childcare work condition (regulation 32(1)(b)(iii)). Survey " + "data does not record it, so this defaults to false." + ) + definition_period = YEAR + default_value = False + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 32(1)(b)(iii)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/32", + ), + dict( + title="Universal Credit Regulations 2013 reg. 3(6)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/3", + ), + ] From db826f6fa9ae736f106c36593e34888499946cf6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 13:32:41 -0400 Subject: [PATCH 2/5] Name the reg 32(1)(b) negative case for what it tests Co-Authored-By: Claude Opus 5.5 --- .../childcare_element/uc_childcare_work_condition_reg_32.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml index 82d6e1ea00..773d3007a0 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml @@ -166,7 +166,7 @@ # partner meets (a), the head (limited capability for work) meets (b). uc_childcare_work_condition: true -- name: Reg. 32(1)(b) - a working partner of a nonworking claimant without an exception +- name: Reg. 32(1)(b) - a working claimant's nonworking partner without an exception period: 2025 absolute_error_margin: 0.01 input: From cf72330f1d8402359b9b8f713632eb50917c8a98 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 22:10:12 -0400 Subject: [PATCH 3/5] Address round-1 review of the reg 32 work condition - Describe the limited-capability and caring exceptions as the approximations they are. is_carer_for_benefits' hours branch checks neither the cared-for person's qualifying benefit (SSCBA 1992 s.70(2), imported by reg 30(1)(a)) nor the reg 30(3) paid-care exclusion. - Rename the offer input to uc_has_offer_of_paid_work_starting_by_end_of_next_assessment_period, so its name carries reg 32(1)(a)'s deadline. - Property tests: workers have employment income, self-employment income or hours alone, and every exhaustive family has a dependant. This kills two mutants the first exhaustive version missed (an unmasked dependant, and employment income standing in for in_work). - YAML: Ben and Sara's case now includes Sara's mother on the higher rate of attendance allowance; add three-flagged-claimant cases and a self-employment case. Co-Authored-By: Claude Opus 5.5 --- .../uc-childcare-partner-exceptions.added.md | 2 +- .../uc_childcare_work_condition_reg_32.yaml | 114 +++++++++++++++--- ..._uc_childcare_work_condition_properties.py | 81 ++++++++++--- .../uc_childcare_work_condition.py | 9 +- ...rting_by_end_of_next_assessment_period.py} | 2 +- .../uc_unable_to_provide_childcare.py | 23 +++- 6 files changed, 187 insertions(+), 44 deletions(-) rename policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/{uc_has_offer_of_paid_work.py => uc_has_offer_of_paid_work_starting_by_end_of_next_assessment_period.py} (92%) diff --git a/changelog.d/uc-childcare-partner-exceptions.added.md b/changelog.d/uc-childcare-partner-exceptions.added.md index 5b01ecba1d..2e833de946 100644 --- a/changelog.d/uc-childcare-partner-exceptions.added.md +++ b/changelog.d/uc-childcare-partner-exceptions.added.md @@ -1 +1 @@ -- Add `uc_has_offer_of_paid_work` (reg 32(1)(a)) and `uc_is_temporarily_absent_from_claimant_household` (reg 32(1)(b)(iii)) inputs, and `uc_childcare_treated_as_in_paid_work` and `uc_unable_to_provide_childcare` for the Universal Credit childcare work condition. +- Add `uc_has_offer_of_paid_work_starting_by_end_of_next_assessment_period` (reg 32(1)(a)) and `uc_is_temporarily_absent_from_claimant_household` (reg 32(1)(b)(iii)) inputs, and `uc_childcare_treated_as_in_paid_work` and `uc_unable_to_provide_childcare` for the Universal Credit childcare work condition. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml index 773d3007a0..e9b15bf067 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml @@ -67,7 +67,7 @@ uc_limited_capability_for_WRA: [false, true] uc_childcare_work_condition: true -- name: Reg. 32(1)(b)(ii) - ADM F7013 example, a partner caring full time for a disabled relative +- name: Reg. 32(1)(b)(ii) - ADM F7013 example, a partner caring full time for her disabled mother period: 2025 absolute_error_margin: 0.01 input: @@ -82,19 +82,28 @@ pauline: age: 4 childcare_expenses: 5_000 + mother: + age: 70 + aa_category: HIGHER benunits: benunit: members: [ben, sara, pauline] + mothers_benunit: + members: [mother] households: household: - members: [ben, sara, pauline] + members: [ben, sara, pauline, mother] output: - # Ben works two days a week; Sara provides full-time care, at least the - # 35 hours of reg. 30 / CA, so "both Ben and Sara satisfy the paid work - # test" (ADM F7013). - is_carer_for_benefits: [false, true, false] - uc_childcare_work_condition: true - uc_childcare_element: 4_250 + # Ben works two days a week. Sara provides full-time care for her mother, + # who lives with them and receives the higher rate of attendance + # allowance, so is a "severely disabled person" (SSCBA 1992 s. 70(2)). + # Sara meets the Carer's Allowance conditions that reg. 30(1)(a) + # imports, so "both Ben and Sara satisfy the paid work test" (ADM F7013). + # The model's is_carer_for_benefits reads only Sara's 40 care hours; it + # does not check her mother's attendance allowance. + is_carer_for_benefits: [false, true, false, false] + uc_childcare_work_condition: [true, false] + uc_childcare_element: [4_250, 0] - name: Reg. 32(1)(b)(ii) - a partner receiving Carer's Allowance period: 2025 @@ -363,7 +372,7 @@ people: claimant: age: 30 - uc_has_offer_of_paid_work: true + uc_has_offer_of_paid_work_starting_by_end_of_next_assessment_period: true output: # ADM F7011 example 1: Colin has secured a job due to start before the # end of the next assessment period. @@ -376,7 +385,7 @@ people: claimant: age: 35 - uc_has_offer_of_paid_work: true + uc_has_offer_of_paid_work_starting_by_end_of_next_assessment_period: true partner: age: 34 employment_income: 15_000 @@ -397,7 +406,7 @@ people: claimant: age: 35 - uc_has_offer_of_paid_work: true + uc_has_offer_of_paid_work_starting_by_end_of_next_assessment_period: true partner: age: 34 uc_limited_capability_for_WRA: true @@ -417,7 +426,7 @@ people: claimant: age: 35 - uc_has_offer_of_paid_work: true + uc_has_offer_of_paid_work_starting_by_end_of_next_assessment_period: true partner: age: 34 benunits: @@ -436,10 +445,10 @@ people: claimant: age: 35 - uc_has_offer_of_paid_work: true + uc_has_offer_of_paid_work_starting_by_end_of_next_assessment_period: true partner: age: 34 - uc_has_offer_of_paid_work: true + uc_has_offer_of_paid_work_starting_by_end_of_next_assessment_period: true benunits: benunit: members: [claimant, partner] @@ -451,3 +460,80 @@ # (b) requires the other member to be "in paid work" or unable to provide # childcare; an offer is neither. uc_childcare_work_condition: false + +- name: Reg. 32(1) - three flagged claimants, one in paid work and two unable to provide childcare + period: 2025 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + is_claimant_or_partner: true + employment_income: 15_000 + second: + age: 39 + is_claimant_or_partner: true + uc_limited_capability_for_WRA: true + third: + age: 38 + is_claimant_or_partner: true + care_hours: 40 + benunits: + benunit: + members: [first, second, third] + households: + household: + members: [first, second, third] + output: + # A couple has two members, so three flagged claimants are a data error, + # not a legal claim. The formula generalises reg. 32(1) as: some claimant + # meets limb (a) and every other claimant meets limb (b). + uc_childcare_work_condition: true + +- name: Reg. 32(1) - three flagged claimants, one of whom neither works nor meets an exception + period: 2025 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + is_claimant_or_partner: true + employment_income: 15_000 + second: + age: 39 + is_claimant_or_partner: true + uc_limited_capability_for_WRA: true + third: + age: 38 + is_claimant_or_partner: true + benunits: + benunit: + members: [first, second, third] + households: + household: + members: [first, second, third] + output: + uc_childcare_work_condition: false + +- name: Reg. 32(1)(a) - self-employment is paid work + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 35 + self_employment_income: 8_000 + partner: + age: 34 + uc_limited_capability_for_WRA: true + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + # Reg. 2: paid work is "work done for payment or in expectation of + # payment"; the in_work proxy counts self-employment earnings. + in_work: [true, false] + uc_childcare_work_condition: true diff --git a/policyengine_uk/tests/test_uc_childcare_work_condition_properties.py b/policyengine_uk/tests/test_uc_childcare_work_condition_properties.py index 147835ea30..a6a732866b 100644 --- a/policyengine_uk/tests/test_uc_childcare_work_condition_properties.py +++ b/policyengine_uk/tests/test_uc_childcare_work_condition_properties.py @@ -11,7 +11,12 @@ as in paid work. Each joint claimant is a claimant (WRA 2012 s. 40). Invariants, for any generated population of single claimants and couples, -each with a dependant child or young person: +each with a dependant child or young person. Workers are generated with +employment income, self-employment income or hours alone, the three routes +into the in_work proxy. The reference applies the model's approximations for +the exceptions, not the full legal tests: limited capability for work is the +uc_limited_capability_for_WRA input, and caring is at least the Carer's +Allowance minimum hours of care (the hours branch of is_carer_for_benefits). 1. Differential: the condition equals a reference transcribed from reg. 32 in its pairwise form, (a)(A) and (b)(B), or (a)(B) and (b)(A). @@ -51,6 +56,9 @@ "maternity_allowance_reported", ] CARE_HOURS = [0, 20, 34, 35, 50] +# The routes into the in_work proxy: positive earnings of either kind, or +# positive hours. +WORK_SOURCES = ["employment", "self_employment", "hours"] # Attributes that can only add a route to the condition. WIDENING = ["works", "payment", "offer", "lcw", "carer", "absent"] @@ -65,7 +73,7 @@ def members(draw): h for h in CARE_HOURS if (h >= MIN_CARE_HOURS) == ("carer" in routes) ] return dict( - works="works" in routes, + works=draw(st.sampled_from(WORK_SOURCES)) if "works" in routes else None, payment=draw(st.sampled_from(payments)), offer="offer" in routes, lcw="lcw" in routes, @@ -89,9 +97,14 @@ def person_inputs(member, age, claimant_or_partner): inputs = { "age": age, "is_claimant_or_partner": claimant_or_partner, - "employment_income": 12_000.0 if member["works"] else 0.0, - "hours_worked": 0.0, - "uc_has_offer_of_paid_work": member["offer"], + "employment_income": 12_000.0 if member["works"] == "employment" else 0.0, + "self_employment_income": ( + 12_000.0 if member["works"] == "self_employment" else 0.0 + ), + "hours_worked": 1_000.0 if member["works"] == "hours" else 0.0, + "uc_has_offer_of_paid_work_starting_by_end_of_next_assessment_period": member[ + "offer" + ], "uc_limited_capability_for_WRA": member["lcw"], "care_hours": float(member["care_hours"]), "uc_is_temporarily_absent_from_claimant_household": member["absent"], @@ -141,13 +154,13 @@ def min_care_hours(): def meets_claimant_limb(member): # Reg. 32(1)(a) with reg. 32(2)(b). - return member["works"] or member["payment"] is not None or member["offer"] + return bool(member["works"]) or member["payment"] is not None or member["offer"] def meets_other_member_limb(member): # Reg. 32(1)(b); joint claimants also have reg. 32(2)(b). unable = member["lcw"] or member["care_hours"] >= MIN_CARE_HOURS or member["absent"] - return member["works"] or member["payment"] is not None or unable + return bool(member["works"]) or member["payment"] is not None or bool(unable) def reference(unit): @@ -167,7 +180,7 @@ def with_member(unit, index, **changes): WIDEN = { - "works": dict(works=True), + "works": dict(works="hours"), "payment": dict(payment="statutory_sick_pay"), "offer": dict(offer=True), "lcw": dict(lcw=True), @@ -191,9 +204,9 @@ def route_sets(): ] -def member_with(routes, payment, care_hours): +def member_with(routes, payment, care_hours, work_source): return dict( - works="works" in routes, + works=work_source if "works" in routes else None, payment=payment if "payment" in routes else None, offer="offer" in routes, lcw="lcw" in routes, @@ -203,27 +216,49 @@ def member_with(routes, payment, care_hours): def test_condition_matches_the_reference_for_every_combination_of_routes(): - # Exhaustive: every single claimant's set of routes with every listed - # payment, and every pair of route sets for a couple, the payment and the - # caring hours cycling across pairs. + # Exhaustive over route sets: every single claimant's set of routes with + # every listed payment and every kind of work, and every pair of route + # sets for a couple, the payment, the kind of work and the caring hours + # cycling across pairs. Every family has a dependant: idle in half of + # them, otherwise with a cycling set of routes of its own. sets = route_sets() + + def dependant(k): + routes = set() if k % 2 == 0 else sets[(5 * k + 3) % len(sets)] + return dict( + dependant=member_with(routes, PAYMENTS[1 + k % 4], 50, WORK_SOURCES[k % 3]), + dependant_age=[3, 17, 18][k % 3], + childcare=[0.0, 5_000.0][k % 2], + ) + singles = [ - dict(adults=[member_with(routes, payment, MIN_CARE_HOURS)]) + dict(adults=[member_with(routes, payment, MIN_CARE_HOURS, source)]) for routes in sets for payment in PAYMENTS[1:] + for source in WORK_SOURCES ] couples = [ dict( adults=[ - member_with(first, PAYMENTS[1 + (i + j) % 4], MIN_CARE_HOURS), - member_with(second, PAYMENTS[1 + (i + 2 * j) % 4], 50), + member_with( + first, + PAYMENTS[1 + (i + j) % 4], + MIN_CARE_HOURS, + WORK_SOURCES[(i + j) % 3], + ), + member_with( + second, + PAYMENTS[1 + (i + 2 * j) % 4], + 50, + WORK_SOURCES[(i + 2 * j + 1) % 3], + ), ] ) for i, first in enumerate(sets) for j, second in enumerate(sets) ] - units = singles + couples - met = calculate(units, with_dependants=False)["uc_childcare_work_condition"] + units = [dict(unit, **dependant(k)) for k, unit in enumerate(singles + couples)] + met = calculate(units)["uc_childcare_work_condition"] expected = np.array([reference(unit) for unit in units]) mismatches = [unit for unit, m, e in zip(units, met, expected) if m != e] assert not mismatches, mismatches[:5] @@ -254,7 +289,13 @@ def test_adding_an_exception_or_work_never_removes_the_condition(cases): @given(st.lists(families(), min_size=1, max_size=30)) def test_a_couple_both_in_paid_work_always_meets_the_condition(units): couples = [ - dict(unit, adults=[dict(adult, works=True) for adult in unit["adults"]]) + dict( + unit, + adults=[ + dict(adult, works=adult["works"] or "self_employment") + for adult in unit["adults"] + ], + ) for unit in units if len(unit["adults"]) == 2 ] @@ -269,7 +310,7 @@ def test_no_exception_meets_the_condition_without_limb_a(units): dict( unit, adults=[ - dict(adult, works=False, payment=None, offer=False) + dict(adult, works=None, payment=None, offer=False) for adult in unit["adults"] ], ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_childcare_work_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_childcare_work_condition.py index 386d2fcf63..86ef872a7e 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_childcare_work_condition.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_childcare_work_condition.py @@ -16,7 +16,9 @@ class uc_childcare_work_condition(Variable): "when one member meets limb (a) and the other meets limb (b). Paid " "work is the in_work proxy (positive hours or earnings in the year), " "or being treated as in paid work under regulation 32(2) " - "(uc_childcare_treated_as_in_paid_work). Dependants are not tested." + "(uc_childcare_treated_as_in_paid_work). The exceptions use the " + "approximations in uc_unable_to_provide_childcare. Dependants are not " + "tested." ) definition_period = YEAR reference = [ @@ -38,7 +40,10 @@ def formula(benunit, period, parameters): ) # Reg. 32(1)(a): in paid work or has an offer of paid work due to # start before the end of the next assessment period. - meets_claimant_limb = in_paid_work | person("uc_has_offer_of_paid_work", period) + meets_claimant_limb = in_paid_work | person( + "uc_has_offer_of_paid_work_starting_by_end_of_next_assessment_period", + period, + ) # Reg. 32(1)(b): the other member is in paid work or is unable to # provide childcare for a reason in (i)-(iii). An offer of paid work # satisfies limb (a) only. diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_has_offer_of_paid_work.py b/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_has_offer_of_paid_work_starting_by_end_of_next_assessment_period.py similarity index 92% rename from policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_has_offer_of_paid_work.py rename to policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_has_offer_of_paid_work_starting_by_end_of_next_assessment_period.py index 408103f17e..8fefd90de2 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_has_offer_of_paid_work.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_has_offer_of_paid_work_starting_by_end_of_next_assessment_period.py @@ -1,7 +1,7 @@ from policyengine_uk.model_api import * -class uc_has_offer_of_paid_work(Variable): +class uc_has_offer_of_paid_work_starting_by_end_of_next_assessment_period(Variable): value_type = bool entity = Person label = "Has an offer of paid work due to start before the end of the next Universal Credit assessment period" diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_unable_to_provide_childcare.py b/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_unable_to_provide_childcare.py index 153874dfda..72de5b28f8 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_unable_to_provide_childcare.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_unable_to_provide_childcare.py @@ -11,12 +11,19 @@ class uc_unable_to_provide_childcare(Variable): "unable to provide childcare because they (i) have limited capability " "for work, (ii) have regular and substantial caring responsibilities " "for a severely disabled person, or (iii) are temporarily absent from " - "the claimant's household. Limb (i) uses the model's limited " - "capability proxy, uc_limited_capability_for_WRA (it defaults to " - "is_disabled_for_benefits); limited capability for work and " - "work-related activity includes limited capability for work. Limb " - "(ii) uses is_carer_for_benefits, the regulation 30 test. Limb (iii) " - "is the input uc_is_temporarily_absent_from_claimant_household." + "the claimant's household. Each limb is an approximation. Limb (i) " + "reads uc_limited_capability_for_WRA, which defaults to " + "is_disabled_for_benefits (disability benefit receipt in survey " + "data), not an observed work capability assessment; limited " + "capability for work and work-related activity includes limited " + "capability for work. Limb (ii), defined in regulation 30, reads " + "is_carer_for_benefits: receipt of Carer's Allowance or Carer Support " + "Payment, or at least the Carer's Allowance qualifying hours of care " + "a week. Its hours branch does not check that the person cared for " + "is severely disabled (a Carer's Allowance condition, SSCBA 1992 " + "s. 70(1)-(2), that regulation 30(1)(a) imports) or exclude a carer " + "paid for the caring (regulation 30(3)). Limb (iii) is the input " + "uc_is_temporarily_absent_from_claimant_household." ) definition_period = YEAR reference = [ @@ -28,6 +35,10 @@ class uc_unable_to_provide_childcare(Variable): title="Universal Credit Regulations 2013 reg. 30", href="https://www.legislation.gov.uk/uksi/2013/376/regulation/30", ), + dict( + title="Social Security Contributions and Benefits Act 1992 s. 70", + href="https://www.legislation.gov.uk/ukpga/1992/4/section/70", + ), dict( title="Advice for Decision Making ch. F7, para. F7013", href="https://assets.publishing.service.gov.uk/media/696a076c7b7f37aa8e4022d9/adm-ch-f7.pdf", From 6ff7b11d5afa32914eaa22379c45afc9e3808087 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 22:27:48 -0400 Subject: [PATCH 4/5] Close the two mutant gaps round 2 of the review found The exhaustive test now tries every payment where one member of a couple has only an offer of work and the other only that payment, and gives non-carers one hour under the minimum care hours. That kills a limb (b) that leaves out maternity allowance and a carer test of any care hours. Adds a YAML case: an offer-only claimant with a partner on maternity allowance. Co-Authored-By: Claude Opus 5.5 --- .../uc_childcare_work_condition_reg_32.yaml | 24 +++++++++++++++ ..._uc_childcare_work_condition_properties.py | 30 +++++++++++++++---- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml index e9b15bf067..fc0f8869e8 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml @@ -365,6 +365,30 @@ uc_childcare_work_condition: true uc_childcare_element: 4_250 +- name: Reg. 32(1)(a)-(b) - a claimant with an offer of paid work and a partner on maternity allowance + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 35 + uc_has_offer_of_paid_work_starting_by_end_of_next_assessment_period: true + partner: + age: 34 + maternity_allowance_reported: 5_000 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + # The claimant meets (a) with the offer. The partner, a joint claimant + # receiving maternity allowance, is treated as in paid work (32(2)(b)), + # so meets (b). Neither member meets both limbs alone. + uc_childcare_treated_as_in_paid_work: [false, true] + uc_childcare_work_condition: true + - name: Reg. 32(1)(a) - a single claimant with an offer of paid work period: 2025 absolute_error_margin: 0 diff --git a/policyengine_uk/tests/test_uc_childcare_work_condition_properties.py b/policyengine_uk/tests/test_uc_childcare_work_condition_properties.py index a6a732866b..7b9547ff31 100644 --- a/policyengine_uk/tests/test_uc_childcare_work_condition_properties.py +++ b/policyengine_uk/tests/test_uc_childcare_work_condition_properties.py @@ -210,7 +210,9 @@ def member_with(routes, payment, care_hours, work_source): payment=payment if "payment" in routes else None, offer="offer" in routes, lcw="lcw" in routes, - care_hours=care_hours if "carer" in routes else 0, + # A non-carer cares for just under the minimum, so the threshold, not + # the absence of any care, separates the two. + care_hours=care_hours if "carer" in routes else MIN_CARE_HOURS - 1, absent="absent" in routes, ) @@ -218,9 +220,13 @@ def member_with(routes, payment, care_hours, work_source): def test_condition_matches_the_reference_for_every_combination_of_routes(): # Exhaustive over route sets: every single claimant's set of routes with # every listed payment and every kind of work, and every pair of route - # sets for a couple, the payment, the kind of work and the caring hours - # cycling across pairs. Every family has a dependant: idle in half of - # them, otherwise with a cycling set of routes of its own. + # sets for a couple, the payment and the kind of work cycling across + # pairs. A carer in a couple cares for exactly the minimum hours if the + # first member and 50 if the second; a non-carer for one hour under the + # minimum. Where one member has only an offer of work and the other only + # a payment, every payment is tried, since that is where a payment alone + # decides limb (b). Every family has a dependant: idle in half of them, + # otherwise with a cycling set of routes of its own. sets = route_sets() def dependant(k): @@ -257,7 +263,21 @@ def dependant(k): for i, first in enumerate(sets) for j, second in enumerate(sets) ] - units = [dict(unit, **dependant(k)) for k, unit in enumerate(singles + couples)] + offer_only, payment_only = {"offer"}, {"payment"} + offer_with_payment = [ + dict( + adults=[ + member_with(offer_only, None, MIN_CARE_HOURS, "employment"), + member_with(payment_only, payment, 50, "employment"), + ][::order] + ) + for payment in PAYMENTS[1:] + for order in (1, -1) + ] + units = [ + dict(unit, **dependant(k)) + for k, unit in enumerate(singles + couples + offer_with_payment) + ] met = calculate(units)["uc_childcare_work_condition"] expected = np.array([reference(unit) for unit in units]) mismatches = [unit for unit, m, e in zip(units, met, expected) if m != e] From b3caf17e3baa22d1e6d46cdb7784382ae589f30e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 22:31:23 -0400 Subject: [PATCH 5/5] Explain the offer and maternity allowance case correctly The partner on maternity allowance meets both limbs; the condition holds because the offer-only claimant meets (a) and the partner meets (b). Co-Authored-By: Claude Opus 5.5 --- .../uc_childcare_work_condition_reg_32.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml index fc0f8869e8..0c43d5ad79 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml @@ -383,9 +383,10 @@ household: members: [claimant, partner] output: - # The claimant meets (a) with the offer. The partner, a joint claimant - # receiving maternity allowance, is treated as in paid work (32(2)(b)), - # so meets (b). Neither member meets both limbs alone. + # The claimant meets only (a), with the offer. The partner, a joint + # claimant receiving maternity allowance, is treated as in paid work + # (32(2)(b), WRA 2012 s. 40), so meets (b). Taking the partner as the + # claimant instead fails, because an offer does not meet (b). uc_childcare_treated_as_in_paid_work: [false, true] uc_childcare_work_condition: true