Skip to content

Read one resolved household head in every programme - #2062

Draft
MaxGhenis wants to merge 3 commits into
ctr-claimant-household-headfrom
resolved-household-head
Draft

MaxGhenis wants to merge 3 commits into
ctr-claimant-household-headfrom
resolved-household-head

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #2059

Stacked on #2009 (ctr-claimant-household-head), which is stacked on #2006 and #1896. It merges after them.

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. 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/.
  • council_tax_reduction_household_head (added by Give Council Tax Reduction to the household head's family, not the oldest adult's #2009, not yet on main) 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_dep_deductions, LHA_allowed_bedrooms, housing_benefit_LHA_allowed_bedrooms, lha_renter_has_non_dependant 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

What a malformed input did, and does now

Household calculations for a home with a £12,000 rent. "Before" is #2009's head, 4b84ea069.

Input Before Now
Two single adults in separate families, both flagged Each family is liable for £6,000. The second flagged family is not a Council Tax Reduction claimant, and not a non-dependant for Universal Credit, Housing Benefit or Council Tax Reduction. 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 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.
    • Only the head's family has Universal Credit non-dependant deductions; only families with a share of the rent have Housing Benefit ones.
    • 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, 8 cases: two flagged families, a couple both flagged, none flagged, none flagged with a tie, two flagged beside a sharer, a flagged lodger, a flagged student, and a household with no input beside one that has it. Each checks rent, Universal Credit and Housing Benefit non-dependants and Council Tax Reduction together. All 8 fail on Give Council Tax Reduction to the household head's family, not the oldest adult's #2009's head.
  • 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.
  • Mutation check (not in CI): PENDING. It puts each reader back on the raw flag, one at a time, or breaks the rule, and expects the two properties to fail without the source scan. A first run found one survivor: share_of_household_rent reads the head only when no one in the household is liable for the rent, and the generator never built such a household. The test now generates families of a child alone and has two explicit examples of it, and both properties catch that mutant. The full run of all 18 mutants against the final test is in progress.
  • Suites, local and targeted (this host cannot hold the whole suite):
    • YAML under household, finance/benefit/family, gov/local_authorities, gov/dfe and gov/dwp: 815 passed.
    • pytest for the new property tests, the Council Tax Reduction and rent property tests, test_deterministic_variables.py, the maintenance-loan proxies and the participation tests: 63 passed.
    • The full make test runs in CI by dispatch, because PR CI only runs for PRs into main: run 37017050713 on 057718b7f: green, 1,527 YAML and 2,094 pytest passed (1 skipped).

Enhanced FRS: no change

Real Microsimulation runs of base 4b84ea069 (#2009's head) against this branch dc149042f, on the same dataset file each time.

Dataset Year Households With exactly one flagged head Variables compared Bitwise identical
Enhanced FRS 2024-25 (e433e532) 2026 52,846 52,846 114 114
Enhanced FRS 2024-25 (e433e532) 2025 52,846 52,846 114 114
Enhanced FRS with sharer, boarder and lodger inputs (#2006's "payer" build, ed1a2991) 2026 52,846 52,846 114 114
  • The 114 variables are the 111 that a traced household calculation shows depending on the head, from the rent variables up to household_net_income, plus is_household_head, household_weight and pension_credit. The base's council_tax_reduction_household_head is compared with is_resolved_household_head.
  • "Bitwise identical" compares each array's bytes and dtype.
  • The comparison is not empty: in the 2026 run there are 52,846 resolved heads, 8,993 non-dependants of a household head and 4,605 families with a Universal Credit non-dependant deduction (unweighted counts of survey records).
  • A second check computes every variable in the model for 2026 on the Enhanced FRS, base and branch, and hashes each array: all 982 are bitwise identical, and none failed to compute on either side.

Coordination with other open PRs

These open PRs add formulas that read is_household_head directly. Whichever lands second 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
#2006 (commit 01d140e77, after this PR was opened) uc_non_dependants_counted. #2006 lands before this PR, so the switch is made here when this PR merges main.

#2015 and #2078 (both stacked on #2009) 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 whichever lands second 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. A household whose only flagged member is under 18 has every programme agreeing on that child's family, and no Council Tax Reduction claim. The survey data never flags a child.
  • 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.

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.

1 participant