From 3eee5f005a7e965b296b1ffabb20f90a1b3c5504 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 16:01:38 -0400 Subject: [PATCH 1/7] WIP: count a shared non-dependant in each joint tenant's HB size criteria Adds the non_dependant_normally_resides_with input (default: every joint occupier, LHA Guidance Manual 2.110; head-only or other joint occupiers for 2.093 example 2) and routes HB 13D(3) other occupiers through it. Still to do: 13D(2)(a)(i), 13D(3A), reg 74(5) and CTR para 8(5) apportionment, tests, impact. Co-Authored-By: Claude Opus 5.5 --- .../variables/gov/dwp/LHA_allowed_bedrooms.py | 14 ++- .../housing_benefit_LHA_allowed_bedrooms.py | 31 ++++- .../non_dependant_normally_resides_with.py | 115 ++++++++++++++++++ 3 files changed, 153 insertions(+), 7 deletions(-) create mode 100644 policyengine_uk/variables/household/consumption/rent/non_dependant_normally_resides_with.py diff --git a/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py b/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py index a89c371159..cd7a81e243 100644 --- a/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py +++ b/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py @@ -26,7 +26,9 @@ def rooms_for_children(boys_under_10, older_boys, girls_under_10, older_girls): return over_10_rooms + under_10_rooms -def bedrooms_for_children(benunit, period, own_child=None, other_child=None): +def bedrooms_for_children( + benunit, period, own_child=None, other_child=None, count_other=None +): """Bedrooms for the children under 16 in the family's size criteria. Children must share rooms in pairs unless they are opposite-sex and one @@ -37,7 +39,9 @@ def bedrooms_for_children(benunit, period, own_child=None, other_child=None): default, every member under 16). ``other_child`` marks people under 16 in other families of the household who count in the household head's family's size criteria, such as a non-dependant's child; they share rooms - with the head family's own children. + with the head family's own children. ``count_other``, if given, maps a + person mask to the number of those people counted for each family, in + place of counting them all for the household head's family. """ person = benunit.members age = person("age", period) @@ -53,8 +57,10 @@ def count(group): own = benunit.sum(own_child & group) if other_child is None: return own - others = person.household.sum(other_child & under_16 & group) - return own + head_family * benunit.max(others) + others = other_child & under_16 & group + if count_other is not None: + return own + count_other(others) + return own + head_family * benunit.max(person.household.sum(others)) return rooms_for_children( count(under_10 & male), 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 414d2d577c..b9afff8c6a 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 @@ -2,6 +2,29 @@ from policyengine_uk.variables.gov.dwp.LHA_allowed_bedrooms import ( bedrooms_for_children, ) +from policyengine_uk.variables.household.consumption.rent.non_dependant_normally_resides_with import ( + non_dependants_residing_with, +) + + +def housing_benefit_other_occupiers(benunit, period, other_occupier): + """For each family, the number of people marked by ``other_occupier`` + (occupiers outside every family liable for the household's rent) who + occupy its dwelling (HB Regs 2006 reg 13D(12)). A boarder or lodger, who + pays the householder, counts for the household head's family. A + non-dependant counts for each joint occupier they normally reside with + (LHA Guidance Manual paras 2.093 and 2.110; see + non_dependant_normally_resides_with).""" + person = benunit.members + boarder_or_lodger = person("pays_rent_to_householder", period) + head_family = benunit.any(person("is_household_head", period)) + of_householder = head_family * benunit.max( + person.household.sum(other_occupier & boarder_or_lodger) + ) + non_dependants = non_dependants_residing_with( + benunit, period, other_occupier & ~boarder_or_lodger + ) + return of_householder + non_dependants class housing_benefit_LHA_allowed_bedrooms(Variable): @@ -59,9 +82,8 @@ def formula(benunit, period, parameters): # everyone in a non-dependant's, boarder's or lodger's family, # including their children. other_occupier = ~head_family & ~sharer & occupier - is_head_family = benunit.any(person("is_household_head", period)) - other_occupiers = is_head_family * benunit.max( - person.household.sum(other_occupier & aged_16_or_over) + other_occupiers = housing_benefit_other_occupiers( + benunit, period, other_occupier & aged_16_or_over ) # Reg 13D(3)(c)-(e): the occupiers' children, the claimant's own and # other families', share rooms with each other. @@ -70,6 +92,9 @@ def formula(benunit, period, parameters): period, own_child=occupier, other_child=other_occupier, + count_other=lambda mask: housing_benefit_other_occupiers( + benunit, period, mask + ), ) # Reg 13D(3A) and (3B): additional bedrooms. additional = benunit("housing_benefit_LHA_additional_bedrooms", period) diff --git a/policyengine_uk/variables/household/consumption/rent/non_dependant_normally_resides_with.py b/policyengine_uk/variables/household/consumption/rent/non_dependant_normally_resides_with.py new file mode 100644 index 0000000000..7488f1ceff --- /dev/null +++ b/policyengine_uk/variables/household/consumption/rent/non_dependant_normally_resides_with.py @@ -0,0 +1,115 @@ +from policyengine_uk.model_api import * + + +class NonDependantResidence(Enum): + EVERY_JOINT_OCCUPIER = "Every family liable for the household's rent" + HOUSEHOLD_HEAD_FAMILY = "The household head's family only" + OTHER_JOINT_OCCUPIERS = ( + "The families sharing the household's rent with the household head's " + "family only" + ) + + +class non_dependant_normally_resides_with(Variable): + value_type = Enum + possible_values = NonDependantResidence + default_value = NonDependantResidence.EVERY_JOINT_OCCUPIER + entity = BenUnit + label = "Joint occupiers this family's non-dependants normally reside with" + documentation = ( + "For a family of non-dependants in a household whose rent is shared " + "by joint occupiers (joint tenants and other sharers of the rent), " + "which of those joint occupiers the family normally resides with for " + "Housing Benefit and Council Tax Reduction. This is a question of " + "fact. A non-dependant who lives with all of them, such as a friend " + "of two joint tenants, is each one's non-dependant: they count in " + "each joint tenant's size criteria and their deduction is apportioned " + "between them. One who lives with only one joint tenant, such as that " + "tenant's sister, counts only for that tenant, who bears the whole " + "deduction. The default is every joint occupier. With more than one " + "family sharing the rent with the household head's family, the third " + "value covers all of those families together. Where no family shares " + "the rent, every value means the household head's family. Universal " + "Credit does not use this input: a non-dependant counts in one Universal " + "Credit claim only, which the model gives to the household head's " + "family (see is_non_dependant_of_household_head)." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/regulation/3", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/13D", + "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://www.legislation.gov.uk/uksi/2012/2885/regulation/9", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/8", + "https://assets.publishing.service.gov.uk/media/5a758238ed915d6faf2b38a2/lha-guidance-manual.pdf#page=33", + ) + + +def _joint_occupiers_by_residence(benunit, period): + """Pairs of (residence value, whether each family is one of the joint + occupiers a non-dependant with that value normally resides with).""" + person = benunit.members + values = NonDependantResidence + head_family = benunit.any(person("is_household_head", period)) + sharer = benunit("liable_for_share_of_household_rent", period) & ~head_family + in_sharer_family = person.benunit( + "liable_for_share_of_household_rent", period + ) & ~person.benunit.any(person("is_household_head", period)) + rent_is_shared = benunit.any(person.household.any(in_sharer_family)) + return ( + (values.EVERY_JOINT_OCCUPIER, head_family | sharer), + (values.HOUSEHOLD_HEAD_FAMILY, head_family), + (values.OTHER_JOINT_OCCUPIERS, where(rent_is_shared, sharer, head_family)), + ) + + +def non_dependants_residing_with(benunit, period, non_dependant): + """For each family, the number of the household's people marked by + ``non_dependant`` (members of non-dependant families) who normally reside + with it under non_dependant_normally_resides_with.""" + person = benunit.members + residence = person.benunit("non_dependant_normally_resides_with", period) + count = 0 + for value, joint_occupier in _joint_occupiers_by_residence(benunit, period): + in_household = benunit.max( + person.household.sum(non_dependant & (residence == value)) + ) + count = count + joint_occupier * in_household + return count + + +def apportioned_non_dependant_deductions(benunit, period, deductions, equally): + """For each family, its part of the deductions for the household's + non-dependants (``deductions``, one amount per person and zero for anyone + who is not a non-dependant), each split between the joint occupiers the + non-dependant normally resides with. + + With ``equally`` false (Housing Benefit, reg 74(5)), each joint occupier's + part is its share of the rent among them: the people liable for the rent + in its family over those in all of them. With ``equally`` true (Council + Tax Reduction, SI 2012/2885 Sch 1 para 8(5)), each part is one over the + number of people liable among them, and the whole where only the family + is liable. + """ + person = benunit.members + residence = person.benunit("non_dependant_normally_resides_with", period) + liable = person("is_liable_for_household_rent", period) + liable_in_family = benunit.sum(liable) + total = 0 + for value, joint_occupier in _joint_occupiers_by_residence(benunit, period): + liable_in_joint_occupiers = benunit.max( + person.household.sum(liable & benunit.project(joint_occupier)) + ) + if equally: + part = where( + liable_in_joint_occupiers > liable_in_family, + 1 / max_(liable_in_joint_occupiers, 1), + 1, + ) + else: + part = liable_in_family / max_(liable_in_joint_occupiers, 1) + pool = benunit.max(person.household.sum(deductions * (residence == value))) + total = total + joint_occupier * part * pool + return total From 55fb4624a406d9ae0dea05ac5305885d4dd1ed9f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 01:24:16 -0400 Subject: [PATCH 2/7] Count a shared non-dependant for each joint tenant in HB and CTR A non-dependant who normally resides with several joint occupiers is each one's non-dependant (HB Regs 2006 reg 3; LHA Guidance Manual 2.110): they count in each joint tenant's size criteria (reg 13D(3), (3A)) and keep each from the young individual's shared accommodation rate (13D(2)(a)(i)), and their deduction is apportioned (reg 74(5); CTR Sch 1 para 8(5)). One who lives with only one joint tenant counts for that tenant alone, who bears the whole deduction (2.093 example 2; HBGM A5 5.622). The new BenUnit input non_dependant_normally_resides_with records which, by default every joint occupier. Universal Credit is unchanged (Sch 4 para 9(2)(f)). Co-Authored-By: Claude Opus 5.5 --- ...HA_joint_tenants_shared_non_dependant.yaml | 450 ++++++++++++++++++ ..._tenant_shared_non_dependant_properties.py | 303 ++++++++++++ .../housing_benefit_non_dep_deductions.py | 17 +- ...housing_benefit_LHA_additional_bedrooms.py | 16 +- .../gov/dwp/housing_benefit_LHA_category.py | 5 +- ...sing_benefit_claimant_has_non_dependant.py | 47 ++ .../dwp/is_non_dependant_of_household_head.py | 8 +- .../gov/dwp/lha_renter_has_non_dependant.py | 63 +-- .../council_tax_reduction/_legacy.py | 17 +- ...ouncil_tax_reduction_non_dep_deductions.py | 24 +- 10 files changed, 896 insertions(+), 54 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_joint_tenants_shared_non_dependant.yaml create mode 100644 policyengine_uk/tests/test_joint_tenant_shared_non_dependant_properties.py create mode 100644 policyengine_uk/variables/gov/dwp/housing_benefit_claimant_has_non_dependant.py diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_joint_tenants_shared_non_dependant.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_joint_tenants_shared_non_dependant.yaml new file mode 100644 index 0000000000..eae3d1f763 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_joint_tenants_shared_non_dependant.yaml @@ -0,0 +1,450 @@ +# Joint tenants and a non-dependant who lives with them. +# +# Housing Benefit (HB Regs 2006 and HB (SPC) Regs 2006): a non-dependant is +# anyone who "normally resides with a claimant" outside the claimant's family +# and the joint occupiers (reg 3(1), (2)(d)), and "resides with" requires +# sharing accommodation other than a bathroom, lavatory or communal area +# (reg 3(4)). Whose non-dependant someone is in a joint tenancy is a question +# of fact (JP v Bournemouth BC [2018] UKUT 75 (AAC), para 34). DWP: +# - LHA Guidance Manual (April 2014) para 2.110: "Joint tenants who have a +# shared non-dependant have the person counted for the size criteria, but +# only have an apportioned non-dependant deduction" (John, Alex and Peter: +# each tenant gets the two-bedroom rate; also para 2.123 example 4). +# - Para 2.093 example 2: Sarah's sister Susan lives with joint tenants Sarah +# and Rachel and "is treated as Sarah's non-dependant so Sarah is allowed the +# two bedroom LHA rate. Rachel is allowed the shared room rate." +# - HB Guidance Manual A5 para 5.622: "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." +# Reg 74(5) (SPC reg 55(5)) apportions the deduction for "a non-dependant in +# respect of more than one joint occupier" by the number of joint occupiers +# and their shares of the payments. Council Tax Reduction apportions it +# equally between the liable persons it is a non-dependant of (SI 2012/2885 +# Sch 1 para 8(5) and the Welsh and Scottish equivalents). +# +# The input non_dependant_normally_resides_with, on the non-dependant's +# family, says which joint occupiers they reside with; by default all of them. +# +# Universal Credit is different: a non-dependant already treated as one in +# another UC claim for the same accommodation is not one again (UC Sch 4 para +# 9(2)(f)), so they count in one claim only, which the model gives to the +# household head's family. +# +# Rent: £20,800 a year (£400 a week), split by the people liable (HB reg +# 12B(4)). Maidstone rates (VOA, April 2024, held for 2025-26): B £172.60, C +# £208.27 a week. The individual deductions are inputs, so the tests do not +# depend on the deduction rates. + +- name: Joint tenants with a shared non-dependant each count them (LHA Guidance Manual 2.110) + period: 2026 + absolute_error_margin: 0.01 + input: + people: + john: + age: 30 + is_household_head: true + alex: + age: 30 + peter: + age: 30 + household_benefits_individual_non_dep_deduction: 1_000 + council_tax_reduction_individual_non_dep_deduction: 300 + benunits: + john_family: + members: [john] + alex_family: + members: [alex] + liable_for_share_of_household_rent: true + peter_family: + members: [peter] + households: + home: + members: [john, alex, peter] + rent: 20_800 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + non_dependant_normally_resides_with: + [EVERY_JOINT_OCCUPIER, EVERY_JOINT_OCCUPIER, EVERY_JOINT_OCCUPIER] + # HB: Peter is an occupier (reg 13D(12)) and a non-dependant (reg 3) of + # each joint tenant: John and Alex are each entitled to 2 bedrooms, are + # not young individuals without a non-dependant (13D(2)(a)(i)), and get + # the two-bedroom rate (C). + housing_benefit_LHA_allowed_bedrooms: [2, 2, 1] + housing_benefit_claimant_has_non_dependant: [true, true, false] + housing_benefit_LHA_category: [C, C, A] + # Reg 74(5): equal shares of the rent, so half of the deduction each. + housing_benefit_non_dep_deductions: [500, 500, 0] + # CTR Sch 1 para 8(5): equally between the two liable persons. + council_tax_reduction_non_dep_deductions: [150, 150, 0] + # UC: Peter counts in John's claim only (Sch 4 para 9(2)(f)). + LHA_allowed_bedrooms: [2, 1, 1] + lha_renter_has_non_dependant: [true, false, false] + +- name: A joint tenant's sister counts only for that tenant (LHA Guidance Manual 2.093 example 2) + period: 2026 + absolute_error_margin: 0.01 + input: + people: + sarah: + age: 30 + is_household_head: true + is_male: false + rachel: + age: 30 + is_male: false + susan: + age: 28 + is_male: false + household_benefits_individual_non_dep_deduction: 1_000 + council_tax_reduction_individual_non_dep_deduction: 300 + benunits: + sarah_family: + members: [sarah] + rachel_family: + members: [rachel] + liable_for_share_of_household_rent: true + susan_family: + members: [susan] + non_dependant_normally_resides_with: HOUSEHOLD_HEAD_FAMILY + households: + home: + members: [sarah, rachel, susan] + rent: 20_800 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + # Sarah: herself and Susan, 2 bedrooms, the two-bedroom rate. Rachel: + # 1 bedroom, a young individual with no non-dependant (13D(2)(a)(i)), and + # she shares the dwelling's rooms in any case (13D(2)(a)(ii)): the shared + # accommodation rate. + housing_benefit_LHA_allowed_bedrooms: [2, 1, 1] + housing_benefit_claimant_has_non_dependant: [true, false, false] + housing_benefit_LHA_category: [C, A, A] + # Susan is a non-dependant of one joint occupier only, so reg 74(5) does + # not apply and Sarah bears the whole deduction (HBGM A5 para 5.622); so + # too for CTR, which apportions only a non-dependant of two or more + # liable persons. + housing_benefit_non_dep_deductions: [1_000, 0, 0] + council_tax_reduction_non_dep_deductions: [300, 0, 0] + +- name: The sister of a joint tenant who is not the household head counts only for her + period: 2026 + absolute_error_margin: 0.01 + input: + people: + rachel: + age: 31 + is_household_head: true + is_male: false + sarah: + age: 30 + is_male: false + susan: + age: 28 + is_male: false + household_benefits_individual_non_dep_deduction: 1_000 + council_tax_reduction_individual_non_dep_deduction: 300 + benunits: + rachel_family: + members: [rachel] + sarah_family: + members: [sarah] + liable_for_share_of_household_rent: true + susan_family: + members: [susan] + non_dependant_normally_resides_with: OTHER_JOINT_OCCUPIERS + households: + home: + members: [rachel, sarah, susan] + rent: 20_800 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + housing_benefit_LHA_allowed_bedrooms: [1, 2, 1] + housing_benefit_claimant_has_non_dependant: [false, true, false] + housing_benefit_LHA_category: [A, C, A] + housing_benefit_non_dep_deductions: [0, 1_000, 0] + council_tax_reduction_non_dep_deductions: [0, 300, 0] + # UC is unchanged: one claim only, the household head's. + LHA_allowed_bedrooms: [2, 1, 1] + +- name: Joint tenants sharing a kitchen and bathroom each get the shared room rate (LHA Guidance Manual 2.093 example 1) + period: 2026 + input: + people: + ali: + age: 40 + is_household_head: true + mustafa: + age: 40 + benunits: + ali_family: + members: [ali] + mustafa_family: + members: [mustafa] + liable_for_share_of_household_rent: true + households: + home: + members: [ali, mustafa] + rent: 20_800 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + housing_benefit_LHA_allowed_bedrooms: [1, 1] + housing_benefit_claimant_has_non_dependant: [false, false] + housing_benefit_LHA_category: [A, A] + +- name: A shared non-dependant is apportioned by the joint occupiers' shares of the rent + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + is_household_head: true + head_partner: + age: 44 + sharer_1: + age: 40 + sharer_2: + age: 41 + friend: + age: 38 + household_benefits_individual_non_dep_deduction: 1_000 + council_tax_reduction_individual_non_dep_deduction: 300 + benunits: + head_family: + members: [head, head_partner] + sharer_1_family: + members: [sharer_1] + liable_for_share_of_household_rent: true + sharer_2_family: + members: [sharer_2] + liable_for_share_of_household_rent: true + friend_family: + members: [friend] + households: + home: + members: [head, head_partner, sharer_1, sharer_2, friend] + rent: 20_800 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + # Four people liable: the couple's share is 2/4, each single sharer's + # 1/4 (reg 12B(4)), and the deduction follows (reg 74(5)). + share_of_household_rent: [0.5, 0.25, 0.25, 0] + housing_benefit_non_dep_deductions: [500, 250, 250, 0] + # CTR: equally between the four liable persons; the applicant couple's + # family bears one person's part, as for the council tax itself (see + # council_tax_reduction_joint_liability_share). + council_tax_reduction_non_dep_deductions: [75, 75, 75, 0] + # The friend counts in each joint occupier's size criteria. + housing_benefit_LHA_allowed_bedrooms: [2, 2, 2, 1] + +- name: A non-dependant of the joint occupiers other than the household head's family + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + is_household_head: true + head_partner: + age: 44 + sharer_1: + age: 40 + sharer_2: + age: 41 + friend: + age: 38 + household_benefits_individual_non_dep_deduction: 1_000 + council_tax_reduction_individual_non_dep_deduction: 300 + benunits: + head_family: + members: [head, head_partner] + sharer_1_family: + members: [sharer_1] + liable_for_share_of_household_rent: true + sharer_2_family: + members: [sharer_2] + liable_for_share_of_household_rent: true + friend_family: + members: [friend] + non_dependant_normally_resides_with: OTHER_JOINT_OCCUPIERS + households: + home: + members: [head, head_partner, sharer_1, sharer_2, friend] + rent: 20_800 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + # The friend lives with the two sharers only: each bears half the HB + # deduction (equal shares of the rent between them) and half the CTR + # deduction; the household head's couple bears none and does not count + # the friend. + housing_benefit_non_dep_deductions: [0, 500, 500, 0] + council_tax_reduction_non_dep_deductions: [0, 150, 150, 0] + housing_benefit_LHA_allowed_bedrooms: [1, 2, 2, 1] + +- name: Without joint occupiers every value means the household head's family + period: 2026 + absolute_error_margin: 0.01 + input: + people: + renter: + age: 55 + is_household_head: true + daughter: + age: 27 + household_benefits_individual_non_dep_deduction: 1_000 + council_tax_reduction_individual_non_dep_deduction: 300 + benunits: + renter_family: + members: [renter] + daughter_family: + members: [daughter] + non_dependant_normally_resides_with: OTHER_JOINT_OCCUPIERS + households: + home: + members: [renter, daughter] + rent: 20_800 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + housing_benefit_LHA_allowed_bedrooms: [2, 1] + housing_benefit_claimant_has_non_dependant: [true, false] + housing_benefit_non_dep_deductions: [1_000, 0] + council_tax_reduction_non_dep_deductions: [300, 0] + +- name: A shared non-dependant couple is one bedroom for each joint tenant + period: 2026 + input: + people: + john: + age: 30 + is_household_head: true + alex: + age: 30 + peter: + age: 30 + paula: + age: 29 + benunits: + john_family: + members: [john] + alex_family: + members: [alex] + liable_for_share_of_household_rent: true + peter_family: + members: [peter, paula] + households: + home: + members: [john, alex, peter, paula] + rent: 20_800 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + # HB reg 13D(3)(a): an occupier couple is one category, so one bedroom, + # in each joint tenant's size criteria. + housing_benefit_LHA_allowed_bedrooms: [2, 2, 1] + # UC: each non-dependant not a child has a bedroom (Sch 4 para + # 10(1)(c)), in the household head's claim only. + LHA_allowed_bedrooms: [3, 1, 1] + +- name: A shared non-dependant's child counts for each joint tenant + period: 2026 + input: + people: + john: + age: 30 + is_household_head: true + alex: + age: 30 + peter: + age: 30 + peter_child: + age: 5 + benunits: + john_family: + members: [john] + alex_family: + members: [alex] + liable_for_share_of_household_rent: true + peter_family: + members: [peter, peter_child] + households: + home: + members: [john, alex, peter, peter_child] + rent: 20_800 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + # Peter and his child occupy each joint tenant's dwelling (reg 13D(12)): + # 1 + Peter + the child (13D(3)(e)) = 3 bedrooms each. + housing_benefit_LHA_allowed_bedrooms: [3, 3, 2] + +- name: A lodger of the household head counts only for the household head + period: 2026 + input: + people: + john: + age: 40 + is_household_head: true + alex: + age: 40 + lodger: + age: 40 + rent_paid_as_lodger: 5_200 + benunits: + john_family: + members: [john] + alex_family: + members: [alex] + liable_for_share_of_household_rent: true + lodger_family: + members: [lodger] + households: + home: + members: [john, alex, lodger] + rent: 20_800 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + # A lodger, who pays the household head, is not a non-dependant (reg + # 3(2)(e)) and occupies the head's dwelling only. + housing_benefit_LHA_allowed_bedrooms: [2, 1, 1] + housing_benefit_claimant_has_non_dependant: [false, false, false] + +- name: A shared non-dependant who needs overnight care gives each joint tenant an additional bedroom + period: 2026 + input: + people: + john: + age: 40 + is_household_head: true + alex: + age: 40 + peter: + age: 40 + attendance_allowance: 5_000 + has_non_resident_overnight_carer: true + benunits: + john_family: + members: [john] + alex_family: + members: [alex] + liable_for_share_of_household_rent: true + peter_family: + members: [peter] + households: + home: + members: [john, alex, peter] + rent: 20_800 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + # HB reg 13D(3A)(a)(iii): a person who occupies the claimant's dwelling + # and requires overnight care. + housing_benefit_LHA_additional_bedrooms: [1, 1, 1] + housing_benefit_LHA_allowed_bedrooms: [3, 3, 2] diff --git a/policyengine_uk/tests/test_joint_tenant_shared_non_dependant_properties.py b/policyengine_uk/tests/test_joint_tenant_shared_non_dependant_properties.py new file mode 100644 index 0000000000..60bb0e3f77 --- /dev/null +++ b/policyengine_uk/tests/test_joint_tenant_shared_non_dependant_properties.py @@ -0,0 +1,303 @@ +"""Property-based tests for non-dependants of joint occupiers in Housing +Benefit and Council Tax Reduction (non_dependant_normally_resides_with). + +Invariants, for any generated population of households: + +1. Conservation (HB reg 74(5)): in every household, the families' Housing + Benefit non-dependant deductions sum to the household's individual + deductions for its non-dependants, whichever joint occupiers each one + resides with. +2. Bounds (CTR Sch 1 para 8(5)): each family's Council Tax Reduction part is + between zero and the household's deductions, and the parts sum to at most + the household's deductions (equal shares per liable person, with a couple + bearing one person's part). +3. Differential: with every non-dependant shared (the default), the Housing + Benefit deductions equal the previous formula (each family's share of the + rent times the household's deductions) and the Council Tax Reduction + deductions equal the previous formula (the deductions outside the family + times council_tax_reduction_joint_liability_share). +4. Attribution: a shared non-dependant counts in the household head's + family's size criteria exactly as when they are the head family's only, + and in a sharer's exactly as when they are the other joint occupiers' + only; a family that is not a joint occupier bears no deduction. +5. No-op: in a household with no sharers, the residence input changes + nothing. +6. Universal Credit does not depend on the residence input. +7. Monotonicity: marking a non-dependant as one joint occupier's only never + raises another joint occupier's bedrooms or deductions. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +PROPERTY_SETTINGS = settings( + max_examples=8, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +RESIDENCE = [ + "EVERY_JOINT_OCCUPIER", + "HOUSEHOLD_HEAD_FAMILY", + "OTHER_JOINT_OCCUPIERS", +] +income = st.floats(0, 60_000, allow_nan=False, allow_infinity=False) + + +@st.composite +def family(draw, max_adults=2): + return dict( + ages=draw(st.lists(st.integers(18, 70), min_size=1, max_size=max_adults)), + child_age=draw(st.one_of(st.none(), st.integers(0, 15))), + income=draw(income), + ) + + +@st.composite +def households(draw): + return dict( + head=draw(family()), + sharers=draw(st.lists(family(), min_size=0, max_size=2)), + non_dependants=draw( + st.lists( + st.tuples(family(), st.sampled_from(RESIDENCE)), + min_size=0, + max_size=2, + ) + ), + lodger=draw(st.booleans()), + rent=draw(st.floats(2_000, 30_000, allow_nan=False)), + ) + + +population = st.lists(households(), min_size=1, max_size=5) + + +def build(population, residence_override=None): + """One situation for the whole population, with each family's role.""" + people, benunits, homes, roles = {}, {}, {}, [] + for h, house in enumerate(population): + members = [] + + def add_family(name, fam, role, extra): + ids = [] + for i, age in enumerate(fam["ages"]): + pid = f"{name}_adult_{i}" + people[pid] = { + "age": {YEAR: age}, + "is_claimant_or_partner": {YEAR: True}, + "is_household_head": {YEAR: False}, + "employment_income": {YEAR: fam["income"] if i == 0 else 0}, + } + ids.append(pid) + if fam["child_age"] is not None: + pid = f"{name}_child" + people[pid] = { + "age": {YEAR: fam["child_age"]}, + "is_claimant_or_partner": {YEAR: False}, + "is_household_head": {YEAR: False}, + } + ids.append(pid) + benunits[name] = {"members": ids, **extra} + members.extend(ids) + roles.append(role) + return ids + + head_ids = add_family(f"h{h}_head", house["head"], "head", {}) + people[head_ids[0]]["is_household_head"] = {YEAR: True} + for f, sharer in enumerate(house["sharers"]): + add_family( + f"h{h}_s{f}", + sharer, + "sharer", + {"liable_for_share_of_household_rent": {YEAR: True}}, + ) + for f, (non_dep, residence) in enumerate(house["non_dependants"]): + add_family( + f"h{h}_n{f}", + non_dep, + "non_dependant", + { + "non_dependant_normally_resides_with": { + YEAR: residence_override or residence + } + }, + ) + if house["lodger"]: + ids = add_family( + f"h{h}_l", + dict(ages=[40], child_age=None, income=10_000), + "lodger", + {}, + ) + people[ids[0]]["rent_paid_as_lodger"] = {YEAR: 4_000} + homes[f"h{h}"] = { + "members": members, + "rent": {YEAR: house["rent"]}, + "tenure_type": {YEAR: "RENT_PRIVATELY"}, + "brma": {YEAR: "MAIDSTONE"}, + } + situation = {"people": people, "benunits": benunits, "households": homes} + return Simulation(situation=situation), np.array(roles) + + +def calc(sim, variable): + return np.asarray(sim.calculate(variable, YEAR), dtype=float) + + +def benunit_household(sim): + """Household index of each benefit unit.""" + person_benunit = sim.populations["benunit"].members_entity_id + person_household = sim.populations["household"].members_entity_id + out = np.zeros(sim.populations["benunit"].count, dtype=int) + out[person_benunit] = person_household + return out + + +def per_household(sim, benunit_values): + return np.bincount( + benunit_household(sim), + weights=benunit_values, + minlength=sim.populations["household"].count, + ) + + +def person_per_household(sim, person_values): + return np.bincount( + sim.populations["household"].members_entity_id, + weights=person_values, + minlength=sim.populations["household"].count, + ) + + +def has_sharer(sim, roles): + return ( + per_household(sim, (roles == "sharer").astype(float))[benunit_household(sim)] + > 0 + ) + + +@PROPERTY_SETTINGS +@given(population) +def test_housing_benefit_deductions_are_conserved(population): + sim, roles = build(population) + deductions = calc(sim, "household_benefits_individual_non_dep_deduction") * calc( + sim, "is_non_dependant_of_household_head" + ) + families = calc(sim, "housing_benefit_non_dep_deductions") + np.testing.assert_allclose( + per_household(sim, families), person_per_household(sim, deductions), atol=1e-6 + ) + # A family that is not a joint occupier bears none. + assert np.all(families[(roles == "non_dependant") | (roles == "lodger")] == 0) + + +@PROPERTY_SETTINGS +@given(population) +def test_council_tax_reduction_deductions_are_bounded(population): + sim, roles = build(population) + deductions = calc(sim, "council_tax_reduction_individual_non_dep_deduction") + in_household = person_per_household(sim, deductions) + families = calc(sim, "council_tax_reduction_non_dep_deductions") + assert np.all(families >= -1e-9) + assert np.all(families <= in_household[benunit_household(sim)] + 1e-6) + assert np.all(per_household(sim, families) <= in_household + 1e-6) + assert np.all(families[(roles == "non_dependant") | (roles == "lodger")] == 0) + + +@PROPERTY_SETTINGS +@given(population) +def test_shared_default_matches_the_previous_formulas(population): + sim, roles = build(population, residence_override="EVERY_JOINT_OCCUPIER") + household = benunit_household(sim) + hb_individual = calc(sim, "household_benefits_individual_non_dep_deduction") * calc( + sim, "is_non_dependant_of_household_head" + ) + share = calc(sim, "share_of_household_rent") + np.testing.assert_allclose( + calc(sim, "housing_benefit_non_dep_deductions"), + share * person_per_household(sim, hb_individual)[household], + atol=1e-6, + ) + ctr_individual = calc(sim, "council_tax_reduction_individual_non_dep_deduction") + own = np.bincount( + sim.populations["benunit"].members_entity_id, + weights=ctr_individual, + minlength=sim.populations["benunit"].count, + ) + previous = (person_per_household(sim, ctr_individual)[household] - own) * calc( + sim, "council_tax_reduction_joint_liability_share" + ) + joint_occupier = (roles == "head") | (roles == "sharer") + np.testing.assert_allclose( + calc(sim, "council_tax_reduction_non_dep_deductions")[joint_occupier], + previous[joint_occupier], + atol=1e-6, + ) + + +@PROPERTY_SETTINGS +@given(population) +def test_shared_counts_as_head_only_for_the_head_and_as_others_only_for_sharers( + population, +): + shared, roles = build(population, residence_override="EVERY_JOINT_OCCUPIER") + head_only, _ = build(population, residence_override="HOUSEHOLD_HEAD_FAMILY") + others_only, _ = build(population, residence_override="OTHER_JOINT_OCCUPIERS") + rooms = "housing_benefit_LHA_allowed_bedrooms" + head = roles == "head" + sharer = roles == "sharer" + np.testing.assert_array_equal( + calc(shared, rooms)[head], calc(head_only, rooms)[head] + ) + np.testing.assert_array_equal( + calc(shared, rooms)[sharer], calc(others_only, rooms)[sharer] + ) + # Monotonicity: one joint occupier's non-dependant never raises another's + # bedrooms or deductions. + deductions = "housing_benefit_non_dep_deductions" + assert np.all(calc(head_only, rooms)[sharer] <= calc(shared, rooms)[sharer]) + assert np.all( + calc(head_only, deductions)[sharer] <= calc(shared, deductions)[sharer] + 1e-6 + ) + with_sharer = has_sharer(shared, roles) + assert np.all( + calc(others_only, rooms)[head & with_sharer] + <= calc(shared, rooms)[head & with_sharer] + ) + assert np.all( + calc(others_only, deductions)[head & with_sharer] + <= calc(shared, deductions)[head & with_sharer] + 1e-6 + ) + + +@PROPERTY_SETTINGS +@given(population) +def test_residence_is_a_no_op_without_sharers_and_for_universal_credit(population): + sims = [build(population, residence_override=r) for r in RESIDENCE] + roles = sims[0][1] + without_sharer = ~has_sharer(sims[0][0], roles) + for variable in [ + "housing_benefit_LHA_allowed_bedrooms", + "housing_benefit_LHA_additional_bedrooms", + "housing_benefit_non_dep_deductions", + "council_tax_reduction_non_dep_deductions", + "housing_benefit_claimant_has_non_dependant", + ]: + reference = calc(sims[0][0], variable)[without_sharer] + for sim, _ in sims[1:]: + np.testing.assert_allclose( + calc(sim, variable)[without_sharer], reference, atol=1e-6 + ) + for variable in [ + "LHA_allowed_bedrooms", + "uc_non_dep_deductions", + "lha_renter_has_non_dependant", + ]: + reference = calc(sims[0][0], variable) + for sim, _ in sims[1:]: + np.testing.assert_allclose(calc(sim, variable), reference, atol=1e-6) 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..de9212436f 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 @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.household.consumption.rent.non_dependant_normally_resides_with import ( + apportioned_non_dependant_deductions, +) class housing_benefit_non_dep_deductions(Variable): @@ -11,6 +14,7 @@ class housing_benefit_non_dep_deductions(Variable): "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", + "https://assets.publishing.service.gov.uk/media/5a7ce2b840f0b6629523c64c/hbgm-a5-calculating-benefit.pdf", ) def formula(benunit, period, parameters): @@ -19,12 +23,15 @@ def formula(benunit, period, parameters): # 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. + # of the payments (reg 74(5); SPC reg 55(5)); one who resides with + # only one joint occupier is deducted in full from that occupier's + # award (HB Guidance Manual A5 para 5.622). Which joint occupiers a + # non-dependant resides with is non_dependant_normally_resides_with; + # a boarder or lodger bears none. person = benunit.members deductions = person( "household_benefits_individual_non_dep_deduction", period ) * person("is_non_dependant_of_household_head", period) - share = benunit("share_of_household_rent", period) - return share * benunit.max(person.household.sum(deductions)) + return apportioned_non_dependant_deductions( + benunit, period, deductions, equally=False + ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_additional_bedrooms.py b/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_additional_bedrooms.py index 5c9d55d1ee..ebd9bca24b 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_additional_bedrooms.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_additional_bedrooms.py @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.housing_benefit_LHA_allowed_bedrooms import ( + housing_benefit_other_occupiers, +) class housing_benefit_LHA_additional_bedrooms(Variable): @@ -11,10 +14,12 @@ class housing_benefit_LHA_additional_bedrooms(Variable): "meets_lha_overnight_care_condition), and one if the claimant or " "partner is a qualifying parent or carer (see " "is_housing_benefit_qualifying_parent_or_carer): two if both. " - "Occupiers outside the family count for the household head's family: " - "non-dependants, boarders and lodgers and their children, but not a " - "sharer of the rent or a child placed with them as a foster child or " - "for adoption. The definitions require a bedroom in the dwelling for " + "Occupiers outside the family count as in " + "housing_benefit_LHA_allowed_bedrooms: boarders and lodgers and their " + "children for the household head's family, and non-dependants and " + "their children for each joint occupier they normally reside with, " + "but not a sharer of the rent or a child placed with them as a foster " + "child or for adoption. The definitions require a bedroom in the dwelling for " "the carer or the foster child; the model does not observe the " "dwelling's bedrooms and assumes there is one. Children and couples " "who cannot share a bedroom because of disability are not modelled." @@ -37,8 +42,7 @@ def formula(benunit, period, parameters): sharer = person.benunit("liable_for_share_of_household_rent", period) placed = person("is_child_or_young_person_placed_with_family", period) other = overnight_care & ~head_family & ~sharer & ~placed - is_head_family = benunit.any(person("is_household_head", period)) - others = is_head_family * benunit.max(person.household.sum(other)) + others = housing_benefit_other_occupiers(benunit, period, other) # Reg 13D(3A)(a)(i)-(iv): the claimant, partner, other members of the # family, and a child or young person for whom the claimant or # partner is a qualifying parent or carer. diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_category.py b/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_category.py index 305cf304bf..16d12d7032 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_category.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_category.py @@ -8,7 +8,8 @@ class housing_benefit_LHA_category(Variable): label = "LHA category of dwelling (Housing Benefit)" documentation = ( "The Housing Benefit category of dwelling. The shared accommodation " - "rate applies to a young individual with no non-dependant, and to a " + "rate applies to a young individual with no non-dependant residing " + "with them (see housing_benefit_claimant_has_non_dependant), and to a " "claimant entitled to one bedroom who lacks exclusive use of " "self-contained accommodation, unless the severe disability premium " "applies. Otherwise the category follows the number of bedrooms in " @@ -30,7 +31,7 @@ def formula(benunit, period, parameters): # HB Regs 2006 reg 13D(2)(a)(i). young_individual = ( benunit("is_housing_benefit_young_individual", period) - & ~benunit("lha_renter_has_non_dependant", period) + & ~benunit("housing_benefit_claimant_has_non_dependant", period) & ~severe_disability ) # Reg 13D(2)(a)(ii): entitled to one bedroom but neither condition in diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit_claimant_has_non_dependant.py b/policyengine_uk/variables/gov/dwp/housing_benefit_claimant_has_non_dependant.py new file mode 100644 index 0000000000..b5ab95cddf --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/housing_benefit_claimant_has_non_dependant.py @@ -0,0 +1,47 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.lha_renter_has_non_dependant import ( + has_non_dependant_in_benefit_unit, +) +from policyengine_uk.variables.household.consumption.rent.non_dependant_normally_resides_with import ( + non_dependants_residing_with, +) + + +class housing_benefit_claimant_has_non_dependant(Variable): + value_type = bool + entity = BenUnit + label = "Housing Benefit claimant has a non-dependant residing with them" + documentation = ( + "Whether a non-dependant resides with the claimant, for the young " + "individual's shared accommodation rate: someone in the benefit unit " + "who is neither a claimant or partner nor a child or young person, or " + "a non-dependant of another family (see " + "is_non_dependant_of_household_head) who normally resides with this " + "family. In a household whose rent is shared, a non-dependant resides " + "with each joint occupier given by non_dependant_normally_resides_with " + "(by default all of them); otherwise with the household head's " + "family. Joint tenants and other sharers of the rent, boarders and " + "lodgers are not non-dependants." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/regulation/3", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/13D", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/21", + "https://assets.publishing.service.gov.uk/media/5a758238ed915d6faf2b38a2/lha-guidance-manual.pdf#page=33", + ) + + def formula(benunit, period, parameters): + person = benunit.members + within_benefit_unit = has_non_dependant_in_benefit_unit(benunit, period) + # HB Regs 2006 reg 3(1), (2)(d)-(e) and (4): a non-dependant of + # several joint occupiers resides with each of them (LHA Guidance + # Manual paras 2.050 and 2.110); one who lives with one joint tenant + # only resides with that tenant alone (para 2.093, example 2). + non_dependant_claimants = person("is_claimant_or_partner", period) & person( + "is_non_dependant_of_household_head", period + ) + other_families = non_dependants_residing_with( + benunit, period, non_dependant_claimants + ) + return within_benefit_unit | (other_families > 0) diff --git a/policyengine_uk/variables/gov/dwp/is_non_dependant_of_household_head.py b/policyengine_uk/variables/gov/dwp/is_non_dependant_of_household_head.py index 5042743e15..5a841b9768 100644 --- a/policyengine_uk/variables/gov/dwp/is_non_dependant_of_household_head.py +++ b/policyengine_uk/variables/gov/dwp/is_non_dependant_of_household_head.py @@ -13,9 +13,11 @@ class is_non_dependant_of_household_head(Variable): "of anyone. The household head and their household are not " "non-dependants of a boarder or lodger who pays them. Universal " "Credit counts a non-dependant in one claim only, which the model " - "gives to the household head's family; Housing Benefit and Council " - "Tax Reduction apportion a non-dependant of several joint occupiers " - "between them. A non-dependant's own children are non-dependants " + "gives to the household head's family. For Housing Benefit and " + "Council Tax Reduction a non-dependant is the non-dependant of each " + "joint occupier they normally reside with (see " + "non_dependant_normally_resides_with): they count in each one's size " + "criteria and their deduction is apportioned between them. A non-dependant's own children are non-dependants " "too; a child looked after by a local authority is not (see " "LHA_allowed_bedrooms). Carers engaged through a charity are not " "identified." diff --git a/policyengine_uk/variables/gov/dwp/lha_renter_has_non_dependant.py b/policyengine_uk/variables/gov/dwp/lha_renter_has_non_dependant.py index 5d0f6f3ce0..091e20739d 100644 --- a/policyengine_uk/variables/gov/dwp/lha_renter_has_non_dependant.py +++ b/policyengine_uk/variables/gov/dwp/lha_renter_has_non_dependant.py @@ -1,45 +1,54 @@ from policyengine_uk.model_api import * +def has_non_dependant_in_benefit_unit(benunit, period): + """Whether someone in the benefit unit is neither a claimant or partner + nor a child or young person, nor a child or young person placed with the + family as a foster child or for adoption.""" + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + # A child or young person under either scheme (UC reg 5; HB reg 19) + # is a dependant, not a non-dependant. + age = person("age", period) + # A foster child or a child placed for adoption is not a non-dependant + # either (UC Sch 4 para 9(2)(c) and (g); HB reg 3(2)(c) and reg 21(3)). + child_or_qyp = ( + person("is_child_or_qualifying_young_person_for_universal_credit", period) + | person("is_child_or_young_person_for_legacy_benefits", period) + | person("is_child_or_young_person_placed_with_family", period) + | ((age >= 16) & (age < 17)) + ) + return benunit.any(~claimant_or_partner & ~child_or_qyp) + + class lha_renter_has_non_dependant(Variable): value_type = bool entity = BenUnit - label = "LHA renter has a non-dependant" + label = "LHA renter has a non-dependant (Universal Credit)" documentation = ( - "Someone in this benefit unit who is neither a claimant or partner " - "nor a child or young person, or, for the household head's family, a " - "claimant or partner of another family who is a non-dependant of the " - "household head (see is_non_dependant_of_household_head): joint " - "tenants and other sharers of the rent, boarders and lodgers are not " - "non-dependants, and a sharer, boarder or lodger has none from the " - "household head's family. A child or young person placed with the " - "family as a foster child or for adoption is not a non-dependant." + "Universal Credit: someone in this benefit unit who is neither a " + "claimant or partner nor a child or young person, or, for the " + "household head's family, a claimant or partner of another family " + "who is a non-dependant of the household head (see " + "is_non_dependant_of_household_head): joint tenants and other " + "sharers of the rent, boarders and lodgers are not non-dependants, " + "and a non-dependant counts in one Universal Credit claim only, which " + "the model gives to the household head's family. A child or young " + "person placed with the family as a foster child or for adoption is " + "not a non-dependant. Housing Benefit has its own test: see " + "housing_benefit_claimant_has_non_dependant." ) definition_period = YEAR reference = ( "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/9", - "https://www.legislation.gov.uk/uksi/2006/213/regulation/3", - "https://www.legislation.gov.uk/uksi/2006/213/regulation/21", + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/28", ) def formula(benunit, period, parameters): person = benunit.members - claimant_or_partner = person("is_claimant_or_partner", period) - # A child or young person under either scheme (UC reg 5; HB reg 19) - # is a dependant, not a non-dependant. - age = person("age", period) - # A foster child or a child placed for adoption is not a - # non-dependant either (UC Sch 4 para 9(2)(c) and (g); HB reg 3(2)(c) - # and reg 21(3)). - child_or_qyp = ( - person("is_child_or_qualifying_young_person_for_universal_credit", period) - | person("is_child_or_young_person_for_legacy_benefits", period) - | person("is_child_or_young_person_placed_with_family", period) - | ((age >= 16) & (age < 17)) - ) - within_benefit_unit = benunit.any(~claimant_or_partner & ~child_or_qyp) - # UC Regs 2013 Sch 4 para 9(2)(d)-(f); HB Regs 2006 reg 3(2)(d)-(e). - non_dependant_claimants = claimant_or_partner & person( + within_benefit_unit = has_non_dependant_in_benefit_unit(benunit, period) + # UC Regs 2013 Sch 4 para 9(2)(d)-(f). + non_dependant_claimants = person("is_claimant_or_partner", period) & person( "is_non_dependant_of_household_head", period ) head_family = benunit.any(person("is_household_head", period)) 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..7efae3e8e3 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 @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.household.consumption.rent.non_dependant_normally_resides_with import ( + apportioned_non_dependant_deductions, +) from policyengine_uk.variables.household.demographic.highest_education import ( EducationType, ) @@ -85,13 +88,15 @@ def local_non_dep_deductions( ) is_benunit_head = benunit.members("is_benunit_head", period) deductions_to_count = is_benunit_head * benunit.project(deduction_for_benunit) - deductions_in_household = benunit.max( - benunit.members.household.sum(deductions_to_count) - ) # 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 - deduction_for_benunit) * share + # equally between them (SI 2012/2885 Sch 1 para 8(5)); one who resides + # with only one of them is deducted in full from that one. Deductions + # are only for families outside every family liable for the household's + # rent (council_tax_reduction_individual_non_dep_deduction_eligible), so + # a claimant is never charged its own. + return apportioned_non_dependant_deductions( + benunit, period, deductions_to_count, equally=True + ) def normal_gross_income_non_dep_deduction( 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..739283f64e 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 @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.household.consumption.rent.non_dependant_normally_resides_with import ( + apportioned_non_dependant_deductions, +) class council_tax_reduction_non_dep_deductions(Variable): @@ -7,14 +10,25 @@ class council_tax_reduction_non_dep_deductions(Variable): label = "CTR non-dependent deductions" 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", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/3", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/5", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/90", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/48", + ) def formula(benunit, period, parameters): 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 + # equally between them (SI 2012/2885 Sch 1 para 8(5); WSI 2013/3029 + # Sch 1 para 3(5) and Sch 6 para 5(5); SSI 2021/249 reg 90(5); SSI + # 2012/319 reg 48(5)); one who resides with only one of them is + # deducted in full from that one (see + # non_dependant_normally_resides_with). + return apportioned_non_dependant_deductions( + benunit, period, deductions, equally=True + ) From 921dca4d949d5cf77113e1937ea1d35da82c892b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 01:26:58 -0400 Subject: [PATCH 3/7] Changelog for #2029 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2029.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/2029.fixed.md diff --git a/changelog.d/2029.fixed.md b/changelog.d/2029.fixed.md new file mode 100644 index 0000000000..cd33b9672f --- /dev/null +++ b/changelog.d/2029.fixed.md @@ -0,0 +1 @@ +- Count a non-dependant who normally resides with several joint tenants in each one's Housing Benefit size criteria and shared accommodation test, apportioning the HB and Council Tax Reduction deductions only between the joint tenants they reside with (new input `non_dependant_normally_resides_with`). From 691d168e388dbb850ab67e6b43d4ba6d0599dc57 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 02:09:13 -0400 Subject: [PATCH 4/7] Keep the previous CTR non-dependant split where the rent is not shared Without sharers the CTR claimant is the family of the household's oldest adult (council_tax_reduction_claimant_benunit), which need not be the is_household_head family that the joint-occupier split targets; the first real enhanced FRS run moved CTR in fewer than 10 survey households. The joint-occupier apportionment (SI 2012/2885 Sch 1 para 8(5)) now applies only where the rent is shared; otherwise the claimant keeps every other family's deductions as before. Adds a Hypothesis differential for the no-sharer case and tightens the CTR bounds to shared households. Co-Authored-By: Claude Opus 5.5 --- ..._tenant_shared_non_dependant_properties.py | 41 ++++++++++++++++--- .../council_tax_reduction/_legacy.py | 41 +++++++++++++++---- ...ouncil_tax_reduction_non_dep_deductions.py | 14 ++----- 3 files changed, 72 insertions(+), 24 deletions(-) diff --git a/policyengine_uk/tests/test_joint_tenant_shared_non_dependant_properties.py b/policyengine_uk/tests/test_joint_tenant_shared_non_dependant_properties.py index 60bb0e3f77..fcbf4b328d 100644 --- a/policyengine_uk/tests/test_joint_tenant_shared_non_dependant_properties.py +++ b/policyengine_uk/tests/test_joint_tenant_shared_non_dependant_properties.py @@ -8,9 +8,11 @@ deductions for its non-dependants, whichever joint occupiers each one resides with. 2. Bounds (CTR Sch 1 para 8(5)): each family's Council Tax Reduction part is - between zero and the household's deductions, and the parts sum to at most - the household's deductions (equal shares per liable person, with a couple - bearing one person's part). + between zero and the household's deductions; where the rent is shared, + the joint occupiers' parts sum to at most the household's deductions + (equal shares per liable person, with a couple bearing one person's part) + and no other family bears any. Where it is not shared, every family's + deductions equal the previous formula (differential). 3. Differential: with every non-dependant shared (the default), the Housing Benefit deductions equal the previous formula (each family's share of the rent times the household's deductions) and the Council Tax Reduction @@ -51,7 +53,7 @@ @st.composite def family(draw, max_adults=2): return dict( - ages=draw(st.lists(st.integers(18, 70), min_size=1, max_size=max_adults)), + ages=draw(st.lists(st.integers(18, 90), min_size=1, max_size=max_adults)), child_age=draw(st.one_of(st.none(), st.integers(0, 15))), income=draw(income), ) @@ -205,8 +207,35 @@ def test_council_tax_reduction_deductions_are_bounded(population): families = calc(sim, "council_tax_reduction_non_dep_deductions") assert np.all(families >= -1e-9) assert np.all(families <= in_household[benunit_household(sim)] + 1e-6) - assert np.all(per_household(sim, families) <= in_household + 1e-6) - assert np.all(families[(roles == "non_dependant") | (roles == "lodger")] == 0) + # Where the rent is shared, the joint occupiers' parts never exceed the + # household's deductions, and no one else bears any. + shared = has_sharer(sim, roles) + joint_occupier = (roles == "head") | (roles == "sharer") + joint_parts = per_household(sim, families * (shared & joint_occupier)) + assert np.all(joint_parts <= in_household + 1e-6) + assert np.all(families[shared & ~joint_occupier] == 0) + + +@PROPERTY_SETTINGS +@given(population) +def test_council_tax_reduction_is_unchanged_without_sharers(population): + """Differential: where no family shares the rent, every family's CTR + non-dependant deductions equal the previous formula (the deductions of + every other family), whoever the oldest adult's claiming family is.""" + sim, roles = build(population) + deductions = calc(sim, "council_tax_reduction_individual_non_dep_deduction") + own = np.bincount( + sim.populations["benunit"].members_entity_id, + weights=deductions, + minlength=sim.populations["benunit"].count, + ) + previous = person_per_household(sim, deductions)[benunit_household(sim)] - own + without_sharer = ~has_sharer(sim, roles) + np.testing.assert_allclose( + calc(sim, "council_tax_reduction_non_dep_deductions")[without_sharer], + previous[without_sharer], + atol=1e-6, + ) @PROPERTY_SETTINGS 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 7efae3e8e3..80454efa6c 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 @@ -13,6 +13,37 @@ def is_full_time_student_non_dep(person, period): ) +def council_tax_reduction_joint_liability_non_dep_deductions( + benunit, period, deductions, own +): + """Each family's deductions for the household's non-dependants + (``deductions``, one amount per person; ``own``, the family's own amount). + + Where the household's rent is shared, a non-dependant of two or more + jointly liable people is apportioned equally between them (SI 2012/2885 + Sch 1 para 8(5); WSI 2013/3029 Sch 1 para 3(5) and Sch 6 para 5(5); SSI + 2021/249 reg 90(5); SSI 2012/319 reg 48(5)), and one who resides with + only one of them is deducted in full from that one (see + non_dependant_normally_resides_with). Otherwise the claimant, the family + of the household's oldest adult (see + council_tax_reduction_claimant_benunit), has every other family's + deductions, as before. + """ + person = benunit.members + rent_is_shared = benunit.any( + person.household.any( + person.benunit("liable_for_share_of_household_rent", period) + ) + ) + in_household = benunit.max(person.household.sum(deductions)) + share = benunit("council_tax_reduction_joint_liability_share", period) + sole = (in_household - own) * share + joint = apportioned_non_dependant_deductions( + benunit, period, deductions, equally=True + ) + return where(rent_is_shared, joint, sole) + + def legacy_council_tax_reduction( benunit, period, @@ -88,14 +119,8 @@ def local_non_dep_deductions( ) is_benunit_head = benunit.members("is_benunit_head", period) deductions_to_count = is_benunit_head * benunit.project(deduction_for_benunit) - # A non-dependant of two or more jointly liable people is apportioned - # equally between them (SI 2012/2885 Sch 1 para 8(5)); one who resides - # with only one of them is deducted in full from that one. Deductions - # are only for families outside every family liable for the household's - # rent (council_tax_reduction_individual_non_dep_deduction_eligible), so - # a claimant is never charged its own. - return apportioned_non_dependant_deductions( - benunit, period, deductions_to_count, equally=True + return council_tax_reduction_joint_liability_non_dep_deductions( + benunit, period, deductions_to_count, deduction_for_benunit ) 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 739283f64e..4ea0d2b3e8 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 @@ -1,6 +1,6 @@ from policyengine_uk.model_api import * -from policyengine_uk.variables.household.consumption.rent.non_dependant_normally_resides_with import ( - apportioned_non_dependant_deductions, +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( + council_tax_reduction_joint_liability_non_dep_deductions, ) @@ -23,12 +23,6 @@ def formula(benunit, period, parameters): deductions = benunit.members( "council_tax_reduction_individual_non_dep_deduction", period ) - # A non-dependant of two or more jointly liable people is apportioned - # equally between them (SI 2012/2885 Sch 1 para 8(5); WSI 2013/3029 - # Sch 1 para 3(5) and Sch 6 para 5(5); SSI 2021/249 reg 90(5); SSI - # 2012/319 reg 48(5)); one who resides with only one of them is - # deducted in full from that one (see - # non_dependant_normally_resides_with). - return apportioned_non_dependant_deductions( - benunit, period, deductions, equally=True + return council_tax_reduction_joint_liability_non_dep_deductions( + benunit, period, deductions, benunit.sum(deductions) ) From 69df794998d229ff2b81aa751756fca36781075d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 02:24:36 -0400 Subject: [PATCH 5/7] Compare money to the penny in the joint-tenant properties The model's amounts are float32, so household sums taken in different orders differ by up to about 1e-3; a 1e-6 bound failed on rounding alone. Co-Authored-By: Claude Opus 5.5 --- ..._tenant_shared_non_dependant_properties.py | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/policyengine_uk/tests/test_joint_tenant_shared_non_dependant_properties.py b/policyengine_uk/tests/test_joint_tenant_shared_non_dependant_properties.py index fcbf4b328d..b620fc6ed4 100644 --- a/policyengine_uk/tests/test_joint_tenant_shared_non_dependant_properties.py +++ b/policyengine_uk/tests/test_joint_tenant_shared_non_dependant_properties.py @@ -48,6 +48,9 @@ "OTHER_JOINT_OCCUPIERS", ] income = st.floats(0, 60_000, allow_nan=False, allow_infinity=False) +# Amounts are float32 in the model, so sums taken in different orders differ +# by up to about 1e-3 at these magnitudes: compare money to the penny. +MONEY_TOLERANCE = 0.01 @st.composite @@ -192,7 +195,9 @@ def test_housing_benefit_deductions_are_conserved(population): ) families = calc(sim, "housing_benefit_non_dep_deductions") np.testing.assert_allclose( - per_household(sim, families), person_per_household(sim, deductions), atol=1e-6 + per_household(sim, families), + person_per_household(sim, deductions), + atol=MONEY_TOLERANCE, ) # A family that is not a joint occupier bears none. assert np.all(families[(roles == "non_dependant") | (roles == "lodger")] == 0) @@ -205,14 +210,14 @@ def test_council_tax_reduction_deductions_are_bounded(population): deductions = calc(sim, "council_tax_reduction_individual_non_dep_deduction") in_household = person_per_household(sim, deductions) families = calc(sim, "council_tax_reduction_non_dep_deductions") - assert np.all(families >= -1e-9) - assert np.all(families <= in_household[benunit_household(sim)] + 1e-6) + assert np.all(families >= -MONEY_TOLERANCE) + assert np.all(families <= in_household[benunit_household(sim)] + MONEY_TOLERANCE) # Where the rent is shared, the joint occupiers' parts never exceed the # household's deductions, and no one else bears any. shared = has_sharer(sim, roles) joint_occupier = (roles == "head") | (roles == "sharer") joint_parts = per_household(sim, families * (shared & joint_occupier)) - assert np.all(joint_parts <= in_household + 1e-6) + assert np.all(joint_parts <= in_household + MONEY_TOLERANCE) assert np.all(families[shared & ~joint_occupier] == 0) @@ -234,7 +239,7 @@ def test_council_tax_reduction_is_unchanged_without_sharers(population): np.testing.assert_allclose( calc(sim, "council_tax_reduction_non_dep_deductions")[without_sharer], previous[without_sharer], - atol=1e-6, + atol=MONEY_TOLERANCE, ) @@ -250,7 +255,7 @@ def test_shared_default_matches_the_previous_formulas(population): np.testing.assert_allclose( calc(sim, "housing_benefit_non_dep_deductions"), share * person_per_household(sim, hb_individual)[household], - atol=1e-6, + atol=MONEY_TOLERANCE, ) ctr_individual = calc(sim, "council_tax_reduction_individual_non_dep_deduction") own = np.bincount( @@ -265,7 +270,7 @@ def test_shared_default_matches_the_previous_formulas(population): np.testing.assert_allclose( calc(sim, "council_tax_reduction_non_dep_deductions")[joint_occupier], previous[joint_occupier], - atol=1e-6, + atol=MONEY_TOLERANCE, ) @@ -291,7 +296,8 @@ def test_shared_counts_as_head_only_for_the_head_and_as_others_only_for_sharers( deductions = "housing_benefit_non_dep_deductions" assert np.all(calc(head_only, rooms)[sharer] <= calc(shared, rooms)[sharer]) assert np.all( - calc(head_only, deductions)[sharer] <= calc(shared, deductions)[sharer] + 1e-6 + calc(head_only, deductions)[sharer] + <= calc(shared, deductions)[sharer] + MONEY_TOLERANCE ) with_sharer = has_sharer(shared, roles) assert np.all( @@ -300,7 +306,7 @@ def test_shared_counts_as_head_only_for_the_head_and_as_others_only_for_sharers( ) assert np.all( calc(others_only, deductions)[head & with_sharer] - <= calc(shared, deductions)[head & with_sharer] + 1e-6 + <= calc(shared, deductions)[head & with_sharer] + MONEY_TOLERANCE ) @@ -320,7 +326,7 @@ def test_residence_is_a_no_op_without_sharers_and_for_universal_credit(populatio reference = calc(sims[0][0], variable)[without_sharer] for sim, _ in sims[1:]: np.testing.assert_allclose( - calc(sim, variable)[without_sharer], reference, atol=1e-6 + calc(sim, variable)[without_sharer], reference, atol=MONEY_TOLERANCE ) for variable in [ "LHA_allowed_bedrooms", @@ -329,4 +335,6 @@ def test_residence_is_a_no_op_without_sharers_and_for_universal_credit(populatio ]: reference = calc(sims[0][0], variable) for sim, _ in sims[1:]: - np.testing.assert_allclose(calc(sim, variable), reference, atol=1e-6) + np.testing.assert_allclose( + calc(sim, variable), reference, atol=MONEY_TOLERANCE + ) From 68647f9b6bb885b39e3e964dc05e25cbb108266b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 07:14:37 -0400 Subject: [PATCH 6/7] Address review r1: supplied rent shares, joint occupiers never non-dependants - A non-dependant of every joint occupier is split exactly as before by share_of_household_rent (HB) and council_tax_reduction_joint_liability_share (CTR), honouring supplied values; one of some joint occupiers is split by their supplied HB shares over the subset's total (reg 74(5)). - Members of the head's and sharers' families are never anyone's non-dependant through the other-family path, whatever their rent (HB reg 3(2)(a), (d); CTR reg 9(2)(a), (d)). - Documentation of the HB size criteria for sharers. - YAML regressions: supplied shares, subset shares, a zero-rent sharer, CTR without sharers (national and Merton), the CTR couple convention. - Properties cover zero rent and supplied shares; the generator supplies formula shares to every household when any household supplies them. Co-Authored-By: Claude Opus 5.5 --- ...HA_joint_tenants_shared_non_dependant.yaml | 181 ++++++++++++++++++ ..._tenant_shared_non_dependant_properties.py | 86 +++++++-- .../housing_benefit_non_dep_deductions.py | 6 +- .../housing_benefit_LHA_allowed_bedrooms.py | 8 +- .../council_tax_reduction/_legacy.py | 2 +- .../non_dependant_normally_resides_with.py | 72 +++++-- 6 files changed, 319 insertions(+), 36 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_joint_tenants_shared_non_dependant.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_joint_tenants_shared_non_dependant.yaml index eae3d1f763..c5c92795d4 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_joint_tenants_shared_non_dependant.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_joint_tenants_shared_non_dependant.yaml @@ -448,3 +448,184 @@ # and requires overnight care. housing_benefit_LHA_additional_bedrooms: [1, 1, 1] housing_benefit_LHA_allowed_bedrooms: [3, 3, 2] + +- name: Supplied rent shares set each joint tenant's part of a shared non-dependant's deduction + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + sharer: + age: 40 + friend: + age: 40 + household_benefits_individual_non_dep_deduction: 1_000 + benunits: + head_family: + members: [head] + share_of_household_rent: 0.75 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + share_of_household_rent: 0.25 + friend_family: + members: [friend] + share_of_household_rent: 0 + households: + home: + members: [head, sharer, friend] + rent: 20_800 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + # Reg 74(5): "having regard to ... the proportion of the payments in + # respect of the dwelling payable by each of them". + housing_benefit_non_dep_deductions: [750, 250, 0] + +- name: A non-dependant of some joint tenants is split by their supplied rent shares + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + sharer_1: + age: 40 + sharer_2: + age: 41 + friend: + age: 38 + household_benefits_individual_non_dep_deduction: 1_000 + benunits: + head_family: + members: [head] + share_of_household_rent: 0.5 + sharer_1_family: + members: [sharer_1] + liable_for_share_of_household_rent: true + share_of_household_rent: 0.3 + sharer_2_family: + members: [sharer_2] + liable_for_share_of_household_rent: true + share_of_household_rent: 0.2 + friend_family: + members: [friend] + share_of_household_rent: 0 + non_dependant_normally_resides_with: OTHER_JOINT_OCCUPIERS + households: + home: + members: [head, sharer_1, sharer_2, friend] + rent: 20_800 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + # 0.3 / (0.3 + 0.2) and 0.2 / (0.3 + 0.2) of the deduction. + housing_benefit_non_dep_deductions: [0, 600, 400, 0] + +- name: A sharer with no rent is never its own or the head's non-dependant + period: 2026 + input: + people: + head: + age: 30 + is_household_head: true + sharer: + age: 30 + benunits: + head_family: + members: [head] + housing_benefit_shares_accommodation: false + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + housing_benefit_shares_accommodation: false + households: + home: + members: [head, sharer] + rent: 0 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + # HB reg 3(2)(a), (d): neither joint occupier is a non-dependant of + # either, whatever the rent; so each 30-year-old is a young individual + # with no non-dependant (13D(2)(a)(i)). + housing_benefit_claimant_has_non_dependant: [false, false] + housing_benefit_LHA_allowed_bedrooms: [1, 1] + housing_benefit_LHA_category: [A, A] + +- name: Without sharers the CTR deductions keep the previous formula, national and local + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + council_tax_reduction_individual_non_dep_deduction: 0 + merton_council_tax_reduction_individual_non_dep_deduction: 0 + adult_1: + age: 40 + council_tax_reduction_individual_non_dep_deduction: 300 + merton_council_tax_reduction_individual_non_dep_deduction: 300 + adult_2: + age: 40 + council_tax_reduction_individual_non_dep_deduction: 200 + merton_council_tax_reduction_individual_non_dep_deduction: 200 + benunits: + head_family: + members: [head] + family_1: + members: [adult_1] + family_2: + members: [adult_2] + households: + home: + members: [head, adult_1, adult_2] + rent: 20_800 + tenure_type: RENT_PRIVATELY + output: + # Every family has every other family's deductions (only the claimant's + # value is used): 300 + 200, 200 and 300. + council_tax_reduction_non_dep_deductions: [500, 200, 300] + merton_council_tax_reduction_non_dep_deductions: [500, 200, 300] + +- name: A non-dependant of only a couple who share the rent with a sharer falls wholly on the couple's CTR claim + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + is_household_head: true + head_partner: + age: 44 + sharer: + age: 40 + relative: + age: 30 + council_tax_reduction_individual_non_dep_deduction: 300 + benunits: + head_family: + members: [head, head_partner] + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + relative_family: + members: [relative] + non_dependant_normally_resides_with: HOUSEHOLD_HEAD_FAMILY + households: + home: + members: [head, head_partner, sharer, relative] + rent: 20_800 + tenure_type: RENT_PRIVATELY + output: + # The model treats a couple as one claim here, as it does when no one + # else is liable: the relative resides with that family alone, so its + # claim bears the whole deduction. Where both partners are named joint + # tenants (liable under LGFA 1992 s.6, not only s.9), the literal Sch 1 + # para 8(5) would split it between the two of them; the model cannot + # tell how a couple is liable. + council_tax_reduction_non_dep_deductions: [300, 0, 0] diff --git a/policyengine_uk/tests/test_joint_tenant_shared_non_dependant_properties.py b/policyengine_uk/tests/test_joint_tenant_shared_non_dependant_properties.py index b620fc6ed4..6e082a4ce9 100644 --- a/policyengine_uk/tests/test_joint_tenant_shared_non_dependant_properties.py +++ b/policyengine_uk/tests/test_joint_tenant_shared_non_dependant_properties.py @@ -15,9 +15,12 @@ deductions equal the previous formula (differential). 3. Differential: with every non-dependant shared (the default), the Housing Benefit deductions equal the previous formula (each family's share of the - rent times the household's deductions) and the Council Tax Reduction - deductions equal the previous formula (the deductions outside the family - times council_tax_reduction_joint_liability_share). + rent, computed or supplied, times the household's deductions) and the + Council Tax Reduction deductions equal the previous formula (the + deductions outside the family times + council_tax_reduction_joint_liability_share), wherever the rent is + positive. With no rent a sharer is not rent-liable, and the previous + formulas wrongly counted its members as non-dependants. 4. Attribution: a shared non-dependant counts in the household head's family's size criteria exactly as when they are the head family's only, and in a sharer's exactly as when they are the other joint occupiers' @@ -30,7 +33,7 @@ """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, assume, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -75,7 +78,12 @@ def households(draw): ) ), lodger=draw(st.booleans()), - rent=draw(st.floats(2_000, 30_000, allow_nan=False)), + rent=draw(st.one_of(st.just(0.0), st.floats(2_000, 30_000, allow_nan=False))), + # Supplied rent-share weights for the head and up to two sharers + # (normalised over the families liable for the rent), or None. + share_weights=draw( + st.one_of(st.none(), st.lists(st.floats(0.05, 1), min_size=3, max_size=3)) + ), ) @@ -85,6 +93,7 @@ def households(draw): def build(population, residence_override=None): """One situation for the whole population, with each family's role.""" people, benunits, homes, roles = {}, {}, {}, [] + supplied = any(house["share_weights"] is not None for house in population) for h, house in enumerate(population): members = [] @@ -112,14 +121,37 @@ def add_family(name, fam, role, extra): roles.append(role) return ids - head_ids = add_family(f"h{h}_head", house["head"], "head", {}) + weights = house["share_weights"] + shares = [None] * (1 + len(house["sharers"])) + if weights is not None: + used = weights[: len(shares)] + shares = [w / sum(used) for w in used] + elif supplied: + # policyengine-core treats a variable given for some families as + # an input for all, defaulting the rest to zero, so supply the + # formula's shares (people liable over people liable in the + # household; every adult here is a claimant or partner). + adults = [len(house["head"]["ages"])] + [ + len(sharer["ages"]) for sharer in house["sharers"] + ] + shares = [a / sum(adults) for a in adults] + + def share_input(i): + if shares[i] is None: + return {} + return {"share_of_household_rent": {YEAR: shares[i]}} + + head_ids = add_family(f"h{h}_head", house["head"], "head", share_input(0)) people[head_ids[0]]["is_household_head"] = {YEAR: True} for f, sharer in enumerate(house["sharers"]): add_family( f"h{h}_s{f}", sharer, "sharer", - {"liable_for_share_of_household_rent": {YEAR: True}}, + { + "liable_for_share_of_household_rent": {YEAR: True}, + **share_input(f + 1), + }, ) for f, (non_dep, residence) in enumerate(house["non_dependants"]): add_family( @@ -129,7 +161,8 @@ def add_family(name, fam, role, extra): { "non_dependant_normally_resides_with": { YEAR: residence_override or residence - } + }, + **({"share_of_household_rent": {YEAR: 0.0}} if supplied else {}), }, ) if house["lodger"]: @@ -137,7 +170,7 @@ def add_family(name, fam, role, extra): f"h{h}_l", dict(ages=[40], child_age=None, income=10_000), "lodger", - {}, + {"share_of_household_rent": {YEAR: 0.0}} if supplied else {}, ) people[ids[0]]["rent_paid_as_lodger"] = {YEAR: 4_000} homes[f"h{h}"] = { @@ -179,6 +212,13 @@ def person_per_household(sim, person_values): ) +def in_joint_occupier_family(sim, roles): + """Whether each person belongs to the household head's or a sharer's + family: such people are never anyone's non-dependant.""" + person_role = roles[sim.populations["benunit"].members_entity_id] + return (person_role == "head") | (person_role == "sharer") + + def has_sharer(sim, roles): return ( per_household(sim, (roles == "sharer").astype(float))[benunit_household(sim)] @@ -190,8 +230,12 @@ def has_sharer(sim, roles): @given(population) def test_housing_benefit_deductions_are_conserved(population): sim, roles = build(population) - deductions = calc(sim, "household_benefits_individual_non_dep_deduction") * calc( - sim, "is_non_dependant_of_household_head" + # A sharer with no rent is not rent-liable, but is still a joint + # occupier and so no one's non-dependant (HB reg 3(2)(d)). + deductions = ( + calc(sim, "household_benefits_individual_non_dep_deduction") + * calc(sim, "is_non_dependant_of_household_head") + * ~in_joint_occupier_family(sim, roles) ) families = calc(sim, "housing_benefit_non_dep_deductions") np.testing.assert_allclose( @@ -252,11 +296,7 @@ def test_shared_default_matches_the_previous_formulas(population): sim, "is_non_dependant_of_household_head" ) share = calc(sim, "share_of_household_rent") - np.testing.assert_allclose( - calc(sim, "housing_benefit_non_dep_deductions"), - share * person_per_household(sim, hb_individual)[household], - atol=MONEY_TOLERANCE, - ) + previous_hb = share * person_per_household(sim, hb_individual)[household] ctr_individual = calc(sim, "council_tax_reduction_individual_non_dep_deduction") own = np.bincount( sim.populations["benunit"].members_entity_id, @@ -267,9 +307,17 @@ def test_shared_default_matches_the_previous_formulas(population): sim, "council_tax_reduction_joint_liability_share" ) joint_occupier = (roles == "head") | (roles == "sharer") + # With no rent, a sharer is not rent-liable, so the previous formulas + # counted its own members as non-dependants; that case is excluded. + positive_rent = np.array([house["rent"] > 0 for house in population])[household] + hb = calc(sim, "housing_benefit_non_dep_deductions") + np.testing.assert_allclose( + hb[positive_rent], previous_hb[positive_rent], atol=MONEY_TOLERANCE + ) + compared = joint_occupier & positive_rent np.testing.assert_allclose( - calc(sim, "council_tax_reduction_non_dep_deductions")[joint_occupier], - previous[joint_occupier], + calc(sim, "council_tax_reduction_non_dep_deductions")[compared], + previous[compared], atol=MONEY_TOLERANCE, ) @@ -279,6 +327,8 @@ def test_shared_default_matches_the_previous_formulas(population): def test_shared_counts_as_head_only_for_the_head_and_as_others_only_for_sharers( population, ): + # Only households with both a sharer and a non-dependant test anything. + assume(any(h["sharers"] and h["non_dependants"] for h in population)) shared, roles = build(population, residence_override="EVERY_JOINT_OCCUPIER") head_only, _ = build(population, residence_override="HOUSEHOLD_HEAD_FAMILY") others_only, _ = build(population, residence_override="OTHER_JOINT_OCCUPIERS") 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 de9212436f..58f47e83c9 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 @@ -33,5 +33,9 @@ def formula(benunit, period, parameters): "household_benefits_individual_non_dep_deduction", period ) * person("is_non_dependant_of_household_head", period) return apportioned_non_dependant_deductions( - benunit, period, deductions, equally=False + benunit, + period, + deductions, + equally=False, + every_joint_occupier_part=benunit("share_of_household_rent", 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 2ba4cb8061..3fbdba58d0 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 @@ -48,8 +48,12 @@ class housing_benefit_LHA_allowed_bedrooms(Variable): "non-dependant, boarder or " "lodger are occupiers too, and share rooms with the claimant's " "children. A child or young person placed with a family in the " - "household as a foster child or for adoption is not an occupier. A " - "sharer's, boarder's or lodger's own claim counts only their own " + "household as a foster child or for adoption is not an occupier. " + "Where the rent is shared, a non-dependant and their children count " + "for each joint occupier they normally reside with, by default every " + "one (see non_dependant_normally_resides_with), while a boarder or " + "lodger, who pays the household head, counts for the head's family " + "only. A boarder's or lodger's own claim counts only their own " "family." ) definition_period = YEAR 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 80454efa6c..9e24fa9e7b 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 @@ -39,7 +39,7 @@ def council_tax_reduction_joint_liability_non_dep_deductions( share = benunit("council_tax_reduction_joint_liability_share", period) sole = (in_household - own) * share joint = apportioned_non_dependant_deductions( - benunit, period, deductions, equally=True + benunit, period, deductions, equally=True, every_joint_occupier_part=share ) return where(rent_is_shared, joint, sole) diff --git a/policyengine_uk/variables/household/consumption/rent/non_dependant_normally_resides_with.py b/policyengine_uk/variables/household/consumption/rent/non_dependant_normally_resides_with.py index dd55e982c7..4df32b9c2d 100644 --- a/policyengine_uk/variables/household/consumption/rent/non_dependant_normally_resides_with.py +++ b/policyengine_uk/variables/household/consumption/rent/non_dependant_normally_resides_with.py @@ -65,52 +65,96 @@ def _joint_occupiers_by_residence(benunit, period): ) +def _outside_joint_occupiers(benunit, period): + """Whether each person is outside every family liable for the household's + rent (the household head's family and the sharers). A joint occupier is + never a non-dependant of itself or of another joint occupier (HB Regs + 2006 reg 3(2)(a), (d); SI 2012/2885 reg 9(2)(a), (d)), whatever its own + rent.""" + person = benunit.members + head_family = person.benunit.any(person("is_household_head", period)) + sharer = person.benunit("liable_for_share_of_household_rent", period) + return ~head_family & ~sharer + + +def _household_total_over_families(benunit, period, value, families): + """For each family, the household total of a family-level ``value`` over + the families marked by ``families``.""" + person = benunit.members + on_head = person("is_benunit_head", period) + return benunit.max( + person.household.sum(on_head * benunit.project(value * families)) + ) + + def non_dependants_residing_with(benunit, period, non_dependant): """For each family, the sum of ``non_dependant`` (a count or weight for each member of a non-dependant family, zero for anyone else) over the household's people who normally reside with it under - non_dependant_normally_resides_with.""" + non_dependant_normally_resides_with. Members of the joint occupiers' + families are never counted.""" person = benunit.members residence = person.benunit("non_dependant_normally_resides_with", period) + source = non_dependant * _outside_joint_occupiers(benunit, period) count = 0 for value, joint_occupier in _joint_occupiers_by_residence(benunit, period): - in_household = benunit.max( - person.household.sum(non_dependant * (residence == value)) - ) + in_household = benunit.max(person.household.sum(source * (residence == value))) count = count + joint_occupier * in_household return count -def apportioned_non_dependant_deductions(benunit, period, deductions, equally): +def apportioned_non_dependant_deductions( + benunit, period, deductions, equally, every_joint_occupier_part +): """For each family, its part of the deductions for the household's non-dependants (``deductions``, one amount per person and zero for anyone - who is not a non-dependant), each split between the joint occupiers the - non-dependant normally resides with. + who is not a non-dependant; members of the joint occupiers' families are + never counted), each split between the joint occupiers the non-dependant + normally resides with. + + For a non-dependant of every joint occupier, each family's part is + ``every_joint_occupier_part``: its share of the rent for Housing Benefit + (share_of_household_rent) and its joint liability share for Council Tax + Reduction (council_tax_reduction_joint_liability_share), as before. - With ``equally`` false (Housing Benefit, reg 74(5)), each joint occupier's - part is its share of the rent among them: the people liable for the rent - in its family over those in all of them. With ``equally`` true (Council - Tax Reduction, SI 2012/2885 Sch 1 para 8(5)), each part is one over the + For a non-dependant of some of them, with ``equally`` false (Housing + Benefit, reg 74(5)), each joint occupier's part is its share of the rent + over the shares of all of them, or its people liable for the rent over + theirs where those shares sum to zero; with ``equally`` true (Council Tax + Reduction, SI 2012/2885 Sch 1 para 8(5)), each part is one over the number of people liable among them, and the whole where only the family is liable. """ person = benunit.members residence = person.benunit("non_dependant_normally_resides_with", period) + source = deductions * _outside_joint_occupiers(benunit, period) liable = person("is_liable_for_household_rent", period) liable_in_family = benunit.sum(liable) + share = benunit("share_of_household_rent", period) + values = NonDependantResidence total = 0 for value, joint_occupier in _joint_occupiers_by_residence(benunit, period): liable_in_joint_occupiers = benunit.max( person.household.sum(liable & benunit.project(joint_occupier)) ) - if equally: + if value == values.EVERY_JOINT_OCCUPIER: + part = every_joint_occupier_part + elif equally: part = where( liable_in_joint_occupiers > liable_in_family, 1 / max_(liable_in_joint_occupiers, 1), 1, ) else: - part = liable_in_family / max_(liable_in_joint_occupiers, 1) - pool = benunit.max(person.household.sum(deductions * (residence == value))) + share_of_joint_occupiers = _household_total_over_families( + benunit, period, share, joint_occupier + ) + part = where( + share_of_joint_occupiers > 0, + share + / where(share_of_joint_occupiers > 0, share_of_joint_occupiers, 1), + liable_in_family / max_(liable_in_joint_occupiers, 1), + ) + pool = benunit.max(person.household.sum(source * (residence == value))) total = total + joint_occupier * part * pool return total From b435c91e2fee7f6834264f307e653905e5f14e15 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 09:01:31 -0400 Subject: [PATCH 7/7] Address review r2: head-only sharer flag, supplied shares without sharers - rent_shared_with_another_family: a sharer flag on the household head's own family shares the rent with no one; the residence helper and the CTR wrapper both use it, so the no-sharer CTR formula holds there. - Without another family sharing the rent, every residence value takes the caller's part, so supplied rent shares give one result. - Document the CTR convention: several jointly liable families each bear one person's part; a non-dependant's only host family, a couple included, bears the whole deduction (s.6 vs s.9 liability not observed). - Properties: supplied zero shares; the old-formula differential compares households where every joint occupier has rent of its own. - YAML regressions for both fixes. Co-Authored-By: Claude Opus 5.5 --- ...HA_joint_tenants_shared_non_dependant.yaml | 66 +++++++++++++++++++ ..._tenant_shared_non_dependant_properties.py | 39 +++++++---- .../council_tax_reduction/_legacy.py | 24 +++---- .../non_dependant_normally_resides_with.py | 29 ++++++-- 4 files changed, 129 insertions(+), 29 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_joint_tenants_shared_non_dependant.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_joint_tenants_shared_non_dependant.yaml index c5c92795d4..d7bfd00078 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_joint_tenants_shared_non_dependant.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_joint_tenants_shared_non_dependant.yaml @@ -629,3 +629,69 @@ # para 8(5) would split it between the two of them; the model cannot # tell how a couple is liable. council_tax_reduction_non_dep_deductions: [300, 0, 0] + +- name: A sharer flag on the household head's own family shares the rent with no one + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + council_tax_reduction_individual_non_dep_deduction: 0 + merton_council_tax_reduction_individual_non_dep_deduction: 0 + adult_1: + age: 40 + council_tax_reduction_individual_non_dep_deduction: 300 + merton_council_tax_reduction_individual_non_dep_deduction: 300 + adult_2: + age: 40 + council_tax_reduction_individual_non_dep_deduction: 200 + merton_council_tax_reduction_individual_non_dep_deduction: 200 + benunits: + head_family: + members: [head] + liable_for_share_of_household_rent: true + family_1: + members: [adult_1] + family_2: + members: [adult_2] + households: + home: + members: [head, adult_1, adult_2] + rent: 20_800 + tenure_type: RENT_PRIVATELY + output: + # The head's family is always liable; the flag matters only for other + # families, so this is the no-sharer case and the previous formula holds. + council_tax_reduction_non_dep_deductions: [500, 200, 300] + merton_council_tax_reduction_non_dep_deductions: [500, 200, 300] + +- name: Without sharers every residence value takes the supplied rent share + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + daughter: + age: 22 + household_benefits_individual_non_dep_deduction: 1_000 + benunits: + head_family: + members: [head] + share_of_household_rent: 0.6 + daughter_family: + members: [daughter] + share_of_household_rent: 0 + non_dependant_normally_resides_with: HOUSEHOLD_HEAD_FAMILY + households: + home: + members: [head, daughter] + rent: 20_800 + tenure_type: RENT_PRIVATELY + output: + # As for the default value: the household head's family's supplied + # share of the rent times the deduction. + housing_benefit_non_dep_deductions: [600, 0] diff --git a/policyengine_uk/tests/test_joint_tenant_shared_non_dependant_properties.py b/policyengine_uk/tests/test_joint_tenant_shared_non_dependant_properties.py index 6e082a4ce9..e87bf9c295 100644 --- a/policyengine_uk/tests/test_joint_tenant_shared_non_dependant_properties.py +++ b/policyengine_uk/tests/test_joint_tenant_shared_non_dependant_properties.py @@ -8,19 +8,23 @@ deductions for its non-dependants, whichever joint occupiers each one resides with. 2. Bounds (CTR Sch 1 para 8(5)): each family's Council Tax Reduction part is - between zero and the household's deductions; where the rent is shared, - the joint occupiers' parts sum to at most the household's deductions - (equal shares per liable person, with a couple bearing one person's part) - and no other family bears any. Where it is not shared, every family's - deductions equal the previous formula (differential). + between zero and the household's deductions; where another family shares + the rent, the joint occupiers' parts sum to at most the household's + deductions (a non-dependant of several jointly liable families is split + equally per liable person, each family bearing one person's part; one of + a single family, a couple included, is that family's whole deduction) + and no other family bears any. Where no other family shares the rent, + every family's deductions equal the previous formula (differential). 3. Differential: with every non-dependant shared (the default), the Housing Benefit deductions equal the previous formula (each family's share of the rent, computed or supplied, times the household's deductions) and the Council Tax Reduction deductions equal the previous formula (the deductions outside the family times - council_tax_reduction_joint_liability_share), wherever the rent is - positive. With no rent a sharer is not rent-liable, and the previous - formulas wrongly counted its members as non-dependants. + council_tax_reduction_joint_liability_share) in every household where the + previous formulas classified the joint occupiers correctly: where every + joint-occupier family has rent of its own. The previous rent-based flags + counted a joint occupier with no rent of its own (no household rent, or a + supplied zero share) as a non-dependant; that is the intended correction. 4. Attribution: a shared non-dependant counts in the household head's family's size criteria exactly as when they are the head family's only, and in a sharer's exactly as when they are the other joint occupiers' @@ -82,7 +86,14 @@ def households(draw): # Supplied rent-share weights for the head and up to two sharers # (normalised over the families liable for the rent), or None. share_weights=draw( - st.one_of(st.none(), st.lists(st.floats(0.05, 1), min_size=3, max_size=3)) + st.one_of( + st.none(), + st.lists( + st.one_of(st.just(0.0), st.floats(0.05, 1)), + min_size=3, + max_size=3, + ), + ) ), ) @@ -123,7 +134,7 @@ def add_family(name, fam, role, extra): weights = house["share_weights"] shares = [None] * (1 + len(house["sharers"])) - if weights is not None: + if weights is not None and sum(weights[: len(shares)]) > 0: used = weights[: len(shares)] shares = [w / sum(used) for w in used] elif supplied: @@ -307,9 +318,11 @@ def test_shared_default_matches_the_previous_formulas(population): sim, "council_tax_reduction_joint_liability_share" ) joint_occupier = (roles == "head") | (roles == "sharer") - # With no rent, a sharer is not rent-liable, so the previous formulas - # counted its own members as non-dependants; that case is excluded. - positive_rent = np.array([house["rent"] > 0 for house in population])[household] + # The previous formulas classified a joint occupier with no rent of its + # own (no household rent, or a supplied zero share) as a non-dependant; + # compare only households where every joint occupier has rent. + rentless = joint_occupier & (calc(sim, "benunit_rent") <= 0) + positive_rent = per_household(sim, rentless.astype(float))[household] == 0 hb = calc(sim, "housing_benefit_non_dep_deductions") np.testing.assert_allclose( hb[positive_rent], previous_hb[positive_rent], atol=MONEY_TOLERANCE 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 9e24fa9e7b..745c4f3975 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 @@ -1,6 +1,7 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.household.consumption.rent.non_dependant_normally_resides_with import ( apportioned_non_dependant_deductions, + rent_shared_with_another_family, ) from policyengine_uk.variables.household.demographic.highest_education import ( EducationType, @@ -19,22 +20,23 @@ def council_tax_reduction_joint_liability_non_dep_deductions( """Each family's deductions for the household's non-dependants (``deductions``, one amount per person; ``own``, the family's own amount). - Where the household's rent is shared, a non-dependant of two or more - jointly liable people is apportioned equally between them (SI 2012/2885 - Sch 1 para 8(5); WSI 2013/3029 Sch 1 para 3(5) and Sch 6 para 5(5); SSI - 2021/249 reg 90(5); SSI 2012/319 reg 48(5)), and one who resides with - only one of them is deducted in full from that one (see - non_dependant_normally_resides_with). Otherwise the claimant, the family + Where a family other than the household head's shares the rent, a + non-dependant of two or more jointly liable families is apportioned + equally between their liable people (SI 2012/2885 Sch 1 para 8(5); WSI + 2013/3029 Sch 1 para 3(5) and Sch 6 para 5(5); SSI 2021/249 reg 90(5); + SSI 2012/319 reg 48(5)), each family bearing one person's part as for + the council tax itself (council_tax_reduction_joint_liability_share). + One who resides with only one of them is deducted in full from that + family, a couple included: the model cannot tell whether both partners + are named tenants liable under LGFA 1992 s.6 (when the literal para 8(5) + would split it between them) or liable only as spouses (s.9). See + non_dependant_normally_resides_with. Otherwise the claimant, the family of the household's oldest adult (see council_tax_reduction_claimant_benunit), has every other family's deductions, as before. """ person = benunit.members - rent_is_shared = benunit.any( - person.household.any( - person.benunit("liable_for_share_of_household_rent", period) - ) - ) + rent_is_shared = rent_shared_with_another_family(benunit, period) in_household = benunit.max(person.household.sum(deductions)) share = benunit("council_tax_reduction_joint_liability_share", period) sole = (in_household - own) * share diff --git a/policyengine_uk/variables/household/consumption/rent/non_dependant_normally_resides_with.py b/policyengine_uk/variables/household/consumption/rent/non_dependant_normally_resides_with.py index 4df32b9c2d..fadc43cb8c 100644 --- a/policyengine_uk/variables/household/consumption/rent/non_dependant_normally_resides_with.py +++ b/policyengine_uk/variables/household/consumption/rent/non_dependant_normally_resides_with.py @@ -47,6 +47,17 @@ class non_dependant_normally_resides_with(Variable): ) +def rent_shared_with_another_family(benunit, period): + """Whether a family other than the household head's shares the + household's rent (liable_for_share_of_household_rent). The head's family + is always liable, so the flag on the head's own family means nothing.""" + person = benunit.members + in_sharer_family = person.benunit( + "liable_for_share_of_household_rent", period + ) & ~person.benunit.any(person("is_household_head", period)) + return benunit.any(person.household.any(in_sharer_family)) + + def _joint_occupiers_by_residence(benunit, period): """Pairs of (residence value, whether each family is one of the joint occupiers a non-dependant with that value normally resides with).""" @@ -54,10 +65,7 @@ def _joint_occupiers_by_residence(benunit, period): values = NonDependantResidence head_family = benunit.any(person("is_household_head", period)) sharer = benunit("liable_for_share_of_household_rent", period) & ~head_family - in_sharer_family = person.benunit( - "liable_for_share_of_household_rent", period - ) & ~person.benunit.any(person("is_household_head", period)) - rent_is_shared = benunit.any(person.household.any(in_sharer_family)) + rent_is_shared = rent_shared_with_another_family(benunit, period) return ( (values.EVERY_JOINT_OCCUPIER, head_family | sharer), (values.HOUSEHOLD_HEAD_FAMILY, head_family), @@ -123,11 +131,21 @@ def apportioned_non_dependant_deductions( theirs where those shares sum to zero; with ``equally`` true (Council Tax Reduction, SI 2012/2885 Sch 1 para 8(5)), each part is one over the number of people liable among them, and the whole where only the family - is liable. + is liable. A family that is a non-dependant's only host, a couple + included, bears the whole deduction: the model cannot tell whether both + partners are named tenants liable under LGFA 1992 s.6 (when the literal + para 8(5) would split it between them) or liable only as spouses (s.9). + + Where no family other than the household head's shares the rent, every + residence value means the head's family alone, which takes + ``every_joint_occupier_part``. """ person = benunit.members residence = person.benunit("non_dependant_normally_resides_with", period) source = deductions * _outside_joint_occupiers(benunit, period) + # Without another family sharing the rent, every value means the + # household head's family alone, so each takes the caller's part. + rent_is_shared = rent_shared_with_another_family(benunit, period) liable = person("is_liable_for_household_rent", period) liable_in_family = benunit.sum(liable) share = benunit("share_of_household_rent", period) @@ -155,6 +173,7 @@ def apportioned_non_dependant_deductions( / where(share_of_joint_occupiers > 0, share_of_joint_occupiers, 1), liable_in_family / max_(liable_in_joint_occupiers, 1), ) + part = where(rent_is_shared, part, every_joint_occupier_part) pool = benunit.max(person.household.sum(source * (residence == value))) total = total + joint_occupier * part * pool return total