From 3771b7466282f1623c4d728b52cc9fd470c29303 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 06:13:11 -0400 Subject: [PATCH 01/10] Fix UC financial capital reports suppressing countable property --- changelog.d/2439.fixed.md | 1 + policyengine_uk/data/uprating_indices.yaml | 6 +- .../means_test/capital/person_sources.yaml | 5 +- .../capital/property_sale_cost_rate.yaml | 14 + .../means_test/capital/property_sources.yaml | 25 ++ .../means_test/capital/sources.yaml | 53 +-- .../income/uc_income_from_capital.yaml | 31 +- .../income/uc_unearned_income.yaml | 11 +- .../universal_credit/is_UC_eligible.yaml | 10 +- .../test_uc_capital_allocation.py | 6 +- .../uc_assessable_capital.yaml | 6 +- .../universal_credit/uc_property_capital.yaml | 65 +++ .../wealth/benunit_reported_capital.yaml | 10 +- ...est_benunit_reported_capital_properties.py | 87 ++-- .../tests/test_stock_capital_periods.py | 2 +- .../tests/test_uc_property_capital.py | 406 ++++++++++++++++++ .../tests/test_uc_property_capital_mock.py | 327 ++++++++++++++ .../household_uc_pool_company_holdings.py | 4 +- ...household_uc_reported_financial_capital.py | 24 ++ .../household_uc_unreported_claimants.py | 10 +- .../universal_credit/uc_assessable_capital.py | 91 ++-- .../uc_non_residential_property_capital.py | 24 ++ .../uc_other_residential_property_capital.py | 24 ++ .../universal_credit/uc_property_capital.py | 38 ++ .../universal_credit/uc_reported_capital.py | 22 +- .../wealth/benunit_reported_capital.py | 20 +- .../non_residential_property_secured_debt.py | 20 + .../input/non_residential_property_value.py | 6 +- ...other_residential_property_secured_debt.py | 20 + .../input/other_residential_property_value.py | 6 +- 30 files changed, 1199 insertions(+), 175 deletions(-) create mode 100644 changelog.d/2439.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/property_sale_cost_rate.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/property_sources.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml create mode 100644 policyengine_uk/tests/test_uc_property_capital.py create mode 100644 policyengine_uk/tests/test_uc_property_capital_mock.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/uc_non_residential_property_capital.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/uc_other_residential_property_capital.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/uc_property_capital.py create mode 100644 policyengine_uk/variables/input/non_residential_property_secured_debt.py create mode 100644 policyengine_uk/variables/input/other_residential_property_secured_debt.py diff --git a/changelog.d/2439.fixed.md b/changelog.d/2439.fixed.md new file mode 100644 index 0000000000..39063b8ec2 --- /dev/null +++ b/changelog.d/2439.fixed.md @@ -0,0 +1 @@ +- Fixed Universal Credit capital assessment so reported financial savings do not suppress countable property, with assessment-year sale-expense and secured-debt valuation and explicit household ownership assumptions. diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index 08e75b61ef..9f1fb59c3d 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -106,8 +106,10 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - mortgage_capital_repayment - net_financial_wealth - non_residential_property_value +- non_residential_property_secured_debt - other_investment_income - other_residential_property_value +- other_residential_property_secured_debt # Purchase prices a dataset may impute; uprated like the property stocks. - additional_residential_property_purchased - non_residential_property_purchased @@ -123,8 +125,8 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - savings - savings_interest_income - sublet_income -# A formula of benunit_reported_capital; listed so a dataset that stores it -# directly is uprated like the input. +# An explicit complete UC override stored in a dataset is uprated like the +# financial input; its default no longer derives from that input. - uc_reported_capital gov.economic_assumptions.yoy_growth.obr.per_capita.mixed_income: - self_employment_income diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml index 36baf598bb..4c1dbab754 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml @@ -8,7 +8,10 @@ description: > Lifetime ISA at its surrender value: regulation 49(1) values capital at its current market or surrender value, and ADM H1659 counts a Lifetime ISA at 75% of its value under 60 and in full from 60. Lifetime ISAs exist from 6 - April 2017, and the list is empty before then. + April 2017, and the list is empty before then. A nonnegative financial + observation (benunit_reported_capital) replaces these covered financial + sources to avoid counting a Lifetime ISA twice. A complete UC override + replaces them together with property and the household financial proxy. metadata: label: Universal Credit person-level capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/property_sale_cost_rate.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/property_sale_cost_rate.yaml new file mode 100644 index 0000000000..9cd29f27b4 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/property_sale_cost_rate.yaml @@ -0,0 +1,14 @@ +description: > + Proportion deducted from current property market value where sale entails + expenses, before subtracting secured encumbrances (regulation 49). Broad + household property inputs are assumed to incur sale expenses. Cash financial + proxies do not receive this deduction. +values: + 2013-04-29: 0.1 +metadata: + label: Universal Credit property sale expense deduction + unit: /1 + period: year + reference: + - title: The Universal Credit Regulations 2013 reg. 49(1) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/49/1 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/property_sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/property_sources.yaml new file mode 100644 index 0000000000..9e6786ee38 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/property_sources.yaml @@ -0,0 +1,25 @@ +description: > + Household property amounts valued for Universal Credit in the assessment year + after sale expenses and property-specific secured debt, floored separately + at zero. The main home is excluded under Schedule 10 paragraph 1. The broad + other-property inputs are assumed to have sale expenses and no Schedule 10 + disregard; reliable asset-level disregard and debt flags are unavailable, + so debt defaults to zero unless supplied explicitly. These approximations + do not imply all second homes are legally countable. Each net amount is + allocated across ALL claimant/partner adults in the household in proportion + to their number in each benefit unit, including units with financial reports, + complete overrides and no UC receipt. A complete override consumes its share. + This is an assumption about unobserved ownership, not a statutory rule. +metadata: + label: Universal Credit countable property sources + period: year + unit: list + reference: + - title: The Universal Credit Regulations 2013 reg. 49(1) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/49/1 + - title: The Universal Credit Regulations 2013 Schedule 10 + href: https://www.legislation.gov.uk/uksi/2013/376/schedule/10 +values: + 2013-04-29: + - uc_other_residential_property_capital + - uc_non_residential_property_capital diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml index 0909fa260e..e6766c94e2 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml @@ -1,49 +1,30 @@ description: > - Model variables currently mapped to countable Universal Credit capital. - Regulation 46 takes the whole of a person's capital into account unless it is - treated as income or disregarded under regulation 48 and Schedule 10. The - current mappings are `savings` for liquid capital, - `other_residential_property_value` for residential property other than the - home, `non_residential_property_value` for other land and premises, and - `corporate_wealth` for shares and similar investment assets. `main_residence_value` - is excluded because Schedule 10 paragraph 1 disregards premises occupied as - the person's home. `private_pension_wealth` is excluded because Schedule 10 - paragraph 10 disregards the value of any right to receive a pension under an - occupational or personal pension scheme (or any other scheme registered under - section 153 of the Finance Act 2004; paragraph 10 of Schedule 10 to the - Northern Ireland regulations makes the same disregard); pension income - actually drawn counts as unearned income instead. Other Schedule 10 disregards within these broad asset - classes are not yet split out separately. Where only household-level capital - is available for a multi-benunit household, the model uses a household-wide - adult-share proxy for residual capital because the dataset does not identify - which adults hold the beneficial interest in each capital asset. The - denominator is all unreported adults in the household, including adults in - benunits that are not themselves UC claimants. That proxy is a data - approximation, not a legislated allocation rule. Regulation 47 is cited only - as related context on jointly held capital. If some benunits provide - `uc_reported_capital` (by default the benefit unit's recorded capital, - `benunit_reported_capital`), the proxy is applied only to the residual - household capital for benunits that remain unreported. + Household financial proxies for Universal Credit: savings and corporate + financial wealth. A nonnegative benunit_reported_capital replaces these + financial proxies and covered person-level sources, including a Lifetime ISA. + Only observed financial totals reduce this financial pool; a complete + uc_reported_capital override has unknown asset composition and is never + subtracted from it. The residual is allocated by claimant/partner counts + to benefit units with neither a financial observation nor a complete override, + including units that do not receive UC. This allocation is an assumption + about unobserved ownership, not a statutory rule. Countable property is + valued and shared independently through property_sources across ALL + claimant/partner adults, with complete-override units consuming their shares. + Main-home property and private pension rights remain excluded under + Schedule 10 paragraphs 1 and 10. Asset-specific Schedule 10 disregards + within these broad financial classes are not separately observed. metadata: - label: Universal Credit countable capital sources + label: Universal Credit financial capital proxy sources period: year unit: list reference: - title: The Universal Credit Regulations 2013 reg. 46(1) href: https://www.legislation.gov.uk/uksi/2013/376/regulation/46/1 - - title: The Universal Credit Regulations 2013 reg. 47 - href: https://www.legislation.gov.uk/uksi/2013/376/regulation/47 - title: The Universal Credit Regulations 2013 reg. 48(1) href: https://www.legislation.gov.uk/uksi/2013/376/regulation/48/1 - - title: The Universal Credit Regulations 2013 Schedule 10 para. 1 - href: https://www.legislation.gov.uk/uksi/2013/376/schedule/10/paragraph/1 - - title: The Universal Credit Regulations 2013 Schedule 10 para. 10 - href: https://www.legislation.gov.uk/uksi/2013/376/schedule/10/paragraph/10 - - title: The Universal Credit Regulations (Northern Ireland) 2016 Schedule 10 para. 10 - href: https://www.legislation.gov.uk/nisr/2016/216/schedule/10/paragraph/10 + - title: The Universal Credit Regulations 2013 Schedule 10 + href: https://www.legislation.gov.uk/uksi/2013/376/schedule/10 values: 2013-04-29: - savings - - other_residential_property_value - - non_residential_property_value - corporate_wealth diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_income_from_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_income_from_capital.yaml index 4467658980..ad9e4b23c1 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_income_from_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_income_from_capital.yaml @@ -18,11 +18,9 @@ # Every case below has income under the personal allowance, so no income tax # or National Insurance, and no housing, child or disability element. # -# Property values are taken as UC capital as entered. Reg. 49(1) would first -# deduct 10% for sale costs and any secured debt from a property's market -# value; the model does not yet (#1960). So "£10,000 of let property" below -# means £10,000 of assessable capital, for example a property with a market -# value of £11,111.11 and no mortgage. +# Reg. 49(1): property market value less 10% sale expenses and then secured +# debt, floored at zero. The let-property cases assume sale expenses, no +# secured debt and no Schedule 10 disregard, so £10,000 is assessed at £9,000. - name: Savings interest, dividends and rent below £6,000 of capital leave the award unchanged period: 2026 @@ -66,12 +64,13 @@ savings: 0 other_residential_property_value: 10_000 output: - # Tariff income on £10,000 of capital: (10,000 - 6,000) / 250 = 16 steps - # x £4.35 = £69.60 a month, £835.20 a year. UC = 5,098.80 - 835.20 = - # £4,263.60. - uc_tariff_income: 835.20 - uc_unearned_income: 835.20 - universal_credit: 4_263.60 + # £10,000 less 10% sale expenses = £9,000. Tariff: (9,000 - 6,000) + # / 250 = 12 steps x £4.35 = £52.20 a month, £626.40 a year. + # UC = 5,098.80 - 626.40 = £4,472.40. + uc_assessable_capital: 9_000 + uc_tariff_income: 626.40 + uc_unearned_income: 626.40 + universal_credit: 4_472.40 - name: Northern Ireland treats rent and interest from capital the same way period: 2026 @@ -92,10 +91,12 @@ savings: 0 other_residential_property_value: 10_000 output: - # UC Regs (NI) 2016 regs. 66(1) and 72 match the GB text on these points. - uc_tariff_income: 835.20 - uc_unearned_income: 835.20 - universal_credit: 4_263.60 + # UC Regs (NI) 2016 regs. 49, 66(1) and 72 match GB on these points: + # £9,000 capital after sale expenses, 12 steps, £626.40 annual tariff. + uc_assessable_capital: 9_000 + uc_tariff_income: 626.40 + uc_unearned_income: 626.40 + universal_credit: 4_472.40 - name: Rent from a lodger in the home does not reduce the award period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_unearned_income.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_unearned_income.yaml index f193617754..81f1cb975e 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_unearned_income.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_unearned_income.yaml @@ -221,11 +221,12 @@ savings: 0 other_residential_property_value: 10_000 output: - # Tariff income on £10,000 of capital in let property (taken as entered; - # reg. 49 valuation is #1960): (10,000 - 6,000) / 250 = 16 steps x £4.35 - # x 12 = £835.20. Neither the rent nor the interest is added on top of it. - uc_tariff_income: 835.20 - uc_unearned_income: 835.20 + # Reg. 49: £10,000 property less 10% sale expenses, with no debt, + # is £9,000. Tariff: (9,000 - 6,000) / 250 = 12 steps x £4.35 x 12 + # = £626.40. Neither the rent nor the interest is added on top of it. + uc_assessable_capital: 9_000 + uc_tariff_income: 626.40 + uc_unearned_income: 626.40 - name: Reported capital drives tariff income for multi-benunit households period: 2025 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_UC_eligible.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_UC_eligible.yaml index 926ade283d..efa082474e 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_UC_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_UC_eligible.yaml @@ -151,7 +151,7 @@ uc_assessable_capital: [20_000, 20_000] is_uc_eligible: [false, false] -- name: Mixed reported and unreported benunits use residual household capital +- name: Benefit units with and without financial observations use residual household capital period: 2025 absolute_error_margin: 0 input: @@ -163,7 +163,7 @@ benunits: benunit_1: members: person_1 - uc_reported_capital: 12_000 + benunit_reported_capital: 12_000 benunit_2: members: person_2 households: @@ -259,7 +259,8 @@ members: person other_residential_property_value: 50_000 output: - uc_assessable_capital: 50_000 + # Reg. 49: £50,000 market value less 10% sale expenses, with no debt. + uc_assessable_capital: 45_000 is_uc_eligible: false - name: Countable non-residential property can push capital over the upper threshold @@ -277,7 +278,8 @@ members: person non_residential_property_value: 50_000 output: - uc_assessable_capital: 50_000 + # Reg. 49: £50,000 market value less 10% sale expenses, with no debt. + uc_assessable_capital: 45_000 is_uc_eligible: false - name: Countable corporate wealth can push capital over the upper threshold diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/test_uc_capital_allocation.py b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/test_uc_capital_allocation.py index 30c50614f6..fe82f6aa46 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/test_uc_capital_allocation.py +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/test_uc_capital_allocation.py @@ -1,4 +1,4 @@ -"""The household capital proxy conserves residual capital within each household.""" +"""The financial proxy conserves residual capital after financial observations.""" import numpy as np import pytest @@ -59,7 +59,9 @@ def household_capital_situations(draw): unreported_indices.append(len(benunits)) benunits[unit_id] = { "members": members, - "uc_reported_capital": {2025: reported_capital if has_reported else -1}, + "benunit_reported_capital": { + 2025: reported_capital if has_reported else -1 + }, } household_members.extend(members) households[f"household_{household_index}"] = { diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_assessable_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_assessable_capital.yaml index 99e1fbfc52..618ee78696 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_assessable_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_assessable_capital.yaml @@ -88,7 +88,7 @@ # Previously [0, 36,000]; each now receives 36,000 * 1/2 = 18,000. uc_assessable_capital: [18_000, 18_000] -- name: Reported capital leaves a residual split between unreported claimants. +- name: Reported financial capital leaves a residual split between unreported claimants. period: 2025 input: people: @@ -112,7 +112,7 @@ benunits: reported: members: [reported_parent, qualifying_young_person] - uc_reported_capital: 8_000 + benunit_reported_capital: 8_000 young: members: [young_claimant] couple: @@ -122,7 +122,7 @@ members: [reported_parent, qualifying_young_person, young_claimant, older_claimant, partner] savings: 32_000 output: - household_uc_reported_capital: 8_000 + household_uc_reported_financial_capital: 8_000 household_uc_unreported_claimants: 3 # 32,000 - 8,000 = 24,000 residual. Shares are 24,000 / 3 = 8,000 # and 24,000 * 2/3 = 16,000. Previously [8,000, 0, 24,000]. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml new file mode 100644 index 0000000000..5d19e67b41 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml @@ -0,0 +1,65 @@ +- name: A financial zero does not exclude a countable second property + period: 2025 + input: + people: + claimant: + age: 30 + is_claimant_or_partner: true + benunits: + benunit: + members: [claimant] + would_claim_uc: true + benunit_reported_capital: 0 + households: + household: + members: [claimant] + other_residential_property_value: 50_000 + output: + uc_assessable_capital: 45_000 + is_uc_eligible: false + universal_credit: 0 + +- name: Financial observations and valued property enter separate capital pools + period: 2025 + input: + people: + claimant: + age: 30 + is_claimant_or_partner: true + benunits: + benunit: + members: [claimant] + would_claim_uc: true + benunit_reported_capital: 6_000 + households: + household: + members: [claimant] + other_residential_property_value: 3_000 + non_residential_property_value: 7_000 + output: + uc_assessable_capital: 15_000 + is_uc_eligible: true + uc_tariff_income: 1_879.2 + +- name: An explicit complete capital override retains zero semantics + period: 2025 + input: + people: + claimant: + age: 30 + is_claimant_or_partner: true + benunits: + benunit: + members: [claimant] + would_claim_uc: true + benunit_reported_capital: 6_000 + uc_reported_capital: 0 + households: + household: + members: [claimant] + savings: 50_000 + other_residential_property_value: 50_000 + output: + uc_assessable_capital: 0 + is_uc_eligible: true + uc_tariff_income: 0 diff --git a/policyengine_uk/tests/policy/baseline/household/wealth/benunit_reported_capital.yaml b/policyengine_uk/tests/policy/baseline/household/wealth/benunit_reported_capital.yaml index af64ec0b3d..819ebc010a 100644 --- a/policyengine_uk/tests/policy/baseline/household/wealth/benunit_reported_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/household/wealth/benunit_reported_capital.yaml @@ -1,12 +1,14 @@ # benunit_reported_capital records the benefit unit's savings and investments # (-1 when nothing is recorded). Universal Credit (WRA 2012 s.5; UC Regulations # 2013 reg.18) and Pension Credit (SPCA 2002 s.5) assess the claimant's and -# partner's capital together, so both programmes take the recorded figure in -# place of the household capital proxy unless their own variable is set. +# partner's capital together. UC replaces financial proxies with this +# observation and counts property separately; uc_reported_capital remains a +# separate complete override. Pension Credit continues to use the observation +# in place of its whole capital proxy unless its own variable is set. # Tariff income: UC Regulations 2013 reg.72, 4.35 pounds a month for each 250 # pounds or part above 6,000. -- name: Recorded capital replaces the household proxy for both programmes. +- name: Recorded financial capital replaces financial proxies for both programmes. period: 2025 input: people: @@ -21,7 +23,7 @@ members: [claimant] savings: 50_000 output: - uc_reported_capital: 2_000 + uc_reported_capital: -1 pension_credit_reported_capital: 2_000 uc_assessable_capital: 2_000 is_uc_eligible: true diff --git a/policyengine_uk/tests/test_benunit_reported_capital_properties.py b/policyengine_uk/tests/test_benunit_reported_capital_properties.py index 495e4da4bc..4f7cd59f70 100644 --- a/policyengine_uk/tests/test_benunit_reported_capital_properties.py +++ b/policyengine_uk/tests/test_benunit_reported_capital_properties.py @@ -1,32 +1,33 @@ """Properties of `benunit_reported_capital`, the benefit unit's recorded capital. -A dataset records what it observes (the benefit unit's savings and -investments, -1 when nothing is recorded), and each means test derives its -own reported capital from it: `uc_reported_capital` and -`pension_credit_reported_capital` default to it and can still be set directly. +A dataset records financial capital (the benefit unit's savings and +investments, -1 when nothing is recorded). Universal Credit replaces only +financial proxies with that observation; `uc_reported_capital` is a separate +complete override and defaults to -1. Pension Credit continues to derive +`pension_credit_reported_capital` from the observation. Invariants, over generated households of one or two benefit units: -1. Derivation: with only the recorded input set, both programmes' reported - capital equals it, -1 included. -2. Differential: recording R once gives the same assessable capital, eligibility - and entitlement for Universal Credit and Pension Credit as setting R on both - programme variables directly, which is how they were supplied before the - input existed. With nothing recorded this is the old default (-1), so the - input changes nothing for data that do not supply it. +1. Derivation: Pension Credit's reported capital equals the financial input, + -1 included. UC's complete override remains -1 without an explicit value. +2. Differential: without property or person-level capital, a financial + observation gives the same assessment as explicitly setting that unit's + complete override too. Retaining the financial observations in both cases + preserves the financial residual available to other units. 3. Precedence: a programme variable set directly wins for that programme only. -4. Override identity and bounds: a unit with recorded R >= 0 has Universal - Credit assessable capital R, whatever the household holds, and assessable - capital is never negative. -5. Monotonicity: Universal Credit tariff income never falls, and eligibility and - the award never rise, as recorded capital rises. +4. Financial identity and bounds: without property or person-level capital, + a unit with recorded R >= 0 has UC assessable capital R. Assessable capital + is never negative. +5. Monotonicity: assessable capital never falls, and eligibility and the award + never rise, as recorded capital rises. Tariff income never falls while the + unit remains eligible; it is zero once the capital limit excludes the unit. 6. Uprating: a dataset that stores the input, or either programme variable directly as datasets built before the input did, projects it to later years exactly as it projects `savings`. 7. Periods: the input set for one year is uprated into later years. A programme variable set directly applies only to the years it is set, as for - any formula variable; later years derive from the input (intended: the - input carries the uprating). + any formula variable. Later years use the financial input, when present, + while the complete UC override returns to -1. """ from pathlib import Path @@ -135,7 +136,7 @@ def outputs(sim): @SETTINGS @given(households()) -def test_recorded_capital_is_equivalent_to_setting_each_programme(case): +def test_financial_capital_matches_complete_override_without_nonfinancial_assets(case): units, household = case via_input = simulate( units, @@ -145,15 +146,23 @@ def test_recorded_capital_is_equivalent_to_setting_each_programme(case): via_programmes = simulate( units, household, - [{v: u["recorded"] for v in PROGRAMME_VARIABLES} for u in units], + [ + { + "benunit_reported_capital": u["recorded"], + **{v: u["recorded"] for v in PROGRAMME_VARIABLES}, + } + for u in units + ], ) a, b = outputs(via_input), outputs(via_programmes) for i, unit in enumerate(units): - for v in PROGRAMME_VARIABLES: - assert a[v][i] == unit["recorded"] + assert a["uc_reported_capital"][i] == -1 + assert a["pension_credit_reported_capital"][i] == unit["recorded"] if unit["recorded"] >= 0: assert a["uc_assessable_capital"][i] == unit["recorded"] for v in OUTPUTS: + if v == "uc_reported_capital": + continue np.testing.assert_allclose(a[v], b[v], rtol=0, atol=1e-6, err_msg=v) for v in ["uc_assessable_capital", "pension_credit_assessable_capital"]: assert np.all(a[v] >= 0) @@ -190,7 +199,9 @@ def run(value): return outputs(simulate(units, household, inputs)) lo, hi = run(low), run(high) - assert hi["uc_tariff_income"][0] >= lo["uc_tariff_income"][0] + assert hi["uc_assessable_capital"][0] >= lo["uc_assessable_capital"][0] + if hi["is_uc_eligible"][0]: + assert hi["uc_tariff_income"][0] >= lo["uc_tariff_income"][0] assert hi["is_uc_eligible"][0] <= lo["is_uc_eligible"][0] assert hi["universal_credit"][0] <= lo["universal_credit"][0] + 1e-6 @@ -249,7 +260,7 @@ def _dataset(benunit_columns, savings): ) -def test_stored_columns_project_like_savings(): +def test_stored_financial_observations_and_explicit_overrides_project_like_savings(): recorded = [10_000.0, -1.0, 0.0] savings = [10_000.0, 20_000.0, 30_000.0] for columns in [ @@ -263,13 +274,33 @@ def test_stored_columns_project_like_savings(): / sim.calculate("savings", 2025).values ) assert np.all(growth > 1) - for name in PROGRAMME_VARIABLES: + projected_names = ["pension_credit_reported_capital"] + if "uc_reported_capital" in columns: + projected_names.append("uc_reported_capital") + else: + for year in [2025, 2027]: + np.testing.assert_array_equal( + sim.calculate("uc_reported_capital", year).values, + [-1, -1, -1], + ) + for name in projected_names: first = sim.calculate(name, 2025).values later = sim.calculate(name, 2027).values np.testing.assert_array_equal(first, recorded) np.testing.assert_allclose(later, np.array(recorded) * growth, rtol=1e-6) # A stored -1 stays negative, so "nothing recorded" survives. assert later[1] < 0 + expected_assessable = np.array([recorded[0], savings[1], recorded[2]]) + np.testing.assert_allclose( + sim.calculate("uc_assessable_capital", 2025).values, + expected_assessable, + rtol=1e-6, + ) + np.testing.assert_allclose( + sim.calculate("uc_assessable_capital", 2027).values, + expected_assessable * growth, + rtol=1e-6, + ) def _single_adult(benunit_inputs): @@ -288,8 +319,10 @@ def test_input_set_once_is_uprated_into_later_years(): "gov.economic_assumptions.indices.obr.per_capita.gdp" ) expected = 10_000 * index("2026-01-01") / index("2025-01-01") - for name in PROGRAMME_VARIABLES: - np.testing.assert_allclose(sim.calculate(name, 2026)[0], expected, rtol=1e-6) + assert sim.calculate("uc_reported_capital", 2026)[0] == -1 + np.testing.assert_allclose( + sim.calculate("pension_credit_reported_capital", 2026)[0], expected, rtol=1e-6 + ) np.testing.assert_allclose( sim.calculate("uc_assessable_capital", 2026)[0], expected, rtol=1e-6 ) diff --git a/policyengine_uk/tests/test_stock_capital_periods.py b/policyengine_uk/tests/test_stock_capital_periods.py index d36c3068b2..3ad2e0226f 100644 --- a/policyengine_uk/tests/test_stock_capital_periods.py +++ b/policyengine_uk/tests/test_stock_capital_periods.py @@ -23,7 +23,7 @@ def test_uc_capital_stocks_are_not_prorated_in_monthly_calculations(): assert simulation.calculate("savings", "2026-01")[0] == 12_000 assert simulation.calculate("corporate_wealth", "2026-01")[0] == 12_000 - assert simulation.calculate("uc_assessable_capital", "2026-01")[0] == 36_000 + assert simulation.calculate("uc_assessable_capital", "2026-01")[0] == 34_800 assert not simulation.calculate("is_uc_eligible", "2026-01")[0] diff --git a/policyengine_uk/tests/test_uc_property_capital.py b/policyengine_uk/tests/test_uc_property_capital.py new file mode 100644 index 0000000000..49159fd525 --- /dev/null +++ b/policyengine_uk/tests/test_uc_property_capital.py @@ -0,0 +1,406 @@ +"""UC financial observations, complete overrides, and countable property. + +Regulations 18, 49 and 72 provide the capital limit, property valuation and +tariff. Equal shares per claimant/partner adult are a modelling convention +for household assets whose beneficial ownership is unobserved. These cases +assume the properties incur sale expenses and have no Schedule 10 disregard. +""" + +from copy import deepcopy + +import numpy as np +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2025 +PROPERTIES = ( + "other_residential_property_value", + "non_residential_property_value", +) +DEBTS = ( + "other_residential_property_secured_debt", + "non_residential_property_secured_debt", +) +PROPERTY_SETTINGS = settings( + max_examples=12, + deadline=None, + derandomize=True, + database=None, +) + + +def _annual(value): + return value if isinstance(value, dict) else {YEAR: value} + + +def _situation(units=None, household=None): + """Build one household, retaining stable person and benefit-unit ids.""" + units = [{"financial": 0}] if units is None else units + people, benunits, members = {}, {}, [] + for i, unit in enumerate(units): + names = [] + for j in range(unit.get("adults", 1)): + name = f"p{i}_{j}" + people[name] = { + "age": {YEAR: 30}, + "is_claimant_or_partner": {YEAR: True}, + } + names.append(name) + for j in range(unit.get("children", 0)): + name = f"c{i}_{j}" + people[name] = { + "age": {YEAR: 10}, + "is_claimant_or_partner": {YEAR: False}, + } + names.append(name) + benunit = { + "members": names, + "would_claim_uc": {YEAR: unit.get("claims_uc", True)}, + } + if "financial" in unit: + benunit["benunit_reported_capital"] = _annual(unit["financial"]) + if "override" in unit: + benunit["uc_reported_capital"] = _annual(unit["override"]) + benunits[f"b{i}"] = benunit + members.extend(names) + return { + "people": people, + "benunits": benunits, + "households": { + "h": { + "members": members, + **{name: _annual(value) for name, value in (household or {}).items()}, + } + }, + } + + +def _values(simulation, variable="uc_assessable_capital", year=YEAR): + return np.asarray(simulation.calculate(variable, year)) + + +@pytest.mark.parametrize("property_variable", PROPERTIES) +def test_reported_financial_zero_keeps_fifty_thousand_pound_property(property_variable): + simulation = Simulation( + situation=_situation(household={property_variable: 50_000, "savings": 0}) + ) + assert _values(simulation)[0] == 45_000 + assert not _values(simulation, "is_uc_eligible")[0] + assert _values(simulation, "universal_credit")[0] == 0 + + +@pytest.mark.parametrize( + "household", + [ + {"other_residential_property_value": 10_000}, + {"non_residential_property_value": 10_000}, + { + "other_residential_property_value": 3_000, + "non_residential_property_value": 7_000, + }, + ], +) +def test_financial_observation_and_property_are_both_counted(household): + simulation = Simulation(situation=_situation([{"financial": 6_000}], household)) + assert _values(simulation)[0] == 15_000 + assert _values(simulation, "is_uc_eligible")[0] + assert _values(simulation, "uc_tariff_income")[0] == pytest.approx(36 * 4.35 * 12) + assert _values(simulation, "universal_credit")[0] == pytest.approx( + _values(simulation, "uc_standard_allowance")[0] - 36 * 4.35 * 12 + ) + + +@pytest.mark.parametrize("override", [0, 2_000, 20_000]) +def test_explicit_complete_override_replaces_financial_and_property_capital(override): + situation = _situation( + [{"financial": 6_000, "override": override}], + {"savings": 50_000, "other_residential_property_value": 50_000}, + ) + situation["people"]["p0_0"]["lifetime_isa_balance"] = {YEAR: 30_000} + simulation = Simulation(situation=situation) + assert _values(simulation)[0] == override + if override == 0: + assert _values(simulation, "universal_credit")[0] == pytest.approx( + _values(simulation, "uc_standard_allowance")[0] + ) + + +def test_financial_observation_does_not_double_count_savings_investments_or_lisa(): + situation = _situation( + [{"financial": 6_000}], {"savings": 50_000, "corporate_wealth": 30_000} + ) + situation["people"]["p0_0"]["lifetime_isa_balance"] = {YEAR: 30_000} + simulation = Simulation(situation=situation) + assert _values(simulation)[0] == 6_000 + assert _values(simulation, "uc_tariff_income")[0] == 0 + + +def test_property_is_allocated_across_all_claimant_and_partner_adults(): + simulation = Simulation( + situation=_situation( + [ + {"adults": 2, "financial": 0, "children": 2}, + {"financial": 0, "claims_uc": False}, + ], + {"other_residential_property_value": 12_000}, + ) + ) + np.testing.assert_allclose(_values(simulation), [7_200, 3_600]) + assert _values(simulation).sum() == pytest.approx(10_800) + + +@pytest.mark.parametrize("complete_override", [0, 50_000]) +def test_mixed_household_keeps_financial_and_property_pools_separate(complete_override): + simulation = Simulation( + situation=_situation( + [ + {"adults": 2, "override": complete_override}, + {"financial": 6_000}, + {"financial": -1}, + ], + {"savings": 12_000, "other_residential_property_value": 40_000}, + ) + ) + # Complete override consumes its £18k conventional property share. Its + # unknown financial/property composition never reduces the financial pool. + np.testing.assert_allclose(_values(simulation), [complete_override, 15_000, 15_000]) + assert _values(simulation)[1:].sum() - 6_000 - 6_000 == pytest.approx(18_000) + + +@pytest.mark.parametrize("other_financial", [-1, 0, 50_000]) +def test_unrelated_financial_reports_do_not_change_property_ownership(other_financial): + simulation = Simulation( + situation=_situation( + [{"financial": 0}, {"financial": other_financial}], + {"other_residential_property_value": 20_000}, + ) + ) + assert _values(simulation)[0] == 9_000 + assert _values(simulation)[1] == pytest.approx(9_000 + max(0, other_financial)) + + +def test_reporting_a_conventional_financial_share_leaves_other_units_unchanged(): + household = {"savings": 12_000, "other_residential_property_value": 12_000} + missing = Simulation(situation=_situation([{}, {}], household)) + observed = Simulation(situation=_situation([{"financial": 6_000}, {}], household)) + np.testing.assert_allclose(_values(missing), [11_400, 11_400]) + np.testing.assert_allclose(_values(observed), _values(missing)) + + +@pytest.mark.parametrize("financial", [None, -1]) +def test_missing_financial_and_spi_style_inputs_retain_the_financial_proxy(financial): + # SPI synthetic units have no FRS financial observation. A missing column + # and the explicit -1 sentinel must retain the same proxy calculation. + unit = {} if financial is None else {"financial": financial} + situation = _situation( + [unit], + { + "savings": 4_000, + "corporate_wealth": 2_000, + "other_residential_property_value": 10_000, + }, + ) + situation["people"]["p0_0"]["lifetime_isa_balance"] = {YEAR: 4_000} + assert _values(Simulation(situation=situation))[0] == 18_000 + + +@pytest.mark.parametrize("financial", [-1, 0, 6_000]) +def test_occupied_main_home_is_excluded(financial): + simulation = Simulation( + situation=_situation( + [{"financial": financial}], + {"main_residence_value": 500_000, "savings": 0}, + ) + ) + assert _values(simulation)[0] == max(0, financial) + + +@pytest.mark.parametrize( + "property_variable,debt_variable", list(zip(PROPERTIES, DEBTS)) +) +@pytest.mark.parametrize("debt,expected", [(0, 9_000), (2_000, 7_000), (20_000, 0)]) +def test_property_sale_expenses_precede_secured_debt_and_zero_floor( + property_variable, debt_variable, debt, expected +): + simulation = Simulation( + situation=_situation(household={property_variable: 10_000, debt_variable: debt}) + ) + assert _values(simulation)[0] == expected + + +def test_secured_debt_on_one_property_component_does_not_reduce_the_other(): + simulation = Simulation( + situation=_situation( + household={ + "other_residential_property_value": 10_000, + "other_residential_property_secured_debt": 20_000, + "non_residential_property_value": 10_000, + } + ) + ) + assert _values(simulation)[0] == 9_000 + + +def test_property_is_valued_in_the_assessment_year(): + simulation = Simulation( + situation=_situation( + [{"financial": {2025: 0, 2026: 0}}], + { + "other_residential_property_value": {2025: 10_000, 2026: 20_000}, + "other_residential_property_secured_debt": {2025: 1_000, 2026: 2_000}, + }, + ) + ) + assert _values(simulation, year=2025)[0] == 8_000 + assert _values(simulation, year=2026)[0] == 16_000 + assert _values(simulation, year="2026-01")[0] == 16_000 + + +@pytest.mark.parametrize( + "capital,tariff_steps,eligible", + [ + (6_000, 0, True), + (6_001, 1, True), + (6_250, 1, True), + (6_251, 2, True), + (16_000, 40, True), + (16_001, 0, False), + ], +) +@pytest.mark.parametrize( + "property_value", [0, 5_000], ids=["cash-only", "with-property"] +) +def test_tariff_boundaries_for_cash_and_valued_property( + capital, tariff_steps, eligible, property_value +): + # Only the property incurs sale expenses: do not reduce cash by 10%. + financial = capital - property_value * 0.9 + simulation = Simulation( + situation=_situation( + [{"financial": financial}], + {"other_residential_property_value": property_value}, + ) + ) + assert _values(simulation)[0] == capital + assert bool(_values(simulation, "is_uc_eligible")[0]) is eligible + assert _values(simulation, "uc_tariff_income")[0] == pytest.approx( + tariff_steps * 4.35 * 12 + ) + + +@PROPERTY_SETTINGS +@given( + financial=st.sampled_from([-1, 0, 6_000]), + source=st.sampled_from( + [ + "financial", + "savings", + "corporate_wealth", + "lifetime_isa_balance", + *PROPERTIES, + ] + ), + amount=st.integers(0, 50_000), + increment=st.integers(0, 50_000), +) +def test_assessable_capital_never_falls_when_a_countable_asset_increases( + financial, source, amount, increment +): + baseline = _situation( + [{"financial": financial}, {"financial": -1}], + {"savings": 10_000, "other_residential_property_value": 10_000}, + ) + if source == "financial": + target = baseline["benunits"]["b0"] + input_variable = "benunit_reported_capital" + elif source == "lifetime_isa_balance": + target = baseline["people"]["p0_0"] + input_variable = source + else: + target = baseline["households"]["h"] + input_variable = source + target[input_variable] = {YEAR: amount} + higher = deepcopy(baseline) + if source == "financial": + higher_target = higher["benunits"]["b0"] + elif source == "lifetime_isa_balance": + higher_target = higher["people"]["p0_0"] + else: + higher_target = higher["households"]["h"] + higher_target[input_variable] = {YEAR: amount + increment} + low_capital = _values(Simulation(situation=baseline)) + high_capital = _values(Simulation(situation=higher)) + # An observed financial increase can reduce another unit's residual + # financial proxy; monotonicity applies to the owner of that observation. + if source == "financial": + assert high_capital[0] >= low_capital[0] + else: + assert np.all(high_capital >= low_capital - 0.01) + + +@PROPERTY_SETTINGS +@given( + adult_counts=st.lists(st.integers(1, 2), min_size=1, max_size=4), + residential=st.integers(0, 100_000), + non_residential=st.integers(0, 100_000), + override_first=st.booleans(), +) +def test_property_allocation_conserves_value_including_complete_override_shares( + adult_counts, residential, non_residential, override_first +): + units = [{"adults": adults, "financial": 0} for adults in adult_counts] + if override_first: + units[0]["override"] = 0 + simulation = Simulation( + situation=_situation( + units, + { + "other_residential_property_value": residential, + "non_residential_property_value": non_residential, + }, + ) + ) + total_property = (residential + non_residential) * 0.9 + expected = np.asarray(adult_counts) / sum(adult_counts) * total_property + consumed = expected[0] if override_first else 0 + if override_first: + expected[0] = 0 + assessed = _values(simulation) + np.testing.assert_allclose(assessed, expected, atol=0.02) + assert assessed.sum() + consumed == pytest.approx(total_property, abs=0.03) + + +@PROPERTY_SETTINGS +@given( + reports=st.lists(st.sampled_from([-1, 0, 6_000]), min_size=3, max_size=3), + property_value=st.integers(0, 100_000), + order=st.permutations([0, 1, 2]), +) +def test_capital_is_independent_of_person_and_benefit_unit_row_order( + reports, property_value, order +): + situation = _situation( + [ + {"adults": 2, "financial": reports[0]}, + {"financial": reports[1]}, + {"financial": reports[2], "override": 0}, + ], + {"savings": 12_000, "other_residential_property_value": property_value}, + ) + permuted = deepcopy(situation) + permuted["benunits"] = {f"b{i}": permuted["benunits"][f"b{i}"] for i in order} + for unit in permuted["benunits"].values(): + unit["members"] = list(reversed(unit["members"])) + permuted["people"] = dict(reversed(list(permuted["people"].items()))) + permuted["households"]["h"]["members"].reverse() + baseline = dict( + zip(situation["benunits"], _values(Simulation(situation=situation))) + ) + reordered = dict(zip(permuted["benunits"], _values(Simulation(situation=permuted)))) + for unit in baseline: + assert reordered[unit] == pytest.approx(baseline[unit]) diff --git a/policyengine_uk/tests/test_uc_property_capital_mock.py b/policyengine_uk/tests/test_uc_property_capital_mock.py new file mode 100644 index 0000000000..f9bb8691f1 --- /dev/null +++ b/policyengine_uk/tests/test_uc_property_capital_mock.py @@ -0,0 +1,327 @@ +"""UC financial and property capital through a local MOCK dataset. + +These are synthetic post-ingestion tables, never survey records. The upstream +uk-data blocker exercises FRS B4/B3 selection and then the real engine. Here +we pin its engine-facing boundary: ``benunit_reported_capital`` is the selected +financial total (including zero or the missing sentinel), while the property +columns arrive separately after imputation. No uk-data dependency or network +access is needed to exercise their valuation and allocation in the engine. +""" + +import numpy as np +import pandas as pd +import pytest + +from policyengine_uk import Simulation +from policyengine_uk.data import UKMultiYearDataset, UKSingleYearDataset + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2025 + + +def _mock_year( + reported=(0,), + *, + adults=None, + household_inputs=None, + person_inputs=None, + overrides=None, + year=YEAR, + age=30, +): + """One MOCK household, with an explicit claimant/partner ownership set.""" + adults = adults or [1] * len(reported) + benunit_ids = np.arange(1, len(reported) + 1) + person_benunit_ids = np.repeat(benunit_ids, adults) + person_count = len(person_benunit_ids) + person = pd.DataFrame( + { + "person_id": np.arange(1, person_count + 1), + "person_benunit_id": person_benunit_ids, + "person_household_id": np.ones(person_count, dtype=int), + "age": age, + "is_claimant_or_partner": True, + "employment_income": 0.0, + **(person_inputs or {}), + } + ) + benunit = pd.DataFrame( + { + "benunit_id": benunit_ids, + "benunit_reported_capital": np.array(reported, dtype=float), + "would_claim_uc": True, + } + ) + if overrides is not None: + benunit["uc_reported_capital"] = overrides + household = pd.DataFrame( + { + "household_id": [1], + "household_weight": [1.0], + "region": ["LONDON"], + "tenure_type": ["OWNED_OUTRIGHT"], + "rent": [0.0], + "council_tax": [0.0], + "savings": [0.0], + "corporate_wealth": [0.0], + "other_residential_property_value": [0.0], + "non_residential_property_value": [0.0], + **{name: [value] for name, value in (household_inputs or {}).items()}, + } + ) + return UKSingleYearDataset( + person=person, benunit=benunit, household=household, fiscal_year=year + ) + + +def _load_mock(tmp_path, *years): + """Round-trip the actual H5 schema before calculating the real formulas.""" + path = tmp_path / "MOCK_uc_property_capital.h5" + UKMultiYearDataset(datasets=list(years)).save(str(path)) + return Simulation(dataset=path) + + +@pytest.mark.parametrize( + "property_variable", + ["other_residential_property_value", "non_residential_property_value"], +) +def test_mock_zero_financial_total_preserves_countable_property( + tmp_path, property_variable +): + """The data PR's age-30 B4=0 / £50,000 property reproducer.""" + simulation = _load_mock( + tmp_path, + _mock_year(household_inputs={property_variable: 50_000}), + ) + + assert simulation.calculate("benunit_reported_capital", YEAR)[0] == 0 + assert simulation.calculate("uc_assessable_capital", YEAR)[0] == 45_000 + assert not simulation.calculate("is_uc_eligible", YEAR)[0] + assert simulation.calculate("universal_credit", YEAR)[0] == 0 + + +@pytest.mark.parametrize( + "reported, household_inputs, expected_capital, tariff_steps", + [ + (6_000, {"other_residential_property_value": 10_000}, 15_000, 36), + ( + 6_000, + { + "other_residential_property_value": 3_000, + "non_residential_property_value": 7_000, + }, + 15_000, + 36, + ), + (0, {}, 0, 0), + (6_000, {"savings": 50_000, "corporate_wealth": 30_000}, 6_000, 0), + ( + -1, + {"savings": 6_000, "other_residential_property_value": 10_000}, + 15_000, + 36, + ), + ], + ids=[ + "financial-plus-property", + "both-property-components", + "zero-financial-no-property", + "financial-proxies-not-counted-twice", + "missing-financial-keeps-proxy-and-property", + ], +) +def test_mock_financial_and_property_pools( + tmp_path, reported, household_inputs, expected_capital, tariff_steps +): + simulation = _load_mock( + tmp_path, + _mock_year((reported,), household_inputs=household_inputs), + ) + + assert simulation.calculate("uc_assessable_capital", YEAR)[0] == expected_capital + assert simulation.calculate("is_uc_eligible", YEAR)[0] + annual_tariff = tariff_steps * 4.35 * 12 + assert simulation.calculate("uc_tariff_income", YEAR)[0] == pytest.approx( + annual_tariff + ) + allowance = simulation.calculate("uc_standard_allowance", YEAR)[0] + assert allowance > 0 + assert simulation.calculate("universal_credit", YEAR)[0] == pytest.approx( + allowance - annual_tariff + ) + + +def test_mock_complete_override_zero_consumes_its_whole_capital_share(tmp_path): + simulation = _load_mock( + tmp_path, + _mock_year( + (6_000,), + overrides=[0.0], + household_inputs={ + "savings": 50_000, + "other_residential_property_value": 50_000, + }, + person_inputs={"lifetime_isa_balance": 30_000.0}, + ), + ) + + assert simulation.calculate("uc_reported_capital", YEAR)[0] == 0 + assert simulation.calculate("uc_assessable_capital", YEAR)[0] == 0 + assert simulation.calculate("is_uc_eligible", YEAR)[0] + assert simulation.calculate("universal_credit", YEAR)[0] == pytest.approx( + simulation.calculate("uc_standard_allowance", YEAR)[0] + ) + + +def test_mock_financial_total_already_covers_lifetime_isa(tmp_path): + simulation = _load_mock( + tmp_path, + _mock_year( + (6_000,), + household_inputs={"household_lifetime_isa_balance": 30_000}, + person_inputs={"lifetime_isa_balance": 30_000.0}, + ), + ) + + assert simulation.calculate("uc_assessable_capital", YEAR)[0] == 6_000 + assert simulation.calculate("uc_tariff_income", YEAR)[0] == 0 + + +def test_mock_main_home_is_excluded_and_cash_has_no_sale_deduction(tmp_path): + simulation = _load_mock( + tmp_path, + _mock_year( + (-1,), + household_inputs={"main_residence_value": 500_000, "savings": 6_001}, + ), + ) + + assert simulation.calculate("uc_assessable_capital", YEAR)[0] == 6_001 + assert simulation.calculate("uc_tariff_income", YEAR)[0] == pytest.approx(4.35 * 12) + + +def test_mock_property_allocation_pools_claimant_and_partner_shares(tmp_path): + simulation = _load_mock( + tmp_path, + _mock_year( + (0, 0), + adults=[2, 1], + household_inputs={"other_residential_property_value": 12_000}, + ), + ) + + assessed = simulation.calculate("uc_assessable_capital", YEAR) + assert assessed.tolist() == pytest.approx([7_200, 3_600]) + assert assessed.sum() == pytest.approx(12_000 * 0.9) + + +@pytest.mark.parametrize( + "property_variable, debt_variable", + [ + ( + "other_residential_property_value", + "other_residential_property_secured_debt", + ), + ( + "non_residential_property_value", + "non_residential_property_secured_debt", + ), + ], +) +@pytest.mark.parametrize("secured_debt, expected", [(2_000, 7_000), (9_500, 0)]) +def test_mock_property_deducts_sale_expenses_before_secured_debt( + tmp_path, property_variable, debt_variable, secured_debt, expected +): + simulation = _load_mock( + tmp_path, + _mock_year( + household_inputs={ + property_variable: 10_000, + debt_variable: secured_debt, + }, + ), + ) + + assert simulation.calculate("uc_assessable_capital", YEAR)[0] == expected + + +def test_mock_property_type_debt_does_not_reduce_another_property_pool(tmp_path): + simulation = _load_mock( + tmp_path, + _mock_year( + household_inputs={ + "other_residential_property_value": 10_000, + "other_residential_property_secured_debt": 20_000, + "non_residential_property_value": 10_000, + }, + ), + ) + + assert simulation.calculate("uc_assessable_capital", YEAR)[0] == 9_000 + + +@pytest.mark.parametrize("other_financial", [-1, 0, 6_000]) +def test_mock_mixed_household_keeps_override_and_property_pools_separate( + tmp_path, other_financial +): + """An override's property share is consumed, never redistributed.""" + simulation = _load_mock( + tmp_path, + _mock_year( + (-1, other_financial, -1), + adults=[1, 2, 1], + overrides=[100_000.0, -1.0, -1.0], + household_inputs={"other_residential_property_value": 12_000}, + ), + ) + + assessed = simulation.calculate("uc_assessable_capital", YEAR) + financial = max(0, other_financial) + assert assessed.tolist() == pytest.approx([100_000, financial + 5_400, 2_700]) + # Add the override unit's consumed conventional share back only for this + # conservation assertion. Its unknown financial/property split is not an + # observed component of either allocation pool. + assert assessed[1:].sum() - financial + 2_700 == pytest.approx(12_000 * 0.9) + + +def test_mock_property_valuation_uses_the_assessment_year(tmp_path): + simulation = _load_mock( + tmp_path, + _mock_year( + (6_000,), household_inputs={"other_residential_property_value": 10_000} + ), + _mock_year( + (6_000,), + household_inputs={"other_residential_property_value": 20_000}, + year=2026, + ), + ) + + assert simulation.calculate("uc_assessable_capital", YEAR)[0] == 15_000 + assert simulation.calculate("uc_assessable_capital", 2026)[0] == 24_000 + + +@pytest.mark.parametrize("reported, expected", [(6_000, 6_000), (0, 0), (-1, 56_000)]) +def test_mock_pension_credit_keeps_its_existing_reported_capital_contract( + tmp_path, reported, expected +): + """Changing UC's financial boundary does not change Pension Credit.""" + simulation = _load_mock( + tmp_path, + _mock_year( + (reported,), + overrides=[0.0], + age=75, + household_inputs={ + "savings": 6_000, + "other_residential_property_value": 50_000, + }, + ), + ) + + assert simulation.calculate("uc_assessable_capital", YEAR)[0] == 0 + assert simulation.calculate("pension_credit_reported_capital", YEAR)[0] == reported + assert ( + simulation.calculate("pension_credit_assessable_capital", YEAR)[0] == expected + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py index 91862290a9..3b50997c3b 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py @@ -28,7 +28,9 @@ class household_uc_pool_company_holdings(Variable): def formula(household, period, parameters): person = household.members - reported = person.benunit("uc_reported_capital", period) >= 0 + reported = (person.benunit("uc_reported_capital", period) >= 0) | ( + person.benunit("benunit_reported_capital", period) >= 0 + ) claimant_or_partner = person("is_uc_claimant", period) off_reported_capital = reported & claimant_or_partner holdings = household.sum( diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py new file mode 100644 index 0000000000..b9a2c7f26a --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py @@ -0,0 +1,24 @@ +from policyengine_uk.model_api import * + + +class household_uc_reported_financial_capital(Variable): + value_type = float + entity = Household + label = "Household financial capital observed for benefit units" + documentation = ( + "Sum of nonnegative benunit_reported_capital observations, counted " + "once per benefit unit. Only these financial observations reduce the " + "household financial proxy. A complete uc_reported_capital override " + "has an unknown financial/property decomposition and is never " + "subtracted from that financial pool." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + + def formula(household, period, parameters): + person = household.members + financial_capital = person.benunit("benunit_reported_capital", period) + return household.sum( + max_(0, financial_capital) * person("is_benunit_head", period) + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_unreported_claimants.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_unreported_claimants.py index 66786f1877..50940158a9 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_unreported_claimants.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_unreported_claimants.py @@ -4,10 +4,13 @@ class household_uc_unreported_claimants(Variable): value_type = int entity = Household - label = "Number of claimants and partners in benunits without reported UC capital" + label = "Claimants and partners without financial or complete UC capital reports" documentation = ( "Claimants and partners counted in PolicyEngine's allocation of residual " - "household capital for Universal Credit. This includes claimant and " + "household financial capital for Universal Credit. Units with a " + "financial observation or a complete UC override do not use this " + "financial proxy. Property uses all claimant/partner adults instead. " + "This includes claimant and " "partner roles in benefit units that do not receive Universal Credit. " "Allocating unidentified household capital by these counts is a " "modelling convention; legislation defines whose capital is assessed, " @@ -23,6 +26,7 @@ class household_uc_unreported_claimants(Variable): def formula(household, period, parameters): person = household.members reported_capital = person.benunit("uc_reported_capital", period) - has_reported_capital = reported_capital >= 0 + financial_capital = person.benunit("benunit_reported_capital", period) + has_reported_capital = (reported_capital >= 0) | (financial_capital >= 0) is_uc_claimant = person("is_uc_claimant", period) return household.sum(is_uc_claimant * ~has_reported_capital) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py index b3b6c78158..52be73de67 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py @@ -6,22 +6,21 @@ class uc_assessable_capital(Variable): entity = BenUnit label = "Universal Credit assessable capital" documentation = ( - "Universal Credit capital counted from the configured capital sources. " - "Where the benefit unit's capital is recorded (uc_reported_capital, by " - "default benunit_reported_capital), that figure replaces them. " - "PolicyEngine allocates " - "the remaining household capital between unreported benefit units in " - "proportion to their claimant and partner counts, including units that " - "do not receive Universal Credit. This allocation is a modelling " - "convention when capital ownership is unobserved, not a statutory rule. " - "The claimant and partner owner set follows section 5 of the Welfare " - "Reform Act 2012 and regulation 18 of the Universal Credit Regulations. " - "Person-level sources, such as a Lifetime ISA, count only for the " - "holder's own benunit, and only when the holder is its claimant or " - "partner (is_uc_claimant): a dependant's capital is not the claimant's. " - "A claimant or partner who stands as sole owner or partner of a " - "company has their holding in it disregarded and the company's capital " - "counted instead (regulation 77(2))." + "Countable capital of the claimant and partner. An explicit nonnegative " + "uc_reported_capital is a complete override, including zero. Otherwise " + "a nonnegative benunit_reported_capital replaces financial household " + "and person-level proxies only; it does not replace countable property. " + "Observed financial totals reduce the household financial pool before " + "its residual is allocated by claimant/partner counts to units with " + "neither report. A complete override is never subtracted from that " + "financial pool because its asset decomposition is unknown. Property " + "is valued separately in the assessment year and allocated across ALL " + "claimant/partner adults, including non-UC units and complete-override " + "units, whose shares are consumed rather than redistributed. These " + "allocations are assumptions about unobserved ownership, not law. " + "Person-level proxies count only claimants/partners, not dependants. " + "Under regulation 77, disregarded company holdings are replaced by " + "the claimant/partner's share of company capital." ) definition_period = YEAR unit = GBP @@ -29,55 +28,53 @@ class uc_assessable_capital(Variable): reference = ( "https://www.legislation.gov.uk/ukpga/2012/5/section/5", "https://www.legislation.gov.uk/uksi/2013/376/regulation/18", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/49", "https://www.legislation.gov.uk/uksi/2013/376/regulation/77", ) def formula(benunit, period, parameters): household = benunit.household - p = parameters(period).gov.dwp.universal_credit.means_test - household_capital = sum( - household(source, period) for source in p.capital.sources + p = parameters(period).gov.dwp.universal_credit.means_test.capital + household_financial_capital = sum( + household(source, period) for source in p.sources ) - benunit_claimants = add(benunit, period, ["is_uc_claimant"]) claimant_or_partner = benunit.members("is_uc_claimant", period) + benunit_claimants = benunit.sum(claimant_or_partner) person_capital = sum( benunit.sum(benunit.members(source, period) * claimant_or_partner) - for source in p.capital.person_sources + for source in p.person_sources ) - household_reported_capital = household("household_uc_reported_capital", period) - household_unreported_claimants = household( - "household_uc_unreported_claimants", period - ) - claimant_divisor = max_(1, household_unreported_claimants) - reported_capital = benunit("uc_reported_capital", period) - use_reported_capital = reported_capital >= 0 - # A person who stands as sole owner or partner of a company has their - # holding in it disregarded and is treated as possessing the company's - # capital (or their share of it) instead (UC Regs 2013 reg. 77(2), - # (3)(a)). A reporting benunit's claimant and partner's holdings come - # off its reported capital, which has no breakdown to cap them - # against. Every other holding (those of people in unreported - # benunits, and of dependants in reporting ones) leaves the household - # pool before it is shared out, up to the household's corporate_wealth - # that holds them. Neither subtraction can take capital below zero. - # Only the claimant and partner's company capital is theirs to count. - pool_holdings = household("household_uc_pool_company_holdings", period) - residual_household_capital = max_( - 0, household_capital - household_reported_capital - pool_holdings + financial_report = benunit("benunit_reported_capital", period) + complete_override = benunit("uc_reported_capital", period) + unreported_claimants = household("household_uc_unreported_claimants", period) + # Complete overrides may include property, so only observed FINANCIAL + # totals can be subtracted from this financial-only pool. + residual_financial_capital = max_( + 0, + household_financial_capital + - household("household_uc_reported_financial_capital", period) + - household("household_uc_pool_company_holdings", period), ) - household_capital_proxy = where( - household_unreported_claimants > 0, - residual_household_capital * benunit_claimants / claimant_divisor, + financial_proxy = where( + unreported_claimants > 0, + residual_financial_capital + * benunit_claimants + / max_(1, unreported_claimants), 0, ) own_holdings = benunit.sum( benunit.members("uc_company_holding_disregard", period) * claimant_or_partner ) + financial_capital = where( + financial_report >= 0, + max_(0, financial_report - own_holdings), + financial_proxy + person_capital, + ) assessed_capital = where( - use_reported_capital, - max_(0, reported_capital - own_holdings), - household_capital_proxy + person_capital, + complete_override >= 0, + max_(0, complete_override - own_holdings), + financial_capital + benunit("uc_property_capital", period), ) company_capital = benunit.sum( benunit.members("uc_company_capital", period) * claimant_or_partner diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_non_residential_property_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_non_residential_property_capital.py new file mode 100644 index 0000000000..d59947a042 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_non_residential_property_capital.py @@ -0,0 +1,24 @@ +from policyengine_uk.model_api import * + + +class uc_non_residential_property_capital(Variable): + value_type = float + entity = Household + label = "Universal Credit net non-residential property capital" + documentation = ( + "Current assessment-year market value of non-residential property, " + "less the regulation 49 sale-expense deduction, then secured debt, " + "floored at zero. Assumes sale expenses and no Schedule 10 disregard " + "within this broad property class. Property-specific secured debt " + "defaults to zero because reliable survey debt flags are unavailable." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/49/1" + + def formula(household, period, parameters): + p = parameters(period).gov.dwp.universal_credit.means_test.capital + market_value = max_(0, household("non_residential_property_value", period)) + debt = max_(0, household("non_residential_property_secured_debt", period)) + return max_(0, market_value * (1 - p.property_sale_cost_rate) - debt) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_other_residential_property_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_other_residential_property_capital.py new file mode 100644 index 0000000000..eaeebb1435 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_other_residential_property_capital.py @@ -0,0 +1,24 @@ +from policyengine_uk.model_api import * + + +class uc_other_residential_property_capital(Variable): + value_type = float + entity = Household + label = "Universal Credit net other residential property capital" + documentation = ( + "Current assessment-year market value of other residential property, " + "less the regulation 49 sale-expense deduction, then secured debt, " + "floored at zero. Assumes sale expenses and no Schedule 10 disregard " + "within this broad property class. Property-specific secured debt " + "defaults to zero because reliable survey debt flags are unavailable." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/49/1" + + def formula(household, period, parameters): + p = parameters(period).gov.dwp.universal_credit.means_test.capital + market_value = max_(0, household("other_residential_property_value", period)) + debt = max_(0, household("other_residential_property_secured_debt", period)) + return max_(0, market_value * (1 - p.property_sale_cost_rate) - debt) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_property_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_property_capital.py new file mode 100644 index 0000000000..7ae4bbb10e --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_property_capital.py @@ -0,0 +1,38 @@ +from policyengine_uk.model_api import * + + +class uc_property_capital(Variable): + value_type = float + entity = BenUnit + label = "Universal Credit property capital share" + documentation = ( + "Benefit unit's conventional share of the household's net countable " + "property in the assessment year. Allocate equally to ALL claimant " + "and partner adults in the household, pooling each unit's shares, " + "regardless of financial reports, UC receipt or complete UC overrides. " + "A complete override consumes this share; it is not redistributed. " + "This is an assumption about unobserved ownership, not a statutory " + "ownership rule. The main home is excluded. Other property is assumed " + "to incur sale expenses and have no Schedule 10 disregard; the data " + "lack reliable asset-level disregard and debt flags, so secured debt " + "defaults to zero unless supplied in the property-specific inputs." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/49", + "https://www.legislation.gov.uk/uksi/2013/376/schedule/10", + ) + + def formula(benunit, period, parameters): + p = parameters(period).gov.dwp.universal_credit.means_test.capital + household_property = sum( + benunit.household(source, period) for source in p.property_sources + ) + person = benunit.members + household_claimants = benunit.max( + person.household.sum(person.household.members("is_uc_claimant", period)) + ) + benunit_claimants = benunit.sum(person("is_uc_claimant", period)) + return household_property * benunit_claimants / max_(1, household_claimants) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py index f78adf9589..bd6fb6f301 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py @@ -6,17 +6,15 @@ class uc_reported_capital(Variable): entity = BenUnit label = "reported Universal Credit capital" documentation = ( - "Capital of the claimant and any partner as recorded for this benefit " - "unit. Universal Credit assesses a couple's capital together (Welfare " - "Reform Act 2012 s. 5; UC Regulations 2013 reg. 18(2)), which a " - "benefit-unit capital record covers, so this is the benefit unit's " - "recorded capital (benunit_reported_capital) unless set directly. " - "When it is 0 or more it replaces the household proxy and the " - "person-level sources in uc_assessable_capital, and the model applies " - "no Schedule 10 disregard to it, so it must already be the countable " - "figure. Any negative value, including -1 when nothing is recorded, " - "means the household proxy applies. Set it to 0 to override the " - "household proxy with zero assessable capital." + "Explicit complete countable-capital override for the claimant and " + "partner together. A nonnegative value, including zero, replaces " + "financial, property and person-level capital sources; it must already " + "reflect programme valuations and Schedule 10 disregards. The existing " + "regulation 77 company holding substitution still applies. The default " + "-1 means no complete override: benunit_reported_capital then replaces " + "only financial proxies, with countable property assessed separately. " + "This variable does not derive from the financial-only FRS total. " + "An override set directly applies only in the supplied assessment year." ) definition_period = YEAR unit = GBP @@ -28,4 +26,4 @@ class uc_reported_capital(Variable): ) def formula(benunit, period, parameters): - return benunit("benunit_reported_capital", period) + return -1 diff --git a/policyengine_uk/variables/household/wealth/benunit_reported_capital.py b/policyengine_uk/variables/household/wealth/benunit_reported_capital.py index 16ade5a62b..cc6162438d 100644 --- a/policyengine_uk/variables/household/wealth/benunit_reported_capital.py +++ b/policyengine_uk/variables/household/wealth/benunit_reported_capital.py @@ -8,16 +8,20 @@ class benunit_reported_capital(Variable): documentation = ( "Savings and investments of the benefit unit's adults (the claimant " "and any partner) as recorded, such as the Family Resources Survey's " - "benefit-unit total (TOTCAPB4). It is an input that records what was " - "observed; each means test derives its own capital from it " - "(uc_reported_capital, pension_credit_reported_capital) and treats it " - "as countable capital, with no disregard or valuation rule applied, so " - "a recorded gross total such as TOTCAPB4 is an approximation of it; " - "set a programme's variable directly where its countable figure is " - "known. A value set for one year is uprated into later years. Any " + "benefit-unit financial total (finite nonnegative TOTCAPB4, otherwise " + "finite nonnegative TOTCAPB3, otherwise -1). The data loader selects " + "that total once, without summing claimant and partner totals again. " + "This financial observation excludes property. Universal Credit uses " + "it instead of financial savings, investments and covered Lifetime " + "ISA proxies, while retaining countable property separately. Pension " + "Credit retains its existing treatment via pension_credit_reported_capital. " + "Survey financial totals approximate countable financial capital: " + "asset-specific disregards and valuations are not observed. Supply " + "uc_reported_capital directly for a complete UC countable-capital " + "override. A value set for one year is uprated into later years. Any " "negative value, including the default -1, means none is recorded, " "and each means test falls back to its household capital proxy. 0 " - "records no capital." + "records no financial capital." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/input/non_residential_property_secured_debt.py b/policyengine_uk/variables/input/non_residential_property_secured_debt.py new file mode 100644 index 0000000000..ec04a1c86d --- /dev/null +++ b/policyengine_uk/variables/input/non_residential_property_secured_debt.py @@ -0,0 +1,20 @@ +from policyengine_uk.model_api import * + + +class non_residential_property_secured_debt(Variable): + value_type = float + entity = Household + label = "Debt secured on non-residential property" + documentation = ( + "Outstanding encumbrances secured specifically on the household's " + "non-residential property. Excludes main-home mortgage debt and unsecured " + "debt. Deducted after sale expenses in UC regulation 49 valuation. " + "The survey lacks reliable property-specific debt flags, so the " + "default zero is a modelling approximation unless supplied explicitly." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + default_value = 0 + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" + reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/49/1" diff --git a/policyengine_uk/variables/input/non_residential_property_value.py b/policyengine_uk/variables/input/non_residential_property_value.py index 45ad43634a..c38e7e3898 100644 --- a/policyengine_uk/variables/input/non_residential_property_value.py +++ b/policyengine_uk/variables/input/non_residential_property_value.py @@ -4,8 +4,10 @@ class non_residential_property_value(Variable): label = "non-residential property value" documentation = ( - "Total value of non-residential property owned by the household, before " - "any additional Universal Credit Schedule 10 disregards within this " + "Current market value of non-residential property owned by the household, " + "before sale expenses and secured debt. Universal Credit values this " + "in the assessment year, assuming sale expenses and no additional " + "Schedule 10 disregards within this " "broad asset class that are not separately split out in the current " "dataset" ) diff --git a/policyengine_uk/variables/input/other_residential_property_secured_debt.py b/policyengine_uk/variables/input/other_residential_property_secured_debt.py new file mode 100644 index 0000000000..ff3488f386 --- /dev/null +++ b/policyengine_uk/variables/input/other_residential_property_secured_debt.py @@ -0,0 +1,20 @@ +from policyengine_uk.model_api import * + + +class other_residential_property_secured_debt(Variable): + value_type = float + entity = Household + label = "Debt secured on other residential property" + documentation = ( + "Outstanding encumbrances secured specifically on the household's " + "other residential property. Excludes main-home mortgage debt and unsecured " + "debt. Deducted after sale expenses in UC regulation 49 valuation. " + "The survey lacks reliable property-specific debt flags, so the " + "default zero is a modelling approximation unless supplied explicitly." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + default_value = 0 + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" + reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/49/1" diff --git a/policyengine_uk/variables/input/other_residential_property_value.py b/policyengine_uk/variables/input/other_residential_property_value.py index 2a447e46ef..f9009e683a 100644 --- a/policyengine_uk/variables/input/other_residential_property_value.py +++ b/policyengine_uk/variables/input/other_residential_property_value.py @@ -4,8 +4,10 @@ class other_residential_property_value(Variable): label = "other residence value" documentation = ( - "Total value of residential property owned by the household other than " - "the main residence, before any additional Universal Credit Schedule " + "Current market value of residential property owned by the household " + "other than the main residence, before sale expenses and secured debt. " + "Universal Credit values this in the assessment year, assuming sale " + "expenses and no additional Schedule " "10 disregards within this broad asset class that are not separately " "split out in the current dataset" ) From 239e8d7b99b5a029e9d0bde2e318adaab2f38b73 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 06:46:15 -0400 Subject: [PATCH 02/10] Update LISA override test for separate financial capital pool --- .../tests/policy/baseline/gov/dwp/lifetime_isa_capital.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/lifetime_isa_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/lifetime_isa_capital.yaml index 723937bda3..ac6b8c58d1 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/lifetime_isa_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/lifetime_isa_capital.yaml @@ -212,7 +212,10 @@ members: [parent_1, parent_2, adult_child] savings: 9_000 output: - uc_assessable_capital: [7_000, 2_000] + # The complete override replaces the child's LISA but has no observed + # financial/property breakdown. It cannot reduce the financial-only pool: + # the unreported parents receive the £9,000 financial proxy. + uc_assessable_capital: [9_000, 2_000] - name: An adult child's Lifetime ISA counts in the child's Housing Benefit benunit only period: 2025 From 7610a52fcb8a05905e9016804f1d47eae62037de Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 07:03:16 -0400 Subject: [PATCH 03/10] Keep disregarded company holdings out of UC financial residuals --- changelog.d/2439.fixed.md | 2 +- .../tests/test_uc_property_capital.py | 40 +++++++++++++++++++ .../household_uc_pool_company_holdings.py | 26 ++++++------ 3 files changed, 55 insertions(+), 13 deletions(-) diff --git a/changelog.d/2439.fixed.md b/changelog.d/2439.fixed.md index 39063b8ec2..2ed1a2ad9e 100644 --- a/changelog.d/2439.fixed.md +++ b/changelog.d/2439.fixed.md @@ -1 +1 @@ -- Fixed Universal Credit capital assessment so reported financial savings do not suppress countable property, with assessment-year sale-expense and secured-debt valuation and explicit household ownership assumptions. +- Fixed Universal Credit capital assessment so reported financial savings do not suppress countable property, with assessment-year sale-expense and secured-debt valuation, explicit household ownership assumptions, and separate financial pools that retain company-holding disregards. diff --git a/policyengine_uk/tests/test_uc_property_capital.py b/policyengine_uk/tests/test_uc_property_capital.py index 49159fd525..41d498fccf 100644 --- a/policyengine_uk/tests/test_uc_property_capital.py +++ b/policyengine_uk/tests/test_uc_property_capital.py @@ -404,3 +404,43 @@ def test_capital_is_independent_of_person_and_benefit_unit_row_order( reordered = dict(zip(permuted["benunits"], _values(Simulation(situation=permuted)))) for unit in baseline: assert reordered[unit] == pytest.approx(baseline[unit]) + + +@pytest.mark.parametrize("complete_override", [0, 20_000]) +@pytest.mark.parametrize( + "financial_report", [None, -1, 10_000], ids=["omitted", "missing", "observed"] +) +def test_complete_override_company_holding_cannot_leak_into_missing_unit_pool( + complete_override, financial_report +): + owner_unit = {"override": complete_override} + if financial_report is not None: + owner_unit["financial"] = financial_report + situation = _situation( + [owner_unit, {"financial": -1}], + {"corporate_wealth": 10_000, "savings": 6_000}, + ) + situation["people"]["p0_0"].update( + { + "stands_as_sole_owner_or_partner_of_company": {YEAR: True}, + "owned_company_carries_on_property_business": {YEAR: True}, + "owned_company_holding_value": {YEAR: 10_000}, + "owned_company_capital": {YEAR: 0}, + } + ) + simulation = Simulation(situation=situation) + assert _values(simulation, "uc_company_owner_treatment_applies")[0] + assert _values(simulation, "uc_company_holding_disregard")[0] == 10_000 + assert _values(simulation, "uc_company_capital")[0] == 0 + # An observed financial report already removes the owner's holding from + # the financial pool. Without one, remove it separately even when the + # owner has a complete override; that override's decomposition is unknown. + expected_pool_disregard = 0 if financial_report == 10_000 else 10_000 + assert ( + _values(simulation, "household_uc_pool_company_holdings")[0] + == expected_pool_disregard + ) + np.testing.assert_allclose( + _values(simulation), + [max(0, complete_override - 10_000), 6_000], + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py index 3b50997c3b..d7041e0ec5 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py @@ -9,14 +9,18 @@ class household_uc_pool_company_holdings(Variable): ) documentation = ( "Disregarded company holdings (uc_company_holding_disregard) taken " - "out of the household's capital before it is shared among the " - "claimants and partners of benunits that do not report their " - "capital. That is every holding except a reporting benunit's " - "claimant's or partner's, which comes off that benunit's reported " - "capital instead: the holdings of everyone in benunits that do not " - "report, and of dependants in benunits that do, whose capital is " - "nobody's to count. So no benunit counts any part of a disregarded " - "holding. A holding is shares, held within the household's " + "out of the household's financial capital before its residual is " + "shared among claimants and partners without a financial observation " + "or complete override. A claimant or partner's holding is already " + "covered when their benunit supplies a nonnegative financial " + "observation (benunit_reported_capital), which is subtracted from the " + "financial pool. All other holdings leave the pool here, including " + "those of complete-override claimants without a financial observation " + "and those of dependants. A complete uc_reported_capital override has " + "unknown asset composition and is never subtracted from the financial " + "pool; its owner's holding is also disregarded in their own assessment. " + "So no benunit counts any part of a disregarded holding. A holding " + "is shares, held within the household's " "corporate_wealth, so no more than that is taken out: a holding " "entered without matching corporate_wealth cannot remove savings or " "property." @@ -28,11 +32,9 @@ class household_uc_pool_company_holdings(Variable): def formula(household, period, parameters): person = household.members - reported = (person.benunit("uc_reported_capital", period) >= 0) | ( - person.benunit("benunit_reported_capital", period) >= 0 - ) + has_financial_report = person.benunit("benunit_reported_capital", period) >= 0 claimant_or_partner = person("is_uc_claimant", period) - off_reported_capital = reported & claimant_or_partner + off_reported_capital = has_financial_report & claimant_or_partner holdings = household.sum( person("uc_company_holding_disregard", period) * ~off_reported_capital ) From 789b488e7bff1d055825a9fb11074382c820e33f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 17 Oct 2026 09:57:13 -0400 Subject: [PATCH 04/10] Scope UC property independence and document inherited capital conventions --- changelog.d/2439.fixed.md | 2 +- .../means_test/capital/person_sources.yaml | 3 +- .../means_test/capital/sources.yaml | 6 +- .../tests/test_uc_property_capital.py | 65 ++++++++++++++++++- .../household_uc_pool_company_holdings.py | 4 +- .../universal_credit/uc_assessable_capital.py | 11 +++- .../universal_credit/uc_reported_capital.py | 13 +++- .../wealth/benunit_reported_capital.py | 3 +- 8 files changed, 93 insertions(+), 14 deletions(-) diff --git a/changelog.d/2439.fixed.md b/changelog.d/2439.fixed.md index 2ed1a2ad9e..aea49b4e4a 100644 --- a/changelog.d/2439.fixed.md +++ b/changelog.d/2439.fixed.md @@ -1 +1 @@ -- Fixed Universal Credit capital assessment so reported financial savings do not suppress countable property, with assessment-year sale-expense and secured-debt valuation, explicit household ownership assumptions, and separate financial pools that retain company-holding disregards. +- Fixed Universal Credit capital assessment so reported financial savings do not suppress countable property, with assessment-year sale-expense and secured-debt valuation and property allocation independent of financial reports. Retained the inherited residual financial convention and company substitution after ordinary-source overrides, and documented the legacy pension-proxy limitation. diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml index 4c1dbab754..dc6970037a 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml @@ -11,7 +11,8 @@ description: > April 2017, and the list is empty before then. A nonnegative financial observation (benunit_reported_capital) replaces these covered financial sources to avoid counting a Lifetime ISA twice. A complete UC override - replaces them together with property and the household financial proxy. + replaces them together with property and the household financial proxy, + before the existing regulation 77 company substitution. metadata: label: Universal Credit person-level capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml index e6766c94e2..2f7c3cd090 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml @@ -10,8 +10,10 @@ description: > about unobserved ownership, not a statutory rule. Countable property is valued and shared independently through property_sources across ALL claimant/partner adults, with complete-override units consuming their shares. - Main-home property and private pension rights remain excluded under - Schedule 10 paragraphs 1 and 10. Asset-specific Schedule 10 disregards + Main-home property and the named private_pension_wealth input are excluded + under Schedule 10 paragraphs 1 and 10. For missing-report households, the + legacy corporate_wealth proxy can still contain non-DB pension wealth that + is not separately identified and removed. Asset-specific Schedule 10 disregards within these broad financial classes are not separately observed. metadata: label: Universal Credit financial capital proxy sources diff --git a/policyengine_uk/tests/test_uc_property_capital.py b/policyengine_uk/tests/test_uc_property_capital.py index 41d498fccf..6107222a29 100644 --- a/policyengine_uk/tests/test_uc_property_capital.py +++ b/policyengine_uk/tests/test_uc_property_capital.py @@ -2,8 +2,14 @@ Regulations 18, 49 and 72 provide the capital limit, property valuation and tariff. Equal shares per claimant/partner adult are a modelling convention -for household assets whose beneficial ownership is unobserved. These cases -assume the properties incur sale expenses and have no Schedule 10 disregard. +for household assets whose beneficial ownership is unobserved. Property +allocation is independent of other units' financial reports and depends only +on claimant and partner adult counts. The residual financial pool is shared +among units without a report, so another unit's report can change that pool +and their capital. This is an inherited convention. These cases assume the +properties incur sale expenses and have no Schedule 10 disregard. An explicit +uc_reported_capital replaces the ordinary sources before regulation 77 company +substitution. """ from copy import deepcopy @@ -130,6 +136,25 @@ def test_explicit_complete_override_replaces_financial_and_property_capital(over ) +def test_explicit_zero_override_still_includes_regulation_77_company_capital(): + situation = _situation( + [{"financial": 6_000, "override": 0}], + {"savings": 12_000, "other_residential_property_value": 40_000}, + ) + situation["people"]["p0_0"].update( + { + "stands_as_sole_owner_or_partner_of_company": {YEAR: True}, + "owned_company_carries_on_property_business": {YEAR: True}, + "owned_company_capital": {YEAR: 8_000}, + } + ) + simulation = Simulation(situation=situation) + # The override replaces the ordinary financial/property assessment; + # regulation 77 then adds the owner's £8k of company capital. + assert _values(simulation, "uc_company_capital")[0] == 8_000 + assert _values(simulation)[0] == 8_000 + + def test_financial_observation_does_not_double_count_savings_investments_or_lisa(): situation = _situation( [{"financial": 6_000}], {"savings": 50_000, "corporate_wealth": 30_000} @@ -184,6 +209,42 @@ def test_unrelated_financial_reports_do_not_change_property_ownership(other_fina assert _values(simulation)[1] == pytest.approx(9_000 + max(0, other_financial)) +@pytest.mark.parametrize( + "reports,expected", + [([-1, -1], [6_000, 6_000]), ([-1, 0], [12_000, 0])], + ids=["both-missing", "second-reports-zero"], +) +def test_inherited_residual_financial_sharing_intentionally_depends_on_other_reports( + reports, expected +): + simulation = Simulation( + situation=_situation( + [{"financial": report} for report in reports], + {"savings": 12_000}, + ) + ) + # With no reports, two adults share £12k equally. A zero report removes + # the second unit from the residual denominator without reducing the + # pool, so the first unit receives all £12k. This convention is intended. + np.testing.assert_allclose(_values(simulation), expected) + + +def test_inherited_financial_sharing_intentionally_adds_to_property_share_change(): + household = {"savings": 12_000, "other_residential_property_value": 40_000} + alone = Simulation(situation=_situation([{"financial": -1}], household)) + with_reporting_unit = Simulation( + situation=_situation([{"financial": -1}, {"financial": 6_000}], household) + ) + # Alone: £12k financial proxy + £40k * 90% property = £48k. + # With the reporting adult: (£12k - £6k) financial proxy + £36k / 2 + # property = £24k. The reporting unit also has £6k + £18k = £24k. + np.testing.assert_allclose(_values(alone), [48_000]) + np.testing.assert_allclose(_values(with_reporting_unit), [24_000, 24_000]) + # The intended £24k fall includes £6k from the inherited residual pool + # as well as the £18k change in the claimant's property share. + assert _values(alone)[0] - _values(with_reporting_unit)[0] == 24_000 + + def test_reporting_a_conventional_financial_share_leaves_other_units_unchanged(): household = {"savings": 12_000, "other_residential_property_value": 12_000} missing = Simulation(situation=_situation([{}, {}], household)) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py index d7041e0ec5..273555205e 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py @@ -19,7 +19,9 @@ class household_uc_pool_company_holdings(Variable): "and those of dependants. A complete uc_reported_capital override has " "unknown asset composition and is never subtracted from the financial " "pool; its owner's holding is also disregarded in their own assessment. " - "So no benunit counts any part of a disregarded holding. A holding " + "When financial reports and holdings are compatible, no benunit counts " + "any part of a disregarded holding. An inconsistent financial report " + "that omits a known holding can leave it in the residual pool. A holding " "is shares, held within the household's " "corporate_wealth, so no more than that is taken out: a holding " "entered without matching corporate_wealth cannot remove savings or " diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py index 52be73de67..35c6fc4c3d 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py @@ -7,16 +7,21 @@ class uc_assessable_capital(Variable): label = "Universal Credit assessable capital" documentation = ( "Countable capital of the claimant and partner. An explicit nonnegative " - "uc_reported_capital is a complete override, including zero. Otherwise " + "uc_reported_capital, including zero, replaces ordinary capital sources " + "before the existing regulation 77 company substitution. Otherwise " "a nonnegative benunit_reported_capital replaces financial household " "and person-level proxies only; it does not replace countable property. " "Observed financial totals reduce the household financial pool before " "its residual is allocated by claimant/partner counts to units with " - "neither report. A complete override is never subtracted from that " + "neither report. Under this inherited financial convention, another " + "unit's financial report can change a missing-report unit's capital. " + "A complete override is never subtracted from that " "financial pool because its asset decomposition is unknown. Property " "is valued separately in the assessment year and allocated across ALL " "claimant/partner adults, including non-UC units and complete-override " - "units, whose shares are consumed rather than redistributed. These " + "units, whose shares are consumed rather than redistributed. Property " + "allocation depends only on claimant/partner adult counts and is " + "independent of other units' financial reports. These " "allocations are assumptions about unobserved ownership, not law. " "Person-level proxies count only claimants/partners, not dependants. " "Under regulation 77, disregarded company holdings are replaced by " diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py index bd6fb6f301..9ca82f5a41 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py @@ -8,11 +8,18 @@ class uc_reported_capital(Variable): documentation = ( "Explicit complete countable-capital override for the claimant and " "partner together. A nonnegative value, including zero, replaces " - "financial, property and person-level capital sources; it must already " - "reflect programme valuations and Schedule 10 disregards. The existing " - "regulation 77 company holding substitution still applies. The default " + "ordinary financial, property and person-level capital sources before " + "the existing regulation 77 company holding substitution; it must " + "already reflect programme valuations and Schedule 10 disregards. " + "An override of zero plus company capital of GBP 8,000 therefore " + "assesses GBP 8,000. The default " "-1 means no complete override: benunit_reported_capital then replaces " "only financial proxies, with countable property assessed separately. " + "Property allocation depends only on claimant/partner adult counts " + "and is independent of other units' financial reports. The residual " + "financial pool is shared among units with neither report; under this " + "inherited convention, another unit's financial report can change a " + "missing-report unit's capital. " "This variable does not derive from the financial-only FRS total. " "An override set directly applies only in the supplied assessment year." ) diff --git a/policyengine_uk/variables/household/wealth/benunit_reported_capital.py b/policyengine_uk/variables/household/wealth/benunit_reported_capital.py index cc6162438d..f99b7c3d78 100644 --- a/policyengine_uk/variables/household/wealth/benunit_reported_capital.py +++ b/policyengine_uk/variables/household/wealth/benunit_reported_capital.py @@ -18,7 +18,8 @@ class benunit_reported_capital(Variable): "Survey financial totals approximate countable financial capital: " "asset-specific disregards and valuations are not observed. Supply " "uc_reported_capital directly for a complete UC countable-capital " - "override. A value set for one year is uprated into later years. Any " + "override before the existing regulation 77 company substitution. " + "A value set for one year is uprated into later years. Any " "negative value, including the default -1, means none is recorded, " "and each means test falls back to its household capital proxy. 0 " "records no financial capital." From 2c22f4e8f050536e0ad6bf548eb1ed618b80c597 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 17 Oct 2026 11:42:49 -0400 Subject: [PATCH 05/10] Restore UC override subtraction from the financial pool --- changelog.d/2439.fixed.md | 2 +- .../means_test/capital/sources.yaml | 7 +- .../universal_credit/uc_property_capital.yaml | 27 ++++++ ...est_benunit_reported_capital_properties.py | 4 +- .../tests/test_uc_property_capital.py | 85 ++++++++++++++++--- .../household_uc_pool_company_holdings.py | 18 ++-- .../household_uc_reported_capital.py | 17 ---- ...household_uc_reported_financial_capital.py | 19 +++-- .../universal_credit/uc_assessable_capital.py | 13 +-- .../universal_credit/uc_reported_capital.py | 6 +- 10 files changed, 139 insertions(+), 59 deletions(-) delete mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_capital.py diff --git a/changelog.d/2439.fixed.md b/changelog.d/2439.fixed.md index aea49b4e4a..3808388038 100644 --- a/changelog.d/2439.fixed.md +++ b/changelog.d/2439.fixed.md @@ -1 +1 @@ -- Fixed Universal Credit capital assessment so reported financial savings do not suppress countable property, with assessment-year sale-expense and secured-debt valuation and property allocation independent of financial reports. Retained the inherited residual financial convention and company substitution after ordinary-source overrides, and documented the legacy pension-proxy limitation. +- Fixed Universal Credit capital assessment so reported financial savings do not suppress countable property, with assessment-year sale-expense and secured-debt valuation and property allocation independent of financial reports. Retained the inherited residual financial convention, including subtraction of complete overrides from the financial pool, and company substitution after ordinary-source overrides, and documented the legacy pension-proxy limitation. diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml index 2f7c3cd090..374052d07a 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml @@ -2,9 +2,10 @@ description: > Household financial proxies for Universal Credit: savings and corporate financial wealth. A nonnegative benunit_reported_capital replaces these financial proxies and covered person-level sources, including a Lifetime ISA. - Only observed financial totals reduce this financial pool; a complete - uc_reported_capital override has unknown asset composition and is never - subtracted from it. The residual is allocated by claimant/partner counts + Each benefit unit's complete uc_reported_capital override, otherwise its + observed financial total, reduces this financial pool. Subtracting complete + overrides retains the inherited allocation convention despite their unknown + asset composition. The residual is allocated by claimant/partner counts to benefit units with neither a financial observation nor a complete override, including units that do not receive UC. This allocation is an assumption about unobserved ownership, not a statutory rule. Countable property is diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml index 5d19e67b41..4b6a03cc28 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml @@ -63,3 +63,30 @@ uc_assessable_capital: 0 is_uc_eligible: true uc_tariff_income: 0 + +- name: A complete override consumes its amount from the household financial pool + period: 2026 + input: + people: + claimant: + age: 30 + is_claimant_or_partner: true + other_claimant: + age: 30 + is_claimant_or_partner: true + benunits: + missing: + members: [claimant] + would_claim_uc: true + overridden: + members: [other_claimant] + would_claim_uc: true + uc_reported_capital: 6_000 + households: + household: + members: [claimant, other_claimant] + savings: 12_000 + output: + uc_assessable_capital: [6_000, 6_000] + uc_tariff_income: [0, 0] + universal_credit: [5_098.80, 5_098.80] diff --git a/policyengine_uk/tests/test_benunit_reported_capital_properties.py b/policyengine_uk/tests/test_benunit_reported_capital_properties.py index 4f7cd59f70..b1884bf6eb 100644 --- a/policyengine_uk/tests/test_benunit_reported_capital_properties.py +++ b/policyengine_uk/tests/test_benunit_reported_capital_properties.py @@ -12,8 +12,8 @@ -1 included. UC's complete override remains -1 without an explicit value. 2. Differential: without property or person-level capital, a financial observation gives the same assessment as explicitly setting that unit's - complete override too. Retaining the financial observations in both cases - preserves the financial residual available to other units. + complete override too. Each unit consumes the same amount from the financial + pool in both cases, preserving the residual available to other units. 3. Precedence: a programme variable set directly wins for that programme only. 4. Financial identity and bounds: without property or person-level capital, a unit with recorded R >= 0 has UC assessable capital R. Assessable capital diff --git a/policyengine_uk/tests/test_uc_property_capital.py b/policyengine_uk/tests/test_uc_property_capital.py index 6107222a29..9c0ddff30a 100644 --- a/policyengine_uk/tests/test_uc_property_capital.py +++ b/policyengine_uk/tests/test_uc_property_capital.py @@ -16,7 +16,7 @@ import numpy as np import pytest -from hypothesis import given, settings +from hypothesis import example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -191,10 +191,28 @@ def test_mixed_household_keeps_financial_and_property_pools_separate(complete_ov {"savings": 12_000, "other_residential_property_value": 40_000}, ) ) - # Complete override consumes its £18k conventional property share. Its - # unknown financial/property composition never reduces the financial pool. - np.testing.assert_allclose(_values(simulation), [complete_override, 15_000, 15_000]) - assert _values(simulation)[1:].sum() - 6_000 - 6_000 == pytest.approx(18_000) + # Complete override consumes its £18k conventional property share and its + # reported amount from the financial pool, under the inherited convention. + missing_financial = max(0, 12_000 - 6_000 - complete_override) + np.testing.assert_allclose( + _values(simulation), [complete_override, 15_000, 9_000 + missing_financial] + ) + assert _values(simulation)[1:].sum() - 6_000 - missing_financial == pytest.approx( + 18_000 + ) + + +@pytest.mark.parametrize("financial", [None, 0, 11_000]) +def test_complete_override_consumes_its_amount_from_the_household_financial_pool( + financial, +): + overriding_unit = {"override": 6_000} + if financial is not None: + overriding_unit["financial"] = financial + simulation = Simulation( + situation=_situation([{"financial": -1}, overriding_unit], {"savings": 12_000}) + ) + np.testing.assert_allclose(_values(simulation), [6_000, 6_000]) @pytest.mark.parametrize("other_financial", [-1, 0, 50_000]) @@ -404,6 +422,50 @@ def test_assessable_capital_never_falls_when_a_countable_asset_increases( assert np.all(high_capital >= low_capital - 0.01) +@PROPERTY_SETTINGS +@given( + savings=st.integers(0, 100_000), + financial_percent=st.integers(0, 100), + complete_override=st.integers(0, 100_000), + superseded_financial=st.integers(0, 100_000), + adult_counts=st.lists(st.integers(1, 2), min_size=3, max_size=3), +) +@example( + savings=12_000, + financial_percent=0, + complete_override=6_000, + superseded_financial=0, + adult_counts=[1, 1, 1], +) +def test_financial_allocation_conserves_pool_with_reports_and_complete_overrides( + savings, financial_percent, complete_override, superseded_financial, adult_counts +): + # Financial observations are compatible with the household pool. Property, + # person-level assets and regulation 77 company substitution are absent. + financial = savings * financial_percent // 100 + simulation = Simulation( + situation=_situation( + [ + {"adults": adult_counts[0], "financial": -1}, + { + "adults": adult_counts[1], + "financial": superseded_financial, + "override": complete_override, + }, + {"adults": adult_counts[2], "financial": financial}, + ], + {"savings": savings}, + ) + ) + assessed = _values(simulation) + assert assessed[1] == complete_override + assert assessed[2] == financial + assert assessed.sum() <= savings + complete_override + 0.03 + # Reports replace parts of the pool; they cannot create additional capital + # unless their combined amounts already exceed that pool. + assert assessed.sum() <= max(savings, financial + complete_override) + 0.03 + + @PROPERTY_SETTINGS @given( adult_counts=st.lists(st.integers(1, 2), min_size=1, max_size=4), @@ -493,15 +555,10 @@ def test_complete_override_company_holding_cannot_leak_into_missing_unit_pool( assert _values(simulation, "uc_company_owner_treatment_applies")[0] assert _values(simulation, "uc_company_holding_disregard")[0] == 10_000 assert _values(simulation, "uc_company_capital")[0] == 0 - # An observed financial report already removes the owner's holding from - # the financial pool. Without one, remove it separately even when the - # owner has a complete override; that override's decomposition is unknown. - expected_pool_disregard = 0 if financial_report == 10_000 else 10_000 - assert ( - _values(simulation, "household_uc_pool_company_holdings")[0] - == expected_pool_disregard - ) + # The complete override supersedes any financial observation. Its asset + # composition is unknown, so the holding still leaves the pool separately. + assert _values(simulation, "household_uc_pool_company_holdings")[0] == 10_000 np.testing.assert_allclose( _values(simulation), - [max(0, complete_override - 10_000), 6_000], + [max(0, complete_override - 10_000), max(0, 6_000 - complete_override)], ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py index 273555205e..c51dee69fe 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py @@ -13,12 +13,13 @@ class household_uc_pool_company_holdings(Variable): "shared among claimants and partners without a financial observation " "or complete override. A claimant or partner's holding is already " "covered when their benunit supplies a nonnegative financial " - "observation (benunit_reported_capital), which is subtracted from the " - "financial pool. All other holdings leave the pool here, including " - "those of complete-override claimants without a financial observation " - "and those of dependants. A complete uc_reported_capital override has " - "unknown asset composition and is never subtracted from the financial " - "pool; its owner's holding is also disregarded in their own assessment. " + "observation (benunit_reported_capital) and no complete override, so " + "that observation is subtracted from the financial pool. All other " + "holdings leave the pool here, including those of complete-override " + "claimants and dependants. A complete uc_reported_capital override " + "consumes its amount from the financial pool under the inherited " + "convention, but its asset composition is unknown; its owner's holding " + "is also disregarded in their own assessment. " "When financial reports and holdings are compatible, no benunit counts " "any part of a disregarded holding. An inconsistent financial report " "that omits a known holding can leave it in the residual pool. A holding " @@ -35,8 +36,11 @@ class household_uc_pool_company_holdings(Variable): def formula(household, period, parameters): person = household.members has_financial_report = person.benunit("benunit_reported_capital", period) >= 0 + has_complete_override = person.benunit("uc_reported_capital", period) >= 0 claimant_or_partner = person("is_uc_claimant", period) - off_reported_capital = has_financial_report & claimant_or_partner + off_reported_capital = ( + has_financial_report & ~has_complete_override & claimant_or_partner + ) holdings = household.sum( person("uc_company_holding_disregard", period) * ~off_reported_capital ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_capital.py deleted file mode 100644 index 2909c5caae..0000000000 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_capital.py +++ /dev/null @@ -1,17 +0,0 @@ -from policyengine_uk.model_api import * - - -class household_uc_reported_capital(Variable): - value_type = float - entity = Household - label = "Household Universal Credit capital explicitly reported by benunits" - definition_period = YEAR - unit = GBP - quantity_type = STOCK - - def formula(household, period, parameters): - person = household.members - reported_capital = person.benunit("uc_reported_capital", period) - has_reported_capital = reported_capital >= 0 - is_benunit_head = person("is_benunit_head", period) - return household.sum(reported_capital * has_reported_capital * is_benunit_head) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py index b9a2c7f26a..877275cc4f 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py @@ -4,13 +4,14 @@ class household_uc_reported_financial_capital(Variable): value_type = float entity = Household - label = "Household financial capital observed for benefit units" + label = "Household UC capital reports consumed from the financial pool" documentation = ( - "Sum of nonnegative benunit_reported_capital observations, counted " - "once per benefit unit. Only these financial observations reduce the " - "household financial proxy. A complete uc_reported_capital override " - "has an unknown financial/property decomposition and is never " - "subtracted from that financial pool." + "Amounts consumed from the household financial proxy, counted once " + "per benefit unit: a nonnegative complete uc_reported_capital override " + "takes precedence over benunit_reported_capital. Without an override, " + "use the nonnegative financial observation. Subtracting a complete " + "override retains the inherited residual-pool convention; it does " + "not imply that the override's asset composition is observed." ) definition_period = YEAR unit = GBP @@ -19,6 +20,10 @@ class household_uc_reported_financial_capital(Variable): def formula(household, period, parameters): person = household.members financial_capital = person.benunit("benunit_reported_capital", period) + complete_override = person.benunit("uc_reported_capital", period) + consumed_capital = where( + complete_override >= 0, complete_override, financial_capital + ) return household.sum( - max_(0, financial_capital) * person("is_benunit_head", period) + max_(0, consumed_capital) * person("is_benunit_head", period) ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py index 35c6fc4c3d..564f64588d 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py @@ -11,12 +11,13 @@ class uc_assessable_capital(Variable): "before the existing regulation 77 company substitution. Otherwise " "a nonnegative benunit_reported_capital replaces financial household " "and person-level proxies only; it does not replace countable property. " - "Observed financial totals reduce the household financial pool before " + "Complete overrides, otherwise observed financial totals, reduce the " + "household financial pool before " "its residual is allocated by claimant/partner counts to units with " "neither report. Under this inherited financial convention, another " - "unit's financial report can change a missing-report unit's capital. " - "A complete override is never subtracted from that " - "financial pool because its asset decomposition is unknown. Property " + "unit's financial report or complete override can change a " + "missing-report unit's capital. Subtracting a complete override is " + "a pool-allocation convention, not an observed asset breakdown. Property " "is valued separately in the assessment year and allocated across ALL " "claimant/partner adults, including non-UC units and complete-override " "units, whose shares are consumed rather than redistributed. Property " @@ -52,8 +53,8 @@ def formula(benunit, period, parameters): financial_report = benunit("benunit_reported_capital", period) complete_override = benunit("uc_reported_capital", period) unreported_claimants = household("household_uc_unreported_claimants", period) - # Complete overrides may include property, so only observed FINANCIAL - # totals can be subtracted from this financial-only pool. + # Each unit consumes its complete override, otherwise its financial + # report, from the financial pool under the inherited convention. residual_financial_capital = max_( 0, household_financial_capital diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py index 9ca82f5a41..f133095049 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py @@ -18,8 +18,10 @@ class uc_reported_capital(Variable): "Property allocation depends only on claimant/partner adult counts " "and is independent of other units' financial reports. The residual " "financial pool is shared among units with neither report; under this " - "inherited convention, another unit's financial report can change a " - "missing-report unit's capital. " + "inherited convention, another unit's financial report or complete " + "override can change a missing-report unit's capital. The complete " + "override consumes its amount from the financial pool, taking " + "precedence over the same unit's financial report. " "This variable does not derive from the financial-only FRS total. " "An override set directly applies only in the supplied assessment year." ) From 6cedab28f666a1ad6378b506696afc340f007831 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 17 Oct 2026 13:20:11 -0400 Subject: [PATCH 06/10] Align LISA override regression with restored pool subtraction --- .../policy/baseline/gov/dwp/lifetime_isa_capital.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/lifetime_isa_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/lifetime_isa_capital.yaml index ac6b8c58d1..fed93729ca 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/lifetime_isa_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/lifetime_isa_capital.yaml @@ -212,10 +212,10 @@ members: [parent_1, parent_2, adult_child] savings: 9_000 output: - # The complete override replaces the child's LISA but has no observed - # financial/property breakdown. It cannot reduce the financial-only pool: - # the unreported parents receive the £9,000 financial proxy. - uc_assessable_capital: [9_000, 2_000] + # The complete override replaces the child's LISA and consumes £2,000 + # from the household financial pool under the inherited convention. + # The unreported parents receive the remaining £7,000 financial proxy. + uc_assessable_capital: [7_000, 2_000] - name: An adult child's Lifetime ISA counts in the child's Housing Benefit benunit only period: 2025 From 33c08e7d83e61207131a98d1d36a90ca3b3f2342 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 12:05:25 -0400 Subject: [PATCH 07/10] Avoid double-counting company holdings in UC overrides --- changelog.d/2439.fixed.md | 2 +- .../means_test/capital/sources.yaml | 9 +- .../universal_credit/uc_property_capital.yaml | 33 ++++ .../test_uc_company_override_properties.py | 174 ++++++++++++++++++ .../tests/test_uc_property_capital.py | 45 ++++- .../tests/test_uc_property_capital_mock.py | 26 +++ .../uc_claimant_company_holding_disregard.py | 21 +++ .../household_uc_pool_company_holdings.py | 19 +- ...household_uc_reported_financial_capital.py | 21 ++- .../universal_credit/uc_reported_capital.py | 4 +- 10 files changed, 331 insertions(+), 23 deletions(-) create mode 100644 policyengine_uk/tests/test_uc_company_override_properties.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/company/uc_claimant_company_holding_disregard.py diff --git a/changelog.d/2439.fixed.md b/changelog.d/2439.fixed.md index 3808388038..8df2e039a1 100644 --- a/changelog.d/2439.fixed.md +++ b/changelog.d/2439.fixed.md @@ -1 +1 @@ -- Fixed Universal Credit capital assessment so reported financial savings do not suppress countable property, with assessment-year sale-expense and secured-debt valuation and property allocation independent of financial reports. Retained the inherited residual financial convention, including subtraction of complete overrides from the financial pool, and company substitution after ordinary-source overrides, and documented the legacy pension-proxy limitation. +- Fixed Universal Credit capital assessment so reported financial savings do not suppress countable property, with assessment-year sale-expense and secured-debt valuation and property allocation independent of financial reports. Retained the inherited residual financial convention, including subtraction of complete overrides from the financial pool, and company substitution after ordinary-source overrides, and documented the legacy pension-proxy limitation. Complete overrides consume their owners' disregarded company holdings once, with a corporate-wealth-capped floor preventing holdings from leaking to other units when an override is smaller. diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml index 374052d07a..2a1b87629f 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml @@ -4,9 +4,12 @@ description: > financial proxies and covered person-level sources, including a Lifetime ISA. Each benefit unit's complete uc_reported_capital override, otherwise its observed financial total, reduces this financial pool. Subtracting complete - overrides retains the inherited allocation convention despite their unknown - asset composition. The residual is allocated by claimant/partner counts - to benefit units with neither a financial observation nor a complete override, + overrides retains the inherited allocation convention. An override precedes + regulation 77 company substitution and includes its claimant's and partner's + holdings: consume at least their disregarded holdings capped at corporate_wealth + without removing them again from the pool. The residual is allocated by + claimant/partner counts to benefit units with neither a financial observation + nor a complete override, including units that do not receive UC. This allocation is an assumption about unobserved ownership, not a statutory rule. Countable property is valued and shared independently through property_sources across ALL diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml index 4b6a03cc28..95951f1d60 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml @@ -90,3 +90,36 @@ uc_assessable_capital: [6_000, 6_000] uc_tariff_income: [0, 0] universal_credit: [5_098.80, 5_098.80] + +- name: A complete override consumes its owner's company holding only once + period: 2026 + input: + people: + owner: + age: 30 + is_claimant_or_partner: true + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_property_business: true + owned_company_holding_value: 10_000 + owned_company_capital: 0 + other_claimant: + age: 30 + is_claimant_or_partner: true + benunits: + overridden: + members: [owner] + would_claim_uc: true + uc_reported_capital: 10_000 + missing: + members: [other_claimant] + would_claim_uc: true + households: + household: + members: [owner, other_claimant] + savings: 20_000 + corporate_wealth: 10_000 + output: + household_uc_reported_financial_capital: 10_000 + household_uc_pool_company_holdings: 0 + uc_assessable_capital: [0, 20_000] + universal_credit: [5_098.80, 0] diff --git a/policyengine_uk/tests/test_uc_company_override_properties.py b/policyengine_uk/tests/test_uc_company_override_properties.py new file mode 100644 index 0000000000..52bfc2cabb --- /dev/null +++ b/policyengine_uk/tests/test_uc_company_override_properties.py @@ -0,0 +1,174 @@ +"""Exhaustive MOCK company-override conservation and residual-pool properties. + +A complete override describes capital before regulation 77 substitution. Its +owner's holding must leave the financial pool once: through that override, +with a floor at the holding represented by corporate wealth. Dependants own +their holdings separately. Holdings above household corporate wealth are +inconsistent proxy observations, so full conservation is asserted only for +compatible inputs; the corporate-wealth cap and residual allocation are +checked for every input, including those inconsistent observations. +""" + +from itertools import product + +import numpy as np +import pytest + +from policyengine_uk import Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2026 +LAYOUTS = { + "one-owner": ((10_000,), (), ()), + "two-owners-one-unit": ((4_000, 6_000), (), ()), + "claimant-and-dependant": ((6_000,), (4_000,), ()), + "owners-in-separate-units": ((6_000,), (), (4_000,)), +} +OTHER_REPORTS = ( + {}, + {"financial": 6_000}, + {"override": 0}, + {"override": 4_000}, + {"override": 12_000}, +) + + +def _company_person(holding=0, dependant=False): + person = { + "age": {YEAR: 10 if dependant else 30}, + "is_claimant_or_partner": {YEAR: not dependant}, + } + if holding: + person.update( + { + "stands_as_sole_owner_or_partner_of_company": {YEAR: True}, + "owned_company_carries_on_property_business": {YEAR: True}, + "owned_company_holding_value": {YEAR: holding}, + "owned_company_capital": {YEAR: 0}, + } + ) + return person + + +def _grid(layout): + """Combine 960 independent three-unit households in one simulation.""" + people, units, households, cases = {}, {}, {}, [] + first_holdings, dependant_holdings, second_holdings = LAYOUTS[layout] + claimant_holdings = (sum(first_holdings), sum(second_holdings), 0) + for index, ( + override, + financial, + corporate, + savings, + other, + missing_adults, + ) in enumerate( + product( + (0, 4_000, 10_000, 20_000), + (None, -1, 10_000), + (0, 4_000, 10_000, 20_000), + (6_000, 20_000), + OTHER_REPORTS, + (1, 2), + ) + ): + prefix = f"h{index}" + specs = [{"override": override}, other, {}] + if financial is not None: + specs[0]["financial"] = financial + members = [] + adult_counts = (len(first_holdings), 1, missing_adults) + for unit_index, (spec, adults, holdings) in enumerate( + zip(specs, adult_counts, (first_holdings, second_holdings, ())) + ): + names = [] + for adult_index in range(adults): + name = f"{prefix}_u{unit_index}_a{adult_index}" + holding = holdings[adult_index] if adult_index < len(holdings) else 0 + people[name] = _company_person(holding) + names.append(name) + if unit_index == 0: + for child_index, holding in enumerate(dependant_holdings): + name = f"{prefix}_child{child_index}" + people[name] = _company_person(holding, dependant=True) + names.append(name) + unit = {"members": names, "would_claim_uc": {YEAR: True}} + if "financial" in spec: + unit["benunit_reported_capital"] = {YEAR: spec["financial"]} + if "override" in spec: + unit["uc_reported_capital"] = {YEAR: spec["override"]} + units[f"{prefix}_u{unit_index}"] = unit + members.extend(names) + households[prefix] = { + "members": members, + "savings": {YEAR: savings}, + "corporate_wealth": {YEAR: corporate}, + } + cases.append( + ( + specs, + adult_counts, + claimant_holdings, + sum(dependant_holdings), + corporate, + savings, + ) + ) + return {"people": people, "benunits": units, "households": households}, cases + + +@pytest.mark.parametrize("layout", LAYOUTS) +def test_company_overrides_conserve_capital_and_keep_holdings_out_of_other_units( + layout, +): + situation, cases = _grid(layout) + simulation = Simulation(situation=situation) + assessed = np.asarray(simulation.calculate("uc_assessable_capital", YEAR)).reshape( + -1, 3 + ) + assert np.all(simulation.calculate("uc_company_capital", YEAR) == 0) + conserved = 0 + for row, (specs, adults, holdings, dependants, corporate, savings) in zip( + assessed, cases + ): + pool = corporate + savings + missing = [ + "override" not in spec and spec.get("financial", -1) < 0 for spec in specs + ] + consumed = sum( + max(spec["override"], min(holding, corporate)) + if "override" in spec + else max(0, spec.get("financial", -1)) + for spec, holding in zip(specs, holdings) + ) + # Only unreported owners and dependants leave the pool separately; + # reported claimants' holdings already belong to their report. + uncovered = dependants + sum( + holding for holding, absent in zip(holdings, missing) if absent + ) + residual = max(0, pool - consumed - min(uncovered, corporate)) + missing_adults = sum(count for count, absent in zip(adults, missing) if absent) + # This identity proves both directions: no owner's holding leaks to a + # missing unit, and an override cannot remove that holding twice. + for capital, count, absent in zip(row, adults, missing): + if absent: + assert capital == pytest.approx( + residual * count / missing_adults, abs=0.03 + ) + total_holdings = sum(holdings) + dependants + explicit_overrides = sum(spec.get("override", 0) for spec in specs) + # Only holdings represented by corporate wealth can be conserved + # against that pool when the person and household inputs conflict. + assert ( + row.sum() + min(sum(holdings), corporate) + <= pool + explicit_overrides + 0.03 + ) + if total_holdings <= corporate: + assert row.sum() + sum(holdings) <= pool + explicit_overrides + 0.03 + if consumed + uncovered <= pool: + # At compatible observations the whole pool is accounted for, + # including the dependant's separately disregarded holding. + assert row.sum() + total_holdings == pytest.approx(pool, abs=0.03) + conserved += 1 + assert conserved > 0 diff --git a/policyengine_uk/tests/test_uc_property_capital.py b/policyengine_uk/tests/test_uc_property_capital.py index 9c0ddff30a..2ada37f7c9 100644 --- a/policyengine_uk/tests/test_uc_property_capital.py +++ b/policyengine_uk/tests/test_uc_property_capital.py @@ -529,7 +529,7 @@ def test_capital_is_independent_of_person_and_benefit_unit_row_order( assert reordered[unit] == pytest.approx(baseline[unit]) -@pytest.mark.parametrize("complete_override", [0, 20_000]) +@pytest.mark.parametrize("complete_override", [0, 4_000, 10_000, 20_000]) @pytest.mark.parametrize( "financial_report", [None, -1, 10_000], ids=["omitted", "missing", "observed"] ) @@ -555,10 +555,45 @@ def test_complete_override_company_holding_cannot_leak_into_missing_unit_pool( assert _values(simulation, "uc_company_owner_treatment_applies")[0] assert _values(simulation, "uc_company_holding_disregard")[0] == 10_000 assert _values(simulation, "uc_company_capital")[0] == 0 - # The complete override supersedes any financial observation. Its asset - # composition is unknown, so the holding still leaves the pool separately. - assert _values(simulation, "household_uc_pool_company_holdings")[0] == 10_000 + # The complete override precedes regulation 77 and therefore includes its + # owner's holding. It consumes at least that holding from the pool once, + # including when zero or smaller than the holding, and supersedes the + # financial observation. + assert _values(simulation, "household_uc_pool_company_holdings")[0] == 0 + assert _values(simulation, "household_uc_reported_financial_capital")[0] == max( + complete_override, 10_000 + ) np.testing.assert_allclose( _values(simulation), - [max(0, complete_override - 10_000), max(0, 6_000 - complete_override)], + [ + max(0, complete_override - 10_000), + max(0, 16_000 - max(complete_override, 10_000)), + ], + ) + + +def test_owner_override_consumes_company_holding_once_in_twenty_thousand_savings_case(): + year = 2026 + situation = _situation( + [{"override": {year: 10_000}}, {"financial": {year: -1}}], + {"corporate_wealth": {year: 10_000}, "savings": {year: 20_000}}, + ) + for person in situation["people"].values(): + person["age"] = {year: 30} + person["is_claimant_or_partner"] = {year: True} + for unit in situation["benunits"].values(): + unit["would_claim_uc"] = {year: True} + situation["people"]["p0_0"].update( + { + "stands_as_sole_owner_or_partner_of_company": {year: True}, + "owned_company_carries_on_property_business": {year: True}, + "owned_company_holding_value": {year: 10_000}, + "owned_company_capital": {year: 0}, + } ) + simulation = Simulation(situation=situation) + # £30k household pool less the owner's £10k override leaves £20k. The + # override includes the holding; removing it again would leave £10k and + # incorrectly award the other unit £4,263.60 Universal Credit in 2026. + np.testing.assert_allclose(_values(simulation, year=year), [0, 20_000]) + assert _values(simulation, "universal_credit", year=year)[1] == 0 diff --git a/policyengine_uk/tests/test_uc_property_capital_mock.py b/policyengine_uk/tests/test_uc_property_capital_mock.py index f9bb8691f1..af03f8aacf 100644 --- a/policyengine_uk/tests/test_uc_property_capital_mock.py +++ b/policyengine_uk/tests/test_uc_property_capital_mock.py @@ -174,6 +174,32 @@ def test_mock_complete_override_zero_consumes_its_whole_capital_share(tmp_path): ) +def test_mock_owner_override_consumes_its_company_holding_once(tmp_path): + year = 2026 + simulation = _load_mock( + tmp_path, + _mock_year( + (-1, -1), + overrides=[10_000.0, -1.0], + household_inputs={"savings": 20_000, "corporate_wealth": 10_000}, + person_inputs={ + "stands_as_sole_owner_or_partner_of_company": [True, False], + "owned_company_carries_on_property_business": [True, False], + "owned_company_holding_value": [10_000.0, 0.0], + "owned_company_capital": [0.0, 0.0], + }, + year=year, + ), + ) + + # The owner's override already contains the holding. The other unit keeps + # the £20k residual and is ineligible for UC after the H5 input round trip. + np.testing.assert_allclose( + simulation.calculate("uc_assessable_capital", year), [0, 20_000] + ) + assert simulation.calculate("universal_credit", year)[1] == 0 + + def test_mock_financial_total_already_covers_lifetime_isa(tmp_path): simulation = _load_mock( tmp_path, diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_claimant_company_holding_disregard.py b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_claimant_company_holding_disregard.py new file mode 100644 index 0000000000..f0a481a92a --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_claimant_company_holding_disregard.py @@ -0,0 +1,21 @@ +from policyengine_uk.model_api import * + + +class uc_claimant_company_holding_disregard(Variable): + value_type = float + entity = BenUnit + label = "Universal Credit company holdings disregarded for the claimant and partner" + documentation = ( + "Sum of the claimant's and partner's disregarded company holdings " + "(uc_company_holding_disregard). A dependant's holding is not theirs." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/77" + + def formula(benunit, period, parameters): + return benunit.sum( + benunit.members("uc_company_holding_disregard", period) + * benunit.members("is_uc_claimant", period) + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py index c51dee69fe..bc50f62031 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py @@ -13,13 +13,14 @@ class household_uc_pool_company_holdings(Variable): "shared among claimants and partners without a financial observation " "or complete override. A claimant or partner's holding is already " "covered when their benunit supplies a nonnegative financial " - "observation (benunit_reported_capital) and no complete override, so " - "that observation is subtracted from the financial pool. All other " - "holdings leave the pool here, including those of complete-override " - "claimants and dependants. A complete uc_reported_capital override " - "consumes its amount from the financial pool under the inherited " - "convention, but its asset composition is unknown; its owner's holding " - "is also disregarded in their own assessment. " + "observation (benunit_reported_capital) or complete override. A complete " + "uc_reported_capital replaces ordinary sources before regulation 77 " + "company substitution, so it includes its claimant's and partner's " + "holdings. household_uc_reported_financial_capital consumes the override " + "or, if larger, their holdings capped at corporate_wealth. Removing " + "those holdings again here would count the same disregard twice. " + "All other holdings leave the pool here, including a dependant's " + "holding even when their benunit supplies a report or override. " "When financial reports and holdings are compatible, no benunit counts " "any part of a disregarded holding. An inconsistent financial report " "that omits a known holding can leave it in the residual pool. A holding " @@ -39,8 +40,8 @@ def formula(household, period, parameters): has_complete_override = person.benunit("uc_reported_capital", period) >= 0 claimant_or_partner = person("is_uc_claimant", period) off_reported_capital = ( - has_financial_report & ~has_complete_override & claimant_or_partner - ) + has_financial_report | has_complete_override + ) & claimant_or_partner holdings = household.sum( person("uc_company_holding_disregard", period) * ~off_reported_capital ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py index 877275cc4f..c3eb71d5f3 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py @@ -9,9 +9,16 @@ class household_uc_reported_financial_capital(Variable): "Amounts consumed from the household financial proxy, counted once " "per benefit unit: a nonnegative complete uc_reported_capital override " "takes precedence over benunit_reported_capital. Without an override, " - "use the nonnegative financial observation. Subtracting a complete " - "override retains the inherited residual-pool convention; it does " - "not imply that the override's asset composition is observed." + "use the nonnegative financial observation. A complete override " + "replaces ordinary sources before regulation 77 company substitution, " + "so it includes its claimant's and partner's disregarded holdings. " + "Consume the larger of the override and those holdings capped at " + "household corporate_wealth: holdings already covered by an override " + "must not leave the pool twice, while an override below them must not " + "leave their remainder for another unit to count. A dependant's " + "holding leaves the pool separately in household_uc_pool_company_holdings. " + "Subtracting complete overrides retains the inherited residual-pool " + "convention." ) definition_period = YEAR unit = GBP @@ -21,8 +28,14 @@ def formula(household, period, parameters): person = household.members financial_capital = person.benunit("benunit_reported_capital", period) complete_override = person.benunit("uc_reported_capital", period) + own_holdings = min_( + person.benunit("uc_claimant_company_holding_disregard", period), + max_(0, person.household("corporate_wealth", period)), + ) consumed_capital = where( - complete_override >= 0, complete_override, financial_capital + complete_override >= 0, + max_(complete_override, own_holdings), + financial_capital, ) return household.sum( max_(0, consumed_capital) * person("is_benunit_head", period) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py index f133095049..3d3cae98c2 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py @@ -20,7 +20,9 @@ class uc_reported_capital(Variable): "financial pool is shared among units with neither report; under this " "inherited convention, another unit's financial report or complete " "override can change a missing-report unit's capital. The complete " - "override consumes its amount from the financial pool, taking " + "override consumes its amount, or its claimant's and partner's " + "disregarded company holdings capped at corporate_wealth if larger, " + "from the financial pool, taking " "precedence over the same unit's financial report. " "This variable does not derive from the financial-only FRS total. " "An override set directly applies only in the supplied assessment year." From de27fcbb0785053b2d62eabe8da2ec666933733a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 15:00:57 -0400 Subject: [PATCH 08/10] Share one corporate-wealth budget across UC holding deductions --- changelog.d/2439.fixed.md | 2 +- .../means_test/capital/sources.yaml | 9 +- .../test_uc_company_override_properties.py | 194 +++++++++++++++--- .../tests/test_uc_property_capital_mock.py | 35 ++++ ...household_uc_company_holding_pool_scale.py | 29 +++ .../household_uc_pool_company_holdings.py | 23 ++- ...household_uc_reported_financial_capital.py | 19 +- .../universal_credit/uc_reported_capital.py | 3 +- 8 files changed, 268 insertions(+), 46 deletions(-) create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/household_uc_company_holding_pool_scale.py diff --git a/changelog.d/2439.fixed.md b/changelog.d/2439.fixed.md index 8df2e039a1..627f0297ef 100644 --- a/changelog.d/2439.fixed.md +++ b/changelog.d/2439.fixed.md @@ -1 +1 @@ -- Fixed Universal Credit capital assessment so reported financial savings do not suppress countable property, with assessment-year sale-expense and secured-debt valuation and property allocation independent of financial reports. Retained the inherited residual financial convention, including subtraction of complete overrides from the financial pool, and company substitution after ordinary-source overrides, and documented the legacy pension-proxy limitation. Complete overrides consume their owners' disregarded company holdings once, with a corporate-wealth-capped floor preventing holdings from leaking to other units when an override is smaller. +- Fixed Universal Credit capital assessment so reported financial savings do not suppress countable property, with assessment-year sale-expense and secured-debt valuation and property allocation independent of financial reports. Retained the inherited residual financial convention, including subtraction of complete overrides from the financial pool, and company substitution after ordinary-source overrides, and documented the legacy pension-proxy limitation. Complete overrides consume their owners' disregarded company holdings once. Override floors and separate holding deductions share one household corporate-wealth budget proportionally, preventing inconsistent holdings from consuming ordinary savings. diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml index 2a1b87629f..309d2f1229 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml @@ -6,8 +6,13 @@ description: > observed financial total, reduces this financial pool. Subtracting complete overrides retains the inherited allocation convention. An override precedes regulation 77 company substitution and includes its claimant's and partner's - holdings: consume at least their disregarded holdings capped at corporate_wealth - without removing them again from the pool. The residual is allocated by + holdings: consume at least their disregarded holdings after applying one + household corporate_wealth budget without removing them again from the pool. + If total holdings exceed nonnegative corporate_wealth, scale each person's + holding proportionally before override floors and separate pool deductions. + This is a defensive rule for inconsistent inputs, not a statutory determination; + regulation 77 substitution in the owner's own assessment is unchanged. + The residual is allocated by claimant/partner counts to benefit units with neither a financial observation nor a complete override, including units that do not receive UC. This allocation is an assumption diff --git a/policyengine_uk/tests/test_uc_company_override_properties.py b/policyengine_uk/tests/test_uc_company_override_properties.py index 52bfc2cabb..6bd802a0cb 100644 --- a/policyengine_uk/tests/test_uc_company_override_properties.py +++ b/policyengine_uk/tests/test_uc_company_override_properties.py @@ -2,11 +2,10 @@ A complete override describes capital before regulation 77 substitution. Its owner's holding must leave the financial pool once: through that override, -with a floor at the holding represented by corporate wealth. Dependants own -their holdings separately. Holdings above household corporate wealth are -inconsistent proxy observations, so full conservation is asserted only for -compatible inputs; the corporate-wealth cap and residual allocation are -checked for every input, including those inconsistent observations. +with a floor at its share of one household corporate-wealth budget. Dependants +own their holdings separately. The independent savings floor and accounting +identities cover inconsistent proxy observations as well as compatible inputs. +Regulation 77 still uses the original holding in its owner's own assessment. """ from itertools import product @@ -128,7 +127,6 @@ def test_company_overrides_conserve_capital_and_keep_holdings_out_of_other_units -1, 3 ) assert np.all(simulation.calculate("uc_company_capital", YEAR) == 0) - conserved = 0 for row, (specs, adults, holdings, dependants, corporate, savings) in zip( assessed, cases ): @@ -136,18 +134,31 @@ def test_company_overrides_conserve_capital_and_keep_holdings_out_of_other_units missing = [ "override" not in spec and spec.get("financial", -1) < 0 for spec in specs ] - consumed = sum( - max(spec["override"], min(holding, corporate)) + explicit = [ + spec.get("override", max(0, spec.get("financial", -1))) for spec in specs + ] + explicit_consumption = sum(explicit) + # Allocate the single corporate budget by ownership shares. This + # oracle never applies a fresh corporate cap to an individual route. + total_holdings = sum(holdings) + dependants + budget = min(corporate, total_holdings) + scale = budget / total_holdings if total_holdings else 0 + effective_holdings = [holding * scale for holding in holdings] + extra_holding_draw = dependants * scale + sum( + holding + if absent + else max(0, holding - spec["override"]) if "override" in spec - else max(0, spec.get("financial", -1)) - for spec, holding in zip(specs, holdings) + else 0 + for holding, spec, absent in zip(effective_holdings, specs, missing) ) - # Only unreported owners and dependants leave the pool separately; - # reported claimants' holdings already belong to their report. - uncovered = dependants + sum( - holding for holding, absent in zip(holdings, missing) if absent + residual = max(0, pool - explicit_consumption - extra_holding_draw) + missing_capital = sum( + capital for capital, absent in zip(row, missing) if absent ) - residual = max(0, pool - consumed - min(uncovered, corporate)) + # Independently of holder grouping or the allocation oracle, holdings + # cannot spend savings. Explicit reports may consume them as before. + assert missing_capital >= max(0, savings - explicit_consumption) - 0.03 missing_adults = sum(count for count, absent in zip(adults, missing) if absent) # This identity proves both directions: no owner's holding leaks to a # missing unit, and an override cannot remove that holding twice. @@ -156,19 +167,148 @@ def test_company_overrides_conserve_capital_and_keep_holdings_out_of_other_units assert capital == pytest.approx( residual * count / missing_adults, abs=0.03 ) - total_holdings = sum(holdings) + dependants explicit_overrides = sum(spec.get("override", 0) for spec in specs) # Only holdings represented by corporate wealth can be conserved # against that pool when the person and household inputs conflict. - assert ( - row.sum() + min(sum(holdings), corporate) - <= pool + explicit_overrides + 0.03 + assert row.sum() + sum(effective_holdings) <= pool + explicit_overrides + 0.03 + # Account for what regulation 77 actually removes from reporting + # owners, without scaling that substitution. This identity also + # covers inconsistent observations and exhausted residual pools. + owner_substitution = sum( + min(holding, reported) + for holding, reported, absent in zip(holdings, explicit, missing) + if not absent + ) + assert row.sum() + owner_substitution + extra_holding_draw == pytest.approx( + max(pool, explicit_consumption + extra_holding_draw), abs=0.03 + ) + + +def _pool_property_situation(cases): + """Batch households and retain their unreported-unit output positions.""" + people, units, households, residual_rows = {}, {}, {}, [] + for index, (specs, corporate, savings) in enumerate(cases): + prefix = f"h{index}" + members, missing_rows = [], [] + for unit_index, (report, adults, dependants) in enumerate(specs): + names = [] + for role, holdings in (("adult", adults), ("child", dependants)): + for person_index, holding in enumerate(holdings): + name = f"{prefix}_u{unit_index}_{role}{person_index}" + people[name] = _company_person(holding, dependant=role == "child") + names.append(name) + unit = {"members": names, "would_claim_uc": {YEAR: True}} + if report is None: + missing_rows.append((len(units), len(adults))) + else: + unit[report] = {YEAR: 0} + units[f"{prefix}_u{unit_index}"] = unit + members.extend(names) + households[prefix] = { + "members": members, + "savings": {YEAR: savings}, + "corporate_wealth": {YEAR: corporate}, + } + residual_rows.append(missing_rows) + return { + "people": people, + "benunits": units, + "households": households, + }, residual_rows + + +@pytest.mark.parametrize("split_route", ["override", "dependant"]) +def test_increasing_or_splitting_disregarded_holdings_preserves_savings(split_route): + """Once corporate wealth is exhausted, holdings cannot eat ordinary savings.""" + cases, comparisons = [], [] + for corporate, savings, multiplier in product( + (0, 1_000, 4_000, 10_000), (0, 6_000, 20_000), (1, 2, 3) + ): + increased = corporate * multiplier + # Keep the target and zero financial-report unit fixed while one + # override owner's holding grows, or is split with another holder. + arrangements = [ + [("uc_reported_capital", (corporate,), ())], + [("uc_reported_capital", (increased,), ())], + ] + if split_route == "override": + arrangements.append( + [ + ("uc_reported_capital", (increased * 0.6,), ()), + ("uc_reported_capital", (increased * 0.4,), ()), + ] + ) + else: + arrangements.append( + [("uc_reported_capital", (increased * 0.6,), (increased * 0.4,))] + ) + start = len(cases) + for owners in arrangements: + cases.append( + ( + owners + [("benunit_reported_capital", (0,), ()), (None, (0,), ())], + corporate, + savings, + ) + ) + comparisons.append((start, savings)) + situation, residual_rows = _pool_property_situation(cases) + capital = np.asarray( + Simulation(situation=situation).calculate("uc_assessable_capital", YEAR) + ) + residual = [sum(capital[row] for row, _ in rows) for rows in residual_rows] + for start, savings in comparisons: + baseline, increased, split = residual[start : start + 3] + assert baseline == pytest.approx(savings, abs=0.03) + assert increased >= baseline - 0.03 + assert split >= increased - 0.03 + assert [increased, split] == pytest.approx([savings, savings], abs=0.03) + + +def test_regrouping_holdings_between_holders_and_units_preserves_residual_pool(): + """Holder counts, reporting routes and dependants share one corporate budget.""" + cases, expected = [], [] + for corporate, savings, total in product( + (0, 1_000, 4_000, 10_000), (0, 6_000, 20_000), (0, 4_000, 10_000, 20_000) + ): + first, second = total * 0.6, total * 0.4 + arrangements = ( + [("uc_reported_capital", (total,), ())], + [("uc_reported_capital", (first, second), ())], + [ + ("uc_reported_capital", (first,), ()), + ("uc_reported_capital", (second,), ()), + ], + [("uc_reported_capital", (first,), (second,))], + [ + ("benunit_reported_capital", (0,), (first,)), + ("benunit_reported_capital", (0,), (second,)), + ], + [("uc_reported_capital", (first,), ()), (None, (second,), ())], + [(None, (first,), (second,))], + [(None, (first,), ()), (None, (second,), ())], ) - if total_holdings <= corporate: - assert row.sum() + sum(holdings) <= pool + explicit_overrides + 0.03 - if consumed + uncovered <= pool: - # At compatible observations the whole pool is accounted for, - # including the dependant's separately disregarded holding. - assert row.sum() + total_holdings == pytest.approx(pool, abs=0.03) - conserved += 1 - assert conserved > 0 + for owners in arrangements: + cases.append( + ( + owners + [("benunit_reported_capital", (0,), ()), (None, (0,), ())], + corporate, + savings, + ) + ) + expected.append(savings + max(0, corporate - total)) + situation, residual_rows = _pool_property_situation(cases) + capital = np.asarray( + Simulation(situation=situation).calculate("uc_assessable_capital", YEAR) + ) + residual = [] + for rows, pool in zip(residual_rows, expected): + residual.append(sum(capital[row] for row, _ in rows)) + assert residual[-1] == pytest.approx(pool, abs=0.03) + adults = sum(count for _, count in rows) + for row, count in rows: + assert capital[row] == pytest.approx(pool * count / adults, abs=0.03) + groups = np.asarray(residual).reshape(-1, len(arrangements)) + np.testing.assert_allclose( + groups, np.repeat(groups[:, :1], len(arrangements), axis=1), atol=0.03 + ) diff --git a/policyengine_uk/tests/test_uc_property_capital_mock.py b/policyengine_uk/tests/test_uc_property_capital_mock.py index af03f8aacf..6f32099a88 100644 --- a/policyengine_uk/tests/test_uc_property_capital_mock.py +++ b/policyengine_uk/tests/test_uc_property_capital_mock.py @@ -200,6 +200,41 @@ def test_mock_owner_override_consumes_its_company_holding_once(tmp_path): assert simulation.calculate("universal_credit", year)[1] == 0 +@pytest.mark.parametrize("layout", ["two-overrides", "override-and-dependant"]) +def test_mock_company_holding_routes_share_one_corporate_budget(tmp_path, layout): + """The two review regressions preserve £20k savings after real H5 loading.""" + year = 2026 + dependant = layout == "override-and-dependant" + holdings = [6_000.0, 4_000.0, 0.0, 0.0] if dependant else [6_000.0, 4_000.0, 0.0] + flags = [holding > 0 for holding in holdings] + simulation = _load_mock( + tmp_path, + _mock_year( + (-1, 0 if dependant else -1, -1), + adults=[2, 1, 1] if dependant else [1, 1, 1], + overrides=[0.0, -1.0 if dependant else 0.0, -1.0], + household_inputs={"savings": 20_000, "corporate_wealth": 4_000}, + person_inputs={ + "age": [30, 10, 30, 30] if dependant else [30, 30, 30], + "is_claimant_or_partner": [True, False, True, True] + if dependant + else [True, True, True], + "stands_as_sole_owner_or_partner_of_company": flags, + "owned_company_carries_on_property_business": flags, + "owned_company_holding_value": holdings, + "owned_company_capital": [0.0] * len(holdings), + }, + year=year, + ), + ) + + np.testing.assert_allclose( + simulation.calculate("uc_assessable_capital", year), [0, 0, 20_000] + ) + assert not simulation.calculate("is_uc_eligible", year)[2] + assert simulation.calculate("universal_credit", year)[2] == 0 + + def test_mock_financial_total_already_covers_lifetime_isa(tmp_path): simulation = _load_mock( tmp_path, diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_company_holding_pool_scale.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_company_holding_pool_scale.py new file mode 100644 index 0000000000..5ab3b0ad5b --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_company_holding_pool_scale.py @@ -0,0 +1,29 @@ +from policyengine_uk.model_api import * + + +class household_uc_company_holding_pool_scale(Variable): + value_type = float + entity = Household + label = "Share of company holdings represented in the UC household financial pool" + documentation = ( + "Common proportional scale for disregarded company holdings when " + "deducting them from the household financial pool. Total holdings " + "share one budget of nonnegative corporate_wealth across override " + "floors and separate pool deductions. This allocation is independent " + "of row order and treats holders equally. It is a defensive rule for " + "inconsistent inputs, not a statutory determination. It does not " + "change regulation 77 substitution in the owner's own assessment." + ) + definition_period = YEAR + unit = "/1" + + def formula(household, period, parameters): + holdings = household.sum( + household.members("uc_company_holding_disregard", period) + ) + corporate_wealth = max_(0, household("corporate_wealth", period)) + return where( + holdings > 0, + min_(1, corporate_wealth / where(holdings > 0, holdings, 1)), + 1, + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py index bc50f62031..67aa7bb5f1 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py @@ -17,17 +17,23 @@ class household_uc_pool_company_holdings(Variable): "uc_reported_capital replaces ordinary sources before regulation 77 " "company substitution, so it includes its claimant's and partner's " "holdings. household_uc_reported_financial_capital consumes the override " - "or, if larger, their holdings capped at corporate_wealth. Removing " + "or, if larger, their effective holdings after the shared household " + "corporate-wealth budget is applied. Removing " "those holdings again here would count the same disregard twice. " "All other holdings leave the pool here, including a dependant's " "holding even when their benunit supplies a report or override. " "When financial reports and holdings are compatible, no benunit counts " "any part of a disregarded holding. An inconsistent financial report " "that omits a known holding can leave it in the residual pool. A holding " - "is shares, held within the household's " - "corporate_wealth, so no more than that is taken out: a holding " - "entered without matching corporate_wealth cannot remove savings or " - "property." + "is shares, held within the household's corporate_wealth. All " + "household holdings share one nonnegative corporate_wealth budget " + "through household_uc_company_holding_pool_scale: when their total " + "exceeds it, scale each person's holding proportionally before " + "either the override floor or this deduction. This is a defensive " + "rule for inconsistent inputs, not a statutory determination. The " + "shared budget prevents holdings from removing ordinary savings or " + "property. It does not change regulation 77 substitution in the " + "owner's own assessment." ) definition_period = YEAR unit = GBP @@ -43,7 +49,8 @@ def formula(household, period, parameters): has_financial_report | has_complete_override ) & claimant_or_partner holdings = household.sum( - person("uc_company_holding_disregard", period) * ~off_reported_capital + person("uc_company_holding_disregard", period) + * person.household("household_uc_company_holding_pool_scale", period) + * ~off_reported_capital ) - corporate_wealth = max_(0, household("corporate_wealth", period)) - return min_(holdings, corporate_wealth) + return holdings diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py index c3eb71d5f3..84e6d7c496 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py @@ -12,13 +12,19 @@ class household_uc_reported_financial_capital(Variable): "use the nonnegative financial observation. A complete override " "replaces ordinary sources before regulation 77 company substitution, " "so it includes its claimant's and partner's disregarded holdings. " - "Consume the larger of the override and those holdings capped at " - "household corporate_wealth: holdings already covered by an override " + "Consume the larger of the override and those holdings after applying " + "household_uc_company_holding_pool_scale. All household holdings share " + "one nonnegative corporate_wealth budget: if their total exceeds it, " + "scale each person's holding proportionally before either this " + "override floor or the separate pool deduction. This is a defensive " + "rule for inconsistent inputs, not a statutory determination. " + "Holdings already covered by an override " "must not leave the pool twice, while an override below them must not " "leave their remainder for another unit to count. A dependant's " "holding leaves the pool separately in household_uc_pool_company_holdings. " "Subtracting complete overrides retains the inherited residual-pool " - "convention." + "convention. The scale changes only deductions from the household " + "pool, not regulation 77 substitution in the owner's own assessment." ) definition_period = YEAR unit = GBP @@ -28,10 +34,9 @@ def formula(household, period, parameters): person = household.members financial_capital = person.benunit("benunit_reported_capital", period) complete_override = person.benunit("uc_reported_capital", period) - own_holdings = min_( - person.benunit("uc_claimant_company_holding_disregard", period), - max_(0, person.household("corporate_wealth", period)), - ) + own_holdings = person.benunit( + "uc_claimant_company_holding_disregard", period + ) * person.household("household_uc_company_holding_pool_scale", period) consumed_capital = where( complete_override >= 0, max_(complete_override, own_holdings), diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py index 3d3cae98c2..b508828d55 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py @@ -21,7 +21,8 @@ class uc_reported_capital(Variable): "inherited convention, another unit's financial report or complete " "override can change a missing-report unit's capital. The complete " "override consumes its amount, or its claimant's and partner's " - "disregarded company holdings capped at corporate_wealth if larger, " + "disregarded company holdings after their proportional share of the " + "household corporate_wealth budget if larger, " "from the financial pool, taking " "precedence over the same unit's financial report. " "This variable does not derive from the financial-only FRS total. " From ee15685cea7eec43c09d71b83636bcd109410256 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 15:00:59 -0400 Subject: [PATCH 09/10] Suppress slow-generation health check for UC row-order property --- policyengine_uk/tests/test_uc_property_capital.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_uc_property_capital.py b/policyengine_uk/tests/test_uc_property_capital.py index 2ada37f7c9..02435651c5 100644 --- a/policyengine_uk/tests/test_uc_property_capital.py +++ b/policyengine_uk/tests/test_uc_property_capital.py @@ -16,7 +16,7 @@ import numpy as np import pytest -from hypothesis import example, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -498,7 +498,7 @@ def test_property_allocation_conserves_value_including_complete_override_shares( assert assessed.sum() + consumed == pytest.approx(total_property, abs=0.03) -@PROPERTY_SETTINGS +@settings(PROPERTY_SETTINGS, suppress_health_check=[HealthCheck.too_slow]) @given( reports=st.lists(st.sampled_from([-1, 0, 6_000]), min_size=3, max_size=3), property_value=st.integers(0, 100_000), From 19b195de8be7c8973c68f8136826154b6272f527 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 17:43:07 -0400 Subject: [PATCH 10/10] Bound invalid company holdings before the shared UC budget --- changelog.d/2439.fixed.md | 2 +- .../means_test/capital/sources.yaml | 7 +- .../test_uc_company_override_properties.py | 110 +++++++++++++++++- ...household_uc_company_holding_pool_scale.py | 15 ++- .../household_uc_pool_company_holdings.py | 14 ++- ...household_uc_reported_financial_capital.py | 20 +++- 6 files changed, 149 insertions(+), 19 deletions(-) diff --git a/changelog.d/2439.fixed.md b/changelog.d/2439.fixed.md index 627f0297ef..74098ea3f7 100644 --- a/changelog.d/2439.fixed.md +++ b/changelog.d/2439.fixed.md @@ -1 +1 @@ -- Fixed Universal Credit capital assessment so reported financial savings do not suppress countable property, with assessment-year sale-expense and secured-debt valuation and property allocation independent of financial reports. Retained the inherited residual financial convention, including subtraction of complete overrides from the financial pool, and company substitution after ordinary-source overrides, and documented the legacy pension-proxy limitation. Complete overrides consume their owners' disregarded company holdings once. Override floors and separate holding deductions share one household corporate-wealth budget proportionally, preventing inconsistent holdings from consuming ordinary savings. +- Fixed Universal Credit capital assessment so reported financial savings do not suppress countable property, with assessment-year sale-expense and secured-debt valuation and property allocation independent of financial reports. Retained the inherited residual financial convention, including subtraction of complete overrides from the financial pool, and company substitution after ordinary-source overrides, and documented the legacy pension-proxy limitation. Complete overrides consume their owners' disregarded company holdings once. Override floors and separate holding deductions share one household corporate-wealth budget proportionally, preventing inconsistent holdings from consuming ordinary savings. Bounded each holding before that budget to isolate negative and infinite inputs from other units' capital. diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml index 309d2f1229..e9d973704b 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml @@ -8,8 +8,11 @@ description: > regulation 77 company substitution and includes its claimant's and partner's holdings: consume at least their disregarded holdings after applying one household corporate_wealth budget without removing them again from the pool. - If total holdings exceed nonnegative corporate_wealth, scale each person's - holding proportionally before override floors and separate pool deductions. + First clip each person's holding to [0, max(0, corporate_wealth)], with + positive infinity mapped to the upper bound and negative infinity to zero. + If total bounded holdings exceed nonnegative corporate_wealth, scale each + person's bounded holding proportionally before override floors and separate + pool deductions. This is a defensive rule for inconsistent inputs, not a statutory determination; regulation 77 substitution in the owner's own assessment is unchanged. The residual is allocated by diff --git a/policyengine_uk/tests/test_uc_company_override_properties.py b/policyengine_uk/tests/test_uc_company_override_properties.py index 6bd802a0cb..735847e6b8 100644 --- a/policyengine_uk/tests/test_uc_company_override_properties.py +++ b/policyengine_uk/tests/test_uc_company_override_properties.py @@ -12,6 +12,8 @@ import numpy as np import pytest +from hypothesis import example, given, settings +from hypothesis import strategies as st from policyengine_uk import Simulation @@ -138,13 +140,18 @@ def test_company_overrides_conserve_capital_and_keep_holdings_out_of_other_units spec.get("override", max(0, spec.get("financial", -1))) for spec in specs ] explicit_consumption = sum(explicit) - # Allocate the single corporate budget by ownership shares. This - # oracle never applies a fresh corporate cap to an individual route. - total_holdings = sum(holdings) + dependants + # Invalid holdings are bounded per person, before grouping into units. + # Allocate one budget by those ownership shares; never recap a route. + bounded_holdings = [ + sum(min(corporate, value) for value in group) + for group in (LAYOUTS[layout][0], LAYOUTS[layout][2], ()) + ] + bounded_dependants = sum(min(corporate, value) for value in LAYOUTS[layout][1]) + total_holdings = sum(bounded_holdings) + bounded_dependants budget = min(corporate, total_holdings) scale = budget / total_holdings if total_holdings else 0 - effective_holdings = [holding * scale for holding in holdings] - extra_holding_draw = dependants * scale + sum( + effective_holdings = [holding * scale for holding in bounded_holdings] + extra_holding_draw = bounded_dependants * scale + sum( holding if absent else max(0, holding - spec["override"]) @@ -312,3 +319,96 @@ def test_regrouping_holdings_between_holders_and_units_preserves_residual_pool() np.testing.assert_allclose( groups, np.repeat(groups[:, :1], len(arrangements), axis=1), atol=0.03 ) + + +@pytest.mark.parametrize( + "specs, rows, expected_capital, expected_uc", + [ + ( + [(None, (np.inf,), ()), (None, (0,), ())], + [0, 1], + [10_000, 10_000], + [4_263.60, 4_263.60], + ), + ( + [("benunit_reported_capital", (0,), (np.inf,)), (None, (0,), ())], + [1], + [20_000], + [0], + ), + ( + [ + ("uc_reported_capital", (10_000,), ()), + ("benunit_reported_capital", (-6_000,), ()), + (None, (0,), ()), + ], + [0, 1, 2], + [0, 6_000, 20_000], + [5_098.80, 5_098.80, 0], + ), + ], + ids=["infinite-owner", "infinite-dependant", "negative-offset"], +) +def test_invalid_holdings_cannot_contaminate_other_units_capital( + specs, rows, expected_capital, expected_uc +): + """Preserve sound parent outputs without changing raw reg. 77 substitution. + + An infinite dependant already makes its own reporting unit's raw + substitution undefined; this regression isolates the unrelated unit. + """ + situation, _ = _pool_property_situation([(specs, 4_000, 20_000)]) + simulation = Simulation(situation=situation) + capital = simulation.calculate("uc_assessable_capital", YEAR)[rows] + uc = simulation.calculate("universal_credit", YEAR)[rows] + assert np.isfinite(capital).all() + assert np.isfinite(uc).all() + np.testing.assert_allclose(capital, expected_capital, atol=0.03) + np.testing.assert_allclose(uc, expected_uc, atol=0.03) + for variable in ( + "household_uc_reported_financial_capital", + "household_uc_pool_company_holdings", + ): + assert np.isfinite(simulation.calculate(variable, YEAR)).all() + + +@settings(max_examples=16, deadline=None, derandomize=True) +@example(holdings=(np.inf, -np.inf, -6_000, 1e30), corporate=4_000, savings=20_000) +@example(holdings=(np.inf, 0, 0, 0), corporate=0, savings=20_000) +@given( + holdings=st.tuples( + *(st.floats(width=32, allow_infinity=True, allow_nan=False) for _ in range(4)) + ), + corporate=st.floats(0, 30_000, allow_nan=False, allow_infinity=False), + savings=st.floats(0, 50_000, allow_nan=False, allow_infinity=False), +) +def test_invalid_holdings_share_a_finite_budget_and_preserve_savings( + holdings, corporate, savings +): + """Invalid pool inputs cannot spend savings or poison any assessed capital. + + Non-finite holdings belong to missing-report units, including a dependant. + Raw regulation 77 substitution for an infinite holding in a reporting unit + is an inherited limitation outside this household-budget contract. + """ + specs = [ + ("uc_reported_capital", (10_000,), ()), + ("benunit_reported_capital", (-6_000,), ()), + (None, holdings[:2], holdings[2:]), + (None, (0,), ()), + ] + situation, residual_rows = _pool_property_situation([(specs, corporate, savings)]) + simulation = Simulation(situation=situation) + capital = np.asarray(simulation.calculate("uc_assessable_capital", YEAR)) + assert np.isfinite(capital).all() + deductions = sum( + simulation.calculate(variable, YEAR)[0] + for variable in ( + "household_uc_reported_financial_capital", + "household_uc_pool_company_holdings", + ) + ) + assert np.isfinite(deductions) + assert 0 <= deductions <= max(0, corporate) + 0.03 + missing_capital = sum(capital[row] for row, _ in residual_rows[0]) + assert missing_capital >= savings - 0.03 diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_company_holding_pool_scale.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_company_holding_pool_scale.py index 5ab3b0ad5b..79a152ab34 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_company_holding_pool_scale.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_company_holding_pool_scale.py @@ -7,7 +7,10 @@ class household_uc_company_holding_pool_scale(Variable): label = "Share of company holdings represented in the UC household financial pool" documentation = ( "Common proportional scale for disregarded company holdings when " - "deducting them from the household financial pool. Total holdings " + "deducting them from the household financial pool. Before budgeting, " + "clip each person's holding to [0, max(0, corporate_wealth)], mapping " + "positive infinity to the upper bound and negative infinity to zero. " + "Total bounded holdings " "share one budget of nonnegative corporate_wealth across override " "floors and separate pool deductions. This allocation is independent " "of row order and treats holders equally. It is a defensive rule for " @@ -18,10 +21,14 @@ class household_uc_company_holding_pool_scale(Variable): unit = "/1" def formula(household, period, parameters): - holdings = household.sum( - household.members("uc_company_holding_disregard", period) - ) corporate_wealth = max_(0, household("corporate_wealth", period)) + person = household.members + holding = person("uc_company_holding_disregard", period) + upper_bound = max_(0, person.household("corporate_wealth", period)) + finite_holding = np.where( + np.isfinite(holding), holding, np.where(holding > 0, upper_bound, 0) + ) + holdings = household.sum(np.clip(finite_holding, 0, upper_bound)) return where( holdings > 0, min_(1, corporate_wealth / where(holdings > 0, holdings, 1)), diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py index 67aa7bb5f1..a59ed03df3 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py @@ -25,8 +25,11 @@ class household_uc_pool_company_holdings(Variable): "When financial reports and holdings are compatible, no benunit counts " "any part of a disregarded holding. An inconsistent financial report " "that omits a known holding can leave it in the residual pool. A holding " - "is shares, held within the household's corporate_wealth. All " - "household holdings share one nonnegative corporate_wealth budget " + "is shares, held within the household's corporate_wealth. Each " + "person's holding is first clipped to [0, max(0, corporate_wealth)], " + "with positive infinity mapped to the upper bound and negative infinity " + "to zero before any multiplication. All household holdings then share " + "one nonnegative corporate_wealth budget " "through household_uc_company_holding_pool_scale: when their total " "exceeds it, scale each person's holding proportionally before " "either the override floor or this deduction. This is a defensive " @@ -48,8 +51,13 @@ def formula(household, period, parameters): off_reported_capital = ( has_financial_report | has_complete_override ) & claimant_or_partner + holding = person("uc_company_holding_disregard", period) + upper_bound = max_(0, person.household("corporate_wealth", period)) + finite_holding = np.where( + np.isfinite(holding), holding, np.where(holding > 0, upper_bound, 0) + ) holdings = household.sum( - person("uc_company_holding_disregard", period) + np.clip(finite_holding, 0, upper_bound) * person.household("household_uc_company_holding_pool_scale", period) * ~off_reported_capital ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py index 84e6d7c496..19deb365f2 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py @@ -13,7 +13,10 @@ class household_uc_reported_financial_capital(Variable): "replaces ordinary sources before regulation 77 company substitution, " "so it includes its claimant's and partner's disregarded holdings. " "Consume the larger of the override and those holdings after applying " - "household_uc_company_holding_pool_scale. All household holdings share " + "household_uc_company_holding_pool_scale. Each person's holding is " + "first clipped to [0, max(0, corporate_wealth)], with positive infinity " + "mapped to the upper bound and negative infinity to zero before any " + "multiplication. All household holdings share " "one nonnegative corporate_wealth budget: if their total exceeds it, " "scale each person's holding proportionally before either this " "override floor or the separate pool deduction. This is a defensive " @@ -34,9 +37,18 @@ def formula(household, period, parameters): person = household.members financial_capital = person.benunit("benunit_reported_capital", period) complete_override = person.benunit("uc_reported_capital", period) - own_holdings = person.benunit( - "uc_claimant_company_holding_disregard", period - ) * person.household("household_uc_company_holding_pool_scale", period) + holding = person("uc_company_holding_disregard", period) + upper_bound = max_(0, person.household("corporate_wealth", period)) + finite_holding = np.where( + np.isfinite(holding), holding, np.where(holding > 0, upper_bound, 0) + ) + bounded_holdings = np.clip(finite_holding, 0, upper_bound) + # Match the claimant holding variable's storage precision before scaling. + own_holdings = person.benunit.sum( + bounded_holdings * person("is_uc_claimant", period) + ).astype(holding.dtype) * person.household( + "household_uc_company_holding_pool_scale", period + ) consumed_capital = where( complete_override >= 0, max_(complete_override, own_holdings),