diff --git a/changelog.d/2039-unflagged-gap.changed.md b/changelog.d/2039-unflagged-gap.changed.md new file mode 100644 index 000000000..e29169cef --- /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. 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/parameters/household/demographic/benefit_unit/presumed_child/age_limit.yaml b/policyengine_uk/parameters/household/demographic/benefit_unit/presumed_child/age_limit.yaml index 79f19d924..4551e3332 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 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. +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, 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 ec32f6adc..4ca6e3de3 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 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. +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; a larger gap (the minimum age gap at any age) applies at any age. 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 000000000..c8fea14d8 --- /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/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 95d447790..00e8f1a8e 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 b0232bd97..2baa03827 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/dwp/lifetime_isa_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/lifetime_isa_capital.yaml index ad7433be1..621f26010 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 00a7b4b38..25527b2b1 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/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 444244a94..a9026ff94 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: 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 e2cdef698..2d5ebe020 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 @@ -374,8 +374,10 @@ # 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. +# 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. Datasets and users can supply is_claimant_or_partner instead. - name: A flagged lone parent with an adult son is single period: 2025 @@ -398,8 +400,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,10 +415,138 @@ households: household: members: [older, younger] + output: + 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 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 + 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 absolute_error_margin: 0 @@ -467,10 +596,10 @@ 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. +- name: A flagged grandparent's daughter 25 years younger is not their partner + # The grandchild under 20 explains the grandparent's parent flag, so the + # under-20 limit stays, but the daughter is 20 or more years younger and so + # presumed the grandparent's child at any age. period: 2025 absolute_error_margin: 0 input: @@ -489,7 +618,7 @@ household: members: [grandparent, daughter, grandchild] output: - is_claimant_or_partner: [true, true, false] + is_claimant_or_partner: [true, false, false] - name: Without flags the under-20 limit still applies at the boundary period: 2025 @@ -497,11 +626,11 @@ input: people: head_a: - age: 40 + age: 37 aged_19: age: 19 head_b: - age: 40 + age: 38 aged_20: age: 20 benunits: @@ -599,10 +728,10 @@ 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. +- name: A flagged lone parent with an adult child 25 years younger and a younger child is single + # The 12-year-old explains the parent flag, so the under-20 limit stays, but + # the 25-year-old is 20 or more years younger and so presumed the parent's + # child at any age. period: 2025 absolute_error_margin: 0 input: @@ -620,6 +749,31 @@ households: household: members: [parent, adult_child, child] + output: + is_claimant_or_partner: [true, false, false] + +- name: A flagged lone parent with an adult child 18 years younger and a younger child is read as a couple + # Documented limitation: the 12-year-old explains the parent flag, so the + # under-20 limit stays, and an unflagged 20-year-old 18 years younger is + # within the gap at which co-resident adults are mostly couples. Supply + # is_claimant_or_partner to enter this household. + period: 2025 + absolute_error_margin: 0 + input: + people: + parent: + age: 38 + is_parent: true + adult_child: + age: 20 + 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] 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 ea0ece7cc..129d7b85b 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_child_and_adult_definitions_properties.py b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py index 8c947e047..8cbb15efc 100644 --- a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py +++ b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py @@ -12,13 +12,17 @@ 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. + 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 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. @@ -32,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. @@ -219,21 +225,21 @@ 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"]) ) -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 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. + # flagged as a parent whose flag no such under-20 member explains, 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 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] @@ -249,14 +255,16 @@ def claimants_or_partners(family, flagged_parent_lifts_age_limit=True): 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 - ) + flag_unexplained = parent[claimant] and not young_child + + 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 flag_unexplained) 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)] @@ -558,7 +566,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 +779,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 @@ -884,16 +905,84 @@ 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_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 @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 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. 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, 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) + for family in families: + if not childless_couple_split_by_any_age_gap(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#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)] + 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) + + +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)] + + +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] + sim = simulate(families) + inferred = sim.calculate("is_claimant_or_partner", YEAR) + 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] * 4) + np.testing.assert_array_equal(supplied.calculate("is_couple", YEAR), [True] * 2) 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 34b3c49c5..a176c79c6 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, diff --git a/policyengine_uk/tests/test_mixed_age_couple_properties.py b/policyengine_uk/tests/test_mixed_age_couple_properties.py index a6b08165e..43c388f1a 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: 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 ac8cd7262..c4e766391 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"]: 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 cf5fef6f3..2f8810671 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 5e4115bcd..f541cadfb 100644 --- a/policyengine_uk/tests/test_uc_state_pension_properties.py +++ b/policyengine_uk/tests/test_uc_state_pension_properties.py @@ -105,9 +105,10 @@ 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}" - # The generated adults are the claimant and partner (a mixed-age - # couple can include an 18- or 19-year-old partner), as the FRS - # would record them. + # The generated adults are the claimant and partner whatever + # their ages (a mixed-age couple can include an 18- or 19-year-old + # partner, or one 20 or more years younger), as the FRS would + # record them. person = { "age": {year: age}, "state_pension": {year: 0.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 bc3729087..8163855ec 100644 --- a/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py +++ b/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py @@ -22,21 +22,28 @@ 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 (a PolicyEngine presumption for households entered without - relationships). + are under 20, and a member at least 20 years younger is presumed their + child at any age (PolicyEngine presumptions 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 + claimant with an unflagged adult 16 to 19 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. + an adult child 16 to 19 years younger and a younger child is read the + same way. + - 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 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 @@ -89,12 +96,16 @@ def formula(person, period, parameters): parent_couple = other_parent & ( person.get_rank(person.benunit, -age, condition=other_parent) < 2 ) - large_gap = claimant_age - age >= p.minimum_age_gap + gap = claimant_age - age + large_gap = gap >= 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. + # parent of a much younger member at any age. Without that, only the + # larger any-age gap overrides the age limit. flag_unexplained = claimant_is_parent & ~person.benunit.any(young_child) - presumed_child = ((age < p.age_limit) | flag_unexplained) & large_gap + presumed_child = (((age < p.age_limit) | flag_unexplained) & large_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)