Skip to content

Send explicit UK claimant roles and stop reading an adult dependant as the partner - #1240

Draft
MaxGhenis wants to merge 3 commits into
mainfrom
uk-builder-claimant-flags
Draft

MaxGhenis wants to merge 3 commits into
mainfrom
uk-builder-claimant-flags

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Problem

The UK household builder puts everyone in one benefit unit and sends only ages and incomes. policyengine-uk then infers who the claimant and partner are from ages, so a lone parent aged 50 with a dependant aged 25 is assessed as a couple (PolicyEngine/policyengine-uk#2039).

The builder made the same mistake in its own composition logic. getBuilderPartnerKey fell back to any other adult aged 18+, so:

  • setting a dependant's age to 25 flipped "Marital status" to "Married";
  • choosing "Single" then deleted the dependant;
  • choosing "Married" did nothing, because the dependant already counted as the partner.

The US builder had the same fallback.

What changes

The partner must be explicit (Household.getBuilderPartnerKey, both countries). A partner is one of:

  • the other member of the primary person's marital unit (US);
  • the person named "your partner";
  • a member of the primary person's own benefit unit whose is_claimant_or_partner is true for the year, when the primary person is flagged true too. A flagged member of another unit is that unit's own claimant. If the primary person is flagged false, or not flagged at all, nobody is their partner by flag. policyengine-core reads an unflagged person as false once anyone has the input.

Age never makes a partner.

The builder's controls manage only the family unit of "you". "Marital status" and "Number of children" read and change only the primary person's own unit (UK benefit unit, US tax unit). New partners and children join the groups that hold "you". Members of another unit belong to their own claimant, so the builder never counts them as children and never deletes them. A saved household whose other unit is listed first no longer gets the new partner in the wrong unit. Main counted and deleted them too.

UK builder households carry explicit roles. Household.getBuilderClaimantRoles(year) gives is_claimant_or_partner per person: true for "you" and the explicit partner, false for every other member. withBuilderClaimantRoles(year) records them, keeping any value a person already has.

Roles are generated only for the builder's structure: a person named "you" and one benefit unit holding everyone. They are all or nothing. policyengine-core gives anyone without an input the variable's default (false), not its formula, so roles for one unit would make another unit's own claimant a non-claimant. Other households get no roles, and policyengine-uk keeps inferring them as before. That covers saved multi-unit households and households made outside the builder.

The roles are sent only when the model accepts them. withSupportedBuilderClaimantRoles (in utils/builderClaimantRoles.ts) runs before every save:

  • When the loaded model information for the household's country defines the variable, it adds the roles.
  • When that model information lacks the variable, it removes any roles the household already carries, so a stored flag never reaches a model that rejects it.
  • Until model information for the household's country has finished loading without error (the app's getModelMetadataError), it leaves the household alone. Saving is blocked then anyway.

It runs at each place the app creates a household:

  • the standalone builder (HouseholdBuilderView);
  • the report-builder modal, for both "create" and "update existing";
  • report submission, which creates the year-copied drafts (createReportSimulations).

The roles are stored in the household itself, the way the US builder stores is_tax_unit_dependent. The v1 and Python-package codecs already pass person fields through unchanged, and both the saved-household calculation and the earnings-variation chart read the stored household, so the two always agree. The in-memory household handed on after a save is the one that was stored.

New children are numbered after existing dependants. Numbering starts after every dependant and after the highest ordinal in use. Adding a child next to an adult dependant now gives "your second dependent" instead of "your first dependent 2", and a gap left by removed children no longer causes a collision either.

Why the gate, and what happens before policyengine-uk ships

is_claimant_or_partner was added to policyengine-uk by PolicyEngine/policyengine-uk#1896, which merged on 2 October 2026 but is not yet in the API's model. I checked the live API on 2 October 2026, and again on 3 October:

  • its UK model information (policyengine-uk 2.90.2, 879 variables) does not list the variable;
  • POST /uk/calculate with the variable returns Unrecognized household variable is_claimant_or_partner;
  • policyengine-core raises SituationParsingError for a situation that names an unknown variable (simulation_builder.py, init_variable_values).

So sending the roles unconditionally would break every UK household calculation. With the gate, this PR is safe to merge now:

  • Until the API deploys a policyengine-uk release with #1896, saves of households without stored roles are byte-for-byte unchanged. A household that already carries roles has them removed before saving, since that model would reject them. Today no such household can have been saved through this app. The builder shows a lone parent with an adult dependant as single, but the live model still assesses them as a couple (checked: is_couple true, UC standard allowance £8,003.64 for 2026).
  • After it deploys, the same build starts sending the roles with no further app change.

Households saved earlier have no roles. Builder households get them the next time they are saved through the builder.

The earnings-variation chart sends the stored household as it is. A household stored with roles would fail there only if the API's model later lost the variable, and the saved-household calculation would fail the same way.

Invariants

Each is a fast-check property. Most run over random sequences of builder actions (marital status, child count 0-5, any person's age 0-100) on UK and US starter households. The multi-unit properties add a separate claimant in a benefit unit of their own, with any age and with or without a role, and optionally record roles first.

  1. The partner is "your partner" if that person exists, and otherwise there is none. Marital status follows.
  2. Changing any member's age never changes who the partner is.
  3. UK roles cover every person, are true for "you" and the explicit partner only, and so name at most two people.
  4. US builder households get no roles.
  5. Recording roles is idempotent and survives the saved-household round trip (toV1CreationPayload then fromV1CreationPayload).
  6. The v1 payload and the Python-package situation carry the same roles (differential between the two codecs).
  7. Without the variable in the model information, no household sends a role, including households that already carry roles.
  8. Setting the child count to n gives n children, keeps every adult, and leaves the partner unchanged.
  9. A claimant in another benefit unit is never the partner, and choosing "single" keeps them.
  10. A household with more than one benefit unit gets no generated roles.
  11. Recording roles, editing further in the builder, then recording again leaves every stored role equal to the composition's role.
  12. On a multi-unit household (the other unit listed first or second), any sequence of builder edits leaves the other unit exactly as it was. Every new member joins the unit of "you", and the child count never includes the other unit's claimant.

Tests

  • Composition (UK and US):
    • a single parent and a couple with a dependant aged 25;
    • "single" keeps the dependant, and on a couple removes only the partner;
    • child naming, including after an ordinal gap;
    • a flagged member of the same benefit unit is the partner;
    • a flag for another year does not make a partner, nor does an unflagged second adult;
    • a flagged claimant in another benefit unit is not the partner, is not counted as a child, and survives "single", "married" and a child count of zero;
    • a new partner and child join the unit of "you" whichever unit is listed first;
    • "you" flagged false, or not flagged, acquire no partner from flagged members, and "single" keeps them;
    • a US child in another tax unit is not counted or removed.
  • Roles and codecs:
    • both required cases (single parent with a dependant aged 25, couple with a dependant aged 25);
    • no roles for two benefit units (an unflagged adult; a separately claiming 17-year-old) or for someone outside the unit;
    • the v1 payload, the Python-package situation, and a fromV1Metadata round trip.
  • Gate: model with the variable; model without it, with and without stored roles; model information still loading, for another country, or not loaded; a two-unit household; a US household.
  • Save sites: the builder view through the real API module with a stubbed fetch, the modal's create and update paths, and createReportSimulations.
  • Mutation check, first commit:
    • restoring the old any-adult fallback fails 23 of the new tests;
    • a gate that never adds roles fails 6, one at every save site;
    • a gate that ignores model information fails 6;
    • overwriting existing roles fails 1;
    • the old child naming fails 2.
  • Mutation check, review fixes:
    • an unscoped flag partner fails 4;
    • roles across the whole household fail 5;
    • passing stored roles through fails 2;
    • naming that ignores gaps fails 1.
  • Mutation check, second review fixes:
    • child lookup across units fails 5;
    • a flag partner without the primary person's flag fails 2;
    • adding new members to the first group fails 2;
    • a gate that ignores loading fails 1.
  • After the second review fixes, the model, utils, integration, report-builder, household-component and hooks suites pass (130 files, 1,789 tests, 0 failed).
  • First commit: the full app suite passed (362 files, 4,077 tests, 10 skipped, 0 failed). After the review fixes, I ran the model, utils, household integration and report-builder suites (86 files, 1,351 tests, 0 failed) on a memory-constrained host. CI runs the full suite. tsc, eslint and prettier are clean.

fast-check is added to the app's devDependencies. The website workspace already uses the same version.

Browser verification

The live API cannot show the single-claimant result yet, so I ran the calculator against a local API:

  • the real policyengine-api v1 routes at 0ebfc086, on a local SQLite database (the same engine swap the API's own legacy test suite uses), never Cloud SQL;
  • policyengine-uk from Presume a much younger member is a flagged parent's child at any age policyengine-uk#2040's head 72d70ddb (2.104.2). policyengine-us was upgraded to 2.21.0 in that environment so it loads under the newer policyengine-core;
  • the calculator dev server with BASE_URL pointed at it (local edit, not committed).

In the UK report builder I created a household with one child, set "you" to 50 and the dependant to 25, and ran the report for 2026:

Household Builder shows Stored is_claimant_or_partner Model result
Lone parent 50, dependant 25 Single, 2 members you true, dependant false is_couple false, UC standard allowance £5,098.80
Couple 50 and 30, dependant 25 Married, 3 members you and partner true, dependant false is_couple true, UC standard allowance £8,003.64

The report page showed household benefits of £5,099 for the lone parent, and the earnings-variation chart loaded from the same stored household. The same lone-parent household without the roles, on the same local model, is a couple with £8,003.64. These are single-household checks on an unmerged model branch, not published figures.

Review

An independent review (GPT-6.1 Sol, via Subfleet) of the first commit requested changes. It reproduced two bugs:

  • a flagged claimant in another benefit unit could become the partner and then be deleted;
  • roles were applied across multi-unit households.

It also noted three smaller points:

  • stored roles still passed through the gate;
  • ordinal gaps caused name collisions;
  • one mock was inline in the integration test.

All five are fixed in 0f4234ba, with the regression tests and properties above.

A second review (GPT-6.1 Sol) of 0f4234ba confirmed all five fixes, and CI on that head ran 4,090 app tests with no failure. It requested changes for two more cases:

  • a 17-year-old claimant in another benefit unit was counted as a child, so a child count of zero deleted them;
  • a primary person flagged false still acquired a flagged member as partner.

It also noted three smaller points:

  • the helper treated still-loading model information as loaded;
  • a new partner joined whichever unit was listed first;
  • the multi-unit properties covered only marital toggles.

All are fixed in 3768c7f7. Its stale "#1896 unmerged" claim and the overstated "byte-for-byte" claim are corrected above.

Notes for review

  • The report output's household inputs list every stored person variable, so "Is claimant or partner" will appear there once the roles are sent, as is_tax_unit_dependent does for US households.
  • A UK household with a second adult who is neither named "your partner" nor flagged now reads as single in the builder. Before, the builder guessed that adult was the partner.

Refs PolicyEngine/policyengine-uk#2039 and PolicyEngine/policyengine-uk#2040 (which lists this as a follow-up).

axiom: n/a: app change to how households are entered and sent; no policy rule changes.

🤖 Generated with Claude Code

…s the partner

The UK builder puts everyone in one benefit unit and sent only ages, so
policyengine-uk inferred the couple from ages: a lone parent aged 50 with a
dependant aged 25 was assessed as a couple (PolicyEngine/policyengine-uk#2039).
The builder made the same mistake itself: getBuilderPartnerKey fell back to any
other adult, so setting a dependant's age to 25 flipped the composition to
married, and choosing "single" then deleted the dependant.

- getBuilderPartnerKey now returns only an explicit partner: the US marital
  unit, "your partner", or a member flagged is_claimant_or_partner for the
  year. Age never makes a partner. This applies to US and UK builders.
- Household.getBuilderClaimantRoles / withBuilderClaimantRoles give a UK
  builder household ("you" present) explicit roles: "you" and the explicit
  partner true, every other member false. Values already set are kept.
- withSupportedBuilderClaimantRoles adds those roles before every save
  (standalone builder, report-builder modal create and update, report year
  copies) only when the loaded UK model information defines the variable.
  policyengine-core rejects a situation naming an unknown variable, and the
  live API (policyengine-uk 2.90.2) does not have it yet, so saves are
  unchanged until it ships.
- New children are numbered after existing dependants, so adding a child
  next to an adult dependant gives "your second dependent", not
  "your first dependent 2".

Tests: composition cases for UK and US single parents and couples with a
dependant aged 25, role and codec round trips (v1 payload, Python package,
saved household), the metadata gate, every save site, and fast-check
properties over random builder action sequences.

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

vercel Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
policyengine-calculator-next Ready Ready Preview Oct 3, 2026 11:34am UTC
policyengine-website Ready Ready Preview Oct 3, 2026 11:34am UTC

Request Review

Independent review of 8c57518 found two reproduced bugs and three smaller
issues; all are fixed here.

- A claimant-or-partner flag names the partner only inside the primary
  person's benefit unit. Before, a flagged claimant in another unit could
  become "the partner", and choosing "single" deleted them.
- Roles are generated only for the builder's structure: "you" and one
  benefit unit holding everyone. policyengine-core gives anyone without an
  input the variable's default (false), not its formula, so roles for one unit
  would make another unit's own claimant a non-claimant. Multi-unit and
  partly-outside households now get no roles, and the model infers them as
  before.
- When the loaded UK model information lacks the variable, roles a household
  already carries are removed before saving, so a stored flag can never reach
  a model that rejects it.
- New children are numbered after the highest dependant ordinal in use, so a
  gap no longer gives "your second dependent 2".
- The integration test's API response moved to fixtures.

Tests: regression cases for each, and fast-check properties over saved
multi-unit households, households already carrying roles, and roles recorded
before and after further builder edits. Undoing each fix fails 4, 5, 2 and 1
tests respectively.

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

The second independent review (of 0f4234b) confirmed the round-1 fixes and
found two more cases; both are fixed here, with three smaller points.

- The partner and child controls manage only the primary person's own family
  unit (UK benefit unit, US tax unit). A 17-year-old claiming in another
  benefit unit was counted as a child, so setting the child count to zero
  deleted them and their unit. Main had the same behaviour.
- A claimant-or-partner flag names a partner only when "you" are flagged as a
  claimant too. Before, "you" flagged false (or unflagged, which
  policyengine-core reads as false once anyone has the input) still acquired a
  flagged member as partner, and "single" deleted them.
- New partners and children join the groups that hold "you", not whichever
  group is listed first, so a saved household whose other unit comes first no
  longer gets the new partner in the wrong unit.
- The role helper treats model information as loaded only when it has finished
  loading without error (the app's getModelMetadataError), so roles are never
  stripped while it is still loading.

Tests: regression cases for each, a US tax-unit case, and a fast-check
property that builder edits on a multi-unit household never touch the other
unit and that new members join the unit of "you". Undoing each fix fails 5, 2,
2 and 1 tests.

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

This branch was successfully deployed

2 active deployments
Preview – policyengine-calculator-next — 3768c7f7 Deployed Oct 3, 2026 by vercel[bot]
Preview – policyengine-website — 3768c7f7 Deployed Oct 3, 2026 by vercel[bot]
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