From 01ebb9dbf51db8c520c76c5876e1caa0e033aabe Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 16:02:05 -0400 Subject: [PATCH 1/7] Presume a much younger member is a flagged parent's child at any age A lone parent flagged is_parent who lives with an adult son or daughter was read as a couple. Below a flagged parent the under-20 limit no longer applies. Unflagged inputs keep it: survey childless couples carry no flags and include large age gaps. No enhanced FRS 2024-25 benefit unit changes. Co-Authored-By: Claude Opus 5.5 --- .../claimant-partner-adult-child.fixed.md | 1 + .../demographic/benefit_unit_structure.yaml | 115 ++++++++++++++++++ .../demographic/is_claimant_or_partner.py | 24 +++- 3 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 changelog.d/claimant-partner-adult-child.fixed.md diff --git a/changelog.d/claimant-partner-adult-child.fixed.md b/changelog.d/claimant-partner-adult-child.fixed.md new file mode 100644 index 0000000000..52ca7363e9 --- /dev/null +++ b/changelog.d/claimant-partner-adult-child.fixed.md @@ -0,0 +1 @@ +A member at least 16 years younger than a claimant flagged as a parent is now presumed their child at any age, so a flagged lone parent living with an adult son or daughter is no longer read as a couple. 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 ce6962b0a0..3d6b3da3a9 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 @@ -371,3 +371,118 @@ members: [head_1, head_2, other] output: 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. + +- name: A flagged lone parent with an adult son is single + period: 2025 + absolute_error_margin: 0 + input: + people: + parent: + age: 50 + is_parent: true + son: + age: 25 + benunits: + benunit: + members: [parent, son] + households: + household: + members: [parent, son] + output: + is_claimant_or_partner: [true, false] + 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. + period: 2025 + absolute_error_margin: 0 + input: + people: + older: + age: 50 + younger: + age: 25 + benunits: + benunit: + members: [older, younger] + households: + household: + members: [older, younger] + output: + is_claimant_or_partner: [true, true] + is_couple: true + +- name: A flagged parent keeps an unflagged partner close in age, not the adult son + period: 2025 + absolute_error_margin: 0 + input: + people: + parent: + age: 50 + is_parent: true + partner: + age: 48 + son: + age: 25 + benunits: + benunit: + members: [parent, partner, son] + households: + household: + members: [parent, partner, son] + output: + is_claimant_or_partner: [true, true, false] + +- name: The 16-year gap below a flagged parent is a boundary + period: 2025 + absolute_error_margin: 0 + input: + people: + parent_a: + age: 50 + is_parent: true + gap_16: + age: 34 + parent_b: + age: 50 + is_parent: true + gap_15: + age: 35 + benunits: + unit_a: + members: [parent_a, gap_16] + unit_b: + members: [parent_b, gap_15] + households: + household_a: + members: [parent_a, gap_16] + household_b: + members: [parent_b, gap_15] + output: + is_claimant_or_partner: [true, false, true, true] + +- name: A flagged grandparent's adult daughter is not their partner + period: 2025 + absolute_error_margin: 0 + input: + people: + grandparent: + age: 70 + is_parent: true + daughter: + age: 45 + grandchild: + age: 10 + benunits: + benunit: + members: [grandparent, daughter, grandchild] + households: + household: + members: [grandparent, daughter, grandchild] + output: + is_claimant_or_partner: [true, false, false] 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 f41fbc3db5..a03b473db0 100644 --- a/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py +++ b/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py @@ -20,10 +20,17 @@ class is_claimant_or_partner(Variable): than one is given), or the eldest adult if the head is not an adult. - The partner is one other member: the eldest other member flagged as a parent (`is_parent`) if there is one, otherwise the eldest other adult - who is not presumed to be the claimant's child. A member under 20 and at - least 16 years younger than the claimant is presumed to be their child - (a PolicyEngine presumption for households entered without - relationships). + 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 + parent flagged `is_parent` who lives with an adult son or daughter is + single, and the son or daughter is a non-dependant. + - 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. To enter a parent living with an adult + child, flag the parent with `is_parent` or supply this variable. - If the claimant is not flagged as a parent but two or more other members are, the two eldest of those are the claimant and partner instead (for example parents living in a grandparent's benefit unit). @@ -59,14 +66,19 @@ def formula(person, period, parameters): claimant_age = person.benunit.max(where(claimant, age, -np.inf)) identified_parent = adult & person("is_parent", period) other_parent = identified_parent & ~claimant + claimant_is_parent = person.benunit.any(claimant & identified_parent) # Two flagged parents other than a non-parent claimant are the couple. parents_are_couple = (person.benunit.sum(other_parent) >= 2) & ~( - person.benunit.any(claimant & identified_parent) + claimant_is_parent ) parent_couple = other_parent & ( person.get_rank(person.benunit, -age, condition=other_parent) < 2 ) - presumed_child = (age < p.age_limit) & (claimant_age - age >= p.minimum_age_gap) + # 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 + ) 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 644ef67538fb4becad61b9b10296ab8b1d9c9a2e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 03:28:12 -0400 Subject: [PATCH 2/7] Keep the claimant/partner property reference in step; add three properties - an unflagged member 16+ years below a claimant flagged as a parent is never their partner, at any age; - flagging the claimant as a parent never adds a partner (metamorphic); - FRS-shaped units (one or two adults, all flagged when there are dependants) keep the earlier under-20 rule exactly (differential). Co-Authored-By: Claude Opus 5.5 --- ..._child_and_adult_definitions_properties.py | 139 +++++++++++++++++- 1 file changed, 134 insertions(+), 5 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 2bb2f7413f..88d8d24faf 100644 --- a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py +++ b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py @@ -9,6 +9,11 @@ Legacy children are Child Benefit children/QYPs other than claimants, partners and children placed by a local authority; the HBAI fallback and 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. 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 @@ -215,13 +220,15 @@ def hbai_dependent_child(p, family): ) -def claimants_or_partners(family): +def claimants_or_partners(family, flagged_parent_lifts_age_limit=True): # 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 (under 20 and at least 16 years younger). 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. + # 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. n = len(family) adult = [not hbai_dependent_child(p, family) for p in family] ages = [p["age"] for p in family] @@ -236,10 +243,12 @@ def claimants_or_partners(family): 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] pool = other_parents or [ i for i in adults - if i != claimant and not (ages[i] < 20 and ages[claimant] - ages[i] >= 16) + if i != claimant + and not ((ages[i] < 20 or no_age_limit) and ages[claimant] - ages[i] >= 16) ] partner = max(pool, key=lambda i: (ages[i], -i)) if pool else None return [i == claimant or i == partner for i in range(n)] @@ -748,3 +757,123 @@ def test_parent_marked_under_16_entrant_preserves_existing_claimants(): sim = simulate([family, family + [person(0, is_parent=True)]]) actual = sim.calculate("is_claimant_or_partner", YEAR) np.testing.assert_array_equal(actual[:2], actual[2:4]) + + +@st.composite +def flagged_claimant_units(draw): + # A head flagged as a parent, with unflagged others of any age, education + # and training below or near them. + families = [] + for _ in range(draw(st.integers(1, 8))): + claimant_age = draw(st.integers(32, 90)) + family = [person(claimant_age, is_parent=True, is_benunit_head=True)] + for _ in range(draw(st.integers(1, 4))): + family.append( + person( + draw(st.integers(0, claimant_age)), + current_education=draw(st.sampled_from(EDUCATIONS)), + is_in_approved_training=draw(st.booleans()), + ) + ) + families.append(family) + return families + + +@PROPERTY_SETTINGS +@given(flagged_claimant_units()) +def test_member_far_below_a_flagged_claimant_is_never_their_partner(families): + sim = simulate(families) + assert_structure(sim, families) + claimants = sim.calculate("is_claimant_or_partner", YEAR) + offset = 0 + for family in families: + claimant_age = family[0]["age"] + assert claimants[offset] + for j, p in enumerate(family[1:], start=1): + if claimant_age - p["age"] >= 16: + assert not claimants[offset + j] + offset += len(family) + + +@st.composite +def unflagged_units_with_adult_head(draw): + families = [] + for _ in range(draw(st.integers(1, 8))): + family = [person(draw(st.integers(20, 90)), is_benunit_head=True)] + for _ in range(draw(st.integers(0, 4))): + family.append( + person( + draw(st.integers(0, 90)), + current_education=draw(st.sampled_from(EDUCATIONS)), + is_in_approved_training=draw(st.booleans()), + ) + ) + families.append(family) + return families + + +@PROPERTY_SETTINGS +@given(unflagged_units_with_adult_head()) +def test_flagging_the_claimant_as_a_parent_never_adds_a_partner(families): + # Metamorphic: the same units before and after flagging the head (the + # claimant) as a parent. The flag can only remove members from the partner + # pool, so the claimant stays and the count of claimants and partners + # never rises; any partner left is under 16 years younger. + flagged = [[{**family[0], "is_parent": True}] + family[1:] for family in families] + combined = families + flagged + sim = simulate(combined) + assert_structure(sim, combined) + claimants = sim.calculate("is_claimant_or_partner", YEAR) + n = sum(map(len, families)) + before_offset, after_offset = 0, n + for family in families: + before = claimants[before_offset : before_offset + len(family)] + after = claimants[after_offset : after_offset + len(family)] + assert before[0] and after[0] + assert after.sum() <= before.sum() + for p, partner in zip(family[1:], after[1:]): + if partner: + assert family[0]["age"] - p["age"] < 16 + before_offset += len(family) + after_offset += len(family) + + +@st.composite +def frs_shaped_units(draw): + # The FRS's benefit unit: one adult or a couple (any ages and gap, either + # one the head), plus dependants; every adult is flagged as a parent if and + # only if there are dependants (policyengine-uk-data's is_parent). + families = [] + for _ in range(draw(st.integers(1, 8))): + adults = draw(st.lists(st.integers(16, 90), min_size=1, max_size=2)) + dependants = draw(st.lists(st.integers(0, 19), max_size=4)) + head = draw(st.integers(0, len(adults) - 1)) + family = [ + person(age, is_parent=bool(dependants), is_benunit_head=i == head) + for i, age in enumerate(adults) + ] + for age in dependants: + family.append( + person( + age, + current_education=draw(st.sampled_from(EDUCATIONS)), + is_in_approved_training=draw(st.booleans()), + ) + ) + families.append(family) + return families + + +@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. + sim = simulate(families) + expected = [ + c + for family in families + for c in claimants_or_partners(family, flagged_parent_lifts_age_limit=False) + ] + assert_values(sim, "is_claimant_or_partner", expected) + assert expected == [c for f in families for c in claimants_or_partners(f)] From 8baea5a6f0e8416c9521652b73e37faaaae3316c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 03:32:55 -0400 Subject: [PATCH 3/7] Describe the flagged-parent presumption in the parameters; changelog for #2039 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2039.fixed.md | 1 + changelog.d/claimant-partner-adult-child.fixed.md | 1 - .../demographic/benefit_unit/presumed_child/age_limit.yaml | 2 +- .../benefit_unit/presumed_child/minimum_age_gap.yaml | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 changelog.d/2039.fixed.md delete mode 100644 changelog.d/claimant-partner-adult-child.fixed.md diff --git a/changelog.d/2039.fixed.md b/changelog.d/2039.fixed.md new file mode 100644 index 0000000000..fd7a3fadc6 --- /dev/null +++ b/changelog.d/2039.fixed.md @@ -0,0 +1 @@ +- A member at least 16 years younger than a claimant flagged as a parent is now presumed to be their child at any age, so a flagged lone parent living with an adult son or daughter is no longer inferred as a couple. Without any parent flag the under-20 limit stays, because survey childless couples carry no flags. diff --git a/changelog.d/claimant-partner-adult-child.fixed.md b/changelog.d/claimant-partner-adult-child.fixed.md deleted file mode 100644 index 52ca7363e9..0000000000 --- a/changelog.d/claimant-partner-adult-child.fixed.md +++ /dev/null @@ -1 +0,0 @@ -A member at least 16 years younger than a claimant flagged as a parent is now presumed their child at any age, so a flagged lone parent living with an adult son or daughter is no longer read as a couple. 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 83ffb9fedd..c8544b2abe 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 head is presumed to be the head's child rather than their partner, when relationships are not supplied. 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. 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 19f9dbe80f..560de05e35 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 benefit-unit head at which a younger member is presumed to be the head's child rather than their partner, when relationships are not supplied. 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. This is a PolicyEngine modelling presumption, not a statutory rule. values: 0001-01-01: 16 metadata: From 15354fa4bf21b421768fc14bb90e5bb1f182eac7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 05:32:24 -0400 Subject: [PATCH 4/7] Document the flagged-claimant trade-off and the explicit-role remedy; add boundary cases Review round 1: a couple with children where only the claimant is flagged and the partner is 16+ years younger is now assessed as a lone parent. The docstring now says flags follow the FRS convention (both partners flagged) and recommends supplying is_claimant_or_partner when relationships are known. New YAML: the unflagged under-20 boundary (19 child, 20 partner); the 60/42/10 couple flagged once (lone parent, UC earned income 0), flagged twice, and with explicit roles (couple, UC earned income 31,563.60). Co-Authored-By: Claude Opus 5.5 --- .../demographic/benefit_unit_structure.yaml | 107 ++++++++++++++++++ .../demographic/is_claimant_or_partner.py | 15 ++- 2 files changed, 118 insertions(+), 4 deletions(-) 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 3d6b3da3a9..e8b6208131 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 @@ -486,3 +486,110 @@ members: [grandparent, daughter, grandchild] output: is_claimant_or_partner: [true, false, false] + +- name: Without flags the under-20 limit still applies at the boundary + period: 2025 + absolute_error_margin: 0 + input: + people: + head_a: + age: 40 + aged_19: + age: 19 + head_b: + age: 40 + aged_20: + age: 20 + benunits: + unit_a: + members: [head_a, aged_19] + unit_b: + members: [head_b, aged_20] + households: + household_a: + members: [head_a, aged_19] + household_b: + members: [head_b, aged_20] + output: + is_claimant_or_partner: [true, false, true, true] + +- name: Flagging only one partner of a large-gap couple assesses a lone parent + # Known trade-off of the presumption: the unflagged partner, 18 years + # younger, is presumed the flagged claimant's child, so their earnings drop + # out of the UC means test. + period: 2025 + absolute_error_margin: 1 + input: + people: + claimant: + age: 60 + is_parent: true + partner: + age: 42 + employment_income: 50_000 + child: + age: 10 + benunits: + benunit: + members: [claimant, partner, child] + households: + household: + members: [claimant, partner, child] + output: + is_claimant_or_partner: [true, false, false] + is_lone_parent: true + uc_earned_income: 0 + +- name: Flagging both partners keeps the large-gap couple + period: 2025 + absolute_error_margin: 1 + input: + people: + claimant: + age: 60 + is_parent: true + partner: + age: 42 + is_parent: true + employment_income: 50_000 + child: + age: 10 + benunits: + benunit: + members: [claimant, partner, child] + households: + household: + members: [claimant, partner, child] + output: + is_claimant_or_partner: [true, true, false] + is_couple: true + uc_earned_income: 31_563.60 + universal_credit: 0 + +- name: Supplying claimant and partner roles keeps the large-gap couple + period: 2025 + absolute_error_margin: 1 + input: + people: + claimant: + age: 60 + is_parent: true + is_claimant_or_partner: true + partner: + age: 42 + employment_income: 50_000 + is_claimant_or_partner: true + child: + age: 10 + is_claimant_or_partner: false + benunits: + benunit: + members: [claimant, partner, child] + households: + household: + members: [claimant, partner, child] + output: + is_couple: true + is_lone_parent: false + uc_earned_income: 31_563.60 + universal_credit: 0 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 a03b473db0..84f33d8d1e 100644 --- a/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py +++ b/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py @@ -24,13 +24,20 @@ class is_claimant_or_partner(Variable): 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 - parent flagged `is_parent` who lives with an adult son or daughter is - single, and the son or daughter is a non-dependant. + parent flagged `is_parent` who lives with an unflagged adult son or + daughter 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. To enter a parent living with an adult - child, flag the parent with `is_parent` or supply this variable. + from a parent and adult child. + - 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 + more years younger is presumed to be their child, and the couple is + assessed as a lone parent. When relationships are known, supply this + variable for everyone (true for the claimant and any partner, false for + everyone else) rather than relying on the presumption. - If the claimant is not flagged as a parent but two or more other members are, the two eldest of those are the claimant and partner instead (for example parents living in a grandparent's benefit unit). From 72d70ddbab4f8cbe823b87436ff2ca8ee023fd6c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 07:17:38 -0400 Subject: [PATCH 5/7] Make the large-gap couple cases assert their true/false outputs; narrow the docstring example Review round 2: a margin of 1 let every true/false expectation in those three cases pass whatever the model returned. A margin of 0.01 enforces them (and still accepts UC earned income 31,563.598). The docstring's lone-parent example now states its conditions: the parent is the claimant, and the child is unflagged and at least 16 years younger. Co-Authored-By: Claude Opus 5.5 --- .../household/demographic/benefit_unit_structure.yaml | 6 +++--- .../household/demographic/is_claimant_or_partner.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) 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 e8b6208131..d64b7fab04 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 @@ -518,7 +518,7 @@ # younger, is presumed the flagged claimant's child, so their earnings drop # out of the UC means test. period: 2025 - absolute_error_margin: 1 + absolute_error_margin: 0.01 input: people: claimant: @@ -542,7 +542,7 @@ - name: Flagging both partners keeps the large-gap couple period: 2025 - absolute_error_margin: 1 + absolute_error_margin: 0.01 input: people: claimant: @@ -568,7 +568,7 @@ - name: Supplying claimant and partner roles keeps the large-gap couple period: 2025 - absolute_error_margin: 1 + absolute_error_margin: 0.01 input: people: claimant: 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 84f33d8d1e..847e56e5b0 100644 --- a/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py +++ b/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py @@ -24,9 +24,9 @@ class is_claimant_or_partner(Variable): 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 - parent flagged `is_parent` who lives with an unflagged adult son or - daughter is single, and the son or daughter is neither claimant nor - partner. + 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 c18c836a998003444182c9661984f610967ad47f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 09:53:28 -0400 Subject: [PATCH 6/7] Lift the under-20 limit only when no young child explains the parent flag Review (Vahid, and the Codex input-contract finding): a flagged claimant with a child under 20 who is 16+ years younger has a flag that child explains, so the flag says nothing about an older unflagged member. Lifting the limit only when the flag is unexplained keeps the #2039 fix (flagged 50 with a son of 25 is single) and restores the large-gap couple with a young child (60/42/10) to a couple, so no probed household is worse than before #1896's follow-up. Documented limitations, unchanged from main: a flagged lone parent with an adult and a younger child, and a flagged grandparent with a daughter and grandchild, are read as couples; an unflagged head with exactly one flagged member pairs them whatever the gap. Supply is_claimant_or_partner for these. Co-Authored-By: Claude Opus 5.5 --- .../presumed_child/age_limit.yaml | 2 +- .../presumed_child/minimum_age_gap.yaml | 2 +- .../demographic/benefit_unit_structure.yaml | 73 ++++++++++++++++--- ..._child_and_adult_definitions_properties.py | 35 ++++++--- .../demographic/is_claimant_or_partner.py | 41 +++++++---- 5 files changed, 115 insertions(+), 38 deletions(-) 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..79f19d9244 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 and no member under it, at least the minimum age gap younger, explains the flag. 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..ec32f6adc1 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 no younger member under the presumed child age limit explains the flag, and otherwise only under that limit. This is a PolicyEngine modelling presumption, not a statutory rule. values: 0001-01-01: 16 metadata: 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..e2cdef6982 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,9 @@ 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, unless a member under 20 and at least 16 +# years younger already explains the flag. Without flags, the under-20 limit +# stays: survey childless couples carry no flags and include large age gaps. - name: A flagged lone parent with an adult son is single period: 2025 @@ -466,7 +467,10 @@ output: is_claimant_or_partner: [true, false, true, true] -- name: A flagged grandparent's adult daughter is not their partner +- name: A flagged grandparent's daughter is read as a partner when a grandchild explains the flag + # Documented limitation, as before this rule: the grandchild under 20 + # explains the grandparent's parent flag, so the under-20 limit stays and the + # 45-year-old is the partner. Supply is_claimant_or_partner for such homes. period: 2025 absolute_error_margin: 0 input: @@ -485,7 +489,7 @@ household: members: [grandparent, daughter, grandchild] output: - is_claimant_or_partner: [true, false, false] + is_claimant_or_partner: [true, true, false] - name: Without flags the under-20 limit still applies at the boundary period: 2025 @@ -513,10 +517,9 @@ output: is_claimant_or_partner: [true, false, true, true] -- name: Flagging only one partner of a large-gap couple assesses a lone parent - # Known trade-off of the presumption: the unflagged partner, 18 years - # younger, is presumed the flagged claimant's child, so their earnings drop - # out of the UC means test. +- name: A young child explains a lone flag, so a large-gap partner stays the partner + # Only the 60-year-old is flagged, but the 10-year-old explains the flag, so + # the under-20 limit stays and the 42-year-old partner keeps their place. period: 2025 absolute_error_margin: 0.01 input: @@ -536,9 +539,11 @@ household: members: [claimant, partner, child] output: - is_claimant_or_partner: [true, false, false] - is_lone_parent: true - uc_earned_income: 0 + is_claimant_or_partner: [true, true, false] + is_couple: true + is_lone_parent: false + uc_earned_income: 31_563.60 + universal_credit: 0 - name: Flagging both partners keeps the large-gap couple period: 2025 @@ -593,3 +598,49 @@ is_lone_parent: false uc_earned_income: 31_563.60 universal_credit: 0 + +- name: A flagged lone parent with an adult child and a younger child is read as a couple + # Documented limitation, as before this rule: the 12-year-old explains the + # parent flag, so the 25-year-old (not under 20) is read as the partner. + # Supply is_claimant_or_partner to enter this household. + period: 2025 + absolute_error_margin: 0 + input: + people: + parent: + age: 50 + is_parent: true + adult_child: + age: 25 + child: + age: 12 + benunits: + benunit: + members: [parent, adult_child, child] + households: + household: + members: [parent, adult_child, child] + output: + is_claimant_or_partner: [true, true, false] + +- name: An unexplained parent flag on one of a childless large-gap pair makes them single + # Trade-off: with no child under 20 to explain it, the flag on the + # 60-year-old marks the 42-year-old as their adult child, not their partner. + period: 2025 + absolute_error_margin: 0 + input: + people: + older: + age: 60 + is_parent: true + younger: + age: 42 + benunits: + benunit: + members: [older, younger] + households: + household: + members: [older, younger] + output: + is_claimant_or_partner: [true, false] + is_couple: false 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..46a52b4a21 100644 --- a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py +++ b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py @@ -10,7 +10,8 @@ partners and children placed by a local authority; the HBAI fallback and 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 + a parent is never their partner, at any age, unless a member under 20 and + at least 16 years younger explains the flag; 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. @@ -225,7 +226,8 @@ def claimants_or_partners(family, flagged_parent_lifts_age_limit=True): # 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 + # flagged as a parent whose flag no such under-20 member explains. 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. @@ -243,7 +245,10 @@ 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] + young_child = any(ages[j] < 20 and ages[claimant] - ages[j] >= 16 for j in range(n)) + no_age_limit = ( + flagged_parent_lifts_age_limit and parent[claimant] and not young_child + ) pool = other_parents or [ i for i in adults @@ -761,16 +766,18 @@ def test_parent_marked_under_16_entrant_preserves_existing_claimants(): @st.composite def flagged_claimant_units(draw): - # A head flagged as a parent, with unflagged others of any age, education - # and training below or near them. + # A head flagged as a parent, with unflagged others no older than them, of + # any education and training. Half the units have no member under 20, so + # nothing explains the flag. families = [] for _ in range(draw(st.integers(1, 8))): claimant_age = draw(st.integers(32, 90)) + youngest = draw(st.sampled_from([0, 20])) family = [person(claimant_age, is_parent=True, is_benunit_head=True)] for _ in range(draw(st.integers(1, 4))): family.append( person( - draw(st.integers(0, claimant_age)), + draw(st.integers(youngest, claimant_age)), current_education=draw(st.sampled_from(EDUCATIONS)), is_in_approved_training=draw(st.booleans()), ) @@ -782,15 +789,21 @@ def flagged_claimant_units(draw): @PROPERTY_SETTINGS @given(flagged_claimant_units()) def test_member_far_below_a_flagged_claimant_is_never_their_partner(families): + # Unless a member under 20 and 16+ years younger explains the flag, every + # member 16+ years below the flagged claimant is their child at any age. + # With such a member, the under-20 limit stays. sim = simulate(families) assert_structure(sim, families) claimants = sim.calculate("is_claimant_or_partner", YEAR) offset = 0 for family in families: claimant_age = family[0]["age"] + explained = any( + p["age"] < 20 and claimant_age - p["age"] >= 16 for p in family[1:] + ) assert claimants[offset] for j, p in enumerate(family[1:], start=1): - if claimant_age - p["age"] >= 16: + if claimant_age - p["age"] >= 16 and (p["age"] < 20 or not explained): assert not claimants[offset + j] offset += len(family) @@ -818,7 +831,8 @@ def test_flagging_the_claimant_as_a_parent_never_adds_a_partner(families): # Metamorphic: the same units before and after flagging the head (the # claimant) as a parent. The flag can only remove members from the partner # pool, so the claimant stays and the count of claimants and partners - # never rises; any partner left is under 16 years younger. + # never rises. Any partner left is under 16 years younger, unless a member + # under 20 and 16+ years younger explains the flag. flagged = [[{**family[0], "is_parent": True}] + family[1:] for family in families] combined = families + flagged sim = simulate(combined) @@ -831,8 +845,11 @@ def test_flagging_the_claimant_as_a_parent_never_adds_a_partner(families): after = claimants[after_offset : after_offset + len(family)] assert before[0] and after[0] assert after.sum() <= before.sum() + explained = any( + p["age"] < 20 and family[0]["age"] - p["age"] >= 16 for p in family[1:] + ) for p, partner in zip(family[1:], after[1:]): - if partner: + if partner and not explained: assert family[0]["age"] - p["age"] < 16 before_offset += len(family) after_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..e58495aa1a 100644 --- a/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py +++ b/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py @@ -22,25 +22,33 @@ 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 - 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. + are under 20 (a PolicyEngine presumption for households entered without + relationships). + - The under-20 limit is lifted when the claimant is flagged as a parent + and no member under 20 and at least 16 years younger is there to explain + the flag. So a lone parent flagged `is_parent` who is the claimant and + lives only with an unflagged son or daughter at least 16 years younger + is single, and the son or daughter is neither claimant nor partner. + - When a child under 20 does explain the flag, the limit stays. A flagged + claimant with an unflagged adult 16 or more years younger and a younger + child is read as a couple with that child, and a lone parent with both + an adult child and a younger child is read the same way. - 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. - 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 - more years younger is presumed to be their child, and the couple is - assessed as a lone parent. When relationships are known, supply this - variable for everyone (true for the claimant and any partner, false for - everyone else) rather than relying on the presumption. + with children, both members of the couple are flagged as parents. When + relationships are known, supply this variable for everyone (true for + the claimant and any partner, false for everyone else) rather than + relying on the presumption. - If the claimant is not flagged as a parent but two or more other members are, the two eldest of those are the claimant and partner instead (for example parents living in a grandparent's benefit unit). + - If the claimant is not flagged as a parent and exactly one other member + is, that member is the partner whatever the age gap (for example an + unflagged grandparent head with a flagged daughter is read as a couple). + Supply this variable for such households. - Any further adults are neither claimant nor partner. """ @@ -81,11 +89,12 @@ def formula(person, period, parameters): parent_couple = other_parent & ( 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 - ) + large_gap = claimant_age - age >= p.minimum_age_gap + young_child = (age < p.age_limit) & large_gap + # A flagged claimant with no young child to explain the flag is the + # parent of a much younger member at any age. + flag_unexplained = claimant_is_parent & ~person.benunit.any(young_child) + presumed_child = ((age < p.age_limit) | flag_unexplained) & large_gap 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 445b96040dfb1fdc2f4ed5363d1fbee788abd067 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 14:31:34 -0400 Subject: [PATCH 7/7] Describe the unexplained-flag condition in the changelog and docs Review round 4: the changelog fragment still described the first rule. Also scope the add-a-child invariant in the property module to the domains it is tested on, and say the lone flagged other member must be an HBAI adult. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2039.fixed.md | 2 +- .../test_child_and_adult_definitions_properties.py | 11 +++++++---- .../household/demographic/is_claimant_or_partner.py | 4 ++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/changelog.d/2039.fixed.md b/changelog.d/2039.fixed.md index fd7a3fadc6..153599a5ae 100644 --- a/changelog.d/2039.fixed.md +++ b/changelog.d/2039.fixed.md @@ -1 +1 @@ -- A member at least 16 years younger than a claimant flagged as a parent is now presumed to be their child at any age, so a flagged lone parent living with an adult son or daughter is no longer inferred as a couple. Without any parent flag the under-20 limit stays, because survey childless couples carry no flags. +- A member at least 16 years younger than a claimant flagged as a parent is now presumed to be their child at any age, unless a member under 20 and at least 16 years younger already explains the flag. So a flagged lone parent living only with an adult son or daughter is no longer inferred as a couple. Without any parent flag the under-20 limit stays, because survey childless couples carry no flags. 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 46a52b4a21..8c947e0471 100644 --- a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py +++ b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py @@ -27,10 +27,13 @@ age_under_18 has the same membership as the shims for school-attendance inputs and household adult age/capital-gains ranks (modelling conventions, not statutory adult definitions). -4. Adding an under-16 dependant leaves existing claimant/partner membership - unchanged, even when 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). +4. In valid families and in units with no flagged parent, adding an under-16 + dependant leaves existing claimant/partner membership unchanged, even when + 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. Before/after families share one Simulation to avoid repeated model construction, but occupy distinct households and benefit units. 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 e58495aa1a..bc37290872 100644 --- a/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py +++ b/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py @@ -45,8 +45,8 @@ class is_claimant_or_partner(Variable): - If the claimant is not flagged as a parent but two or more other members are, the two eldest of those are the claimant and partner instead (for example parents living in a grandparent's benefit unit). - - If the claimant is not flagged as a parent and exactly one other member - is, that member is the partner whatever the age gap (for example an + - If the claimant is not flagged as a parent and exactly one other HBAI + adult is, that adult is the partner whatever the age gap (for example an unflagged grandparent head with a flagged daughter is read as a couple). Supply this variable for such households. - Any further adults are neither claimant nor partner.