From e9b1185ee9f6eb37b1686a10f92e94033392c79b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 13:27:56 -0400 Subject: [PATCH 1/6] Give Council Tax Reduction to the household head's family, not the oldest adult's The claimant family was the one whose oldest member was the household's oldest adult. Families tied for oldest were both flagged, and every CTR scheme (England pensioners, Scotland, Wales, the four London schemes and Oxford) then paid each of them a full award: two single 85-year-olds in one household with no income got twice the council tax. The same flag left both out of each other's non-dependant deductions. Only a person liable to pay the council tax is in a class entitled to a reduction (SI 2012/2885 Sch 1 paras 2(a), 3(a); SI 2013/3029 regs 22(a) to 25(a); SSI 2021/249 reg 13(3)(a); SSI 2012/319 reg 14(3)(a)). Liability goes to the resident with the greatest interest in the dwelling (LGFA 1992 s.6, s.75), not the eldest. The FRS household reference person is a householder, in whose name the accommodation is owned or rented, which the data carries as is_household_head. So the claimant family is now the head's, plus any family liable for a share of the rent (jointly liable, s.6(3), s.75(3)). - benunit_contains_household_head: the family holding the household head. Exactly one per household: with several flagged, the eldest flagged; with none, the eldest member. - council_tax_reduction_claimant_benunit: head's family or a sharer family. - council_tax_reduction_joint_liability_share: counts the claimants and partners of the claimant families as jointly liable. - council_tax_reduction_individual_non_dep_deduction_eligible: no member of a claimant family is a non-dependant (reg 9(2)(d)). Tests: 12 YAML cases (each scheme, ties, a parent in their child's home, malformed head flags) and Hypothesis properties (one head per household, at most one claimant family unless the rent is shared, no reduction outside claimant families or above the council tax, non-dependants, a differential with the person-level head, age invariance, and the previous rule where the head is the eldest). Co-Authored-By: Claude Opus 5.5 --- .../ctr-claimant-household-head.fixed.md | 1 + .../claimant_household_head.yaml | 400 ++++++++++++++++++ ...uncil_tax_reduction_claimant_properties.py | 293 +++++++++++++ ...sharers_boarders_and_lodgers_properties.py | 6 +- .../benunit_contains_household_head.py | 29 +- .../council_tax_reduction_claimant_benunit.py | 35 +- ...n_individual_non_dep_deduction_eligible.py | 2 +- ...cil_tax_reduction_joint_liability_share.py | 16 +- 8 files changed, 752 insertions(+), 30 deletions(-) create mode 100644 changelog.d/ctr-claimant-household-head.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml create mode 100644 policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py diff --git a/changelog.d/ctr-claimant-household-head.fixed.md b/changelog.d/ctr-claimant-household-head.fixed.md new file mode 100644 index 0000000000..9ec474fa33 --- /dev/null +++ b/changelog.d/ctr-claimant-household-head.fixed.md @@ -0,0 +1 @@ +Give Council Tax Reduction to the household head's family, the liable resident, instead of the oldest adult's, so families tied for oldest no longer both get a full award. diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml new file mode 100644 index 0000000000..e41a7d5eb3 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml @@ -0,0 +1,400 @@ +# Who claims Council Tax Reduction in a household of several families. +# Only a person liable to pay the council tax is in a class entitled to a +# reduction: England SI 2012/2885 Sch 1 paras 2(a) and 3(a); Wales SI +# 2013/3029 regs 22(a) to 25(a); Scotland SSI 2021/249 reg 13(1) and (3)(a) +# and SSI 2012/319 reg 14(1) and (3)(a). The liable resident is the one with +# the greatest interest in the dwelling (Local Government Finance Act 1992 +# s.6 in England and Wales, s.75 in Scotland), not the eldest. The model +# takes the household head (the household reference person, a householder) +# as that person, so the head's family claims and every other adult is a +# non-dependant (SI 2012/2885 reg 9; SI 2013/3029 reg 9; SSI 2021/249 reg 8). +# Joint liability where the rent is shared: joint_liability.yaml. + +- name: Two single pensioners of the same age in England - only the head's family claims + period: 2026 + absolute_error_margin: 0.01 + input: + people: + friend: + age: 85 + is_household_head: false + council_tax_reduction_individual_non_dep_deduction: 0 + householder: + age: 85 + is_household_head: true + benunits: + friend_family: + members: [friend] + council_tax_reduction_applicable_income: 0 + householder_family: + members: [householder] + council_tax_reduction_applicable_income: 0 + households: + home: + members: [friend, householder] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + benunit_contains_household_head: [false, true] + council_tax_reduction_claimant_benunit: [false, true] + council_tax_reduction_individual_non_dep_deduction_eligible: [true, false] + simulated_council_tax_reduction_benunit: [0, 1_800] + # One award of the council tax, not two. + council_tax_reduction: 1_800 + +- name: Two single pensioners of the same age in Scotland with no head flagged - one family claims + period: 2026 + absolute_error_margin: 0.01 + input: + people: + first: + age: 85 + council_tax_reduction_individual_non_dep_deduction: 0 + second: + age: 85 + council_tax_reduction_individual_non_dep_deduction: 0 + benunits: + first_family: + members: [first] + council_tax_reduction_applicable_income: 0 + second_family: + members: [second] + council_tax_reduction_applicable_income: 0 + households: + home: + members: [first, second] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + # With no input, the eldest member is the head, ties by order. + is_household_head: [true, false] + council_tax_reduction_claimant_benunit: [true, false] + simulated_council_tax_reduction_benunit: [1_800, 0] + council_tax_reduction: 1_800 + +- name: Two single pensioners of the same age in Wales - only the head's family claims + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 85 + is_household_head: true + friend: + age: 85 + is_household_head: false + council_tax_reduction_individual_non_dep_deduction: 0 + benunits: + householder_family: + members: [householder] + council_tax_reduction_applicable_income: 0 + friend_family: + members: [friend] + council_tax_reduction_applicable_income: 0 + households: + home: + members: [householder, friend] + country: WALES + local_authority: CARDIFF + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_claimant_benunit: [true, false] + simulated_council_tax_reduction_benunit: [1_800, 0] + council_tax_reduction: 1_800 + +- name: Two working-age adults of the same age in Newham - the other adult is a non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + friend: + age: 40 + is_household_head: false + householder: + age: 40 + is_household_head: true + benunits: + friend_family: + members: [friend] + would_claim_uc: false + claims_all_entitled_benefits: true + householder_family: + members: [householder] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + home: + members: [friend, householder] + country: ENGLAND + local_authority: NEWHAM + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_claimant_benunit: [false, true] + # The friend, not in remunerative work: 7.54 a week x 52 = 392.08. + newham_council_tax_reduction_non_dep_deductions: [0, 392.08] + # 1,800 x 70% - 392.08 = 867.92, once. + newham_council_tax_reduction: [0, 867.92] + council_tax_reduction: 867.92 + +- name: Two working-age adults of the same age in Merton - only the head's family claims + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 40 + is_household_head: true + friend: + age: 40 + is_household_head: false + merton_council_tax_reduction_individual_non_dep_deduction: 0 + benunits: + householder_family: + members: [householder] + would_claim_uc: false + claims_all_entitled_benefits: true + friend_family: + members: [friend] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + home: + members: [householder, friend] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + merton_council_tax_reduction: [1_800, 0] + council_tax_reduction: 1_800 + +- name: Two working-age adults of the same age in Kingston upon Thames - only the head's family claims + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 40 + is_household_head: true + friend: + age: 40 + is_household_head: false + kingston_upon_thames_council_tax_reduction_individual_non_dep_deduction: 0 + benunits: + householder_family: + members: [householder] + would_claim_uc: false + claims_all_entitled_benefits: true + friend_family: + members: [friend] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + home: + members: [householder, friend] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + council_tax: 1_800 + savings: 0 + output: + kingston_upon_thames_council_tax_reduction: [1_800, 0] + council_tax_reduction: 1_800 + +- name: Two working-age adults of the same age in Westminster - only the head's family claims + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 40 + is_household_head: true + friend: + age: 40 + is_household_head: false + westminster_council_tax_reduction_individual_non_dep_deduction: 0 + benunits: + householder_family: + members: [householder] + would_claim_uc: false + claims_all_entitled_benefits: true + friend_family: + members: [friend] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + home: + members: [householder, friend] + country: ENGLAND + local_authority: WESTMINSTER + council_tax: 1_800 + savings: 0 + output: + westminster_council_tax_reduction: [1_800, 0] + council_tax_reduction: 1_800 + +- name: Two working-age adults of the same age in Oxford - the other adult is a non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 40 + is_household_head: true + friend: + age: 40 + is_household_head: false + benunits: + householder_family: + members: [householder] + would_claim_uc: false + claims_all_entitled_benefits: true + friend_family: + members: [friend] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + home: + members: [householder, friend] + country: ENGLAND + local_authority: OXFORD + council_tax: 1_800 + savings: 0 + output: + # The friend, not in remunerative work: 5.20 a week x 52 = 270.40. + oxford_council_tax_reduction_non_dep_deductions: [270.4, 0] + oxford_council_tax_reduction: [1_529.6, 0] + council_tax_reduction: 1_529.6 + +- name: A pensioner living in their adult child's home in Scotland is a non-dependant, not the claimant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 45 + is_household_head: true + parent: + age: 80 + is_household_head: false + council_tax_reduction_individual_non_dep_deduction: 200 + benunits: + householder_family: + members: [householder] + council_tax_reduction_applicable_income: 0 + parent_family: + members: [parent] + council_tax_reduction_applicable_income: 0 + households: + home: + members: [householder, parent] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + # The eldest adult is not the liable resident; the head is. + benunit_contains_household_head: [true, false] + council_tax_reduction_claimant_benunit: [true, false] + council_tax_reduction_household_has_pensioner: false + council_tax_reduction_individual_non_dep_deduction_eligible: [false, true] + council_tax_reduction_non_dep_deductions: [200, 0] + simulated_council_tax_reduction_benunit: [1_600, 0] + council_tax_reduction: 1_600 + +- name: A pensioner in their working-age child's home in England does not put the household on the pensioner scheme + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 45 + is_household_head: true + parent: + age: 80 + is_household_head: false + attendance_allowance: 5_000 + benunits: + householder_family: + members: [householder] + would_claim_uc: false + parent_family: + members: [parent] + households: + home: + members: [householder, parent] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + # The claimant's family is working-age; Maidstone's working-age scheme is + # not modelled, so no simulated reduction. + council_tax_reduction_household_has_pensioner: false + council_tax_reduction_scheme_supported: false + simulated_council_tax_reduction_benunit: [0, 0] + # A non-dependant's Attendance Allowance does not exempt the claimant + # from non-dependant deductions (SI 2012/2885 Sch 1 para 8(6)). + council_tax_reduction_household_has_non_dep_exemption: false + +- name: With two members flagged as head, the eldest flagged member's family claims + period: 2026 + absolute_error_margin: 0.01 + input: + people: + younger_flagged: + age: 60 + is_household_head: true + eldest_unflagged: + age: 90 + is_household_head: false + older_flagged: + age: 70 + is_household_head: true + benunits: + younger_family: + members: [younger_flagged] + eldest_family: + members: [eldest_unflagged] + older_family: + members: [older_flagged] + households: + home: + members: [younger_flagged, eldest_unflagged, older_flagged] + country: WALES + local_authority: CARDIFF + council_tax: 1_800 + output: + benunit_contains_household_head: [false, false, true] + council_tax_reduction_claimant_benunit: [false, false, true] + +- name: With no member flagged as head, the eldest member's family claims + period: 2026 + absolute_error_margin: 0.01 + input: + people: + younger: + age: 30 + is_household_head: false + elder: + age: 50 + is_household_head: false + benunits: + younger_family: + members: [younger] + elder_family: + members: [elder] + households: + home: + members: [younger, elder] + country: WALES + local_authority: CARDIFF + council_tax: 1_800 + output: + benunit_contains_household_head: [false, true] + council_tax_reduction_claimant_benunit: [false, true] diff --git a/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py b/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py new file mode 100644 index 0000000000..7e00182ba9 --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py @@ -0,0 +1,293 @@ +"""Property-based tests for who claims Council Tax Reduction in a household +of several families. + +Only a person liable to pay the council tax is in a class entitled to a +reduction (SI 2012/2885 Sch 1 paras 2-3; SI 2013/3029 regs 22-25; SSI +2021/249 reg 13; SSI 2012/319 reg 14). The model takes the household head +(the household reference person, a householder) as the liable resident, and +families liable for a share of the rent as jointly liable with the head's. + +Invariants, for any generated population of households, including ties in +age, no head flagged, and several heads flagged: + +1. One head: exactly one family in each household contains the household + head. With one or more members flagged, it holds a flagged member; with + none, it holds a member of the greatest age. +2. Claimants: a household whose rent is not shared has exactly one claimant + family, the head's. Where the rent is shared, the claimant families are + the head's and the families liable for a share of it. +3. No other family gets a reduction, and the household's reduction never + exceeds its council tax. +4. Non-dependants: no member of a claimant family is a non-dependant; every + adult in a family that neither claims nor pays rent is. +5. Differential: where the input flags at most one head, the family holding + the head is the one holding the person-level household head that Housing + Benefit and Universal Credit use. +6. Not age: with one head flagged, raising every other member's age above + the head's does not change who claims. +7. No-op: where the flagged head is strictly the eldest member and the rent + is not shared, the claimant family is the eldest adult's family, the + previous rule. +""" + +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=20, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +SCHEMES = [ + ("ENGLAND", "MAIDSTONE"), + ("ENGLAND", "MERTON"), + ("ENGLAND", "KINGSTON_UPON_THAMES"), + ("ENGLAND", "NEWHAM"), + ("ENGLAND", "WESTMINSTER"), + ("ENGLAND", "OXFORD"), + ("SCOTLAND", "CITY_OF_EDINBURGH"), + ("WALES", "CARDIFF"), +] +HEAD_FLAGS = ["one", "unset", "none", "several"] +# Ages around State Pension age and repeated values, so ties are common. +adult_age = st.one_of( + st.sampled_from([18, 25, 40, 66, 67, 80, 85]), st.integers(18, 100) +) +money = st.floats(0, 40_000, allow_nan=False, allow_infinity=False) + + +@st.composite +def family(draw): + return dict( + ages=draw(st.lists(adult_age, min_size=1, max_size=2)), + child_age=draw(st.one_of(st.none(), st.integers(0, 15))), + earnings=draw(st.lists(money, min_size=2, max_size=2)), + sharer=draw(st.booleans()), + ) + + +@st.composite +def households(draw): + families = draw(st.lists(family(), min_size=1, max_size=4)) + adults = sum(len(f["ages"]) for f in families) + return dict( + families=families, + scheme=draw(st.sampled_from(SCHEMES)), + council_tax=draw(st.floats(0, 4_000, allow_nan=False)), + rent=draw(money), + savings=draw(st.floats(0, 20_000, allow_nan=False)), + head_flags=draw(st.sampled_from(HEAD_FLAGS)), + # Which adult is flagged ("one"), or which are ("several"). + head_choice=draw(st.lists(st.integers(0, adults - 1), min_size=1)), + ) + + +population = st.lists(households(), min_size=1, max_size=8) + + +def build(population, age_override=None): + """One situation for the whole population, plus per-person facts. + + age_override(house, adult_index, age, flagged) -> new age, if given. + """ + people, benunits, homes = {}, {}, {} + facts = dict(flagged=[], age=[], household=[], benunit=[], sharer=[]) + benunit_index = 0 + for h, house in enumerate(population): + country, local_authority = house["scheme"] + if house["head_flags"] == "one": + flagged_adults = {house["head_choice"][0]} + elif house["head_flags"] == "several": + flagged_adults = set(house["head_choice"]) + else: + flagged_adults = set() + members, adult_index = [], 0 + for f, fam in enumerate(house["families"]): + ids = [] + for i, age in enumerate(fam["ages"]): + pid = f"h{h}_f{f}_adult_{i}" + flagged = adult_index in flagged_adults + if age_override is not None: + age = age_override(house, adult_index, age, flagged) + person = {"age": age, "employment_income": fam["earnings"][i]} + if house["head_flags"] != "unset": + person["is_household_head"] = flagged + people[pid] = person + ids.append(pid) + facts["flagged"].append(flagged) + facts["age"].append(age) + adult_index += 1 + if fam["child_age"] is not None: + pid = f"h{h}_f{f}_child" + people[pid] = {"age": fam["child_age"]} + if house["head_flags"] != "unset": + people[pid]["is_household_head"] = False + ids.append(pid) + facts["flagged"].append(False) + facts["age"].append(fam["child_age"]) + facts["household"].extend([h] * len(ids)) + facts["benunit"].extend([benunit_index] * len(ids)) + # The first family is only a sharer if it does not hold the head. + facts["sharer"].append(fam["sharer"] and f > 0) + benunits[f"h{h}_f{f}"] = { + "members": ids, + "liable_for_share_of_household_rent": fam["sharer"] and f > 0, + "claims_all_entitled_benefits": True, + "would_claim_uc": True, + } + members.extend(ids) + benunit_index += 1 + homes[f"h{h}"] = { + "members": members, + "country": country, + "local_authority": local_authority, + "council_tax": house["council_tax"], + "rent": house["rent"], + "tenure_type": "RENT_PRIVATELY", + "savings": house["savings"], + } + situation = { + group: { + name: { + key: value if key == "members" else {YEAR: value} + for key, value in entity.items() + } + for name, entity in entities.items() + } + for group, entities in ( + ("people", people), + ("benunits", benunits), + ("households", homes), + ) + } + return situation, {k: np.array(v) for k, v in facts.items()} + + +def calc(simulation, variable, map_to=None): + return np.asarray(simulation.calculate(variable, YEAR, map_to=map_to)) + + +def per_household(values, facts): + """Sum benefit-unit values within each household.""" + household_of_benunit = np.zeros(facts["sharer"].size, dtype=int) + household_of_benunit[facts["benunit"]] = facts["household"] + return np.bincount(household_of_benunit, weights=values.astype(float)) + + +@PROPERTY_SETTINGS +@given(population) +def test_claimant_invariants(population): + situation, facts = build(population) + sim = Simulation(situation=situation) + head = calc(sim, "benunit_contains_household_head") + claimant = calc(sim, "council_tax_reduction_claimant_benunit") + sharer = facts["sharer"] + shared = per_household(sharer, facts) > 0 + n_households = shared.size + person_household = facts["household"] + person_head_family = head[facts["benunit"]] + + # 1. One head, holding a flagged member if any, else the eldest. + assert np.all(per_household(head, facts) == 1) + for h in range(n_households): + in_h = person_household == h + flagged = facts["flagged"][in_h] + if population[h]["head_flags"] in ("one", "several"): + assert np.any(flagged & person_head_family[in_h]) + elif population[h]["head_flags"] == "none" or not all( + house["head_flags"] == "unset" for house in population + ): + ages = facts["age"][in_h] + assert np.any((ages == ages.max()) & person_head_family[in_h]) + + # 2. Claimants. + claimants = per_household(claimant, facts) + assert np.all(claimants[~shared] == 1) + assert np.array_equal(claimant, head | sharer) + + # 3. No reduction outside claimant families; never above the council tax. + reduction = calc(sim, "simulated_council_tax_reduction_benunit") + assert np.all(reduction[~claimant] == 0) + household_reduction = calc(sim, "council_tax_reduction") + assert np.all(household_reduction <= calc(sim, "council_tax") + 0.01) + + # 4. Non-dependants. + non_dep = calc(sim, "council_tax_reduction_individual_non_dep_deduction_eligible") + person_claimant = claimant[facts["benunit"]] + person_rent_liable = calc(sim, "benunit_is_rent_liable")[facts["benunit"]] + adult = calc(sim, "age") >= 18 + assert not np.any(non_dep & person_claimant) + assert np.all(non_dep[adult & ~person_claimant & ~person_rent_liable]) + + # 5. Differential with the person-level household head. Once anyone in + # the simulation has the input, everyone else's defaults to false, so an + # "unset" household is well formed only if every household is. + all_unset = all(house["head_flags"] == "unset" for house in population) + well_formed = np.array( + [ + population[h]["head_flags"] == "one" + or (population[h]["head_flags"] == "unset" and all_unset) + for h in person_household + ] + ) + person_level_head = calc(sim, "is_household_head") + assert np.array_equal( + person_head_family[well_formed], + sim.map_result( + sim.map_result(person_level_head, "person", "benunit") > 0, + "benunit", + "person", + ).astype(bool)[well_formed], + ) + + +@PROPERTY_SETTINGS +@given(population) +def test_claimant_does_not_follow_age(population): + for house in population: + house["head_flags"] = "one" + situation, facts = build(population) + + def older_than_head(house, adult_index, age, flagged): + return age if flagged else 101 + adult_index + + raised, _ = build(population, age_override=older_than_head) + # 6. Not age. + before = calc( + Simulation(situation=situation), "council_tax_reduction_claimant_benunit" + ) + after = calc(Simulation(situation=raised), "council_tax_reduction_claimant_benunit") + assert np.array_equal(before, after) + + +@PROPERTY_SETTINGS +@given(population) +def test_no_op_where_head_is_eldest(population): + for house in population: + house["head_flags"] = "one" + for fam in house["families"]: + fam["sharer"] = False + + def head_eldest(house, adult_index, age, flagged): + return 110 if flagged else min(age, 100) + + situation, facts = build(population, age_override=head_eldest) + sim = Simulation(situation=situation) + claimant = calc(sim, "council_tax_reduction_claimant_benunit") + # 7. The previous rule: the family of the household's eldest adult. + age = calc(sim, "age") + benunit_max_age = sim.map_result( + sim.map_result(age, "person", "benunit", how="max"), "benunit", "person" + ) + household_max_age = sim.map_result( + sim.map_result(age, "person", "household", how="max"), "household", "person" + ) + previous = sim.map_result( + (benunit_max_age == household_max_age).astype(float), "person", "benunit" + ) + assert np.array_equal(claimant, previous > 0) diff --git a/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py b/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py index c3b3efca5a..387d7aac7b 100644 --- a/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py +++ b/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py @@ -225,9 +225,9 @@ def test_meals_and_council_tax(population): # 6. Meals. assert np.all(calc(a, "housing_benefit_meals_deduction") >= 0) assert np.all(calc(a, "LHA_cap") <= calc(b, "LHA_cap") + 1e-6) - # 7. Council tax shares, where the rent is shared. (Elsewhere the family - # of the household's oldest adult claims, and families tied for oldest - # both do: a separate, existing issue.) + # 7. Council tax shares, where the rent is shared. (Elsewhere only the + # household head's family claims: test_council_tax_reduction_claimant_ + # properties.py.) claimant = calc(a, "council_tax_reduction_claimant_benunit") share = calc(a, "council_tax_reduction_joint_liability_share") total = a.map_result(claimant * share, "benunit", "household") diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/benunit_contains_household_head.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/benunit_contains_household_head.py index 6a2d45c076..851281efaa 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/benunit_contains_household_head.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/benunit_contains_household_head.py @@ -4,12 +4,33 @@ class benunit_contains_household_head(Variable): value_type = bool entity = BenUnit - label = "Benefit unit contains the oldest adult in the household" + label = "Benefit unit contains the household head" + documentation = ( + "Whether the household head is in this family. The head is the " + "household reference person: a householder, in whose name the " + "accommodation is owned or rented, and so the resident liable for the " + "council tax under the Local Government Finance Act 1992 (s.6 in " + "England and Wales, s.75 in Scotland), which the Council Tax Reduction " + "classes of applicant require. Each household has exactly one head. " + "Where the input flags more than one member, the eldest flagged member " + "is the head; where it flags none, the eldest member is." + ) definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/14/section/6", + "https://www.legislation.gov.uk/ukpga/1992/14/section/75", + ) def formula(benunit, period, parameters): person = benunit.members household = person.household - benunit_max_age = benunit.max(person("age", period)) - household_max_age = benunit.max(household.max(person("age", period))) - return benunit_max_age == household_max_age + age = person("age", period) + flagged = person("is_household_head", period) + # get_rank breaks ties in age by order within the household, so + # exactly one member has rank 0. + rank = where( + household.any(flagged), + person.get_rank(household, -age, condition=flagged), + person.get_rank(household, -age), + ) + return benunit.any(rank == 0) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py index 449b9d0808..b776235c89 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py @@ -7,27 +7,30 @@ class council_tax_reduction_claimant_benunit(Variable): label = "Family claims Council Tax Reduction for the household" documentation = ( "Whether this family can claim Council Tax Reduction on the " - "household's council tax. Where the household's rent is shared, every " - "family liable for it is jointly and severally liable for the council " - "tax and claims on its part. Otherwise the family of the household's " - "oldest adult claims, as before." + "household's council tax. Only a person liable to pay the council tax " + "is in a class entitled to a reduction. Where the household's rent is " + "shared, every family liable for it is jointly and severally liable " + "for the council tax and claims on its part. Otherwise the household " + "head's family claims: the head is the household reference person, a " + "householder in whose name the accommodation is owned or rented, and " + "so the liable resident. Other families in the household are not " + "liable and cannot claim, whatever their ages." ) definition_period = YEAR reference = ( "https://www.legislation.gov.uk/ukpga/1992/14/section/6", + "https://www.legislation.gov.uk/ukpga/1992/14/section/75", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/2", "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/7", + "https://www.legislation.gov.uk/wsi/2013/3029/regulation/22", + "https://www.legislation.gov.uk/wsi/2013/3029/regulation/24", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/13", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/14", ) def formula(benunit, period, parameters): - person = benunit.members - rent_is_shared = benunit.any( - person.household.any( - person.benunit("liable_for_share_of_household_rent", period) - ) - ) - liable_family = benunit.any(person("is_liable_for_household_rent", period)) - return where( - rent_is_shared, - liable_family, - benunit("benunit_contains_household_head", period), - ) + # A family liable for a share of the rent is jointly and severally + # liable for the council tax with the head's family (s.6(3), s.75(3)). + head_family = benunit("benunit_contains_household_head", period) + sharer = benunit("liable_for_share_of_household_rent", period) + return head_family | sharer diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py index 459598f8d3..cb40c160c8 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py @@ -23,6 +23,6 @@ def formula(person, period, parameters): # SI 2012/2885 reg 9(2)(d)-(e) and the Welsh and Scottish equivalents. return ( (person("age", period) >= 18) - & ~person.benunit("benunit_contains_household_head", period) + & ~person.benunit("council_tax_reduction_claimant_benunit", period) & ~person.benunit("benunit_is_rent_liable", period) ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_joint_liability_share.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_joint_liability_share.py index 215992982e..ea815e3413 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_joint_liability_share.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_joint_liability_share.py @@ -11,11 +11,13 @@ class council_tax_reduction_joint_liability_share(Variable): "the maximum reduction is divided by the number of people jointly and " "severally liable, and a deduction for a non-dependant of two or more " "of them is apportioned equally between them. The model takes the " - "people liable for the household's rent as those jointly liable for " - "its council tax (residents with the same interest, Local Government " - "Finance Act 1992 s.6) and follows the regulations' wording, dividing " - "by every person liable, the claimant's partner included. Students " - "are not excluded from the count. Otherwise the share is one." + "claimants and partners of the families that claim (the household " + "head's family and any family liable for a share of the rent) as " + "those jointly liable for the council tax (residents with the same " + "interest, Local Government Finance Act 1992 s.6 and s.75, and their " + "partners, s.9 and s.77) and follows the regulations' wording, " + "dividing by every person liable, the claimant's partner included. " + "Students are not excluded from the count. Otherwise the share is one." ) definition_period = YEAR unit = "/1" @@ -31,7 +33,9 @@ def formula(benunit, period, parameters): # SI 2012/2885 Sch 1 para 7(3)-(4) and para 8(5); the Welsh and # Scottish schemes have the same wording. person = benunit.members - liable = person("is_liable_for_household_rent", period) + liable = person("is_claimant_or_partner", period) & person.benunit( + "council_tax_reduction_claimant_benunit", period + ) liable_people = benunit.max(person.household.sum(liable)) in_family = benunit.sum(liable) jointly_with_others = (in_family > 0) & (liable_people > in_family) From d373d4352773f0f481dbfd58b9308ee2faa77340 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 16:03:03 -0400 Subject: [PATCH 2/6] State the CTR non-dependant property for the applicant's family Reg 9(2)(a) excludes the applicant's family (claimant, partner and the children they are responsible for) from non-dependants, not every member of the FRS benefit unit. PR #2017 charges non-dependants who share a benefit unit with the claimant, so the property now asserts that no claimant or partner of a claimant family is a non-dependant. It holds on this branch and on its composition with #2017. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_council_tax_reduction_claimant_properties.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py b/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py index 7e00182ba9..f00f6debaf 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py @@ -18,7 +18,8 @@ the head's and the families liable for a share of it. 3. No other family gets a reduction, and the household's reduction never exceeds its council tax. -4. Non-dependants: no member of a claimant family is a non-dependant; every +4. Non-dependants: no claimant or partner of a claimant family is a + non-dependant (the applicant's family, SI 2012/2885 reg 9(2)(a)); every adult in a family that neither claims nor pays rent is. 5. Differential: where the input flags at most one head, the family holding the head is the one holding the person-level household head that Housing @@ -221,7 +222,8 @@ def test_claimant_invariants(population): person_claimant = claimant[facts["benunit"]] person_rent_liable = calc(sim, "benunit_is_rent_liable")[facts["benunit"]] adult = calc(sim, "age") >= 18 - assert not np.any(non_dep & person_claimant) + claimant_or_partner = calc(sim, "is_claimant_or_partner") + assert not np.any(non_dep & person_claimant & claimant_or_partner) assert np.all(non_dep[adult & ~person_claimant & ~person_rent_liable]) # 5. Differential with the person-level household head. Once anyone in From 7b21ab460dc72e4c08fc03909d9921c95593cf1c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 01:21:57 -0400 Subject: [PATCH 3/6] Require an adult claimant or partner to claim CTR; pin the joint-share and non-dependant changes Review r1 (20261001-133703-ctr-claimant-review-r1) found that a sharer family with no claimant or partner (a lone 15-year-old) claimed with a share of one, paying the household twice its council tax, where the base excluded it. council_tax_reduction_claimant_benunit now also requires a claimant or partner aged 18 or over. No one under 18 is a "resident" who can be liable (LGFA 1992 s.6(5), s.99(1)). The joint-liability count likewise takes only claimants and partners aged 18 or over. Documentation no longer says the head is the liable resident, only that the model takes the head as that resident. It names what is not identified: joint owners in separate families, and owner-liable dwellings. It notes that rent and non-dependant rules read the raw is_household_head flag. Citations add LGFA 1992 ss.9, 13A, 77, 77A, 99 and Sch 1A para 2; SI 2012/2885 Sch 1 para 3; SI 2012/2886 Sch para 16 (default working-age class D); and Wales regs 21, 23 and 25. YAML: - the Merton, Kingston and Westminster ties now compute the friend's non-dependant deduction from each scheme's scale; - the no-flag case is now a tie that fails on the base; - new cases cover a zero-rent sharer (a claimant, not a non-dependant), shared rent with no head flagged and with two heads flagged (shares sum to one), and sharer families aged 15 and 17. 16 of 17 cases fail on the base; the 15-year-old case guards e9b1185ee's regression. Properties: the generator adds minor-only sharer families. New invariant 8 says claimant shares sum to at most one. Invariant 3 is scoped to simulated CTR. Formula restatements are labelled as pins. Co-Authored-By: Claude Opus 5.5 --- .../claimant_household_head.yaml | 238 ++++++++++++++++-- ...uncil_tax_reduction_claimant_properties.py | 67 +++-- .../benunit_contains_household_head.py | 15 +- .../council_tax_reduction_claimant_benunit.py | 36 ++- ...cil_tax_reduction_joint_liability_share.py | 21 +- 5 files changed, 317 insertions(+), 60 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml index e41a7d5eb3..bb451dab5b 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml @@ -142,7 +142,7 @@ newham_council_tax_reduction: [0, 867.92] council_tax_reduction: 867.92 -- name: Two working-age adults of the same age in Merton - only the head's family claims +- name: Two working-age adults of the same age in Merton - the other adult is a non-dependant period: 2026 absolute_error_margin: 0.01 input: @@ -153,7 +153,6 @@ friend: age: 40 is_household_head: false - merton_council_tax_reduction_individual_non_dep_deduction: 0 benunits: householder_family: members: [householder] @@ -171,10 +170,12 @@ council_tax: 1_800 savings: 0 output: - merton_council_tax_reduction: [1_800, 0] - council_tax_reduction: 1_800 + # The friend, not in remunerative work: 5.20 a week x 52 = 270.40. + merton_council_tax_reduction_non_dep_deductions: [270.4, 0] + merton_council_tax_reduction: [1_529.6, 0] + council_tax_reduction: 1_529.6 -- name: Two working-age adults of the same age in Kingston upon Thames - only the head's family claims +- name: Two working-age adults of the same age in Kingston upon Thames - the other adult is a non-dependant period: 2026 absolute_error_margin: 0.01 input: @@ -185,7 +186,6 @@ friend: age: 40 is_household_head: false - kingston_upon_thames_council_tax_reduction_individual_non_dep_deduction: 0 benunits: householder_family: members: [householder] @@ -203,10 +203,12 @@ council_tax: 1_800 savings: 0 output: - kingston_upon_thames_council_tax_reduction: [1_800, 0] - council_tax_reduction: 1_800 + # The friend, not in remunerative work: 5.64 a week x 52 = 293.28. + kingston_upon_thames_council_tax_reduction_non_dep_deductions: [293.28, 0] + kingston_upon_thames_council_tax_reduction: [1_506.72, 0] + council_tax_reduction: 1_506.72 -- name: Two working-age adults of the same age in Westminster - only the head's family claims +- name: Two working-age adults of the same age in Westminster - the other adult is a non-dependant period: 2026 absolute_error_margin: 0.01 input: @@ -217,7 +219,6 @@ friend: age: 40 is_household_head: false - westminster_council_tax_reduction_individual_non_dep_deduction: 0 benunits: householder_family: members: [householder] @@ -235,8 +236,10 @@ council_tax: 1_800 savings: 0 output: - westminster_council_tax_reduction: [1_800, 0] - council_tax_reduction: 1_800 + # The friend, not in remunerative work: 5.20 a week x 52 = 270.40. + westminster_council_tax_reduction_non_dep_deductions: [270.4, 0] + westminster_council_tax_reduction: [1_529.6, 0] + council_tax_reduction: 1_529.6 - name: Two working-age adults of the same age in Oxford - the other adult is a non-dependant period: 2026 @@ -373,28 +376,225 @@ benunit_contains_household_head: [false, false, true] council_tax_reduction_claimant_benunit: [false, false, true] -- name: With no member flagged as head, the eldest member's family claims +- name: With no member flagged as head and the eldest tied, the first eldest member's family claims period: 2026 absolute_error_margin: 0.01 input: people: + first_eldest: + age: 50 + is_household_head: false younger: age: 30 is_household_head: false - elder: + second_eldest: age: 50 is_household_head: false benunits: + first_family: + members: [first_eldest] younger_family: members: [younger] - elder_family: - members: [elder] + second_family: + members: [second_eldest] households: home: - members: [younger, elder] + members: [first_eldest, younger, second_eldest] country: WALES local_authority: CARDIFF council_tax: 1_800 output: - benunit_contains_household_head: [false, true] - council_tax_reduction_claimant_benunit: [false, true] + benunit_contains_household_head: [true, false, false] + council_tax_reduction_claimant_benunit: [true, false, false] + +- name: A sharer with no rent to pay is a claimant, not a non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 40 + is_household_head: true + sharer: + age: 35 + is_household_head: false + benunits: + householder_family: + members: [householder] + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + council_tax_reduction_applicable_income: 0 + households: + home: + members: [householder, sharer] + rent: 0 + tenure_type: RENT_PRIVATELY + country: WALES + local_authority: CARDIFF + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_claimant_benunit: [true, true] + council_tax_reduction_joint_liability_share: [0.5, 0.5] + # Jointly and severally liable people are not non-dependants (SI 2013/3029 + # reg 9(2)(d)), even when no rent is payable. + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false] + simulated_council_tax_reduction_benunit: [900, 900] + council_tax_reduction: 1_800 + +- name: With no member flagged as head and the rent shared, the eldest member's family and the sharer split the council tax + period: 2026 + absolute_error_margin: 0.01 + input: + people: + eldest: + age: 60 + is_household_head: false + sharer: + age: 40 + is_household_head: false + other_adult: + age: 30 + is_household_head: false + council_tax_reduction_individual_non_dep_deduction: 0 + benunits: + eldest_family: + members: [eldest] + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + council_tax_reduction_applicable_income: 0 + other_family: + members: [other_adult] + council_tax_reduction_applicable_income: 0 + households: + home: + members: [eldest, sharer, other_adult] + rent: 9_000 + tenure_type: RENT_PRIVATELY + country: WALES + local_authority: CARDIFF + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_claimant_benunit: [true, true, false] + # Two people jointly liable: the head (by the eldest-member fallback) and + # the sharer. The shares sum to one. + council_tax_reduction_joint_liability_share: [0.5, 0.5, 1] + simulated_council_tax_reduction_benunit: [900, 900, 0] + council_tax_reduction: 1_800 + +- name: With two members flagged as head and the rent shared, the council tax is still split only among claimants + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older_flagged: + age: 60 + is_household_head: true + younger_flagged: + age: 50 + is_household_head: true + sharer: + age: 40 + is_household_head: false + benunits: + older_family: + members: [older_flagged] + council_tax_reduction_applicable_income: 0 + younger_family: + members: [younger_flagged] + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + council_tax_reduction_applicable_income: 0 + households: + home: + members: [older_flagged, younger_flagged, sharer] + rent: 9_000 + tenure_type: RENT_PRIVATELY + country: WALES + local_authority: CARDIFF + council_tax: 1_800 + savings: 0 + output: + # Malformed input. The eldest flagged member is the head; the other + # flagged family neither claims nor counts as jointly liable. (Rent + # variables read the raw flag, so that family still has a share of the + # rent.) + council_tax_reduction_claimant_benunit: [true, false, true] + council_tax_reduction_joint_liability_share: [0.5, 1, 0.5] + simulated_council_tax_reduction_benunit: [900, 0, 900] + council_tax_reduction: 1_800 + +- name: A sharer family with no adult does not claim, so the head's family claims the whole council tax + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 40 + is_household_head: true + child_sharer: + age: 15 + is_household_head: false + benunits: + householder_family: + members: [householder] + council_tax_reduction_applicable_income: 0 + child_family: + members: [child_sharer] + liable_for_share_of_household_rent: true + council_tax_reduction_applicable_income: 0 + households: + home: + members: [householder, child_sharer] + rent: 9_000 + tenure_type: RENT_PRIVATELY + country: WALES + local_authority: CARDIFF + council_tax: 1_800 + savings: 0 + output: + # Only a resident aged 18 or over can be liable (LGFA 1992 s.6(5)). + council_tax_reduction_claimant_benunit: [true, false] + council_tax_reduction_joint_liability_share: [1, 1] + simulated_council_tax_reduction_benunit: [1_800, 0] + council_tax_reduction: 1_800 + +- name: A sharer family whose only member is 17 does not claim + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 40 + is_household_head: true + young_sharer: + age: 17 + is_household_head: false + benunits: + householder_family: + members: [householder] + council_tax_reduction_applicable_income: 0 + young_family: + members: [young_sharer] + liable_for_share_of_household_rent: true + council_tax_reduction_applicable_income: 0 + households: + home: + members: [householder, young_sharer] + rent: 9_000 + tenure_type: RENT_PRIVATELY + country: WALES + local_authority: CARDIFF + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_claimant_benunit: [true, false] + council_tax_reduction_joint_liability_share: [1, 1] + simulated_council_tax_reduction_benunit: [1_800, 0] diff --git a/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py b/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py index f00f6debaf..c5f174481f 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py @@ -8,19 +8,24 @@ families liable for a share of the rent as jointly liable with the head's. Invariants, for any generated population of households, including ties in -age, no head flagged, and several heads flagged: +age, no head flagged, several heads flagged, and sharer families with no +member aged 18 or over. Invariants marked "pin" restate the formula, so they +guard against regressions rather than check it independently. 1. One head: exactly one family in each household contains the household head. With one or more members flagged, it holds a flagged member; with none, it holds a member of the greatest age. 2. Claimants: a household whose rent is not shared has exactly one claimant - family, the head's. Where the rent is shared, the claimant families are - the head's and the families liable for a share of it. -3. No other family gets a reduction, and the household's reduction never - exceeds its council tax. + family, the head's. A family with no member aged 18 or over never claims + (no one under 18 can be liable, LGFA 1992 s.6(5)). Pin: the claimant + families are the head's and the sharers, each with a claimant or partner + aged 18 or over. +3. Simulated reductions: no family outside the claimants gets one, and a + household's total never exceeds its council tax. (Reported reductions, + used where a scheme is not modelled, are outside this test.) 4. Non-dependants: no claimant or partner of a claimant family is a - non-dependant (the applicant's family, SI 2012/2885 reg 9(2)(a)); every - adult in a family that neither claims nor pays rent is. + non-dependant (the applicant's family, SI 2012/2885 reg 9(2)(a)). Pin: + every adult in a family that neither claims nor pays rent is one. 5. Differential: where the input flags at most one head, the family holding the head is the one holding the person-level household head that Housing Benefit and Universal Credit use. @@ -29,6 +34,9 @@ 7. No-op: where the flagged head is strictly the eldest member and the rent is not shared, the claimant family is the eldest adult's family, the previous rule. +8. Shares: in every household, the claimant families' shares of the council + tax sum to at most one, so jointly liable claims never cover more than + the whole council tax. """ import numpy as np @@ -69,13 +77,20 @@ def family(draw): child_age=draw(st.one_of(st.none(), st.integers(0, 15))), earnings=draw(st.lists(money, min_size=2, max_size=2)), sharer=draw(st.booleans()), + # Outside the first family: sometimes a single person aged 15-17. + minor_age=draw(st.one_of(st.none(), st.none(), st.integers(15, 17))), ) +def adult_ages(fam, f): + """The family's adults (none for a minor-only family after the first).""" + return [] if f > 0 and fam["minor_age"] is not None else fam["ages"] + + @st.composite def households(draw): families = draw(st.lists(family(), min_size=1, max_size=4)) - adults = sum(len(f["ages"]) for f in families) + adults = sum(len(adult_ages(fam, f)) for f, fam in enumerate(families)) return dict( families=families, scheme=draw(st.sampled_from(SCHEMES)), @@ -110,7 +125,7 @@ def build(population, age_override=None): members, adult_index = [], 0 for f, fam in enumerate(house["families"]): ids = [] - for i, age in enumerate(fam["ages"]): + for i, age in enumerate(adult_ages(fam, f)): pid = f"h{h}_f{f}_adult_{i}" flagged = adult_index in flagged_adults if age_override is not None: @@ -123,14 +138,17 @@ def build(population, age_override=None): facts["flagged"].append(flagged) facts["age"].append(age) adult_index += 1 - if fam["child_age"] is not None: + child_age = fam["child_age"] + if f > 0 and fam["minor_age"] is not None: + child_age = fam["minor_age"] + if child_age is not None: pid = f"h{h}_f{f}_child" - people[pid] = {"age": fam["child_age"]} + people[pid] = {"age": child_age} if house["head_flags"] != "unset": people[pid]["is_household_head"] = False ids.append(pid) facts["flagged"].append(False) - facts["age"].append(fam["child_age"]) + facts["age"].append(child_age) facts["household"].extend([h] * len(ids)) facts["benunit"].extend([benunit_index] * len(ids)) # The first family is only a sharer if it does not hold the head. @@ -209,20 +227,33 @@ def test_claimant_invariants(population): # 2. Claimants. claimants = per_household(claimant, facts) assert np.all(claimants[~shared] == 1) - assert np.array_equal(claimant, head | sharer) + age = calc(sim, "age") + claimant_or_partner = calc(sim, "is_claimant_or_partner") + has_adult = np.zeros(claimant.size, dtype=bool) + np.logical_or.at(has_adult, facts["benunit"], age >= 18) + assert not np.any(claimant & ~has_adult) + adult_claimant = np.zeros(claimant.size, dtype=bool) + np.logical_or.at( + adult_claimant, facts["benunit"], claimant_or_partner & (age >= 18) + ) + assert np.array_equal(claimant, (head | sharer) & adult_claimant) - # 3. No reduction outside claimant families; never above the council tax. + # 3. Simulated reductions: none outside claimant families; never above + # the council tax. reduction = calc(sim, "simulated_council_tax_reduction_benunit") assert np.all(reduction[~claimant] == 0) - household_reduction = calc(sim, "council_tax_reduction") - assert np.all(household_reduction <= calc(sim, "council_tax") + 0.01) + council_tax = calc(sim, "council_tax") + assert np.all(per_household(reduction, facts) <= council_tax + 0.01) + + # 8. Shares of the council tax sum to at most one. + share = calc(sim, "council_tax_reduction_joint_liability_share") + assert np.all(per_household(share * claimant, facts) <= 1 + 1e-6) # 4. Non-dependants. non_dep = calc(sim, "council_tax_reduction_individual_non_dep_deduction_eligible") person_claimant = claimant[facts["benunit"]] person_rent_liable = calc(sim, "benunit_is_rent_liable")[facts["benunit"]] - adult = calc(sim, "age") >= 18 - claimant_or_partner = calc(sim, "is_claimant_or_partner") + adult = age >= 18 assert not np.any(non_dep & person_claimant & claimant_or_partner) assert np.all(non_dep[adult & ~person_claimant & ~person_rent_liable]) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/benunit_contains_household_head.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/benunit_contains_household_head.py index 851281efaa..82946361d6 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/benunit_contains_household_head.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/benunit_contains_household_head.py @@ -8,12 +8,15 @@ class benunit_contains_household_head(Variable): documentation = ( "Whether the household head is in this family. The head is the " "household reference person: a householder, in whose name the " - "accommodation is owned or rented, and so the resident liable for the " - "council tax under the Local Government Finance Act 1992 (s.6 in " - "England and Wales, s.75 in Scotland), which the Council Tax Reduction " - "classes of applicant require. Each household has exactly one head. " - "Where the input flags more than one member, the eldest flagged member " - "is the head; where it flags none, the eldest member is." + "accommodation is owned or rented. Council Tax Reduction takes the " + "head as the resident liable for the council tax under the Local " + "Government Finance Act 1992 (s.6 in England and Wales, s.75 in " + "Scotland), which every class of applicant requires. Each household " + "has exactly one head. Where the input flags more than one member, " + "the eldest flagged member is the head; where it flags none, the " + "eldest member is. Rent and non-dependant rules for Universal Credit " + "and Housing Benefit read is_household_head directly, so they agree " + "with this only where exactly one member is flagged." ) definition_period = YEAR reference = ( diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py index b776235c89..444d30244a 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py @@ -7,23 +7,34 @@ class council_tax_reduction_claimant_benunit(Variable): label = "Family claims Council Tax Reduction for the household" documentation = ( "Whether this family can claim Council Tax Reduction on the " - "household's council tax. Only a person liable to pay the council tax " - "is in a class entitled to a reduction. Where the household's rent is " - "shared, every family liable for it is jointly and severally liable " - "for the council tax and claims on its part. Otherwise the household " - "head's family claims: the head is the household reference person, a " - "householder in whose name the accommodation is owned or rented, and " - "so the liable resident. Other families in the household are not " - "liable and cannot claim, whatever their ages." + "household's council tax. Every class of person entitled to a " + "reduction is limited to people liable to pay the council tax, and " + "liability goes to the resident (aged 18 or over) with the greatest " + "interest in the dwelling, jointly where several hold the same " + "interest. The model takes the household head (the household " + "reference person, a householder in whose name the accommodation is " + "owned or rented) as that resident, and families liable for a share " + "of the household's rent as jointly liable with the head's family. " + "Other families are treated as not liable, whatever their ages. A " + "family claims only if its claimant or partner is aged 18 or over. " + "Joint owners in separate families, and dwellings where the owner " + "rather than a resident is liable, are not identified." ) definition_period = YEAR reference = ( "https://www.legislation.gov.uk/ukpga/1992/14/section/6", + "https://www.legislation.gov.uk/ukpga/1992/14/section/13A", "https://www.legislation.gov.uk/ukpga/1992/14/section/75", + "https://www.legislation.gov.uk/ukpga/1992/14/section/99", + "https://www.legislation.gov.uk/ukpga/1992/14/schedule/1A/paragraph/2", "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/2", - "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/7", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/3", + "https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/16", + "https://www.legislation.gov.uk/wsi/2013/3029/regulation/21", "https://www.legislation.gov.uk/wsi/2013/3029/regulation/22", + "https://www.legislation.gov.uk/wsi/2013/3029/regulation/23", "https://www.legislation.gov.uk/wsi/2013/3029/regulation/24", + "https://www.legislation.gov.uk/wsi/2013/3029/regulation/25", "https://www.legislation.gov.uk/ssi/2021/249/regulation/13", "https://www.legislation.gov.uk/ssi/2012/319/regulation/14", ) @@ -31,6 +42,11 @@ class council_tax_reduction_claimant_benunit(Variable): def formula(benunit, period, parameters): # A family liable for a share of the rent is jointly and severally # liable for the council tax with the head's family (s.6(3), s.75(3)). + # Only a resident aged 18 or over can be liable (s.6(5), s.99(1)). + person = benunit.members + adult_claimant = benunit.any( + person("is_claimant_or_partner", period) & (person("age", period) >= 18) + ) head_family = benunit("benunit_contains_household_head", period) sharer = benunit("liable_for_share_of_household_rent", period) - return head_family | sharer + return (head_family | sharer) & adult_claimant diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_joint_liability_share.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_joint_liability_share.py index ea815e3413..fe10ffee90 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_joint_liability_share.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_joint_liability_share.py @@ -11,11 +11,12 @@ class council_tax_reduction_joint_liability_share(Variable): "the maximum reduction is divided by the number of people jointly and " "severally liable, and a deduction for a non-dependant of two or more " "of them is apportioned equally between them. The model takes the " - "claimants and partners of the families that claim (the household " - "head's family and any family liable for a share of the rent) as " - "those jointly liable for the council tax (residents with the same " - "interest, Local Government Finance Act 1992 s.6 and s.75, and their " - "partners, s.9 and s.77) and follows the regulations' wording, " + "claimants and partners aged 18 or over of the families that claim " + "(the household head's family and any family liable for a share of " + "the rent) as those jointly liable for the council tax (residents " + "with the same interest, Local Government Finance Act 1992 s.6 and " + "s.75, and their partners, s.9, s.77 and s.77A) and follows the " + "regulations' wording, " "dividing by every person liable, the claimant's partner included. " "Students are not excluded from the count. Otherwise the share is one." ) @@ -23,6 +24,10 @@ class council_tax_reduction_joint_liability_share(Variable): unit = "/1" reference = ( "https://www.legislation.gov.uk/ukpga/1992/14/section/6", + "https://www.legislation.gov.uk/ukpga/1992/14/section/9", + "https://www.legislation.gov.uk/ukpga/1992/14/section/75", + "https://www.legislation.gov.uk/ukpga/1992/14/section/77", + "https://www.legislation.gov.uk/ukpga/1992/14/section/77A", "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/7", "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/8", "https://www.legislation.gov.uk/wsi/2013/3029", @@ -33,8 +38,10 @@ def formula(benunit, period, parameters): # SI 2012/2885 Sch 1 para 7(3)-(4) and para 8(5); the Welsh and # Scottish schemes have the same wording. person = benunit.members - liable = person("is_claimant_or_partner", period) & person.benunit( - "council_tax_reduction_claimant_benunit", period + liable = ( + person("is_claimant_or_partner", period) + & (person("age", period) >= 18) + & person.benunit("council_tax_reduction_claimant_benunit", period) ) liable_people = benunit.max(person.household.sum(liable)) in_family = benunit.sum(liable) From 4b84ea069aec9dcc90d533c9501ebfea13cde4d8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 05:39:25 -0400 Subject: [PATCH 4/6] Treat the household head and liable partners as the CTR liable people, not the generic claimant/partner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review r2c (20261002-044503-ctr-claimant-review-r2c, GPT-6.1 Sol) found N2. The 18+ claimant-or-partner gate dropped a supported grouping: an 80-year-old household head living in one benefit unit with two 17-year-old parents and their baby. #1896's parent-couple rule makes the parents the claimant and partner, so the family had no adult claimant or partner and lost a £1,800 award to £0. The model now names the liable people directly, in a new person-level variable, council_tax_reduction_liable_person. Each must be aged 18 or over (LGFA 1992 s.6(5), s.99(1)). They are: - the household head; - the head's partner, where the head is a claimant or partner (s.9, s.77, s.77A); - the claimant and partner of a family liable for a share of the rent. A family claims if and only if it has such a member, and the joint-liability share counts exactly these people. The settled one-per-household head moves into council_tax_reduction_household_head (Person), and benunit_contains_household_head now reads it. For all-adult households with the head as claimant, the liable set is unchanged. Tests: - YAML: a grandparent head with 17-year-old parents in her family (claims £1,800), and adult parents in a grandparent's family (not jointly liable). Both fail on 7b21ab460. - Properties: the generator adds 16-17-year-old parent couples with a baby in the head's family. New independent checks: the head's family always claims when the head is 18+; every liable person is 18+ and in the head's family or a sharer family. These fail on 7b21ab460. Co-Authored-By: Claude Opus 5.5 --- .../claimant_household_head.yaml | 78 +++++++++++++++++++ ...uncil_tax_reduction_claimant_properties.py | 46 ++++++++--- .../benunit_contains_household_head.py | 13 +--- .../council_tax_reduction_claimant_benunit.py | 16 ++-- .../council_tax_reduction_household_head.py | 27 +++++++ ...cil_tax_reduction_joint_liability_share.py | 20 ++--- .../council_tax_reduction_liable_person.py | 41 ++++++++++ 7 files changed, 197 insertions(+), 44 deletions(-) create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_head.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_liable_person.py diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml index bb451dab5b..3f858af953 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml @@ -598,3 +598,81 @@ council_tax_reduction_claimant_benunit: [true, false] council_tax_reduction_joint_liability_share: [1, 1] simulated_council_tax_reduction_benunit: [1_800, 0] + +- name: A grandparent household head whose family includes a young couple of 17-year-old parents claims + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + young_mother: + age: 17 + is_parent: true + is_household_head: false + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + council_tax_reduction_applicable_income: 0 + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + # The young parents are the family's claimant and partner, but the + # grandmother is the household head and the only resident aged 18 or + # over, so she is the liable person (LGFA 1992 s.75, s.99(1)). + is_claimant_or_partner: [false, true, true, false] + council_tax_reduction_liable_person: [true, false, false, false] + council_tax_reduction_claimant_benunit: [true] + council_tax_reduction_joint_liability_share: [1] + simulated_council_tax_reduction_benunit: [1_800] + council_tax_reduction: 1_800 + +- name: Adult parents living in a grandparent head's family are not jointly liable with her + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 75 + is_household_head: true + mother: + age: 30 + is_parent: true + is_household_head: false + father: + age: 32 + is_parent: true + is_household_head: false + child: + age: 4 + is_household_head: false + benunits: + family: + members: [grandmother, mother, father, child] + council_tax_reduction_applicable_income: 0 + households: + home: + members: [grandmother, mother, father, child] + country: WALES + local_authority: CARDIFF + council_tax: 1_800 + savings: 0 + output: + # Only the head and the head's own partner are liable; the parents are + # not the grandmother's partners (LGFA 1992 s.6, s.9). + council_tax_reduction_liable_person: [true, false, false, false] + council_tax_reduction_claimant_benunit: [true] + council_tax_reduction_joint_liability_share: [1] diff --git a/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py b/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py index c5f174481f..572eec3698 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py @@ -15,11 +15,14 @@ 1. One head: exactly one family in each household contains the household head. With one or more members flagged, it holds a flagged member; with none, it holds a member of the greatest age. -2. Claimants: a household whose rent is not shared has exactly one claimant - family, the head's. A family with no member aged 18 or over never claims - (no one under 18 can be liable, LGFA 1992 s.6(5)). Pin: the claimant - families are the head's and the sharers, each with a claimant or partner - aged 18 or over. +2. Claimants: a household whose rent is not shared has at most one claimant + family, the head's, and the head's family claims whenever the head is + 18 or over (including a grandparent head whose family's claimant and + partner are 17-year-old parents). A family with no member aged 18 or + over never claims (no one under 18 can be liable, LGFA 1992 s.6(5)). + Every person treated as liable is 18 or over and in the head's family or + a sharer family. Pin: a family claims if and only if it has a member + treated as liable. 3. Simulated reductions: no family outside the claimants gets one, and a household's total never exceeds its council tax. (Reported reductions, used where a scheme is not modelled, are outside this test.) @@ -79,6 +82,9 @@ def family(draw): sharer=draw(st.booleans()), # Outside the first family: sometimes a single person aged 15-17. minor_age=draw(st.one_of(st.none(), st.none(), st.integers(15, 17))), + # In the first family: sometimes a couple of parents aged 16-17 with + # a baby, living in the head's family (#1896's parent-couple rule). + minor_parents=draw(st.one_of(st.none(), st.none(), st.integers(16, 17))), ) @@ -138,6 +144,16 @@ def build(population, age_override=None): facts["flagged"].append(flagged) facts["age"].append(age) adult_index += 1 + if f == 0 and fam["minor_parents"] is not None: + for i in range(3): + pid = f"h{h}_f{f}_young_{i}" + age = fam["minor_parents"] if i < 2 else 0 + people[pid] = {"age": age, "is_parent": i < 2} + if house["head_flags"] != "unset": + people[pid]["is_household_head"] = False + ids.append(pid) + facts["flagged"].append(False) + facts["age"].append(age) child_age = fam["child_age"] if f > 0 and fam["minor_age"] is not None: child_age = fam["minor_age"] @@ -226,17 +242,25 @@ def test_claimant_invariants(population): # 2. Claimants. claimants = per_household(claimant, facts) - assert np.all(claimants[~shared] == 1) + assert np.all(claimants[~shared] == 1) # every head here is 18 or over age = calc(sim, "age") claimant_or_partner = calc(sim, "is_claimant_or_partner") has_adult = np.zeros(claimant.size, dtype=bool) np.logical_or.at(has_adult, facts["benunit"], age >= 18) assert not np.any(claimant & ~has_adult) - adult_claimant = np.zeros(claimant.size, dtype=bool) - np.logical_or.at( - adult_claimant, facts["benunit"], claimant_or_partner & (age >= 18) - ) - assert np.array_equal(claimant, (head | sharer) & adult_claimant) + liable = calc(sim, "council_tax_reduction_liable_person") + head_person = calc(sim, "council_tax_reduction_household_head") + in_head_or_sharer = (head | sharer)[facts["benunit"]] + assert np.all(age[liable] >= 18) + assert np.all(in_head_or_sharer[liable]) + head_adult = np.zeros(claimant.size, dtype=bool) + np.logical_or.at(head_adult, facts["benunit"], head_person & (age >= 18)) + assert np.all(claimant[head_adult]) + claimants_not_shared = per_household(claimant, facts)[~shared] + assert np.all(claimants_not_shared <= 1) + has_liable = np.zeros(claimant.size, dtype=bool) + np.logical_or.at(has_liable, facts["benunit"], liable) + assert np.array_equal(claimant, has_liable) # 3. Simulated reductions: none outside claimant families; never above # the council tax. diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/benunit_contains_household_head.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/benunit_contains_household_head.py index 82946361d6..195f8b4ea7 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/benunit_contains_household_head.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/benunit_contains_household_head.py @@ -25,15 +25,6 @@ class benunit_contains_household_head(Variable): ) def formula(benunit, period, parameters): - person = benunit.members - household = person.household - age = person("age", period) - flagged = person("is_household_head", period) - # get_rank breaks ties in age by order within the household, so - # exactly one member has rank 0. - rank = where( - household.any(flagged), - person.get_rank(household, -age, condition=flagged), - person.get_rank(household, -age), + return benunit.any( + benunit.members("council_tax_reduction_household_head", period) ) - return benunit.any(rank == 0) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py index 444d30244a..db8fe75099 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py @@ -16,7 +16,8 @@ class council_tax_reduction_claimant_benunit(Variable): "owned or rented) as that resident, and families liable for a share " "of the household's rent as jointly liable with the head's family. " "Other families are treated as not liable, whatever their ages. A " - "family claims only if its claimant or partner is aged 18 or over. " + "family claims only through a member treated as liable, who must be " + "aged 18 or over: see council_tax_reduction_liable_person. " "Joint owners in separate families, and dwellings where the owner " "rather than a resident is liable, are not identified." ) @@ -40,13 +41,8 @@ class council_tax_reduction_claimant_benunit(Variable): ) def formula(benunit, period, parameters): - # A family liable for a share of the rent is jointly and severally - # liable for the council tax with the head's family (s.6(3), s.75(3)). - # Only a resident aged 18 or over can be liable (s.6(5), s.99(1)). - person = benunit.members - adult_claimant = benunit.any( - person("is_claimant_or_partner", period) & (person("age", period) >= 18) + # The head's family and families liable for a share of the rent claim, + # each through a member treated as liable (aged 18 or over). + return benunit.any( + benunit.members("council_tax_reduction_liable_person", period) ) - head_family = benunit("benunit_contains_household_head", period) - sharer = benunit("liable_for_share_of_household_rent", period) - return (head_family | sharer) & adult_claimant diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_head.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_head.py new file mode 100644 index 0000000000..1f0229bf12 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_head.py @@ -0,0 +1,27 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_household_head(Variable): + value_type = bool + entity = Person + label = "Household head for Council Tax Reduction" + documentation = ( + "The member Council Tax Reduction treats as the household head: the " + "household reference person, exactly one per household. Where the " + "input flags more than one member, the eldest flagged member is the " + "head; where it flags none, the eldest member is. Ties go by order." + ) + definition_period = YEAR + + def formula(person, period, parameters): + household = person.household + age = person("age", period) + flagged = person("is_household_head", period) + # get_rank breaks ties in age by order within the household, so + # exactly one member has rank 0. + rank = where( + household.any(flagged), + person.get_rank(household, -age, condition=flagged), + person.get_rank(household, -age), + ) + return rank == 0 diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_joint_liability_share.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_joint_liability_share.py index fe10ffee90..d7587541ae 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_joint_liability_share.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_joint_liability_share.py @@ -10,13 +10,13 @@ class council_tax_reduction_joint_liability_share(Variable): "tax with people other than their partner, the council tax used for " "the maximum reduction is divided by the number of people jointly and " "severally liable, and a deduction for a non-dependant of two or more " - "of them is apportioned equally between them. The model takes the " - "claimants and partners aged 18 or over of the families that claim " - "(the household head's family and any family liable for a share of " - "the rent) as those jointly liable for the council tax (residents " - "with the same interest, Local Government Finance Act 1992 s.6 and " - "s.75, and their partners, s.9, s.77 and s.77A) and follows the " - "regulations' wording, " + "of them is apportioned equally between them. The people jointly " + "liable are those the model treats as liable for the council tax " + "(council_tax_reduction_liable_person: the household head, the " + "head's partner and the claimants and partners of families liable " + "for a share of the rent, each aged 18 or over; Local Government " + "Finance Act 1992 s.6, s.9, s.75, s.77 and s.77A). The model follows " + "the regulations' wording, " "dividing by every person liable, the claimant's partner included. " "Students are not excluded from the count. Otherwise the share is one." ) @@ -38,11 +38,7 @@ def formula(benunit, period, parameters): # SI 2012/2885 Sch 1 para 7(3)-(4) and para 8(5); the Welsh and # Scottish schemes have the same wording. person = benunit.members - liable = ( - person("is_claimant_or_partner", period) - & (person("age", period) >= 18) - & person.benunit("council_tax_reduction_claimant_benunit", period) - ) + liable = person("council_tax_reduction_liable_person", period) liable_people = benunit.max(person.household.sum(liable)) in_family = benunit.sum(liable) jointly_with_others = (in_family > 0) & (liable_people > in_family) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_liable_person.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_liable_person.py new file mode 100644 index 0000000000..cdc697fb4a --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_liable_person.py @@ -0,0 +1,41 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_liable_person(Variable): + value_type = bool + entity = Person + label = "Treated as liable for the council tax in Council Tax Reduction" + documentation = ( + "Whether the model treats this person as liable to pay the household's " + "council tax. Liability goes to the resident with the greatest " + "interest in the dwelling, jointly where several hold the same " + "interest, and to that person's resident spouse, civil partner or " + "cohabiting partner. A resident is aged 18 or over. The model takes " + "these as liable, each only if aged 18 or over: " + "the household head; " + "the head's partner, where the head is a claimant or partner of their " + "family; " + "and the claimant and partner of any family liable for a share of " + "the household's rent. " + "Other adults in the head's family, such as grown-up children or a " + "young couple living in a grandparent's family, are not liable." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/14/section/6", + "https://www.legislation.gov.uk/ukpga/1992/14/section/9", + "https://www.legislation.gov.uk/ukpga/1992/14/section/75", + "https://www.legislation.gov.uk/ukpga/1992/14/section/77", + "https://www.legislation.gov.uk/ukpga/1992/14/section/77A", + "https://www.legislation.gov.uk/ukpga/1992/14/section/99", + ) + + def formula(person, period, parameters): + adult = person("age", period) >= 18 # s.6(5), s.99(1) + head = person("council_tax_reduction_household_head", period) + claimant_or_partner = person("is_claimant_or_partner", period) + head_is_claimant_or_partner = person.benunit.any(head & claimant_or_partner) + head_family = person.benunit.any(head) + heads_partner = head_family & head_is_claimant_or_partner & claimant_or_partner + sharer = person.benunit("liable_for_share_of_household_rent", period) + return adult & (head | heads_partner | (sharer & claimant_or_partner)) From d9465e9c8322e7c8ff910e313ea63f9338ee3f8a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 10:44:17 -0400 Subject: [PATCH 5/6] Update the tests for the head rule after merging main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - joint_liability.yaml: #2006's "An older parent who is not liable stays the claimant where no rent is shared" asserted the oldest-adult rule this PR replaces. The head's family now claims and the father is its non-dependant; the award stays £0 (default council's working-age scheme, not simulated). Values checked by running the case. - claimant_household_head.yaml: a composition case. Where the rent is shared, a student head's family does not claim, and the co-tenant claims the whole council tax with the head as its non-dependant (LGFA 1992 s.75(4); #2006's student exclusion). - test_council_tax_reduction_claimant_properties.py: generate adults in higher education; the claimant pin and the head-claims check allow for the student exclusion where the rent is shared; a new check that no family there claims through students alone; the non-dependant pin follows main's formula (sharer families and boarders' and lodgers' families). Co-Authored-By: Claude Opus 5.5 --- .../claimant_household_head.yaml | 41 +++ .../joint_liability.yaml | 315 ++++++++++++++++++ ...uncil_tax_reduction_claimant_properties.py | 72 ++-- 3 files changed, 409 insertions(+), 19 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml index 3f858af953..b419f34353 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml @@ -676,3 +676,44 @@ council_tax_reduction_liable_person: [true, false, false, false] council_tax_reduction_claimant_benunit: [true] council_tax_reduction_joint_liability_share: [1] + +- name: Where the rent is shared, a student head's family does not claim and the co-tenant claims the whole council tax + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 30 + is_household_head: true + in_HE: true + cotenant: + age: 30 + is_household_head: false + benunits: + head_family: + members: [head] + cotenant_family: + members: [cotenant] + liable_for_share_of_household_rent: true + council_tax_reduction_applicable_income: 0 + households: + home: + members: [head, cotenant] + rent: 9_000 + tenure_type: RENT_PRIVATELY + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + # The head is treated as liable, but where the rent is shared a student + # claims nothing (#2006, Default Scheme Sch para 75(1)) and is not + # counted among those jointly liable (Sch 1 para 7(5)). Where a student + # shares liability with a non-student, the non-student is solely liable + # (LGFA 1992 s.75(4)), so the co-tenant claims on the whole bill and the + # head is the co-tenant's non-dependant. + council_tax_reduction_liable_person: [true, true] + council_tax_reduction_claimant_benunit: [false, true] + council_tax_reduction_joint_liability_share: [0.5, 1] + council_tax_reduction_individual_non_dep_deduction_eligible: [true, false] + simulated_council_tax_reduction_benunit: [0, 1_800] diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/joint_liability.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/joint_liability.yaml index 47b722480c..b8886db438 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/joint_liability.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/joint_liability.yaml @@ -154,3 +154,318 @@ council_tax_reduction_individual_non_dep_deduction_eligible: [false, false] council_tax_reduction_joint_liability_share: [1, 1] simulated_council_tax_reduction_benunit: [1_800, 0] + +- name: An older parent who is not liable is the head's non-dependant where no rent is shared + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + employment_income: 30_000 + father: + age: 72 + state_pension: 6_000 + benunits: + head_family: + members: [head] + father_family: + members: [father] + households: + home: + members: [head, father] + rent: 9_000 + tenure_type: RENT_PRIVATELY + council_tax: 2_000 + country: ENGLAND + region: LONDON + output: + # No sharer: the household head's family claims, as the resident liable + # for the council tax (LGFA 1992 s.6), however old the father is, and the + # father is the head's non-dependant (SI 2012/2885 reg 9). No local + # authority is given, so the head's working-age claim falls under the + # default council's (Maidstone's) scheme, which the model does not + # simulate. + council_tax_reduction_claimant_benunit: [true, false] + council_tax_reduction_individual_non_dep_deduction_eligible: [false, true] + simulated_council_tax_reduction_benunit: [0, 0] + +- name: A non-liable resident is a non-dependant of the sharers however old + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + cotenant: + age: 35 + father: + age: 72 + council_tax_reduction_individual_non_dep_deduction: 300 + benunits: + head_family: + members: [head] + council_tax_reduction_applicable_income: 0 + cotenant_family: + members: [cotenant] + liable_for_share_of_household_rent: true + council_tax_reduction_applicable_income: 0 + father_family: + members: [father] + households: + home: + members: [head, cotenant, father] + rent: 9_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + country: WALES + region: WALES + output: + council_tax_reduction_claimant_benunit: [true, true, false] + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, true] + # 300 apportioned equally between the two liable persons: 150 each. + council_tax_reduction_non_dep_deductions: [150, 150, 0] + # 1,800 / 2 - 150 = 750 each. + simulated_council_tax_reduction_benunit: [750, 750, 0] + +- name: An excluded student sharer is not counted and does not claim + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 72 + is_household_head: true + student: + age: 25 + in_HE: true + benunits: + head_family: + members: [head] + council_tax_reduction_applicable_income: 0 + student_family: + members: [student] + liable_for_share_of_household_rent: true + households: + home: + members: [head, student] + rent: 9_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + country: WALES + region: WALES + output: + # Sch 1 para 7(5) (Wales Sch 1 para 2(5)): the student is left out of + # the count, so the pensioner's share is 1; a full-time student is + # excluded from entitlement (Default Scheme Sch para 75(1)). + council_tax_reduction_joint_liability_share: [1, 0.5] + council_tax_reduction_claimant_benunit: [true, false] + simulated_council_tax_reduction_benunit: [1_800, 0] + +- name: A working-age sharer does not claim under a pensioner co-tenant's scheme + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + sharer: + age: 40 + benunits: + head_family: + members: [head] + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + council_tax_reduction_applicable_income: 0 + households: + home: + members: [head, sharer] + rent: 15_000 + tenure_type: RENT_PRIVATELY + council_tax: 2_000 + country: ENGLAND + region: SOUTH_EAST + local_authority: MAIDSTONE + output: + # SI 2012/2885 reg 3: each applicant is a pensioner on their own and + # their partner's circumstances. The pensioner claims on half the bill + # under the pension-age scheme. The sharer falls under Maidstone's + # working-age scheme, which the model does not simulate, so keeps the + # reported reduction (none here). + council_tax_reduction_claims_are_joint: true + council_tax_reduction_claim_pensioner: [true, false] + council_tax_reduction_claim_scheme_supported: [true, false] + simulated_council_tax_reduction_benunit: [1_000, 0] + council_tax_benefit: [1_000, 0] + +- name: A pensioner sharer keeps the pension-age scheme beside a mixed-age couple on Universal Credit + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 72 + is_household_head: true + state_pension: 9_000 + partner: + age: 55 + sharer: + age: 68 + state_pension: 9_000 + benunits: + couple: + members: [head, partner] + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + households: + home: + members: [head, partner, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 2_000 + country: ENGLAND + region: SOUTH_EAST + local_authority: MAIDSTONE + brma: MAIDSTONE + output: + # The couple has a Universal Credit award with a working-age member, so + # is not a pensioner (reg 3(1)(a)(ii)); the single sharer over pension + # age is, on her own circumstances. Three people are jointly liable: + # 2,000 / 3 = 666.67 for her claim. The couple falls under the + # working-age scheme, which is not simulated in Maidstone. + council_tax_reduction_pensioner: [false, true] + council_tax_reduction_claim_pensioner: [false, true] + council_tax_reduction_joint_liability_share: [0.333333, 0.333333] + simulated_council_tax_reduction_benunit: [0, 666.67] + council_tax_benefit: [0, 666.67] + +- name: The same households in the other order give the pensioner the same claim + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 68 + is_household_head: true + state_pension: 9_000 + sharer: + age: 72 + is_household_head: false + state_pension: 9_000 + sharer_partner: + age: 55 + is_household_head: false + benunits: + head_family: + members: [head] + couple: + members: [sharer, sharer_partner] + liable_for_share_of_household_rent: true + households: + home: + members: [head, sharer, sharer_partner] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 2_000 + country: ENGLAND + region: SOUTH_EAST + local_authority: MAIDSTONE + brma: MAIDSTONE + output: + # The household's oldest person is in the mixed-age couple, whose + # Universal Credit award makes it working-age; that does not carry over + # to the single pensioner's claim. + council_tax_reduction_pensioner: [true, false] + council_tax_reduction_claim_pensioner: [true, false] + simulated_council_tax_reduction_benunit: [666.67, 0] + council_tax_benefit: [666.67, 0] + +- name: A sharer on PIP is exempt from non-dependant deductions; the other sharer is not + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + is_household_head: true + sharer: + age: 35 + pip_dl: 3_988.40 + non_dependant: + age: 25 + employment_income: 20_000 + benunits: + head_family: + members: [head] + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + council_tax_reduction_applicable_income: 0 + non_dependant_family: + members: [non_dependant] + households: + home: + members: [head, sharer, non_dependant] + rent: 15_000 + tenure_type: RENT_PRIVATELY + council_tax: 2_000 + country: ENGLAND + region: SOUTH_EAST + local_authority: OXFORD + output: + # Oxford's scheme exempts an applicant who, or whose partner, gets the + # PIP daily living component (as SI 2012/2886 Sch para 30(6)); the + # exemption is the applicant's own. The non-dependant's 270.40 is + # apportioned between the two jointly liable people: 135.20 each, and + # nothing from the exempt sharer. + council_tax_reduction_applicant_has_non_dep_exemption: [false, true, false] + oxford_council_tax_reduction_non_dep_deductions: [135.20, 0, 0] + # 2,000 / 2 = 1,000 each, less the head's 135.20. + oxford_council_tax_reduction: [864.80, 1_000, 0] + council_tax_benefit: [864.80, 1_000, 0] + +- name: The head's PIP does not exempt the other sharer's claim + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + is_household_head: true + pip_dl: 3_988.40 + sharer: + age: 35 + non_dependant: + age: 25 + employment_income: 20_000 + benunits: + head_family: + members: [head] + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + council_tax_reduction_applicable_income: 0 + non_dependant_family: + members: [non_dependant] + households: + home: + members: [head, sharer, non_dependant] + rent: 15_000 + tenure_type: RENT_PRIVATELY + council_tax: 2_000 + country: ENGLAND + region: SOUTH_EAST + local_authority: OXFORD + output: + council_tax_reduction_applicant_has_non_dep_exemption: [true, false, false] + oxford_council_tax_reduction_non_dep_deductions: [0, 135.20, 0] + oxford_council_tax_reduction: [1_000, 864.80, 0] + council_tax_benefit: [1_000, 864.80, 0] diff --git a/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py b/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py index 572eec3698..09af6f3d4f 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py @@ -7,28 +7,36 @@ (the household reference person, a householder) as the liable resident, and families liable for a share of the rent as jointly liable with the head's. +Where the rent is shared, a full-time student is excluded from entitlement +(Default Scheme Sch para 75(1)); the model takes a person in higher education +as one, so a family there claims only through a liable non-student. + Invariants, for any generated population of households, including ties in -age, no head flagged, several heads flagged, and sharer families with no -member aged 18 or over. Invariants marked "pin" restate the formula, so they -guard against regressions rather than check it independently. +age, no head flagged, several heads flagged, sharer families with no member +aged 18 or over, and adults in higher education. Invariants marked "pin" +restate the formula, so they guard against regressions rather than check it +independently. 1. One head: exactly one family in each household contains the household head. With one or more members flagged, it holds a flagged member; with none, it holds a member of the greatest age. 2. Claimants: a household whose rent is not shared has at most one claimant family, the head's, and the head's family claims whenever the head is - 18 or over (including a grandparent head whose family's claimant and - partner are 17-year-old parents). A family with no member aged 18 or - over never claims (no one under 18 can be liable, LGFA 1992 s.6(5)). - Every person treated as liable is 18 or over and in the head's family or - a sharer family. Pin: a family claims if and only if it has a member - treated as liable. + 18 or over and, where the rent is shared, not a student (including a + grandparent head whose family's claimant and partner are 17-year-old + parents). A family with no member aged 18 or over never claims (no one + under 18 can be liable, LGFA 1992 s.6(5)), and where the rent is shared + no family claims through students alone. Every person treated as liable + is 18 or over and in the head's family or a sharer family. Pin: a family + claims if and only if it has a member treated as liable who, where the + rent is shared, is not a student. 3. Simulated reductions: no family outside the claimants gets one, and a household's total never exceeds its council tax. (Reported reductions, used where a scheme is not modelled, are outside this test.) 4. Non-dependants: no claimant or partner of a claimant family is a - non-dependant (the applicant's family, SI 2012/2885 reg 9(2)(a)). Pin: - every adult in a family that neither claims nor pays rent is one. + non-dependant (the applicant's family, SI 2012/2885 reg 9(2)(a)). Pin: no + member of a sharer family is one, and every adult outside the claimant + families, the sharer families and boarders' and lodgers' families is one. 5. Differential: where the input flags at most one head, the family holding the head is the one holding the person-level household head that Housing Benefit and Universal Credit use. @@ -79,6 +87,10 @@ def family(draw): ages=draw(st.lists(adult_age, min_size=1, max_size=2)), child_age=draw(st.one_of(st.none(), st.integers(0, 15))), earnings=draw(st.lists(money, min_size=2, max_size=2)), + # Each adult is sometimes in higher education. + students=draw( + st.lists(st.sampled_from([False, False, True]), min_size=2, max_size=2) + ), sharer=draw(st.booleans()), # Outside the first family: sometimes a single person aged 15-17. minor_age=draw(st.one_of(st.none(), st.none(), st.integers(15, 17))), @@ -136,7 +148,11 @@ def build(population, age_override=None): flagged = adult_index in flagged_adults if age_override is not None: age = age_override(house, adult_index, age, flagged) - person = {"age": age, "employment_income": fam["earnings"][i]} + person = { + "age": age, + "employment_income": fam["earnings"][i], + "in_HE": fam["students"][i], + } if house["head_flags"] != "unset": person["is_household_head"] = flagged people[pid] = person @@ -207,11 +223,16 @@ def calc(simulation, variable, map_to=None): return np.asarray(simulation.calculate(variable, YEAR, map_to=map_to)) +def household_of_benunit(facts): + """The household index of each benefit unit.""" + households = np.zeros(facts["sharer"].size, dtype=int) + households[facts["benunit"]] = facts["household"] + return households + + def per_household(values, facts): """Sum benefit-unit values within each household.""" - household_of_benunit = np.zeros(facts["sharer"].size, dtype=int) - household_of_benunit[facts["benunit"]] = facts["household"] - return np.bincount(household_of_benunit, weights=values.astype(float)) + return np.bincount(household_of_benunit(facts), weights=values.astype(float)) @PROPERTY_SETTINGS @@ -253,13 +274,24 @@ def test_claimant_invariants(population): in_head_or_sharer = (head | sharer)[facts["benunit"]] assert np.all(age[liable] >= 18) assert np.all(in_head_or_sharer[liable]) + student = calc(sim, "in_HE") + person_shared = shared[person_household] + excluded_student = person_shared & student head_adult = np.zeros(claimant.size, dtype=bool) - np.logical_or.at(head_adult, facts["benunit"], head_person & (age >= 18)) + np.logical_or.at( + head_adult, + facts["benunit"], + head_person & (age >= 18) & ~excluded_student, + ) assert np.all(claimant[head_adult]) claimants_not_shared = per_household(claimant, facts)[~shared] assert np.all(claimants_not_shared <= 1) + has_adult_non_student = np.zeros(claimant.size, dtype=bool) + np.logical_or.at(has_adult_non_student, facts["benunit"], (age >= 18) & ~student) + benunit_shared = shared[household_of_benunit(facts)] + assert not np.any(claimant & benunit_shared & ~has_adult_non_student) has_liable = np.zeros(claimant.size, dtype=bool) - np.logical_or.at(has_liable, facts["benunit"], liable) + np.logical_or.at(has_liable, facts["benunit"], liable & ~excluded_student) assert np.array_equal(claimant, has_liable) # 3. Simulated reductions: none outside claimant families; never above @@ -276,10 +308,12 @@ def test_claimant_invariants(population): # 4. Non-dependants. non_dep = calc(sim, "council_tax_reduction_individual_non_dep_deduction_eligible") person_claimant = claimant[facts["benunit"]] - person_rent_liable = calc(sim, "benunit_is_rent_liable")[facts["benunit"]] + person_sharer = sharer[facts["benunit"]] + lodger = calc(sim, "pays_rent_to_householder") adult = age >= 18 assert not np.any(non_dep & person_claimant & claimant_or_partner) - assert np.all(non_dep[adult & ~person_claimant & ~person_rent_liable]) + assert not np.any(non_dep & person_sharer) + assert np.all(non_dep[adult & ~person_claimant & ~person_sharer & ~lodger]) # 5. Differential with the person-level household head. Once anyone in # the simulation has the input, everyone else's defaults to false, so an From 3b1e2f32c3c0d8f7d119bf7a42b9c77f14db8559 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 12:51:55 -0400 Subject: [PATCH 6/6] Cite the Scottish provisions in the student-head CTR case Review r5 (P3-3): the case is Scottish, so cite SSI 2021/249 reg 20(2) and reg 79(4) for the student exclusion and the divisor, and note that reg 90(7)(c) makes no deduction for a full-time student non-dependant. The model's national deduction is nil there only because the head has no income; #2007 adds the student exemption. Comment only. Co-Authored-By: Claude Opus 5.5 --- .../claimant_household_head.yaml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml index b419f34353..089272e357 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml @@ -706,12 +706,14 @@ council_tax: 1_800 savings: 0 output: - # The head is treated as liable, but where the rent is shared a student - # claims nothing (#2006, Default Scheme Sch para 75(1)) and is not - # counted among those jointly liable (Sch 1 para 7(5)). Where a student - # shares liability with a non-student, the non-student is solely liable - # (LGFA 1992 s.75(4)), so the co-tenant claims on the whole bill and the - # head is the co-tenant's non-dependant. + # The head is treated as liable, but where the rent is shared the model + # gives a student no claim (#2006; in Scotland SSI 2021/249 reg 20(2)) + # and leaves them out of those jointly liable (reg 79(4)). Where a + # student shares liability with a non-student, the non-student is solely + # liable (LGFA 1992 s.75(4)), so the co-tenant claims on the whole bill + # and the head is the co-tenant's non-dependant. No deduction is made for + # a full-time student non-dependant (reg 90(7)(c)); the model's national + # deduction is nil here only because the head has no income. council_tax_reduction_liable_person: [true, true] council_tax_reduction_claimant_benunit: [false, true] council_tax_reduction_joint_liability_share: [0.5, 1]