From de51949949142af25b577daa84fd3df392bd58fa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 23:21:42 -0400 Subject: [PATCH 1/5] Count only claimants' income in Universal Credit uc_earned_income and uc_unearned_income summed every member of the benefit unit, so a dependent child's or qualifying young person's wages, profits, savings interest, dividends, rent or pension reduced the parents' award. WRA 2012 s. 8(4) and UC Regs 2013 reg. 22(1) deduct the income of the claimant, or the combined income of joint claimants. - is_uc_assessed_claimant: the single claimant or the two members of the couple whose income counts (is_uc_claimant, limited to the two eldest, as the reg. 62(3) couple rule already did). - uc_earned_income, uc_unearned_income: sum over the assessed claimants. - is_benefit_cap_exempt_earnings: reg. 82(1)(a) tests the claimant's or the couple's earned income, so a dependant's job no longer lifts the cap. - uc_childcare_work_condition: reg. 32(1) tests the claimant and the other member of the couple, not every member aged 18 or over. - add_for_members: the helper and its import are byte-identical to PolicyEngine/policyengine-uk#1896's. Fixes #1976 Co-Authored-By: Claude Opus 5.5 --- changelog.d/uc-claimant-only-income.added.md | 1 + changelog.d/uc-claimant-only-income.fixed.md | 1 + .../programs/gov/dwp/universal-credit.ipynb | 2 +- policyengine_uk/model_api.py | 1 + policyengine_uk/utils/benefit_unit.py | 21 +++++++++ .../gov/dwp/is_benefit_cap_exempt_earnings.py | 15 ++++-- .../uc_childcare_work_condition.py | 15 +++--- .../income/uc_earned_income.py | 33 +++++++++---- .../income/uc_individual_earned_income.py | 9 +--- .../income/uc_unearned_income.py | 34 ++++++++++++-- .../is_uc_assessed_claimant.py | 47 +++++++++++++++++++ 11 files changed, 149 insertions(+), 30 deletions(-) create mode 100644 changelog.d/uc-claimant-only-income.added.md create mode 100644 changelog.d/uc-claimant-only-income.fixed.md create mode 100644 policyengine_uk/utils/benefit_unit.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/is_uc_assessed_claimant.py diff --git a/changelog.d/uc-claimant-only-income.added.md b/changelog.d/uc-claimant-only-income.added.md new file mode 100644 index 000000000..742561aad --- /dev/null +++ b/changelog.d/uc-claimant-only-income.added.md @@ -0,0 +1 @@ +Add `is_uc_assessed_claimant`, the single claimant or the two joint claimants whose income a Universal Credit award assesses (`is_uc_claimant`, limited to the two eldest), and the `add_for_members` helper that sums person-level variables over chosen benefit-unit members. diff --git a/changelog.d/uc-claimant-only-income.fixed.md b/changelog.d/uc-claimant-only-income.fixed.md new file mode 100644 index 000000000..7549ebae5 --- /dev/null +++ b/changelog.d/uc-claimant-only-income.fixed.md @@ -0,0 +1 @@ +Count only the claimants' income in Universal Credit (UC Regs 2013 reg. 22(1)): `uc_earned_income` and `uc_unearned_income` summed every member of the benefit unit, so a dependent child's or qualifying young person's wages, profits, savings interest, dividends, rent or pension reduced the parents' award. The benefit cap earnings exception (reg. 82(1)(a)) and the childcare work condition (reg. 32(1)) now read the claimants' earnings and work only, so a dependant's job neither lifts the cap nor switches the childcare costs element on. diff --git a/docs/book/programs/gov/dwp/universal-credit.ipynb b/docs/book/programs/gov/dwp/universal-credit.ipynb index dd182e4bc..914707957 100644 --- a/docs/book/programs/gov/dwp/universal-credit.ipynb +++ b/docs/book/programs/gov/dwp/universal-credit.ipynb @@ -44,7 +44,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## How PolicyEngine computes Universal Credit\n\nPolicyEngine computes Universal Credit at the benefit-unit level in three stages:\n\n1. **Maximum entitlement** (`uc_maximum_amount`): sum the elements the benunit qualifies for — `uc_standard_allowance`, `uc_child_element`, `uc_disability_elements`, `uc_carer_element`, `uc_housing_costs_element`, and `uc_childcare_element`.\n2. **Means test**: the maximum entitlement is reduced by the benunit's countable earnings above its work allowance (`uc_work_allowance`) at the published taper rate. Each person's earnings (`uc_individual_earned_income`) are net of their own relievable pension contributions and the income tax and National Insurance they pay on that employment or self-employment (UC Regulations 2013 regs 55(5) and 57(2)): `uc_income_tax_on_earnings` treats earnings as the lowest slice of the person's non-savings income, so tax on pensions, property, savings and dividends is never deducted, and one partner's tax never reduces the other's earnings. A claimant with a self-employment profit or loss outside a start-up period is subject to the minimum income floor (reg 62): when their earnings after those deductions are below `uc_minimum_income_floor`, they are treated as having that amount instead. The floor is the minimum wage for their age for 35 hours a week (reg 90(2)), less the income tax and the Class 2 and Class 4 National Insurance a self-employed person would pay on it (reg 62(4)(b) leaves this amount to the Secretary of State; this is the basis of DWP's guidance and figures, and setting the `gov.dwp.universal_credit.means_test.minimum_income_floor.self_employed_national_insurance` parameter to false deducts primary Class 1 instead). A member of a couple is lifted only while the couple's combined earnings are below the couple threshold, the sum of both partners' floors, and never above it (reg 62(3)). The combined earnings above the work allowance are then tapered (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter, currently 55%), plus all of its unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, contribution-based JSA, savings, dividend and property income, and the tariff income deemed from capital (reg 72), which replaces the actual yield of the capital it is charged on. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The benefit cap only applies to benunits without a benefit-cap exemption (working enough hours, having a qualifying disability benefit, etc.).\n\nThe take-up step is handled by the input variable `would_claim_uc`, which is populated stochastically when the dataset is built so that PolicyEngine's caseload aggregates match published DWP claimant numbers rather than the full eligible population.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." + "source": "## How PolicyEngine computes Universal Credit\n\nPolicyEngine computes Universal Credit at the benefit-unit level in three stages:\n\n1. **Maximum entitlement** (`uc_maximum_amount`): sum the elements the benunit qualifies for — `uc_standard_allowance`, `uc_child_element`, `uc_disability_elements`, `uc_carer_element`, `uc_housing_costs_element`, and `uc_childcare_element`.\n2. **Means test**: the maximum entitlement is reduced by the claimants' countable earnings above the work allowance (`uc_work_allowance`) at the published taper rate. Only claimants' income counts: the single claimant, or the two joint claimants (`is_uc_assessed_claimant`; UC Regulations 2013 reg 22(1)). The earnings and unearned income of a child, a qualifying young person or anyone else in the benefit unit are theirs, not the claimant's, and never reduce the award. Each person's earnings (`uc_individual_earned_income`) are net of their own relievable pension contributions and the income tax and National Insurance they pay on that employment or self-employment (UC Regulations 2013 regs 55(5) and 57(2)): `uc_income_tax_on_earnings` treats earnings as the lowest slice of the person's non-savings income, so tax on pensions, property, savings and dividends is never deducted, and one partner's tax never reduces the other's earnings. A claimant with a self-employment profit or loss outside a start-up period is subject to the minimum income floor (reg 62): when their earnings after those deductions are below `uc_minimum_income_floor`, they are treated as having that amount instead. The floor is the minimum wage for their age for 35 hours a week (reg 90(2)), less the income tax and the Class 2 and Class 4 National Insurance a self-employed person would pay on it (reg 62(4)(b) leaves this amount to the Secretary of State; this is the basis of DWP's guidance and figures, and setting the `gov.dwp.universal_credit.means_test.minimum_income_floor.self_employed_national_insurance` parameter to false deducts primary Class 1 instead). A member of a couple is lifted only while the couple's combined earnings are below the couple threshold, the sum of both partners' floors, and never above it (reg 62(3)). The combined earnings above the work allowance are then tapered (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter, currently 55%), plus all of the claimants' unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, contribution-based JSA, savings, dividend and property income, and the tariff income deemed from capital (reg 72), which replaces the actual yield of the capital it is charged on. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The benefit cap only applies to benunits without a benefit-cap exemption (the claimants earning enough, having a qualifying disability benefit, etc.). The earnings exception reads the claimants' earnings only (reg 82(1)(a)), as does the childcare work condition (reg 32(1)).\n\nThe take-up step is handled by the input variable `would_claim_uc`, which is populated stochastically when the dataset is built so that PolicyEngine's caseload aggregates match published DWP claimant numbers rather than the full eligible population.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." }, { "cell_type": "code", diff --git a/policyengine_uk/model_api.py b/policyengine_uk/model_api.py index 367801d23..597de7c32 100644 --- a/policyengine_uk/model_api.py +++ b/policyengine_uk/model_api.py @@ -3,5 +3,6 @@ from policyengine_core import periods from microdf import MicroSeries, MicroDataFrame from policyengine_uk.utils.scenario import Scenario +from policyengine_uk.utils.benefit_unit import add_for_members GBP = "currency-GBP" diff --git a/policyengine_uk/utils/benefit_unit.py b/policyengine_uk/utils/benefit_unit.py new file mode 100644 index 000000000..853726105 --- /dev/null +++ b/policyengine_uk/utils/benefit_unit.py @@ -0,0 +1,21 @@ +"""Benefit-unit aggregation helpers for means tests.""" + + +def add_for_members(benunit, period, variables, members): + """Sum variables over the benefit-unit members a means test counts. + + ``members`` is a person-level boolean array. Person-level variables are + summed over those members only; benefit-unit variables are added as they + are. Means tests pass the claimant and partner plus the programme's own + children or young persons, so a member who is none of these (for example + an 18-year-old who is neither a partner nor a qualifying young person) + does not count. + """ + person = benunit.members + total = 0 + for name in variables: + if person.entity.get_variable(name).entity.is_person: + total = total + benunit.sum(person(name, period) * members) + else: + total = total + benunit(name, period) + return total diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py index c27e13c39..715fa26ae 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py @@ -23,11 +23,18 @@ def formula(benunit, period, parameters): # Earnings exemption for UC (£846/month = £10,152/year) # Note: Only check earned income, not UC amount itself to avoid circular dependency + # UC Regs 2013 reg. 82(1)(a) tests "the claimant's earned income or, + # if the claimant is a member of a couple, the couple's combined + # earned income": a dependant's earnings do not count. + claimant = person("is_uc_assessed_claimant", period) uc_earned = benunit.sum( - benunit.members("employment_income", period) - + benunit.members("self_employment_income", period) - - benunit.members("income_tax", period) - - benunit.members("national_insurance", period) + ( + person("employment_income", period) + + person("self_employment_income", period) + - person("income_tax", period) + - person("national_insurance", period) + ) + * claimant ) earnings_threshold = 10_152 meets_earnings_test = uc_earned >= earnings_threshold 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 b1a0765b1..661786c65 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 @@ -5,14 +5,17 @@ class uc_childcare_work_condition(Variable): value_type = bool 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." + ) definition_period = YEAR - reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/32/2020-04-06" + reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/32" def formula(benunit, period, parameters): person = benunit.members - adult = person("is_adult", period) + claimant = person("is_uc_claimant", period) in_work = person("in_work", period) - adults_in_work = adult & in_work - # Benefit unit must not have any adults not in work. - all_adults_in_work = benunit.all(in_work | ~adult) - return benunit.any(adults_in_work) & all_adults_in_work + return benunit.any(claimant & in_work) & benunit.all(in_work | ~claimant) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py index 1880148d3..9a69a049a 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py @@ -5,17 +5,34 @@ class uc_earned_income(Variable): value_type = float entity = BenUnit label = "Universal Credit earned income (after deductions and work allowance)" + documentation = ( + "The claimant's earned income, or joint claimants' combined earned " + "income, less the work allowance. Earnings of a child, a qualifying " + "young person or anyone else in the benefit unit who is not a " + "claimant do not count." + ) definition_period = YEAR unit = GBP - reference = dict( - title="Universal Credit Regulations 2013 reg. 22(1)(b)", - href="https://www.legislation.gov.uk/uksi/2013/376/regulation/22", - ) + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 22(1)(b)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/22", + ), + dict( + title="Welfare Reform Act 2012 s. 8(3) and (4)", + href="https://www.legislation.gov.uk/ukpga/2012/5/section/8", + ), + ] def formula(benunit, period, parameters): - # Each person's earned income is net of their own deductions - # (reg. 55(5), reg. 57(2)); the work allowance then comes off the - # combined earned income before the taper (reg. 22(1)(b)). - earned_income = add(benunit, period, ["uc_individual_earned_income"]) + # Reg. 22(1)(b) deducts "the claimant's earned income (or, in the case + # of joint claimants, their combined earned income)". Each claimant's + # earned income is net of their own deductions (reg. 55(5), reg. + # 57(2)); the work allowance then comes off the combined earned + # income before the taper. + claimants = benunit.members("is_uc_assessed_claimant", period) + earned_income = add_for_members( + benunit, period, ["uc_individual_earned_income"], claimants + ) work_allowance = benunit("uc_work_allowance", period) return max_(0, earned_income - work_allowance) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py index ba90cccf7..4157fc2e1 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py @@ -42,13 +42,8 @@ def formula(person, period, parameters): # reg. 62(2). Partners' earned income is their actual earned income: # when both are below their thresholds each is treated as having # their own, whichever partner's floor is applied first. A claim has - # at most two claimants; where the data flag more (an adult child in - # the parents' benefit unit), the two eldest are the couple. - age = person("age", period) - is_claimant = person("is_uc_claimant", period) - claimant = is_claimant & ( - person.get_rank(person.benunit, -age, condition=is_claimant) < 2 - ) + # at most two claimants (is_uc_assessed_claimant). + claimant = person("is_uc_assessed_claimant", period) claimant_earned_income = earned_income * claimant partner_earned_income = ( person.benunit.sum(claimant_earned_income) - claimant_earned_income diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_unearned_income.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_unearned_income.py index 8dc07ee66..99011293b 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_unearned_income.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_unearned_income.py @@ -5,13 +5,39 @@ class uc_unearned_income(Variable): value_type = float entity = BenUnit label = "Universal Credit unearned income" + documentation = ( + "The claimant's unearned income, or joint claimants' combined " + "unearned income. Income of a child, a qualifying young person or " + "anyone else in the benefit unit who is not a claimant does not count." + ) definition_period = YEAR unit = GBP + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 22(1)(a)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/22", + ), + dict( + title="Welfare Reform Act 2012 s. 8(3) and (4)", + href="https://www.legislation.gov.uk/ukpga/2012/5/section/8", + ), + dict( + title="Universal Credit Regulations 2013 reg. 66(1)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/66", + ), + ] def formula(benunit, period, parameters): + # Reg. 22(1)(a) deducts "all of the claimant's unearned income (or in + # the case of joint claimants all of their combined unearned income)". + # Person-level sources count for claimants only; benefit-unit sources + # (tariff income from capital) are added as they are. p = parameters(period).gov.dwp.universal_credit.means_test + claimants = benunit.members("is_uc_assessed_claimant", period) household = benunit.household - total = add(benunit, period, p.income_definitions.unearned) + total = add_for_members( + benunit, period, p.income_definitions.unearned, claimants + ) tariff_income_applies = benunit("uc_tariff_income", period) > 0 reported_capital = benunit("uc_reported_capital", period) has_reported_capital = reported_capital >= 0 @@ -20,10 +46,10 @@ def formula(benunit, period, parameters): ) + household("non_residential_property_value", period) capital_derived_income = ( ((household("savings", period) > 0) | has_reported_capital) - * benunit("savings_interest_income", period) + * add_for_members(benunit, period, ["savings_interest_income"], claimants) + ((household("corporate_wealth", period) > 0) | has_reported_capital) - * benunit("dividend_income", period) + * add_for_members(benunit, period, ["dividend_income"], claimants) + ((property_capital > 0) | has_reported_capital) - * benunit("property_income", period) + * add_for_members(benunit, period, ["property_income"], claimants) ) return total - tariff_income_applies * capital_derived_income diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_assessed_claimant.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_assessed_claimant.py new file mode 100644 index 000000000..f3c54b7a2 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_assessed_claimant.py @@ -0,0 +1,47 @@ +from policyengine_uk.model_api import * + + +class is_uc_assessed_claimant(Variable): + value_type = bool + entity = Person + label = "Universal Credit claimant whose income is assessed" + documentation = ( + "The single claimant, or one of the two members of the couple, whose " + "income a Universal Credit award assesses: `is_uc_claimant`, limited " + "to the two eldest. Universal Credit deducts the income of the " + "claimant, or the combined income of joint claimants; where a member " + "of a couple claims as a single person, their partner's income counts " + "as if they were joint claimants. A child's or qualifying young " + "person's income is theirs, not the claimant's, and does not count; " + "nor does the income of anyone else in the benefit unit." + ) + definition_period = YEAR + reference = [ + dict( + title="Welfare Reform Act 2012 s. 2(1)", + href="https://www.legislation.gov.uk/ukpga/2012/5/section/2", + ), + dict( + title="Welfare Reform Act 2012 s. 8(4)", + href="https://www.legislation.gov.uk/ukpga/2012/5/section/8", + ), + dict( + title="Welfare Reform Act 2012 s. 40", + href="https://www.legislation.gov.uk/ukpga/2012/5/section/40", + ), + dict( + title="Universal Credit Regulations 2013 reg. 22(1) and (3)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/22", + ), + ] + + def formula(person, period, parameters): + # A claim is made by a single person or jointly by the two members of + # a couple (WRA 2012 s. 2(1)), and "claimant" means a single claimant + # or each of joint claimants (s. 40), so there are at most two. Where + # the data flag more (an adult child in the parents' benefit unit), + # the two eldest are the claimants. + claimant = person("is_uc_claimant", period) + age = person("age", period) + rank = person.get_rank(person.benunit, -age, condition=claimant) + return claimant & (rank < 2) From 15e790d87bab547f8b6d1c3e16de2aa395488158 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 23:21:42 -0400 Subject: [PATCH 2/5] Test that a dependant's income never changes Universal Credit YAML cases computed by hand from regs 22, 36, 80A and 82, and Hypothesis properties: a dependant's income never changes UC; earned and unearned income match the regulation's closed form; at most two assessed claimants; member order does not matter. Co-Authored-By: Claude Opus 5.5 --- .../universal_credit/dependants_earnings.yaml | 129 ++++++++ .../income/uc_earned_income.yaml | 113 +++++++ .../income/uc_unearned_income.yaml | 77 +++++ .../is_uc_assessed_claimant.yaml | 83 ++++++ .../test_uc_claimant_income_properties.py | 275 ++++++++++++++++++ 5 files changed, 677 insertions(+) create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/dependants_earnings.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_uc_assessed_claimant.yaml create mode 100644 policyengine_uk/tests/test_uc_claimant_income_properties.py diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/dependants_earnings.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/dependants_earnings.yaml new file mode 100644 index 000000000..4652eaa39 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/dependants_earnings.yaml @@ -0,0 +1,129 @@ +# A dependant's earnings are not the claimant's. Besides the income +# deducted from the award (reg. 22(1): income/uc_earned_income.yaml and +# income/uc_unearned_income.yaml), two Universal Credit tests read earnings: +# the benefit cap's earnings exception (reg. 82(1)(a): "the claimant's earned +# income or, if the claimant is a member of a couple, the couple's combined +# earned income") and the childcare work condition (reg. 32(1): "the claimant +# is in paid work" and, in a couple, "the other member"). + +- name: A qualifying young person's earnings do not exempt the family from the benefit cap + # Reg. 80A(2)(d)(ii): £22,020 a year outside Greater London for a claimant + # responsible for a child or qualifying young person. Before this was + # fixed the young person's £12,000 met the earnings exception. + period: 2026 + absolute_error_margin: 0 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 17 + current_education: UPPER_SECONDARY + employment_income: 12_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + output: + is_benefit_cap_exempt_earnings: false + benefit_cap: 22_020 + +- name: The claimant's own earnings still meet the benefit cap earnings exception + period: 2026 + absolute_error_margin: 0 + input: + people: + parent: + age: 40 + is_parent: true + employment_income: 12_000 + young_person: + age: 17 + current_education: UPPER_SECONDARY + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + output: + is_benefit_cap_exempt_earnings: true + +- name: Joint claimants' combined earnings meet the benefit cap earnings exception + # Neither partner's £6,000 reaches the threshold alone; together they do. + period: 2026 + absolute_error_margin: 0 + input: + people: + parent_1: + age: 40 + is_parent: true + employment_income: 6_000 + parent_2: + age: 38 + is_parent: true + employment_income: 6_000 + child: + age: 5 + benunits: + benunit: + members: [parent_1, parent_2, child] + households: + household: + members: [parent_1, parent_2, child] + region: NORTH_EAST + output: + is_benefit_cap_exempt_earnings: true + +- name: A dependent 18-year-old's job makes no difference to the childcare work condition + # The working lone parent meets it whether or not the young person works. + # Before this was fixed an 18-year-old without a job blocked it, so their + # taking one switched the childcare costs element on. + period: 2026 + absolute_error_margin: 0 + input: + people: + parent: + age: 40 + is_parent: true + employment_income: 15_000 + young_person: + age: 18 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + child: + age: 3 + childcare_expenses: 5_000 + benunits: + benunit: + members: [parent, young_person, child] + output: + is_uc_claimant: [true, false, false] + uc_childcare_work_condition: true + +- name: A dependent 18-year-old's job does not meet the childcare work condition for a parent out of work + period: 2026 + absolute_error_margin: 0 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 18 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + employment_income: 5_000 + child: + age: 3 + childcare_expenses: 5_000 + benunits: + benunit: + members: [parent, young_person, child] + output: + uc_childcare_work_condition: false diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earned_income.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earned_income.yaml index 378941b5d..258e035ad 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earned_income.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earned_income.yaml @@ -59,3 +59,116 @@ output: uc_individual_earned_income: [900, 450] uc_earned_income: 1_050 + +# Reg. 22(1)(b) deducts "the claimant's earned income (or, in the case of +# joint claimants, their combined earned income)". A child's or qualifying +# young person's earnings are theirs, not the claimant's. + +- name: A qualifying young person's earnings do not reduce a lone parent's Universal Credit + # Standard allowance £424.90 (single, 25 or over) and child element + # £351.88 (first child, born before 6 April 2017) a month: reg. 36 table, + # 2026-27. The parent has no earned income, so nothing is deducted. Before + # this was fixed the child's £12,000 less the £8,520 work allowance was + # tapered: uc_earned_income 3,480, UC £7,407.36. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + child: + age: 17 + current_education: UPPER_SECONDARY + employment_income: 12_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + output: + # The child's own earnings are below the personal allowance and the + # primary threshold, so nothing is deducted from them. + uc_individual_earned_income: [0, 12_000] + uc_earned_income: 0 + universal_credit: (424.90 + 351.88) * 12 + +- name: Joint claimants' earnings combine and their qualifying young person's do not + # Higher work allowance £710 a month (responsible for a qualifying young + # person, no housing costs element): reg. 22(2)(a) table, 2026-27. The + # couple's earned income is 10,000 + 5,000 (below the personal allowance + # and primary threshold, so no deductions); less 8,520 is 6,480. Maximum + # amount (666.97 + 351.88) x 12 = 12,226.20, less 55% of 6,480 = 3,564. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent_1: + age: 40 + is_parent: true + employment_income: 10_000 + parent_2: + age: 38 + is_parent: true + employment_income: 5_000 + young_person: + age: 18 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + employment_income: 8_000 + benunits: + benunit: + members: [parent_1, parent_2, young_person] + households: + household: + members: [parent_1, parent_2, young_person] + output: + uc_individual_earned_income: [10_000, 5_000, 8_000] + uc_work_allowance: 710 * 12 + uc_earned_income: 10_000 + 5_000 - 710 * 12 + universal_credit: (666.97 + 351.88) * 12 - 0.55 * (15_000 - 710 * 12) + +- name: An adult child flagged as a third claimant does not add earned income + # Joint claimants with no child or qualifying young person and no limited + # capability for work have no work allowance (reg. 22(1)(b)(i)). + period: 2026 + absolute_error_margin: 0 + input: + people: + parent_1: + age: 50 + employment_income: 10_000 + parent_2: + age: 48 + adult_child: + age: 25 + employment_income: 11_000 + benunits: + benunit: + members: [parent_1, parent_2, adult_child] + output: + is_uc_assessed_claimant: [true, true, false] + uc_work_allowance: 0 + uc_earned_income: 10_000 + +- name: A dependant's self-employed profits do not count and get no minimum income floor + # Before this was fixed the child's £12,000 (no tax or Class 4 below + # £12,570) less the £8,520 work allowance counted: 3,480. + period: 2026 + absolute_error_margin: 0 + input: + people: + parent: + age: 40 + is_parent: true + child: + age: 17 + current_education: UPPER_SECONDARY + self_employment_income: 12_000 + benunits: + benunit: + members: [parent, child] + output: + uc_mif_applies: [false, false] + uc_earned_income: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_unearned_income.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_unearned_income.yaml index b1db7dcd7..48c84bb30 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_unearned_income.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_unearned_income.yaml @@ -251,3 +251,80 @@ uc_assessable_capital: 6_001 uc_tariff_income: 4.35 * 12 uc_unearned_income: 4.35 * 12 + 120 + +# Reg. 22(1)(a) deducts "all of the claimant's unearned income (or in the +# case of joint claimants all of their combined unearned income)". A +# dependant's income is not the claimant's. + +- name: A dependant's unearned income does not count and the claimant's does + period: 2026 + absolute_error_margin: 0 + input: + people: + parent: + age: 40 + is_parent: true + private_pension_income: 1_000 + child: + age: 17 + current_education: UPPER_SECONDARY + savings_interest_income: 300 + dividend_income: 200 + property_income: 100 + private_pension_income: 400 + carers_allowance: 500 + benunits: + benunit: + members: [parent, child] + output: + uc_unearned_income: 1_000 + +- name: A dependant's savings interest does not wipe out a lone parent's Universal Credit + # Reg. 36 table, 2026-27: £424.90 + £351.88 a month. Before this was + # fixed the child's £12,000 counted pound for pound and UC was nil. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + child: + age: 17 + current_education: UPPER_SECONDARY + savings_interest_income: 12_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + output: + uc_unearned_income: 0 + universal_credit: (424.90 + 351.88) * 12 + +- name: With tariff income, a dependant's savings interest is neither added nor taken off + # The household's £6,001 is £1 over the £6,000 lower limit: one £4.35 + # monthly step (reg. 72). The claimant's £120 of interest is replaced by + # the tariff income; the child's £500 never counted. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + is_parent: true + savings_interest_income: 120 + child: + age: 10 + savings_interest_income: 500 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + savings: 6_001 + output: + uc_tariff_income: 4.35 * 12 + uc_unearned_income: 4.35 * 12 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_uc_assessed_claimant.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_uc_assessed_claimant.yaml new file mode 100644 index 000000000..c85e20fd2 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_uc_assessed_claimant.yaml @@ -0,0 +1,83 @@ +# A Universal Credit claim is made by a single person or jointly by the two +# members of a couple (Welfare Reform Act 2012 s. 2(1)); "claimant" means a +# single claimant or each of joint claimants (s. 40). Only claimants' income +# counts (UC Regs 2013 reg. 22(1)). + +- name: A lone parent is the claimant and their child is not + period: 2026 + absolute_error_margin: 0 + input: + people: + parent: + age: 40 + is_parent: true + child: + age: 17 + current_education: UPPER_SECONDARY + benunits: + benunit: + members: [parent, child] + output: + is_uc_claimant: [true, false] + is_uc_assessed_claimant: [true, false] + +- name: Both members of a couple are claimants and their qualifying young person is not + period: 2026 + absolute_error_margin: 0 + input: + people: + parent_1: + age: 40 + is_parent: true + parent_2: + age: 38 + is_parent: true + young_person: + age: 18 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + benunits: + benunit: + members: [parent_1, parent_2, young_person] + output: + is_child_or_qualifying_young_person_for_universal_credit: [false, false, true] + is_uc_claimant: [true, true, false] + is_uc_assessed_claimant: [true, true, false] + +- name: An adult child flagged as a claimant is not a third claimant + period: 2026 + absolute_error_margin: 0 + input: + people: + parent_1: + age: 50 + parent_2: + age: 48 + adult_child: + age: 25 + benunits: + benunit: + members: [parent_1, parent_2, adult_child] + output: + is_uc_claimant: [true, true, true] + is_uc_assessed_claimant: [true, true, false] + +- name: Of more than two members flagged as claimants, the two eldest are the claimants whatever their order + period: 2026 + absolute_error_margin: 0 + input: + people: + youngest: + age: 30 + is_uc_claimant: true + eldest: + age: 60 + is_uc_claimant: true + middle: + age: 45 + is_uc_claimant: true + benunits: + benunit: + members: [youngest, eldest, middle] + output: + is_uc_assessed_claimant: [false, true, true] diff --git a/policyengine_uk/tests/test_uc_claimant_income_properties.py b/policyengine_uk/tests/test_uc_claimant_income_properties.py new file mode 100644 index 000000000..17576db2c --- /dev/null +++ b/policyengine_uk/tests/test_uc_claimant_income_properties.py @@ -0,0 +1,275 @@ +"""Property-based tests: Universal Credit counts only claimants' income. + +UC Regs 2013 reg. 22(1) deducts "all of the claimant's unearned income (or in +the case of joint claimants all of their combined unearned income)" and "the +claimant's earned income (or, in the case of joint claimants, their combined +earned income)". A claim is made by a single person or jointly by a couple +(Welfare Reform Act 2012 s. 2(1)), so it has at most two claimants. A child's +or qualifying young person's income, and the income of anyone else in the +benefit unit, is not the claimant's. + +Invariants, for any generated population of single claimants, couples and +mixed-age couples with up to three dependants aged 0 to 19 (in or out of +education), with or without rent, childcare costs and capital, in England, +Wales and Scotland: + +1. A dependant's income never changes Universal Credit. Giving every member + who is not one of the (at most two) assessed claimants earnings, + self-employment profits or losses, miscellaneous income, savings interest, + dividends, property income or a private pension leaves UC, UC before the + benefit cap, earned income and unearned income exactly as they are when + those members have no income. +2. Differential against the regulation: earned income equals the work + allowance taken off the sum of the assessed claimants' own earned income + (floored at zero), and unearned income equals the assessed claimants' + listed unearned income plus tariff income, less the claimants' capital + yield that tariff income replaces, written here from reg. 22(1) and the + parameter list. +3. There are at most two assessed claimants in a benefit unit, they are + members flagged as claimants, and they are every flagged member when two + or fewer are flagged. +4. Order does not matter: listing the members of every benefit unit in + reverse leaves the assessed claimants and UC unchanged. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +PROPERTY_SETTINGS = settings( + max_examples=15, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +YEARS = [2020, 2026, 2027] +TENURES = ["RENT_FROM_COUNCIL", "RENT_PRIVATELY", "OWNED_OUTRIGHT"] +REGIONS = ["LONDON", "NORTH_EAST", "WALES", "SCOTLAND"] +EDUCATION = ["UPPER_SECONDARY", "TERTIARY", "NOT_IN_EDUCATION"] +WORKING_AGE = st.integers(20, 64) +PENSION_AGE = st.integers(67, 80) +SHAPES = { + "single": [WORKING_AGE], + "couple": [WORKING_AGE, WORKING_AGE], + "mixed_age": [PENSION_AGE, WORKING_AGE], +} +amount = st.one_of(st.just(0.0), st.floats(0, 30_000)) +INCOME_VARIABLES = { + "employment_income": amount, + "self_employment_income": st.one_of(st.just(0.0), st.floats(-5_000, 30_000)), + "miscellaneous_income": amount, + "savings_interest_income": amount, + "dividend_income": amount, + "property_income": amount, + "private_pension_income": amount, +} +BENUNIT_VARIABLES = [ + "universal_credit", + "universal_credit_pre_benefit_cap", + "uc_earned_income", + "uc_unearned_income", + "uc_work_allowance", + "uc_tariff_income", +] + + +@st.composite +def families(draw): + shape = draw(st.sampled_from(list(SHAPES))) + claimants = [ + dict( + age=draw(age), + employment_income=draw(amount), + savings_interest_income=draw(amount), + private_pension_income=draw(amount), + ) + for age in SHAPES[shape] + ] + dependants = [] + for _ in range(draw(st.integers(0, 3))): + age = draw(st.integers(0, 19)) + dependant = dict( + age=age, + childcare_expenses=draw(st.one_of(st.just(0.0), st.floats(0, 8_000))), + **{v: draw(s) for v, s in INCOME_VARIABLES.items()}, + ) + if age >= 16: + # At 18 and 19 only a qualifying young person (non-advanced + # education begun before 19) is a dependant; anyone else that + # age is an adult whom the calculator treats as a claimant. + dependant["current_education"] = ( + draw(st.sampled_from(EDUCATION)) if age < 18 else "UPPER_SECONDARY" + ) + dependant["age_started_or_accepted_current_education_or_training"] = draw( + st.integers(16, min(age, 18)) + ) + dependants.append(dependant) + return dict( + claimants=claimants, + dependants=dependants, + tenure=draw(st.sampled_from(TENURES)), + rent=draw(st.one_of(st.just(0.0), st.floats(0, 30_000))), + region=draw(st.sampled_from(REGIONS)), + savings=draw(st.one_of(st.just(0.0), st.floats(0, 20_000))), + ) + + +populations = st.lists(families(), min_size=1, max_size=6) + + +def situation(units, year, zero_dependants=False, reverse=False): + """One simulation holding every family. + + With ``zero_dependants`` every dependant's income is nil. With + ``reverse`` the people of each family are entered, and listed in their + benefit unit and household, in reverse order. + """ + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + members = [] + for j, claimant in enumerate(unit["claimants"]): + person = {k: {year: v} for k, v in claimant.items()} + person["is_parent"] = {year: bool(unit["dependants"])} + members.append((f"p{i}_{j}", person)) + for k, dependant in enumerate(unit["dependants"]): + person = { + key: {year: 0.0 if zero_dependants and key in INCOME_VARIABLES else v} + for key, v in dependant.items() + } + members.append((f"d{i}_{k}", person)) + if reverse: + members = members[::-1] + names = [] + for name, person in members: + person["would_claim_marriage_allowance"] = {year: False} + people[name] = person + names.append(name) + benunits[f"b{i}"] = {"members": names} + households[f"h{i}"] = { + "members": names, + "rent": {year: unit["rent"]}, + "tenure_type": {year: unit["tenure"]}, + "region": {year: unit["region"]}, + "savings": {year: unit["savings"]}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def simulate(units, year, **kwargs): + return Simulation(situation=situation(units, year, **kwargs)) + + +def benunit_values(sim, year): + return {v: np.asarray(sim.calculate(v, year)) for v in BENUNIT_VARIABLES} + + +def assessed_claimants_dependants(units): + """The model's assessed claimants are the claimants the test built.""" + flags = [] + for unit in units: + flags += [True] * len(unit["claimants"]) + [False] * len(unit["dependants"]) + return np.array(flags) + + +@PROPERTY_SETTINGS +@given(units=populations, year=st.sampled_from(YEARS)) +def test_dependants_income_never_changes_universal_credit(units, year): + with_income = simulate(units, year) + without_income = simulate(units, year, zero_dependants=True) + np.testing.assert_array_equal( + np.asarray(with_income.calculate("is_uc_assessed_claimant", year)), + assessed_claimants_dependants(units), + err_msg=str(units), + ) + a = benunit_values(with_income, year) + b = benunit_values(without_income, year) + for v in BENUNIT_VARIABLES: + np.testing.assert_allclose(a[v], b[v], atol=0.01, err_msg=f"{v}: {units}") + + +@PROPERTY_SETTINGS +@given(units=populations, year=st.sampled_from(YEARS)) +def test_income_matches_regulation_22_closed_form(units, year): + sim = simulate(units, year) + assessed = np.asarray(sim.calculate("is_uc_assessed_claimant", year)) + + def claimant_sum(variable): + values = np.asarray(sim.calculate(variable, year)) * assessed + return np.asarray(sim.map_result(values, "person", "benunit")) + + v = benunit_values(sim, year) + np.testing.assert_allclose( + v["uc_earned_income"], + np.maximum( + 0, claimant_sum("uc_individual_earned_income") - v["uc_work_allowance"] + ), + atol=0.01, + err_msg=str(units), + ) + p = sim.tax_benefit_system.parameters(year).gov.dwp.universal_credit + listed = p.means_test.income_definitions.unearned + person_level = [ + name + for name in listed + if sim.tax_benefit_system.variables[name].entity.is_person + ] + benunit_level = [name for name in listed if name not in person_level] + expected = sum(claimant_sum(name) for name in person_level) + sum( + np.asarray(sim.calculate(name, year)) for name in benunit_level + ) + # Tariff income replaces the actual yield of the capital it is charged + # on (reg. 72(3)). The generated households hold savings only (no + # corporate wealth or other property), so only the claimants' savings + # interest is replaced. + tariff = v["uc_tariff_income"] > 0 + has_savings = np.array([unit["savings"] > 0 for unit in units]) + expected = expected - tariff * has_savings * claimant_sum("savings_interest_income") + np.testing.assert_allclose( + v["uc_unearned_income"], expected, atol=0.01, err_msg=str(units) + ) + + +@PROPERTY_SETTINGS +@given( + units=populations, + year=st.sampled_from(YEARS), + extra_claimants=st.lists(st.booleans(), min_size=6, max_size=6), +) +def test_at_most_two_assessed_claimants_drawn_from_flagged_claimants( + units, year, extra_claimants +): + data = situation(units, year) + # Flag some dependants as claimants too, as data or users may. + for i, unit in enumerate(units): + if extra_claimants[i] and unit["dependants"]: + data["people"][f"d{i}_0"]["is_uc_claimant"] = {year: True} + sim = Simulation(situation=data) + claimant = np.asarray(sim.calculate("is_uc_claimant", year)) + assessed = np.asarray(sim.calculate("is_uc_assessed_claimant", year)) + flagged = np.asarray(sim.map_result(claimant.astype(float), "person", "benunit")) + counted = np.asarray(sim.map_result(assessed.astype(float), "person", "benunit")) + assert np.all(counted <= 2), units + assert np.all(claimant[assessed]), units + np.testing.assert_array_equal(counted, np.minimum(flagged, 2), err_msg=str(units)) + + +@PROPERTY_SETTINGS +@given(units=populations, year=st.sampled_from(YEARS)) +def test_member_order_does_not_matter(units, year): + forward_data = situation(units, year) + backward_data = situation(units, year, reverse=True) + forward = Simulation(situation=forward_data) + backward = Simulation(situation=backward_data) + a = benunit_values(forward, year) + b = benunit_values(backward, year) + for v in BENUNIT_VARIABLES: + np.testing.assert_allclose(a[v], b[v], atol=0.01, err_msg=f"{v}: {units}") + + # The assessed claimants are the same people, by name. + def assessed(sim, data): + flags = np.asarray(sim.calculate("is_uc_assessed_claimant", year)) + return {name for name, flag in zip(data["people"], flags) if flag} + + assert assessed(forward, forward_data) == assessed(backward, backward_data), units From c8f3902abf4b693ff4f9e9a5eb1104cbef9865cc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 23:48:15 -0400 Subject: [PATCH 3/5] Review fixes: childcare and benefit cap total read assessed claimants; stronger properties - uc_childcare_work_condition and uc_mif_applies read is_uc_assessed_claimant, so a third flagged member's work neither meets nor blocks the condition. - benefit_cap_reduction counts person-level benefits for the claimants only (WRA 2012 s. 96(1), reg. 80(1)). - Property tests build capped and childcare families, give dependants contributory benefits, flag every member as a claimant, and check earned and unearned income against the reg. 22 and reg. 72 figures. Co-Authored-By: Claude Opus 5.5 --- ...s_earnings.yaml => dependants_income.yaml} | 95 ++++- .../test_uc_claimant_income_properties.py | 337 ++++++++++++------ .../gov/dwp/benefit_cap_reduction.py | 10 +- .../uc_childcare_work_condition.py | 4 +- .../income/income_floor/uc_mif_applies.py | 2 +- .../is_uc_assessed_claimant.py | 7 +- 6 files changed, 332 insertions(+), 123 deletions(-) rename policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/{dependants_earnings.yaml => dependants_income.yaml} (53%) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/dependants_earnings.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/dependants_income.yaml similarity index 53% rename from policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/dependants_earnings.yaml rename to policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/dependants_income.yaml index 4652eaa39..4abb22afd 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/dependants_earnings.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/dependants_income.yaml @@ -1,10 +1,12 @@ -# A dependant's earnings are not the claimant's. Besides the income -# deducted from the award (reg. 22(1): income/uc_earned_income.yaml and -# income/uc_unearned_income.yaml), two Universal Credit tests read earnings: -# the benefit cap's earnings exception (reg. 82(1)(a): "the claimant's earned -# income or, if the claimant is a member of a couple, the couple's combined -# earned income") and the childcare work condition (reg. 32(1): "the claimant -# is in paid work" and, in a couple, "the other member"). +# A dependant's income is not the claimant's. Besides the income deducted +# from the award (reg. 22(1): income/uc_earned_income.yaml and +# income/uc_unearned_income.yaml), three other Universal Credit rules read +# members' income or work: the benefit cap's earnings exception (reg. +# 82(1)(a): "the claimant's earned income or, if the claimant is a member of a +# couple, the couple's combined earned income"), the benefit cap total (WRA +# 2012 s. 96(1): "the welfare benefits to which a single person or couple is +# entitled") and the childcare work condition (reg. 32(1): "the claimant is in +# paid work" and, in a couple, "the other member"). - name: A qualifying young person's earnings do not exempt the family from the benefit cap # Reg. 80A(2)(d)(ii): £22,020 a year outside Greater London for a claimant @@ -127,3 +129,82 @@ members: [parent, young_person, child] output: uc_childcare_work_condition: false + +- name: An adult child flagged as a third claimant neither meets nor blocks the childcare work condition + # Both members of the couple work, so reg. 32(1) is met whether or not + # their adult child does. + period: 2026 + absolute_error_margin: 0 + input: + people: + parent_1: + age: 50 + employment_income: 15_000 + parent_2: + age: 48 + employment_income: 15_000 + adult_child: + age: 25 + child: + age: 3 + childcare_expenses: 5_000 + benunits: + benunit: + members: [parent_1, parent_2, adult_child, child] + output: + is_uc_assessed_claimant: [true, true, false, false] + uc_childcare_work_condition: true + +- name: A dependant's contributory benefit is not part of the benefit cap total + # The cap is £22,020 (reg. 80A(2)(d)(ii)). The parent's award before the + # cap is set to £30,000 and Child Benefit to nil, so the excess is 7,980. + # The young person's £5,000 is theirs, not the claimant's (WRA 2012 s. + # 96(1); reg. 80(1)): before this was fixed the excess was 12,980. + period: 2026 + absolute_error_margin: 0 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 17 + current_education: UPPER_SECONDARY + jsa_contrib: 5_000 + benunits: + benunit: + members: [parent, young_person] + universal_credit_pre_benefit_cap: 30_000 + child_benefit: 0 + households: + household: + members: [parent, young_person] + region: NORTH_EAST + output: + benefit_cap: 22_020 + benefit_cap_reduction: 30_000 - 22_020 + +- name: The claimant's own contributory benefit is part of the benefit cap total + period: 2026 + absolute_error_margin: 0 + input: + people: + parent: + age: 40 + is_parent: true + jsa_contrib: 2_000 + young_person: + age: 17 + current_education: UPPER_SECONDARY + benunits: + benunit: + members: [parent, young_person] + universal_credit_pre_benefit_cap: 30_000 + child_benefit: 0 + households: + household: + members: [parent, young_person] + region: NORTH_EAST + output: + benefit_cap: 22_020 + benefit_cap_reduction: 30_000 + 2_000 - 22_020 diff --git a/policyengine_uk/tests/test_uc_claimant_income_properties.py b/policyengine_uk/tests/test_uc_claimant_income_properties.py index 17576db2c..6fe31d6db 100644 --- a/policyengine_uk/tests/test_uc_claimant_income_properties.py +++ b/policyengine_uk/tests/test_uc_claimant_income_properties.py @@ -3,33 +3,47 @@ UC Regs 2013 reg. 22(1) deducts "all of the claimant's unearned income (or in the case of joint claimants all of their combined unearned income)" and "the claimant's earned income (or, in the case of joint claimants, their combined -earned income)". A claim is made by a single person or jointly by a couple -(Welfare Reform Act 2012 s. 2(1)), so it has at most two claimants. A child's -or qualifying young person's income, and the income of anyone else in the -benefit unit, is not the claimant's. +earned income)"; WRA 2012 s. 8(4) says the same of the Act's deductions. A +claim is made by a single person or jointly by a couple (s. 2(1)), so it has +at most two claimants. A child's or qualifying young person's income, and the +income of anyone else in the benefit unit, is not the claimant's. The benefit +cap's earnings exception (reg. 82(1)(a)), the benefit cap total (WRA 2012 s. +96(1)) and the childcare work condition (reg. 32(1)) are framed the same way. Invariants, for any generated population of single claimants, couples and -mixed-age couples with up to three dependants aged 0 to 19 (in or out of +mixed-age couples with up to four dependants aged 0 to 19 (in or out of education), with or without rent, childcare costs and capital, in England, -Wales and Scotland: - -1. A dependant's income never changes Universal Credit. Giving every member - who is not one of the (at most two) assessed claimants earnings, - self-employment profits or losses, miscellaneous income, savings interest, - dividends, property income or a private pension leaves UC, UC before the - benefit cap, earned income and unearned income exactly as they are when - those members have no income. -2. Differential against the regulation: earned income equals the work - allowance taken off the sum of the assessed claimants' own earned income - (floored at zero), and unearned income equals the assessed claimants' - listed unearned income plus tariff income, less the claimants' capital - yield that tariff income replaces, written here from reg. 22(1) and the - parameter list. -3. There are at most two assessed claimants in a benefit unit, they are - members flagged as claimants, and they are every flagged member when two - or fewer are flagged. -4. Order does not matter: listing the members of every benefit unit in - reverse leaves the assessed claimants and UC unchanged. +Wales and Scotland. A third of the families are built to be over the benefit +cap, and a third to have working claimants paying for childcare: + +1. A dependant's income never changes Universal Credit. Giving every + dependant earnings, self-employment profits or losses, miscellaneous + income, savings interest, dividends, property income, a private pension or + contributory benefits leaves UC, UC before the benefit cap, the cap + reduction, earned income, unearned income and the childcare costs element + exactly as they are when the dependants have no income. +2. The assessed claimants are the claimants: in every generated family the + model assesses the claimant and partner and nobody else. +3. Differential against the regulations, for 2026-27: with earnings below the + personal allowance and primary threshold, earned income equals the + claimants' combined earnings less the reg. 22 work allowance (£710 a + month, or £427 with the housing costs element, where they are responsible + for a child or qualifying young person), and unearned income equals the + claimants' private pensions plus either their savings interest or, with + capital over £6,000, the reg. 72 tariff income of £4.35 a month for each + £250 or part. The amounts are written here from the regulations, not read + from the model. +4. There are at most two assessed claimants however many members are flagged + as claimants, they are flagged claimants, and they are every flagged + member when two or fewer are flagged. +5. Order does not matter: entering the people of every family in reverse + leaves the assessed claimants and UC unchanged. (Members of the same age + rank in the order listed, so with more than two flagged claimants and a + tie in age the order would decide; the generated families flag at most + two.) + +Marriage Allowance is switched off throughout, as in the other Universal +Credit property tests. """ import numpy as np @@ -56,7 +70,12 @@ "mixed_age": [PENSION_AGE, WORKING_AGE], } amount = st.one_of(st.just(0.0), st.floats(0, 30_000)) -INCOME_VARIABLES = { +# What a dependant may receive. The last two are contributory benefits, which +# count towards the benefit cap total when they are the claimant's. +# Contributory ESA is left out: any member's ESA exempts the benefit unit from +# the cap in the model, which is a question of whose circumstances count, not +# whose income. +DEPENDANT_INCOME = { "employment_income": amount, "self_employment_income": st.one_of(st.just(0.0), st.floats(-5_000, 30_000)), "miscellaneous_income": amount, @@ -64,20 +83,87 @@ "dividend_income": amount, "property_income": amount, "private_pension_income": amount, + "jsa_contrib": st.one_of(st.just(0.0), st.floats(0, 6_000)), + "incapacity_benefit": st.one_of(st.just(0.0), st.floats(0, 8_000)), } BENUNIT_VARIABLES = [ "universal_credit", "universal_credit_pre_benefit_cap", + "benefit_cap_reduction", "uc_earned_income", "uc_unearned_income", - "uc_work_allowance", - "uc_tariff_income", + "uc_childcare_element", ] +@st.composite +def dependants(draw, minimum=0, maximum=4, childcare=False): + members = [] + for _ in range(draw(st.integers(minimum, maximum))): + age = draw(st.integers(0, 19)) + member = dict(age=age, **{v: draw(s) for v, s in DEPENDANT_INCOME.items()}) + if age >= 16: + # At 18 and 19 only a qualifying young person is a dependant: + # in non-advanced education begun before 19 and, at 19, before + # the 1 September after their birthday (reg. 5(1)). Anyone else + # that age is an adult whom the calculator treats as a claimant. + member["current_education"] = ( + draw(st.sampled_from(EDUCATION)) if age < 18 else "UPPER_SECONDARY" + ) + member["age_started_or_accepted_current_education_or_training"] = draw( + st.integers(16, min(age, 18)) + ) + if age == 19: + member[ + "is_before_universal_credit_qualifying_young_person_terminal_date" + ] = True + members.append(member) + if childcare: + # A young child in paid childcare, and an 18-year-old at school whose + # job must neither meet nor block the parents' work condition. + members.append(dict(age=3, childcare_expenses=draw(st.floats(1_000, 9_000)))) + members.append( + dict( + age=18, + current_education="UPPER_SECONDARY", + age_started_or_accepted_current_education_or_training=17, + **{v: draw(s) for v, s in DEPENDANT_INCOME.items()}, + ) + ) + return members + + @st.composite def families(draw): + kind = draw(st.sampled_from(["general", "capped", "childcare"])) shape = draw(st.sampled_from(list(SHAPES))) + if kind == "capped": + # Claimants without earnings, several children and a high council + # rent: over the cap unless something exempts them. + claimants = [dict(age=draw(age)) for age in SHAPES[shape]] + return dict( + claimants=claimants, + dependants=draw(dependants(minimum=3)), + tenure="RENT_FROM_COUNCIL", + rent=draw(st.floats(12_000, 30_000)), + region=draw(st.sampled_from(REGIONS)), + savings=0.0, + ) + if kind == "childcare": + # Every claimant works, so the work condition is the claimants' to + # meet. + claimants = [ + dict(age=draw(age), employment_income=draw(st.floats(3_000, 30_000))) + for age in SHAPES[shape] + ] + return dict( + claimants=claimants, + dependants=draw(dependants(maximum=1, childcare=True)), + tenure=draw(st.sampled_from(TENURES)), + rent=draw(st.one_of(st.just(0.0), st.floats(0, 15_000))), + region=draw(st.sampled_from(REGIONS)), + savings=0.0, + ) claimants = [ dict( age=draw(age), @@ -87,28 +173,9 @@ def families(draw): ) for age in SHAPES[shape] ] - dependants = [] - for _ in range(draw(st.integers(0, 3))): - age = draw(st.integers(0, 19)) - dependant = dict( - age=age, - childcare_expenses=draw(st.one_of(st.just(0.0), st.floats(0, 8_000))), - **{v: draw(s) for v, s in INCOME_VARIABLES.items()}, - ) - if age >= 16: - # At 18 and 19 only a qualifying young person (non-advanced - # education begun before 19) is a dependant; anyone else that - # age is an adult whom the calculator treats as a claimant. - dependant["current_education"] = ( - draw(st.sampled_from(EDUCATION)) if age < 18 else "UPPER_SECONDARY" - ) - dependant["age_started_or_accepted_current_education_or_training"] = draw( - st.integers(16, min(age, 18)) - ) - dependants.append(dependant) return dict( claimants=claimants, - dependants=dependants, + dependants=draw(dependants()), tenure=draw(st.sampled_from(TENURES)), rent=draw(st.one_of(st.just(0.0), st.floats(0, 30_000))), region=draw(st.sampled_from(REGIONS)), @@ -119,12 +186,13 @@ def families(draw): populations = st.lists(families(), min_size=1, max_size=6) -def situation(units, year, zero_dependants=False, reverse=False): +def situation(units, year, zero_dependants=False, reverse=False, flag_all=False): """One simulation holding every family. With ``zero_dependants`` every dependant's income is nil. With ``reverse`` the people of each family are entered, and listed in their - benefit unit and household, in reverse order. + benefit unit and household, in reverse order. With ``flag_all`` every + member is flagged as a claimant, as data or users may. """ people, benunits, households = {}, {}, {} for i, unit in enumerate(units): @@ -135,7 +203,7 @@ def situation(units, year, zero_dependants=False, reverse=False): members.append((f"p{i}_{j}", person)) for k, dependant in enumerate(unit["dependants"]): person = { - key: {year: 0.0 if zero_dependants and key in INCOME_VARIABLES else v} + key: {year: 0.0 if zero_dependants and key in DEPENDANT_INCOME else v} for key, v in dependant.items() } members.append((f"d{i}_{k}", person)) @@ -144,6 +212,8 @@ def situation(units, year, zero_dependants=False, reverse=False): names = [] for name, person in members: person["would_claim_marriage_allowance"] = {year: False} + if flag_all: + person["is_uc_claimant"] = {year: True} people[name] = person names.append(name) benunits[f"b{i}"] = {"members": names} @@ -157,16 +227,12 @@ def situation(units, year, zero_dependants=False, reverse=False): return {"people": people, "benunits": benunits, "households": households} -def simulate(units, year, **kwargs): - return Simulation(situation=situation(units, year, **kwargs)) - - def benunit_values(sim, year): return {v: np.asarray(sim.calculate(v, year)) for v in BENUNIT_VARIABLES} -def assessed_claimants_dependants(units): - """The model's assessed claimants are the claimants the test built.""" +def built_claimants(units): + """Person flags, in order: the claimants the test built, then dependants.""" flags = [] for unit in units: flags += [True] * len(unit["claimants"]) + [False] * len(unit["dependants"]) @@ -176,13 +242,8 @@ def assessed_claimants_dependants(units): @PROPERTY_SETTINGS @given(units=populations, year=st.sampled_from(YEARS)) def test_dependants_income_never_changes_universal_credit(units, year): - with_income = simulate(units, year) - without_income = simulate(units, year, zero_dependants=True) - np.testing.assert_array_equal( - np.asarray(with_income.calculate("is_uc_assessed_claimant", year)), - assessed_claimants_dependants(units), - err_msg=str(units), - ) + with_income = Simulation(situation=situation(units, year)) + without_income = Simulation(situation=situation(units, year, zero_dependants=True)) a = benunit_values(with_income, year) b = benunit_values(without_income, year) for v in BENUNIT_VARIABLES: @@ -191,61 +252,98 @@ def test_dependants_income_never_changes_universal_credit(units, year): @PROPERTY_SETTINGS @given(units=populations, year=st.sampled_from(YEARS)) -def test_income_matches_regulation_22_closed_form(units, year): - sim = simulate(units, year) - assessed = np.asarray(sim.calculate("is_uc_assessed_claimant", year)) - - def claimant_sum(variable): - values = np.asarray(sim.calculate(variable, year)) * assessed - return np.asarray(sim.map_result(values, "person", "benunit")) - - v = benunit_values(sim, year) - np.testing.assert_allclose( - v["uc_earned_income"], - np.maximum( - 0, claimant_sum("uc_individual_earned_income") - v["uc_work_allowance"] - ), - atol=0.01, +def test_assessed_claimants_are_the_claimant_and_partner(units, year): + sim = Simulation(situation=situation(units, year)) + np.testing.assert_array_equal( + np.asarray(sim.calculate("is_uc_assessed_claimant", year)), + built_claimants(units), err_msg=str(units), ) - p = sim.tax_benefit_system.parameters(year).gov.dwp.universal_credit - listed = p.means_test.income_definitions.unearned - person_level = [ - name - for name in listed - if sim.tax_benefit_system.variables[name].entity.is_person + + +# Reg. 22(2) table and reg. 72(1), 2026-27, a month. +HIGHER_WORK_ALLOWANCE = 710 +LOWER_WORK_ALLOWANCE = 427 +TARIFF_INCOME_PER_STEP = 4.35 +TARIFF_STEP = 250 +TARIFF_LOWER_LIMIT = 6_000 +# Below the 2026-27 personal allowance and primary threshold (£12,570), so +# nothing is deducted from the claimant's earnings (reg. 55(5)). +untaxed_earnings = st.one_of(st.just(0.0), st.floats(0, 12_000)) + + +@st.composite +def untaxed_families(draw): + """Working-age claimants whose earnings carry no tax or NI, with capital + under the £16,000 limit (reg. 18).""" + claimants = [ + dict( + age=draw(st.integers(25, 60)), + employment_income=draw(untaxed_earnings), + savings_interest_income=draw(st.one_of(st.just(0.0), st.floats(0, 500))), + private_pension_income=draw(st.one_of(st.just(0.0), st.floats(0, 500))), + ) + for _ in range(draw(st.integers(1, 2))) ] - benunit_level = [name for name in listed if name not in person_level] - expected = sum(claimant_sum(name) for name in person_level) + sum( - np.asarray(sim.calculate(name, year)) for name in benunit_level + return dict( + claimants=claimants, + dependants=draw(dependants()), + tenure=draw(st.sampled_from(TENURES)), + rent=draw(st.one_of(st.just(0.0), st.floats(1_000, 12_000))), + region=draw(st.sampled_from(REGIONS)), + savings=draw(st.one_of(st.just(0.0), st.floats(0, 15_900))), ) - # Tariff income replaces the actual yield of the capital it is charged - # on (reg. 72(3)). The generated households hold savings only (no - # corporate wealth or other property), so only the claimants' savings - # interest is replaced. - tariff = v["uc_tariff_income"] > 0 - has_savings = np.array([unit["savings"] > 0 for unit in units]) - expected = expected - tariff * has_savings * claimant_sum("savings_interest_income") - np.testing.assert_allclose( - v["uc_unearned_income"], expected, atol=0.01, err_msg=str(units) + + +@PROPERTY_SETTINGS +@given(units=st.lists(untaxed_families(), min_size=1, max_size=6)) +def test_income_matches_regulations_22_and_72(units): + year = 2026 + sim = Simulation(situation=situation(units, year)) + earned = np.asarray(sim.calculate("uc_earned_income", year)) + unearned = np.asarray(sim.calculate("uc_unearned_income", year)) + # The model says only whether the award has a housing costs element and + # whether a dependant is a child or qualifying young person. + housing = np.asarray(sim.calculate("uc_housing_costs_element", year)) > 0 + qualifying = np.asarray( + sim.calculate("is_child_or_qualifying_young_person_for_universal_credit", year) ) + person = 0 + for i, unit in enumerate(units): + n_claimants, n_dependants = len(unit["claimants"]), len(unit["dependants"]) + responsible = qualifying[ + person + n_claimants : person + n_claimants + n_dependants + ].any() + person += n_claimants + n_dependants + work_allowance = 0 + if responsible: + monthly = LOWER_WORK_ALLOWANCE if housing[i] else HIGHER_WORK_ALLOWANCE + work_allowance = monthly * 12 + earnings = sum(c["employment_income"] for c in unit["claimants"]) + expected_earned = max(0, earnings - work_allowance) + pensions = sum(c["private_pension_income"] for c in unit["claimants"]) + interest = sum(c["savings_interest_income"] for c in unit["claimants"]) + excess = unit["savings"] - TARIFF_LOWER_LIMIT + if excess > 0: + # Tariff income replaces the capital's actual yield (reg. 72(3)). + steps = np.ceil(excess / TARIFF_STEP) + expected_unearned = pensions + steps * TARIFF_INCOME_PER_STEP * 12 + else: + expected_unearned = pensions + interest + np.testing.assert_allclose( + earned[i], expected_earned, atol=0.5, err_msg=str(unit) + ) + np.testing.assert_allclose( + unearned[i], expected_unearned, atol=0.5, err_msg=str(unit) + ) @PROPERTY_SETTINGS -@given( - units=populations, - year=st.sampled_from(YEARS), - extra_claimants=st.lists(st.booleans(), min_size=6, max_size=6), -) +@given(units=populations, year=st.sampled_from(YEARS), flag_all=st.booleans()) def test_at_most_two_assessed_claimants_drawn_from_flagged_claimants( - units, year, extra_claimants + units, year, flag_all ): - data = situation(units, year) - # Flag some dependants as claimants too, as data or users may. - for i, unit in enumerate(units): - if extra_claimants[i] and unit["dependants"]: - data["people"][f"d{i}_0"]["is_uc_claimant"] = {year: True} - sim = Simulation(situation=data) + sim = Simulation(situation=situation(units, year, flag_all=flag_all)) claimant = np.asarray(sim.calculate("is_uc_claimant", year)) assessed = np.asarray(sim.calculate("is_uc_assessed_claimant", year)) flagged = np.asarray(sim.map_result(claimant.astype(float), "person", "benunit")) @@ -255,6 +353,29 @@ def test_at_most_two_assessed_claimants_drawn_from_flagged_claimants( np.testing.assert_array_equal(counted, np.minimum(flagged, 2), err_msg=str(units)) +def test_three_flagged_claimants_leave_two_assessed(): + # A deterministic case of the property above: a couple and their child, + # all flagged, in each of three years. + units = [ + dict( + claimants=[dict(age=40), dict(age=38)], + dependants=[dict(age=12)], + tenure="OWNED_OUTRIGHT", + rent=0.0, + region="NORTH_EAST", + savings=0.0, + ) + ] + for year in YEARS: + sim = Simulation(situation=situation(units, year, flag_all=True)) + assert list(sim.calculate("is_uc_claimant", year)) == [True, True, True] + assert list(sim.calculate("is_uc_assessed_claimant", year)) == [ + True, + True, + False, + ] + + @PROPERTY_SETTINGS @given(units=populations, year=st.sampled_from(YEARS)) def test_member_order_does_not_matter(units, year): diff --git a/policyengine_uk/variables/gov/dwp/benefit_cap_reduction.py b/policyengine_uk/variables/gov/dwp/benefit_cap_reduction.py index bb0fddc2a..7d021f0b6 100644 --- a/policyengine_uk/variables/gov/dwp/benefit_cap_reduction.py +++ b/policyengine_uk/variables/gov/dwp/benefit_cap_reduction.py @@ -22,7 +22,9 @@ def formula(benunit, period, parameters): "esa_contrib", "sda", ] - return max_( - add(benunit, period, CAPPED_BENEFITS) - benunit("benefit_cap", period), - 0, - ) + # The cap applies to "the welfare benefits to which a single person or + # couple is entitled" (WRA 2012 s. 96(1); UC Regs 2013 reg. 80(1)), so + # a dependant's own contributory benefit is not part of the total. + claimants = benunit.members("is_uc_assessed_claimant", period) + total = add_for_members(benunit, period, CAPPED_BENEFITS, claimants) + return max_(total - benunit("benefit_cap", period), 0) 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 661786c65..d54594976 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 @@ -15,7 +15,9 @@ class uc_childcare_work_condition(Variable): reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/32" def formula(benunit, period, parameters): + # Reg. 32(1): "the claimant is in paid work" and, in a couple, "the + # other member". Nobody else's work counts or is required. person = benunit.members - claimant = person("is_uc_claimant", period) + claimant = person("is_uc_assessed_claimant", period) in_work = person("in_work", period) return benunit.any(claimant & in_work) & benunit.all(in_work | ~claimant) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_applies.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_applies.py index d4ddb8a5c..ee94ce504 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_applies.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_applies.py @@ -16,7 +16,7 @@ def formula(person, period, parameters): # Reg. 62(1) applies to a claimant, not a dependant, in gainful # self-employment: a trade carried on in expectation of profit (reg. # 64), so a trading loss counts (ADM H4503). - claimant = person("is_uc_claimant", period) + claimant = person("is_uc_assessed_claimant", period) has_self_empl_income = person("self_employment_income", period) != 0 in_startup_period = person("uc_is_in_startup_period", period) return claimant & has_self_empl_income & ~in_startup_period diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_assessed_claimant.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_assessed_claimant.py index f3c54b7a2..6ae7c6ec1 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_assessed_claimant.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_assessed_claimant.py @@ -13,7 +13,9 @@ class is_uc_assessed_claimant(Variable): "of a couple claims as a single person, their partner's income counts " "as if they were joint claimants. A child's or qualifying young " "person's income is theirs, not the claimant's, and does not count; " - "nor does the income of anyone else in the benefit unit." + "nor does the income of anyone else in the benefit unit. Who the " + "claimants are comes from `is_uc_claimant`: a member it flags is " + "taken as a claimant, whatever their age." ) definition_period = YEAR reference = [ @@ -40,7 +42,8 @@ def formula(person, period, parameters): # a couple (WRA 2012 s. 2(1)), and "claimant" means a single claimant # or each of joint claimants (s. 40), so there are at most two. Where # the data flag more (an adult child in the parents' benefit unit), - # the two eldest are the claimants. + # the two eldest are the claimants; members of the same age rank in + # the order they are listed. claimant = person("is_uc_claimant", period) age = person("age", period) rank = person.get_rank(person.benunit, -age, condition=claimant) From 2a81291392dd2980c38558619f5ebcd3cd84f0c2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 23:59:24 -0400 Subject: [PATCH 4/5] Pin the tie rule for more than two flagged claimants Co-Authored-By: Claude Opus 5.5 --- .../is_uc_assessed_claimant.yaml | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_uc_assessed_claimant.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_uc_assessed_claimant.yaml index c85e20fd2..d10f832d6 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_uc_assessed_claimant.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_uc_assessed_claimant.yaml @@ -81,3 +81,26 @@ members: [youngest, eldest, middle] output: is_uc_assessed_claimant: [false, true, true] + +- name: Flagged claimants of the same age rank in the order listed + # The Act fixes the number of claimants, not which of three flagged + # members they are; taking the eldest two, and the first listed on a tie, + # is a modelling rule. + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + is_uc_claimant: true + second: + age: 40 + is_uc_claimant: true + third: + age: 40 + is_uc_claimant: true + benunits: + benunit: + members: [first, second, third] + output: + is_uc_assessed_claimant: [true, true, false] From a8fb8740e3be93fa4956878510d1f88b42d9d512 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 09:16:21 -0400 Subject: [PATCH 5/5] Add a property over capped families whose children earn above the exception threshold Co-Authored-By: Claude Opus 5.5 --- .../test_uc_claimant_income_properties.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/policyengine_uk/tests/test_uc_claimant_income_properties.py b/policyengine_uk/tests/test_uc_claimant_income_properties.py index 6fe31d6db..e97635fcd 100644 --- a/policyengine_uk/tests/test_uc_claimant_income_properties.py +++ b/policyengine_uk/tests/test_uc_claimant_income_properties.py @@ -22,6 +22,9 @@ contributory benefits leaves UC, UC before the benefit cap, the cap reduction, earned income, unearned income and the childcare costs element exactly as they are when the dependants have no income. + A separate property builds only families over the cap, each with a child + earning more than the earnings exception's threshold, and checks that the + cap still applies in full. 2. The assessed claimants are the claimants: in every generated family the model assesses the claimant and partner and nobody else. 3. Differential against the regulations, for 2026-27: with earnings below the @@ -250,6 +253,57 @@ def test_dependants_income_never_changes_universal_credit(units, year): np.testing.assert_allclose(a[v], b[v], atol=0.01, err_msg=f"{v}: {units}") +@st.composite +def capped_families(draw): + """Working-age claimants with no income, four or five children and a high + council rent, so the benefit cap always bites; at least one child has + earnings well over the earnings exception's threshold.""" + claimants = [ + dict(age=draw(st.integers(25, 60))) for _ in range(draw(st.integers(1, 2))) + ] + children = [ + dict(age=draw(st.integers(0, 15)), employment_income=draw(amount)) + for _ in range(draw(st.integers(3, 4))) + ] + children.append( + dict( + age=draw(st.integers(0, 15)), + employment_income=draw(st.floats(15_000, 30_000)), + ) + ) + return dict( + claimants=claimants, + dependants=children, + tenure="RENT_FROM_COUNCIL", + rent=draw(st.floats(15_000, 30_000)), + region=draw(st.sampled_from(REGIONS)), + savings=0.0, + ) + + +@PROPERTY_SETTINGS +@given( + units=st.lists(capped_families(), min_size=1, max_size=6), + year=st.sampled_from(YEARS), +) +def test_dependants_earnings_do_not_lift_the_benefit_cap(units, year): + with_income = Simulation(situation=situation(units, year)) + without_income = Simulation(situation=situation(units, year, zero_dependants=True)) + capped = np.asarray(without_income.calculate("benefit_cap_reduction", year)) + # Every generated family is over the cap, so the property is not vacuous. + assert np.all(capped > 0), units + for v in ["benefit_cap_reduction", "universal_credit"]: + np.testing.assert_allclose( + np.asarray(with_income.calculate(v, year)), + np.asarray(without_income.calculate(v, year)), + atol=0.01, + err_msg=f"{v}: {units}", + ) + assert not np.any( + np.asarray(with_income.calculate("is_benefit_cap_exempt_earnings", year)) + ), units + + @PROPERTY_SETTINGS @given(units=populations, year=st.sampled_from(YEARS)) def test_assessed_claimants_are_the_claimant_and_partner(units, year):