Skip to content

Supply is_claimant_or_partner from the FRS adult table - #524

Merged
MaxGhenis merged 2 commits into
mainfrom
frs-claimant-or-partner
Oct 7, 2026
Merged

MaxGhenis merged 2 commits into
mainfrom
frs-claimant-or-partner

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Supplies policyengine-uk's is_claimant_or_partner (each benefit unit's single adult or couple) from the survey, so the model stops inferring it from ages. Follow-up to PolicyEngine/policyengine-uk#2039 (PolicyEngine/policyengine-uk#2040). Companion to issue #486, which proposes supplying is_hbai_dependent_child from the FRS child table: every FRS person is in exactly one of the two tables.

What changes

Dataset is_claimant_or_partner Why
FRS (datasets/frs.py) membership of the FRS adult table An FRS benefit unit is one adult or a couple plus dependent children. The adult table holds the head (UPERSON 1) and any partner (UPERSON 2); any other adult in the household forms and heads their own benefit unit.
SPI (datasets/spi.py) true Each SPI record is its own one-person benefit unit.
Public transfer dataset (datasets/enhanced_cps.py) the tax-unit head, plus the spouse for joint filers Each source household is one tax unit, head listed first and any spouse second.
  • derive_is_claimant_or_partner_from_frs_microdata sits next to derive_is_parent_from_frs_microdata. It raises if a benefit unit has no adult record or more than two, so a future FRS release with a different structure fails the build instead of passing wrong roles.
  • stack_datasets refuses to stack a person table without the column onto one with it.
  • The SPI-synthetic copies, CGT band donors, OA cloning, subsampling, uprating and local-area subsets all copy whole person tables, so the column follows them. The second-stage QRF (imputations/frs_only.py) only rewrites its curated list.
  • storage/enhanced_cps_2025.h5 gains the column in place, without a rebuild. A rebuild would also recalibrate its weights against today's targets and lock. The column is copied from the builder's own uncalibrated pass over the same source manifest, after checking person id, household, benefit unit and age line up for every row. Every other column and table is unchanged (checked with assert_frame_equal). Result: 13,074 one-adult and 15,458 two-adult benefit units; the role equals "aged 18+" for this source.

policyengine-uk defines the variable from PolicyEngine/policyengine-uk#1896 (merged 2 October). Simulation.build_from_multi_year_dataset sets every dataset column as an input for every simulated year and skips columns the installed release does not define, so the column is inert for earlier releases, including the 2.93.0 lock this repo builds with. The runs below confirm that policyengine-uk main returns the supplied roles exactly.

Evidence that adult-table membership is the role

Raw FRS adult and child tables, four releases, counts of survey records only:

Release Benefit units 1 adult record 2 adult records Two-adult units coded (1, 2) Child records with UPERSON 1
2020-21 11,464 5,652 5,812 all 0
2022-23 28,629 14,778 13,851 all 0
2023-24 19,374 10,158 9,216 all 0
2024-25 18,850 9,986 8,864 all 0

Every benefit unit has exactly one UPERSON 1 adult, no benefit unit lacks an adult record, and adult and child person ids never overlap. A child record can be UPERSON 2 (under a single head), so UPERSON 2 alone does not identify a partner; adult-table membership does.

Impact (real microsimulation runs)

A seeded, production-settings build of this branch (512 epochs, 1 OA clone; the same cached calibration-target downloads as the other sessions' main builds) was simulated with policyengine-uk main (3c48247e, which includes PolicyEngine/policyengine-uk#1896), for 2025 and 2026. Builds are not bit-reproducible across worktrees, so the comparison is made within the built file: the column as supplied vs the column dropped, so that the model infers it. Weights and every other input are identical. The build's own simulations run on the 2.93.0 lock, whose loader skips the column, so calibration does not see it.

  • No result changes today. policyengine-uk main's inference agrees with the supplied roles for all 113,567 people in both years. Every compared array is identical: person is_claimant_or_partner, income tax and NI; benefit-unit UC, HB, Pension Credit, Child Benefit, CTC, WTC, IS, benefit cap and relation_type; household CTR, net income, HBAI net income BHC/AHC, benefits, tax and BHC/AHC poverty. The same holds on Presume a much younger member is a flagged parent's child at any age policyengine-uk#2040's head (c18c836a9).
  • What it buys: the microsimulation no longer depends on the age presumption. With the column supplied, main, Presume a much younger member is a flagged parent's child at any age policyengine-uk#2040 and the proposed unflagged 20-year presumption give identical results for every array above. Without it, that presumption would split 33 survey couples (96k weighted households) who are 20+ years apart and carry no parent flag.
  • Built-dataset invariant tests pass on this build (13 passed) and in CI's TESTING build.

Cells under 10 survey households are suppressed; SPI-synthetic and donor copies do not count toward the 10.

Tests

  • tests/test_claimant_or_partner.py:
    • examples: a grown-up child heading their own unit; a couple with children; units with no adult or three adults are rejected;
    • Hypothesis properties over generated FRS-shaped households (1-6 households, 1-3 benefit units each, 1-2 adults plus 0-4 children, records shuffled): every unit has a claimant and at most one partner; record order never changes anyone's role; every is_parent is a claimant or partner; a unit with no adult or a third adult is rejected;
    • stack_datasets keeps the roles (bool) and refuses a one-sided stack;
    • built datasets (run in CI after make data): base and enhanced FRS have the column, 1-2 per unit, every benefit-unit head and every is_parent among them, all aged 16+, and everyone aged 20+ has it (FRS dependent children are 19 at most), so a dropped partner fails.
  • test_clone_and_assign.py: roles follow each clone. test_spi_build.py: every SPI record is its unit's claimant. test_policybench_transfer.py: the head and a joint spouse; the checked-in H5 matches the builder on the first 500 households. Its leaf-input check now allows dataset-supplied roles, which the installed policyengine-uk may not define.
  • Hypothesis is added to the dev extra. uv.lock adds it and also corrects the lock's stale self-version (1.56.16 to 1.57.4, matching pyproject.toml).
  • The synthetic create_frs smoke test (test_legacy_benefit_proxies.py) asserts the column. Mutation check: all 8 mutants killed by unit tests, including not setting the FRS column at all.
  • Reviews: independent review approved 61f93ba; a delta review approved 5e320ff (the age-20+ and smoke-test hardening it suggested).

Invariants

  1. Each benefit unit has one or two claimants/partners (the build raises otherwise).
  2. Every benefit-unit head and every is_parent adult is a claimant or partner; dependent children never are.
  3. Roles don't depend on record order.
  4. Stacking, cloning, subsampling and uprating preserve roles; stacking refuses a one-sided table.
  5. Differential: the checked-in transfer H5 equals its builder.

Merge

Merging releases data (push.yaml builds and uploads). Per the batched-release ruling, this lands with the next batch when Max says go, after CI and an independent review approve the head.

Follow-ups

🤖 Generated with Claude Code

An FRS benefit unit is one adult or a couple plus dependent children. The
adult table holds the head (UPERSON 1) and any partner (UPERSON 2); any
other adult forms and heads their own benefit unit. So adult-table
membership is policyengine-uk's is_claimant_or_partner (#1896), which the
model otherwise infers from ages and cannot get right for unflagged
large-age-gap households (policyengine-uk#2039).

- frs.py derives it next to is_parent and rejects a benefit unit without
  one or two adult records (none in FRS 2020-21, 2022-23, 2023-24 or 2024-25).
- SPI records are one-person benefit units, so each is its claimant.
- The transfer builder marks the tax-unit head and a joint filer's spouse;
  the checked-in enhanced_cps_2025.h5 gains the builder's column in place
  (every other column unchanged).
- stack_datasets refuses to stack a person table without the role onto one
  with it. Cloning, subsampling, uprating and the SPI and CGT donor copies
  carry whole person tables, so the column follows them.
- Tests: examples, Hypothesis properties over generated FRS-shaped
  households, stacking, cloning, SPI, transfer builder vs checked-in H5,
  and built-dataset invariants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Built datasets: every person aged 20+ is a claimant or partner (FRS
  dependent children are 19 at most), so an omitted partner in a
  childless couple fails.
- The synthetic create_frs smoke test asserts the column, so dropping the
  assignment now fails a unit test, not only CI's built-dataset test.
- Changelog: stacking refuses to combine a person table that has the role
  with one that does not (two tables both without it still stack).

Suggested by review 20261002-095728-ukdata-cp-review-r1 (APPROVE at 61f93ba).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 3, 2026 11:51
MaxGhenis added a commit that referenced this pull request Oct 6, 2026
Conflict resolved per runbook A4 step 13: datasets/frs.py keeps all three new functions, #490's derive_all_claimants_over_state_pension_age, #524's derive_is_claimant_or_partner_from_frs_microdata and #525's derive_uc_is_in_gainful_self_employment. #525's uv.lock edits auto-merged and are reset to main's in R.

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

Copy link
Copy Markdown
Contributor Author

Queued in release PR #544 for the 10/8 uk-data batch. It lands only on Max's go (d833).

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