From a9e1b4ff42f997e069d6fc09f6cd7cf6a6f27415 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 13:23:50 -0400 Subject: [PATCH 01/14] Give each benefit-unit member aged 16 or over a bedroom in the LHA size criteria Universal Credit: a qualifying young person the renter is responsible for has a bedroom (UC Regs 2013 Sch 4 para 10(1)(b)), and any other member aged 16 or over is a non-dependant with one (para 9(2), 10(1)(c)), except a qualifying young person no one is responsible for (para 9(2)(g)). Housing Benefit: every member aged 16 or over other than the claimant or partner is a person who is not a child (HB Regs 2006 and HB (SPC) Regs 2006 reg 13D(3)(b); reg 2(1)). Co-Authored-By: Claude Opus 5.5 --- .../variables/gov/dwp/LHA_allowed_bedrooms.py | 39 +++++++++++++++---- .../housing_benefit_LHA_allowed_bedrooms.py | 24 +++++++++--- 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py b/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py index 14d4d0b3e7..4f91b49750 100644 --- a/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py +++ b/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py @@ -42,25 +42,50 @@ class LHA_allowed_bedrooms(Variable): label = "The number of bedrooms covered by LHA for the benefit unit" documentation = ( "Universal Credit size criteria: one bedroom for the renter or " - "couple, one for each non-dependant aged 16 or over, and the " - "children's bedrooms. Joint tenants and other sharers of the rent, " - "boarders and lodgers are not non-dependants, so they add no bedroom " - "to anyone's entitlement; only the household head's family has " - "non-dependants (see is_non_dependant_of_household_head)." + "couple, one for each member of the benefit unit aged 16 or over who " + "is not the claimant or partner, one for each non-dependant aged 16 or " + "over, and the children's bedrooms. A qualifying young person the " + "renter is responsible for has their own bedroom; any other member " + "aged 16 or over is a non-dependant and has one too, except a " + "qualifying young person no one is responsible for, such as one " + "looked after by a local authority. Joint tenants and other sharers " + "of the rent, boarders and lodgers are not non-dependants, so they " + "add no bedroom to anyone's entitlement; only the household head's " + "family has non-dependants from other families (see " + "is_non_dependant_of_household_head)." ) definition_period = YEAR reference = ( "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/9", "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/10", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/4", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", ) def formula(benunit, period, parameters): person = benunit.members + # A child is a person under 16 (WRA 2012 s.40). aged_16_or_over = person("age", period) >= 16 - # UC Regs 2013 Sch 4 para 10(1)(c): a non-dependant who is not a child. + family_member = aged_16_or_over & ~person("is_claimant_or_partner", period) + # UC Regs 2013 Sch 4 para 10(1)(b): a qualifying young person for + # whom the renter is responsible (regs 4 and 5). + qualifying_young_person = family_member & person( + "is_child_or_qualifying_young_person_for_universal_credit", period + ) + # Para 10(1)(c) with para 9(2): any other member aged 16 or over is a + # non-dependant, unless they are a qualifying young person for whom + # no one is responsible (para 9(2)(g); reg 4(6)). + family_non_dependant = family_member & ~person( + "is_qualifying_young_person_for_universal_credit", period + ) + family_rooms = benunit.sum(qualifying_young_person | family_non_dependant) + # Para 10(1)(c): a non-dependant from another family who is not a + # child. non_dependant = aged_16_or_over & person( "is_non_dependant_of_household_head", period ) head_family = benunit.any(person("is_household_head", period)) non_dependants = head_family * benunit.max(person.household.sum(non_dependant)) - return 1 + non_dependants + bedrooms_for_children(benunit, period) + return ( + 1 + family_rooms + non_dependants + bedrooms_for_children(benunit, period) + ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_allowed_bedrooms.py b/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_allowed_bedrooms.py index 005d00c653..12ff521ea5 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_allowed_bedrooms.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_allowed_bedrooms.py @@ -13,25 +13,39 @@ class housing_benefit_LHA_allowed_bedrooms(Variable): "couple, one for each other occupier aged 16 or over, and the " "children's bedrooms. Occupiers are everyone who lives in the " "dwelling as their home except a joint tenant outside the claimant's " - "household, so a householder's boarder or lodger adds a bedroom, as " - "does a non-dependant, but a sharer of the rent does not. A sharer's, " - "boarder's or lodger's own claim counts only their own family." + "household. So every member of the benefit unit aged 16 or over who " + "is not the claimant or partner adds a bedroom, such as a young " + "person in full-time education; so do a householder's boarder or " + "lodger and a non-dependant, but a sharer of the rent does not. A " + "sharer's, boarder's or lodger's own claim counts only their own " + "family." ) definition_period = YEAR reference = ( "https://www.legislation.gov.uk/uksi/2006/213/regulation/13D", "https://www.legislation.gov.uk/uksi/2006/214/regulation/13D", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/2", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/2", ) def formula(benunit, period, parameters): person = benunit.members + # A child is a person under 16 (HB Regs 2006 reg 2(1)). aged_16_or_over = person("age", period) >= 16 + # HB Regs 2006 reg 13D(3)(b): a person who is not a child, here a + # member of the benefit unit other than the claimant or partner. + family_rooms = benunit.sum( + aged_16_or_over & ~person("is_claimant_or_partner", period) + ) head_family = person.benunit.any(person("is_household_head", period)) sharer = person.benunit("liable_for_share_of_household_rent", period) - # HB Regs 2006 reg 13D(3), with "occupiers" as defined in 13D(12). + # Reg 13D(3)(b) for occupiers outside the family, as defined in + # 13D(12). other_occupier = aged_16_or_over & ~head_family & ~sharer is_head_family = benunit.any(person("is_household_head", period)) other_occupiers = is_head_family * benunit.max( person.household.sum(other_occupier) ) - return 1 + other_occupiers + bedrooms_for_children(benunit, period) + return ( + 1 + family_rooms + other_occupiers + bedrooms_for_children(benunit, period) + ) From 7c6f5f30965f8bbcd2c5d1fcbe21ec8d196cca3c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 13:33:22 -0400 Subject: [PATCH 02/14] Test the bedroom of each benefit-unit member aged 16 or over YAML cases for a qualifying young person aged 17 and 19, an 18-year-old non-dependant, a looked-after qualifying young person (no UC bedroom), a couple with a young person and two younger children (category E), a partner aged 19 (no extra bedroom) and a pensioner couple's grandchild under HB (SPC). Two LHA_claimant_composition cases asserted the old one-bedroom count for an apprentice non-dependant and an 18-year-old qualifying young person; both are now two bedrooms (Sch 4 para 10(1)(c) and (b)). Hypothesis properties: bedrooms never fall when a person aged 16-19 joins any family; the exact change when they do; and agreement with an independent count of the size criteria, children's rooms found by brute force. Fixes #2008 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2008.fixed.md | 1 + .../family/LHA_claimant_composition.yaml | 16 +- .../family/LHA_young_person_bedroom.yaml | 251 ++++++++++++++++++ ...est_lha_bedroom_young_people_properties.py | 247 +++++++++++++++++ 4 files changed, 509 insertions(+), 6 deletions(-) create mode 100644 changelog.d/2008.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_young_person_bedroom.yaml create mode 100644 policyengine_uk/tests/test_lha_bedroom_young_people_properties.py diff --git a/changelog.d/2008.fixed.md b/changelog.d/2008.fixed.md new file mode 100644 index 0000000000..b2adf85554 --- /dev/null +++ b/changelog.d/2008.fixed.md @@ -0,0 +1 @@ +- Give each member of the benefit unit aged 16 or over who is not the claimant or partner a bedroom in the Universal Credit and Housing Benefit LHA size criteria: a qualifying young person (UC Regs 2013 Sch 4 para 10(1)(b)) or other non-dependant (para 10(1)(c)), and for Housing Benefit any person who is not a child (HB Regs 2006 reg 13D(3)(b)). diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_claimant_composition.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_claimant_composition.yaml index 2038e23465..915e00ae84 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_claimant_composition.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_claimant_composition.yaml @@ -1,7 +1,7 @@ # UC Regulations 2013, Schedule 4 paragraphs 9 and 27-28: # https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/28 -# The existing bedroom calculation is unchanged: B = 1 bedroom, C = 2; -# A is the shared accommodation rate, overriding the bedroom calculation. +# B = 1 bedroom, C = 2; A is the shared accommodation rate, overriding the +# bedroom calculation. - name: Single 17-year-old renter is not their own dependent child period: 2025 input: @@ -63,8 +63,11 @@ is_responsible_for_child_or_qualifying_young_person_for_universal_credit: false lha_renter_has_non_dependant: true is_lha_shared_accommodation_rate_specified_renter: false - # No UC QYP, but a non-dependant blocks A. Existing bedroom formula = 1: B. - LHA_category: B + # No UC QYP (UC Regs 2013 reg 5(3): training under a contract of + # employment does not count), but a non-dependant blocks A. The + # non-dependant is not a child, so has a bedroom (Sch 4 para 10(1)(c)): + # 1 + 1 = 2: C. + LHA_category: C - name: Lone parent with an 18-year-old qualifying young person period: 2025 @@ -87,8 +90,9 @@ is_responsible_for_child_or_qualifying_young_person_for_universal_credit: true lha_renter_has_non_dependant: false is_lha_shared_accommodation_rate_specified_renter: false - # Responsibility for a UC QYP blocks A. Existing bedroom formula = 1: B. - LHA_category: B + # Responsibility for a UC QYP blocks A. The QYP has a bedroom (Sch 4 + # para 10(1)(b)): 1 + 1 = 2: C. + LHA_category: C - name: Couple aged 30 and 17 is not a single specified renter period: 2025 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_young_person_bedroom.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_young_person_bedroom.yaml new file mode 100644 index 0000000000..d1a3e83f8e --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_young_person_bedroom.yaml @@ -0,0 +1,251 @@ +# A member of the benefit unit aged 16 or over who is not the claimant or +# partner has their own bedroom in the size criteria. +# +# Universal Credit: UC Regs 2013 Sch 4 para 10(1)(b), "a qualifying young +# person for whom the renter or either joint renter is responsible"; anyone +# else aged 16 or over living with the renter is a non-dependant (para 9(2)) +# with a bedroom under para 10(1)(c), "a non-dependant who is not a child", +# except a qualifying young person no one is responsible for (para 9(2)(g)). +# A child is a person under 16 (WRA 2012 s.40). +# +# Housing Benefit: HB Regs 2006 and HB (SPC) Regs 2006 reg 13D(3)(b), "a +# person who is not a child", where a child is a person under 16 (reg 2(1)). +# +# Rents of £200 a week (£10,400 a year) sit between the published Maidstone +# one-bedroom (B) and two-bedroom (C) rates (VOA, LHA April 2024, held for +# 2025-26: B £172.60, C £208.27 a week), so the bedroom decides whether the +# rent is met in full. + +- name: A qualifying young person has their own bedroom + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_household_head: true + is_parent: true + young_person: + age: 17 + current_education: UPPER_SECONDARY + benunits: + family: + members: [parent, young_person] + households: + home: + members: [parent, young_person] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_claimant_or_partner: [true, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, true] + # UC: the renter (para 10(1)(a)) and the qualifying young person (para + # 10(1)(b)): 2 bedrooms, category C. + LHA_allowed_bedrooms: 2 + LHA_category: C + # The rent is below the two-bedroom rate, so it is met in full. + uc_housing_costs_element: 10_400 + # HB: one bedroom each for the claimant and the young person, neither of + # whom is a child (reg 13D(3)(b)): 2 bedrooms, category C. + housing_benefit_LHA_allowed_bedrooms: 2 + housing_benefit_LHA_category: C + +- name: A qualifying young person aged 19 has their own bedroom + period: 2026 + input: + people: + parent: + age: 50 + is_household_head: true + is_parent: true + young_person: + age: 19 + current_education: UPPER_SECONDARY + # UC Regs 2013 reg 5(1)(b) and 5(2): enrolled before 19, and before + # the 1 September after their 19th birthday. + age_started_or_accepted_current_education_or_training: 18 + is_before_universal_credit_qualifying_young_person_terminal_date: true + benunits: + family: + members: [parent, young_person] + households: + home: + members: [parent, young_person] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_claimant_or_partner: [true, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, true] + LHA_allowed_bedrooms: 2 + LHA_category: C + uc_housing_costs_element: 10_400 + housing_benefit_LHA_allowed_bedrooms: 2 + housing_benefit_LHA_category: C + +- name: A member aged 18 who is not a qualifying young person is a non-dependant with a bedroom + period: 2026 + input: + people: + parent: + age: 45 + is_household_head: true + is_parent: true + son: + age: 18 + current_education: NOT_IN_EDUCATION + benunits: + family: + members: [parent, son] + households: + home: + members: [parent, son] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_claimant_or_partner: [true, false] + is_qualifying_young_person_for_universal_credit: [false, false] + # UC: not a qualifying young person, so a non-dependant (para 9(2)) who + # is not a child (para 10(1)(c)). + LHA_allowed_bedrooms: 2 + LHA_category: C + # HB: a person who is not a child (reg 13D(3)(b)). + housing_benefit_LHA_allowed_bedrooms: 2 + housing_benefit_LHA_category: C + +- name: A qualifying young person no one is responsible for has no Universal Credit bedroom + period: 2026 + input: + people: + carer: + age: 40 + is_household_head: true + is_parent: true + young_person: + age: 17 + current_education: UPPER_SECONDARY + # UC Regs 2013 reg 4(6)(a): no one is responsible for a qualifying + # young person looked after by a local authority. + is_looked_after_by_local_authority: true + benunits: + family: + members: [carer, young_person] + households: + home: + members: [carer, young_person] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_qualifying_young_person_for_universal_credit: [false, true] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + # Not under para 10(1)(b), and not a non-dependant (para 9(2)(g)): the + # renter's bedroom only. + LHA_allowed_bedrooms: 1 + LHA_category: B + +- name: A qualifying young person cannot share with a younger sibling of the same sex + period: 2026 + input: + people: + parent_1: + age: 42 + is_household_head: true + is_parent: true + is_male: true + parent_2: + age: 40 + is_parent: true + is_male: false + young_person: + age: 17 + is_male: true + current_education: UPPER_SECONDARY + boy: + age: 12 + is_male: true + girl: + age: 8 + is_male: false + benunits: + family: + members: [parent_1, parent_2, young_person, boy, girl] + households: + home: + members: [parent_1, parent_2, young_person, boy, girl] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_claimant_or_partner: [true, true, false, false, false] + # UC: the couple (para 10(1)(a)), the qualifying young person (b), and + # one bedroom each for the boy and the girl, who are of opposite sexes + # and one of whom is 10 or over ((d)-(f)): 4 bedrooms, category E. + LHA_allowed_bedrooms: 4 + LHA_category: E + # HB: the couple (reg 13D(3)(a)), the young person ((b)), and the two + # children ((e)): 4 bedrooms, the maximum category. + housing_benefit_LHA_allowed_bedrooms: 4 + housing_benefit_LHA_category: E + +- name: A partner aged 19 adds no bedroom + period: 2026 + input: + people: + claimant: + age: 24 + is_household_head: true + partner: + age: 19 + current_education: NOT_IN_EDUCATION + benunits: + couple: + members: [claimant, partner] + households: + home: + members: [claimant, partner] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_claimant_or_partner: [true, true] + # The couple share one bedroom (UC para 10(1)(a); HB reg 13D(3)(a)). + LHA_allowed_bedrooms: 1 + LHA_category: B + housing_benefit_LHA_allowed_bedrooms: 1 + housing_benefit_LHA_category: B + +- name: A pensioner couple's grandchild in full-time education has their own Housing Benefit bedroom + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandparent_1: + age: 70 + is_household_head: true + grandparent_2: + age: 68 + grandchild: + age: 17 + current_education: UPPER_SECONDARY + benunits: + family: + members: [grandparent_1, grandparent_2, grandchild] + households: + home: + members: [grandparent_1, grandparent_2, grandchild] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_claimant_or_partner: [true, true, false] + is_child_or_young_person_for_legacy_benefits: [false, false, true] + # HB (SPC) Regs 2006 reg 13D(3): the couple ((a)) and the young person + # ((b)): 2 bedrooms, category C. + housing_benefit_LHA_allowed_bedrooms: 2 + housing_benefit_LHA_category: C + # No income, so Housing Benefit meets the eligible rent: the rent, which + # is below the two-bedroom rate. + housing_benefit: 10_400 diff --git a/policyengine_uk/tests/test_lha_bedroom_young_people_properties.py b/policyengine_uk/tests/test_lha_bedroom_young_people_properties.py new file mode 100644 index 0000000000..b23fa6a8cd --- /dev/null +++ b/policyengine_uk/tests/test_lha_bedroom_young_people_properties.py @@ -0,0 +1,247 @@ +"""Property-based tests for the bedrooms of benefit-unit members aged 16 or +over who are not the claimant or partner (UC Regs 2013 Sch 4 paras 9-10; HB +Regs 2006 and HB (SPC) Regs 2006 reg 13D(3)). + +Invariants, for any generated population of households: + +1. Monotonicity: adding a person aged 16 to 19 to any family never lowers + any family's Universal Credit or Housing Benefit bedrooms, whether the + claimant and partner are supplied or inferred. +2. Exact effect: when the added person is not the claimant or partner, their + family gains one Housing Benefit bedroom, and one Universal Credit bedroom + unless they are a qualifying young person no one is responsible for (para + 9(2)(g)). The household head's family also gains one bedroom under both + schemes if the person joins a non-dependant's family, and one Housing + Benefit bedroom if they join a boarder's or lodger's family. No other + family's bedrooms change. +3. Reference: every family's bedrooms equal an independent count of the size + criteria: one for the claimant or couple; one for each other member aged + 16 or over (for Universal Credit, except a qualifying young person no one + is responsible for); for the household head's family, one for each person + aged 16 or over in a non-dependant's family (and, for Housing Benefit, in + a boarder's or lodger's family); and the fewest rooms that hold the + family's children under 16 two to a room, where only children of the same + sex or two children under 10 may share, found by brute force. Children of + other families are not counted for the household head, matching the model + (a separate, existing gap). +""" + +from itertools import combinations + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2025 # Situation inputs without a period are set for 2025. +PROPERTY_SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +ROLES = ["sharer", "boarder", "lodger", "non_dependant"] +EDUCATION = ["NOT_IN_EDUCATION", "UPPER_SECONDARY", "TERTIARY"] + +child = st.tuples(st.integers(0, 15), st.booleans()) # (age, is_male) + + +@st.composite +def family(draw, role): + return dict( + role=role, + adults=draw(st.lists(st.integers(20, 85), min_size=1, max_size=2)), + children=draw(st.lists(child, min_size=0, max_size=4)), + ) + + +@st.composite +def household(draw): + others = draw( + st.lists(st.sampled_from(ROLES).flatmap(family), min_size=0, max_size=2) + ) + return [draw(family("head"))] + others + + +@st.composite +def young_person(draw): + return dict( + age=draw(st.integers(16, 19)), + is_male=draw(st.booleans()), + current_education=draw(st.sampled_from(EDUCATION)), + age_started_or_accepted_current_education_or_training=draw(st.integers(14, 19)), + is_before_universal_credit_qualifying_young_person_terminal_date=draw( + st.booleans() + ), + is_looked_after_by_local_authority=draw(st.booleans()), + receives_benefits_in_own_right=draw(st.booleans()), + ) + + +@st.composite +def scenario(draw): + population = draw(st.lists(household(), min_size=1, max_size=4)) + h = draw(st.integers(0, len(population) - 1)) + f = draw(st.integers(0, len(population[h]) - 1)) + return population, (h, f), draw(young_person()) + + +def build(population, added=None, supply_roles=True): + """One situation for the population, optionally with a person added to + family ``added[0]`` = (household, family) with attributes ``added[1]``. + + Returns the situation and, per benefit unit, its (household, family).""" + people, benunits, homes, keys = {}, {}, {}, [] + for h, families in enumerate(population): + members = [] + for f, fam in enumerate(families): + name = f"h{h}_f{f}" + ids = [] + for i, age in enumerate(fam["adults"]): + pid = f"{name}_adult_{i}" + people[pid] = {"age": age, "is_household_head": False} + if supply_roles: + people[pid]["is_claimant_or_partner"] = True + elif fam["children"]: + people[pid]["is_parent"] = True + ids.append(pid) + for i, (age, male) in enumerate(fam["children"]): + pid = f"{name}_child_{i}" + people[pid] = {"age": age, "is_male": male, "is_household_head": False} + if supply_roles: + people[pid]["is_claimant_or_partner"] = False + ids.append(pid) + if added is not None and added[0] == (h, f): + pid = f"{name}_added" + people[pid] = {**added[1], "is_household_head": False} + if supply_roles: + people[pid]["is_claimant_or_partner"] = False + ids.append(pid) + extra = {} + if fam["role"] == "sharer": + extra["liable_for_share_of_household_rent"] = True + if fam["role"] in ("boarder", "lodger"): + people[ids[0]][f"rent_paid_as_{fam['role']}"] = 5_000 + if fam["role"] == "head": + people[ids[0]]["is_household_head"] = True + benunits[name] = {"members": ids, **extra} + members.extend(ids) + keys.append((h, f)) + homes[f"h{h}"] = { + "members": members, + "rent": 12_000, + "tenure_type": "RENT_PRIVATELY", + "brma": "MAIDSTONE", + } + situation = {"people": people, "benunits": benunits, "households": homes} + return situation, keys + + +def bedrooms(situation): + sim = Simulation(situation=situation) + uc = np.asarray(sim.calculate("LHA_allowed_bedrooms", YEAR)) + hb = np.asarray(sim.calculate("housing_benefit_LHA_allowed_bedrooms", YEAR)) + return sim, uc, hb + + +def fewest_rooms_for_children(children): + """Brute force: the fewest rooms holding the children two to a room, + where a pair must be of the same sex or both under 10.""" + + def can_share(a, b): + return a[1] == b[1] or (a[0] < 10 and b[0] < 10) + + def best(remaining): + if not remaining: + return 0 + first, rest = remaining[0], remaining[1:] + rooms = 1 + best(rest) + for j, other in enumerate(rest): + if can_share(first, other): + rooms = min(rooms, 1 + best(rest[:j] + rest[j + 1 :])) + return rooms + + return best(list(children)) + + +@PROPERTY_SETTINGS +@given(scenario(), st.booleans()) +def test_adding_a_person_aged_16_to_19_never_lowers_bedrooms(case, supply_roles): + population, target, person = case + before, keys = build(population, supply_roles=supply_roles) + after, keys_after = build(population, (target, person), supply_roles) + assert keys == keys_after + _, uc_before, hb_before = bedrooms(before) + _, uc_after, hb_after = bedrooms(after) + # 1. Monotonicity. + assert np.all(uc_after >= uc_before) + assert np.all(hb_after >= hb_before) + + +@PROPERTY_SETTINGS +@given(scenario()) +def test_exact_effect_of_adding_a_person_aged_16_to_19(case): + population, target, person = case + before, keys = build(population) + after, _ = build(population, (target, person)) + _, uc_before, hb_before = bedrooms(before) + sim, uc_after, hb_after = bedrooms(after) + # Whether the added person is a qualifying young person no one is + # responsible for. (The qualifying young person tests themselves are + # covered elsewhere.) + pid = f"h{target[0]}_f{target[1]}_added" + index = list(after["people"]).index(pid) + qualifying = sim.calculate("is_qualifying_young_person_for_universal_credit", YEAR) + responsible = sim.calculate( + "is_child_or_qualifying_young_person_for_universal_credit", YEAR + ) + unclaimed = bool(qualifying[index]) and not bool(responsible[index]) + # 2. Exact effect. + expected_uc = np.zeros(len(keys)) + expected_hb = np.zeros(len(keys)) + t = keys.index(target) + expected_uc[t] += 0 if unclaimed else 1 + expected_hb[t] += 1 + role = population[target[0]][target[1]]["role"] + head = keys.index((target[0], 0)) + if role == "non_dependant": + expected_uc[head] += 1 + expected_hb[head] += 1 + elif role in ("boarder", "lodger"): + expected_hb[head] += 1 + assert np.array_equal(uc_after - uc_before, expected_uc) + assert np.array_equal(hb_after - hb_before, expected_hb) + + +@PROPERTY_SETTINGS +@given(scenario()) +def test_bedrooms_match_an_independent_count_of_the_size_criteria(case): + population, target, person = case + situation, keys = build(population, (target, person)) + sim, uc, hb = bedrooms(situation) + qualifying = sim.calculate("is_qualifying_young_person_for_universal_credit", YEAR) + responsible = sim.calculate( + "is_child_or_qualifying_young_person_for_universal_credit", YEAR + ) + unclaimed = dict( + zip(situation["people"], np.asarray(qualifying) & ~np.asarray(responsible)) + ) + # 3. Reference count. + for b, (h, f) in enumerate(keys): + families = population[h] + fam = families[f] + added = (h, f) == target + own_children = fewest_rooms_for_children(fam["children"]) + own_uc = int(added and not unclaimed[f"h{h}_f{f}_added"]) + own_hb = int(added) + outside_uc = outside_hb = 0 + if fam["role"] == "head": + for g, other in enumerate(families[1:], start=1): + aged_16_or_over = len(other["adults"]) + int(target == (h, g)) + if other["role"] == "non_dependant": + outside_uc += aged_16_or_over + if other["role"] in ("non_dependant", "boarder", "lodger"): + outside_hb += aged_16_or_over + assert uc[b] == 1 + own_uc + outside_uc + own_children + assert hb[b] == 1 + own_hb + outside_hb + own_children From 73abf3f4a82501f3648f1a37e1a15540b7621b4c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 14:39:49 -0400 Subject: [PATCH 03/14] Charge non-dependant deductions for non-dependants within the claimant's benefit unit A member of a benefit unit aged 16 or over who is not the claimant, partner or a qualifying young person (UC) or child or young person (HB, CTR) is a non-dependant of the renter (UC Regs 2013 Sch 4 para 9; HB Regs 2006 and HB (SPC) Regs 2006 reg 3; SI 2012/2885 reg 9 and the Welsh and Scottish equivalents). The deductions now charge them: - UC: to the renter's own claim; non-dependants in other families stay with the household head's claim (para 9(2)(f)). Adds the para 15 renter exemption and limits the para 16 Pension Credit exemption to the claimant or partner who receives it. - HB: apportioned between joint occupiers by rent share (reg 74(5)); a boarder or lodger bears their own. - National CTR: pooled across the claiming families by joint-liability share; only claiming families bear deductions. Fixes #2014. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2014.fixed.md | 1 + .../family/benefit_unit_non_dependants.yaml | 592 ++++++++++++++++++ ...individual_non_dep_deduction_eligible.yaml | 24 +- .../uc_individual_non_dep_deduction.yaml | 5 + ...individual_non_dep_deduction_eligible.yaml | 38 +- ...t_benefit_unit_non_dependant_properties.py | 381 +++++++++++ ...sharers_boarders_and_lodgers_properties.py | 8 +- ...t_individual_non_dep_deduction_eligible.py | 18 +- .../housing_benefit_non_dep_deductions.py | 39 +- ..._unit_non_dependant_for_legacy_benefits.py | 43 ++ ...unit_non_dependant_for_universal_credit.py | 36 ++ ...c_individual_non_dep_deduction_eligible.py | 21 +- .../uc_non_dep_deduction_exempt.py | 9 +- .../uc_non_dep_deductions.py | 35 +- .../uc_non_dep_deductions_renter_exempt.py | 31 + ...n_individual_non_dep_deduction_eligible.py | 31 +- ...ouncil_tax_reduction_non_dep_deductions.py | 17 +- 17 files changed, 1282 insertions(+), 47 deletions(-) create mode 100644 changelog.d/2014.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml create mode 100644 policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py create mode 100644 policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_legacy_benefits.py create mode 100644 policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_universal_credit.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions_renter_exempt.py diff --git a/changelog.d/2014.fixed.md b/changelog.d/2014.fixed.md new file mode 100644 index 0000000000..b3c0b44145 --- /dev/null +++ b/changelog.d/2014.fixed.md @@ -0,0 +1 @@ +- Charge Universal Credit, Housing Benefit and national Council Tax Reduction non-dependant deductions for members of the claimant's own benefit unit who are not the claimant, partner or a child or young person, such as an adult son (UC Regs 2013 Sch 4 para 9; HB Regs 2006 and HB (SPC) Regs 2006 reg 3; SI 2012/2885 reg 9 and the Welsh and Scottish equivalents), and apply the Universal Credit renter exemption from housing cost contributions (Sch 4 para 15). diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml new file mode 100644 index 0000000000..3efb8dabd7 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml @@ -0,0 +1,592 @@ +# Non-dependants within the claimant's own benefit unit. +# +# A benefit unit can include someone aged 16 or over who is neither the +# claimant or partner nor a child or young person the claimant is responsible +# for, such as an adult son or a 19-year-old who has left education. In law +# that person is a non-dependant of the renter (UC Regs 2013 Sch 4 para +# 9(1)(c) and (2); HB Regs 2006 and HB (SPC) Regs 2006 reg 3; CTR SI +# 2012/2885 reg 9, WSI 2013/3029 reg 9, SSI 2021/249 reg 8), so their family's +# claim bears a deduction for them, subject to each scheme's exemptions. +# +# Amounts for 2026-27: +# - UC housing cost contribution £96.55 a month (Sch 4 para 14(1)), aged 21 +# or over (para 16(2)(a)): £1,158.60 a year. +# - HB non-dependant aged 18 or over not in remunerative work £20.40 a week +# (reg 74(1)(b)): £1,060.80 a year. In remunerative work with gross income +# of £300 a week: £64.35 (reg 74(2)(c)), £3,346.20 a year. The HB cases +# give part-time earners £50 a week for 10 hours, below remunerative work +# (16 hours, reg 6) and inside the lowest band. + +- name: UC, an adult son in the renter's benefit unit bears a housing cost contribution + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, son] + households: + household: + members: [parent, son] + output: + is_benefit_unit_non_dependant_for_universal_credit: [false, true] + uc_individual_non_dep_deduction: [0, 1_158.60] + uc_non_dep_deductions: 1_158.60 + +- name: UC, no contribution for a non-dependant in the benefit unit under 21 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + # Left education: not a qualifying young person. + age: 19 + is_claimant_or_partner: false + is_qualifying_young_person_for_universal_credit: false + benunits: + benunit: + members: [parent, son] + households: + household: + members: [parent, son] + output: + is_benefit_unit_non_dependant_for_universal_credit: [false, true] + uc_non_dep_deductions: 0 + +- name: UC, a qualifying young person the renter is responsible for is not a non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + daughter: + age: 19 + is_claimant_or_partner: false + is_qualifying_young_person_for_universal_credit: true + benunits: + benunit: + members: [parent, daughter] + households: + household: + members: [parent, daughter] + output: + is_benefit_unit_non_dependant_for_universal_credit: [false, false] + uc_non_dep_deductions: 0 + +# Sch 4 para 9(2)(g): a qualifying young person for whom no one in the +# renter's extended benefit unit is responsible is not a non-dependant. +- name: UC, a qualifying young person looked after by a local authority is not a non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 50 + is_household_head: true + is_claimant_or_partner: true + young_person: + age: 17 + is_claimant_or_partner: false + is_qualifying_young_person_for_universal_credit: true + is_looked_after_by_local_authority: true + benunits: + benunit: + members: [carer, young_person] + households: + household: + members: [carer, young_person] + output: + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + is_benefit_unit_non_dependant_for_universal_credit: [false, false] + uc_non_dep_deductions: 0 + +# Sch 4 para 15: no contribution for any non-dependant if the renter, or +# either joint renter of a couple, receives the PIP daily living component. +- name: UC, no contribution where the renter receives the PIP daily living component + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + pip_dl: 3_000 + son: + age: 25 + is_claimant_or_partner: false + aunt: + # A non-dependant in another family. + age: 30 + benunits: + benunit_1: + members: [parent, son] + benunit_2: + members: [aunt] + households: + household: + members: [parent, son, aunt] + output: + uc_non_dep_deductions_renter_exempt: [true, false] + uc_non_dep_deductions: [0, 0] + +- name: UC, no contribution where the renter's partner is blind + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent_1: + age: 52 + is_household_head: true + is_claimant_or_partner: true + parent_2: + age: 50 + is_claimant_or_partner: true + is_blind: true + son: + age: 25 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent_1, parent_2, son] + households: + household: + members: [parent_1, parent_2, son] + output: + uc_non_dep_deductions: 0 + +# Sch 4 para 16(2)(d): no contribution for a non-dependant receiving +# Attendance Allowance. +- name: UC, no contribution for a non-dependant in the benefit unit receiving Attendance Allowance + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + attendance_allowance: 3_000 + benunits: + benunit: + members: [parent, son] + households: + household: + members: [parent, son] + output: + uc_non_dep_deduction_exempt: [false, true] + uc_non_dep_deductions: 0 + +# Pension Credit is the claimant's award: an adult son in a family on Pension +# Credit is not receiving it, so para 16(2)(b) does not exempt him. +- name: UC, a family's Pension Credit does not exempt the non-dependant in its benefit unit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 70 + is_claimant_or_partner: true + son: + age: 40 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, son] + pension_credit: 2_000 + output: + uc_non_dep_deduction_exempt: [true, false] + +- name: UC, the head's family bears non-dependants in its own benefit unit and in other families + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + aunt: + age: 60 + is_claimant_or_partner: true + benunits: + benunit_1: + members: [parent, son] + benunit_2: + members: [aunt] + households: + household: + members: [parent, son, aunt] + output: + uc_non_dep_deductions: [2_317.20, 0] + +# A non-dependant family's own adult member is a non-dependant of the +# household head, counted once, in the head's claim (Sch 4 para 9(2)(f)). +- name: UC, an adult in a non-dependant family is the household head's non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + is_claimant_or_partner: true + daughter: + age: 45 + is_claimant_or_partner: true + grandson: + age: 23 + is_claimant_or_partner: false + benunits: + benunit_1: + members: [head] + benunit_2: + members: [daughter, grandson] + households: + household: + members: [head, daughter, grandson] + output: + uc_non_dep_deductions: [2_317.20, 0] + +# A sharer of the rent bears the non-dependant in its own benefit unit; the +# household head's family does not (para 9(2)(f)). +- name: UC, a sharer of the rent bears the non-dependant in its own benefit unit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + sharer: + age: 48 + is_claimant_or_partner: true + sharer_son: + age: 26 + is_claimant_or_partner: false + benunits: + benunit_1: + members: [head] + benunit_2: + members: [sharer, sharer_son] + liable_for_share_of_household_rent: true + households: + household: + members: [head, sharer, sharer_son] + rent: 12_000 + output: + uc_non_dep_deductions: [0, 1_158.60] + +- name: UC, the housing costs element nets off the contribution for an adult son + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, son] + benunit_tenure_type: RENT_FROM_COUNCIL + benunit_rent: 6_000 + households: + household: + members: [parent, son] + output: + # £6,000 - £1,158.60. + uc_housing_costs_element: 4_841.40 + +- name: HB, an adult son in the claimant's benefit unit working part time + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 2_600 + weekly_hours: 10 + benunits: + benunit: + members: [parent, son] + households: + household: + members: [parent, son] + output: + is_benefit_unit_non_dependant_for_legacy_benefits: [false, true] + housing_benefit_non_dep_deductions: 1_060.80 + +- name: HB, an adult daughter in the claimant's benefit unit working full time on £300 a week + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + daughter: + age: 22 + is_claimant_or_partner: false + employment_income: 15_600 + weekly_hours: 37.5 + benunits: + benunit: + members: [parent, daughter] + households: + household: + members: [parent, daughter] + output: + housing_benefit_non_dep_deductions: 3_346.20 + +- name: HB, no deduction for a non-dependant in the benefit unit under 18 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 17 + is_claimant_or_partner: false + is_child_or_qualifying_young_person_for_child_benefit: false + benunits: + benunit: + members: [parent, son] + households: + household: + members: [parent, son] + output: + is_benefit_unit_non_dependant_for_legacy_benefits: [false, true] + housing_benefit_non_dep_deductions: 0 + +# HB reg 19: a Child Benefit qualifying young person is in the claimant's +# family, not a non-dependant. +- name: HB, a young person in the claimant's family is not a non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + daughter: + age: 19 + is_claimant_or_partner: false + is_child_or_qualifying_young_person_for_child_benefit: true + benunits: + benunit: + members: [parent, daughter] + households: + household: + members: [parent, daughter] + output: + is_benefit_unit_non_dependant_for_legacy_benefits: [false, false] + housing_benefit_non_dep_deductions: 0 + +# HB reg 74(5): a non-dependant of two joint occupiers is apportioned by their +# shares of the rent, whichever family the non-dependant belongs to. +- name: HB, joint occupiers share the deduction for an adult son in one of their families + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 2_600 + weekly_hours: 10 + sharer: + age: 48 + is_claimant_or_partner: true + benunits: + benunit_1: + members: [head, son] + benunit_2: + members: [sharer] + liable_for_share_of_household_rent: true + households: + household: + members: [head, son, sharer] + rent: 12_000 + output: + share_of_household_rent: [0.5, 0.5] + housing_benefit_non_dep_deductions: [530.40, 530.40] + +# A lodger's family has no share of the household's rent: it bears only the +# non-dependant in its own benefit unit. +- name: HB, a lodger bears the non-dependant in their own benefit unit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 60 + is_household_head: true + is_claimant_or_partner: true + lodger: + age: 45 + is_claimant_or_partner: true + rent_paid_as_lodger: 5_200 + lodger_son: + age: 21 + is_claimant_or_partner: false + employment_income: 2_600 + weekly_hours: 10 + benunits: + benunit_1: + members: [head] + benunit_2: + members: [lodger, lodger_son] + households: + household: + members: [head, lodger, lodger_son] + rent: 9_000 + output: + housing_benefit_non_dep_deductions: [0, 1_060.80] + +- name: CTR, the claimant's family bears the non-dependant in its benefit unit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + council_tax_reduction_individual_non_dep_deduction: 300 + benunits: + benunit: + members: [parent, son] + households: + household: + members: [parent, son] + output: + council_tax_reduction_individual_non_dep_deduction_eligible: [false, true] + council_tax_reduction_non_dep_deductions: 300 + +- name: CTR, no deduction for a non-dependant in the benefit unit under 18 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 17 + is_claimant_or_partner: false + is_child_or_qualifying_young_person_for_child_benefit: false + benunits: + benunit: + members: [parent, son] + households: + household: + members: [parent, son] + output: + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false] + council_tax_reduction_non_dep_deductions: 0 + +# SI 2012/2885 Sch 1 para 8(5): a non-dependant of two jointly liable people +# is apportioned equally between them, wherever the non-dependant's family. +- name: CTR, jointly liable families share the deduction for an adult son in one of them + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + council_tax_reduction_individual_non_dep_deduction: 300 + sharer: + age: 48 + is_claimant_or_partner: true + benunits: + benunit_1: + members: [head, son] + benunit_2: + members: [sharer] + liable_for_share_of_household_rent: true + households: + household: + members: [head, son, sharer] + rent: 12_000 + output: + council_tax_reduction_joint_liability_share: [0.5, 0.5] + council_tax_reduction_non_dep_deductions: [150, 150] + +# Only a family that claims bears deductions; the non-dependant's own family +# does not. +- name: CTR, a non-dependant family with an adult member bears no deductions itself + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + is_claimant_or_partner: true + daughter: + age: 45 + is_claimant_or_partner: true + council_tax_reduction_individual_non_dep_deduction: 200 + grandson: + age: 23 + is_claimant_or_partner: false + council_tax_reduction_individual_non_dep_deduction: 100 + benunits: + benunit_1: + members: [head] + benunit_2: + members: [daughter, grandson] + households: + household: + members: [head, daughter, grandson] + output: + council_tax_reduction_individual_non_dep_deduction_eligible: [false, true, true] + council_tax_reduction_non_dep_deductions: [300, 0] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.yaml index 0307c37986..3d8f2812d9 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.yaml @@ -1,7 +1,7 @@ - name: Eligible period: 2025 input: - benunit_is_rent_liable: false + is_non_dependant_of_household_head: true age: 18 output: housing_benefit_individual_non_dep_deduction_eligible: true @@ -9,7 +9,7 @@ - name: Age ineligible period: 2025 input: - benunit_is_rent_liable: false + is_non_dependant_of_household_head: true age: 17 output: housing_benefit_individual_non_dep_deduction_eligible: false @@ -21,3 +21,23 @@ age: 18 output: housing_benefit_individual_non_dep_deduction_eligible: false + +# HB Regs 2006 reg 3: a member of the claimant's own benefit unit who is not +# the claimant, partner or a child or young person. +- name: Non-dependant within the benefit unit + period: 2025 + input: + is_non_dependant_of_household_head: false + is_benefit_unit_non_dependant_for_legacy_benefits: true + age: 19 + output: + housing_benefit_individual_non_dep_deduction_eligible: true + +- name: Non-dependant within the benefit unit, under 18 + period: 2025 + input: + is_non_dependant_of_household_head: false + is_benefit_unit_non_dependant_for_legacy_benefits: true + age: 17 + output: + housing_benefit_individual_non_dep_deduction_eligible: false diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction.yaml index 1f7f3eada3..75d21ea9ce 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction.yaml @@ -6,6 +6,8 @@ person: age: 22 uc_non_dep_deduction_exempt: false + # A member of another family who is not liable for rent. + is_non_dependant_of_household_head: true benunits: benunit: members: person @@ -21,6 +23,7 @@ person: age: 22 uc_non_dep_deduction_exempt: true + is_non_dependant_of_household_head: true benunits: benunit: members: person @@ -36,6 +39,8 @@ person: age: 20 uc_non_dep_deduction_exempt: false + # A member of another family who is not liable for rent. + is_non_dependant_of_household_head: true benunits: benunit: members: person diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.yaml index 11fe57ddce..99888fb21a 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.yaml @@ -4,7 +4,7 @@ input: age: 21 uc_non_dep_deduction_exempt: false - benunit_is_rent_liable: false + is_non_dependant_of_household_head: true output: uc_individual_non_dep_deduction_eligible: true @@ -14,6 +14,40 @@ input: age: 20 uc_non_dep_deduction_exempt: false - benunit_is_rent_liable: false + is_non_dependant_of_household_head: true + output: + uc_individual_non_dep_deduction_eligible: false + +- name: Exempt + period: 2020 + absolute_error_margin: 0 + input: + age: 21 + uc_non_dep_deduction_exempt: true + is_non_dependant_of_household_head: true + output: + uc_individual_non_dep_deduction_eligible: false + +# UC Regs 2013 Sch 4 para 9(1)(c): a member of the renter's own benefit unit +# who is not the claimant, partner or a qualifying young person. +- name: Non-dependant within the benefit unit + period: 2020 + absolute_error_margin: 0 + input: + age: 25 + uc_non_dep_deduction_exempt: false + is_non_dependant_of_household_head: false + is_benefit_unit_non_dependant_for_universal_credit: true + output: + uc_individual_non_dep_deduction_eligible: true + +- name: The renter is not their own non-dependant + period: 2020 + absolute_error_margin: 0 + input: + age: 25 + uc_non_dep_deduction_exempt: false + is_non_dependant_of_household_head: false + is_benefit_unit_non_dependant_for_universal_credit: false output: uc_individual_non_dep_deduction_eligible: false diff --git a/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py new file mode 100644 index 0000000000..1702d26e6e --- /dev/null +++ b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py @@ -0,0 +1,381 @@ +"""Property-based tests for non-dependants within a benefit unit. + +A benefit unit can include members aged 16 or over who are neither the +claimant or partner nor a qualifying young person: in law they are +non-dependants of the renter (UC Regs 2013 Sch 4 para 9; HB Regs 2006 reg 3; +CTR SI 2012/2885 reg 9 and equivalents). Invariants, for any generated +population of households: + +1. Oracle: each family's Universal Credit, Housing Benefit and Council Tax + Reduction non-dependant deductions equal an independent calculation from + the generated household structure: who is a non-dependant, of whom, and + how a non-dependant of several joint occupiers is apportioned. +2. Conservation: every Housing Benefit and Council Tax Reduction + non-dependant deduction is borne in full, once, across the household's + families; every Universal Credit contribution is borne by exactly one + family unless that family's renter is exempt. +3. Exemptions: a family whose renter is exempt under Sch 4 para 15 has no + Universal Credit deductions; no deduction is charged for a non-dependant + under 21 (UC) or under 18 (HB, CTR), or for a qualifying young person. +4. No-op: in households without non-dependants inside a benefit unit, the + deductions equal the previous formulas, which charged only members of + other families. +5. Bedrooms: every Universal Credit non-dependant within a benefit unit has a + bedroom in the size criteria (para 10(1)(c)), so the deduction and the + bedroom use one definition. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +# Situation inputs without a period key are set for 2025, so the properties +# are checked in 2025, when the contribution was £93.02 a month. +YEAR = 2025 +UC_CONTRIBUTION = 93.02 * 12 +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# Members of a benefit unit who are not the claimant or partner. +# dependant: a qualifying young person the claimant is responsible for; +# looked_after: a qualifying young person placed by a local authority, for +# whom no one in the family is responsible; +# non_dependant: aged 16 or over and not a qualifying young person. +KINDS = ["dependant", "looked_after", "non_dependant"] +ROLES = ["sharer", "lodger", "non_dependant"] + + +@st.composite +def extra_member(draw): + kind = draw(st.sampled_from(KINDS)) + age = draw(st.integers(16, 19) if kind != "non_dependant" else st.integers(16, 60)) + return dict( + kind=kind, + age=age, + earnings=draw(st.sampled_from([0, 2_600, 15_600, 30_000])), + hours=draw(st.sampled_from([0, 10, 37.5])), + attendance_allowance=draw(st.booleans()), + ) + + +@st.composite +def family(draw, role): + # The household head's family has the household's oldest members, so it + # is unambiguously the head's (and the council tax claimant's) family. + adult_age = st.integers(61, 90) if role == "head" else st.integers(25, 59) + return dict( + role=role, + adult_ages=draw(st.lists(adult_age, min_size=1, max_size=2)), + extras=draw(st.lists(extra_member(), min_size=0, max_size=2)), + child=draw(st.booleans()), + renter_pip=draw(st.booleans()), + payment=draw(st.floats(1_000, 10_000, allow_nan=False)), + ) + + +@st.composite +def household(draw): + head = draw(family("head")) + others = [ + draw(family(draw(st.sampled_from(ROLES)))) + for _ in range(draw(st.integers(0, 2))) + ] + return dict( + families=[head] + others, + rent=draw(st.floats(3_000, 20_000, allow_nan=False)), + tenure=draw(st.sampled_from(["RENT_FROM_COUNCIL", "RENT_PRIVATELY"])), + ) + + +population = st.lists(household(), min_size=1, max_size=5) + + +def build(population, convert=None): + """One situation for the whole population, and a record per person and + per family for the oracle. convert maps extra members' kinds to others.""" + people, benunits, homes = {}, {}, {} + person_rows, family_rows = [], [] + for h, house in enumerate(population): + members = [] + for f, fam in enumerate(house["families"]): + name = f"h{h}_f{f}" + ids = [] + head_family = f == 0 + # The head's family has the household's oldest member. + for i, age in enumerate(fam["adult_ages"]): + pid = f"{name}_adult_{i}" + pip = fam["renter_pip"] and i == 0 + people[pid] = { + "age": age, + "is_claimant_or_partner": True, + "is_household_head": head_family and i == 0, + "pip_dl": 3_000.0 if pip else 0.0, + } + ids.append(pid) + person_rows.append( + dict( + household=h, + family=len(family_rows), + kind="adult", + age=age, + disability_benefit=pip, + ) + ) + for i, extra in enumerate(fam["extras"]): + kind = (convert or {}).get(extra["kind"], extra["kind"]) + pid = f"{name}_extra_{i}" + young = kind != "non_dependant" + people[pid] = { + "age": min(extra["age"], 19) if young else extra["age"], + "is_claimant_or_partner": False, + "is_household_head": False, + "is_qualifying_young_person_for_universal_credit": young, + "is_child_or_qualifying_young_person_for_child_benefit": young, + "is_looked_after_by_local_authority": kind == "looked_after", + "employment_income": float(extra["earnings"]), + "weekly_hours": float(extra["hours"]), + "attendance_allowance": ( + 3_000.0 if extra["attendance_allowance"] else 0.0 + ), + } + ids.append(pid) + person_rows.append( + dict( + household=h, + family=len(family_rows), + kind=kind, + age=people[pid]["age"], + disability_benefit=extra["attendance_allowance"], + ) + ) + if fam["child"]: + pid = f"{name}_child" + people[pid] = { + "age": 8, + "is_claimant_or_partner": False, + "is_household_head": False, + } + ids.append(pid) + person_rows.append( + dict( + household=h, + family=len(family_rows), + kind="child", + age=8, + disability_benefit=False, + ) + ) + benunit = {"members": ids} + if fam["role"] == "sharer": + benunit["liable_for_share_of_household_rent"] = True + if fam["role"] == "lodger": + people[ids[0]]["rent_paid_as_lodger"] = fam["payment"] + benunits[name] = benunit + members.extend(ids) + family_rows.append( + dict( + household=h, + role=fam["role"], + renter_pip=fam["renter_pip"], + n_liable=len(fam["adult_ages"]), + ) + ) + homes[f"h{h}"] = { + "members": members, + "rent": house["rent"], + "tenure_type": house["tenure"], + "brma": "MAIDSTONE", + } + situation = {"people": people, "benunits": benunits, "households": homes} + return situation, person_rows, family_rows + + +def calc(sim, variable, map_to=None): + return np.asarray(sim.calculate(variable, YEAR, map_to=map_to), dtype=float) + + +def oracle(person_rows, family_rows, uc, hb, ctr, pension_credit, claims, ctr_share): + """Expected deductions per family from the generated structure. + + uc, hb and ctr are each person's individual deduction from the model; + the oracle decides independently who is whose non-dependant. + """ + n = len(family_rows) + exp_uc, exp_hb, exp_ctr = np.zeros(n), np.zeros(n), np.zeros(n) + expected_uc_individual = np.zeros(len(person_rows)) + households = sorted({f["household"] for f in family_rows}) + for h in households: + fams = [i for i, f in enumerate(family_rows) if f["household"] == h] + head = fams[0] + # Families liable for the household's rent: the head's and sharers'. + liable = [i for i in fams if family_rows[i]["role"] in ("head", "sharer")] + total_liable = sum(family_rows[i]["n_liable"] for i in liable) + share = { + i: (family_rows[i]["n_liable"] / total_liable if i in liable else 0.0) + for i in fams + } + for p, row in enumerate(person_rows): + if row["household"] != h: + continue + f = row["family"] + role = family_rows[f]["role"] + other_family = role == "non_dependant" + own_family = row["kind"] == "non_dependant" + # UC: a member of a family not liable for rent is the head's + # non-dependant; a non-QYP member aged 16+ of any other family is + # their own family's. + # Para 16: Attendance Allowance or PIP daily living, or Pension + # Credit, which only a claimant or partner receives. + uc_exempt = row["disability_benefit"] or ( + row["kind"] == "adult" and pension_credit[f] > 0 + ) + uc_non_dependant = other_family or own_family + if uc_non_dependant and row["age"] >= 21 and not uc_exempt: + expected_uc_individual[p] = UC_CONTRIBUTION + bearer = head if other_family else f + if uc_non_dependant and not family_rows[bearer]["renter_pip"]: + exp_uc[bearer] += uc[p] + # HB: pooled across joint occupiers by share; a lodger's own + # non-dependants fall on the lodger. + if other_family or (own_family and share[f] > 0): + for i in fams: + exp_hb[i] += share[i] * hb[p] + elif own_family: + exp_hb[f] += hb[p] + # CTR: every non-dependant is pooled across the claiming families. + if other_family or own_family: + for i in fams: + exp_ctr[i] += claims[i] * ctr_share[i] * ctr[p] + return exp_uc, exp_hb, exp_ctr, expected_uc_individual + + +def run(population, convert=None): + situation, person_rows, family_rows = build(population, convert) + sim = Simulation(situation=situation) + values = dict( + uc=calc(sim, "uc_individual_non_dep_deduction"), + hb=calc(sim, "household_benefits_individual_non_dep_deduction"), + ctr=calc(sim, "council_tax_reduction_individual_non_dep_deduction"), + pension_credit=calc(sim, "pension_credit"), + claims=calc(sim, "council_tax_reduction_claimant_benunit"), + ctr_share=calc(sim, "council_tax_reduction_joint_liability_share"), + ) + return sim, person_rows, family_rows, values + + +@PROPERTY_SETTINGS +@given(population) +def test_deductions_match_the_oracle(population): + sim, person_rows, family_rows, v = run(population) + exp_uc, exp_hb, exp_ctr, exp_uc_individual = oracle(person_rows, family_rows, **v) + # 1. Oracle. + assert np.allclose(v["uc"], exp_uc_individual, atol=0.01) + assert np.allclose(calc(sim, "uc_non_dep_deductions"), exp_uc, atol=0.01) + assert np.allclose( + calc(sim, "housing_benefit_non_dep_deductions"), exp_hb, atol=0.01 + ) + assert np.allclose( + calc(sim, "council_tax_reduction_non_dep_deductions"), exp_ctr, atol=0.01 + ) + + +@PROPERTY_SETTINGS +@given(population) +def test_conservation_and_exemptions(population): + sim, person_rows, family_rows, v = run(population) + ages = np.array([row["age"] for row in person_rows]) + kinds = np.array([row["kind"] for row in person_rows]) + # 2. Conservation: HB and CTR deductions are borne once in full. + hb_families = sim.map_result( + calc(sim, "housing_benefit_non_dep_deductions"), "benunit", "household" + ) + hb_people = sim.map_result(v["hb"], "person", "household") + assert np.allclose(hb_families, hb_people, atol=0.01) + claim_shares = sim.map_result(v["claims"] * v["ctr_share"], "benunit", "household") + ctr_families = sim.map_result( + calc(sim, "council_tax_reduction_non_dep_deductions"), "benunit", "household" + ) + ctr_people = sim.map_result(v["ctr"], "person", "household") + assert np.allclose(ctr_families, claim_shares * ctr_people, atol=0.01) + # UC: each contribution is borne once, except where the bearer is exempt. + uc_families = sim.map_result( + calc(sim, "uc_non_dep_deductions"), "benunit", "household" + ) + uc_people = sim.map_result(v["uc"], "person", "household") + assert np.all(uc_families <= uc_people + 0.01) + # 3. Exemptions. + renter_exempt = calc(sim, "uc_non_dep_deductions_renter_exempt") > 0 + assert np.all(calc(sim, "uc_non_dep_deductions")[renter_exempt] == 0) + assert np.all(v["uc"][ages < 21] == 0) + assert np.all(v["hb"][ages < 18] == 0) + assert np.all(v["ctr"][ages < 18] == 0) + # Nobody in a family liable for rent is charged except its own + # non-dependants. + not_non_dependant = kinds != "non_dependant" + liable_family = np.array( + [family_rows[row["family"]]["role"] != "non_dependant" for row in person_rows] + ) + for amounts in (v["uc"], v["hb"], v["ctr"]): + assert np.all(amounts[not_non_dependant & liable_family] == 0) + + +@PROPERTY_SETTINGS +@given(population) +def test_no_op_without_non_dependants_in_a_benefit_unit(population): + every_kind_dependant = {kind: "dependant" for kind in KINDS} + sim, person_rows, family_rows, v = run(population, every_kind_dependant) + # 4. The previous formulas: only members of other families, charged to + # the head's family (UC), by rent share (HB) or by claim share (CTR). + person_household = np.array([row["household"] for row in person_rows]) + family_household = np.array([row["household"] for row in family_rows]) + other = calc(sim, "is_non_dependant_of_household_head") > 0 + + def other_families(amounts): + by_household = np.bincount( + person_household, + weights=amounts * other, + minlength=family_household.max() + 1, + ) + return by_household[family_household] + + head_family = np.array([row["role"] == "head" for row in family_rows]) + renter_exempt = calc(sim, "uc_non_dep_deductions_renter_exempt") > 0 + assert np.allclose( + calc(sim, "uc_non_dep_deductions"), + np.where(renter_exempt, 0, head_family * other_families(v["uc"])), + atol=0.01, + ) + assert np.allclose( + calc(sim, "housing_benefit_non_dep_deductions"), + calc(sim, "share_of_household_rent") * other_families(v["hb"]), + atol=0.01, + ) + assert np.allclose( + calc(sim, "council_tax_reduction_non_dep_deductions"), + v["claims"] * v["ctr_share"] * other_families(v["ctr"]), + atol=0.01, + ) + + +@PROPERTY_SETTINGS +@given(population) +def test_every_non_dependant_in_a_benefit_unit_has_a_bedroom(population): + sim, person_rows, family_rows, v = run(population) + # 5. Para 10(1)(b)-(c): a bedroom for each responsible qualifying young + # person and each non-dependant within the unit; the rest of the count + # (renter, children, other families' non-dependants) is unchanged when + # those members are made dependants instead. + in_unit = calc(sim, "is_benefit_unit_non_dependant_for_universal_credit") + kinds = np.array([row["kind"] for row in person_rows]) + assert np.array_equal(in_unit > 0, kinds == "non_dependant") + rooms = calc(sim, "LHA_allowed_bedrooms") + as_dependants, *_ = run(population, {"non_dependant": "dependant"}) + rooms_as_dependants = calc(as_dependants, "LHA_allowed_bedrooms") + # A non-dependant has a bedroom as a responsible qualifying young person + # would, so the count is the same either way. + assert np.array_equal(rooms, rooms_as_dependants) diff --git a/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py b/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py index c3b3efca5a..823c384da4 100644 --- a/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py +++ b/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py @@ -12,9 +12,11 @@ 3. Tenure: a boarder's or lodger's family rents privately, is never in social housing, and is LHA-eligible. 4. Non-dependants: a family liable for rent is never a non-dependant; only - the household head's family has Universal Credit non-dependant - deductions; a boarder's or lodger's family has no Housing Benefit or - Council Tax Reduction non-dependant deductions. + the household head's family has Universal Credit deductions for + non-dependants in other families; a boarder's or lodger's family has no + Housing Benefit or Council Tax Reduction deductions for them. (The + generated families have no non-dependants within their own benefit unit, + which test_benefit_unit_non_dependant_properties covers.) 5. Category: the Universal Credit LHA category does not depend on the household input is_shared_accommodation; the Housing Benefit category is the shared rate for any family entitled to one bedroom that lacks diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.py b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.py index 37ea75a311..164a42b907 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.py @@ -5,10 +5,24 @@ class housing_benefit_individual_non_dep_deduction_eligible(Variable): value_type = bool entity = Person label = "eligible person for the Housing Benefit non-dependent deduction" + documentation = ( + "A non-dependant aged 18 or over: either a member of another family " + "who is not liable for rent (see is_non_dependant_of_household_head) " + "or a member of their own benefit unit who is not the claimant, " + "partner or a child or young person (see " + "is_benefit_unit_non_dependant_for_legacy_benefits)." + ) definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/regulation/3", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/74", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/55", + ) def formula(person, period, parameters): - rent_liable = person.benunit("benunit_is_rent_liable", period) p = parameters(period).gov.dwp.housing_benefit.non_dep_deduction + non_dependant = person("is_non_dependant_of_household_head", period) | person( + "is_benefit_unit_non_dependant_for_legacy_benefits", period + ) age_eligible = person("age", period) >= p.age_threshold - return age_eligible & ~rent_liable + return non_dependant & age_eligible diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py index e5e9e3a933..03adebf196 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py @@ -5,11 +5,20 @@ class housing_benefit_non_dep_deductions(Variable): value_type = float entity = BenUnit label = "non-dependent deductions" + documentation = ( + "Deductions for the claimant's non-dependants: members of other " + "families of the household who are not liable for rent, and members " + "of a family liable for the rent who are not its claimant, partner or " + "a child or young person. A non-dependant of several joint occupiers " + "is apportioned between them by their shares of the rent; a boarder " + "or lodger bears only the non-dependants in their own family." + ) definition_period = YEAR unit = GBP reference = ( "https://www.legislation.gov.uk/uksi/2006/213/regulation/3", "https://www.legislation.gov.uk/uksi/2006/213/regulation/74", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/3", "https://www.legislation.gov.uk/uksi/2006/214/regulation/55", ) @@ -17,14 +26,26 @@ def formula(benunit, period, parameters): # Deductions are made for non-dependants residing with the claimant # (HB Regs 2006 reg 74; HB (SPC) Regs 2006 reg 55). Joint occupiers, # boarders, lodgers and the landlord's household are not - # non-dependants (reg 3(2)(d)-(e), 3(4)). A non-dependant of more - # than one joint occupier is apportioned between them by their shares - # of the payments (reg 74(5); SPC reg 55(5)), so each family liable - # for the household's rent bears its share; a boarder or lodger bears - # none. + # non-dependants (reg 3(2)(d)-(e), 3(4)). person = benunit.members - deductions = person( - "household_benefits_individual_non_dep_deduction", period - ) * person("is_non_dependant_of_household_head", period) + deductions = person("household_benefits_individual_non_dep_deduction", period) + other_family = person("is_non_dependant_of_household_head", period) + # A member of a rent-liable family who is not in its claimant's + # family under reg 3(2)(a) and (c) is a non-dependant too. + own_family = ( + person("is_benefit_unit_non_dependant_for_legacy_benefits", period) + & ~other_family + ) + # A non-dependant of more than one joint occupier is apportioned + # between them by their shares of the payments (reg 74(5); SPC reg + # 55(5)): one living with joint occupiers resides with each of them, + # whichever family they belong to. A boarder or lodger has no share + # of the household's rent and bears only their own family's. + joint_occupier = person.benunit("share_of_household_rent", period) > 0 + shared = other_family | (own_family & joint_occupier) share = benunit("share_of_household_rent", period) - return share * benunit.max(person.household.sum(deductions)) + from_joint_occupation = share * benunit.max( + person.household.sum(deductions * shared) + ) + from_own_family = benunit.sum(deductions * own_family * ~joint_occupier) + return from_joint_occupation + from_own_family diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_legacy_benefits.py b/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_legacy_benefits.py new file mode 100644 index 0000000000..e1449d56e8 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_legacy_benefits.py @@ -0,0 +1,43 @@ +from policyengine_uk.model_api import * + + +class is_benefit_unit_non_dependant_for_legacy_benefits(Variable): + value_type = bool + entity = Person + label = "Non-dependant within the benefit unit for Housing Benefit and Council Tax Reduction" + documentation = ( + "A member of the benefit unit who is neither the claimant or partner " + "nor a child or young person: someone aged 16 or over who is not a " + "Child Benefit qualifying young person. The benefit unit follows the " + "survey's family, which can include an adult child or a 16 to 19 year " + "old who is not a qualifying young person. Under the Housing Benefit " + "and Council Tax Reduction schemes such a person is not in the " + "claimant's family (SSCBA 1992 s.137(1), with young person defined " + "by Child Benefit) but normally resides with the claimant, so is a " + "non-dependant. A child or young person is either in the family or, " + "if placed with the claimant by a local authority, excluded from " + "being a non-dependant." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/regulation/3", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/19", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/21", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/3", + "https://www.legislation.gov.uk/uksi/2012/2885/regulation/9", + "https://www.legislation.gov.uk/wsi/2013/3029/regulation/9", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/8", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/3", + "https://www.legislation.gov.uk/ukpga/1992/4/section/137", + ) + + def formula(person, period, parameters): + # HB Regs 2006 and HB (SPC) Regs 2006 reg 3(2)(a), (c) with regs 19 + # and 21(3); SI 2012/2885 and WSI 2013/3029 reg 9(2)(a), (c); SSI + # 2021/249 reg 8(2)(a)-(b); SSI 2012/319 reg 3. A Child Benefit child + # is under 16. + claimant_or_partner = person("is_claimant_or_partner", period) + child_or_young_person = person( + "is_child_or_qualifying_young_person_for_child_benefit", period + ) + return ~claimant_or_partner & ~child_or_young_person diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_universal_credit.py new file mode 100644 index 0000000000..bc6b71d000 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_universal_credit.py @@ -0,0 +1,36 @@ +from policyengine_uk.model_api import * + + +class is_benefit_unit_non_dependant_for_universal_credit(Variable): + value_type = bool + entity = Person + label = "Non-dependant within the benefit unit for Universal Credit" + documentation = ( + "A member of the benefit unit, aged 16 or over, who is neither the " + "claimant or partner nor a qualifying young person. The benefit unit " + "follows the survey's family, which can include an adult child or a " + "16 to 19 year old who is not a qualifying young person. In Universal " + "Credit law such a person is not in the claimant's family but is a " + "non-dependant of the renter: they normally live in the accommodation " + "and are none of the persons excluded by Sch 4 para 9(2). A qualifying " + "young person the renter is responsible for is a member of the " + "renter's extended benefit unit, and one no one is responsible for, " + "for example one looked after by a local authority, is excluded by " + "para 9(2)(g). A child, under 16, is always one or the other." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/9", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/4", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.legislation.gov.uk/ukpga/2012/5/section/40", + ) + + def formula(person, period, parameters): + # UC Regs 2013 Sch 4 para 9(1)(a)-(b) and (2)(a), (g); WRA 2012 s.40. + not_a_child = ~person("is_child_for_universal_credit", period) + claimant_or_partner = person("is_claimant_or_partner", period) + qualifying_young_person = person( + "is_qualifying_young_person_for_universal_credit", period + ) + return not_a_child & ~claimant_or_partner & ~qualifying_young_person diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.py index abd9869256..d47928de45 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.py @@ -5,14 +5,27 @@ class uc_individual_non_dep_deduction_eligible(Variable): value_type = bool entity = Person label = "Eligible person for the Universal Credit non-dependent deduction" + documentation = ( + "A non-dependant of a renter, aged 21 or over and not otherwise " + "exempt: either a member of another family who is not liable for rent " + "(see is_non_dependant_of_household_head) or a member of their own " + "benefit unit who is not the claimant, partner or a qualifying young " + "person (see is_benefit_unit_non_dependant_for_universal_credit)." + ) definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/9", + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/13", + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/16", + ) def formula(person, period, parameters): - not_rent_liable = ~person.benunit("benunit_is_rent_liable", period) p = parameters( period ).gov.dwp.universal_credit.elements.housing.non_dep_deduction - age = person("age", period) - age_eligible = age >= p.age_threshold + non_dependant = person("is_non_dependant_of_household_head", period) | person( + "is_benefit_unit_non_dependant_for_universal_credit", period + ) + age_eligible = person("age", period) >= p.age_threshold exempt = person("uc_non_dep_deduction_exempt", period) - return ~exempt & age_eligible & not_rent_liable + return non_dependant & age_eligible & ~exempt diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deduction_exempt.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deduction_exempt.py index 432cfb0936..512d2e32b3 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deduction_exempt.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deduction_exempt.py @@ -11,13 +11,18 @@ class uc_non_dep_deduction_exempt(Variable): "receiving Pension Credit, the middle or higher rate of the DLA care " "component, the PIP daily living component or Attendance Allowance, or " "who is entitled to Carer's Allowance (underlying entitlement included, " - "so the carer test is is_carer_for_benefits rather than receipt)." + "so the carer test is is_carer_for_benefits rather than receipt). " + "Pension Credit is awarded to a benefit unit's claimant and partner, " + "so another adult in the unit is not receiving it." ) reference = "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/16" def formula(person, period, parameters): + receives_pension_credit = person("is_claimant_or_partner", period) & ( + person.benunit("pension_credit", period) > 0 + ) return ( - (person.benunit("pension_credit", period) > 0) + receives_pension_credit | person("dla_sc_middle_plus", period) | (person("pip_dl", period) > 0) | (person("attendance_allowance", period) > 0) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions.py index eca4abcd2a..20509cd891 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions.py @@ -5,23 +5,42 @@ class uc_non_dep_deductions(Variable): value_type = float entity = BenUnit label = "Universal Credit non-dependent deductions" + documentation = ( + "Housing cost contributions for the renter's non-dependants: those in " + "other families of the household, for the household head's family " + "only, and those within the renter's own benefit unit. None if the " + "renter or a joint renter is exempt." + ) definition_period = YEAR unit = GBP reference = ( "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/9", "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/13", + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/15", ) def formula(benunit, period, parameters): # A housing cost contribution is deducted for each non-dependant in # the renter's extended benefit unit (UC Regs 2013 Sch 4 para 13). - # Non-dependants live outside the household head's family and are - # not liable for rent, and only the household head's family has them - # (para 9(2)(d)-(f)): a sharer, boarder or lodger has none from the - # household head's family. person = benunit.members - deductions = person("uc_individual_non_dep_deduction", period) * person( - "is_non_dependant_of_household_head", period - ) + deductions = person("uc_individual_non_dep_deduction", period) + # Non-dependants from other families live outside the household + # head's family and are not liable for rent. A non-dependant counts + # in one claim only (para 9(2)(f)), which the model gives to the + # household head's family, so a sharer, boarder or lodger has none + # from other families. + other_family = person("is_non_dependant_of_household_head", period) head_family = benunit.any(person("is_household_head", period)) - return head_family * benunit.max(person.household.sum(deductions)) + from_other_families = head_family * benunit.max( + person.household.sum(deductions * other_family) + ) + # A member of the renter's own benefit unit who is not the claimant, + # partner or a qualifying young person is a non-dependant of the + # renter (para 9(1)(c) and (2)(a), (g)). + own_family = ( + person("is_benefit_unit_non_dependant_for_universal_credit", period) + & ~other_family + ) + from_own_family = benunit.sum(deductions * own_family) + renter_exempt = benunit("uc_non_dep_deductions_renter_exempt", period) + return where(renter_exempt, 0, from_other_families + from_own_family) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions_renter_exempt.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions_renter_exempt.py new file mode 100644 index 0000000000..187b0ec1ab --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions_renter_exempt.py @@ -0,0 +1,31 @@ +from policyengine_uk.model_api import * + + +class uc_non_dep_deductions_renter_exempt(Variable): + value_type = bool + entity = BenUnit + label = "Universal Credit renter exempt from all housing cost contributions" + documentation = ( + "No housing cost contribution is deducted for any non-dependant if the " + "renter, or either joint renter of a couple, is blind or receives " + "Attendance Allowance, the middle or highest rate of the DLA care " + "component or the PIP daily living component. Scottish disability " + "assistance (adult and pension age disability payments, child " + "disability payment, Scottish adult DLA) has no separate input, and " + "entitlement suspended in hospital is not modelled." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/15" + + def formula(benunit, period, parameters): + # UC Regs 2013 Sch 4 para 15: "joint renters" are joint claimants + # (para 1(2)), so the claimant and partner. + person = benunit.members + renter = person("is_claimant_or_partner", period) + qualifying = ( + person("is_blind", period) + | person("dla_sc_middle_plus", period) + | (person("attendance_allowance", period) > 0) + | (person("pip_dl", period) > 0) + ) + return benunit.any(renter & qualifying) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py index 459598f8d3..2ef332d2d7 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py @@ -6,23 +6,28 @@ class council_tax_reduction_individual_non_dep_deduction_eligible(Variable): entity = Person label = "eligible person for CTR non-dependent deduction" documentation = ( - "An adult outside the claimant's family who is not liable for rent. " - "Someone jointly and severally liable for the council tax with the " - "claimant (a joint tenant or other sharer of the rent) and someone " - "liable to the claimant on a commercial basis (a boarder or lodger) " - "is not a non-dependant." + "A non-dependant aged 18 or over: an adult outside the claimant's " + "family who is not liable for rent, or a member of a benefit unit who " + "is not its claimant, partner or a child or young person (see " + "is_benefit_unit_non_dependant_for_legacy_benefits). Someone jointly " + "and severally liable for the council tax with the claimant (a joint " + "tenant or other sharer of the rent) and someone liable to the " + "claimant on a commercial basis (a boarder or lodger) is not a " + "non-dependant; an adult in their benefit unit who is not liable is." ) definition_period = YEAR reference = ( "https://www.legislation.gov.uk/uksi/2012/2885/regulation/9", - "https://www.legislation.gov.uk/wsi/2013/3029", - "https://www.legislation.gov.uk/ssi/2021/249", + "https://www.legislation.gov.uk/wsi/2013/3029/regulation/9", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/8", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/3", ) def formula(person, period, parameters): - # SI 2012/2885 reg 9(2)(d)-(e) and the Welsh and Scottish equivalents. - return ( - (person("age", period) >= 18) - & ~person.benunit("benunit_contains_household_head", period) - & ~person.benunit("benunit_is_rent_liable", period) - ) + # SI 2012/2885 reg 9(2)(a), (c)-(e) and the Welsh and Scottish + # equivalents; deductions are for non-dependants aged 18 or over. + other_family = ~person.benunit( + "benunit_contains_household_head", period + ) & ~person.benunit("benunit_is_rent_liable", period) + own_family = person("is_benefit_unit_non_dependant_for_legacy_benefits", period) + return (person("age", period) >= 18) & (other_family | own_family) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py index 6241abf4c7..e45f392c5d 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py @@ -5,16 +5,29 @@ class council_tax_reduction_non_dep_deductions(Variable): value_type = float entity = BenUnit label = "CTR non-dependent deductions" + documentation = ( + "Deductions for the applicant's non-dependants, including those in " + "the applicant's own benefit unit, for a family that claims. A " + "non-dependant of two or more jointly liable people is apportioned " + "equally between them." + ) definition_period = YEAR unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/regulation/9", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/8", + ) def formula(benunit, period, parameters): + # Every eligible person in the household is a non-dependant of each + # claiming family: the claimant's own family has only its benefit-unit + # non-dependants, and jointly liable sharers are not non-dependants. deductions = benunit.members( "council_tax_reduction_individual_non_dep_deduction", period ) deductions_in_household = benunit.max(benunit.members.household.sum(deductions)) - deductions_in_benunit = benunit.sum(deductions) # A non-dependant of two or more jointly liable people is apportioned # equally between them (SI 2012/2885 Sch 1 para 8(5)). share = benunit("council_tax_reduction_joint_liability_share", period) - return (deductions_in_household - deductions_in_benunit) * share + claims = benunit("council_tax_reduction_claimant_benunit", period) + return claims * deductions_in_household * share From 93e810efb5ab4d80d0840fc7c912d9c61da5567c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 15:12:30 -0400 Subject: [PATCH 04/14] Test that a disabled child does not exempt the UC renter from housing cost contributions UC Regs 2013 Sch 4 para 15(1)(a) turns on the renter's own circumstances; found by a mutation that applied the exemption to every family member. Co-Authored-By: Claude Opus 5.5 --- .../family/benefit_unit_non_dependants.yaml | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml index 3efb8dabd7..f4b44e503d 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml @@ -169,6 +169,34 @@ output: uc_non_dep_deductions: 0 +# Para 15(1)(a) exempts "any renter who is a single person to whom +# sub-paragraph (2) applies": the renter's own disability, not a child's. +- name: UC, a disabled child in the renter's family does not exempt the renter + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + child: + age: 10 + is_claimant_or_partner: false + dla_sc_category: MIDDLE + benunits: + benunit: + members: [parent, son, child] + households: + household: + members: [parent, son, child] + output: + uc_non_dep_deductions_renter_exempt: false + uc_non_dep_deductions: 1_158.60 + # Sch 4 para 16(2)(d): no contribution for a non-dependant receiving # Attendance Allowance. - name: UC, no contribution for a non-dependant in the benefit unit receiving Attendance Allowance From 547b9693722099ede55d28b566d89c38d2316211 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 15:21:42 -0400 Subject: [PATCH 05/14] Pin the child age floor and HB/CTR eligibility in the in-unit tests Mutation testing (15 one-line mutations of the new logic) left the in-unit age floor surviving the YAML tests, and dropping in-unit non-dependants from HB or CTR eligibility surviving the properties. The YAML case now checks a child's classification, and the oracle checks HB and CTR eligibility independently. All 15 mutations are now killed by both. Co-Authored-By: Claude Opus 5.5 --- .../family/benefit_unit_non_dependants.yaml | 3 +++ ...t_benefit_unit_non_dependant_properties.py | 24 ++++++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml index f4b44e503d..fc8ef67bbd 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml @@ -194,6 +194,9 @@ household: members: [parent, son, child] output: + # A child under 16 is never a non-dependant (WRA 2012 s.40). + is_benefit_unit_non_dependant_for_universal_credit: [false, true, false] + is_benefit_unit_non_dependant_for_legacy_benefits: [false, true, false] uc_non_dep_deductions_renter_exempt: false uc_non_dep_deductions: 1_158.60 diff --git a/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py index 1702d26e6e..acdc9821f5 100644 --- a/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py +++ b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py @@ -7,9 +7,10 @@ population of households: 1. Oracle: each family's Universal Credit, Housing Benefit and Council Tax - Reduction non-dependant deductions equal an independent calculation from - the generated household structure: who is a non-dependant, of whom, and - how a non-dependant of several joint occupiers is apportioned. + Reduction non-dependant deductions, and who is eligible for an individual + deduction, equal an independent calculation from the generated household + structure: who is a non-dependant, of whom, and how a non-dependant of + several joint occupiers is apportioned. 2. Conservation: every Housing Benefit and Council Tax Reduction non-dependant deduction is borne in full, once, across the household's families; every Universal Credit contribution is borne by exactly one @@ -282,6 +283,23 @@ def test_deductions_match_the_oracle(population): assert np.allclose( calc(sim, "council_tax_reduction_non_dep_deductions"), exp_ctr, atol=0.01 ) + # HB and CTR eligibility: a non-dependant aged 18 or over, either in a + # family not liable for rent or within their own benefit unit. + expected_eligible = np.array( + [ + row["age"] >= 18 + and ( + family_rows[row["family"]]["role"] == "non_dependant" + or row["kind"] == "non_dependant" + ) + for row in person_rows + ] + ) + for variable in [ + "housing_benefit_individual_non_dep_deduction_eligible", + "council_tax_reduction_individual_non_dep_deduction_eligible", + ]: + assert np.array_equal(calc(sim, variable) > 0, expected_eligible), variable @PROPERTY_SETTINGS From 155766bd865a0b9e02171da981f1bb23f04dfa70 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 02:30:51 -0400 Subject: [PATCH 06/14] Charge a joint occupier's own non-dependant to that occupier alone A non-dependant in a family's own benefit unit is that family's alone, not shared between joint occupiers: DWP guidance treats a joint tenant's sister as "Sarah's non-dependant" only (LHA Guidance Manual 2.093, example 2) and says "if a person is a non-dependant of only one of the joint occupiers, take the whole of the deduction from that joint occupier's entitlement" (HBGM A5 5.622). Reg 74(5) and CTR Sch 1 para 8(5) apportionment now apply only to non-dependants from other families. A lodger's family is one commercial arrangement for CTR too, so its adult son is no one's non-dependant there. Co-Authored-By: Claude Opus 5.5 --- .../family/benefit_unit_non_dependants.yaml | 81 ++++++++++++++++-- ...t_benefit_unit_non_dependant_properties.py | 82 +++++++++++++------ .../housing_benefit_non_dep_deductions.py | 30 ++++--- ...n_individual_non_dep_deduction_eligible.py | 20 +++-- ...ouncil_tax_reduction_non_dep_deductions.py | 32 +++++--- 5 files changed, 178 insertions(+), 67 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml index fc8ef67bbd..c4ec323a09 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml @@ -448,9 +448,10 @@ is_benefit_unit_non_dependant_for_legacy_benefits: [false, false] housing_benefit_non_dep_deductions: 0 -# HB reg 74(5): a non-dependant of two joint occupiers is apportioned by their -# shares of the rent, whichever family the non-dependant belongs to. -- name: HB, joint occupiers share the deduction for an adult son in one of their families +# A joint occupier's own adult son is that occupier's non-dependant only (LHA +# Guidance Manual 2.093, example 2), so reg 74(5) apportionment does not apply +# and his family bears the whole deduction, though it pays half the rent. +- name: HB, a joint tenant's adult son is that tenant's non-dependant only period: 2026 absolute_error_margin: 0.01 input: @@ -479,7 +480,7 @@ rent: 12_000 output: share_of_household_rent: [0.5, 0.5] - housing_benefit_non_dep_deductions: [530.40, 530.40] + housing_benefit_non_dep_deductions: [1_060.80, 0] # A lodger's family has no share of the household's rent: it bears only the # non-dependant in its own benefit unit. @@ -559,9 +560,9 @@ council_tax_reduction_individual_non_dep_deduction_eligible: [false, false] council_tax_reduction_non_dep_deductions: 0 -# SI 2012/2885 Sch 1 para 8(5): a non-dependant of two jointly liable people -# is apportioned equally between them, wherever the non-dependant's family. -- name: CTR, jointly liable families share the deduction for an adult son in one of them +# An applicant's own adult son is that applicant's non-dependant only, so SI +# 2012/2885 Sch 1 para 8(5) apportionment does not apply. +- name: CTR, a jointly liable applicant's adult son is that applicant's non-dependant only period: 2026 absolute_error_margin: 0.01 input: @@ -589,7 +590,71 @@ rent: 12_000 output: council_tax_reduction_joint_liability_share: [0.5, 0.5] - council_tax_reduction_non_dep_deductions: [150, 150] + council_tax_reduction_non_dep_deductions: [300, 0] + +# Para 8(5) still apportions a non-dependant from another family between the +# jointly liable people. +- name: CTR, jointly liable families share the deduction for another family's adult + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + sharer: + age: 48 + is_claimant_or_partner: true + friend: + age: 30 + is_claimant_or_partner: true + council_tax_reduction_individual_non_dep_deduction: 300 + benunits: + benunit_1: + members: [head] + benunit_2: + members: [sharer] + liable_for_share_of_household_rent: true + benunit_3: + members: [friend] + households: + household: + members: [head, sharer, friend] + rent: 12_000 + output: + council_tax_reduction_non_dep_deductions: [150, 150, 0] + +# A lodger's family is one commercial arrangement with the applicant (SI +# 2012/2885 reg 9(2)(e)), so the lodger's adult son is no one's non-dependant. +- name: CTR, a lodger's adult son is not the applicant's non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 60 + is_household_head: true + is_claimant_or_partner: true + lodger: + age: 45 + is_claimant_or_partner: true + rent_paid_as_lodger: 5_200 + lodger_son: + age: 21 + is_claimant_or_partner: false + benunits: + benunit_1: + members: [head] + benunit_2: + members: [lodger, lodger_son] + households: + household: + members: [head, lodger, lodger_son] + rent: 9_000 + output: + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, false] + council_tax_reduction_non_dep_deductions: [0, 0] # Only a family that claims bears deductions; the non-dependant's own family # does not. diff --git a/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py index acdc9821f5..d2f07529b9 100644 --- a/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py +++ b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py @@ -10,11 +10,14 @@ Reduction non-dependant deductions, and who is eligible for an individual deduction, equal an independent calculation from the generated household structure: who is a non-dependant, of whom, and how a non-dependant of - several joint occupiers is apportioned. -2. Conservation: every Housing Benefit and Council Tax Reduction - non-dependant deduction is borne in full, once, across the household's - families; every Universal Credit contribution is borne by exactly one - family unless that family's renter is exempt. + several joint occupiers is apportioned (another family's by rent or + liability share; a family's own non-dependant is that family's alone). +2. Conservation: every Housing Benefit non-dependant deduction is borne in + full, once, across the household's families; a Council Tax Reduction + claiming family bears its own non-dependants in full and another family's + in proportion to its joint-liability share; every Universal Credit + contribution is borne by exactly one family unless that family's renter + is exempt. 3. Exemptions: a family whose renter is exempt under Sch 4 para 15 has no Universal Credit deductions; no deduction is charged for a non-dependant under 21 (UC) or under 18 (HB, CTR), or for a qualifying young person. @@ -241,17 +244,22 @@ def oracle(person_rows, family_rows, uc, hb, ctr, pension_credit, claims, ctr_sh bearer = head if other_family else f if uc_non_dependant and not family_rows[bearer]["renter_pip"]: exp_uc[bearer] += uc[p] - # HB: pooled across joint occupiers by share; a lodger's own - # non-dependants fall on the lodger. - if other_family or (own_family and share[f] > 0): + # HB: another family's non-dependant is pooled across joint + # occupiers by rent share; a family's own non-dependant is its + # alone (LHA Guidance Manual 2.093), a lodger's included. + if other_family: for i in fams: exp_hb[i] += share[i] * hb[p] elif own_family: exp_hb[f] += hb[p] - # CTR: every non-dependant is pooled across the claiming families. - if other_family or own_family: + # CTR: another family's non-dependant is pooled across the + # claiming families; a claiming family's own is its alone; a + # lodger's family is no one's. + if other_family: for i in fams: exp_ctr[i] += claims[i] * ctr_share[i] * ctr[p] + elif own_family and role in ("head", "sharer"): + exp_ctr[f] += ctr[p] return exp_uc, exp_hb, exp_ctr, expected_uc_individual @@ -283,23 +291,34 @@ def test_deductions_match_the_oracle(population): assert np.allclose( calc(sim, "council_tax_reduction_non_dep_deductions"), exp_ctr, atol=0.01 ) + # HB and CTR eligibility: a non-dependant aged 18 or over, either in a # family not liable for rent or within their own benefit unit. - expected_eligible = np.array( - [ - row["age"] >= 18 - and ( - family_rows[row["family"]]["role"] == "non_dependant" - or row["kind"] == "non_dependant" - ) - for row in person_rows - ] + def eligible(own_family_roles): + return np.array( + [ + row["age"] >= 18 + and ( + family_rows[row["family"]]["role"] == "non_dependant" + or ( + row["kind"] == "non_dependant" + and family_rows[row["family"]]["role"] in own_family_roles + ) + ) + for row in person_rows + ] + ) + + # HB: any family's own non-dependants; CTR: only a claiming family's (the + # household head's or a sharer's), not a boarder's or lodger's. + assert np.array_equal( + calc(sim, "housing_benefit_individual_non_dep_deduction_eligible") > 0, + eligible(("head", "sharer", "lodger", "non_dependant")), + ) + assert np.array_equal( + calc(sim, "council_tax_reduction_individual_non_dep_deduction_eligible") > 0, + eligible(("head", "sharer", "non_dependant")), ) - for variable in [ - "housing_benefit_individual_non_dep_deduction_eligible", - "council_tax_reduction_individual_non_dep_deduction_eligible", - ]: - assert np.array_equal(calc(sim, variable) > 0, expected_eligible), variable @PROPERTY_SETTINGS @@ -314,12 +333,23 @@ def test_conservation_and_exemptions(population): ) hb_people = sim.map_result(v["hb"], "person", "household") assert np.allclose(hb_families, hb_people, atol=0.01) + # CTR: a claiming family's own non-dependant is borne by it in full; + # another family's is borne by the claiming families in proportion to + # their joint-liability shares (which follow the regulations' per-person + # wording, so they need not sum to one). claim_shares = sim.map_result(v["claims"] * v["ctr_share"], "benunit", "household") ctr_families = sim.map_result( calc(sim, "council_tax_reduction_non_dep_deductions"), "benunit", "household" ) - ctr_people = sim.map_result(v["ctr"], "person", "household") - assert np.allclose(ctr_families, claim_shares * ctr_people, atol=0.01) + own_of_claimant = np.array( + [ + row["kind"] == "non_dependant" and v["claims"][row["family"]] > 0 + for row in person_rows + ] + ) + ctr_own = sim.map_result(v["ctr"] * own_of_claimant, "person", "household") + ctr_other = sim.map_result(v["ctr"] * ~own_of_claimant, "person", "household") + assert np.allclose(ctr_families, claim_shares * ctr_other + ctr_own, atol=0.01) # UC: each contribution is borne once, except where the bearer is exempt. uc_families = sim.map_result( calc(sim, "uc_non_dep_deductions"), "benunit", "household" diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py index 03adebf196..f3f93c6dfe 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py @@ -9,9 +9,10 @@ class housing_benefit_non_dep_deductions(Variable): "Deductions for the claimant's non-dependants: members of other " "families of the household who are not liable for rent, and members " "of a family liable for the rent who are not its claimant, partner or " - "a child or young person. A non-dependant of several joint occupiers " - "is apportioned between them by their shares of the rent; a boarder " - "or lodger bears only the non-dependants in their own family." + "a child or young person. A non-dependant from another family is " + "apportioned between joint occupiers by their shares of the rent. A " + "non-dependant in a family's own benefit unit is that family's alone, " + "including a boarder's or lodger's." ) definition_period = YEAR unit = GBP @@ -20,6 +21,8 @@ class housing_benefit_non_dep_deductions(Variable): "https://www.legislation.gov.uk/uksi/2006/213/regulation/74", "https://www.legislation.gov.uk/uksi/2006/214/regulation/3", "https://www.legislation.gov.uk/uksi/2006/214/regulation/55", + "https://assets.publishing.service.gov.uk/media/5a758238ed915d6faf2b38a2/lha-guidance-manual.pdf#page=33", + "https://assets.publishing.service.gov.uk/media/5a7ce2b840f0b6629523c64c/hbgm-a5-calculating-benefit.pdf", ) def formula(benunit, period, parameters): @@ -38,14 +41,17 @@ def formula(benunit, period, parameters): ) # A non-dependant of more than one joint occupier is apportioned # between them by their shares of the payments (reg 74(5); SPC reg - # 55(5)): one living with joint occupiers resides with each of them, - # whichever family they belong to. A boarder or lodger has no share - # of the household's rent and bears only their own family's. - joint_occupier = person.benunit("share_of_household_rent", period) > 0 - shared = other_family | (own_family & joint_occupier) + # 55(5)). A member of a joint occupier's own family unit is that + # occupier's non-dependant only (LHA Guidance Manual 2.093, example + # 2: a joint tenant's sister "is treated as Sarah's non-dependant"), + # and "if a person is a non-dependant of only one of the joint + # occupiers, take the whole of the deduction from that joint + # occupier's entitlement" (HBGM A5 5.622). Their family bears the + # whole deduction, whatever its share of the rent; so does a boarder + # or lodger. share = benunit("share_of_household_rent", period) - from_joint_occupation = share * benunit.max( - person.household.sum(deductions * shared) + from_other_families = share * benunit.max( + person.household.sum(deductions * other_family) ) - from_own_family = benunit.sum(deductions * own_family * ~joint_occupier) - return from_joint_occupation + from_own_family + from_own_family = benunit.sum(deductions * own_family) + return from_other_families + from_own_family diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py index 2ef332d2d7..f235efc452 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py @@ -7,13 +7,12 @@ class council_tax_reduction_individual_non_dep_deduction_eligible(Variable): label = "eligible person for CTR non-dependent deduction" documentation = ( "A non-dependant aged 18 or over: an adult outside the claimant's " - "family who is not liable for rent, or a member of a benefit unit who " - "is not its claimant, partner or a child or young person (see " - "is_benefit_unit_non_dependant_for_legacy_benefits). Someone jointly " - "and severally liable for the council tax with the claimant (a joint " - "tenant or other sharer of the rent) and someone liable to the " - "claimant on a commercial basis (a boarder or lodger) is not a " - "non-dependant; an adult in their benefit unit who is not liable is." + "family who is not liable for rent, or a member of a claiming " + "family's benefit unit who is not its claimant, partner or a child or " + "young person (see is_benefit_unit_non_dependant_for_legacy_benefits). " + "Someone jointly and severally liable for the council tax with the " + "claimant (a joint tenant or other sharer of the rent) and a boarder " + "or lodger, with their family, is not a non-dependant." ) definition_period = YEAR reference = ( @@ -29,5 +28,10 @@ def formula(person, period, parameters): other_family = ~person.benunit( "benunit_contains_household_head", period ) & ~person.benunit("benunit_is_rent_liable", period) - own_family = person("is_benefit_unit_non_dependant_for_legacy_benefits", period) + # A member of a claiming family's own benefit unit who is not in its + # claimant's family. A boarder's or lodger's family is one commercial + # arrangement, so its members are no one's non-dependants. + own_family = person( + "is_benefit_unit_non_dependant_for_legacy_benefits", period + ) & person.benunit("council_tax_reduction_claimant_benunit", period) return (person("age", period) >= 18) & (other_family | own_family) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py index e45f392c5d..d2498f1126 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py @@ -6,10 +6,9 @@ class council_tax_reduction_non_dep_deductions(Variable): entity = BenUnit label = "CTR non-dependent deductions" documentation = ( - "Deductions for the applicant's non-dependants, including those in " - "the applicant's own benefit unit, for a family that claims. A " - "non-dependant of two or more jointly liable people is apportioned " - "equally between them." + "Deductions for a claiming family's non-dependants: those in its own " + "benefit unit, in full, and those in other families, apportioned " + "equally between the jointly liable people." ) definition_period = YEAR unit = GBP @@ -19,15 +18,22 @@ class council_tax_reduction_non_dep_deductions(Variable): ) def formula(benunit, period, parameters): - # Every eligible person in the household is a non-dependant of each - # claiming family: the claimant's own family has only its benefit-unit - # non-dependants, and jointly liable sharers are not non-dependants. - deductions = benunit.members( + person = benunit.members + deductions = person( "council_tax_reduction_individual_non_dep_deduction", period ) - deductions_in_household = benunit.max(benunit.members.household.sum(deductions)) - # A non-dependant of two or more jointly liable people is apportioned - # equally between them (SI 2012/2885 Sch 1 para 8(5)). - share = benunit("council_tax_reduction_joint_liability_share", period) claims = benunit("council_tax_reduction_claimant_benunit", period) - return claims * deductions_in_household * share + # A non-dependant in a claiming family's own benefit unit is that + # applicant's alone (as for HB: LHA Guidance Manual 2.093, example 2). + own_family = person( + "is_benefit_unit_non_dependant_for_legacy_benefits", period + ) & person.benunit("council_tax_reduction_claimant_benunit", period) + from_own_family = benunit.sum(deductions * own_family) + # A non-dependant from another family is a non-dependant of each + # jointly liable person, apportioned equally between them (SI + # 2012/2885 Sch 1 para 8(5)). + share = benunit("council_tax_reduction_joint_liability_share", period) + from_other_families = share * benunit.max( + person.household.sum(deductions * ~own_family) + ) + return claims * (from_other_families + from_own_family) From 2d546285355ec9b7846a610cded321106ae2f022 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 04:34:56 -0400 Subject: [PATCH 07/14] Apportion every non-dependant between joint occupiers, as the regulations say MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review r2 (F3, F4, F5) found three departures from the text. F3. 155766bd8 charged a joint occupier's own adult son wholly to that occupier, citing LHA Guidance Manual 2.093 example 2 and HBGM A5 5.622. HB reg 3(1) and (4) and CTR reg 9(1) make him a non-dependant of every joint occupier he shares accommodation with: none of reg 3(2)(a)-(f) excludes him in relation to the other occupier. Reg 74(5) and CTR Sch 1 para 8(5) then apportion him. The LHA example concerns the size criteria and a sister, whom the model already treats as another family's non-dependant and apportions; 5.622 is conditional. The old rule also made the result depend on whether the survey codes the son in his parent's benefit unit or his own. HB and national CTR now pool every non-dependant, in any family, and apportion by rent share (HB) or joint-liability share (CTR). F4. A lodger's adult son was no one's non-dependant for CTR. Reg 9(2)(e) excludes only the person liable to pay the applicant, as HB reg 3(2)(e)(i) does; the household extension in (iii) is for the landlord's side. He is now eligible and is the householder's non-dependant for CTR and HB, and still the lodger's own for HB, in full, since they are not joint occupiers. F5. Widening the shared CTR eligibility made a jointly liable sharer's local claim bear the head's own son (Newham: £489.84 in 2025). The local aggregation now counts only other families' non-dependants, as before, so this change is neutral for the five local schemes until each council's aggregation is brought into line. YAML: joint tenants share one tenant's son by rent share (and get the same result with him coded in his own unit); jointly liable applicants share one's son equally; a lodger's son is the householder's non-dependant (HB and CTR) and the lodger's (HB); Newham sharer does not bear the head's son. Property oracle and conservation invariants restated for pooling. Co-Authored-By: Claude Opus 5.5 --- .../family/benefit_unit_non_dependants.yaml | 116 +++++++++++++++--- ...t_benefit_unit_non_dependant_properties.py | 87 +++++++------ .../housing_benefit_non_dep_deductions.py | 38 +++--- .../council_tax_reduction/_legacy.py | 10 +- ...n_individual_non_dep_deduction_eligible.py | 25 ++-- ...ouncil_tax_reduction_non_dep_deductions.py | 31 ++--- 6 files changed, 197 insertions(+), 110 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml index c4ec323a09..51d2b8e129 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml @@ -448,10 +448,10 @@ is_benefit_unit_non_dependant_for_legacy_benefits: [false, false] housing_benefit_non_dep_deductions: 0 -# A joint occupier's own adult son is that occupier's non-dependant only (LHA -# Guidance Manual 2.093, example 2), so reg 74(5) apportionment does not apply -# and his family bears the whole deduction, though it pays half the rent. -- name: HB, a joint tenant's adult son is that tenant's non-dependant only +# A joint tenant's adult son normally resides with both joint tenants (reg +# 3(1) and (4)) and is in neither's family under reg 3(2)(a), so he is a +# non-dependant of each, apportioned by their shares of the rent (reg 74(5)). +- name: HB, joint tenants share the deduction for one tenant's adult son period: 2026 absolute_error_margin: 0.01 input: @@ -480,11 +480,47 @@ rent: 12_000 output: share_of_household_rent: [0.5, 0.5] - housing_benefit_non_dep_deductions: [1_060.80, 0] + housing_benefit_non_dep_deductions: [530.40, 530.40] -# A lodger's family has no share of the household's rent: it bears only the -# non-dependant in its own benefit unit. -- name: HB, a lodger bears the non-dependant in their own benefit unit +# The same son coded as his own benefit unit gives the same deductions: the +# result does not depend on how the survey groups him. +- name: HB, joint tenants share the deduction for an adult son in his own benefit unit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: true + employment_income: 2_600 + weekly_hours: 10 + sharer: + age: 48 + is_claimant_or_partner: true + benunits: + benunit_1: + members: [head] + benunit_2: + members: [sharer] + liable_for_share_of_household_rent: true + benunit_3: + members: [son] + households: + household: + members: [head, son, sharer] + rent: 12_000 + output: + housing_benefit_non_dep_deductions: [530.40, 530.40, 0] + +# A lodger's adult son is the lodger's non-dependant, and the householder's +# too: reg 3(2)(e)(i) excludes only the person liable to pay the householder. +# The lodger is not a joint occupier with the householder, so reg 74(5) does +# not apportion him and each bears the whole deduction. +- name: HB, a lodger's adult son is a non-dependant of the lodger and of the householder period: 2026 absolute_error_margin: 0.01 input: @@ -512,7 +548,7 @@ members: [head, lodger, lodger_son] rent: 9_000 output: - housing_benefit_non_dep_deductions: [0, 1_060.80] + housing_benefit_non_dep_deductions: [1_060.80, 1_060.80] - name: CTR, the claimant's family bears the non-dependant in its benefit unit period: 2026 @@ -560,9 +596,10 @@ council_tax_reduction_individual_non_dep_deduction_eligible: [false, false] council_tax_reduction_non_dep_deductions: 0 -# An applicant's own adult son is that applicant's non-dependant only, so SI -# 2012/2885 Sch 1 para 8(5) apportionment does not apply. -- name: CTR, a jointly liable applicant's adult son is that applicant's non-dependant only +# An applicant's adult son normally resides with both jointly liable people +# and is in neither's family (SI 2012/2885 reg 9(1), (2)(a)), so the deduction +# is apportioned equally between them (Sch 1 para 8(5)). +- name: CTR, jointly liable applicants share the deduction for one applicant's adult son period: 2026 absolute_error_margin: 0.01 input: @@ -590,7 +627,7 @@ rent: 12_000 output: council_tax_reduction_joint_liability_share: [0.5, 0.5] - council_tax_reduction_non_dep_deductions: [300, 0] + council_tax_reduction_non_dep_deductions: [150, 150] # Para 8(5) still apportions a non-dependant from another family between the # jointly liable people. @@ -625,9 +662,10 @@ output: council_tax_reduction_non_dep_deductions: [150, 150, 0] -# A lodger's family is one commercial arrangement with the applicant (SI -# 2012/2885 reg 9(2)(e)), so the lodger's adult son is no one's non-dependant. -- name: CTR, a lodger's adult son is not the applicant's non-dependant +# SI 2012/2885 reg 9(2)(e) excludes only the person liable to pay the +# applicant, so a lodger's adult son who pays nothing himself is the +# applicant's non-dependant. +- name: CTR, a lodger's adult son is the applicant's non-dependant period: 2026 absolute_error_margin: 0.01 input: @@ -643,6 +681,7 @@ lodger_son: age: 21 is_claimant_or_partner: false + council_tax_reduction_individual_non_dep_deduction: 300 benunits: benunit_1: members: [head] @@ -653,8 +692,8 @@ members: [head, lodger, lodger_son] rent: 9_000 output: - council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, false] - council_tax_reduction_non_dep_deductions: [0, 0] + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, true] + council_tax_reduction_non_dep_deductions: [300, 0] # Only a family that claims bears deductions; the non-dependant's own family # does not. @@ -686,3 +725,44 @@ output: council_tax_reduction_individual_non_dep_deduction_eligible: [false, true, true] council_tax_reduction_non_dep_deductions: [300, 0] + +# The English working-age local schemes keep their own aggregation, which +# charges only other families' non-dependants. Widening the shared +# eligibility must not make a jointly liable sharer bear the head's own son. +- name: Local CTR (Newham), a jointly liable sharer does not bear the head's own in-unit son + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 20_000 + weekly_hours: 37.5 + sharer: + age: 48 + is_claimant_or_partner: true + benunits: + benunit_1: + members: [head, son] + would_claim_council_tax_reduction: true + benunit_2: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_council_tax_reduction: true + households: + household: + members: [head, son, sharer] + rent: 12_000 + region: LONDON + local_authority: NEWHAM + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + output: + council_tax_reduction_joint_liability_share: [0.5, 0.5] + newham_council_tax_reduction_non_dep_deductions: [0, 0] + newham_council_tax_reduction: [630, 630] diff --git a/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py index d2f07529b9..45a5ab4714 100644 --- a/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py +++ b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py @@ -10,14 +10,14 @@ Reduction non-dependant deductions, and who is eligible for an individual deduction, equal an independent calculation from the generated household structure: who is a non-dependant, of whom, and how a non-dependant of - several joint occupiers is apportioned (another family's by rent or - liability share; a family's own non-dependant is that family's alone). + several joint occupiers is apportioned (by rent or liability share, + whichever family the non-dependant is in). 2. Conservation: every Housing Benefit non-dependant deduction is borne in - full, once, across the household's families; a Council Tax Reduction - claiming family bears its own non-dependants in full and another family's - in proportion to its joint-liability share; every Universal Credit - contribution is borne by exactly one family unless that family's renter - is exempt. + full across the joint occupiers, and once more by a boarder or lodger for + a non-dependant in its own benefit unit; each Council Tax Reduction + claiming family bears every non-dependant in proportion to its + joint-liability share; every Universal Credit contribution is borne by + exactly one family unless that family's renter is exempt. 3. Exemptions: a family whose renter is exempt under Sch 4 para 15 has no Universal Credit deductions; no deduction is charged for a non-dependant under 21 (UC) or under 18 (HB, CTR), or for a qualifying young person. @@ -244,22 +244,22 @@ def oracle(person_rows, family_rows, uc, hb, ctr, pension_credit, claims, ctr_sh bearer = head if other_family else f if uc_non_dependant and not family_rows[bearer]["renter_pip"]: exp_uc[bearer] += uc[p] - # HB: another family's non-dependant is pooled across joint - # occupiers by rent share; a family's own non-dependant is its - # alone (LHA Guidance Manual 2.093), a lodger's included. - if other_family: + # HB: every non-dependant, in any family, is pooled across the + # joint occupiers by rent share; a boarder or lodger, who is not + # a joint occupier, also bears its own in full. + if other_family or own_family: for i in fams: exp_hb[i] += share[i] * hb[p] - elif own_family: - exp_hb[f] += hb[p] - # CTR: another family's non-dependant is pooled across the - # claiming families; a claiming family's own is its alone; a - # lodger's family is no one's. - if other_family: + if own_family and role == "lodger": + exp_hb[f] += hb[p] + # CTR: every non-dependant, a lodger's included, is pooled across + # the claiming families by joint-liability share, except that a + # family's own claimant or partner is not its non-dependant. + if other_family or own_family: for i in fams: + if i == f and not own_family: + continue exp_ctr[i] += claims[i] * ctr_share[i] * ctr[p] - elif own_family and role in ("head", "sharer"): - exp_ctr[f] += ctr[p] return exp_uc, exp_hb, exp_ctr, expected_uc_individual @@ -309,16 +309,15 @@ def eligible(own_family_roles): ] ) - # HB: any family's own non-dependants; CTR: only a claiming family's (the - # household head's or a sharer's), not a boarder's or lodger's. - assert np.array_equal( - calc(sim, "housing_benefit_individual_non_dep_deduction_eligible") > 0, - eligible(("head", "sharer", "lodger", "non_dependant")), - ) - assert np.array_equal( - calc(sim, "council_tax_reduction_individual_non_dep_deduction_eligible") > 0, - eligible(("head", "sharer", "non_dependant")), - ) + # Any family's own non-dependants, a boarder's or lodger's included. + for variable in ( + "housing_benefit_individual_non_dep_deduction_eligible", + "council_tax_reduction_individual_non_dep_deduction_eligible", + ): + assert np.array_equal( + calc(sim, variable) > 0, + eligible(("head", "sharer", "lodger", "non_dependant")), + ) @PROPERTY_SETTINGS @@ -327,29 +326,29 @@ def test_conservation_and_exemptions(population): sim, person_rows, family_rows, v = run(population) ages = np.array([row["age"] for row in person_rows]) kinds = np.array([row["kind"] for row in person_rows]) - # 2. Conservation: HB and CTR deductions are borne once in full. + # 2. Conservation. HB: each deduction is borne in full across the joint + # occupiers, and a lodger's own non-dependant once more by the lodger. hb_families = sim.map_result( calc(sim, "housing_benefit_non_dep_deductions"), "benunit", "household" ) - hb_people = sim.map_result(v["hb"], "person", "household") + lodgers_own = np.array( + [ + row["kind"] == "non_dependant" + and family_rows[row["family"]]["role"] == "lodger" + for row in person_rows + ] + ) + hb_people = sim.map_result(v["hb"] * (1 + lodgers_own), "person", "household") assert np.allclose(hb_families, hb_people, atol=0.01) - # CTR: a claiming family's own non-dependant is borne by it in full; - # another family's is borne by the claiming families in proportion to - # their joint-liability shares (which follow the regulations' per-person - # wording, so they need not sum to one). + # CTR: every non-dependant is borne by the claiming families in + # proportion to their joint-liability shares (which follow the + # regulations' per-person wording, so they need not sum to one). claim_shares = sim.map_result(v["claims"] * v["ctr_share"], "benunit", "household") ctr_families = sim.map_result( calc(sim, "council_tax_reduction_non_dep_deductions"), "benunit", "household" ) - own_of_claimant = np.array( - [ - row["kind"] == "non_dependant" and v["claims"][row["family"]] > 0 - for row in person_rows - ] - ) - ctr_own = sim.map_result(v["ctr"] * own_of_claimant, "person", "household") - ctr_other = sim.map_result(v["ctr"] * ~own_of_claimant, "person", "household") - assert np.allclose(ctr_families, claim_shares * ctr_other + ctr_own, atol=0.01) + ctr_people = sim.map_result(v["ctr"], "person", "household") + assert np.allclose(ctr_families, claim_shares * ctr_people, atol=0.01) # UC: each contribution is borne once, except where the bearer is exempt. uc_families = sim.map_result( calc(sim, "uc_non_dep_deductions"), "benunit", "household" diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py index f3f93c6dfe..66fa7799c8 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py @@ -9,10 +9,10 @@ class housing_benefit_non_dep_deductions(Variable): "Deductions for the claimant's non-dependants: members of other " "families of the household who are not liable for rent, and members " "of a family liable for the rent who are not its claimant, partner or " - "a child or young person. A non-dependant from another family is " - "apportioned between joint occupiers by their shares of the rent. A " - "non-dependant in a family's own benefit unit is that family's alone, " - "including a boarder's or lodger's." + "a child or young person. Each non-dependant is apportioned between " + "joint occupiers by their shares of the rent, whichever family they " + "are in. A boarder or lodger also bears the whole deduction for a " + "non-dependant in its own benefit unit." ) definition_period = YEAR unit = GBP @@ -21,7 +21,6 @@ class housing_benefit_non_dep_deductions(Variable): "https://www.legislation.gov.uk/uksi/2006/213/regulation/74", "https://www.legislation.gov.uk/uksi/2006/214/regulation/3", "https://www.legislation.gov.uk/uksi/2006/214/regulation/55", - "https://assets.publishing.service.gov.uk/media/5a758238ed915d6faf2b38a2/lha-guidance-manual.pdf#page=33", "https://assets.publishing.service.gov.uk/media/5a7ce2b840f0b6629523c64c/hbgm-a5-calculating-benefit.pdf", ) @@ -39,19 +38,20 @@ def formula(benunit, period, parameters): person("is_benefit_unit_non_dependant_for_legacy_benefits", period) & ~other_family ) - # A non-dependant of more than one joint occupier is apportioned - # between them by their shares of the payments (reg 74(5); SPC reg - # 55(5)). A member of a joint occupier's own family unit is that - # occupier's non-dependant only (LHA Guidance Manual 2.093, example - # 2: a joint tenant's sister "is treated as Sarah's non-dependant"), - # and "if a person is a non-dependant of only one of the joint - # occupiers, take the whole of the deduction from that joint - # occupier's entitlement" (HBGM A5 5.622). Their family bears the - # whole deduction, whatever its share of the rent; so does a boarder - # or lodger. + # A non-dependant normally resides with each joint occupier (reg 3(1) + # and (4)), whichever family they belong to: another family's adult, + # a joint occupier's own adult son, or a boarder's or lodger's (reg + # 3(2)(e)(i) excludes only the person liable to pay the claimant). + # One who is a non-dependant of more than one joint occupier is + # apportioned between them by their shares of the payments (reg + # 74(5); SPC reg 55(5)). share = benunit("share_of_household_rent", period) - from_other_families = share * benunit.max( - person.household.sum(deductions * other_family) + from_joint_occupiers = share * benunit.max( + person.household.sum(deductions * (other_family | own_family)) ) - from_own_family = benunit.sum(deductions * own_family) - return from_other_families + from_own_family + # A boarder or lodger is not a joint occupier with the householder, + # so it also bears the whole deduction for a non-dependant in its own + # benefit unit. The model charges it for no one else. + joint_occupier = share > 0 + from_own_family = where(joint_occupier, 0, benunit.sum(deductions * own_family)) + return from_joint_occupiers + from_own_family diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py index 966d230ff7..840f829137 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py @@ -74,7 +74,15 @@ def local_non_dep_deductions( individual_deduction_variable, one_deduction_for_uc_couples=True, ): - deductions = benunit.members(individual_deduction_variable, period) + # The local schemes still charge only the non-dependants of other + # families. Non-dependants within a family's own benefit unit, now + # eligible for the national schemes, are left out here until each + # council's aggregation is brought into line. + person = benunit.members + other_family = ~person.benunit( + "benunit_contains_household_head", period + ) & ~person.benunit("benunit_is_rent_liable", period) + deductions = person(individual_deduction_variable, period) * other_family deduction_for_benunit = benunit.max(deductions) if not one_deduction_for_uc_couples: has_uc = benunit("universal_credit", period) > 0 diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py index f235efc452..2f72ade201 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py @@ -7,12 +7,13 @@ class council_tax_reduction_individual_non_dep_deduction_eligible(Variable): label = "eligible person for CTR non-dependent deduction" documentation = ( "A non-dependant aged 18 or over: an adult outside the claimant's " - "family who is not liable for rent, or a member of a claiming " - "family's benefit unit who is not its claimant, partner or a child or " - "young person (see is_benefit_unit_non_dependant_for_legacy_benefits). " - "Someone jointly and severally liable for the council tax with the " - "claimant (a joint tenant or other sharer of the rent) and a boarder " - "or lodger, with their family, is not a non-dependant." + "family who is not liable for rent, or a member of any family's " + "benefit unit who is not its claimant, partner or a child or young " + "person (see is_benefit_unit_non_dependant_for_legacy_benefits), " + "including a boarder's or lodger's adult son. Someone jointly and " + "severally liable for the council tax with the claimant (a joint " + "tenant or other sharer of the rent) and a boarder or lodger who pays " + "the claimant are not non-dependants." ) definition_period = YEAR reference = ( @@ -28,10 +29,8 @@ def formula(person, period, parameters): other_family = ~person.benunit( "benunit_contains_household_head", period ) & ~person.benunit("benunit_is_rent_liable", period) - # A member of a claiming family's own benefit unit who is not in its - # claimant's family. A boarder's or lodger's family is one commercial - # arrangement, so its members are no one's non-dependants. - own_family = person( - "is_benefit_unit_non_dependant_for_legacy_benefits", period - ) & person.benunit("council_tax_reduction_claimant_benunit", period) - return (person("age", period) >= 18) & (other_family | own_family) + # A member of any family's benefit unit who is not in its claimant's + # family. Reg 9(2)(e) excludes only the person liable to pay the + # applicant, so a boarder's or lodger's adult son is a non-dependant. + in_unit = person("is_benefit_unit_non_dependant_for_legacy_benefits", period) + return (person("age", period) >= 18) & (other_family | in_unit) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py index d2498f1126..b2589bfae4 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py @@ -6,9 +6,9 @@ class council_tax_reduction_non_dep_deductions(Variable): entity = BenUnit label = "CTR non-dependent deductions" documentation = ( - "Deductions for a claiming family's non-dependants: those in its own " - "benefit unit, in full, and those in other families, apportioned " - "equally between the jointly liable people." + "Deductions for a claiming family's non-dependants, whether in its " + "own benefit unit or another family's, apportioned equally between " + "the jointly liable people." ) definition_period = YEAR unit = GBP @@ -23,17 +23,18 @@ def formula(benunit, period, parameters): "council_tax_reduction_individual_non_dep_deduction", period ) claims = benunit("council_tax_reduction_claimant_benunit", period) - # A non-dependant in a claiming family's own benefit unit is that - # applicant's alone (as for HB: LHA Guidance Manual 2.093, example 2). - own_family = person( + # Every eligible non-dependant normally resides with each liable + # person (SI 2012/2885 reg 9(1)): another family's adult, an + # applicant's own adult son, or a boarder's or lodger's (reg 9(2)(e) + # excludes only the person liable to pay the applicant). A + # non-dependant of two or more jointly liable people is apportioned + # equally between them (Sch 1 para 8(5)). Members of the applicant's + # own family are not its non-dependants (reg 9(2)(a)). + own_family_member = ~person( "is_benefit_unit_non_dependant_for_legacy_benefits", period - ) & person.benunit("council_tax_reduction_claimant_benunit", period) - from_own_family = benunit.sum(deductions * own_family) - # A non-dependant from another family is a non-dependant of each - # jointly liable person, apportioned equally between them (SI - # 2012/2885 Sch 1 para 8(5)). - share = benunit("council_tax_reduction_joint_liability_share", period) - from_other_families = share * benunit.max( - person.household.sum(deductions * ~own_family) ) - return claims * (from_other_families + from_own_family) + non_dependants = benunit.max(person.household.sum(deductions)) - benunit.sum( + deductions * own_family_member + ) + share = benunit("council_tax_reduction_joint_liability_share", period) + return claims * share * non_dependants From 8aaf5a505efb47697e9da59212e253f69bb2da80 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 04:41:53 -0400 Subject: [PATCH 08/14] Keep CTR eligibility and the local mask on one other-family definition The local schemes' aggregation masks the shared individual deductions to members of other families not liable for rent, so it stays as on base while the national eligibility also covers in-unit non-dependants. That mask must be exactly the eligibility's other-family term. #2009 changes the term to the claimant family, so that a sharer with no rent is a claimant and not a non-dependant (reg 9(2)(d)). in_other_non_liable_family now holds the term once, and both the eligibility and local_non_dep_deductions call it, so composing with #2009 is one edit. No change in behaviour. Co-Authored-By: Claude Opus 5.5 --- .../council_tax_reduction/_legacy.py | 18 ++++++++++++++---- ...on_individual_non_dep_deduction_eligible.py | 7 ++++--- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py index 840f829137..ffbf70c6fd 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py @@ -4,6 +4,17 @@ ) +def in_other_non_liable_family(person, period): + """Whether each person is in a family other than the claimant's that is + not liable for the rent: all its members aged 18 or over are the + claimant's non-dependants (SI 2012/2885 reg 9(2)(a), (d)-(e)). The + eligibility for a non-dependant deduction and the local schemes' + aggregation both use this, so they stay on one definition.""" + return ~person.benunit("benunit_contains_household_head", period) & ~person.benunit( + "benunit_is_rent_liable", period + ) + + def is_full_time_student_non_dep(person, period): return (person("current_education", period) != EducationType.NOT_IN_EDUCATION) | ( person("in_HE", period) @@ -79,10 +90,9 @@ def local_non_dep_deductions( # eligible for the national schemes, are left out here until each # council's aggregation is brought into line. person = benunit.members - other_family = ~person.benunit( - "benunit_contains_household_head", period - ) & ~person.benunit("benunit_is_rent_liable", period) - deductions = person(individual_deduction_variable, period) * other_family + deductions = person(individual_deduction_variable, period) * ( + in_other_non_liable_family(person, period) + ) deduction_for_benunit = benunit.max(deductions) if not one_deduction_for_uc_couples: has_uc = benunit("universal_credit", period) > 0 diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py index 2f72ade201..2c39ee3daa 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( + in_other_non_liable_family, +) class council_tax_reduction_individual_non_dep_deduction_eligible(Variable): @@ -26,9 +29,7 @@ class council_tax_reduction_individual_non_dep_deduction_eligible(Variable): def formula(person, period, parameters): # SI 2012/2885 reg 9(2)(a), (c)-(e) and the Welsh and Scottish # equivalents; deductions are for non-dependants aged 18 or over. - other_family = ~person.benunit( - "benunit_contains_household_head", period - ) & ~person.benunit("benunit_is_rent_liable", period) + other_family = in_other_non_liable_family(person, period) # A member of any family's benefit unit who is not in its claimant's # family. Reg 9(2)(e) excludes only the person liable to pay the # applicant, so a boarder's or lodger's adult son is a non-dependant. From e72f2b8fec4694414e32e89b779f04ec86e85a02 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 05:30:37 -0400 Subject: [PATCH 09/14] Test that a CTR applicant is never charged a deduction for itself Mutation check v3 found that dropping the applicant's own-family subtraction survived every test: with formula-computed eligibility the applicant's family never has an eligible member. An individual deduction set directly as an input does, and reg 9(2)(a) still keeps the applicant's own family out of its non-dependants, as base did. The YAML case pins it. Co-Authored-By: Claude Opus 5.5 --- .../family/benefit_unit_non_dependants.yaml | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml index 51d2b8e129..ffa7cbe70f 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml @@ -766,3 +766,33 @@ council_tax_reduction_joint_liability_share: [0.5, 0.5] newham_council_tax_reduction_non_dep_deductions: [0, 0] newham_council_tax_reduction: [630, 630] + +# SI 2012/2885 reg 9(2)(a): the applicant's own family are not its +# non-dependants, so an individual deduction set for the applicant (here as +# an input) is never charged to the applicant's own claim. +- name: CTR, an applicant is never charged a deduction for itself + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + council_tax_reduction_individual_non_dep_deduction: 300 + friend: + age: 30 + is_claimant_or_partner: true + council_tax_reduction_individual_non_dep_deduction: 200 + benunits: + benunit_1: + members: [head] + benunit_2: + members: [friend] + households: + household: + members: [head, friend] + rent: 12_000 + output: + council_tax_reduction_claimant_benunit: [true, false] + council_tax_reduction_non_dep_deductions: [200, 0] From 270412d3089ff419aa811b164e04782ed9dd6853 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 07:51:05 -0400 Subject: [PATCH 10/14] State the CTR other-family proxy and the in-unit benefits limitation Review r3: - R3-6: in_other_non_liable_family's docstring stated the proxy as if it were reg 9(2)(a), (d)-(e). It is a proxy: a family jointly liable for the council tax but paying no rent is wrongly included, which #2009's claimant-family test fixes. - R3-3: an in-unit non-dependant's own benefit awards are not modelled, so the HB reg 74(8) and CTR Sch 1 para 8(8) exemptions for a non-dependant on an income-related benefit or on UC with no earned income never apply to them. The documentation of is_benefit_unit_non_dependant_for_legacy_benefits now says so. No change in behaviour. Co-Authored-By: Claude Opus 5.5 --- ...enefit_unit_non_dependant_for_legacy_benefits.py | 5 ++++- .../council_tax_reduction/_legacy.py | 13 ++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_legacy_benefits.py b/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_legacy_benefits.py index e1449d56e8..7819dc646c 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_legacy_benefits.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_legacy_benefits.py @@ -16,7 +16,10 @@ class is_benefit_unit_non_dependant_for_legacy_benefits(Variable): "by Child Benefit) but normally resides with the claimant, so is a " "non-dependant. A child or young person is either in the family or, " "if placed with the claimant by a local authority, excluded from " - "being a non-dependant." + "being a non-dependant. Such a person's own benefit awards are not " + "modelled, so the exemptions for a non-dependant on an income-related " + "benefit or on Universal Credit with no earned income (HB reg 74(8), " + "CTR Sch 1 para 8(8)) never apply to them." ) definition_period = YEAR reference = ( diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py index ffbf70c6fd..fd35957f53 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py @@ -5,11 +5,14 @@ def in_other_non_liable_family(person, period): - """Whether each person is in a family other than the claimant's that is - not liable for the rent: all its members aged 18 or over are the - claimant's non-dependants (SI 2012/2885 reg 9(2)(a), (d)-(e)). The - eligibility for a non-dependant deduction and the local schemes' - aggregation both use this, so they stay on one definition.""" + """Whether each person is in a family that is neither the oldest adult's + nor liable for the rent. This stands in for SI 2012/2885 reg 9(2)(a) and + (d)-(e): such a family is outside the claimant's family, and not jointly + liable or paying the claimant. It is a proxy: a family jointly liable for + the council tax but paying no rent is wrongly included, which #2009's + claimant-family test fixes. The eligibility for a non-dependant deduction + and the local schemes' aggregation both use this, so they stay on one + definition.""" return ~person.benunit("benunit_contains_household_head", period) & ~person.benunit( "benunit_is_rent_liable", period ) From ae4e42bf78211edd71537f428edf2610b5ec6bef Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 19:14:36 -0400 Subject: [PATCH 11/14] Make the in-unit property oracle scheme-aware and follow the UC counted claim Since #2015 each Council Tax Reduction claim is assessed under its own family's scheme, and #2007 recomputes a jointly liable applicant's deductions under the other scheme. The oracle and the conservation and no-op invariants charged every claiming family the head's-scheme amounts, so a pensioner head with working-age sharers in England (no national working-age scale) failed against a correct model. They now take each family's own-scheme amounts, other-scheme amounts from the aggregator's helper, and each family's scheme from the model. UC attribution follows main's uc_non_dependants_counted: other families' non-dependants count in the first liable claiming family's claim, and a family's own in-unit non-dependant in its own claim if it claims UC. The no-op invariant uses the counted claim instead of the head's family. Co-Authored-By: Claude Opus 5.5 --- ...t_benefit_unit_non_dependant_properties.py | 111 +++++++++++++----- 1 file changed, 81 insertions(+), 30 deletions(-) diff --git a/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py index 0e1c7cd98f..8a2428b98f 100644 --- a/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py +++ b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py @@ -17,7 +17,8 @@ counted once) is borne in full across the joint occupiers, and once more by a boarder or lodger for a non-dependant in its own benefit unit, unless a claimant is exempt; each Council Tax Reduction claiming family - bears every non-dependant in proportion to its joint-liability share + bears every non-dependant, under its own scheme, in proportion to its + joint-liability share unless the applicant is exempt; every Universal Credit contribution is borne by exactly one family unless that family's renter is exempt. 3. Exemptions: a family whose renter is exempt (UC Sch 4 para 15; HB reg @@ -33,7 +34,13 @@ from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st +from policyengine_core.periods import period as make_period + from policyengine_uk import Simulation +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._national_non_dependants import ( + household_head_family_is_pensioner, + individual_non_dep_deduction, +) # Situation inputs without a period key are set for 2025, so the properties # are checked in 2025, when the contribution was £93.02 a month. @@ -231,6 +238,8 @@ def oracle( ctr_share, uc_counted, uc_own_claim, + ctr_other, + ctr_own_scheme_is_heads, ): """Expected deductions per family from the generated structure. @@ -239,12 +248,16 @@ def oracle( Universal Credit claim counts the household's non-dependants (uc_non_dependants_counted), whether a family makes its own claim, the CTR claim flags and the joint-liability shares are read from the model. + ctr is each person's deduction under the scheme of the household head's + family; ctr_other under the other scheme, which a claiming family on that + scheme bears (SI 2012/2885 reg 3). """ n = len(family_rows) exp_uc, exp_hb, exp_ctr = np.zeros(n), np.zeros(n), np.zeros(n) expected_uc_individual = np.zeros(len(person_rows)) hb = counted_once_per_couple(person_rows, hb) ctr = counted_once_per_couple(person_rows, ctr) + ctr_other = counted_once_per_couple(person_rows, ctr_other) households = sorted({f["household"] for f in family_rows}) for h in households: fams = [i for i, f in enumerate(family_rows) if f["household"] == h] @@ -300,7 +313,8 @@ def oracle( for i in fams: if i == f and not own_family: continue - exp_ctr[i] += claims[i] * ctr_share[i] * ctr[p] + amount = ctr[p] if ctr_own_scheme_is_heads[i] else ctr_other[p] + exp_ctr[i] += claims[i] * ctr_share[i] * amount # A claimant or partner on PIP daily living exempts their own family # from all HB and CTR deductions (reg 74(6); Sch 1 para 8(6)). for i in fams: @@ -310,6 +324,54 @@ def oracle( return exp_uc, exp_hb, exp_ctr, expected_uc_individual +def ctr_by_scheme(sim, person_rows, family_rows): + """Each person's CTR deduction under the other scheme (pensioner or + working-age) from the household head's family, and whether each family is + on the head's scheme. Computed with the aggregator's own helper, since a + jointly liable applicant on the other scheme has its deductions + recomputed under that scheme.""" + person = sim.populations["person"] + period = make_period(YEAR) + head_pensioner = np.asarray(household_head_family_is_pensioner(person, period)) + eligible = ( + calc(sim, "council_tax_reduction_individual_non_dep_deduction_eligible") > 0 + ) + other = np.asarray( + individual_non_dep_deduction( + person, period, sim.tax_benefit_system.parameters, ~head_pensioner + ), + dtype=float, + ) + first_member = {} + for p, row in enumerate(person_rows): + first_member.setdefault(row["family"], p) + family_head_pensioner = np.array( + [head_pensioner[first_member[f]] for f in range(len(family_rows))] + ) + family_pensioner = calc(sim, "council_tax_reduction_pensioner") > 0 + return dict( + ctr_other=eligible * other, + ctr_own_scheme_is_heads=family_pensioner == family_head_pensioner, + ) + + +def pool_under_own_scheme(person_rows, family_rows, v, mask=None): + """For each family, the household's CTR deductions (a couple's once) + under that family's own scheme, over the members in mask (everyone by + default).""" + mask = np.ones(len(person_rows), dtype=bool) if mask is None else mask + person_household = np.array([row["household"] for row in person_rows]) + family_household = np.array([row["household"] for row in family_rows]) + pools = {} + for scheme, amounts in (("head", v["ctr"]), ("other", v["ctr_other"])): + counted = counted_once_per_couple(person_rows, amounts) * mask + by_household = np.bincount( + person_household, weights=counted, minlength=family_household.max() + 1 + ) + pools[scheme] = by_household[family_household] + return np.where(v["ctr_own_scheme_is_heads"], pools["head"], pools["other"]) + + def run(population, convert=None): situation, person_rows, family_rows = build(population, convert) sim = Simulation(situation=situation) @@ -321,6 +383,7 @@ def run(population, convert=None): claims=calc(sim, "council_tax_reduction_claimant_benunit"), ctr_share=calc(sim, "council_tax_reduction_joint_liability_share"), uc_counted=calc(sim, "uc_non_dependants_counted") > 0, + **ctr_by_scheme(sim, person_rows, family_rows), uc_own_claim=( (calc(sim, "benunit_is_rent_liable") > 0) & (calc(sim, "is_uc_eligible") > 0) @@ -393,7 +456,6 @@ def test_conservation_and_exemptions(population): # across the joint occupiers, and a lodger's own non-dependant once more # by the lodger, where no claimant is exempt. hb_counted = counted_once_per_couple(person_rows, v["hb"]) - ctr_counted = counted_once_per_couple(person_rows, v["ctr"]) hb_families = sim.map_result( calc(sim, "housing_benefit_non_dep_deductions"), "benunit", "household" ) @@ -415,30 +477,21 @@ def test_conservation_and_exemptions(population): ) assert np.allclose(hb_families[~hb_exempt], hb_people[~hb_exempt], atol=0.01) assert np.all(hb_families <= hb_people + 0.01) - # CTR: every non-dependant is borne by the claiming families in - # proportion to their joint-liability shares (which follow the - # regulations' per-person wording, so they need not sum to one); none - # where the applicant is exempt. - claim_shares = sim.map_result(v["claims"] * v["ctr_share"], "benunit", "household") - ctr_families = sim.map_result( - calc(sim, "council_tax_reduction_non_dep_deductions"), "benunit", "household" - ) - ctr_people = sim.map_result(ctr_counted, "person", "household") - ctr_exempt = ( - sim.map_result( - calc(sim, "council_tax_reduction_applicant_has_non_dep_exemption") - * v["claims"], - "benunit", - "household", - ) - > 0 - ) - assert np.allclose( - ctr_families[~ctr_exempt], - (claim_shares * ctr_people)[~ctr_exempt], - atol=0.01, + # CTR: every non-dependant is borne by each claiming family, under its + # own scheme, in proportion to its joint-liability share (which follows + # the regulations' per-person wording, so shares need not sum to one); + # none where the applicant is exempt. This holds on the generator's + # domain (rent > 0, claimants liable for it), where no claiming family's + # own claimant or partner has a deduction. + ctr_families = calc(sim, "council_tax_reduction_non_dep_deductions") + expected_ctr = ( + v["claims"] + * v["ctr_share"] + * pool_under_own_scheme(person_rows, family_rows, v) ) - assert np.all(ctr_families <= claim_shares * ctr_people + 0.01) + ctr_exempt = calc(sim, "council_tax_reduction_applicant_has_non_dep_exemption") > 0 + assert np.allclose(ctr_families[~ctr_exempt], expected_ctr[~ctr_exempt], atol=0.01) + assert np.all(ctr_families[ctr_exempt] == 0) # UC: each contribution is borne once, except where the bearer is exempt. uc_families = sim.map_result( calc(sim, "uc_non_dep_deductions"), "benunit", "household" @@ -507,11 +560,9 @@ def other_families(amounts): atol=0.01, ) ctr_exempt = calc(sim, "council_tax_reduction_applicant_has_non_dep_exemption") > 0 - ctr_counted = counted_once_per_couple(person_rows, v["ctr"]) + pool = pool_under_own_scheme(person_rows, family_rows, v, mask=other) assert np.allclose( calc(sim, "council_tax_reduction_non_dep_deductions"), - np.where( - ctr_exempt, 0, v["claims"] * v["ctr_share"] * other_families(ctr_counted) - ), + np.where(ctr_exempt, 0, v["claims"] * v["ctr_share"] * pool), atol=0.01, ) From 58db77c0ebd2809e0249db899f377f699d47984f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 22:29:51 -0400 Subject: [PATCH 12/14] Keep the household head out of the UC and HB in-unit sets (review r4) F1. Where a liable household head is not her benefit unit's claimant or partner, she applies for Council Tax Reduction alone (#2078), but the UC and HB in-unit helpers made her a non-dependant of her own benefit unit's couple. She is the householder in whose name the accommodation is rented: UC Sch 4 para 9(2)(d) and HB reg 3(2)(d)-(e) exclude her. Both helpers now exclude is_resolved_household_head. A YAML case pins UC and HB at 0 while CTR keeps the couple as her non-dependants. F2. uc_non_dep_deductions' documentation states that "makes a claim" is the claims_universal_credit flag, so a family flagged as claiming counts its own non-dependants even with a nil award, and says where other families' go. A YAML case pins a non-claiming lodger's son in the householder's claim. F5. The changelog names the household head rule, joint-occupier apportionment and the narrowed para 16(2)(b) Pension Credit limb. The property oracle checks all three in-unit sets (UC, HB, CTR); the generator now states the child's Child Benefit status, since inputs set for some members are inputs for all. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2014.fixed.md | 2 +- .../family/benefit_unit_non_dependants.yaml | 68 +++++++++++++++++++ ...t_benefit_unit_non_dependant_properties.py | 19 ++++-- ..._unit_non_dependant_for_legacy_benefits.py | 12 +++- ...unit_non_dependant_for_universal_credit.py | 15 +++- .../uc_non_dep_deductions.py | 14 ++-- 6 files changed, 115 insertions(+), 15 deletions(-) diff --git a/changelog.d/2014.fixed.md b/changelog.d/2014.fixed.md index b3c0b44145..7f69203350 100644 --- a/changelog.d/2014.fixed.md +++ b/changelog.d/2014.fixed.md @@ -1 +1 @@ -- Charge Universal Credit, Housing Benefit and national Council Tax Reduction non-dependant deductions for members of the claimant's own benefit unit who are not the claimant, partner or a child or young person, such as an adult son (UC Regs 2013 Sch 4 para 9; HB Regs 2006 and HB (SPC) Regs 2006 reg 3; SI 2012/2885 reg 9 and the Welsh and Scottish equivalents), and apply the Universal Credit renter exemption from housing cost contributions (Sch 4 para 15). +- Charge Universal Credit, Housing Benefit and national Council Tax Reduction non-dependant deductions for members of the claimant's own benefit unit who are not the claimant, partner or a child or young person, such as an adult son (UC Regs 2013 Sch 4 para 9; HB Regs 2006 and HB (SPC) Regs 2006 reg 3; SI 2012/2885 reg 9 and the Welsh and Scottish equivalents). Housing Benefit and Council Tax Reduction apportion every non-dependant between joint occupiers or jointly liable applicants, a lodger's adult son included, and a household head is never her own benefit unit's non-dependant. Apply the Universal Credit renter exemption from housing cost contributions (Sch 4 para 15), and give the para 16(2)(b) Pension Credit exemption only to the claimant or partner who receives it, so a pensioner's adult son is no longer exempt by her award. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml index 153438eef8..b8da4762e7 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml @@ -1224,3 +1224,71 @@ uc_non_dep_receives_pension_credit: [false, true] uc_non_dep_deduction_exempt: [false, true, false] uc_non_dep_deductions: [1_158.60, 0] + +# The household head is the householder in whose name the accommodation is +# rented, so she is no one's non-dependant for UC (Sch 4 para 9(2)(d)) or HB +# (reg 3(2)(d)), even where her benefit unit's claimant and partner are the +# young couple. For CTR she applies alone and they are her non-dependants. +- name: UC and HB, a head who applies for CTR alone is not her benefit unit's non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 70 + is_household_head: true + is_claimant_or_partner: false + son: + age: 30 + is_claimant_or_partner: true + employment_income: 25_000 + weekly_hours: 37.5 + partner: + age: 29 + is_claimant_or_partner: true + benunits: + benunit: + members: [grandmother, son, partner] + households: + household: + members: [grandmother, son, partner] + rent: 12_000 + output: + is_benefit_unit_non_dependant_for_universal_credit: [false, false, false] + is_benefit_unit_non_dependant_for_legacy_benefits: [false, false, false] + is_benefit_unit_non_dependant_for_council_tax_reduction: [false, true, true] + uc_non_dep_deductions: [0] + housing_benefit_non_dep_deductions: [0] + +# Sch 4 para 9(2)(f): a family that makes no Universal Credit claim treats +# no one as its non-dependant, so a non-claiming lodger's adult son counts in +# the householder's claim. "Making a claim" is the model's would-claim flag. +- name: UC, a non-claiming lodger's adult son counts in the householder's claim + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 40 + is_household_head: true + is_claimant_or_partner: true + lodger: + age: 39 + is_claimant_or_partner: true + rent_paid_as_lodger: 5_200 + lodger_son: + age: 25 + is_claimant_or_partner: false + benunits: + benunit_1: + members: [householder] + benunit_2: + members: [lodger, lodger_son] + would_claim_uc: false + households: + household: + members: [householder, lodger, lodger_son] + rent: 9_000 + output: + uc_non_dependants_counted: [true, false] + uc_non_dep_deductions: [1_158.60, 0] diff --git a/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py index 8a2428b98f..df83ec5ba1 100644 --- a/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py +++ b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py @@ -166,10 +166,13 @@ def build(population, convert=None): ) if fam["child"]: pid = f"{name}_child" + # Inputs set for some members are inputs for all, so the + # child's Child Benefit status is stated, not left to default. people[pid] = { "age": 8, "is_claimant_or_partner": False, "is_household_head": False, + "is_child_or_qualifying_young_person_for_child_benefit": True, } ids.append(pid) person_rows.append( @@ -398,13 +401,17 @@ def run(population, convert=None): def test_deductions_match_the_oracle(population): sim, person_rows, family_rows, v = run(population) exp_uc, exp_hb, exp_ctr, exp_uc_individual = oracle(person_rows, family_rows, **v) - # 1. Oracle. The members classed as in-unit non-dependants are exactly - # the generated non-QYP members. + # 1. Oracle. The members each scheme classes as in-unit non-dependants + # are exactly the generated non-QYP members. kinds = np.array([row["kind"] for row in person_rows]) - assert np.array_equal( - calc(sim, "is_benefit_unit_non_dependant_for_universal_credit") > 0, - kinds == "non_dependant", - ) + for in_unit_variable in ( + "is_benefit_unit_non_dependant_for_universal_credit", + "is_benefit_unit_non_dependant_for_legacy_benefits", + "is_benefit_unit_non_dependant_for_council_tax_reduction", + ): + assert np.array_equal( + calc(sim, in_unit_variable) > 0, kinds == "non_dependant" + ), in_unit_variable assert np.allclose(v["uc"], exp_uc_individual, atol=0.01) assert np.allclose(calc(sim, "uc_non_dep_deductions"), exp_uc, atol=0.01) assert np.allclose( diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_legacy_benefits.py b/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_legacy_benefits.py index 7819dc646c..7069ff4524 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_legacy_benefits.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_legacy_benefits.py @@ -19,7 +19,13 @@ class is_benefit_unit_non_dependant_for_legacy_benefits(Variable): "being a non-dependant. Such a person's own benefit awards are not " "modelled, so the exemptions for a non-dependant on an income-related " "benefit or on Universal Credit with no earned income (HB reg 74(8), " - "CTR Sch 1 para 8(8)) never apply to them." + "CTR Sch 1 para 8(8)) never apply to them. The household head " + "(is_resolved_household_head), a householder in whose name the " + "accommodation is rented, is liable for it and so never a " + "non-dependant (HB reg 3(2)(d)-(e)), even where the benefit unit's " + "claimant and partner are someone else. Council Tax Reduction uses " + "is_benefit_unit_non_dependant_for_council_tax_reduction, built on " + "the CTR applicant." ) definition_period = YEAR reference = ( @@ -43,4 +49,6 @@ def formula(person, period, parameters): child_or_young_person = person( "is_child_or_qualifying_young_person_for_child_benefit", period ) - return ~claimant_or_partner & ~child_or_young_person + # Reg 3(2)(d)-(e): the household head is liable for the dwelling. + household_head = person("is_resolved_household_head", period) + return ~claimant_or_partner & ~child_or_young_person & ~household_head diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_universal_credit.py index bc6b71d000..80dcda54e2 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_universal_credit.py @@ -16,7 +16,11 @@ class is_benefit_unit_non_dependant_for_universal_credit(Variable): "young person the renter is responsible for is a member of the " "renter's extended benefit unit, and one no one is responsible for, " "for example one looked after by a local authority, is excluded by " - "para 9(2)(g). A child, under 16, is always one or the other." + "para 9(2)(g). A child, under 16, is always one or the other. The " + "household head (is_resolved_household_head), a householder in whose " + "name the accommodation is rented, is liable for it and so never a " + "non-dependant (para 9(2)(d)), even where the benefit unit's " + "claimant and partner are someone else." ) definition_period = YEAR reference = ( @@ -33,4 +37,11 @@ def formula(person, period, parameters): qualifying_young_person = person( "is_qualifying_young_person_for_universal_credit", period ) - return not_a_child & ~claimant_or_partner & ~qualifying_young_person + # Para 9(2)(d): the household head is liable for the accommodation. + household_head = person("is_resolved_household_head", period) + return ( + not_a_child + & ~claimant_or_partner + & ~qualifying_young_person + & ~household_head + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions.py index 000f387d1d..9f06dbb16d 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions.py @@ -6,10 +6,16 @@ class uc_non_dep_deductions(Variable): entity = BenUnit label = "Universal Credit non-dependent deductions" documentation = ( - "Housing cost contributions for the renter's non-dependants: those in " - "other families of the household, for the household head's family " - "only, and those within the renter's own benefit unit. None if the " - "renter or a joint renter is exempt." + "Housing cost contributions for the renter's non-dependants. Those in " + "other families of the household count in one claim, the one " + "uc_non_dependants_counted picks (the household head's family if it " + "claims, otherwise the first claiming sharer). Those within a " + "renter's own benefit unit count in that family's claim if it makes " + "one, otherwise in the same counted claim. Making a claim is the " + "model's claims_universal_credit flag for a renting, UC-eligible " + "family, so a family flagged as claiming counts its own non-dependants " + "even where its award is nil. None if the renter or a joint renter is " + "exempt." ) definition_period = YEAR unit = GBP From 2e37b1e761a8f6d1fa7f90b8a6f8deb316152f5b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 00:36:01 -0400 Subject: [PATCH 13/14] Restate main's household-head agreement property for in-unit non-dependants (review r4) F7. #2062's test_every_programme_reads_one_head pinned three rules that assume no non-dependants inside a benefit unit; on this branch it failed deterministically (a 19-year-old presumed the 82-year-old head's child is an in-unit CTR non-dependant, as SI 2012/2885 reg 9(2)(a) gives). Restated: - UC deductions fall on the counting claim, or on a family's own claim for its in-unit non-dependants; - HB deductions only for families with a share of the rent, or a boarder or lodger with a non-dependant in its own benefit unit; - no one in the adult head's family is a CTR non-dependant except a member outside the applicant's family. F6. The property generator now draws would_claim_uc per family, so the in-unit routing's claim factor is exercised (its mutant now fails the oracle); a YAML case pins a non-claiming head whose son counts in a claiming sharer's claim. Co-Authored-By: Claude Opus 5.5 --- .../family/benefit_unit_non_dependants.yaml | 34 +++++++++++++++++++ ...t_benefit_unit_non_dependant_properties.py | 5 ++- ...est_household_head_agreement_properties.py | 34 ++++++++++++++----- 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml index 3c61bdbcf9..1edd5322e9 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml @@ -1327,3 +1327,37 @@ output: share_of_household_rent: [0.5, 0.5] housing_benefit_non_dep_deductions: [500, 500] + +# Sch 4 para 9(2)(f): the head's family is flagged not to claim Universal +# Credit, so its adult son counts in the jointly liable sharer's claim. +- name: UC, a non-claiming head's adult son counts in a claiming sharer's claim + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + employment_income: 150_000 + weekly_hours: 37.5 + son: + age: 25 + is_claimant_or_partner: false + sharer: + age: 45 + is_claimant_or_partner: true + benunits: + benunit_1: + members: [head, son] + would_claim_uc: false + benunit_2: + members: [sharer] + liable_for_share_of_household_rent: true + households: + household: + members: [head, son, sharer] + rent: 12_000 + output: + uc_non_dependants_counted: [false, true] + uc_non_dep_deductions: [0, 1_158.60] diff --git a/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py index df83ec5ba1..bf211f4a82 100644 --- a/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py +++ b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py @@ -85,6 +85,9 @@ def family(draw, role): extras=draw(st.lists(extra_member(), min_size=0, max_size=2)), child=draw(st.booleans()), renter_pip=draw(st.booleans()), + # Take-up: a family flagged not to claim Universal Credit passes its + # in-unit non-dependants to the claim that counts the household's. + would_claim_uc=draw(st.booleans()), payment=draw(st.floats(1_000, 10_000, allow_nan=False)), ) @@ -184,7 +187,7 @@ def build(population, convert=None): disability_benefit=False, ) ) - benunit = {"members": ids} + benunit = {"members": ids, "would_claim_uc": fam["would_claim_uc"]} if fam["role"] == "sharer": benunit["liable_for_share_of_household_rent"] = True if fam["role"] == "lodger": diff --git a/policyengine_uk/tests/test_household_head_agreement_properties.py b/policyengine_uk/tests/test_household_head_agreement_properties.py index 4862578ad5..26135828ed 100644 --- a/policyengine_uk/tests/test_household_head_agreement_properties.py +++ b/policyengine_uk/tests/test_household_head_agreement_properties.py @@ -28,13 +28,17 @@ if and only if their family is neither the head's nor liable for rent. - Universal Credit non-dependants: at most one family in a household counts them (uc_non_dependants_counted; UC Regs 2013 Sch 4 para - 9(2)(f)). Pin: deductions fall only on that family's claim, and where - the head's family is liable for the rent and claims Universal Credit, - it is the head's. Only families with a share of the rent have Housing - Benefit non-dependant deductions. + 9(2)(f)). Pin: other families' non-dependants fall only on that + family's claim, and a family's own in-unit non-dependants on its own + claim; where the head's family is liable for the rent and claims + Universal Credit, the counting claim is the head's. Only families with + a share of the rent have Housing Benefit non-dependant deductions, + except a boarder or lodger for a non-dependant in its own benefit unit. - Council Tax Reduction: only the head's family and sharers claim; where the head is 18 or over, the head's family claims and no one in it is a - non-dependant. + non-dependant except a member who is outside the applicant's family + (is_benefit_unit_non_dependant_for_council_tax_reduction; SI 2012/2885 + reg 9(2)(a)). - A family paying the householder as boarders or lodgers is never the head's, and the maintenance loan never has the head living with parents. 4. Metamorphic: every value the simulation computes, other than the @@ -395,7 +399,11 @@ def test_every_programme_reads_one_head(population): # counts the household's non-dependants: the head's family's wherever it # is liable for the rent and claims. counted = calc(sim, "uc_non_dependants_counted") - assert np.all(counted[calc(sim, "uc_non_dep_deductions") > 0]) + # A family's own in-unit non-dependants count in its own claim. + has_uc_in_unit = any_in_benunit( + calc(sim, "is_benefit_unit_non_dependant_for_universal_credit"), facts + ) + assert np.all((counted | has_uc_in_unit)[calc(sim, "uc_non_dep_deductions") > 0]) household_of_benunit = np.zeros(head_family.size, dtype=int) household_of_benunit[benunit_of] = facts["household"] assert np.all(sum_in_household(counted, household_of_benunit) <= 1) @@ -409,7 +417,16 @@ def test_every_programme_reads_one_head(population): in_household = head_claims_uc[household_of_benunit] assert np.array_equal(counted[in_household], head_family[in_household]) # Housing Benefit non-dependant deductions. - assert np.all(calc(sim, "housing_benefit_non_dep_deductions")[share == 0] == 0) + # A boarder or lodger bears a non-dependant in its own benefit unit. + has_legacy_in_unit = any_in_benunit( + calc(sim, "is_benefit_unit_non_dependant_for_legacy_benefits"), facts + ) + assert np.all( + calc(sim, "housing_benefit_non_dep_deductions")[ + (share == 0) & ~has_legacy_in_unit + ] + == 0 + ) # Council Tax Reduction. claimant = calc(sim, "council_tax_reduction_claimant_benunit") assert not np.any(claimant & ~head_family & ~sharer) @@ -418,7 +435,8 @@ def test_every_programme_reads_one_head(population): ctr_non_dependant = calc( sim, "council_tax_reduction_individual_non_dep_deduction_eligible" ) - assert not np.any(ctr_non_dependant & adult_head_family[benunit_of]) + ctr_in_unit = calc(sim, "is_benefit_unit_non_dependant_for_council_tax_reduction") + assert not np.any(ctr_non_dependant & adult_head_family[benunit_of] & ~ctr_in_unit) # Boarders and lodgers; the maintenance loan. assert not np.any(calc(sim, "pays_rent_to_householder") & person_head_family) arrangement = calc(sim, "maintenance_loan_living_arrangement") From 3efa8bd6082f9d7bf32d6351f21f1d729327b2e3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 02:06:54 -0400 Subject: [PATCH 14/14] Restate main's CTR claimant property for in-unit non-dependants The full Python suite at 2e37b1e76 failed test_claimant_invariants: it pinned that no member of a sharer family is a CTR non-dependant, but an 18-year-old presumed the 66-year-old sharer's child, not in education, is outside the sharer's family and a non-dependant (SI 2012/2885 reg 9(1), (2)(a)). The assertion now excludes is_benefit_unit_non_dependant_for_ council_tax_reduction members, and "no claimant or partner is a non-dependant" now reads #2078's applicant identity, so a head applying alone may have her benefit unit's couple as non-dependants. Co-Authored-By: Claude Opus 5.5 --- ...council_tax_reduction_claimant_properties.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py b/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py index 6424555e09..214ad859bb 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py @@ -33,10 +33,13 @@ 3. Simulated reductions: no family outside the claimants gets one, and a household's total never exceeds its council tax. (Reported reductions, used where a scheme is not modelled, are outside this test.) -4. Non-dependants: no claimant or partner of a claimant family is a - non-dependant (the applicant's family, SI 2012/2885 reg 9(2)(a)). Pin: no - member of a sharer family is one, and every adult outside the claimant - families, the sharer families and boarders' and lodgers' families is one. +4. Non-dependants: no applicant or partner of a claimant family + (is_council_tax_reduction_applicant_or_partner) is a non-dependant (the + applicant's family, SI 2012/2885 reg 9(2)(a)). Pin: no member of a sharer + family is one except a member outside its applicant's family + (is_benefit_unit_non_dependant_for_council_tax_reduction), and every + adult outside the claimant families, the sharer families and boarders' + and lodgers' families is one. 5. Differential: where the input flags exactly one member of a household, or no household in the simulation has the input (so the flag's formula picks the eldest), the family holding the head is the one holding the @@ -314,8 +317,10 @@ def test_claimant_invariants(population): person_sharer = sharer[facts["benunit"]] lodger = calc(sim, "pays_rent_to_householder") adult = age >= 18 - assert not np.any(non_dep & person_claimant & claimant_or_partner) - assert not np.any(non_dep & person_sharer) + applicant_or_partner = calc(sim, "is_council_tax_reduction_applicant_or_partner") + in_unit = calc(sim, "is_benefit_unit_non_dependant_for_council_tax_reduction") + assert not np.any(non_dep & person_claimant & applicant_or_partner) + assert not np.any(non_dep & person_sharer & ~in_unit) assert np.all(non_dep[adult & ~person_claimant & ~person_sharer & ~lodger]) # 5. Differential with the person-level household head flag. Once anyone in