Skip to content

Presume a much younger member is a flagged parent's child at any age - #2040

Draft
MaxGhenis wants to merge 8 commits into
mainfrom
claimant-partner-adult-child
Draft

MaxGhenis wants to merge 8 commits into
mainfrom
claimant-partner-adult-child

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #2039

Follow-up to #1896, now merged. The base is main.

What changes

is_claimant_or_partner presumes that a member is the claimant's child rather than their partner when they are at least 16 years younger (presumed_child.minimum_age_gap) and either:

  • under 20 (presumed_child.age_limit, as before), or
  • new: below a claimant flagged as a parent (is_parent) whose flag no young child explains. A young child here means a member under 20 and at least 16 years younger.

So a lone parent aged 50, flagged is_parent, living only with an unflagged son aged 25 is now single. The son is neither claimant nor partner. When a young child does explain the flag, the under-20 limit stays, as before.

This narrower form follows Vahid's review. The first version lifted the limit for any flagged claimant, which turned a large-gap couple with one partner flagged into a lone parent. That was the case the Codex review flagged: ages 60, 42 and 10, with the 42-year-old earning £50,000, gave UC of £8,869.68 instead of £0. That household is a couple again.

Household (2025; flagged = is_parent) Before (main) This PR
flagged 50, unflagged 25 couple single
unflagged 50, unflagged 25 couple couple
flagged 60, unflagged 42, unflagged 10 couple couple
flagged 60, unflagged 42 (no children) couple single
flagged 50, unflagged 25, unflagged 12 couple couple
flagged 70, unflagged 45, unflagged 10 couple couple

The docstring states the remaining limitations, all unchanged from main. For each, it recommends supplying is_claimant_or_partner (true for the claimant and any partner, false for everyone else):

  • a flagged lone parent with both an adult child and a younger child is read as a couple;
  • so is a flagged grandparent with a daughter and a grandchild;
  • an unflagged head with exactly one flagged member pairs them, whatever the gap;
  • without any flag, ages alone never make a member aged 20 or over a child.

The one new trade-off: a parent flag on one of a childless pair 16 or more years apart makes them single. With no child in the unit, the flag marks the younger member as the adult child.

Scope: this PR fixes who the claimant and partner are. It does not charge an adult child in the claimant's own benefit unit a non-dependant deduction. Here, the UC/HB/CTR non-dependant deductions count only members of other benefit units (household minus benefit unit). In-unit non-dependants are #2017 (issue #2014). Until that lands, enter an adult non-dependant as a separate benefit unit when housing entitlement matters.

Why this presumption

Measured on the enhanced FRS 2024-25 (2025 policy year). Figures are aggregates only; cells from fewer than 10 survey households are suppressed.

  • The flag rule moves no FRS benefit unit. policyengine-uk-data flags every raw FRS adult record (the head and any partner) in a unit with dependants as a parent (derive_is_parent_from_frs_microdata, from the FRS dependent-child count). A flagged claimant's partner is therefore always found among the flagged parents, before the presumption is reached.

    Unflagged HBAI adults in flagged units do exist. They are raw child records that the HBAI fallback calls adults, and all are under 20, so they were already presumed children.

  • A gap-only rule (no age limit, flagged or not) would split 220 FRS couples, about 203k weighted households. These are childless couples with large age gaps, and they carry no flags. Adding an unflagged gap rule would still split real FRS couples:

    Unflagged gap threshold Survey households split Weighted households
    20+ 109 77k
    25+ 48 12k
    30+ 26 6k
  • How often a large gap means a couple. The table covers co-resident pairs of HBAI adults where the younger is 20 or over. "Couple share" is the share the FRS puts in one benefit unit, as a share of weighted pairs (each pair takes its household's weight).

    Age gap Couple share
    16-19 61%
    20-24 12%
    25-29 0.6%
    30+ 0.2%
    16+ overall 5.9%

    Most large-gap pairs are in separate benefit units. The FRS benefit-unit IDs do not record kinship, so reading those pairs as parents and adult children is an inference, not a measurement.

    An unflagged rule needs the dataset to supply is_claimant_or_partner first. That is policyengine-uk-data#524 (draft), with a stacked PE-UK PR for an unflagged gap rule. Both are queued as a methodology decision.

How the calculators build households

Caller What it sends
policyengine-app-v2 UK builder One benefit unit holding everyone. Its default composition sets age (and employment_income) and supplies no relationship roles. A user can add is_parent through the builder's custom-variable search, but heads and is_claimant_or_partner are formula variables that search does not offer. Its composition logic (getBuilderPartnerKey) treats any other adult aged 18+ as the partner once no "your partner" exists. Draft policyengine-app-v2#1240 sends explicit roles and stops reading an adult dependant as the partner.
policyengine.py uk.calculate_household One benefit unit of all people, plus only the variables the caller supplies.
policyengine-api / household-api Pass the household through.

Impact (real base vs branch runs)

The enhanced FRS 2024-25 was run on main at 3c48247 (which includes #1896) and on this branch at c18c836, for 2025 and 2026. Every array compared is identical in both years:

  • household: net income, HBAI net income BHC/AHC, household benefits and tax;
  • benefit unit: UC, HB, CTR, Pension Credit, Child Benefit, CTC, WTC, IS, the benefit cap reduction and relation_type;
  • person: is_claimant_or_partner, income tax, NI, and BHC/AHC poverty.

The first version of the rule was also identical (verified independently by review round 1).

Invariants

Each is executed as a Hypothesis property over the domain stated:

  1. Any benefit unit (any ages, education, flags and head): at most two claimants or partners, all HBAI adults (existing).
  2. A head aged 32-90 flagged as a parent, with unflagged others no older than the head: a member at least 16 years younger is never claimant or partner if they are under 20, or if no member under 20 and 16+ years younger explains the flag (new). Half the generated units have no member under 20.
  3. Metamorphic, on units that start wholly unflagged with an adult head (20-90): flagging the head as a parent never raises the number of claimants and partners, and the claimant stays (new). Where no young child explains the flag, any unflagged partner left is under 16 years younger.
  4. Differential, on FRS-shaped units (one or two raw adults of any ages, gap and head; all flagged if and only if there are dependants under 20): the rule equals the earlier under-20 rule. This is why the microsimulation cannot move (new).

The reference claimants_or_partners in test_child_and_adult_definitions_properties.py follows the new rule. The exhaustive age × education × flag grid checks it against the model.

Mutation check: against main's formula, 3 of the new YAML cases fail. They are the flagged 50/25 single, the 16-year boundary, and the childless flagged 60/42. Five of the 12 tests in the property file fail:

  • the exhaustive grid;
  • test_entry_terminal_and_wtc_disability_boundaries;
  • property 1, through the updated reference;
  • properties 2 and 3.

Property 4 passes on both formulas, as intended. Log: mutation_vs_main_r2.log.

Tests

  • YAML. New cases in benefit_unit_structure.yaml:
    • flagged lone parent 50 with a son of 25 is single;
    • unflagged 50 and 25 are still a couple;
    • a flagged parent keeps an unflagged partner of 48 and excludes a son of 25;
    • the 16-year boundary (34 is a child, 35 the partner);
    • the unflagged under-20 boundary (19 is a child, 20 the partner);
    • the large-gap couple (60, 42, child 10), in three versions:
      • flagged once: couple, UC earned income £31,563.60;
      • flagged twice: the same;
      • with supplied roles: the same;
    • the documented limitations: 50/25/12 and 70/45/10 are couples;
    • the new trade-off: flagged 60 with 42 and no children is single.
  • Results.
    • Full policy YAML suite: 1,622 passed.
    • test_child_and_adult_definitions_properties.py: 12 passed.
    • Full pytest at the first version: 2,065 passed, 45 skipped, 1 xfailed.
  • Lint. ruff format and ruff check are clean on the changed Python files.

axiom: n/a: a PolicyEngine presumption for households entered without relationships, not a statutory rule. Benefit law defines the claimant and partner by relationship (WRA 2012 s.39), not by age.

Follow-ups

🤖 Generated with Claude Code

MaxGhenis and others added 4 commits October 1, 2026 16:02
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 <noreply@anthropic.com>
…rties

- 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 <noreply@anthropic.com>
…for #2039

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Response to the round-1 independent review (REQUEST_CHANGES at 8baea5a)

Fixes are in 15354fa4, and the PR body is updated. The review confirmed exact FRS equality across 26 arrays in both years, at the review head.

  1. Invariant wording. Each invariant now states its own domain. Invariant 3 is stated for units that start wholly unflagged, with an adult head. Its age clause covers unflagged partners only. Flagged-partner priority is called out, and the YAML case with both partners flagged covers it.
  2. Large-gap couple with only the claimant flagged. This is now documented in the docstring as a trade-off of the FRS flag convention, and the docstring recommends supplying is_claimant_or_partner when relationships are known. Three YAML cases use a claimant of 60, a partner of 42 earning £50,000 and a child of 10:
    • claimant flagged only: lone parent, UC earned income £0;
    • both partners flagged: couple, £31,563.60, UC £0;
    • roles supplied: the same as both flagged.
  3. Non-dependant deductions in the same unit. The PR is now scoped as fixing who the claimant and partner are. The body says this branch's UC/HB/CTR deductions count only members of other benefit units, and that in-unit non-dependants are Charge non-dependant deductions for non-dependants within the claimant's benefit unit #2017 (Charge non-dependant deductions for non-dependants within the claimant's benefit unit #2014). The docstring now says the adult child is "neither claimant nor partner", not "a non-dependant".
  4. Pair table. It now reports weighted pairs in one versus separate benefit units, says kinship is not observed, and labels the parent and adult-child reading as an inference about co-resident pairs in general.
  5. App wording. The default composition supplies no roles, but a user can add is_parent through the custom-variable search. Heads and is_claimant_or_partner are not offered there.

Also added: an unflagged age-20 boundary case (19 is a child, 20 the partner), so the preserved limit no longer depends on sampling. "Adult" is clarified as "raw FRS adult record" in the neutrality explanation.

Tests on 15354fa: full policy YAML 1,569 passed; the property file 12 passed.

@juaristi22

Copy link
Copy Markdown
Collaborator

Codex review — input-contract decision required

Reviewed commit 15354fa4bf21b421768fc14bb90e5bb1f182eac7. PARTIAL; 0 confirmed blocking finding(s). Independent code/test and policy/source reviews were consolidated for the changed behavior and affected dependencies.

code-1 — [P2] Resolve the effect of partial parent flags before adopting the new default

is_claimant_or_partner.py:84–91 removes the under20 limit whenever the claimant is parent-flagged, presuming any unflagged member at least16years younger to be their child. That fixes the stated adult-child example but materially changes accepted partially specified household inputs. In2025, ages60/42/10, with only the60-year-old flagged as a parent and the42-year-old earning£50,000, the model returns claimant/partner flags [true,false,false], UC earned income£0 and final UC £8,869.68. Flag both adults as parents, or explicitly supply their claimant/partner roles, and the same incomes yield UC earned income£31,563.60 and UC£0.

The PR documents this trade-off and tests it, so this is not an undisclosed coding accident. Nor do the ages alone establish whether the42-year-old is a partner or an adult child. A maintainer/product decision is needed on whether the complete-parent-flags convention is enforced for every supported household-input path. If partial flags remain supported, prefer explicit relationship/role data or validation rather than silently choosing a materially different family. Existing tests establish the designed inference, not the truth of the relationship. Confidence: high for the executed sensitivity; actual relationship and acceptable input contract require a decision.

The policy review agrees: Welfare Reform Act 2012 section 39 defines a couple by relationship, but does not choose a default for ambiguous inputs. FRS array equality establishes compatibility with that dataset convention, not the correct relationship for a calculator submission. The two reviewers' overlapping finding is counted once.

This is a relevant model-default decision, not grounds to close the PR as irrelevant. The current documentation and tests acknowledge the trade-off; explicit acceptance or input validation is still needed.

Outstanding evidence/decision:

  • Maintainer acceptance of the input contract, coverage of partial parent flags across supported app/API/library inputs, and the intended relationship in ambiguous submissions remain unresolved.

Validation: 36 focused tests passed. An independent final-award diagnostic measured UC of £8,869.68 with the single parent flag versus £0 with both parent flags; ages and incomes were unchanged. One official source original was inspected and its cached bytes verified. No relationship truth was inferred from person names or age gaps.

Focused tests used an existing cached Python 3.13 environment (Core 3.32.9, NumPy 2.1.3, pandas 2.3.1, microdf-python 1.2.1, Hypothesis 6.168.2, pytest 8.4.2) with this PR’s isolated source snapshot. No dependencies were installed; this was not a freshly synced lock environment or a full-suite/population run. This is a review comment, not a formal GitHub review vote.

Live check before posting (2026-10-02T10:39:11.307643+00:00): same commit, CLEAN; 0 failed checks; 0 pending checks.

@vahid-ahmadi

Copy link
Copy Markdown
Collaborator

Review pass (Claude Code, on Vahid's behalf) at 15354fa4

Short version: the rule does what the body says, the FRS neutrality argument holds, and the tests pass. On the input-contract decision raised in the Codex review, I'd take a narrower version of the rule. It keeps the fix for the flagged lone parent with an adult child and removes the 60/42/10 couple regression, so every probe I ran either improves on #1896 or matches it. I've also listed two ambiguous inputs that neither branch handles, as candidates for this PR or a follow-up.

Probes (is_claimant_or_partner, 2025; "flagged" means is_parent):

Household (ages) #1896 (base) This PR Narrower rule (below)
flagged 50, unflagged 25 couple single ✓ single ✓
unflagged 50, unflagged 25 couple couple couple
flagged 60, unflagged 42, unflagged 10 couple lone parent couple
flagged 50, unflagged 25, unflagged 12 couple single couple (as base)
flagged 50, unflagged 34, unflagged 36 50 + 36 50 + 36 50 + 36
flagged 45, flagged 30, unflagged 5 couple couple couple
unflagged head 70, flagged 45, unflagged 10 70 + 45 couple 70 + 45 couple 70 + 45 couple
unflagged head 25, flagged 50 25 + 50 couple 25 + 50 couple 25 + 50 couple

1. Suggestion: only drop the age limit when the parent flag is otherwise unexplained. A flagged claimant who already has a child under 20 in the unit has a flag that child explains, so the flag says nothing about an older unflagged member. Lift the under-20 limit only when no such child is present:

large_gap = claimant_age - age >= p.minimum_age_gap
young_child = (age < p.age_limit) & large_gap
flag_unexplained = claimant_is_parent & ~person.benunit.any(young_child)
presumed_child = ((age < p.age_limit) | flag_unexplained) & large_gap
  • Tried locally, not committed (the table's last column). It keeps A lone parent flagged as a parent and their adult child are inferred as a couple #2039's case (50 and 25, single). It returns the Codex review's 60/42/10 case to a couple, which removes the £8,869.68 UC swing.
  • The cost: a flagged lone parent with both an adult child and a younger child (50/25/12) stays a couple, as on Replace generic child and adult flags with each programme's legal definitions #1896 today. So it's no worse than the base anywhere I probed, whereas the current rule trades one wrong answer for another.
  • FRS neutrality should still hold. Every FRS unit with a flagged adult has a dependant under 20, so the new branch never fires there. Property 4 would confirm it.
  • If you keep the current rule instead, I'd want the app follow-up (sending is_claimant_or_partner from policyengine-app-v2) filed as an issue before merge. Partially flagged input is reachable through the app's custom-variable search.

2. Question (pre-existing, in the spirit of "ambiguous family inputs"): when the claimant is unflagged and exactly one other member is flagged, the flagged member becomes the partner whatever the age gap.

  • An unflagged grandparent head of 70 with a flagged daughter of 45 and a grandchild of 10 is a 70 + 45 couple.
  • An unflagged head of 25 with a flagged mother of 50 is a couple.

The two-flagged-parents branch already handles the grandparent case when both parents are present. A symmetric rule would fix the single-parent version: a flagged member at least 16 years older than the claimant is presumed their parent, not their partner. Alternatively, when exactly one non-claimant member is flagged and the claimant isn't, make that member the claimant. Fine as a follow-up if you'd rather keep this PR narrow; worth a line in the docstring either way.

3. Nit: the docstring paragraph on partial flags could also say the result depends on whether the flag is "explained" by a younger child, if you adopt item 1.

Checked and correct

…ow 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 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Response to the round-2 independent review (REQUEST_CHANGES at 15354fa)

Fixes are in 72d70ddb, and the PR body is updated.

  • N1 (void true/false assertions): the three large-gap couple cases now use absolute_error_margin: 0.01. That enforces is_claimant_or_partner, is_lone_parent and is_couple, and still accepts UC earned income of £31,563.598. The structure file passes, 24 of 24.

  • N2: the body now says the parameter descriptions state the rule and the docstring states the trade-off.

  • N3: I re-ran the mutation myself against Replace generic child and adult flags with each programme's legal definitions #1896's formula:

    • YAML: 4 of the 9 new cases fail.
    • Property file: 5 of 12 fail (the exhaustive grid, the entry-terminal and WTC boundaries test, property 1 through the updated reference, and properties 2 and 3).
    • Property 4 passes on both.

    The body now gives these counts.

  • N4: the docstring example now reads "…flagged is_parent who is the claimant and lives with an unflagged son or daughter at least 16 years younger is single".

  • Nit: invariant 2's domain now reads "unflagged others no older than the head".

MaxGhenis and others added 2 commits October 2, 2026 09:37
…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 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Thanks, both. Adopted Vahid's narrower rule in c18c836. The under-20 limit is now lifted only when the claimant is flagged and no member under 20 and at least 16 years younger explains the flag:

large_gap = claimant_age - age >= p.minimum_age_gap
young_child = (age < p.age_limit) & large_gap
flag_unexplained = claimant_is_parent & ~person.benunit.any(young_child)
presumed_child = ((age < p.age_limit) | flag_unexplained) & large_gap

Codex input-contract finding. The 60/42/10 household flagged once is a couple again, with UC earned income £31,563.60 and UC £0, the same as when both are flagged or roles are supplied. The YAML now asserts that with a 0.01 margin, so its true/false outputs are enforced. Of Vahid's probes, only the #2039 case changes from main. The one new trade-off is a childless pair 16+ years apart where only the elder is flagged, which becomes single. With no child in the unit, that flag marks the younger member as the adult child. It has its own YAML case.

Vahid's question 2: unflagged head with one flagged member (grandparent with a flagged daughter, or a 25-year-old head with a flagged mother). This is unchanged and now documented in the docstring as a limitation, with the advice to supply is_claimant_or_partner. I haven't added the symmetric "flagged member 16+ years older is the parent" rule here. In the FRS, couples with children have both partners flagged, and the FRS head can be the younger partner. That rule would split those couples unless it is measured and scoped first, so it's better as its own change after policyengine-uk-data#524 supplies roles from the FRS.

App follow-up: draft policyengine-app-v2#1240 sends explicit roles from the UK builder. Data follow-up: draft policyengine-uk-data#524 supplies is_claimant_or_partner from FRS adult records. A stacked PE-UK PR for an unflagged 20-year gap is queued as a methodology call for Max.

Re-measured on c18c836:

  • enhanced FRS 2024-25, main at 3c48247 vs branch, 2025 and 2026: every compared array is identical;
  • full policy YAML suite: 1,622 passed;
  • property file: 12 passed;
  • mutation check against main: 3 new YAML cases fail and 5 of the 12 property tests fail, while the FRS differential passes on both.

The PR body is rewritten to match.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A lone parent flagged as a parent and their adult child are inferred as a couple

3 participants