Skip to content

Read one resolved household head in every programme - #2062

Merged
MaxGhenis merged 9 commits into
mainfrom
resolved-household-head
Oct 6, 2026
Merged

MaxGhenis merged 9 commits into
mainfrom
resolved-household-head

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #2059

#2009 and #2006, which this PR was stacked on, have merged; it now targets main and has main (60821c713) merged in.

What this changes

is_household_head is an input, so it can flag several members of a household, or none. Council Tax Reduction settled on one head (#2009), but rent, non-dependant and maintenance-loan variables read the raw flag, so a malformed input left programmes disagreeing about whose household it is.

This PR adds one person-level head and points every reader at it.

  • is_resolved_household_head (new, Person). Exactly one member of each household, chosen from the is_household_head input and age alone. It is Give Council Tax Reduction to the household head's family, not the oldest adult's #2009's rule, moved out of Council Tax Reduction:
    • the member the input flags;
    • where the input flags several members, the eldest flagged member;
    • where it flags none, the eldest member;
    • a tie in age goes to the member listed first among the people.
  • benunit_contains_household_head is now defined from it, and moves from the Council Tax Reduction folder to household/demographic/. It keeps its name, so code that reads it by name is unaffected by the move.
  • council_tax_reduction_household_head (added by Give Council Tax Reduction to the household head's family, not the oldest adult's #2009) is removed. It was the same rule under a Council Tax Reduction name.
  • is_household_head keeps its formula and stays the input. Its documentation now points readers to the resolved head.

No formula other than is_resolved_household_head reads is_household_head any more.

Reader Now reads
is_liable_for_household_rent, share_of_household_rent, pays_rent_to_householder benunit_contains_household_head
personal_rent is_resolved_household_head for who carries the family's rent, benunit_contains_household_head for the family
is_non_dependant_of_household_head, uc_non_dependants_counted benunit_contains_household_head
maintenance_loan_living_arrangement, maintenance_loan_household_income is_resolved_household_head
council_tax_reduction_liable_person is_resolved_household_head
dynamics/participation.py nothing: it read the flag into a variable it never used, and the line is removed

uc_non_dep_deductions, LHA_allowed_bedrooms, housing_benefit_LHA_allowed_bedrooms and lha_renter_has_non_dependant also read the raw flag when this PR was opened. #2006 has since rewritten them on main: uc_non_dep_deductions, LHA_allowed_bedrooms and lha_renter_has_non_dependant now read uc_non_dependants_counted, which this PR moves, and housing_benefit_LHA_allowed_bedrooms reads is_liable_for_household_rent. This PR leaves them as main has them.

What a malformed input did, and does now

Household calculations for a home with a £12,000 rent. "Before" is main at 60821c713 (every value the same as at 6b366b281).

Input Before Now
Two single adults in separate families, both flagged Each family is liable for £6,000, so Universal Credit and Housing Benefit treat neither as a non-dependant. Council Tax Reduction treats the elder adult's family as the claimant and the younger adult as its non-dependant. The elder flagged adult's family is liable for the £12,000 and claims. The other family is its non-dependant in all three programmes.
A couple, both flagged The rent is counted on each partner: the family's rent is £24,000. £12,000, on the elder partner.
Two single adults in separate families, neither flagged No one is liable for any rent, while Council Tax Reduction treats the elder adult's family as the claimant. The elder adult's family is liable for the £12,000 and claims. The other family is its non-dependant.
A lodger flagged beside the householder, paying £5,200 The lodger is treated as a second householder: half the household's rent on top of the £5,200, as a council tenant. The lodger pays the householder the £5,200, rents privately, and has no share of the household's rent.
A student flagged beside their parent The maintenance loan treats the student as living away from home. Living with parents.
The head (50) and a sharer (40) both flagged, beside a non-dependant (30), all claiming Universal Credit Both renters' Universal Credit claims count the non-dependant: each takes the £1,158.60 deduction and a bedroom for them, so the sharer's award is £1,158.60 lower than with only the head flagged. UC Regs 2013 Sch 4 para 9(2)(f) allows one claim to count them. Only the head's family's claim counts the non-dependant.

The first row is finding 8 of the review of #2009. In every row the result now equals the result with only the resolved head flagged.

Invariants

For any flag input (one member, several, both members of a couple, everyone, none, or no input):

  1. One head. Exactly one member of each household is the head, by the rule above.
  2. One family. benunit_contains_household_head holds for the head's family and no other.
  3. Agreement. Every programme treats that family as the household head's.
    • Rent: only the head's family and sharers have a share; the head's family's share sits on the head alone; the shares sum to the household's rent.
    • A person is a non-dependant of the household head if and only if their family is neither the head's nor liable for rent.
    • Universal Credit non-dependant deductions fall only on the claim that counts the household's non-dependants (uc_non_dependants_counted, Give sharers, boarders and lodgers their own rent, tenure and LHA category #2006's rule under UC Regs 2013 Sch 4 para 9(2)(f)). At most one family in a household is counted, and where the head's family is liable for the rent and claims Universal Credit, it is the head's. Only families with a share of the rent have Housing Benefit non-dependant deductions.
    • Council Tax Reduction: only the head's family and sharers claim; where the head is 18 or over, the head's family claims and no one in it is a non-dependant.
    • A family paying the householder as boarders or lodgers is never the head's. The maintenance loan never has the head living with parents.
  4. Flags matter only through the head. Every value a simulation computes, other than the is_household_head input, is identical to the simulation whose input flags exactly the resolved head.
  5. One reader. No module outside is_resolved_household_head reads is_household_head.

Survey data: where every household has exactly one flagged head, the resolved head is the flagged head, so nothing changes (measured below).

Tests

  • YAML: household/demographic/resolved_household_head.yaml, 9 cases: two flagged families, a couple both flagged, none flagged, none flagged with a tie, two flagged beside a sharer, the head and a sharer both flagged beside a non-dependant, a flagged lodger, a flagged student, and a household with no input beside one that has it. Together they cover rent, Universal Credit and Housing Benefit non-dependants and size criteria, Council Tax Reduction, boarders and lodgers, and the maintenance loan; most cases check several of these at once. All 9 fail on main (60821c713, with the is_resolved_household_head lines dropped), each on an intended output.
    • After merging main, the "two flagged beside a sharer" case follows Give sharers, boarders and lodgers their own rent, tenure and LHA category #2006's rule: the resolved head is 70, over State Pension age and not eligible for Universal Credit, so the sharer's claim counts the non-dependant and carries the £1,000 deduction (UC Regs 2013 Sch 4 para 9(2)(f)). The same household with only the resolved head flagged gives the same values.
  • Hypothesis: test_household_head_agreement_properties.py. Invariants 1 to 3 against an independent implementation of the rule; invariant 4 by comparing every computed array between the two simulations; invariant 5 by scanning the package source. Explicit @example households cover branches random generation rarely reaches: no one liable for the rent, a student flagged beside an earning parent, the head and a sharer both flagged beside a non-dependant, and no one flagged with the head's family listed after a sharer's.
  • Mutation check (not in CI), run without the source scan:
    • Before merging main (057718b7f, and 273762480 for the two maintenance-loan mutants): 18 mutants, each putting one reader back on the raw flag or breaking the rule. All 18 were caught by at least one property: the comparison test (invariant 4) caught all 15 readers put back on the raw flag, and the direct test (invariants 1 to 3) caught all 3 rule mutants. The first runs found three survivors, each a branch random generation never reached: share_of_household_rent's fallback when no one in the household is liable, and the two maintenance-loan proxies, which turn on the head only for a student in higher education with an earning sponsor, in an owner-occupied home. The test now generates families of a child alone and carries three explicit @example households for those branches.
    • After merging main at 6b366b281 (5b27c0ae0): 14 mutants, since the four readers Give sharers, boarders and lodgers their own rent, tenure and LHA category #2006 rewrote no longer read the head (their five mutants are gone) and uc_non_dependants_counted gets one. All 14 were caught: the comparison test caught all 11 readers put back on the raw flag, and the direct test caught all 3 rule mutants. The comparison test caught uc_non_dependants_counted only through a random example, so two explicit examples now pin it:
      • the head and a sharer both flagged: with explicit examples only (5cacd4959), both properties pass, and both fail with uc_non_dependants_counted back on the raw flag;
      • no one flagged, the head's family listed after a sharer's: run alone (48043f73c), it makes both properties fail with uc_non_dependants_counted on the raw flag, and the direct test fail without Give sharers, boarders and lodgers their own rent, tenure and LHA category #2006's head-first order (the comparison test cannot catch that one, since both of its simulations use the same order).
    • These results hold at 48043f73c: the readers are unchanged since 5b27c0ae0, Apply the UC minimum income floor only to claimants subject to all work-related requirements #2081 (in the merge of main at 60821c713) changes nothing they depend on, and adding @example households leaves Hypothesis's random draws as they were.
  • Suites, local and targeted (this host cannot hold the whole suite). 48043f73c changes only the property test and a YAML comment from 5cacd4959, the merge of main:
    • YAML under household, finance/benefit/family, gov/local_authorities, gov/dfe and gov/dwp: 1,670 passed on 5cacd4959; resolved_household_head.yaml (9 cases) passed on 48043f73c.
    • pytest on 22 test files that read the head or what depends on it (the household-head, rent, Council Tax Reduction, Housing Benefit, LHA, benefit cap, carer, means-test, severe disability premium, student support, State Pension Credit age and maintenance-loan tests, test_deterministic_variables.py and test_dynamics_demographics.py) and the code_health folder: 2,169 passed on 5cacd4959. test_household_head_agreement_properties.py in full: 3 passed on 48043f73c.
    • ruff format --check: clean.
    • CI (the full make test): passed on 00126a3d9 (run 37374413632) and on 48043f73c (run 37391222133).

Enhanced FRS: no change

Real Microsimulation runs of main at 60821c713 against this branch at 5cacd4959, on the same dataset file. The model code is the same at 48043f73c, which changes only tests.

Dataset Year Households With exactly one flagged head Variables compared Bitwise identical
Enhanced FRS 2024-25 (e433e532) 2026 52,846 52,846 141 141

Coordination with other open PRs

These open PRs add formulas that read is_household_head directly. With this PR on main, each has to read benunit_contains_household_head or is_resolved_household_head instead; invariant 5's test fails until it does.

PR Formulas it adds or changes that read the raw flag
#2002 legacy_benefits_home_letting_income, rent_from_boarders_and_lodgers, pays_rent_to_householder
#2016 LHA_additional_bedrooms, LHA_allowed_bedrooms, housing_benefit_LHA_additional_bedrooms
#2030 the same three, and universal_credit_renter_has_non_dependant
#2035 housing_benefit_LHA_allowed_bedrooms, non_dependant_normally_resides_with
#2055 housing_benefit_LHA_allowed_bedrooms, housing_benefit_shares_accommodation, joint_tenant_in_household_head_household
#2080 housing_benefit_LHA_cannot_share_bedrooms and the helpers it refactors
#2077 council_tax_reduction_individual_non_dep_deduction_eligible

#2006's uc_non_dependants_counted (from its commit 01d140e77, after this PR was opened) is switched here, in the merge of main.

#2015 and #2078 (#2078 is stacked on #2015) add council_tax_reduction_head_applies_alone and is_council_tax_reduction_applicant_or_partner, which read council_tax_reduction_household_head, the variable this PR removes; #2078 also reads it in test_council_tax_reduction_applicant_properties.py. It has the same formula as is_resolved_household_head, so each renames those reads and changes nothing else. A deleted variable fails loudly (VariableNotFound), so a missed read cannot pass CI.

Not in this PR

  • The rule takes the eldest flagged member even if that member is a child. If a household's only flagged member is under 18, every programme agrees the child's family is the head's. No one in that family is treated as liable for the council tax, so it makes no Council Tax Reduction claim; a sharer family still claims on its own liability, and with no sharer no one in the household claims. Survey data takes the flag from the FRS household reference person (hrpid == 1).
  • Give Council Tax Reduction to the household head's family, not the oldest adult's #2009's deferred finding N3 (the Council Tax Reduction means test reads the generic claimant, not the applicant) is unchanged.

axiom: n/a: model-structure change. It resolves a model input (which household member is the head) and changes no provision, rate or eligibility rule.

🤖 Generated with Claude Code

is_household_head is an input, so it can flag several members of a
household, or none. Council Tax Reduction settled on one head, but rent,
non-dependant and maintenance-loan variables read the raw flag, so a
malformed input left programmes disagreeing about whose household it is:
two flagged families each got half the rent while one was neither a
claimant nor a non-dependant, a couple both flagged had the rent counted
twice, and a household with no one flagged had no rent at all.

Add is_resolved_household_head (the eldest flagged member, else the eldest
member; exactly one per household), define benunit_contains_household_head
from it, and point every reader of the raw flag at one of the two. This is
the rule council_tax_reduction_household_head had, so that variable goes.

Survey data has exactly one flagged head per household, so results on it
do not change.

Fixes #2059

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
share_of_household_rent falls back to the head's family only when no one in
the household is liable for the rent, which needs the head's family to have no
claimant: a family of children under 16. The generator never made one, so a
mutation that put that fallback back on the raw is_household_head flag went
uncaught. It now makes a family of one child under 16 a quarter of the time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
share_of_household_rent gives the whole rent to the head's family when no one
in the household is liable for it. That happens only when the head's family
has no claimant, and random generation almost never builds a household where
it also disagrees with the raw flags. Two explicit examples do: a household of
two children with no one flagged beside a household that has the input, and
two flagged children where the elder is alone in its family. With them, both
properties catch a mutation that puts the fallback back on the raw flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Composition note from #2078 (also stacked on #2009), which makes the CTR means test assess the applicant and partner rather than the benefit unit's claimant and partner.

#2078 adds two variables that read council_tax_reduction_household_head, which this PR deletes:

  • policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_head_applies_alone.py
  • policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_council_tax_reduction_applicant_or_partner.py

So does its property test, policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py (the identity invariant).

is_resolved_household_head here has the same formula (flagged eldest, else eldest, ties by order). Whichever of #2062 and #2078 lands second renames council_tax_reduction_household_head to is_resolved_household_head in those three files; nothing else changes. #2015 carries byte-identical copies of the two variable files, so the same rename applies there.

MaxGhenis and others added 6 commits October 5, 2026 11:11
…ead's documentation

- Add STUDENT_FLAGGED_BESIDE_PARENT as an explicit example in both
  household-head properties: a higher-education student flagged beside an
  earning parent in an owner-occupied home, the only case where the
  maintenance loan's living-arrangement and household-income proxies turn
  on the head. Both raw-reader mutants are caught with it.
- Say in is_resolved_household_head's documentation that it is chosen from
  the input and age alone, not from who owns or rents the accommodation.
- Make invariant 5 of the CTR claimant properties say what its mask checks:
  exactly one flagged member, or no input anywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…head

Conflicts:
- LHA_allowed_bedrooms, lha_renter_has_non_dependant, uc_non_dep_deductions:
  take main's (#2006) `counted = benunit("uc_non_dependants_counted", period)`.
- housing_benefit_LHA_allowed_bedrooms: take main's (#2006) rewrite, which
  reads is_liable_for_household_rent and no head.
- test_council_tax_reduction_claimant_properties docstring: main's invariant
  4, this branch's invariant 5.

New raw reader from main: uc_non_dependants_counted now reads
benunit_contains_household_head. The household-head property test computes
it, and its Universal Credit assertion follows #2006's rule: deductions fall
only on the counted claim, at most one per household, and on the head's
family wherever that family is liable for the rent and claims.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's uc_non_dependants_counted (UC Regs 2013 Sch 4 para 9(2)(f)) gives the
household's non-dependants to the head's family's Universal Credit claim if
it claims, else to the first liable family that does. The resolved head here
is 70, over State Pension age and not eligible for UC, so the sharer's claim
carries the £1,000 deduction. The well-formed twin (only the elder flagged)
gives the same values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review after the merge of main (finding 3): moving uc_non_dependants_counted
onto the resolved head was guarded only by a random example. With the head and
a sharer both flagged beside a non-dependant, main's raw-flag reading counts
the non-dependant in both renters' Universal Credit claims, so the deduction is
taken twice and both get a bedroom for them; UC Regs 2013 Sch 4 para 9(2)(f)
allows one. Add that household as a YAML case and as an explicit example of
both household-head properties.

Also, from the same review:
- the first YAML case's comment says what main did: the younger flagged adult
  was neither a CTR claimant nor a UC or HB non-dependant (CTR did treat them
  as a non-dependant);
- the property test's UC invariant says which part restates the formula;
- is_household_head's documentation says its default applies only where no
  one in the simulation has the input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mple

Review after-merge-delta, nit 1: no explicit example told #2006's head-first
order in uc_non_dependants_counted from the order the families are given. The
new HEAD_LISTED_AFTER_SHARER household flags no one, so the eldest member is
the head, and lists the head's family after a sharer's, beside a
non-dependant, all claiming Universal Credit. The head's family's claim counts
the non-dependant; read from the raw flag, or without the head-first order,
the sharer's would. Both properties carry it.

Nit 2: the YAML comment on the head-and-sharer case now separates what UC
Regs 2013 Sch 4 para 9(2)(f) requires (only one claim counts the
non-dependant) from the model's choice of the head's family's claim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 6, 2026 00:44
@MaxGhenis
MaxGhenis merged commit c4659aa into main Oct 6, 2026
6 checks passed
@MaxGhenis
MaxGhenis deleted the resolved-household-head branch October 6, 2026 00:46
MaxGhenis added a commit that referenced this pull request Oct 6, 2026
Brings in #2062 (one resolved household head) since 60821c7. #2062
deletes council_tax_reduction_household_head, which this PR's applicant
files read; the merge had no textual conflict, so without this change
every Council Tax Reduction calculation on the merged tree fails with
VariableNotFound. As agreed on #2062 ("whichever lands second"), the two
reads in is_council_tax_reduction_applicant_or_partner and
council_tax_reduction_head_applies_alone now use is_resolved_household_head,
which has the same formula. Nothing else changes.

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

Programmes read the raw household head flag, so a malformed input makes them disagree about whose household it is

1 participant