From 613326be14b748956f3337afcf574a315eb29ffe Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 09:54:44 -0400 Subject: [PATCH 01/13] Presume a member 20+ years younger is the claimant's child, without flags Unflagged inputs (calculators, policyengine.py, the APIs) kept the under-20 limit because survey childless couples carry no flags. Once the dataset supplies is_claimant_or_partner from the FRS adult table (policyengine-uk-data), the presumption only applies to inputs without roles, so it can follow the FRS base rate: among co-resident HBAI adults (younger 20+), pairs 16-19 years apart are mostly couples (61%) and pairs 20+ apart mostly not (12% at 20-24, under 1% at 25+). - New parameter presumed_child.minimum_age_gap_at_any_age (20): a member at least that much younger is presumed the claimant's child at any age. A member flagged as a parent can still be the partner. - YAML: 50 + 25 unflagged is now single; the 20-year boundary (19 vs 20, and at 90/70); a flagged partner 25 years younger stays the partner; an unflagged 60/38 couple with a child is a lone parent (documented trade-off) unless roles are supplied. The under-20 boundary case moves to an 18-year gap so it still tests the age limit. - Properties: the reference rule follows; the FRS-shape differential now says the change touches only childless couples 20+ years apart (intended), and supplied roles are always kept. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2039-unflagged-gap.changed.md | 1 + .../presumed_child/age_limit.yaml | 2 +- .../presumed_child/minimum_age_gap.yaml | 2 +- .../minimum_age_gap_at_any_age.yaml | 6 + .../demographic/benefit_unit_structure.yaml | 121 +++++++++++++++++- ..._child_and_adult_definitions_properties.py | 86 +++++++++---- .../demographic/is_claimant_or_partner.py | 26 ++-- 7 files changed, 203 insertions(+), 41 deletions(-) create mode 100644 changelog.d/2039-unflagged-gap.changed.md create mode 100644 policyengine_uk/parameters/household/demographic/benefit_unit/presumed_child/minimum_age_gap_at_any_age.yaml diff --git a/changelog.d/2039-unflagged-gap.changed.md b/changelog.d/2039-unflagged-gap.changed.md new file mode 100644 index 0000000000..209a2e5a07 --- /dev/null +++ b/changelog.d/2039-unflagged-gap.changed.md @@ -0,0 +1 @@ +- Without any parent flag, a member at least 20 years younger than the claimant is now presumed to be their child at any age (`presumed_child.minimum_age_gap_at_any_age`), so ages 50 and 25 entered without relationships are a single claimant and an adult child, not a couple. Members 16 to 19 years younger stay partners from age 20. Survey datasets supply `is_claimant_or_partner`, so the presumption only applies to inputs without it. diff --git a/policyengine_uk/parameters/household/demographic/benefit_unit/presumed_child/age_limit.yaml b/policyengine_uk/parameters/household/demographic/benefit_unit/presumed_child/age_limit.yaml index c8544b2abe..739370ef67 100644 --- a/policyengine_uk/parameters/household/demographic/benefit_unit/presumed_child/age_limit.yaml +++ b/policyengine_uk/parameters/household/demographic/benefit_unit/presumed_child/age_limit.yaml @@ -1,4 +1,4 @@ -description: Age below which a benefit-unit member much younger than the claimant is presumed to be the claimant's child rather than their partner, when relationships are not supplied. The limit does not apply when the claimant is flagged as a parent. This is a PolicyEngine modelling presumption, not a statutory rule. +description: Age below which a benefit-unit member much younger than the claimant is presumed to be the claimant's child rather than their partner, when relationships are not supplied. The limit does not apply when the claimant is flagged as a parent, nor to a member younger by at least the minimum age gap at any age. This is a PolicyEngine modelling presumption, not a statutory rule. values: 0001-01-01: 20 metadata: diff --git a/policyengine_uk/parameters/household/demographic/benefit_unit/presumed_child/minimum_age_gap.yaml b/policyengine_uk/parameters/household/demographic/benefit_unit/presumed_child/minimum_age_gap.yaml index 560de05e35..d8d17ed802 100644 --- a/policyengine_uk/parameters/household/demographic/benefit_unit/presumed_child/minimum_age_gap.yaml +++ b/policyengine_uk/parameters/household/demographic/benefit_unit/presumed_child/minimum_age_gap.yaml @@ -1,4 +1,4 @@ -description: Minimum age gap below the claimant at which a younger member is presumed to be the claimant's child rather than their partner, when relationships are not supplied. It applies at any age when the claimant is flagged as a parent, and otherwise only under the presumed child age limit. This is a PolicyEngine modelling presumption, not a statutory rule. +description: Minimum age gap below the claimant at which a younger member is presumed to be the claimant's child rather than their partner, when relationships are not supplied. It applies at any age when the claimant is flagged as a parent, and otherwise only under the presumed child age limit; a larger gap (the minimum age gap at any age) applies at any age without flags. This is a PolicyEngine modelling presumption, not a statutory rule. values: 0001-01-01: 16 metadata: diff --git a/policyengine_uk/parameters/household/demographic/benefit_unit/presumed_child/minimum_age_gap_at_any_age.yaml b/policyengine_uk/parameters/household/demographic/benefit_unit/presumed_child/minimum_age_gap_at_any_age.yaml new file mode 100644 index 0000000000..c8fea14d8c --- /dev/null +++ b/policyengine_uk/parameters/household/demographic/benefit_unit/presumed_child/minimum_age_gap_at_any_age.yaml @@ -0,0 +1,6 @@ +description: Minimum age gap below the claimant at which a younger member is presumed to be the claimant's child rather than their partner at any age, even when the claimant is not flagged as a parent, when relationships are not supplied. A member flagged as a parent can still be the partner. This is a PolicyEngine modelling presumption, not a statutory rule. +values: + 0001-01-01: 20 +metadata: + unit: year + label: presumed child minimum age gap at any age diff --git a/policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml b/policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml index d64b7fab04..c323ffc53c 100644 --- a/policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml +++ b/policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml @@ -373,8 +373,10 @@ is_claimant_or_partner: [true, true, false] # Below a claimant flagged as a parent, a member at least 16 years younger is -# presumed their child at any age. Without flags, the under-20 limit stays: -# survey childless couples carry no flags and include large age gaps. +# presumed their child at any age. Without flags, a member is presumed the +# claimant's child under 20 at a 16-year gap, or at any age at a 20-year gap: +# co-resident FRS adults 16-19 years apart are mostly couples, those 20 or more +# apart mostly not. Survey datasets supply is_claimant_or_partner instead. - name: A flagged lone parent with an adult son is single period: 2025 @@ -397,8 +399,7 @@ relation_type: SINGLE is_couple: false -- name: Without a parent flag, a 25-year gap is still read as a couple - # Documented limitation: indistinguishable from a survey childless couple. +- name: Without flags, a member 25 years younger is presumed the claimant's child period: 2025 absolute_error_margin: 0 input: @@ -414,8 +415,114 @@ household: members: [older, younger] output: - is_claimant_or_partner: [true, true] + is_claimant_or_partner: [true, false] + relation_type: SINGLE + is_couple: false + +- name: Without flags, the 20-year gap at any age is a boundary + period: 2025 + absolute_error_margin: 0 + input: + people: + head_a: + age: 50 + gap_19: + age: 31 + head_b: + age: 50 + gap_20: + age: 30 + head_c: + age: 90 + gap_20_at_70: + age: 70 + benunits: + unit_a: + members: [head_a, gap_19] + unit_b: + members: [head_b, gap_20] + unit_c: + members: [head_c, gap_20_at_70] + households: + household_a: + members: [head_a, gap_19] + household_b: + members: [head_b, gap_20] + household_c: + members: [head_c, gap_20_at_70] + output: + is_claimant_or_partner: [true, true, true, false, true, false] + +- name: A flagged partner 25 years younger stays the partner + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 60 + is_parent: true + partner: + age: 35 + is_parent: true + child: + age: 5 + benunits: + benunit: + members: [claimant, partner, child] + households: + household: + members: [claimant, partner, child] + output: + is_claimant_or_partner: [true, true, false] + is_couple: true + +- name: An unflagged couple 22 years apart with a child is assessed as a lone parent + # Known trade-off of the unflagged presumption: calculators that send ages + # only cannot tell this couple from a parent living with an adult child. + # Supplying is_claimant_or_partner keeps the couple (next case). + period: 2025 + absolute_error_margin: 0 + input: + people: + older: + age: 60 + younger: + age: 38 + child: + age: 10 + benunits: + benunit: + members: [older, younger, child] + households: + household: + members: [older, younger, child] + output: + is_claimant_or_partner: [true, false, false] + is_lone_parent: true + +- name: Supplied roles keep an unflagged couple 22 years apart + period: 2025 + absolute_error_margin: 0 + input: + people: + older: + age: 60 + is_claimant_or_partner: true + younger: + age: 38 + is_claimant_or_partner: true + child: + age: 10 + is_claimant_or_partner: false + benunits: + benunit: + members: [older, younger, child] + households: + household: + members: [older, younger, child] + output: is_couple: true + is_lone_parent: false - name: A flagged parent keeps an unflagged partner close in age, not the adult son period: 2025 @@ -493,11 +600,11 @@ input: people: head_a: - age: 40 + age: 37 aged_19: age: 19 head_b: - age: 40 + age: 38 aged_20: age: 20 benunits: diff --git a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py index 88d8d24faf..bdff80b1ae 100644 --- a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py +++ b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py @@ -11,9 +11,10 @@ the claimant-or-partner presumption follow their documented assumptions. An unflagged member at least 16 years younger than a claimant flagged as a parent is never their partner, at any age; flagging the claimant as a - parent never adds a partner; and benefit units shaped like the FRS's - (one or two adults, all flagged as parents when there are dependants) - keep the earlier under-20 presumption exactly. + parent never adds a partner. On benefit units shaped like the FRS's (one + or two adults, all flagged as parents when there are dependants) the + any-age 20-year gap changes only childless couples 20 or more years apart + (intended), and supplied claimant/partner roles are always kept. 2. HBAI types partition everyone. Any benefit unit with one head has at most two claimants/partners, all of them HBAI adults, and at least one if it has an HBAI adult; valid families (one or two adults aged 20+ and @@ -220,15 +221,16 @@ def hbai_dependent_child(p, family): ) -def claimants_or_partners(family, flagged_parent_lifts_age_limit=True): +def claimants_or_partners(family, any_age_gap=20): # is_claimant_or_partner's documented rule. The claimant is the head if an # HBAI adult, else the eldest HBAI adult. The partner is the eldest other # flagged parent, else the eldest other adult not presumed the claimant's # child: at least 16 years younger and either under 20 or below a claimant - # flagged as a parent. If the claimant is not a flagged parent but two or - # more others are, the two eldest of those are the couple instead. Age ties - # go to the earlier member. flagged_parent_lifts_age_limit=False is the - # earlier rule (under 20 only), kept for the FRS-shape differential test. + # flagged as a parent, or at least 20 years younger at any age. If the + # claimant is not a flagged parent but two or more others are, the two + # eldest of those are the couple instead. Age ties go to the earlier + # member. any_age_gap=None is the earlier rule without the any-age gap, + # kept for the differential test. n = len(family) adult = [not hbai_dependent_child(p, family) for p in family] ages = [p["age"] for p in family] @@ -243,12 +245,15 @@ def claimants_or_partners(family, flagged_parent_lifts_age_limit=True): if len(other_parents) >= 2 and not parent[claimant]: couple = sorted(other_parents, key=lambda i: (-ages[i], i))[:2] return [i in couple for i in range(n)] - no_age_limit = flagged_parent_lifts_age_limit and parent[claimant] + + def presumed_child(i): + gap = ages[claimant] - ages[i] + if any_age_gap is not None and gap >= any_age_gap: + return True + return (ages[i] < 20 or parent[claimant]) and gap >= 16 + pool = other_parents or [ - i - for i in adults - if i != claimant - and not ((ages[i] < 20 or no_age_limit) and ages[claimant] - ages[i] >= 16) + i for i in adults if i != claimant and not presumed_child(i) ] partner = max(pool, key=lambda i: (ages[i], -i)) if pool else None return [i == claimant or i == partner for i in range(n)] @@ -864,16 +869,53 @@ def frs_shaped_units(draw): return families +def survey_roles(family): + # The FRS adult table: everyone in a unit without dependants (nobody is + # flagged), otherwise the adults flagged as parents. + flagged = any(p["is_parent"] for p in family) + return [p["is_parent"] or not flagged for p in family] + + +def childless_couple_20_years_apart(family): + return ( + len(family) == 2 + and all(survey_roles(family)) + and abs(family[0]["age"] - family[1]["age"]) >= 20 + ) + + @PROPERTY_SETTINGS @given(frs_shaped_units()) -def test_frs_shaped_units_keep_the_under_20_presumption(families): - # Differential: on FRS-shaped units the rule equals the earlier, age-limited - # one, so the change cannot move the enhanced FRS microsimulation. +def test_frs_shaped_units_change_only_for_childless_couples_20_years_apart(families): + # Differential against the rule without the any-age gap. On FRS-shaped + # units the two differ only in childless two-adult units 20 or more years + # apart, which carry no flags: there the younger adult is presumed the + # claimant's child (intended). Survey datasets supply is_claimant_or_partner, + # so the microsimulation does not depend on this presumption. sim = simulate(families) - expected = [ - c - for family in families - for c in claimants_or_partners(family, flagged_parent_lifts_age_limit=False) - ] + expected = [c for family in families for c in claimants_or_partners(family)] assert_values(sim, "is_claimant_or_partner", expected) - assert expected == [c for f in families for c in claimants_or_partners(f)] + for family in families: + if not childless_couple_20_years_apart(family): + assert claimants_or_partners(family) == claimants_or_partners( + family, any_age_gap=None + ) + + +@PROPERTY_SETTINGS +@given(frs_shaped_units()) +def test_supplied_claimant_and_partner_roles_are_kept(families): + # policyengine-uk-data supplies is_claimant_or_partner from the FRS adult + # table: the head and any partner, whatever their ages and gap. Supplied + # roles override the presumption, so the FRS couples it would split stay + # couples, and every unit keeps one claimant and at most one partner. + roles = [role for family in families for role in survey_roles(family)] + sim = simulate(families) + sim.set_input("is_claimant_or_partner", YEAR, roles) + assert_values(sim, "is_claimant_or_partner", roles) + is_couple = sim.calculate("is_couple", YEAR) + offset = 0 + for i, family in enumerate(families): + n_roles = sum(roles[offset : offset + len(family)]) + assert is_couple[i] == (n_roles == 2) + offset += len(family) diff --git a/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py b/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py index 847e56e5b0..d798ac6521 100644 --- a/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py +++ b/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py @@ -22,15 +22,19 @@ class is_claimant_or_partner(Variable): parent (`is_parent`) if there is one, otherwise the eldest other adult who is not presumed to be the claimant's child. A member at least 16 years younger than the claimant is presumed to be their child if they - are under 20 or the claimant is flagged as a parent (a PolicyEngine - presumption for households entered without relationships). So a lone + are under 20 or the claimant is flagged as a parent, and a member at + least 20 years younger is presumed their child at any age (PolicyEngine + presumptions for households entered without relationships). So a lone parent flagged `is_parent` who is the claimant and lives with an unflagged son or daughter at least 16 years younger is single, and the son or daughter is neither claimant nor partner. - - Without any parent flag, a member aged 20 or over is never presumed a - child. Survey benefit units carry no flags for childless couples, and - those include large age gaps, so the model cannot tell such a couple - from a parent and adult child. + - Without any parent flag, a member aged 20 or over is presumed a child + only when at least 20 years younger. In the Family Resources Survey, + co-resident adults 20 or more years apart are mostly in separate benefit + units, while those 16 to 19 years apart are mostly couples. A couple 20 + or more years apart entered without flags or roles is therefore assessed + as a single claimant; supply `is_claimant_or_partner` for such a couple. + Survey datasets can supply it from the survey's own benefit units. - Flags follow the Family Resources Survey convention: in a benefit unit with children, both members of the couple are flagged as parents. If only the claimant of a couple with children is flagged, a partner 16 or @@ -82,10 +86,12 @@ def formula(person, period, parameters): person.get_rank(person.benunit, -age, condition=other_parent) < 2 ) # Below a flagged parent the age limit does not apply: a much younger - # unflagged member is their child, whatever their age. - presumed_child = ((age < p.age_limit) | claimant_is_parent) & ( - claimant_age - age >= p.minimum_age_gap - ) + # unflagged member is their child, whatever their age. Without flags, + # only a larger gap overrides the age limit. + gap = claimant_age - age + presumed_child = ( + ((age < p.age_limit) | claimant_is_parent) & (gap >= p.minimum_age_gap) + ) | (gap >= p.minimum_age_gap_at_any_age) other_adult = adult & ~claimant & ~presumed_child pool = where(person.benunit.any(other_parent), other_parent, other_adult) partner = pool & (person.get_rank(person.benunit, -age, condition=pool) == 0) From 04482030aef340b671429dfba37c5933d59997cf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 12:09:36 -0400 Subject: [PATCH 02/13] Test every unflagged adult pair against the 20-year gap Exhaustive over two unflagged adults aged 20-90 (head the elder): the younger is the partner exactly when under 20 years younger. The Hypothesis properties rarely draw an exact 20-year adult pair, so threshold mutants (21, 19, strict >) were caught only by one YAML boundary case; this test kills all three on its own. Co-Authored-By: Claude Opus 5.5 --- .../test_child_and_adult_definitions_properties.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py index dce08537b6..323c3a6d37 100644 --- a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py +++ b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py @@ -934,3 +934,15 @@ def test_supplied_claimant_and_partner_roles_are_kept(families): n_roles = sum(roles[offset : offset + len(family)]) assert is_couple[i] == (n_roles == 2) offset += len(family) + + +def test_unflagged_adult_pairs_follow_the_20_year_gap(): + # Exhaustive over unflagged two-adult units, both 20 to 90, head the + # elder: the younger is the partner exactly when under 20 years younger. + pairs = [(a, b) for a in range(20, 91) for b in range(20, a + 1)] + families = [[person(a, is_benunit_head=True), person(b)] for a, b in pairs] + sim = simulate(families) + claimants = sim.calculate("is_claimant_or_partner", YEAR) + expected = [c for a, b in pairs for c in (True, a - b < 20)] + np.testing.assert_array_equal(claimants, expected) + assert expected == [c for f in families for c in claimants_or_partners(f)] From d8fc7444f73e73757934e94237f35de18b885068 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 15:20:15 -0400 Subject: [PATCH 03/13] Address review r1: supply roles in wide-gap couple fixtures, fix invariants - The 70/45 Lifetime ISA and 79/54 Pension Credit couples supply is_claimant_or_partner and assert it, keeping their capital and eligibility expectations (the new presumption would otherwise split them). - The valid-family property now checks membership against the reference rule exactly and states the one intended exception (an unflagged couple whose head is 20+ years older); module docstring updated. - Deterministic test: a 70/45 couple with each head order, inferred and with supplied roles. YAML: a younger head 20 years below the other adult keeps the couple. - Docstring and differential comment: the split applies when the elder is the claimant. - Reference HBAI helper: an identified parent must be 16 or over, as in is_hbai_dependent_child; the under-16-parent regression now also checks the reference. Co-Authored-By: Claude Opus 5.5 --- .../gov/dwp/lifetime_isa_capital.yaml | 5 ++ .../is_pension_credit_eligible.yaml | 5 ++ .../demographic/benefit_unit_structure.yaml | 22 ++++++++ ..._child_and_adult_definitions_properties.py | 54 +++++++++++++++---- .../demographic/is_claimant_or_partner.py | 9 ++-- 5 files changed, 81 insertions(+), 14 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/lifetime_isa_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/lifetime_isa_capital.yaml index 259462514e..21513b7599 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/lifetime_isa_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/lifetime_isa_capital.yaml @@ -337,15 +337,19 @@ pension_credit_assessable_capital: [4_000, 0] - name: Pension Credit counts a younger partner's Lifetime ISA after the charge + # The couple is 25 years apart, so the roles are supplied: without them the + # 45-year-old would be presumed the claimant's child. period: 2025 absolute_error_margin: 0 input: people: claimant: age: 70 + is_claimant_or_partner: true partner: age: 45 lifetime_isa_balance: 10_000 + is_claimant_or_partner: true benunits: couple: members: [claimant, partner] @@ -353,6 +357,7 @@ household: members: [claimant, partner] output: + is_claimant_or_partner: [true, true] pension_credit_assessable_capital: 7_500 - name: A dependent young person's Lifetime ISA does not count against the parent's claim diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/is_pension_credit_eligible.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/is_pension_credit_eligible.yaml index 00a7b4b387..25527b2b17 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/is_pension_credit_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/is_pension_credit_eligible.yaml @@ -160,17 +160,22 @@ is_pension_credit_eligible: true - name: Case 4, mixed-age couple without protected exception is ineligible. + # The couple is 25 years apart, so the roles are supplied: without them the + # 54-year-old would be presumed the claimant's child. period: 2026 input: people: person1: age: 79 + is_claimant_or_partner: true person2: age: 54 + is_claimant_or_partner: true benunits: benunit: members: [person1, person2] output: + is_claimant_or_partner: [true, true] is_pension_credit_eligible: false - name: Case 5, child in benefit unit does not block pension-age adult eligibility. diff --git a/policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml b/policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml index 6ab26fa965..0ddcccfde6 100644 --- a/policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml +++ b/policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml @@ -454,6 +454,28 @@ output: is_claimant_or_partner: [true, true, true, false, true, false] +- name: A younger head 20 years below the other adult keeps the couple + # The gap is measured below the claimant (the head), so an elder partner is + # never presumed the head's child. + period: 2025 + absolute_error_margin: 0 + input: + people: + head: + age: 50 + is_benunit_head: true + elder: + age: 70 + benunits: + benunit: + members: [head, elder] + households: + household: + members: [head, elder] + output: + is_claimant_or_partner: [true, true] + is_couple: true + - name: A flagged partner 25 years younger stays the partner period: 2025 absolute_error_margin: 0 diff --git a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py index 323c3a6d37..7cd5ef6059 100644 --- a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py +++ b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py @@ -14,12 +14,15 @@ at least 16 years younger explains the flag; flagging the claimant as a parent never adds a partner. On benefit units shaped like the FRS's (one or two adults, all flagged as parents when there are dependants) the - any-age 20-year gap changes only childless couples 20 or more years apart - (intended), and supplied claimant/partner roles are always kept. + any-age 20-year gap changes only childless couples whose claimant is 20 or + more years older than the other adult (intended), and supplied + claimant/partner roles are always kept. 2. HBAI types partition everyone. Any benefit unit with one head has at most two claimants/partners, all of them HBAI adults, and at least one if it has an HBAI adult; valid families (one or two adults aged 20+ and - dependants) have exactly their adults. Couple/single and + dependants) have exactly their adults, except an unflagged couple whose + head is 20+ years older than the other adult (the any-age presumption, + intended), and always when the roles are supplied. Couple/single and couple/lone-parent/single-person partition benefit units. Heads aged 16+ are claimants or partners, including when explicitly younger than others, unless two other members are flagged parents and the head is not. @@ -217,7 +220,7 @@ def hbai_dependent_child(p, family): return True return ( p["age"] < 20 - and any(member["is_parent"] for member in family) + and any(member["is_parent"] and member["age"] >= 16 for member in family) and (non_advanced_education(p) or p["is_in_approved_training"]) ) @@ -558,7 +561,16 @@ def test_valid_family_partition_and_adding_a_child_preserves_claimants( for family in families: old = claimants[old_offset : old_offset + len(family)] new = claimants[new_offset : new_offset + len(family)] - assert sum(old) == sum(p["age"] >= 20 for p in family) + np.testing.assert_array_equal(old, claimants_or_partners(family)) + # Every adult is selected, except in an unflagged couple whose head + # (the claimant) is 20+ years older: the any-age presumption (intended). + adults = [p for p in family if p["age"] >= 20] + wide_gap = ( + len(adults) == 2 + and not adults[0]["is_parent"] + and adults[0]["age"] - adults[1]["age"] >= 20 + ) + assert sum(old) == len(adults) - wide_gap assert sum(old) in (1, 2) np.testing.assert_array_equal(new, old) assert not claimants[new_offset + len(family)] @@ -762,9 +774,13 @@ def test_parent_marked_under_16_entrant_preserves_existing_claimants(): person(20, is_benunit_head=True), person(18, current_education="POST_SECONDARY"), ] - sim = simulate([family, family + [person(0, is_parent=True)]]) + entrant = family + [person(0, is_parent=True)] + sim = simulate([family, entrant]) actual = sim.calculate("is_claimant_or_partner", YEAR) np.testing.assert_array_equal(actual[:2], actual[2:4]) + # The reference ignores a parent flag under 16, as the model does. + expected = claimants_or_partners(family) + claimants_or_partners(entrant) + np.testing.assert_array_equal(actual, expected) @st.composite @@ -903,10 +919,11 @@ def childless_couple_20_years_apart(family): @given(frs_shaped_units()) def test_frs_shaped_units_change_only_for_childless_couples_20_years_apart(families): # Differential against the rule without the any-age gap. On FRS-shaped - # units the two differ only in childless two-adult units 20 or more years - # apart, which carry no flags: there the younger adult is presumed the - # claimant's child (intended). Survey datasets supply is_claimant_or_partner, - # so the microsimulation does not depend on this presumption. + # units the two can differ only in childless two-adult units 20 or more + # years apart, which carry no flags: there, if the claimant (the head) is + # the elder, the younger adult is presumed their child (intended); a + # younger head keeps the couple. Survey datasets supply + # is_claimant_or_partner, so the microsimulation does not depend on this. sim = simulate(families) expected = [c for family in families for c in claimants_or_partners(family)] assert_values(sim, "is_claimant_or_partner", expected) @@ -946,3 +963,20 @@ def test_unflagged_adult_pairs_follow_the_20_year_gap(): expected = [c for a, b in pairs for c in (True, a - b < 20)] np.testing.assert_array_equal(claimants, expected) assert expected == [c for f in families for c in claimants_or_partners(f)] + + +def test_wide_gap_couples_by_head_order_and_with_supplied_roles(): + # An unflagged couple 25 years apart: with the elder as head (the claimant) + # the younger is presumed their child; with the younger as head the elder + # is the partner. Supplied roles keep the couple either way. + elder_head = [person(70, is_benunit_head=True), person(45)] + younger_head = [person(45, is_benunit_head=True), person(70)] + families = [elder_head, younger_head, elder_head, younger_head] + sim = simulate(families) + inferred = sim.calculate("is_claimant_or_partner", YEAR) + np.testing.assert_array_equal(inferred[:4], [True, False, True, True]) + assert claimants_or_partners(elder_head) == [True, False] + assert claimants_or_partners(younger_head) == [True, True] + supplied = simulate(families) + supplied.set_input("is_claimant_or_partner", YEAR, [True] * 8) + np.testing.assert_array_equal(supplied.calculate("is_couple", YEAR), [True] * 4) diff --git a/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py b/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py index efef6965d7..bb8c30710d 100644 --- a/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py +++ b/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py @@ -39,10 +39,11 @@ class is_claimant_or_partner(Variable): only when at least 20 years younger. In the Family Resources Survey, co-resident adults 20 or more years apart are mostly in separate benefit units, while those 16 to 19 years apart are mostly couples. A couple 20 - or more years apart entered without roles is therefore assessed as a - single claimant unless the younger is flagged as a parent; supply - `is_claimant_or_partner` for such a couple. Survey datasets can supply - it from the survey's own benefit units. + or more years apart entered without roles, whose elder is the claimant + (the head, or the eldest adult when no adult head is given), is + therefore assessed as a single claimant unless the younger is flagged as + a parent; supply `is_claimant_or_partner` for such a couple. Survey + datasets can supply it from the survey's own benefit units. - Flags follow the Family Resources Survey convention: in a benefit unit with children, both members of the couple are flagged as parents. When relationships are known, supply this variable for everyone (true for From f43e5ec3b8cf2c355c231b2adc5ed5eb89da42ec Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 07:48:35 -0400 Subject: [PATCH 04/13] Supply roles in couple-generating property tests; correct dataset claims CI on d8fc7444f: two property tests build couples with random ages and no roles, so the 20-year presumption split the wide-gap ones (30/67, 78/49, 93/35) and their independent expectations failed. Their generators now supply is_claimant_or_partner (true for the adults, false for children in the UC test), since the units are couples by construction. The strict xfail for #1942 keeps failing only for its own reason. Review r2: the changelog, test comments and YAML header no longer claim survey datasets supply the role. Only datasets that carry it (the enhanced FRS once policyengine-uk-data#524 ships) are unaffected; the bundle's populace_uk_2023 and 2023-24 FRS overlays carry no role. The FRS-shape differential exempts only units whose head is 20+ years older, and the head-order test checks every person. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2039-unflagged-gap.changed.md | 2 +- .../demographic/benefit_unit_structure.yaml | 2 +- ..._child_and_adult_definitions_properties.py | 31 ++++++++++--------- ...sion_credit_reported_capital_properties.py | 8 ++++- .../tests/test_uc_state_pension_properties.py | 10 ++++-- 5 files changed, 33 insertions(+), 20 deletions(-) diff --git a/changelog.d/2039-unflagged-gap.changed.md b/changelog.d/2039-unflagged-gap.changed.md index 209a2e5a07..e29169cef0 100644 --- a/changelog.d/2039-unflagged-gap.changed.md +++ b/changelog.d/2039-unflagged-gap.changed.md @@ -1 +1 @@ -- Without any parent flag, a member at least 20 years younger than the claimant is now presumed to be their child at any age (`presumed_child.minimum_age_gap_at_any_age`), so ages 50 and 25 entered without relationships are a single claimant and an adult child, not a couple. Members 16 to 19 years younger stay partners from age 20. Survey datasets supply `is_claimant_or_partner`, so the presumption only applies to inputs without it. +- Without any parent flag, a member at least 20 years younger than the claimant is now presumed to be their child at any age (`presumed_child.minimum_age_gap_at_any_age`), so ages 50 and 25 entered without relationships are a single claimant and an adult child, not a couple. Members 16 to 19 years younger stay partners from age 20. The presumption only applies where `is_claimant_or_partner` is not supplied: a dataset that carries it (such as policyengine-uk-data's enhanced FRS once it supplies the FRS roles) is unaffected, while datasets without it are not. diff --git a/policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml b/policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml index 0ddcccfde6..2d5ebe0204 100644 --- a/policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml +++ b/policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml @@ -377,7 +377,7 @@ # years younger already explains the flag. Without flags, a member is presumed # the claimant's child under 20 at a 16-year gap, or at any age at a 20-year # gap: co-resident FRS adults 16-19 years apart are mostly couples, those 20 or -# more apart mostly not. Survey datasets supply is_claimant_or_partner instead. +# more apart mostly not. Datasets and users can supply is_claimant_or_partner instead. - name: A flagged lone parent with an adult son is single period: 2025 diff --git a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py index 7cd5ef6059..810fa9accb 100644 --- a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py +++ b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py @@ -907,12 +907,12 @@ def survey_roles(family): return [p["is_parent"] or not flagged for p in family] -def childless_couple_20_years_apart(family): - return ( - len(family) == 2 - and all(survey_roles(family)) - and abs(family[0]["age"] - family[1]["age"]) >= 20 - ) +def childless_couple_split_by_any_age_gap(family): + # A childless two-adult unit whose head (the claimant) is 20+ years older. + if len(family) != 2 or not all(survey_roles(family)): + return False + head, other = sorted(family, key=lambda p: not p["is_benunit_head"]) + return head["age"] - other["age"] >= 20 @PROPERTY_SETTINGS @@ -922,13 +922,14 @@ def test_frs_shaped_units_change_only_for_childless_couples_20_years_apart(famil # units the two can differ only in childless two-adult units 20 or more # years apart, which carry no flags: there, if the claimant (the head) is # the elder, the younger adult is presumed their child (intended); a - # younger head keeps the couple. Survey datasets supply - # is_claimant_or_partner, so the microsimulation does not depend on this. + # younger head keeps the couple. A dataset that carries + # is_claimant_or_partner (policyengine-uk-data#524's enhanced FRS) does not + # depend on this; datasets without the role do. sim = simulate(families) expected = [c for family in families for c in claimants_or_partners(family)] assert_values(sim, "is_claimant_or_partner", expected) for family in families: - if not childless_couple_20_years_apart(family): + if not childless_couple_split_by_any_age_gap(family): assert claimants_or_partners(family) == claimants_or_partners( family, any_age_gap=None ) @@ -937,8 +938,8 @@ def test_frs_shaped_units_change_only_for_childless_couples_20_years_apart(famil @PROPERTY_SETTINGS @given(frs_shaped_units()) def test_supplied_claimant_and_partner_roles_are_kept(families): - # policyengine-uk-data supplies is_claimant_or_partner from the FRS adult - # table: the head and any partner, whatever their ages and gap. Supplied + # policyengine-uk-data#524 supplies is_claimant_or_partner from the FRS + # adult table: the head and any partner, whatever their ages and gap. Supplied # roles override the presumption, so the FRS couples it would split stay # couples, and every unit keeps one claimant and at most one partner. roles = [role for family in families for role in survey_roles(family)] @@ -971,12 +972,12 @@ def test_wide_gap_couples_by_head_order_and_with_supplied_roles(): # is the partner. Supplied roles keep the couple either way. elder_head = [person(70, is_benunit_head=True), person(45)] younger_head = [person(45, is_benunit_head=True), person(70)] - families = [elder_head, younger_head, elder_head, younger_head] + families = [elder_head, younger_head] sim = simulate(families) inferred = sim.calculate("is_claimant_or_partner", YEAR) - np.testing.assert_array_equal(inferred[:4], [True, False, True, True]) + np.testing.assert_array_equal(inferred, [True, False, True, True]) assert claimants_or_partners(elder_head) == [True, False] assert claimants_or_partners(younger_head) == [True, True] supplied = simulate(families) - supplied.set_input("is_claimant_or_partner", YEAR, [True] * 8) - np.testing.assert_array_equal(supplied.calculate("is_couple", YEAR), [True] * 4) + supplied.set_input("is_claimant_or_partner", YEAR, [True] * 4) + np.testing.assert_array_equal(supplied.calculate("is_couple", YEAR), [True] * 2) diff --git a/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py b/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py index cf5fef6f30..2f88106718 100644 --- a/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py +++ b/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py @@ -77,9 +77,15 @@ def simulate(people, units, household, overrides=None): "members": unit["members"], "pension_credit_reported_capital": {year: value}, } + # Every unit is a single adult or a couple whatever the age gap, so the + # roles are supplied rather than left to the age presumption (which reads + # a member 20+ years younger as the claimant's child). situation = { "people": { - name: {k: {year: v} for k, v in person.items()} + name: { + **{k: {year: v} for k, v in person.items()}, + "is_claimant_or_partner": {year: True}, + } for name, person in people.items() }, "benunits": benunits, diff --git a/policyengine_uk/tests/test_uc_state_pension_properties.py b/policyengine_uk/tests/test_uc_state_pension_properties.py index 9b68022746..d3cec9c8ea 100644 --- a/policyengine_uk/tests/test_uc_state_pension_properties.py +++ b/policyengine_uk/tests/test_uc_state_pension_properties.py @@ -102,7 +102,13 @@ def situation(units, year, income_variable="state_pension", pension_bump=0.0): names = [] for j, age in enumerate(unit["ages"]): name = f"p{i}_{j}" - person = {"age": {year: age}, "state_pension": {year: 0.0}} + # The adults are the couple whatever their age gap, so the roles + # are supplied rather than left to the age presumption. + person = { + "age": {year: age}, + "state_pension": {year: 0.0}, + "is_claimant_or_partner": {year: True}, + } if j == 0: amount = unit["state_pension"] + pension_bump person[income_variable] = {year: amount} @@ -112,7 +118,7 @@ def situation(units, year, income_variable="state_pension", pension_bump=0.0): names.append(name) for k, age in enumerate(unit["children"]): name = f"c{i}_{k}" - people[name] = {"age": {year: age}} + people[name] = {"age": {year: age}, "is_claimant_or_partner": {year: False}} names.append(name) benunits[f"b{i}"] = {"members": names} households[f"h{i}"] = { From 45f349789665deaf245ae5d054ad18fe2ecbafdb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 14:36:57 -0400 Subject: [PATCH 05/13] Merge #2040's head fd5e9474; restate invariant 4 for the any-age gap #2040's docstring example (a flagged 50 with a 25-year-old becomes a couple when a 12-year-old is added) does not hold with the any-age gap: a member 20+ years younger stays the claimant's child either way. The example now uses a 16-19-year gap (38 and 20), checked against the model. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_child_and_adult_definitions_properties.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py index 19e1f1b166..8cbb15efcb 100644 --- a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py +++ b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py @@ -36,8 +36,10 @@ the added child carries a parent marker (an identified parent must be aged 16 or over; the minimised counterexample that found this is kept as a regression test). It does not hold below a flagged claimant whose flag - nothing explains: a flagged 50 with a 25-year-old is single, and adding a - 12-year-old explains the flag and makes them a couple. + nothing explains, for a member 16 to 19 years younger: a flagged 38 with a + 20-year-old is single, and adding a 12-year-old explains the flag and + makes them a couple. A member 20 or more years younger stays the + claimant's child either way (the any-age gap). Before/after families share one Simulation to avoid repeated model construction, but occupy distinct households and benefit units. From d88ac0dc9f61f060127853d365d998bb870c828e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 14:57:49 -0400 Subject: [PATCH 06/13] Supply roles in main's 70/50 mixed-age couple fixtures Merging main brought ten YAML cases (benefit cap for pension-age claimants, CTR pensioner status, the HB earnings disregard) that enter a mixed-age couple aged 70 and 50 without roles. Exactly 20 years apart, the younger partner is presumed the claimant's child, so each case now supplies is_claimant_or_partner for the two partners. Expectations are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../housing_benefit_applicable_income_disregard.yaml | 2 ++ .../gov/dwp/benefit_cap/benefit_cap_pension_age.yaml | 6 ++++++ .../council_tax_reduction_pensioner.yaml | 12 ++++++++++++ 3 files changed, 20 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_disregard.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_disregard.yaml index 95d4477903..00e8f1a8ef 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_disregard.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_disregard.yaml @@ -460,8 +460,10 @@ people: claimant: age: 70 + is_claimant_or_partner: true partner: age: 50 + is_claimant_or_partner: true employment_income: 3_000 benunits: benunit: diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_pension_age.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_pension_age.yaml index b0232bd974..2baa03827a 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_pension_age.yaml @@ -21,8 +21,10 @@ people: older: age: 70 + is_claimant_or_partner: true younger: age: 50 + is_claimant_or_partner: true child_1: age: 4 child_2: @@ -54,8 +56,10 @@ people: older: age: 70 + is_claimant_or_partner: true younger: age: 50 + is_claimant_or_partner: true employment_income: 20_000 child_1: age: 4 @@ -118,8 +122,10 @@ people: older: age: 70 + is_claimant_or_partner: true younger: age: 50 + is_claimant_or_partner: true jsa_income_reported: 4_000 benunits: benunit: diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.yaml index 444244a941..a9026ff940 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.yaml @@ -19,9 +19,11 @@ people: older: age: 70 + is_claimant_or_partner: true council_tax_benefit_reported: 700 younger: age: 50 + is_claimant_or_partner: true benunits: benunit: members: [older, younger] @@ -130,8 +132,10 @@ people: older: age: 70 + is_claimant_or_partner: true younger: age: 50 + is_claimant_or_partner: true benunits: benunit: members: [older, younger] @@ -161,8 +165,10 @@ people: older: age: 70 + is_claimant_or_partner: true younger: age: 50 + is_claimant_or_partner: true son: age: 30 employment_income: 30_000 @@ -261,8 +267,10 @@ people: older: age: 70 + is_claimant_or_partner: true younger: age: 50 + is_claimant_or_partner: true benunits: benunit: members: [older, younger] @@ -285,8 +293,10 @@ people: older: age: 70 + is_claimant_or_partner: true younger: age: 50 + is_claimant_or_partner: true benunits: benunit: members: [older, younger] @@ -318,8 +328,10 @@ people: older: age: 70 + is_claimant_or_partner: true younger: age: 50 + is_claimant_or_partner: true child: age: 9 benunits: From 8de59f799e7628eb0be2a68fde5943771c545afc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 15:19:36 -0400 Subject: [PATCH 07/13] Supply roles in two more couple-generating property tests test_mixed_age_couple_properties and the P6 test in test_benefit_cap_and_ctr_pension_age_properties build couples with random ages (for example 67 and 25) and no roles, so the 20-year presumption split them. Both now supply is_claimant_or_partner for the two partners. Co-Authored-By: Claude Opus 5.5 --- .../test_benefit_cap_and_ctr_pension_age_properties.py | 7 +++++-- policyengine_uk/tests/test_mixed_age_couple_properties.py | 3 +++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py b/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py index ea0ece7cc3..129d7b85b7 100644 --- a/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py @@ -322,8 +322,11 @@ def run(older_is_pension_age): names = [f"p{i}_a", f"p{i}_b"] + [ f"p{i}_c{k}" for k in range(c["children"]) ] - people[names[0]] = {"age": {PERIOD: older}} - people[names[1]] = {"age": {PERIOD: c["working_age"]}} + # The two adults are the couple whatever their age gap, so the + # roles are supplied rather than left to the age presumption. + couple = {"is_claimant_or_partner": {PERIOD: True}} + people[names[0]] = {"age": {PERIOD: older}, **couple} + people[names[1]] = {"age": {PERIOD: c["working_age"]}, **couple} for k, name in enumerate(names[2:]): people[name] = {"age": {PERIOD: 2 + 3 * k}} benunits[f"b{i}"] = { diff --git a/policyengine_uk/tests/test_mixed_age_couple_properties.py b/policyengine_uk/tests/test_mixed_age_couple_properties.py index a6b08165e9..43c388f1a4 100644 --- a/policyengine_uk/tests/test_mixed_age_couple_properties.py +++ b/policyengine_uk/tests/test_mixed_age_couple_properties.py @@ -158,6 +158,9 @@ def cases(draw): people["dependant"] = {"age": 18, "current_education": "UPPER_SECONDARY"} people["older"]["is_parent"] = True for name, person in people.items(): + # The older and younger members are the couple whatever their age gap, + # so the roles are supplied rather than left to the age presumption. + person["is_claimant_or_partner"] = name != "dependant" if name == "dependant": continue if person["age"] >= 67: From 73a7b904b3f5c5234f8aa2cb3cba681ac90e750a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 16:27:24 -0400 Subject: [PATCH 08/13] Supply roles in the CTR pensioner earnings disregard generator Main's new test_council_tax_reduction_pensioner_earnings_disregard_properties builds two-adult families with independently drawn ages and no roles; the 20-year presumption splits the wide-gap ones. The adults now supply is_claimant_or_partner (children false). 7 passed. Co-Authored-By: Claude Opus 5.5 --- ...x_reduction_pensioner_earnings_disregard_properties.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py b/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py index 34b3c49c58..a176c79c66 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py @@ -116,11 +116,17 @@ def situation(units, year, earnings_bump=0.0, no_income_related_benefits=False): "weekly_hours": {year: adult["weekly_hours"]}, "is_disabled_for_benefits": {year: adult["disabled"]}, "state_pension": {year: adult["state_pension"]}, + # The adults are the claimant and partner whatever their age + # gap, so the roles are supplied rather than presumed. + "is_claimant_or_partner": {year: True}, } names.append(name) for k, child_age in enumerate(unit["children"]): name = f"c{i}_{k}" - people[name] = {"age": {year: child_age}} + people[name] = { + "age": {year: child_age}, + "is_claimant_or_partner": {year: False}, + } names.append(name) benunits[f"b{i}"] = { "members": names, From 12962b09fd9751fd4d6e238b5d474cae4b139561 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 17:22:54 -0400 Subject: [PATCH 09/13] Supply roles in the Pension Credit carer income generator PR CI on 73a7b904b: test_scottish_carer_supplement_is_not_means_tested_income drew a pension-age couple 25 years apart (67 and 92) with no roles, so the 20-year presumption split it. Every member of these units is an adult of the couple, so each now supplies is_claimant_or_partner. 3 passed; it was the only failure among 2,693 Python tests and 2,006 YAML cases in that run. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_pension_credit_carer_income_properties.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/policyengine_uk/tests/test_pension_credit_carer_income_properties.py b/policyengine_uk/tests/test_pension_credit_carer_income_properties.py index ac8cd72622..c4e7663911 100644 --- a/policyengine_uk/tests/test_pension_credit_carer_income_properties.py +++ b/policyengine_uk/tests/test_pension_credit_carer_income_properties.py @@ -142,6 +142,9 @@ def situation(families): "state_pension": {YEAR: state_pension}, # All private pension goes to the first adult. "private_pension_income": {YEAR: float(pension) * (j == 0)}, + # The adults are the claimant and partner whatever their + # age gap, so the roles are supplied rather than presumed. + "is_claimant_or_partner": {YEAR: True}, } if is_carer: if fam["by_hours"]: From 37f9d5917f2519437b5dc7a44826f25ffa69fd6e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 20:00:17 -0400 Subject: [PATCH 10/13] Supply roles to main's couples 20+ years apart Main added more tests that build a couple 20 or more years apart without is_claimant_or_partner, which the any-age presumption reads as a parent and child. A role audit (the formula logging every case where this rule and the rule without the any-age gap disagree) over the YAML suite and the non-microsimulation pytest suite found them. - YAML: 26 cases in 13 files (mixed-age HB, UC, WTC, Pension Credit, benefit cap and CTR couples) give both partners the role and assert relation_type COUPLE. - Hypothesis helpers: the benefit cap and CTR pension-age families, the HB income-related passport families, the HB LHA order end-to-end households (with a 90/68 example), the HB earnings disregard couples (with a 60/40 example) and the severe disability premium families (with a 60/40 example) supply the roles: the drawn adults true, children and young persons false, and a non-dependant in their own benefit unit true (from 16 in the SDP test, as its carer reference reads them). The benefit cap test also asserts every drawn couple stays a couple. Expectations and oracles are unchanged. Co-Authored-By: Claude Opus 5.5 --- ...enefit_pension_age_personal_allowance.yaml | 3 ++ ...ing_benefit_universal_credit_passport.yaml | 3 ++ .../housing_benefit/pension_age_premiums.yaml | 3 ++ .../family/tax_credits_working_hours.yaml | 6 ++++ .../income/income_floor/uc_MIF_applies.yaml | 3 ++ .../income_floor/uc_mif_earned_income.yaml | 3 ++ .../income/uc_earnings_deductions.yaml | 12 +++++++ ...ork_related_group_apart_from_earnings.yaml | 21 ++++++++++++ .../benefit_cap/benefit_cap_pension_age.yaml | 3 ++ .../state_pension_credit_qualifying_age.yaml | 6 ++++ .../council_tax_reduction_pensioner.yaml | 6 ++++ ...eduction_pensioner_earnings_disregard.yaml | 3 ++ .../legacy_closure_migrant_nations.yaml | 6 ++++ ...efit_cap_and_ctr_pension_age_properties.py | 29 ++++++++++++---- ...g_benefit_earnings_disregard_properties.py | 33 ++++++++++++++++++- ...ome_related_benefit_passport_properties.py | 11 ++++++- ...st_housing_benefit_lha_order_properties.py | 14 +++++--- ...st_severe_disability_premium_properties.py | 21 ++++++++++-- 18 files changed, 171 insertions(+), 15 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml index 213e6a0c50..1d72d96bda 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml @@ -214,10 +214,12 @@ age: 80 months_since_last_birthday: 4.1613 is_male: true + is_claimant_or_partner: true younger: age: 60 months_since_last_birthday: 9.1613 is_male: false + is_claimant_or_partner: true benunits: benunit: members: [older, younger] @@ -227,6 +229,7 @@ housing_benefit_pension_age_regulations_apply: true benefits_premiums: 0 output: + relation_type: COUPLE housing_benefit_attained_pension_age_before_cutoff: true housing_benefit_applicable_amount: 383.35 * 52 # para 1(2)(b), older attained 1 June 2011, younger attained 1 January 2033 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_universal_credit_passport.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_universal_credit_passport.yaml index 4c7eabd6eb..7218062a7b 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_universal_credit_passport.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_universal_credit_passport.yaml @@ -249,9 +249,11 @@ age: 70 private_pension_income: 5_000 housing_benefit_reported: 1 + is_claimant_or_partner: true partner: age: 40 employment_income: 2_000 + is_claimant_or_partner: true benunits: benunit: members: [claimant, partner] @@ -266,6 +268,7 @@ rent: 5_720 savings: 9_000 output: + relation_type: COUPLE housing_benefit_pension_age_regulations_apply: false housing_benefit_on_passporting_benefit: true housing_benefit_applicable_income_disregard: 2_000 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_premiums.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_premiums.yaml index f48a31b1f6..63158af70c 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_premiums.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_premiums.yaml @@ -289,9 +289,11 @@ claimant: age: {2026: 50} is_disabled_for_benefits: {2026: true} + is_claimant_or_partner: true partner: age: {2026: 70} state_pension: {2026: 3_000} + is_claimant_or_partner: true benunits: benunit: members: [claimant, partner] @@ -303,6 +305,7 @@ rent: {2026: 6_240} council_tax: {2026: 2_000} output: + relation_type: COUPLE is_uc_entitled: true housing_benefit_pension_age_regulations_apply: false council_tax_reduction_pensioner: false diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/tax_credits_working_hours.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/tax_credits_working_hours.yaml index b4c1d9e90e..ebdf03cf6a 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/tax_credits_working_hours.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/tax_credits_working_hours.yaml @@ -13,18 +13,21 @@ is_parent: false is_disabled_for_benefits: false working_tax_credit_reported: 1 + is_claimant_or_partner: true adult_1: age: 40 hours_worked: 1040 employment_income: 5000 is_parent: false is_disabled_for_benefits: false + is_claimant_or_partner: true benunits: family: members: - adult_0 - adult_1 output: + relation_type: COUPLE working_tax_credit: 0 is_WTC_eligible: false - name: A worker aged sixty qualifies on their own sixteen hours @@ -39,18 +42,21 @@ is_parent: false is_disabled_for_benefits: false working_tax_credit_reported: 1 + is_claimant_or_partner: true adult_1: age: 40 hours_worked: 0 employment_income: 0 is_parent: false is_disabled_for_benefits: false + is_claimant_or_partner: true benunits: family: members: - adult_0 - adult_1 output: + relation_type: COUPLE is_WTC_eligible: true working_tax_credit: 4935 - name: A thirty hour worker cannot borrow a partners age twenty five diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_MIF_applies.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_MIF_applies.yaml index 8bd4119b6c..9120dc1082 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_MIF_applies.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_MIF_applies.yaml @@ -240,10 +240,13 @@ older: age: 70 self_employment_income: 3_000 + is_claimant_or_partner: true younger: age: 40 self_employment_income: 3_000 + is_claimant_or_partner: true output: + relation_type: COUPLE uc_mif_applies: [false, true] - name: A partner who cannot be a joint claimant is not a claimant diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_mif_earned_income.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_mif_earned_income.yaml index a7c6abd945..38778687b2 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_mif_earned_income.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_mif_earned_income.yaml @@ -531,15 +531,18 @@ age: 70 employment_income: 6_000 would_claim_marriage_allowance: false + is_claimant_or_partner: true self_employed: age: 40 self_employment_income: 3_600 would_claim_marriage_allowance: false + is_claimant_or_partner: true households: household: members: [older, self_employed] region: NORTH_EAST output: + relation_type: COUPLE uc_mif_applies: [false, true] uc_minimum_income_floor: [0, 20_386.03] # 20,386.03 - 6,000. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml index 309195440b..dd01419059 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml @@ -105,9 +105,11 @@ pensioner: age: 70 state_pension: 16_000 + is_claimant_or_partner: true partner: age: 45 employment_income: 13_000 + is_claimant_or_partner: true benunits: benunit: members: [pensioner, partner] @@ -117,6 +119,7 @@ tenure_type: RENT_FROM_COUNCIL rent: 20_000 output: + relation_type: COUPLE # Pensioner: (16,000 - 12,570) x 20% = 686, on State Pension. # Partner: (13,000 - 12,570) x 20% = 86 and (13,000 - 12,569.96) x 8% # = 34.40. Both are above the personal allowance, so there is no @@ -304,9 +307,11 @@ employment_income: 20_000 married_couples_allowance: 11_000 would_claim_marriage_allowance: false + is_claimant_or_partner: true partner: age: 45 would_claim_marriage_allowance: false + is_claimant_or_partner: true benunits: benunit: members: [older, partner] @@ -314,6 +319,7 @@ household: members: [older, partner] output: + relation_type: COUPLE # Married couple's allowance reduction: 10% x 11,000 = 1,100. capped_mcad: [1_100, 0] # (20,000 - 12,570) x 20% = 1,486, less 1,100. @@ -331,9 +337,11 @@ private_pension_income: 5_000 married_couples_allowance: 11_000 would_claim_marriage_allowance: false + is_claimant_or_partner: true partner: age: 45 would_claim_marriage_allowance: false + is_claimant_or_partner: true benunits: benunit: members: [older, partner] @@ -341,6 +349,7 @@ household: members: [older, partner] output: + relation_type: COUPLE # 1,486 on earnings + 5,000 x 20% on the pension - 1,100. income_tax: [1_386, 0] # The reduction still comes off the earnings slice: 1,486 - 1,100. @@ -359,9 +368,11 @@ unused_pension_annual_allowance: 0 married_couples_allowance: 11_000 would_claim_marriage_allowance: false + is_claimant_or_partner: true partner: age: 45 would_claim_marriage_allowance: false + is_claimant_or_partner: true benunits: benunit: members: [older, partner] @@ -369,6 +380,7 @@ household: members: [older, partner] output: + relation_type: COUPLE # Contributions of 65,000 exceed the 60,000 annual allowance by 5,000, # charged at the basic rate: 1,000. personal_pension_contributions_tax: [1_000, 0] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_related_requirements/uc_work_related_group_apart_from_earnings.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_related_requirements/uc_work_related_group_apart_from_earnings.yaml index 536f66f1de..24a1192e83 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_related_requirements/uc_work_related_group_apart_from_earnings.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_related_requirements/uc_work_related_group_apart_from_earnings.yaml @@ -133,9 +133,12 @@ people: older: age: 70 + is_claimant_or_partner: true younger: age: 40 + is_claimant_or_partner: true output: + relation_type: COUPLE uc_work_related_group_apart_from_earnings: [NO_REQUIREMENTS, ALL_REQUIREMENTS] # Reg. 89(1)(a) reads "the qualifying age for state pension credit": State @@ -163,10 +166,13 @@ age: 64 months_since_last_birthday: 6 gender: MALE + is_claimant_or_partner: true younger: age: 40 gender: FEMALE + is_claimant_or_partner: true output: + relation_type: COUPLE has_attained_state_pension_credit_qualifying_age: [true, false] is_SP_age: [false, false] uc_work_related_group_apart_from_earnings: [NO_REQUIREMENTS, ALL_REQUIREMENTS] @@ -181,10 +187,13 @@ age: 63 months_since_last_birthday: 4 gender: MALE + is_claimant_or_partner: true younger: age: 40 gender: FEMALE + is_claimant_or_partner: true output: + relation_type: COUPLE has_attained_state_pension_credit_qualifying_age: [false, false] is_SP_age: [false, false] uc_work_related_group_apart_from_earnings: [ALL_REQUIREMENTS, ALL_REQUIREMENTS] @@ -197,10 +206,13 @@ age: 64 months_since_last_birthday: 4 gender: MALE + is_claimant_or_partner: true younger: age: 41 gender: FEMALE + is_claimant_or_partner: true output: + relation_type: COUPLE has_attained_state_pension_credit_qualifying_age: [true, false] is_SP_age: [false, false] uc_work_related_group_apart_from_earnings: [NO_REQUIREMENTS, ALL_REQUIREMENTS] @@ -215,10 +227,13 @@ age: 64 months_since_last_birthday: 6 gender: FEMALE + is_claimant_or_partner: true younger: age: 40 gender: MALE + is_claimant_or_partner: true output: + relation_type: COUPLE has_attained_state_pension_credit_qualifying_age: [true, false] is_SP_age: [true, false] uc_work_related_group_apart_from_earnings: [NO_REQUIREMENTS, ALL_REQUIREMENTS] @@ -234,10 +249,13 @@ age: 64 months_since_last_birthday: 10 gender: MALE + is_claimant_or_partner: true younger: age: 40 gender: FEMALE + is_claimant_or_partner: true output: + relation_type: COUPLE has_attained_state_pension_credit_qualifying_age: [false, false] is_SP_age: [false, false] uc_work_related_group_apart_from_earnings: [ALL_REQUIREMENTS, ALL_REQUIREMENTS] @@ -253,10 +271,13 @@ months_since_last_birthday: 6 gender: MALE self_employment_income: 5_000 + is_claimant_or_partner: true younger: age: 40 gender: FEMALE + is_claimant_or_partner: true output: + relation_type: COUPLE uc_is_in_gainful_self_employment: [true, false] uc_work_related_group_apart_from_earnings: [NO_REQUIREMENTS, ALL_REQUIREMENTS] uc_mif_applies: [false, false] diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_pension_age.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_pension_age.yaml index 153751dd71..e4754a2f4c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_pension_age.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_pension_age.yaml @@ -92,8 +92,10 @@ people: older: age: 70 + is_claimant_or_partner: true younger: age: 50 + is_claimant_or_partner: true benunits: benunit: members: [older, younger] @@ -105,6 +107,7 @@ members: [older, younger] region: LONDON output: + relation_type: COUPLE is_uc_entitled: false housing_benefit_pension_age_regulations_apply: true is_housing_benefit_benefit_cap_exempt: true diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/state_pension_credit_qualifying_age.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/state_pension_credit_qualifying_age.yaml index 6dd710343d..0031940459 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/state_pension_credit_qualifying_age.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/state_pension_credit_qualifying_age.yaml @@ -557,8 +557,10 @@ age: 50 is_carer_for_benefits: true income_support_reported: 1_000 + is_claimant_or_partner: true partner: age: 70 + is_claimant_or_partner: true benunits: benunit: members: [carer, partner] @@ -566,6 +568,7 @@ household: members: [carer, partner] output: + relation_type: COUPLE pension_credit: 0 income_support_eligible: true @@ -579,9 +582,11 @@ younger: age: 50 income_support_reported: 1_000 + is_claimant_or_partner: true older: age: 70 is_carer_for_benefits: true + is_claimant_or_partner: true benunits: benunit: members: [younger, older] @@ -589,6 +594,7 @@ household: members: [younger, older] output: + relation_type: COUPLE income_support_eligible: false - name: Case 25, a couple that would claim Pension Credit is treated as on it even when its income is too high. diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.yaml index 1796b847d4..e1dbac5588 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.yaml @@ -49,8 +49,10 @@ older: age: 70 council_tax_benefit_reported: 700 + is_claimant_or_partner: true younger: age: 50 + is_claimant_or_partner: true benunits: benunit: members: [older, younger] @@ -63,6 +65,7 @@ council_tax: 2_000 savings: 0 output: + relation_type: COUPLE is_uc_entitled: false council_tax_reduction_pensioner: true council_tax_reduction_scheme_supported: true @@ -79,9 +82,11 @@ older: age: 70 council_tax_benefit_reported: 700 + is_claimant_or_partner: true younger: age: 50 jsa_income_reported: 4_000 + is_claimant_or_partner: true benunits: benunit: members: [older, younger] @@ -94,6 +99,7 @@ council_tax: 2_000 savings: 0 output: + relation_type: COUPLE jsa_income: 4_000 council_tax_reduction_pensioner: false council_tax_benefit: 700 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.yaml index 1f42bf1d23..71ed83563c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.yaml @@ -384,8 +384,10 @@ claimant: age: 70 employment_income: 1_000 + is_claimant_or_partner: true partner: age: 50 + is_claimant_or_partner: true benunits: benunit: members: [claimant, partner] @@ -395,6 +397,7 @@ members: [claimant, partner] country: ENGLAND output: + relation_type: COUPLE # SI 2012/2885 reg 3(2): not a pensioner where the applicant or partner # is on income-related ESA, so the pensioner schedule does not apply. council_tax_reduction_pensioner: false diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_closure_migrant_nations.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_closure_migrant_nations.yaml index d2d2842137..1286eb7752 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_closure_migrant_nations.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_closure_migrant_nations.yaml @@ -156,8 +156,10 @@ employment_income: 8_000 weekly_hours: 30 working_tax_credit_reported: 2_000 + is_claimant_or_partner: true younger: age: 40 + is_claimant_or_partner: true benunits: family: members: [older, younger] @@ -172,6 +174,7 @@ council_tax: 2_000 savings: 0 output: + relation_type: COUPLE legacy_benefits_closed: true claims_uc_at_legacy_closure: true is_uc_eligible: true @@ -187,8 +190,10 @@ employment_income: 8_000 weekly_hours: 30 working_tax_credit_reported: 2_000 + is_claimant_or_partner: true younger: age: 40 + is_claimant_or_partner: true benunits: family: members: [older, younger] @@ -203,6 +208,7 @@ council_tax: 2_000 savings: 0 output: + relation_type: COUPLE legacy_benefits_closed: true claims_uc_at_legacy_closure: true is_uc_eligible: true diff --git a/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py b/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py index d67f849a95..4f19a8b41c 100644 --- a/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py @@ -20,9 +20,10 @@ Universal Credit needs a claimant or partner under the qualifying age (UC Regs 2013 reg 3(2)(a)). Claimant and partner are is_claimant_or_partner, which -presumes a member under 20 and at least 16 years younger than the claimant to -be their child, so a pensioner's qualifying young person is a dependant -whether or not is_parent is set; the draws cover both. "On Universal Credit" +the families supply: the drawn adults are the claimant and partner, and the +qualifying young person and children are not. Left to the age presumption, a +partner 20 or more years younger would be read as the claimant's child, and +a mixed-age couple as a single pensioner. "On Universal Credit" below means an award before the cap with a claimant or partner under State Pension age. Legacy income-related benefit is set as an input. @@ -199,7 +200,10 @@ def situation(units): has_young_person = any(role == "young" for role, _ in unit["members"]) for j, (role, age) in enumerate(unit["members"]): name = f"p{i}_{j}" - person = {"age": {PERIOD: age}} + person = { + "age": {PERIOD: age}, + "is_claimant_or_partner": {PERIOD: role == "adult"}, + } if role == "young": person["current_education"] = {PERIOD: "UPPER_SECONDARY"} elif age >= 67: @@ -215,7 +219,10 @@ def situation(units): names.append(name) for k in range(unit["children"]): name = f"p{i}_c{k}" - people[name] = {"age": {PERIOD: 2 + 3 * k}} + people[name] = { + "age": {PERIOD: 2 + 3 * k}, + "is_claimant_or_partner": {PERIOD: False}, + } names.append(name) benunit = { "members": names, @@ -243,6 +250,7 @@ def situation(units): BENUNIT = [ + "relation_type", "is_uc_entitled", "housing_benefit_pension_age_regulations_apply", "council_tax_reduction_pensioner", @@ -281,7 +289,13 @@ def calculate(units, before=False): names = BEFORE else: names = BENUNIT + HOUSEHOLD - return {name: np.asarray(simulation.calculate(name, PERIOD)) for name in names} + values = {name: simulation.calculate(name, PERIOD) for name in names} + return { + name: np.asarray( + value.decode_to_str() if hasattr(value, "decode_to_str") else value + ) + for name, value in values.items() + } def has_pension_age_claimant(unit): @@ -303,6 +317,9 @@ def test_benefit_cap_and_ctr_pension_age_invariants(units): before = calculate(units, before=True) cap_only = calculate(units, before="cap") for i, unit in enumerate(units): + # The drawn couples stay couples, however far apart their ages. + adults = sum(role == "adult" for role, _ in unit["members"]) + assert now["relation_type"][i] == ("COUPLE" if adults == 2 else "SINGLE") on_uc = bool(now["is_uc_entitled"][i]) and has_working_age_claimant(unit) legacy = unit["legacy"] > 0 # P1 diff --git a/policyengine_uk/tests/test_housing_benefit_earnings_disregard_properties.py b/policyengine_uk/tests/test_housing_benefit_earnings_disregard_properties.py index 06c0663331..412178af99 100644 --- a/policyengine_uk/tests/test_housing_benefit_earnings_disregard_properties.py +++ b/policyengine_uk/tests/test_housing_benefit_earnings_disregard_properties.py @@ -136,11 +136,18 @@ def situation(units, earnings_bump=0.0, income_support=0.0, age_map=None): YEAR: adult["pension_contributions"] }, "childcare_expenses": {YEAR: unit["childcare"] if j == 0 else 0.0}, + # The generated adults are the claimant and partner whatever + # their age gap (or mapped ages), so the roles are supplied + # rather than left to the age presumption. + "is_claimant_or_partner": {YEAR: True}, } names.append(name) for k, child_age in enumerate(unit["children"]): name = f"c{i}_{k}" - people[name] = {"age": {YEAR: child_age}} + people[name] = { + "age": {YEAR: child_age}, + "is_claimant_or_partner": {YEAR: False}, + } names.append(name) benunits[f"b{i}"] = { "members": names, @@ -300,3 +307,27 @@ def test_additional_disregard_history(): for instant, amount in expected.items(): p = system.parameters(instant).gov.dwp.housing_benefit.means_test assert p.income_disregard.worker == amount, instant + + +def test_a_couple_twenty_years_apart_keeps_the_couple_disregard(): + # Supplied as a couple, ages 60 and 40 keep the couple amount; without + # roles the age presumption would read the 40-year-old as the claimant's + # child and apply the single amount instead. + unit = dict( + shape="couple", + adults=[ + dict( + age=age, + employment_income=income, + weekly_hours=0, + disabled=False, + pension_contributions=0.0, + ) + for age, income in ((60, 1_000.0), (40, 0.0)) + ], + children=[], + childcare=0.0, + ) + values = calculate([unit]) + disregard = values["housing_benefit_applicable_income_disregard"][0] + assert abs(disregard - DISREGARD.couple * WEEKS) < 0.01 diff --git a/policyengine_uk/tests/test_housing_benefit_income_related_benefit_passport_properties.py b/policyengine_uk/tests/test_housing_benefit_income_related_benefit_passport_properties.py index 9a9fccd960..ef5625528f 100644 --- a/policyengine_uk/tests/test_housing_benefit_income_related_benefit_passport_properties.py +++ b/policyengine_uk/tests/test_housing_benefit_income_related_benefit_passport_properties.py @@ -108,6 +108,10 @@ def situation(units, bump=0.0, awards=None, ages=None): age = adult["age"] if ages is None else ages[i][j] person = { "age": {YEAR: age}, + # The drawn adults are the claimant and partner, however far + # apart their ages; the age presumption alone would read a + # partner 20 or more years younger as the claimant's child. + "is_claimant_or_partner": {YEAR: True}, "employment_income": {YEAR: adult["employment_income"] + bump}, "private_pension_income": { YEAR: adult["private_pension_income"] + bump @@ -121,7 +125,10 @@ def situation(units, bump=0.0, awards=None, ages=None): members.append(name) for k, child_age in enumerate(unit["children"]): name = f"c{i}_{k}" - people[name] = {"age": {YEAR: child_age}} + people[name] = { + "age": {YEAR: child_age}, + "is_claimant_or_partner": {YEAR: False}, + } members.append(name) family_awards = unit["awards"] if awards is None else awards[i] benunits[f"b{i}"] = { @@ -134,6 +141,8 @@ def situation(units, bump=0.0, awards=None, ages=None): name = f"n{i}" people[name] = { "age": {YEAR: 30}, + # The claimant of their own benefit unit. + "is_claimant_or_partner": {YEAR: True}, "employment_income": {YEAR: unit["non_dependant_earnings"]}, } benunits[f"nb{i}"] = {"members": [name], "would_claim_uc": {YEAR: False}} diff --git a/policyengine_uk/tests/test_housing_benefit_lha_order_properties.py b/policyengine_uk/tests/test_housing_benefit_lha_order_properties.py index ba3818a4f9..13400ccaa5 100644 --- a/policyengine_uk/tests/test_housing_benefit_lha_order_properties.py +++ b/policyengine_uk/tests/test_housing_benefit_lha_order_properties.py @@ -317,9 +317,9 @@ def pension_age_households(draw): # The single pensioner from the issue (State Pension 16,000, above the # minimum guarantee; rent 30,000 against an LHA of 9,467.64), alone and with -# a working-age non-dependant, and a pension-age couple in the same flat. -# Each has rent above the LHA and income above the applicable amount, where -# the two orders differ. +# a working-age non-dependant, and a pension-age couple in the same flat, +# also 22 years apart. Each has rent above the LHA and income above the +# applicable amount, where the two orders differ. END_TO_END_EXAMPLE = [ dict( ages=[70], @@ -333,7 +333,7 @@ def pension_age_households(draw): for non_dependant_earnings in [None, 20_000.0] ] + [ dict( - ages=[70, 68], + ages=ages, state_pension=12_000.0, private_pension=0.0, tenure="RENT_PRIVATELY", @@ -341,6 +341,7 @@ def pension_age_households(draw): lha_rate=9_467.64, non_dependant_earnings=None, ) + for ages in [[70, 68], [90, 68]] ] @@ -353,6 +354,9 @@ def end_to_end(population): name = f"p{i}_{j}" people[name] = { "age": {YEAR: age}, + # The drawn pensioners are the claimant and partner, however + # far apart their ages. + "is_claimant_or_partner": {YEAR: True}, "state_pension_reported": {YEAR: unit["state_pension"]}, "private_pension_income": {YEAR: unit["private_pension"]}, } @@ -368,6 +372,8 @@ def end_to_end(population): name = f"n{i}" people[name] = { "age": {YEAR: 30}, + # The claimant of their own benefit unit. + "is_claimant_or_partner": {YEAR: True}, "employment_income": {YEAR: unit["non_dependant_earnings"]}, } benunits[f"n{i}"] = {"members": [name]} diff --git a/policyengine_uk/tests/test_severe_disability_premium_properties.py b/policyengine_uk/tests/test_severe_disability_premium_properties.py index 6e8764ddb8..071df1b402 100644 --- a/policyengine_uk/tests/test_severe_disability_premium_properties.py +++ b/policyengine_uk/tests/test_severe_disability_premium_properties.py @@ -98,9 +98,10 @@ def test_northern_ireland_commencements_by_model_year(): "afcs_other": ({"afcs_reported": 1_000.0}, False), } benefit = st.sampled_from(sorted(BENEFITS)) -# Adults aged 20 to 60 and children under 16 are the claimant and partner, and -# their children, under any reading of the benefit unit (18 and 19 year olds -# can be qualifying young persons). +# Adults aged 20 to 60 are the claimant and partner, and children under 16 +# their children; simulate() supplies those roles, since the age presumption +# would read a partner 20 or more years younger as the claimant's child (18 +# and 19 year olds can be qualifying young persons). ADULT_AGE = st.integers(20, 60) @@ -183,11 +184,19 @@ def simulate(units, variables=("severe_disability_premium",)): family = [] for j, p in enumerate(family_members(unit)): people[f"h{i}_f{j}"] = person_inputs(p) + # The generated adults are the claimant and partner; children and + # 16-19 year olds are not. + people[f"h{i}_f{j}"]["is_claimant_or_partner"] = { + YEAR: j < len(unit["adults"]) + } family.append(f"h{i}_f{j}") benunits[f"h{i}_family"] = {"members": family} members = list(family) for j, p in enumerate(unit["others"]): people[f"h{i}_o{j}"] = person_inputs(p) + # Each is the claimant of their own benefit unit from age 16, as + # the carer reference reads them. + people[f"h{i}_o{j}"]["is_claimant_or_partner"] = {YEAR: p["age"] >= 16} benunits[f"h{i}_o{j}"] = {"members": [f"h{i}_o{j}"]} members.append(f"h{i}_o{j}") hh[f"h{i}"] = {"members": members} @@ -292,6 +301,12 @@ def _adult(benefit, blind=False, carer=False): children=[], others=[], ), + # The same couple 20 years apart (60 and 40) keeps the double rate. + dict( + adults=[dict(_adult("pip_standard"), age=60), _adult("aa_lower")], + children=[], + others=[], + ), # One qualifies, the other is blind: the single rate. dict( adults=[_adult("dla_middle"), _adult("none", blind=True)], From 313cf9f5a718e298f4868e153334760dfb4866e5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 23:17:59 -0400 Subject: [PATCH 11/13] Supply roles to the remaining couple generators 20+ years apart CI on 37f9d5917 failed 15 property tests in eight files whose generators draw couples 20 or more years apart without is_claimant_or_partner, which the any-age presumption reads as a parent and child. The role audit also found DfE extended childcare, whose oracle reads both drawn adults as the couple, passing only on its derandomized draws. - Roles supplied (drawn adults true, children and dependants false): Pension Credit earnings disregard and war pensions, CTR carer income, HB pension-age cohorts, UC claimant income, UC company owners (all four builders), the reg 89(1)(a) qualifying-age couples, and DfE extended childcare. - The legacy carer premium test checks the model's inferred roles against its generator, so it keeps inference: an older partner is now drawn under 20 years above the head, with a 40/59 boundary example. Expectations and oracles are unchanged. Co-Authored-By: Claude Opus 5.5 --- ...l_tax_reduction_carer_income_properties.py | 5 ++++ ...fe_childcare_student_support_properties.py | 9 ++++++- ...est_housing_benefit_pension_age_cohorts.py | 5 +++- .../test_legacy_carer_premium_properties.py | 22 +++++++++++++---- ...on_credit_earnings_disregard_properties.py | 4 ++++ ...t_pension_credit_war_pension_properties.py | 4 ++++ .../test_uc_claimant_income_properties.py | 5 ++++ .../tests/test_uc_company_owner_properties.py | 24 ++++++++++++++++--- .../test_uc_work_related_groups_properties.py | 6 ++++- 9 files changed, 74 insertions(+), 10 deletions(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py index e317bf68e5..f68de90e56 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py @@ -186,6 +186,11 @@ def situation(families): name = f"p{i}_{k}_{c}_{j}" person = { "age": {YEAR: a["age"]}, + # The drawn adults are the claimant and partner, + # however far apart their ages; the age presumption + # alone would read a partner 20 or more years younger + # as the claimant's child. + "is_claimant_or_partner": {YEAR: True}, "state_pension": {YEAR: a.get("state_pension", 0.0)}, "employment_income": {YEAR: a.get("employment_income", 0.0)}, # All private pension goes to the first adult. diff --git a/policyengine_uk/tests/test_dfe_childcare_student_support_properties.py b/policyengine_uk/tests/test_dfe_childcare_student_support_properties.py index 4202d88b7e..35f5cee286 100644 --- a/policyengine_uk/tests/test_dfe_childcare_student_support_properties.py +++ b/policyengine_uk/tests/test_dfe_childcare_student_support_properties.py @@ -249,6 +249,9 @@ def test_extended_childcare_eligibility_matches_regs_13_to_15(year, families): for adult in adults: inputs = { "age": adult["age"], + # The drawn adults are the claimant and partner, as the + # oracle below reads them, however far apart their ages. + "is_claimant_or_partner": True, "is_parent": bool(children), "is_carer_for_benefits": adult["status"] == "carer", "employment_income": adult["earnings"], @@ -261,7 +264,11 @@ def test_extended_childcare_eligibility_matches_regs_13_to_15(year, families): people.append(inputs) for age, looked_after in children: people.append( - {"age": age, "is_looked_after_by_local_authority": looked_after} + { + "age": age, + "is_claimant_or_partner": False, + "is_looked_after_by_local_authority": looked_after, + } ) benunit = {"universal_credit": uc, "uc_carer_element": carer_element} built.append((people, benunit, {"country": "ENGLAND"})) diff --git a/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py b/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py index 3334321751..f252da765d 100644 --- a/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py +++ b/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py @@ -65,7 +65,9 @@ def build(units: list, year: int, region: str = "LONDON") -> Simulation: member over State Pension age is under the pension-age Housing Benefit regulations and is a Council Tax Reduction pensioner. That leaves the cohort rule as the only thing under test (a mixed-age couple on Universal - Credit would otherwise be in the working-age tables).""" + Credit would otherwise be in the working-age tables). Every member is the + claimant or partner, and says so: the age presumption would read a + partner 20 or more years younger as the claimant's child.""" people, benunits = {}, {} mid_year = grid_months(date(year, 10, 6)) for u, members in enumerate(units): @@ -78,6 +80,7 @@ def build(units: list, year: int, region: str = "LONDON") -> Simulation: "age": {year: age}, "months_since_last_birthday": {year: age_in_months - 12 * age}, "is_male": {year: male}, + "is_claimant_or_partner": {year: True}, } names.append(name) pension_age = at_pension_age(members, year) diff --git a/policyengine_uk/tests/test_legacy_carer_premium_properties.py b/policyengine_uk/tests/test_legacy_carer_premium_properties.py index 79c0c51fbf..420a91ceb0 100644 --- a/policyengine_uk/tests/test_legacy_carer_premium_properties.py +++ b/policyengine_uk/tests/test_legacy_carer_premium_properties.py @@ -33,8 +33,9 @@ when the one who cares holds the Income Support award. Adults are 18 to 85, so pension-age families are covered. No adult under 20 -is 16 or more years younger than the other adult, whom the model would then -presume to be their parent. +is 16 or more years younger than the other adult, and no adult is 20 or more +years younger, either of which the model would read as the other adult's +child. Each example builds every family twice in one simulation, in separate households and benefit units: once as drawn and once with a changed caring @@ -98,8 +99,9 @@ def families(draw): family = [head] if draw(st.booleans()): # Within 15 years of the head in both directions when either could be - # under 20, so neither is presumed the other's child. - oldest_partner = 85 if head_age >= 20 else head_age + 15 + # under 20, and under 20 years older otherwise, so neither is presumed + # the other's child. + oldest_partner = min(85, head_age + 19) if head_age >= 20 else head_age + 15 family.append( { "age": draw(st.integers(max(18, head_age - 15), oldest_partner)), @@ -160,11 +162,23 @@ def situation(units, same_person=None): }, {"age": 33, "is_parent": False, **CARING}, ] +# And above 20: a partner 19 years older than a 40-year-old head is drawn; 20 +# years older is not (the model would presume the head to be their child). +OLDER_PARTNER_BOUNDARY = [ + { + "age": 40, + "is_parent": False, + "income_support_reported": 1_000, + **CARING, + }, + {"age": 59, "is_parent": False, **CARING}, +] @SETTINGS @given(st.lists(families(), min_size=1, max_size=6)) @example(drawn=[(BOUNDARY, 2)]) +@example(drawn=[(OLDER_PARTNER_BOUNDARY, 2)]) def test_dependants_caring_never_changes_premium_or_is_eligibility(drawn): units = [family for family, _ in drawn] adults = [n for _, n in drawn] diff --git a/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py index cad0a87ef3..a1a24b8711 100644 --- a/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py +++ b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py @@ -110,6 +110,10 @@ def simulate(case): name: {k: {str(YEAR): v} for k, v in person.items()} for name, person in people.items() } + # The claimant and partner say so: the age presumption would read a + # partner 20 or more years younger (85 and 64) as the claimant's child. + for name, person in dated.items(): + person["is_claimant_or_partner"] = {str(YEAR): name != "child"} return Simulation( situation={ "people": dated, diff --git a/policyengine_uk/tests/test_pension_credit_war_pension_properties.py b/policyengine_uk/tests/test_pension_credit_war_pension_properties.py index 122cfcb68e..ef90b4e19d 100644 --- a/policyengine_uk/tests/test_pension_credit_war_pension_properties.py +++ b/policyengine_uk/tests/test_pension_credit_war_pension_properties.py @@ -122,6 +122,10 @@ def family(draw, min_age=67): def person_inputs(fam, j, a, private_pension, afcs, war_widows_pension): person = { "age": {YEAR: a["age"]}, + # The drawn adults are the claimant and partner, however far apart + # their ages; the age presumption alone would read a partner 20 or + # more years younger as the claimant's child. + "is_claimant_or_partner": {YEAR: True}, "state_pension": {YEAR: a["state_pension"]}, # All private pension goes to the first adult. "private_pension_income": {YEAR: float(private_pension) * (j == 0)}, diff --git a/policyengine_uk/tests/test_uc_claimant_income_properties.py b/policyengine_uk/tests/test_uc_claimant_income_properties.py index 19ac9c1b77..c89715d506 100644 --- a/policyengine_uk/tests/test_uc_claimant_income_properties.py +++ b/policyengine_uk/tests/test_uc_claimant_income_properties.py @@ -203,12 +203,17 @@ def situation(units, year, zero_dependants=False, reverse=False, flag_all=False) for j, claimant in enumerate(unit["claimants"]): person = {k: {year: v} for k, v in claimant.items()} person["is_parent"] = {year: bool(unit["dependants"])} + # The drawn claimants are the claimant and partner, however far + # apart their ages; the age presumption alone would read a + # partner 20 or more years younger as the claimant's child. + person["is_claimant_or_partner"] = {year: True} members.append((f"p{i}_{j}", person)) for k, dependant in enumerate(unit["dependants"]): person = { key: {year: 0.0 if zero_dependants and key in DEPENDANT_INCOME else v} for key, v in dependant.items() } + person["is_claimant_or_partner"] = {year: False} members.append((f"d{i}_{k}", person)) if reverse: members = members[::-1] diff --git a/policyengine_uk/tests/test_uc_company_owner_properties.py b/policyengine_uk/tests/test_uc_company_owner_properties.py index 84301ea83c..beafcec75b 100644 --- a/policyengine_uk/tests/test_uc_company_owner_properties.py +++ b/policyengine_uk/tests/test_uc_company_owner_properties.py @@ -178,6 +178,10 @@ def situation(variants): facts = {**adult["company"], **overrides.get(j, {})} people[name] = { "age": {YEAR: adult["age"]}, + # The drawn adults are the claimant and partner, however far + # apart their ages; the age presumption alone would read a + # partner 20 or more years younger as the claimant's child. + "is_claimant_or_partner": {YEAR: True}, "employment_income": {YEAR: adult["employment_income"]}, "self_employment_income": {YEAR: adult["self_employment_income"]}, "uc_is_in_startup_period": {YEAR: adult["uc_is_in_startup_period"]}, @@ -189,7 +193,10 @@ def situation(variants): names.append(name) for k in range(unit["children"]): name = f"c{i}_{k}" - people[name] = {"age": {YEAR: 5 + k}} + people[name] = { + "age": {YEAR: 5 + k}, + "is_claimant_or_partner": {YEAR: False}, + } names.append(name) benunits[f"b{i}"] = {"members": names, "would_claim_uc": {YEAR: True}} households[f"h{i}"] = { @@ -622,6 +629,8 @@ def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs c = adult["company"] people[name] = { "age": {YEAR: adult["age"]}, + # Each unit's adults are its claimant and partner. + "is_claimant_or_partner": {YEAR: True}, **{k: {YEAR: v} for k, v in c.items()}, } if applies(c): @@ -690,12 +699,14 @@ def build(facts): name = f"p{i}_{j}" people[name] = { "age": {YEAR: adult["age"]}, + "is_claimant_or_partner": {YEAR: True}, "employment_income": {YEAR: adult["employment_income"]}, "self_employment_income": {YEAR: adult["self_employment_income"]}, } names.append(name) people[f"d{i}"] = { "age": {YEAR: 17}, + "is_claimant_or_partner": {YEAR: False}, "is_in_non_advanced_education": {YEAR: True}, **{k: {YEAR: v} for k, v in facts.items()}, } @@ -748,15 +759,22 @@ def build(with_holdings): for h, (family, other) in enumerate(pairs): family_names = [] for j, adult in enumerate(family["adults"]): - people[f"h{h}_f{j}"] = {"age": {YEAR: adult["age"]}} + people[f"h{h}_f{j}"] = { + "age": {YEAR: adult["age"]}, + "is_claimant_or_partner": {YEAR: True}, + } family_names.append(f"h{h}_f{j}") dependant = { "age": {YEAR: 17}, + "is_claimant_or_partner": {YEAR: False}, "is_in_non_advanced_education": {YEAR: True}, } other_names = [] for j, adult in enumerate(other["adults"]): - people[f"h{h}_o{j}"] = {"age": {YEAR: adult["age"]}} + people[f"h{h}_o{j}"] = { + "age": {YEAR: adult["age"]}, + "is_claimant_or_partner": {YEAR: True}, + } other_names.append(f"h{h}_o{j}") shares = 0.0 if with_holdings: diff --git a/policyengine_uk/tests/test_uc_work_related_groups_properties.py b/policyengine_uk/tests/test_uc_work_related_groups_properties.py index bbb631832c..bbb38bde64 100644 --- a/policyengine_uk/tests/test_uc_work_related_groups_properties.py +++ b/policyengine_uk/tests/test_uc_work_related_groups_properties.py @@ -472,7 +472,9 @@ def test_partner_who_cannot_be_a_joint_claimant(units, year): def qualifying_age_couples(): """Each birth date, for each sex, as the elder member of a couple whose partner is 40 and has no section 19 to 21 circumstance. The elder is - listed first in each benefit unit.""" + listed first in each benefit unit. Both are given the claimant-or-partner + role, since the age presumption would read a partner 20 or more years + younger as the elder's child.""" people, benunits, households, keys = {}, {}, {}, [] for male in (True, False): for birth in QUALIFYING_AGE_BIRTHS: @@ -487,10 +489,12 @@ def qualifying_age_couples(): "age": ages, "months_since_last_birthday": months, "is_male": {year: male for year in QUALIFYING_AGE_YEARS}, + "is_claimant_or_partner": {year: True for year in QUALIFYING_AGE_YEARS}, } people[partner] = { "age": {year: 40 for year in QUALIFYING_AGE_YEARS}, "is_male": {year: not male for year in QUALIFYING_AGE_YEARS}, + "is_claimant_or_partner": {year: True for year in QUALIFYING_AGE_YEARS}, } benunits[f"b_{elder}"] = {"members": [elder, partner]} households[f"h_{elder}"] = {"members": [elder, partner]} From 60dbff680dfc38107e4cfca6318b7b4474249cfc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 23:19:34 -0400 Subject: [PATCH 12/13] Supply roles to main's new 76/40 benefit cap couple The mixed-age couple in is_uc_benefit_cap_exempt_earnings.yaml (76 and 40, two children, no parent flags) is a couple by construction; the any-age presumption would read the 40-year-old as the claimant's child. Both partners now supply is_claimant_or_partner and the case asserts relation_type COUPLE. Co-Authored-By: Claude Opus 5.5 --- .../gov/dwp/benefit_cap/is_uc_benefit_cap_exempt_earnings.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_uc_benefit_cap_exempt_earnings.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_uc_benefit_cap_exempt_earnings.yaml index dacaa41c45..07ad9c5aab 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_uc_benefit_cap_exempt_earnings.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_uc_benefit_cap_exempt_earnings.yaml @@ -161,8 +161,10 @@ age: 76 employment_income: 11_000 employee_pension_contributions: 1_000 + is_claimant_or_partner: true younger_partner: age: 40 + is_claimant_or_partner: true child1: age: 5 child2: @@ -177,6 +179,7 @@ tenure_type: RENT_FROM_COUNCIL rent: 30_000 output: + relation_type: COUPLE is_uc_assessed_claimant: [true, true, false, false] housing_benefit_pension_age_regulations_apply: false uc_individual_earned_income_before_mif: [11_000, 0, 0, 0] From 916ea84fadaf2ba7315201081ff3c73b2d77abb9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 22 Oct 2026 04:16:37 -0400 Subject: [PATCH 13/13] Close r6's findings: wide-gap couples in the remaining property tests Review r6 found three property tests that fail on valid draws of couples 20 or more years apart (passing CI only on their derandomized examples), and others whose pinned examples or shapes no longer reach their stated path. - Roles supplied, with wide-gap examples: Pension Credit prescribed benefits (90/67) and HB savings-credit-only (80/100, 100/80). - Roles supplied: UC minimum income floor (its 66/40 example), trading losses (the pinned 57/31 family), the HB UC passport, PAWHP household income, pension-age SDP and SDP AFCS. - CTR claimant invariant 6 holds every claimant-or-partner role at the model's reading of the drawn ages before raising the other adults, so the age presumption cannot move them. - UC claimant income and means-test income check the model's own reading of the claimants, so they keep inference: their generators now draw couples under 20 years apart, in place of supplied roles. - Comments corrected where they described the old presumption (CTR claimant family scheme, CTR applicant, benefit cap P1-P5); changelog notes that uk-data has supplied the roles since 1.58.0. Expectations and oracles are unchanged. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2039-unflagged-gap.changed.md | 2 +- ...efit_cap_and_ctr_pension_age_properties.py | 5 ++- ...ncil_tax_reduction_applicant_properties.py | 5 ++- ...ction_claimant_family_scheme_properties.py | 8 +++-- ...uncil_tax_reduction_claimant_properties.py | 16 ++++++--- ..._benefit_savings_credit_only_properties.py | 26 ++++++++++++-- ...it_universal_credit_passport_properties.py | 11 +++++- .../test_means_test_income_properties.py | 6 ++-- .../test_pawhp_household_income_properties.py | 3 ++ ...ge_severe_disability_premium_properties.py | 8 +++-- ...n_credit_prescribed_benefits_properties.py | 23 ++++++++++-- .../test_severe_disability_afcs_properties.py | 3 ++ .../tests/test_trading_loss_properties.py | 5 +++ .../test_uc_claimant_income_properties.py | 36 ++++++++++++------- ...test_uc_minimum_income_floor_properties.py | 16 ++++++--- 15 files changed, 135 insertions(+), 38 deletions(-) diff --git a/changelog.d/2039-unflagged-gap.changed.md b/changelog.d/2039-unflagged-gap.changed.md index e29169cef0..95473ba611 100644 --- a/changelog.d/2039-unflagged-gap.changed.md +++ b/changelog.d/2039-unflagged-gap.changed.md @@ -1 +1 @@ -- Without any parent flag, a member at least 20 years younger than the claimant is now presumed to be their child at any age (`presumed_child.minimum_age_gap_at_any_age`), so ages 50 and 25 entered without relationships are a single claimant and an adult child, not a couple. Members 16 to 19 years younger stay partners from age 20. The presumption only applies where `is_claimant_or_partner` is not supplied: a dataset that carries it (such as policyengine-uk-data's enhanced FRS once it supplies the FRS roles) is unaffected, while datasets without it are not. +- Without any parent flag, a member at least 20 years younger than the claimant is now presumed to be their child at any age (`presumed_child.minimum_age_gap_at_any_age`), so ages 50 and 25 entered without relationships are a single claimant and an adult child, not a couple. Members 16 to 19 years younger stay partners from age 20. The presumption only applies where `is_claimant_or_partner` is not supplied: a dataset that carries it (such as policyengine-uk-data's enhanced FRS, which has supplied the FRS roles since 1.58.0) is unaffected, while datasets without it are not. diff --git a/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py b/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py index 4f19a8b41c..24aa3da4af 100644 --- a/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py @@ -23,7 +23,10 @@ the families supply: the drawn adults are the claimant and partner, and the qualifying young person and children are not. Left to the age presumption, a partner 20 or more years younger would be read as the claimant's child, and -a mixed-age couple as a single pensioner. "On Universal Credit" +a mixed-age couple as a single pensioner. (So these draws no longer exercise +the presumption that reads a pensioner's 18-year-old as a dependant, with or +without is_parent; this change leaves that rule as it was, and +benefit_unit_structure.yaml covers it.) "On Universal Credit" below means an award before the cap with a claimant or partner under State Pension age. Legacy income-related benefit is set as an input. diff --git a/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py b/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py index 82450619df..e690a00ecf 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py @@ -11,7 +11,10 @@ partner (#1896's parent-couple rule). She then applies alone. Invariants, for any generated population of households: heads alone, in a -couple (with partners aged 16 and over), or sharing a benefit unit with two +couple (with partners aged 16 and over; where the two are 20 or more years +apart, or 16 or more with the younger under 20, the model presumes the +younger the elder's child and reads no couple, and the invariants follow the +model's reading), or sharing a benefit unit with two parents aged 16 to 30 and their baby; another family, sometimes a sharer; income-based Jobseeker's Allowance and Universal Credit supplied for the head's benefit unit; the head's disability, Attendance Allowance, Carer's diff --git a/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py index 9849322beb..5a4c72c805 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py @@ -123,9 +123,11 @@ "armed_forces_independence_payment": 9_000, "is_blind": True, } -# Ages of 20 or more are never presumed to be a child of the family, so -# changing them never changes who is a claimant or partner. The sampled values -# sit on both sides of State Pension age. +# Adults are 20 or more. The situation supplies their claimant-or-partner +# role, so changing their ages never changes who is a claimant or partner +# (without the input, a member 20 or more years younger than the claimant +# would be presumed their child). The sampled values sit on both sides of +# State Pension age. adult_age = st.one_of( st.sampled_from([20, 30, 45, 64, 65, 66, 67, 70, 85]), st.integers(20, 100) ) diff --git a/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py b/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py index 6424555e09..37e6168269 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py @@ -43,8 +43,12 @@ person-level is_household_head flag. (Every programme reads the same head; see test_household_head_agreement_properties.py.) -6. Not age: with one head flagged, raising every other member's age above - the head's does not change who claims. +6. Not age: with one head flagged, raising every other adult's age above + the head's does not change who claims. Each person's claimant-or-partner + role is held at the model's reading of the drawn ages: the age + presumption (a member 20 or more years younger than the claimant is their + child) would otherwise read the head, or an adult of another family, as + a raised member's child. 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. @@ -352,9 +356,11 @@ def older_than_head(house, adult_index, age, flagged): raised, _ = build(population, age_override=older_than_head) # 6. Not age. - before = calc( - Simulation(situation=situation), "council_tax_reduction_claimant_benunit" - ) + drawn = Simulation(situation=situation) + before = calc(drawn, "council_tax_reduction_claimant_benunit") + roles = calc(drawn, "is_claimant_or_partner") + for person, role in zip(raised["people"].values(), roles): + person["is_claimant_or_partner"] = {YEAR: bool(role)} after = calc(Simulation(situation=raised), "council_tax_reduction_claimant_benunit") assert np.array_equal(before, after) diff --git a/policyengine_uk/tests/test_housing_benefit_savings_credit_only_properties.py b/policyengine_uk/tests/test_housing_benefit_savings_credit_only_properties.py index 6d19738615..10a39e6a78 100644 --- a/policyengine_uk/tests/test_housing_benefit_savings_credit_only_properties.py +++ b/policyengine_uk/tests/test_housing_benefit_savings_credit_only_properties.py @@ -19,7 +19,9 @@ the savings credit withdrawn, or 39%, and the combined withdrawal is 91%. Invariants, for single people and couples aged 80 or over (so State Pension -age was reached before April 2016 and savings credit is possible), who rent +age was reached before April 2016 and savings credit is possible; a couple's +members supply is_claimant_or_partner, since the age presumption would read a +partner 20 or more years younger as the claimant's child), who rent from the council in England, Wales or Scotland and have no children, carers or earnings, with private pension on a £250 grid: @@ -45,7 +47,7 @@ """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -127,6 +129,7 @@ def situation(families): name = f"p{i}_{k}_{j}" person = { "age": {YEAR: a["age"]}, + "is_claimant_or_partner": {YEAR: True}, "state_pension": {YEAR: a["state_pension"]}, # All private pension goes to the first adult. "private_pension_income": {YEAR: float(pension) * (j == 0)}, @@ -203,8 +206,25 @@ def describe(fam, g, i, k): ) +# Couples 20 years apart, the pension on the younger and then the elder. +WIDE_GAP_COUPLES = [ + dict( + adults=[ + dict(age=first, disability={}, blind=False, state_pension=6_000.0), + dict(age=second, disability={}, blind=False, state_pension=6_000.0), + ], + country="ENGLAND", + rent=6_000.0, + council_tax=1_500.0, + savings=0.0, + ) + for first, second in [(80, 100), (100, 80)] +] + + @PROPERTY_SETTINGS @given(st.lists(family(), min_size=1, max_size=4)) +@example(WIDE_GAP_COUPLES) def test_net_income_rises_with_private_pension_on_savings_credit_only(families): g = grid(families) net = g["household_net_income"] + g["tv_licence"] @@ -223,6 +243,7 @@ def test_net_income_rises_with_private_pension_on_savings_credit_only(families): @PROPERTY_SETTINGS @given(st.lists(family(), min_size=1, max_size=4)) +@example(WIDE_GAP_COUPLES) def test_net_income_does_not_fall_where_savings_credit_ends(families): g = grid(families) net = ( @@ -249,6 +270,7 @@ def deemed_income(capital): @PROPERTY_SETTINGS @given(st.lists(family(), min_size=1, max_size=4)) +@example(WIDE_GAP_COUPLES) def test_income_follows_the_route_for_the_award(families): g = grid(families) savings = np.array([fam["savings"] for fam in families])[:, None] diff --git a/policyengine_uk/tests/test_housing_benefit_universal_credit_passport_properties.py b/policyengine_uk/tests/test_housing_benefit_universal_credit_passport_properties.py index e7d2d7143a..caab1a85b0 100644 --- a/policyengine_uk/tests/test_housing_benefit_universal_credit_passport_properties.py +++ b/policyengine_uk/tests/test_housing_benefit_universal_credit_passport_properties.py @@ -168,6 +168,10 @@ def situation(units, bump=0.0, overrides=None): name = f"a{i}_{j}" person = { "age": {YEAR: adult["age"]}, + # The drawn adults are the claimant and partner, however far + # apart their ages; the age presumption alone would read a + # partner 20 or more years younger as the claimant's child. + "is_claimant_or_partner": {YEAR: True}, "employment_income": {YEAR: adult["employment_income"] + bump}, "private_pension_income": { YEAR: adult["private_pension_income"] + bump @@ -179,7 +183,10 @@ def situation(units, bump=0.0, overrides=None): members.append(name) for k, child_age in enumerate(unit["children"]): name = f"c{i}_{k}" - people[name] = {"age": {YEAR: child_age}} + people[name] = { + "age": {YEAR: child_age}, + "is_claimant_or_partner": {YEAR: False}, + } members.append(name) benunit = { "members": list(members), @@ -202,6 +209,8 @@ def situation(units, bump=0.0, overrides=None): name = f"n{i}" people[name] = { "age": {YEAR: 30}, + # The claimant of their own benefit unit. + "is_claimant_or_partner": {YEAR: True}, "employment_income": {YEAR: unit["non_dependant_earnings"]}, } benunits[f"nb{i}"] = {"members": [name], "would_claim_uc": {YEAR: False}} diff --git a/policyengine_uk/tests/test_means_test_income_properties.py b/policyengine_uk/tests/test_means_test_income_properties.py index 49a9088ef2..341d48f3d9 100644 --- a/policyengine_uk/tests/test_means_test_income_properties.py +++ b/policyengine_uk/tests/test_means_test_income_properties.py @@ -42,7 +42,9 @@ @st.composite def families(draw): - # A head, an optional partner, and one member who by construction is + # A head, an optional partner under 20 years younger (so the age + # presumption reads them as the partner, not the head's child), and one + # member who by construction is # outside every programme's family: a 16-19-year-old at least 16 years # younger than the head (presumed the head's child) who is not in # non-advanced education, so no programme's young person; or a third adult @@ -64,7 +66,7 @@ def families(draw): } ] if kind == "third_adult" or draw(st.booleans()): - partner_age = draw(st.integers(20, head_age)) + partner_age = draw(st.integers(max(20, head_age - 19), head_age)) family.append({"age": partner_age, "employment_income": draw(money)}) if kind == "third_adult": other_age = draw(st.integers(20, partner_age)) diff --git a/policyengine_uk/tests/test_pawhp_household_income_properties.py b/policyengine_uk/tests/test_pawhp_household_income_properties.py index 4ff4255422..05d244a83f 100644 --- a/policyengine_uk/tests/test_pawhp_household_income_properties.py +++ b/policyengine_uk/tests/test_pawhp_household_income_properties.py @@ -142,6 +142,9 @@ def situation(population): for a, adult in enumerate(unit["adults"]): name = f"h{h}_u{u}_a{a}" people[name] = {key: {year: value} for key, value in adult.items()} + # Each unit's adults are its claimant and partner, however far + # apart their ages (40 and 85 are drawn). + people[name]["is_claimant_or_partner"] = {year: True} unit_members.append(name) benunits[f"h{h}_u{u}"] = {"members": unit_members} if unit["pension_credit"] is not None: diff --git a/policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py b/policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py index a0deb726ef..ccf296eb39 100644 --- a/policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py +++ b/policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py @@ -6,8 +6,11 @@ The structural and boundary properties draw single people and couples aged at least 72 without children, carers or other household residents, who rent -from the council in England in 2026. The schedule property also draws -younger claimants: +from the council in England in 2026. A couple's members supply +is_claimant_or_partner, since the age presumption would read a partner 20 or +more years younger (72 and 95 are drawn) as the claimant's child. The +schedule property also draws younger claimants, and reads the model's own +claimant and partner: 1. Structural: where Housing Benefit is available, its applicable amount is at least the Pension Credit minimum guarantee, and so is the Council Tax @@ -107,6 +110,7 @@ def situation(families): name = f"p{i}_{k}_{j}" person = { "age": {YEAR: a["age"]}, + "is_claimant_or_partner": {YEAR: True}, "state_pension": {YEAR: a["state_pension"]}, # All private pension goes to the first adult. "private_pension_income": {YEAR: float(pension) * (j == 0)}, diff --git a/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py b/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py index cbdfaa5d61..1a7a52a654 100644 --- a/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py +++ b/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py @@ -14,7 +14,9 @@ ESA non-qualifying income for Savings Credit; IIDB stays qualifying income. Draws: single people and couples over State Pension age in England, Wales or -Scotland, renting from the council in 2026, with State Pension, a private +Scotland (the drawn adults supply is_claimant_or_partner, since the age +presumption would read a partner 20 or more years younger as the claimant's +child), renting from the council in 2026, with State Pension, a private pension and any of the four benefits (each on a drawn member, at a drawn amount). Some families also have a carer receiving Carer's Allowance or, in Scotland, Carer Support Payment, so the sources #1952 added are covered @@ -65,7 +67,7 @@ """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -141,6 +143,7 @@ def situation(rows): name = f"p{r}_{j}" person = { "age": {YEAR: a["age"]}, + "is_claimant_or_partner": {YEAR: True}, "state_pension": {YEAR: a["state_pension"]}, # All private pension goes to the first adult. "private_pension_income": {YEAR: float(pension) * (j == 0)}, @@ -229,8 +232,24 @@ def test_pension_credit_and_housing_benefit_assess_the_same_prescribed_benefits( ) +# A couple 23 years apart, the IIDB on the younger partner: their benefit is +# the couple's income. +WIDE_GAP_COUPLE = dict( + adults=[dict(age=90, state_pension=4_000.0), dict(age=67, state_pension=4_000.0)], + benefits={benefit: (1, 0.0) for benefit in PRESCRIBED_BENEFITS} + | {"iidb": (1, 2_000.0)}, + carer=None, + by_hours=False, + country="ENGLAND", + rent=6_000.0, + council_tax=1_500.0, + savings=0.0, +) + + @PROPERTY_SETTINGS @given(st.lists(family(state_pension=8_000), min_size=1, max_size=4)) +@example([WIDE_GAP_COUPLE]) def test_guarantee_credit_falls_pound_for_pound_with_each_prescribed_benefit( families, ): diff --git a/policyengine_uk/tests/test_severe_disability_afcs_properties.py b/policyengine_uk/tests/test_severe_disability_afcs_properties.py index 0e6b2b32ae..c2ed896051 100644 --- a/policyengine_uk/tests/test_severe_disability_afcs_properties.py +++ b/policyengine_uk/tests/test_severe_disability_afcs_properties.py @@ -96,6 +96,9 @@ def situation(units, afcs=None, afip=None): values["armed_forces_independence_payment"] = afip( name, values["armed_forces_independence_payment"] ) + # The adults are the claimant and partner, however far apart + # their ages (25 and 60 are drawn); dependants are neither. + values["is_claimant_or_partner"] = role == "a" if role == "a": values["is_parent"] = bool(unit["dependants"]) elif values["age"] >= 16: diff --git a/policyengine_uk/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py index 2b0c05f5b3..3bb9c7cde9 100644 --- a/policyengine_uk/tests/test_trading_loss_properties.py +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -402,9 +402,13 @@ def families(draw): adults = [head_age] if draw(st.booleans()): adults.append(draw(st.integers(18, 80))) + # The adults are the claimant and partner, however far apart their ages; + # the age presumption alone would read a partner 20 or more years younger + # as the head's child. Children, without the input, are neither. people = [ { "age": age, + "is_claimant_or_partner": True, "employment_income": draw(money(60_000)), "self_employment_income": draw(money(30_000)), "private_pension_income": draw(money(20_000)), @@ -456,6 +460,7 @@ def family_situation(units, overrides=None, benunit_overrides=None, year=YEAR): def adult(**inputs): base = { "age": 40, + "is_claimant_or_partner": True, "employment_income": 0.0, "self_employment_income": 0.0, "private_pension_income": 0.0, diff --git a/policyengine_uk/tests/test_uc_claimant_income_properties.py b/policyengine_uk/tests/test_uc_claimant_income_properties.py index c89715d506..858430fc5e 100644 --- a/policyengine_uk/tests/test_uc_claimant_income_properties.py +++ b/policyengine_uk/tests/test_uc_claimant_income_properties.py @@ -73,6 +73,19 @@ "mixed_age": [PENSION_AGE, WORKING_AGE], } amount = st.one_of(st.just(0.0), st.floats(0, 30_000)) + + +def claimant_ages(draw, strategies): + """The claimants' ages, each within 19 years of the first. The age + presumption reads a member 20 or more years younger than the claimant as + their child, and invariants 2 and 5 check the model's own reading of who + the claimants are, so the draws keep to couples it reads as couples.""" + ages = [draw(strategies[0])] + for strategy in strategies[1:]: + ages.append(draw(strategy.filter(lambda a, first=ages[0]: abs(a - first) < 20))) + return ages + + # What a dependant may receive. The last two are contributory benefits, which # count towards the benefit cap total when they are the claimant's. # Contributory ESA is left out: any member's ESA exempts the benefit unit from @@ -143,7 +156,7 @@ def families(draw): if kind == "capped": # Claimants without earnings, several children and a high council # rent: over the cap unless something exempts them. - claimants = [dict(age=draw(age)) for age in SHAPES[shape]] + claimants = [dict(age=age) for age in claimant_ages(draw, SHAPES[shape])] return dict( claimants=claimants, dependants=draw(dependants(minimum=3)), @@ -156,8 +169,8 @@ def families(draw): # Every claimant works, so the work condition is the claimants' to # meet. claimants = [ - dict(age=draw(age), employment_income=draw(st.floats(3_000, 30_000))) - for age in SHAPES[shape] + dict(age=age, employment_income=draw(st.floats(3_000, 30_000))) + for age in claimant_ages(draw, SHAPES[shape]) ] return dict( claimants=claimants, @@ -169,12 +182,12 @@ def families(draw): ) claimants = [ dict( - age=draw(age), + age=age, employment_income=draw(amount), savings_interest_income=draw(amount), private_pension_income=draw(amount), ) - for age in SHAPES[shape] + for age in claimant_ages(draw, SHAPES[shape]) ] return dict( claimants=claimants, @@ -203,17 +216,12 @@ def situation(units, year, zero_dependants=False, reverse=False, flag_all=False) for j, claimant in enumerate(unit["claimants"]): person = {k: {year: v} for k, v in claimant.items()} person["is_parent"] = {year: bool(unit["dependants"])} - # The drawn claimants are the claimant and partner, however far - # apart their ages; the age presumption alone would read a - # partner 20 or more years younger as the claimant's child. - person["is_claimant_or_partner"] = {year: True} members.append((f"p{i}_{j}", person)) for k, dependant in enumerate(unit["dependants"]): person = { key: {year: 0.0 if zero_dependants and key in DEPENDANT_INCOME else v} for key, v in dependant.items() } - person["is_claimant_or_partner"] = {year: False} members.append((f"d{i}_{k}", person)) if reverse: members = members[::-1] @@ -263,8 +271,9 @@ def capped_families(draw): """Working-age claimants with no income, four or five children and a high council rent, so the benefit cap always bites; at least one child has earnings well over the earnings exception's threshold.""" + n = draw(st.integers(1, 2)) claimants = [ - dict(age=draw(st.integers(25, 60))) for _ in range(draw(st.integers(1, 2))) + dict(age=age) for age in claimant_ages(draw, [st.integers(25, 60)] * n) ] children = [ dict(age=draw(st.integers(0, 15)), employment_income=draw(amount)) @@ -335,14 +344,15 @@ def test_assessed_claimants_are_the_claimant_and_partner(units, year): def untaxed_families(draw): """Working-age claimants whose earnings carry no tax or NI, with capital under the £16,000 limit (reg. 18).""" + n = draw(st.integers(1, 2)) claimants = [ dict( - age=draw(st.integers(25, 60)), + age=age, employment_income=draw(untaxed_earnings), savings_interest_income=draw(st.one_of(st.just(0.0), st.floats(0, 500))), private_pension_income=draw(st.one_of(st.just(0.0), st.floats(0, 500))), ) - for _ in range(draw(st.integers(1, 2))) + for age in claimant_ages(draw, [st.integers(25, 60)] * n) ] return dict( claimants=claimants, diff --git a/policyengine_uk/tests/test_uc_minimum_income_floor_properties.py b/policyengine_uk/tests/test_uc_minimum_income_floor_properties.py index 0f7b5eeb53..178697640a 100644 --- a/policyengine_uk/tests/test_uc_minimum_income_floor_properties.py +++ b/policyengine_uk/tests/test_uc_minimum_income_floor_properties.py @@ -12,7 +12,9 @@ Invariants, for any generated population of single people, couples, couples with an older partner aged 65 to 80 (mixed-age once past State -Pension age) and couples with an adult child in their benefit unit, with +Pension age) and couples with an adult child in their benefit unit (the +drawn adults supply is_claimant_or_partner: true for the couple, false for +the adult child and children), with employment, self-employment profits and losses, pension contributions and start-up periods, in England, Wales and Scotland: @@ -72,9 +74,11 @@ # the tax year: Class 4 stays due for that year (SI 2001/1004 reg. 91(a)), # but primary Class 1 and Class 2 stop. PENSION_AGE = st.integers(65, 80) -# The last shape has an adult child in the parents' benefit unit. The model -# flags at most two claimants (is_claimant_or_partner), here the parents; the -# couple is the two eldest claimants. The YAML tests flag a third by input. +# The last shape has an adult child in the parents' benefit unit. The +# situation supplies is_claimant_or_partner for the parents and not the adult +# child, since the age presumption would read a parent 20 or more years +# younger than the other as their child; the couple is the two eldest +# claimants. The YAML tests flag a third by input. SHAPES = { "single": [WORKING_AGE], "couple": [WORKING_AGE, WORKING_AGE], @@ -145,12 +149,14 @@ def situation(units, year, bump=None): if bump is not None and bump[:2] == (i, j): variable, amount = bump[2], bump[3] person[variable] = {year: adult[variable] + amount} + # The couple are the first two adults; an adult child is neither. + person["is_claimant_or_partner"] = {year: j < 2} person["would_claim_marriage_allowance"] = {year: False} people[name] = person names.append(name) for k, age in enumerate(unit["children"]): name = f"c{i}_{k}" - people[name] = {"age": {year: age}} + people[name] = {"age": {year: age}, "is_claimant_or_partner": {year: False}} names.append(name) benunits[f"b{i}"] = {"members": names} households[f"h{i}"] = {