diff --git a/changelog.d/2439.fixed.md b/changelog.d/2439.fixed.md new file mode 100644 index 0000000000..74098ea3f7 --- /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 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/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..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 @@ -8,7 +8,11 @@ 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, + 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/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..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 @@ -1,49 +1,44 @@ 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. + 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. An override precedes + 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. + 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 + 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 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 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/lifetime_isa_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/lifetime_isa_capital.yaml index 723937bda3..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,6 +212,9 @@ members: [parent_1, parent_2, adult_child] savings: 9_000 output: + # 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 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..95951f1d60 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml @@ -0,0 +1,125 @@ +- 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 + +- 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] + +- 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/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..b1884bf6eb 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. 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. 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_company_override_properties.py b/policyengine_uk/tests/test_uc_company_override_properties.py new file mode 100644 index 0000000000..735847e6b8 --- /dev/null +++ b/policyengine_uk/tests/test_uc_company_override_properties.py @@ -0,0 +1,414 @@ +"""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 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 + +import numpy as np +import pytest +from hypothesis import example, given, settings +from hypothesis import strategies as st + +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) + 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 + ] + explicit = [ + spec.get("override", max(0, spec.get("financial", -1))) for spec in specs + ] + explicit_consumption = sum(explicit) + # 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 bounded_holdings] + extra_holding_draw = bounded_dependants * scale + sum( + holding + if absent + else max(0, holding - spec["override"]) + if "override" in spec + else 0 + for holding, spec, absent in zip(effective_holdings, specs, missing) + ) + residual = max(0, pool - explicit_consumption - extra_holding_draw) + missing_capital = sum( + capital for capital, absent in zip(row, missing) if absent + ) + # 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. + for capital, count, absent in zip(row, adults, missing): + if absent: + assert capital == pytest.approx( + residual * count / missing_adults, abs=0.03 + ) + 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() + 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,), ())], + ) + 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 + ) + + +@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/tests/test_uc_property_capital.py b/policyengine_uk/tests/test_uc_property_capital.py new file mode 100644 index 0000000000..02435651c5 --- /dev/null +++ b/policyengine_uk/tests/test_uc_property_capital.py @@ -0,0 +1,599 @@ +"""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. 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 + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, 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_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} + ) + 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 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]) +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)) + + +@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)) + 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( + 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), + 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) + + +@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), + 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]) + + +@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"] +) +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 + # 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, 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 new file mode 100644 index 0000000000..6f32099a88 --- /dev/null +++ b/policyengine_uk/tests/test_uc_property_capital_mock.py @@ -0,0 +1,388 @@ +"""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_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 + + +@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, + _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/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_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..79a152ab34 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_company_holding_pool_scale.py @@ -0,0 +1,36 @@ +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. 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 " + "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): + 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)), + 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 91862290a9..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 @@ -9,17 +9,34 @@ 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 " - "corporate_wealth, so no more than that is taken out: a holding " - "entered without matching corporate_wealth cannot remove savings or " - "property." + "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) 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 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. 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 " + "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 @@ -28,11 +45,20 @@ class household_uc_pool_company_holdings(Variable): def formula(household, period, parameters): person = household.members - reported = person.benunit("uc_reported_capital", period) >= 0 + 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 = reported & claimant_or_partner + 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) * ~off_reported_capital + np.clip(finite_holding, 0, upper_bound) + * 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_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 new file mode 100644 index 0000000000..19deb365f2 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py @@ -0,0 +1,59 @@ +from policyengine_uk.model_api import * + + +class household_uc_reported_financial_capital(Variable): + value_type = float + entity = Household + label = "Household UC capital reports consumed from the financial pool" + documentation = ( + "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. 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 after applying " + "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 " + "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. The scale changes only deductions from the household " + "pool, not regulation 77 substitution in the owner's own assessment." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + + def formula(household, period, parameters): + person = household.members + financial_capital = person.benunit("benunit_reported_capital", period) + complete_override = person.benunit("uc_reported_capital", 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), + financial_capital, + ) + return household.sum( + max_(0, consumed_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..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 @@ -6,22 +6,27 @@ 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, 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. " + "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 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 " + "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 " + "the claimant/partner's share of company capital." ) definition_period = YEAR unit = GBP @@ -29,55 +34,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) + # 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 + - 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..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 @@ -6,17 +6,27 @@ 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 " + "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 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 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. " + "An override set directly applies only in the supplied assessment year." ) definition_period = YEAR unit = GBP @@ -28,4 +38,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..f99b7c3d78 100644 --- a/policyengine_uk/variables/household/wealth/benunit_reported_capital.py +++ b/policyengine_uk/variables/household/wealth/benunit_reported_capital.py @@ -8,16 +8,21 @@ 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 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 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" )