diff --git a/changelog.d/2039.fixed.md b/changelog.d/2039.fixed.md new file mode 100644 index 0000000000..153599a5ae --- /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, 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/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..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 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 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 19f9dbe80f..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 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 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 ce6962b0a0..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 @@ -371,3 +371,276 @@ 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, 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 + 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 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: + 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, true, 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: 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: + 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, 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 + absolute_error_margin: 0.01 + 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: 0.01 + 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 + +- 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 2bb2f7413f..8c947e0471 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,12 @@ 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, 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. 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 @@ -21,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. @@ -215,13 +224,16 @@ 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 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. n = len(family) adult = [not hbai_dependent_child(p, family) for p in family] ages = [p["age"] for p in family] @@ -236,10 +248,15 @@ 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)] + 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 - 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 +765,135 @@ 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 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(youngest, 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): + # 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 and (p["age"] < 20 or not explained): + 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, 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) + 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() + 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 and not explained: + 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)] 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..bc37290872 100644 --- a/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py +++ b/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py @@ -20,13 +20,35 @@ 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 + 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 (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. 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 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. """ @@ -59,14 +81,20 @@ 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) + 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)