From 7a5076c4a13b6949a98177961c6c46702b262834 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 17:28:09 -0400 Subject: [PATCH 01/38] Value means-tested capital less sale expenses and secured debt UC Regs 2013 reg. 49(1) and its legacy equivalents (HB Regs 2006 reg. 47, HB (SPC) Regs 2006 reg. 45, IS Regs 1987 reg. 49, JSA Regs 1996 reg. 111, ESA Regs 2008 reg. 113, SPC Regs 2002 reg. 19) calculate capital at market or surrender value less 10% where a sale would incur expenses and less any encumbrance secured on it. The model summed every capital source at full value. Each programme gets a capital.sale_expenses node (rate 0.1 and the sources whose sale incurs expenses: land, property and corporate_wealth, not cash savings). A shared helper applies the 10% before the encumbrance and never values an encumbered asset below nil. New household inputs carry debt secured on other residential property, non-residential property and land; the Enhanced FRS has none, so they default to zero. Co-Authored-By: Claude Opus 5.5 --- changelog.d/uc-capital-sale-expenses.fixed.md | 1 + policyengine_uk/data/uprating_indices.yaml | 3 + .../income/capital/sale_expenses/rate.yaml | 14 ++ .../income/capital/sale_expenses/sources.yaml | 22 ++ .../income/capital/sale_expenses/rate.yaml | 14 ++ .../income/capital/sale_expenses/sources.yaml | 26 +++ .../capital/sale_expenses/rate.yaml | 16 ++ .../capital/sale_expenses/sources.yaml | 28 +++ .../capital/sale_expenses/rate.yaml | 14 ++ .../capital/sale_expenses/sources.yaml | 26 +++ .../income/capital/sale_expenses/rate.yaml | 14 ++ .../income/capital/sale_expenses/sources.yaml | 26 +++ .../capital/sale_expenses/rate.yaml | 16 ++ .../capital/sale_expenses/sources.yaml | 31 +++ .../family/esa_income/esa_income_capital.yaml | 8 +- .../housing_benefit_capital.yaml | 35 ++- .../income_support_capital.yaml | 8 +- .../family/jsa_income/jsa_income_capital.yaml | 8 +- .../universal_credit/is_UC_eligible.yaml | 12 +- .../baseline/gov/dwp/capital_valuation.yaml | 218 ++++++++++++++++++ .../pension_credit_deemed_income.yaml | 6 +- .../pension_credit/pension_credit_income.yaml | 26 ++- .../test_capital_valuation_properties.py | 182 +++++++++++++++ .../tests/test_stock_capital_periods.py | 8 +- policyengine_uk/utils/capital_valuation.py | 40 ++++ .../gov/dwp/esa_income_assessable_capital.py | 10 +- .../housing_benefit_assessable_capital.py | 10 +- .../dwp/income_support_assessable_capital.py | 10 +- .../gov/dwp/jsa_income_assessable_capital.py | 10 +- .../pension_credit_assessable_capital.py | 8 +- .../universal_credit/uc_assessable_capital.py | 11 +- .../non_residential_property_secured_debt.py | 17 ++ ...other_residential_property_secured_debt.py | 17 ++ .../input/owned_land_secured_debt.py | 16 ++ 34 files changed, 869 insertions(+), 42 deletions(-) create mode 100644 changelog.d/uc-capital-sale-expenses.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/rate.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/rate.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/rate.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/rate.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/rate.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml create mode 100644 policyengine_uk/tests/test_capital_valuation_properties.py create mode 100644 policyengine_uk/utils/capital_valuation.py create mode 100644 policyengine_uk/variables/input/non_residential_property_secured_debt.py create mode 100644 policyengine_uk/variables/input/other_residential_property_secured_debt.py create mode 100644 policyengine_uk/variables/input/owned_land_secured_debt.py diff --git a/changelog.d/uc-capital-sale-expenses.fixed.md b/changelog.d/uc-capital-sale-expenses.fixed.md new file mode 100644 index 0000000000..34790cb3d8 --- /dev/null +++ b/changelog.d/uc-capital-sale-expenses.fixed.md @@ -0,0 +1 @@ +Value Universal Credit, Housing Benefit, Income Support, income-based JSA, income-related ESA and Pension Credit capital at market value less 10% where a sale would incur expenses (land, property and shares; not cash savings) and less any debt secured on the asset, as UC Regs 2013 reg. 49(1) and its legacy equivalents require. Adds secured-debt inputs for other residential property, non-residential property and land. diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index 4fbc8bc819..4dad54bc83 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -92,10 +92,13 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - miscellaneous_income - mortgage_capital_repayment - net_financial_wealth +- non_residential_property_secured_debt - non_residential_property_value - other_investment_income +- other_residential_property_secured_debt - other_residential_property_value - owned_land +- owned_land_secured_debt - pension_income - private_pension_wealth - private_transfer_income diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/rate.yaml new file mode 100644 index 0000000000..468d182cb9 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/rate.yaml @@ -0,0 +1,14 @@ +description: > + Share of the current market or surrender value deducted from income-related ESA + capital where there would be expenses attributable to sale. The deduction + is a flat 10% whatever the actual costs, taken before any incumbrance + secured on the asset. +values: + 2008-10-27: 0.1 +metadata: + label: Income-related ESA capital sale expense deduction + period: year + unit: /1 + reference: + - title: Employment and Support Allowance Regulations 2008 reg. 113(a) + href: https://www.legislation.gov.uk/uksi/2008/794/regulation/113 diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml new file mode 100644 index 0000000000..b1946fe614 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml @@ -0,0 +1,22 @@ +description: > + Capital sources whose sale would incur expenses, so that income-related ESA + values them at 90% of market value. Land and property always have costs of + sale. Shares are sold through a broker, so `corporate_wealth` is listed; it + also holds unit trusts, which have no costs of sale, but the dataset does + not separate them. `savings` is not listed because a bank or building + society account is valued at its balance and withdrawing money is not a sale + (see the Universal Credit parameter of the same name for the guidance). + Sources not counted as income-related ESA capital are ignored. +metadata: + label: Income-related ESA capital sources with sale expenses + period: year + unit: list + reference: + - title: Employment and Support Allowance Regulations 2008 reg. 113(a) + href: https://www.legislation.gov.uk/uksi/2008/794/regulation/113 +values: + 2008-10-27: + - owned_land + - other_residential_property_value + - non_residential_property_value + - corporate_wealth diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/rate.yaml new file mode 100644 index 0000000000..ff2e4d5759 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/rate.yaml @@ -0,0 +1,14 @@ +description: > + Share of the current market or surrender value deducted from income-based JSA + capital where there would be expenses attributable to sale. The deduction + is a flat 10% whatever the actual costs, taken before any incumbrance + secured on the asset. +values: + 1996-10-07: 0.1 +metadata: + label: Income-based JSA capital sale expense deduction + period: year + unit: /1 + reference: + - title: Jobseeker's Allowance Regulations 1996 reg. 111(a) + href: https://www.legislation.gov.uk/uksi/1996/207/regulation/111 diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml new file mode 100644 index 0000000000..1a64a5ee2c --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml @@ -0,0 +1,26 @@ +description: > + Capital sources whose sale would incur expenses, so that income-based JSA + values them at 90% of market value. Land and property always have costs of + sale. Shares are sold through a broker, so `corporate_wealth` is listed; it + also holds unit trusts, which have no costs of sale, but the dataset does + not separate them. `savings` is not listed because a bank or building + society account is valued at its balance and withdrawing money is not a sale + (DMG 29620-29623, 29681 and 29685). Sources not counted as income-based JSA + capital are ignored. +metadata: + label: Income-based JSA capital sources with sale expenses + period: year + unit: list + reference: + - title: Jobseeker's Allowance Regulations 1996 reg. 111(a) + href: https://www.legislation.gov.uk/uksi/1996/207/regulation/111 + - title: Decision Makers' Guide chapter 29, paras. 29620-29623 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=124 + - title: Decision Makers' Guide chapter 29, paras. 29681 and 29685 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=139 +values: + 1996-10-07: + - owned_land + - other_residential_property_value + - non_residential_property_value + - corporate_wealth diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/rate.yaml new file mode 100644 index 0000000000..f496742f77 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/rate.yaml @@ -0,0 +1,16 @@ +description: > + Share of the current market or surrender value deducted from Housing Benefit + capital where there would be expenses attributable to sale. The deduction + is a flat 10% whatever the actual costs, taken before any encumbrance + secured on the asset. +values: + 2006-04-10: 0.1 +metadata: + label: Housing Benefit capital sale expense deduction + period: year + unit: /1 + reference: + - title: The Housing Benefit Regulations 2006 reg. 47(a) + href: https://www.legislation.gov.uk/uksi/2006/213/regulation/47 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg. 45(a) + href: https://www.legislation.gov.uk/uksi/2006/214/regulation/45 diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml new file mode 100644 index 0000000000..cb504ed3c4 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml @@ -0,0 +1,28 @@ +description: > + Capital sources whose sale would incur expenses, so that Housing Benefit + values them at 90% of market value. Land and property always have costs of + sale. Shares are sold through a broker, so `corporate_wealth` is listed; it + also holds unit trusts, which have no costs of sale, but the dataset does + not separate them. `savings` is not listed because a bank or building + society account is valued at its balance and withdrawing money is not a sale + (HBGM W1.430, W1.530-W1.531, P1.430 and P1.530-P1.531). Sources not counted + as Housing Benefit capital are ignored. +metadata: + label: Housing Benefit capital sources with sale expenses + period: year + unit: list + reference: + - title: The Housing Benefit Regulations 2006 reg. 47(a) + href: https://www.legislation.gov.uk/uksi/2006/213/regulation/47 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg. 45(a) + href: https://www.legislation.gov.uk/uksi/2006/214/regulation/45 + - title: Housing Benefit Guidance Manual BW1, paras. W1.430 and W1.530-W1.531 + href: https://assets.publishing.service.gov.uk/media/5a749328ed915d0e8e3994c0/hbgm-bw1-assessment-of-capital.pdf#page=36 + - title: Housing Benefit Guidance Manual BP1, paras. P1.430 and P1.530-P1.531 + href: https://assets.publishing.service.gov.uk/media/5a7ec5d840f0b6230268b71f/hbgm-bp1-assessment-of-capital.pdf#page=31 +values: + 2006-04-10: + - owned_land + - other_residential_property_value + - non_residential_property_value + - corporate_wealth diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/rate.yaml new file mode 100644 index 0000000000..7cb86fe853 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/rate.yaml @@ -0,0 +1,14 @@ +description: > + Share of the current market or surrender value deducted from Income Support + capital where there would be expenses attributable to sale. The deduction + is a flat 10% whatever the actual costs, taken before any incumbrance + secured on the asset. +values: + 2010-01-01: 0.1 +metadata: + label: Income Support capital sale expense deduction + period: year + unit: /1 + reference: + - title: Income Support (General) Regulations 1987 reg. 49(a) + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/49 diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml new file mode 100644 index 0000000000..0af0f836b0 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml @@ -0,0 +1,26 @@ +description: > + Capital sources whose sale would incur expenses, so that Income Support + values them at 90% of market value. Land and property always have costs of + sale. Shares are sold through a broker, so `corporate_wealth` is listed; it + also holds unit trusts, which have no costs of sale, but the dataset does + not separate them. `savings` is not listed because a bank or building + society account is valued at its balance and withdrawing money is not a sale + (DMG 29620-29623, 29681 and 29685). Sources not counted as Income Support + capital are ignored. +metadata: + label: Income Support capital sources with sale expenses + period: year + unit: list + reference: + - title: Income Support (General) Regulations 1987 reg. 49(a) + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/49 + - title: Decision Makers' Guide chapter 29, paras. 29620-29623 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=124 + - title: Decision Makers' Guide chapter 29, paras. 29681 and 29685 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=139 +values: + 2010-01-01: + - owned_land + - other_residential_property_value + - non_residential_property_value + - corporate_wealth diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/rate.yaml new file mode 100644 index 0000000000..b344e9dcf2 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/rate.yaml @@ -0,0 +1,14 @@ +description: > + Share of the current market or surrender value deducted from Pension Credit + capital where there would be expenses attributable to sale. The deduction + is a flat 10% whatever the actual costs, taken before any encumbrance + secured on the asset. +values: + 2003-10-06: 0.1 +metadata: + label: Pension Credit capital sale expense deduction + period: year + unit: /1 + reference: + - title: State Pension Credit Regulations 2002 reg. 19(a) + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/19 diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml new file mode 100644 index 0000000000..da4dae9ae1 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml @@ -0,0 +1,26 @@ +description: > + Capital sources whose sale would incur expenses, so that Pension Credit + values them at 90% of market value. Land and property always have costs of + sale. Shares are sold through a broker, so `corporate_wealth` is listed; it + also holds unit trusts, which have no costs of sale, but the dataset does + not separate them. `savings` is not listed because a bank or building + society account is valued at its balance and withdrawing money is not a sale + (DMG 84712-84716, 84731 and 84772). Sources not counted as Pension Credit + capital are ignored. +metadata: + label: Pension Credit capital sources with sale expenses + period: year + unit: list + reference: + - title: State Pension Credit Regulations 2002 reg. 19(a) + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/19 + - title: Decision Makers' Guide chapter 84, paras. 84712-84716 + href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=99 + - title: Decision Makers' Guide chapter 84, paras. 84731 and 84772 + href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=102 +values: + 2003-10-06: + - owned_land + - other_residential_property_value + - non_residential_property_value + - corporate_wealth diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml new file mode 100644 index 0000000000..150561614f --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml @@ -0,0 +1,16 @@ +description: > + Share of the current market or surrender value deducted from Universal + Credit capital where there would be expenses attributable to sale. The + deduction is a flat 10% whatever the actual costs, taken before any + encumbrance secured on the asset. +values: + 2013-04-29: 0.1 +metadata: + label: Universal Credit capital sale expense deduction + period: year + unit: /1 + reference: + - title: The Universal Credit Regulations 2013 reg. 49(1)(a) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/49 + - title: Advice for Decision Making chapter H1, paras. H1602-H1608 + href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=44 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml new file mode 100644 index 0000000000..969e9ac619 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml @@ -0,0 +1,31 @@ +description: > + Capital sources whose sale would incur expenses, so that Universal Credit + values them at 90% of market value. Real property always has costs of sale + (ADM H1606, citing R(IS) 21/93), so `owned_land`, + `other_residential_property_value` and `non_residential_property_value` are + listed. `savings` is not listed: the 10% applies only where selling needs + an agent, broker, solicitor or accountant (ADM H1604-H1605), the costs of + withdrawing money from a building society are not costs of sale (H1607), + and an account is valued at its balance (H1675). `corporate_wealth` is + listed because quoted shares are valued less 10% for costs of sale (H1665). + It also holds unit trusts, which have no costs of sale (H1674), and stocks + and shares ISAs, which the guidance values at their withdrawal value + (H1656); the dataset does not separate these, so the deduction also + reaches them. Sources not counted as Universal Credit capital are ignored. +metadata: + label: Universal Credit capital sources with sale expenses + period: year + unit: list + reference: + - title: The Universal Credit Regulations 2013 reg. 49(1)(a) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/49 + - title: Advice for Decision Making chapter H1, paras. H1604-H1607 + href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=44 + - title: Advice for Decision Making chapter H1, paras. H1665 and H1673-H1675 + href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=57 +values: + 2013-04-29: + - owned_land + - other_residential_property_value + - non_residential_property_value + - corporate_wealth diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml index bdbbef68e0..abe48a7b5d 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml @@ -19,7 +19,9 @@ other_residential_property_value: 3_000 non_residential_property_value: 4_000 output: - esa_income_assessable_capital: 10_500 + # Savings at face value; land, shares and property less 10% for the + # expenses of sale (ESA Regs 2008 reg. 113(a)): 1,000 + 90% of 9,500. + esa_income_assessable_capital: 9_550 - name: Income-related ESA has no tariff income at the lower capital threshold period: 2026 @@ -210,4 +212,6 @@ corporate_wealth: 2_000 private_pension_wealth: 150_000 output: - esa_income_assessable_capital: 3_000 + # Savings at face value; land, shares and property less 10% for the + # expenses of sale (ESA Regs 2008 reg. 113(a)): 1,000 + 90% of 2,000. + esa_income_assessable_capital: 2_800 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_capital.yaml index e9a0cd47c3..c73e3b37f5 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_capital.yaml @@ -89,7 +89,7 @@ - name: Housing Benefit counts direct land holdings as capital period: 2025 - absolute_error_margin: 0 + absolute_error_margin: 0.01 input: people: person: @@ -103,11 +103,36 @@ households: household: members: person - owned_land: 16_001 + # 90% of 17,780 is 16,002, over the 16,000 limit after the 10% + # deduction for the expenses of sale (HB Regs 2006 reg. 47(a)). + owned_land: 17_780 output: - housing_benefit_assessable_capital: 16_001 + housing_benefit_assessable_capital: 16_002 housing_benefit_eligible: false +- name: Housing Benefit values land less 10% for the expenses of sale + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + in_social_housing: true + housing_benefit_reported: 1 + benunits: + benunit: + members: person + would_claim_uc: false + households: + household: + members: person + # Land worth 16,001 is valued at 14,400.90 (HB Regs 2006 reg. 47(a)), + # within the limit. + owned_land: 16_001 + output: + housing_benefit_assessable_capital: 14_400.9 + housing_benefit_eligible: true + - name: Housing Benefit counts corporate wealth as a broad financial-capital proxy period: 2025 absolute_error_margin: 0 @@ -126,7 +151,9 @@ members: person corporate_wealth: 50_000 output: - housing_benefit_assessable_capital: 50_000 + # Market value less 10% for the expenses of sale (HB Regs 2006 + # reg. 47(a)). + housing_benefit_assessable_capital: 45_000 housing_benefit_eligible: false - name: Mixed benunits split household Housing Benefit capital by adult share diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_capital.yaml index 585884ea9c..f90e5f1a9d 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_capital.yaml @@ -23,7 +23,9 @@ other_residential_property_value: 3000 non_residential_property_value: 4000 output: - income_support_assessable_capital: 10500 + # Savings at face value; land, shares and property less 10% for the + # expenses of sale (IS Regs 1987 reg. 49(a)): 1,000 + 90% of 9,500. + income_support_assessable_capital: 9550 - name: Income Support has no tariff income at the lower capital threshold period: 2021 @@ -233,4 +235,6 @@ corporate_wealth: 2000 private_pension_wealth: 150000 output: - income_support_assessable_capital: 3000 + # Savings at face value; land, shares and property less 10% for the + # expenses of sale (IS Regs 1987 reg. 49(a)): 1,000 + 90% of 2,000. + income_support_assessable_capital: 2800 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_capital.yaml index 13ecc3d659..bc50925503 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_capital.yaml @@ -19,7 +19,9 @@ other_residential_property_value: 3_000 non_residential_property_value: 4_000 output: - jsa_income_assessable_capital: 10_500 + # Savings at face value; land, shares and property less 10% for the + # expenses of sale (JSA Regs 1996 reg. 111(a)): 1,000 + 90% of 9,500. + jsa_income_assessable_capital: 9_550 - name: Income-based JSA has no tariff income at the lower capital threshold period: 2025 @@ -184,4 +186,6 @@ corporate_wealth: 2_000 private_pension_wealth: 150_000 output: - jsa_income_assessable_capital: 3_000 + # Savings at face value; land, shares and property less 10% for the + # expenses of sale (JSA Regs 1996 reg. 111(a)): 1,000 + 90% of 2,000. + jsa_income_assessable_capital: 2_800 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 fa145e20c1..fc466402eb 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 @@ -259,7 +259,9 @@ members: person other_residential_property_value: 50_000 output: - uc_assessable_capital: 50_000 + # Market value less 10% for the expenses of sale (UC Regs 2013 + # reg. 49(1)(a)). + uc_assessable_capital: 45_000 is_uc_eligible: false - name: Countable non-residential property can push capital over the upper threshold @@ -277,7 +279,9 @@ members: person non_residential_property_value: 50_000 output: - uc_assessable_capital: 50_000 + # Market value less 10% for the expenses of sale (UC Regs 2013 + # reg. 49(1)(a)). + uc_assessable_capital: 45_000 is_uc_eligible: false - name: Countable corporate wealth can push capital over the upper threshold @@ -295,7 +299,9 @@ members: person corporate_wealth: 50_000 output: - uc_assessable_capital: 50_000 + # Market value less 10% for the expenses of sale (UC Regs 2013 + # reg. 49(1)(a)). + uc_assessable_capital: 45_000 is_uc_eligible: false - name: Main residence value is excluded from Universal Credit capital diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml new file mode 100644 index 0000000000..840b084ec1 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml @@ -0,0 +1,218 @@ +# Capital is valued at market value less 10% where a sale would incur +# expenses, and less any encumbrance secured on it: UC Regs 2013 reg. 49(1); +# HB Regs 2006 reg. 47; IS Regs 1987 reg. 49; JSA Regs 1996 reg. 111; ESA Regs +# 2008 reg. 113; SPC Regs 2002 reg. 19. Savings in a bank or building society +# have no sale expenses; land, property and shares do (ADM H1604-H1607). +# Expected values are worked by hand from those provisions. + +- name: UC values a mortgaged holiday home as in ADM H1602 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # 125,000 - 10% (12,500) - 100,500 mortgage = 12,000. + other_residential_property_value: 125_000 + other_residential_property_secured_debt: 100_500 + output: + uc_assessable_capital: 12_000 + is_uc_eligible: true + # 24 steps of 250 above 6,000 at 4.35 a month: 24 * 4.35 * 12. + uc_tariff_income: 1_252.8 + +- name: UC counts cash savings at face value + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + savings: 15_000 + output: + uc_assessable_capital: 15_000 + is_uc_eligible: true + +- name: UC shares valued less sale expenses bring capital within the limit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # 5,000 + 90% of 12,000 = 15,800, within the 16,000 limit; at full + # value the household would hold 17,000. + savings: 5_000 + corporate_wealth: 12_000 + output: + uc_assessable_capital: 15_800 + is_uc_eligible: true + # 9,800 above 6,000 is 40 steps of 250 (rounded up): 40 * 4.35 * 12. + uc_tariff_income: 2_088 + +- name: UC negative equity in a second home does not reduce savings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # The mortgage is deducted only from the property it is secured on + # (ADM H1615-H1616), so the property is valued at nil. + savings: 10_000 + other_residential_property_value: 100_000 + other_residential_property_secured_debt: 150_000 + output: + uc_assessable_capital: 10_000 + is_uc_eligible: true + +- name: UC deducts sale expenses before the encumbrance on business premises + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # (90% of 40,000 - 20,000) + 90% of 5,000 = 16,000 + 4,500. + non_residential_property_value: 40_000 + non_residential_property_secured_debt: 20_000 + corporate_wealth: 5_000 + output: + uc_assessable_capital: 20_500 + is_uc_eligible: false + +- name: Housing Benefit values encumbered land less sale expenses + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + in_social_housing: true + housing_benefit_reported: 1 + benunits: + benunit: + members: person + would_claim_uc: false + households: + household: + members: person + # 1,000 + (90% of 20,000 - 5,000) = 14,000 (HB Regs 2006 reg. 47). + savings: 1_000 + owned_land: 20_000 + owned_land_secured_debt: 5_000 + output: + housing_benefit_assessable_capital: 14_000 + # 32 steps of 250 above 6,000 at 1 a week: 32 * 52. + housing_benefit_tariff_income: 1_664 + housing_benefit_eligible: true + +- name: Income Support values encumbered land less sale expenses + period: 2021 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # 90% of 20,000 - 5,000 = 13,000 (IS Regs 1987 reg. 49). + owned_land: 20_000 + owned_land_secured_debt: 5_000 + output: + income_support_assessable_capital: 13_000 + +- name: Income-based JSA values a mortgaged second home less sale expenses + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # 2,000 + (90% of 30,000 - 25,000) = 4,000 (JSA Regs 1996 reg. 111). + savings: 2_000 + other_residential_property_value: 30_000 + other_residential_property_secured_debt: 25_000 + output: + jsa_income_assessable_capital: 4_000 + +- name: Income-related ESA values shares less sale expenses + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: person + households: + household: + members: person + # 3,000 + 90% of 10,000 = 12,000 (ESA Regs 2008 reg. 113). + savings: 3_000 + corporate_wealth: 10_000 + output: + esa_income_assessable_capital: 12_000 + +- name: Pension Credit values mortgaged business premises less sale expenses + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 70 + benunits: + benunit: + members: person + households: + household: + members: person + # 2,000 + (90% of 30,000 - 10,000) = 19,000 (SPC Regs 2002 reg. 19). + savings: 2_000 + non_residential_property_value: 30_000 + non_residential_property_secured_debt: 10_000 + output: + pension_credit_assessable_capital: 19_000 + # 18 steps of 500 above 10,000 at 1 a week: 18 * 52. + pension_credit_deemed_income: 936 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_deemed_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_deemed_income.yaml index cf16632edc..5e4f8db1ae 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_deemed_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_deemed_income.yaml @@ -78,9 +78,11 @@ households: household: members: person - owned_land: 10_001 + # 90% of 11,120 is 10,008 after the 10% deduction for the expenses + # of sale (SPC Regs 2002 reg. 19(a)): one 500 step above 10,000. + owned_land: 11_120 output: - pension_credit_assessable_capital: 10_001 + pension_credit_assessable_capital: 10_008 pension_credit_deemed_income: 52 - name: Pension Credit does not dilute pensioner capital across unrelated adult benunits. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml index b6cf5c9493..b578d941fe 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml @@ -76,10 +76,16 @@ other_residential_property_value: 55_370 savings: 455 output: - pension_credit_assessable_capital: 55_825 - pension_credit_deemed_income: 4_784 - pension_credit_income: 15_260 - pension_credit: 0 + # 455 + 90% of 55,370 (SPC Regs 2002 reg. 19(a)). Deemed income is + # 81 steps of 500 above 10,000 at 1 a week: 81 * 52 = 4,212. + pension_credit_assessable_capital: 50_288 + pension_credit_deemed_income: 4_212 + pension_credit_income: 14_688 + # Income exceeds the 12,376 guarantee, so no guarantee credit. Savings + # credit: 60% of (12,376 - 208.07 * 52) = 933.82, less 40% of + # (14,688 - 12,376) = 924.80, leaves 9.02. + guarantee_credit: 0 + pension_credit: 9.016 - name: Case 5, 2026 Pension Credit uses tariff income with the published guarantee rate. period: 2026 @@ -99,12 +105,14 @@ savings: 87_285 corporate_wealth: 12_650 output: - pension_credit_assessable_capital: 99_935 - pension_credit_deemed_income: 9_360 - pension_credit_income: 9_360 + # 87,285 + 90% of 12,650 (SPC Regs 2002 reg. 19(a)). Deemed income is + # 178 steps of 500 above 10,000 at 1 a week: 178 * 52 = 9,256. + pension_credit_assessable_capital: 98_670 + pension_credit_deemed_income: 9_256 + pension_credit_income: 9_256 standard_minimum_guarantee: 12_376 - guarantee_credit: 3_016 - pension_credit: 3_016 + guarantee_credit: 3_120 + pension_credit: 3_120 - name: Pension Credit capital disregards private pension wealth period: 2026 diff --git a/policyengine_uk/tests/test_capital_valuation_properties.py b/policyengine_uk/tests/test_capital_valuation_properties.py new file mode 100644 index 0000000000..76a7aa5d1d --- /dev/null +++ b/policyengine_uk/tests/test_capital_valuation_properties.py @@ -0,0 +1,182 @@ +"""Property-based tests for how the means tests value capital. + +Universal Credit and each legacy means test calculate capital in the United +Kingdom at its current market or surrender value less 10% where there would +be expenses attributable to sale, and less any encumbrance secured on it (UC +Regs 2013 reg. 49(1); HB Regs 2006 reg. 47; HB (SPC) Regs 2006 reg. 45; IS +Regs 1987 reg. 49; JSA Regs 1996 reg. 111; ESA Regs 2008 reg. 113; SPC Regs +2002 reg. 19). Savings in a bank or building society incur no sale expenses; +land, property and shares do. + +Invariants, for any generated population of single-adult households: + +1. Non-increasing: every programme's assessable capital is at most the same + household's capital with the deduction switched off (rate 0, no debt). +2. Cash only: a household holding only savings has the same capital with the + deduction on or off. +3. Differential: capital equals a direct implementation of the regulation, + savings + sum over sources of max(0, 90% of value - secured debt). +4. Monotone: a higher sale-expense rate, or more secured debt, never raises + capital. +5. Isolation: debt secured on one asset never takes capital below the value + of the household's other assets, and capital is never negative. +""" + +from functools import reduce + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +PROPERTY_SETTINGS = settings( + max_examples=5, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# Assessable capital variable -> parameter node holding its sources and +# sale-expense rule. Pension Credit counts only pension-age adults; the +# others are tested on working-age adults (pension-age Housing Benefit +# disregards all capital for Guarantee Credit recipients). +PROGRAMMES = { + "uc_assessable_capital": "gov.dwp.universal_credit.means_test.capital", + "housing_benefit_assessable_capital": "gov.dwp.housing_benefit.means_test.capital", + "income_support_assessable_capital": "gov.dwp.income_support.means_test.capital", + "jsa_income_assessable_capital": "gov.dwp.JSA.income.capital", + "esa_income_assessable_capital": "gov.dwp.ESA.income.capital", + "pension_credit_assessable_capital": "gov.dwp.pension_credit.income.capital", +} +PENSION_AGE_ONLY = {"pension_credit_assessable_capital"} +ASSETS = [ + "savings", + "owned_land", + "corporate_wealth", + "other_residential_property_value", + "non_residential_property_value", +] +DEBTS = { + "owned_land": "owned_land_secured_debt", + "other_residential_property_value": "other_residential_property_secured_debt", + "non_residential_property_value": "non_residential_property_secured_debt", +} +amount = st.one_of(st.just(0.0), st.floats(0, 400_000, allow_nan=False)) + + +@st.composite +def households(draw, cash_only=False): + assets = {asset: draw(amount) for asset in ASSETS} + debts = {debt: draw(amount) for debt in DEBTS.values()} + if cash_only: + assets = {asset: 0.0 for asset in ASSETS} | {"savings": assets["savings"]} + debts = {debt: 0.0 for debt in debts} + return dict(age=draw(st.sampled_from([30, 70])), assets=assets, debts=debts) + + +def situation(units, debt_scale=1.0): + people, benunits, homes = {}, {}, {} + for i, unit in enumerate(units): + people[f"p{i}"] = {"age": {YEAR: unit["age"]}} + benunits[f"b{i}"] = {"members": [f"p{i}"]} + homes[f"h{i}"] = { + "members": [f"p{i}"], + **{k: {YEAR: v} for k, v in unit["assets"].items()}, + **{k: {YEAR: v * debt_scale} for k, v in unit["debts"].items()}, + } + return {"people": people, "benunits": benunits, "households": homes} + + +def rate_reform(rate): + return { + f"{node}.sale_expenses.rate": {str(YEAR): rate} for node in PROGRAMMES.values() + } + + +def calculate(units, rate=None, debt_scale=1.0): + sim = Simulation( + situation=situation(units, debt_scale), + reform=None if rate is None else rate_reform(rate), + ) + return sim, {v: np.asarray(sim.calculate(v, YEAR), dtype=float) for v in PROGRAMMES} + + +def parameter_node(sim, path): + return reduce(getattr, path.split("."), sim.tax_benefit_system.parameters(YEAR)) + + +def applies(variable, unit): + return (unit["age"] >= 67) == (variable in PENSION_AGE_ONLY) + + +def expected_capital(unit, node): + """Reg. 49(1) applied source by source, independently of the model.""" + total = 0.0 + for source in node.sources: + value = unit["assets"][source] + if source in node.sale_expenses.sources: + value *= 1 - node.sale_expenses.rate + if source in DEBTS: + value = max(0.0, value - unit["debts"][DEBTS[source]]) + total += value + return total + + +def close(a, b): + return abs(a - b) <= 1 + 1e-6 * abs(b) + + +@PROPERTY_SETTINGS +@given(st.lists(households(), min_size=1, max_size=30)) +def test_valuation_matches_the_regulation_and_never_adds_capital(units): + sim, valued = calculate(units) + _, full = calculate(units, rate=0.0, debt_scale=0.0) + for variable, path in PROGRAMMES.items(): + node = parameter_node(sim, path) + assert node.sale_expenses.rate == 0.1 + assert "savings" in node.sources + assert "savings" not in node.sale_expenses.sources + for i, unit in enumerate(units): + assert valued[variable][i] >= 0, (variable, unit) + assert valued[variable][i] <= full[variable][i] + 0.01, (variable, unit) + if applies(variable, unit): + expected = expected_capital(unit, node) + assert close(valued[variable][i], expected), (variable, unit) + # Debt secured on property never reaches the other assets. + floor = sum( + unit["assets"][s] + * (1 - node.sale_expenses.rate * (s in node.sale_expenses.sources)) + for s in node.sources + if s not in DEBTS + ) + assert valued[variable][i] >= floor - 1, (variable, unit) + + +@PROPERTY_SETTINGS +@given(st.lists(households(cash_only=True), min_size=1, max_size=30)) +def test_cash_only_households_have_no_deduction(units): + _, valued = calculate(units) + _, full = calculate(units, rate=0.0) + for variable in PROGRAMMES: + assert np.allclose(valued[variable], full[variable]), variable + for i, unit in enumerate(units): + if applies(variable, unit): + assert close(valued[variable][i], unit["assets"]["savings"]), variable + + +@PROPERTY_SETTINGS +@given( + st.lists(households(), min_size=1, max_size=30), + st.floats(0, 0.5), + st.floats(0, 0.5), + st.floats(0, 2), +) +def test_capital_is_non_increasing_in_rate_and_secured_debt(units, r1, r2, scale): + low, high = sorted([r1, r2]) + _, at_low = calculate(units, rate=low) + _, at_high = calculate(units, rate=high) + _, more_debt = calculate(units, rate=low, debt_scale=1 + scale) + for variable in PROGRAMMES: + assert np.all(at_high[variable] <= at_low[variable] + 0.01), variable + assert np.all(more_debt[variable] <= at_low[variable] + 0.01), variable diff --git a/policyengine_uk/tests/test_stock_capital_periods.py b/policyengine_uk/tests/test_stock_capital_periods.py index d36c3068b2..e694045d13 100644 --- a/policyengine_uk/tests/test_stock_capital_periods.py +++ b/policyengine_uk/tests/test_stock_capital_periods.py @@ -23,7 +23,9 @@ 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 + # Savings at face value; property and shares less 10% for the expenses + # of sale (UC Regs 2013 reg. 49(1)(a)): 12,000 + 2 * 10,800. + assert simulation.calculate("uc_assessable_capital", "2026-01")[0] == 33_600 assert not simulation.calculate("is_uc_eligible", "2026-01")[0] @@ -43,7 +45,9 @@ def test_pension_credit_capital_stocks_are_not_prorated_monthly(): simulation = Simulation(situation=situation) assert simulation.calculate("savings", "2026-01")[0] == 12_000 + # Land and shares less 10% for the expenses of sale (SPC Regs 2002 + # reg. 19(a)): 12,000 + 2 * 10,800. assert ( simulation.calculate("pension_credit_assessable_capital", "2026-01")[0] - == 36_000 + == 33_600 ) diff --git a/policyengine_uk/utils/capital_valuation.py b/policyengine_uk/utils/capital_valuation.py new file mode 100644 index 0000000000..2e70896a1a --- /dev/null +++ b/policyengine_uk/utils/capital_valuation.py @@ -0,0 +1,40 @@ +"""Value means-tested capital as the capital-valuation regulations require. + +Universal Credit and each legacy means test value capital in the United +Kingdom "at its current market or surrender value less (a) where there would +be expenses attributable to sale, 10% [...]; and (b) the amount of any +encumbrances secured on it" (UC Regs 2013 reg. 49(1) and its legacy +equivalents). Each programme sets its own rate and the capital sources whose +sale would incur expenses in a ``capital.sale_expenses`` parameter node. +""" + +from policyengine_core.model_api import max_ + +# Household inputs holding the debts secured on each capital source, such as +# a mortgage on a second property. The encumbrance is deducted only from the +# asset it is secured on, so negative equity in one asset never reduces the +# value of another (ADM H1615-H1616). +SECURED_DEBT = { + "other_residential_property_value": "other_residential_property_secured_debt", + "non_residential_property_value": "non_residential_property_secured_debt", + "owned_land": "owned_land_secured_debt", +} + + +def valued_capital(value_of, sources, sale_expenses): + """Sum ``sources`` at market value less sale expenses and secured debt. + + ``value_of(variable)`` returns that household variable on the entity the + caller is working with. The sale-expense deduction comes first and the + encumbrance second (ADM H1608), and an encumbered asset is never valued + below nil. + """ + total = 0 + for source in sources: + value = value_of(source) + if source in sale_expenses.sources: + value = value * (1 - sale_expenses.rate) + if source in SECURED_DEBT: + value = max_(0, value - value_of(SECURED_DEBT[source])) + total = total + value + return total diff --git a/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py b/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py index 473bcdfebb..d9e00ba312 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import valued_capital class esa_income_assessable_capital(Variable): @@ -7,7 +8,8 @@ class esa_income_assessable_capital(Variable): label = "Assessable capital for income-related ESA" documentation = ( "Household capital apportioned to the benefit unit for the income-related " - "ESA capital test. Because the dataset only stores these stocks at " + "ESA capital test, valued at market value less 10% where a sale would " + "incur expenses and less any debt secured on it (reg. 113). Because the dataset only stores these stocks at " "household level, the model allocates full household capital to any " "benunit with a reported income-related ESA award and only falls back to " "an adult-share proxy when nobody in the household is on that reported " @@ -23,8 +25,10 @@ def formula(benunit, period, parameters): person = benunit.members claiming_esa_income = add(benunit, period, ["esa_income_reported"]) > 0 - household_capital = sum( - benunit.max(person.household(source, period)) for source in sources + household_capital = valued_capital( + lambda variable: benunit.max(person.household(variable, period)), + sources, + ESA.capital.sale_expenses, ) benunit_adults = add(benunit, period, ["is_adult"]) household_reporting_claimants = benunit.max( diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py index c978c5f699..cf1178e0a2 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import valued_capital class housing_benefit_assessable_capital(Variable): @@ -7,7 +8,10 @@ class housing_benefit_assessable_capital(Variable): label = "Housing Benefit assessable capital" documentation = ( "Housing Benefit capital counted from the configured capital sources, " - "allocated across benunits using a household adult-share proxy." + "each valued at market value less 10% where a sale would incur " + "expenses and less any debt secured on it (reg. 47 of SI 2006/213, " + "reg. 45 of SI 2006/214), allocated across benunits using a household " + "adult-share proxy." ) definition_period = YEAR unit = GBP @@ -18,7 +22,9 @@ def formula(benunit, period, parameters): person = benunit.members any_over_SP_age = benunit.any(person("is_SP_age", period)) p = parameters(period).gov.dwp.housing_benefit.means_test.capital - household_capital = sum(household(source, period) for source in p.sources) + household_capital = valued_capital( + lambda variable: household(variable, period), p.sources, p.sale_expenses + ) benunit_adults = add(benunit, period, ["is_adult"]) household_adults = benunit.max( person.household.sum(person.household.members("is_adult", period)) diff --git a/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py b/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py index e497c670f8..2afff9bedf 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import valued_capital class income_support_assessable_capital(Variable): @@ -7,7 +8,8 @@ class income_support_assessable_capital(Variable): label = "Assessable capital for Income Support" documentation = ( "Household capital apportioned to the benefit unit for the Income Support " - "capital test. Because the dataset only stores these stocks at household " + "capital test, valued at market value less 10% where a sale would incur " + "expenses and less any debt secured on it (reg. 49). Because the dataset only stores these stocks at household " "level, the model allocates full household capital to any benunit on the " "IS claim path and only falls back to an adult-share proxy when nobody in " "the household is on that path." @@ -27,8 +29,10 @@ def formula(benunit, period, parameters): # any benunit on the IS claim path gets the full observed household total. # If nobody in the household is on that path, fall back to an all-adults # proxy so direct inspection still returns a usable value. - household_capital = sum( - benunit.max(person.household(source, period)) for source in sources + household_capital = valued_capital( + lambda variable: benunit.max(person.household(variable, period)), + sources, + IS.means_test.capital.sale_expenses, ) benunit_adults = add(benunit, period, ["is_adult"]) household_claiming_adults = benunit.max( diff --git a/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py b/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py index 2483cc9945..8fe20569d8 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import valued_capital class jsa_income_assessable_capital(Variable): @@ -7,7 +8,8 @@ class jsa_income_assessable_capital(Variable): label = "Assessable capital for income-based JSA" documentation = ( "Household capital apportioned to the benefit unit for the income-based " - "JSA capital test. Because the dataset only stores these stocks at " + "JSA capital test, valued at market value less 10% where a sale would " + "incur expenses and less any debt secured on it (reg. 111). Because the dataset only stores these stocks at " "household level, the model allocates full household capital to any " "benunit with a reported income-based JSA award and only falls back to " "an adult-share proxy when nobody in the household is on that reported " @@ -23,8 +25,10 @@ def formula(benunit, period, parameters): person = benunit.members claiming_jsa_income = add(benunit, period, ["jsa_income_reported"]) > 0 - household_capital = sum( - benunit.max(person.household(source, period)) for source in sources + household_capital = valued_capital( + lambda variable: benunit.max(person.household(variable, period)), + sources, + JSA.capital.sale_expenses, ) benunit_adults = add(benunit, period, ["is_adult"]) household_reporting_claimants = benunit.max( diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py index 9f4c1fde97..068fbc1872 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import valued_capital class pension_credit_assessable_capital(Variable): @@ -7,7 +8,8 @@ class pension_credit_assessable_capital(Variable): label = "Pension Credit assessable capital" documentation = ( "Pension Credit capital counted from the configured capital sources, " - "split only across pension-age adults in the household so pensioner " + "each valued at market value less 10% where a sale would incur " + "expenses and less any debt secured on it (reg. 19), split only across pension-age adults in the household so pensioner " "couples pool capital together without dilution by unrelated working-" "age adults." ) @@ -19,7 +21,9 @@ def formula(benunit, period, parameters): household = benunit.household person = benunit.members p = parameters(period).gov.dwp.pension_credit.income.capital - household_capital = sum(household(source, period) for source in p.sources) + household_capital = valued_capital( + lambda variable: household(variable, period), p.sources, p.sale_expenses + ) any_pension_age = benunit.any(person("is_SP_age", period)) benunit_pension_age_adults = benunit.sum(person("is_SP_age", period)) household_pension_age_adults = benunit.max( 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 eef9c8f729..d2f0125a76 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 @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import valued_capital class uc_assessable_capital(Variable): @@ -7,7 +8,9 @@ class uc_assessable_capital(Variable): label = "Universal Credit assessable capital" documentation = ( "Universal Credit capital counted from the configured capital sources, " - "with benunit-reported overrides when available." + "each valued at market value less 10% where a sale would incur " + "expenses and less any debt secured on it (reg. 49(1)), with " + "benunit-reported overrides when available." ) definition_period = YEAR unit = GBP @@ -16,8 +19,10 @@ class uc_assessable_capital(Variable): 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 + household_capital = valued_capital( + lambda variable: household(variable, period), + p.capital.sources, + p.capital.sale_expenses, ) benunit_adults = add(benunit, period, ["is_adult"]) household_reported_capital = household("household_uc_reported_capital", period) 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..3dc39a2996 --- /dev/null +++ b/policyengine_uk/variables/input/non_residential_property_secured_debt.py @@ -0,0 +1,17 @@ +from policyengine_uk.model_api import * + + +class non_residential_property_secured_debt(Variable): + label = "debt secured on non-residential property" + documentation = ( + "Amount owed on mortgages and other charges secured on non-residential " + "property the household owns. Means tests deduct it from that " + "property's value (UC Regs 2013 reg. 49(1)(b) and its legacy " + "equivalents)." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = STOCK + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" 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..50ec795c0d --- /dev/null +++ b/policyengine_uk/variables/input/other_residential_property_secured_debt.py @@ -0,0 +1,17 @@ +from policyengine_uk.model_api import * + + +class other_residential_property_secured_debt(Variable): + label = "debt secured on other residential property" + documentation = ( + "Amount owed on mortgages and other charges secured on residential " + "property the household owns other than the main residence, such as a " + "buy-to-let mortgage. Means tests deduct it from that property's value " + "(UC Regs 2013 reg. 49(1)(b) and its legacy equivalents)." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = STOCK + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" diff --git a/policyengine_uk/variables/input/owned_land_secured_debt.py b/policyengine_uk/variables/input/owned_land_secured_debt.py new file mode 100644 index 0000000000..b58b570051 --- /dev/null +++ b/policyengine_uk/variables/input/owned_land_secured_debt.py @@ -0,0 +1,16 @@ +from policyengine_uk.model_api import * + + +class owned_land_secured_debt(Variable): + label = "debt secured on owned land" + documentation = ( + "Amount owed on mortgages and other charges secured on land-only plots " + "the household owns. Means tests deduct it from that land's value (UC " + "Regs 2013 reg. 49(1)(b) and its legacy equivalents)." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = STOCK + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" From 0d315ed29e1e8365349fa3fa6d6b44d5adf97c1e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 22:07:34 -0400 Subject: [PATCH 02/38] Tighten citations and tests from the adversarial review - Each programme's sale-expense parameters now cite its GB and NI regulations, the guidance paragraph that orders the 10% before the encumbrance, and per-asset guidance with exact PDF page anchors (DMG ch 52 for ESA, HBGM Annex F for HB savings, H1656/H1665/H1673-H1675 for UC). - The source-list descriptions and changelog say plainly that corporate_wealth also holds unit trusts, ISAs, employee share options and, in datasets built before the pension split, non-DB pension wealth. - The helper and secured-debt docs say debts net within a source category. - The property-test oracle fixes the sale-expense assets and the 10% itself instead of reading the parameters, checks pension-age HB, and runs on larger populations. New hand-computed cases cover HB second homes and premises, per-source flooring between two encumbered sources, pension-age HB, and a monthly secured debt. Co-Authored-By: Claude Opus 5.5 --- changelog.d/uc-capital-sale-expenses.fixed.md | 2 +- .../income/capital/sale_expenses/rate.yaml | 12 ++- .../income/capital/sale_expenses/sources.yaml | 29 +++++-- .../income/capital/sale_expenses/rate.yaml | 15 +++- .../income/capital/sale_expenses/sources.yaml | 25 ++++-- .../capital/sale_expenses/rate.yaml | 17 +++- .../capital/sale_expenses/sources.yaml | 39 ++++++--- .../capital/sale_expenses/rate.yaml | 12 ++- .../capital/sale_expenses/sources.yaml | 25 ++++-- .../income/capital/sale_expenses/rate.yaml | 12 ++- .../income/capital/sale_expenses/sources.yaml | 31 ++++--- .../capital/sale_expenses/rate.yaml | 8 +- .../capital/sale_expenses/sources.yaml | 32 +++++--- .../family/esa_income/esa_income_capital.yaml | 4 +- .../income_support_capital.yaml | 4 +- .../family/jsa_income/jsa_income_capital.yaml | 4 +- .../baseline/gov/dwp/capital_valuation.yaml | 81 ++++++++++++++++++- .../test_capital_valuation_properties.py | 68 +++++++++++----- .../tests/test_stock_capital_periods.py | 10 ++- policyengine_uk/utils/capital_valuation.py | 29 ++++--- .../non_residential_property_secured_debt.py | 6 +- ...other_residential_property_secured_debt.py | 5 +- .../input/owned_land_secured_debt.py | 7 +- 23 files changed, 357 insertions(+), 120 deletions(-) diff --git a/changelog.d/uc-capital-sale-expenses.fixed.md b/changelog.d/uc-capital-sale-expenses.fixed.md index 34790cb3d8..6b926ace41 100644 --- a/changelog.d/uc-capital-sale-expenses.fixed.md +++ b/changelog.d/uc-capital-sale-expenses.fixed.md @@ -1 +1 @@ -Value Universal Credit, Housing Benefit, Income Support, income-based JSA, income-related ESA and Pension Credit capital at market value less 10% where a sale would incur expenses (land, property and shares; not cash savings) and less any debt secured on the asset, as UC Regs 2013 reg. 49(1) and its legacy equivalents require. Adds secured-debt inputs for other residential property, non-residential property and land. +Value Universal Credit, Housing Benefit, Income Support, income-based JSA, income-related ESA and Pension Credit capital at market value less 10% where a sale would incur expenses and less any debt secured on the asset, as UC Regs 2013 reg. 49(1) and its legacy equivalents require. The 10% applies to land, property and corporate_wealth, not to cash savings; corporate_wealth also holds unit trusts, which have no costs of sale, and in datasets built before the private pension split it holds pension wealth, so the deduction reaches those too until the data separate them. Adds secured-debt inputs for other residential property, non-residential property and land. diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/rate.yaml index 468d182cb9..e360c3ff9a 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/rate.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/rate.yaml @@ -1,8 +1,8 @@ description: > - Share of the current market or surrender value deducted from income-related ESA - capital where there would be expenses attributable to sale. The deduction - is a flat 10% whatever the actual costs, taken before any incumbrance - secured on the asset. + Share of the current market or surrender value deducted from income-related + ESA capital where there would be expenses attributable to sale. It is a flat + 10% whatever the actual costs, taken from the gross value before any + encumbrance secured on the asset is deducted. values: 2008-10-27: 0.1 metadata: @@ -12,3 +12,7 @@ metadata: reference: - title: Employment and Support Allowance Regulations 2008 reg. 113(a) href: https://www.legislation.gov.uk/uksi/2008/794/regulation/113 + - title: Employment and Support Allowance Regulations (Northern Ireland) 2008 reg. 113(a) + href: https://www.legislation.gov.uk/nisr/2008/280/regulation/113 + - title: Decision Makers' Guide chapter 52, para. 52624 + href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=116 diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml index b1946fe614..40f6f3fb9a 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml @@ -1,11 +1,18 @@ description: > Capital sources whose sale would incur expenses, so that income-related ESA - values them at 90% of market value. Land and property always have costs of - sale. Shares are sold through a broker, so `corporate_wealth` is listed; it - also holds unit trusts, which have no costs of sale, but the dataset does - not separate them. `savings` is not listed because a bank or building - society account is valued at its balance and withdrawing money is not a sale - (see the Universal Credit parameter of the same name for the guidance). + values them at 90% of market value. Costs of sale normally arise where an + agent, broker, auctioneer, solicitor or accountant is needed, and real + property always has them (DMG 52622), so `owned_land`, + `other_residential_property_value` and `non_residential_property_value` are + listed. `savings` is not listed: a bank or building society account is + valued at its balance, and the costs of withdrawing money are not costs of + sale (DMG 52620-52623 and 52685). `corporate_wealth` is listed because + shares are valued less 10% for the costs of selling them (DMG 52671). + It is a mixed variable, though: it also holds unit trusts, which have no + costs of sale (DMG 52681), stocks and shares ISAs and employee share + options, and datasets built before private pension wealth was split out also + fold non-defined-benefit pension wealth into it, which is not capital at + all. The deduction reaches all of these until the data separate them. Sources not counted as income-related ESA capital are ignored. metadata: label: Income-related ESA capital sources with sale expenses @@ -14,6 +21,16 @@ metadata: reference: - title: Employment and Support Allowance Regulations 2008 reg. 113(a) href: https://www.legislation.gov.uk/uksi/2008/794/regulation/113 + - title: Employment and Support Allowance Regulations (Northern Ireland) 2008 reg. 113(a) + href: https://www.legislation.gov.uk/nisr/2008/280/regulation/113 + - title: Decision Makers' Guide chapter 52, paras. 52620-52622 + href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=115 + - title: Decision Makers' Guide chapter 52, para. 52623 + href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=116 + - title: Decision Makers' Guide chapter 52, para. 52671 + href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=126 + - title: Decision Makers' Guide chapter 52, paras. 52681 and 52685 + href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=128 values: 2008-10-27: - owned_land diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/rate.yaml index ff2e4d5759..e67f83c653 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/rate.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/rate.yaml @@ -1,8 +1,11 @@ description: > - Share of the current market or surrender value deducted from income-based JSA - capital where there would be expenses attributable to sale. The deduction - is a flat 10% whatever the actual costs, taken before any incumbrance - secured on the asset. + Share of the current market or surrender value deducted from income-based + JSA capital where there would be expenses attributable to sale. It is a flat + 10% whatever the actual costs, taken from the gross value before any + encumbrance secured on the asset is deducted. Before 1 October 2007 the same + deduction sat in reg. 111(a)(i) alongside a separate valuation for National + Savings Certificates; SI 2007/2618 substituted the regulation in its current + form. values: 1996-10-07: 0.1 metadata: @@ -12,3 +15,7 @@ metadata: reference: - title: Jobseeker's Allowance Regulations 1996 reg. 111(a) href: https://www.legislation.gov.uk/uksi/1996/207/regulation/111 + - title: Jobseeker's Allowance Regulations (Northern Ireland) 1996 reg. 111(a), as substituted by S.R. 2007/396 reg. 7(11) + href: https://www.legislation.gov.uk/nisr/2007/396/regulation/7 + - title: Decision Makers' Guide chapter 29, para. 29624 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=125 diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml index 1a64a5ee2c..538833841b 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml @@ -1,12 +1,19 @@ description: > Capital sources whose sale would incur expenses, so that income-based JSA - values them at 90% of market value. Land and property always have costs of - sale. Shares are sold through a broker, so `corporate_wealth` is listed; it - also holds unit trusts, which have no costs of sale, but the dataset does - not separate them. `savings` is not listed because a bank or building - society account is valued at its balance and withdrawing money is not a sale - (DMG 29620-29623, 29681 and 29685). Sources not counted as income-based JSA - capital are ignored. + values them at 90% of market value. Costs of sale normally arise where an + agent, broker, auctioneer, solicitor or accountant is needed, and real + property always has them (DMG 29622), so `owned_land`, + `other_residential_property_value` and `non_residential_property_value` are + listed. `savings` is not listed: a bank or building society account is + valued at its balance, and the costs of withdrawing money are not costs of + sale (DMG 29620-29623 and 29685). `corporate_wealth` is listed because + shares are valued less 10% for the costs of selling them (DMG 29671). It is + a mixed variable, though: it also holds unit trusts, which have no costs of + sale (DMG 29681), stocks and shares ISAs and employee share options, and + datasets built before private pension wealth was split out also fold non- + defined-benefit pension wealth into it, which is not capital at all. The + deduction reaches all of these until the data separate them. Sources not + counted as income-based JSA capital are ignored. metadata: label: Income-based JSA capital sources with sale expenses period: year @@ -14,8 +21,12 @@ metadata: reference: - title: Jobseeker's Allowance Regulations 1996 reg. 111(a) href: https://www.legislation.gov.uk/uksi/1996/207/regulation/111 + - title: Jobseeker's Allowance Regulations (Northern Ireland) 1996 reg. 111(a), as substituted by S.R. 2007/396 reg. 7(11) + href: https://www.legislation.gov.uk/nisr/2007/396/regulation/7 - title: Decision Makers' Guide chapter 29, paras. 29620-29623 href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=124 + - title: Decision Makers' Guide chapter 29, para. 29671 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=137 - title: Decision Makers' Guide chapter 29, paras. 29681 and 29685 href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=139 values: diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/rate.yaml index f496742f77..45b58092df 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/rate.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/rate.yaml @@ -1,8 +1,11 @@ description: > Share of the current market or surrender value deducted from Housing Benefit - capital where there would be expenses attributable to sale. The deduction - is a flat 10% whatever the actual costs, taken before any encumbrance - secured on the asset. + capital where there would be expenses attributable to sale. It is a flat 10% + whatever the actual costs, taken from the gross value before any encumbrance + secured on the asset is deducted. Before 1 October 2007 the same deduction + sat in reg. 47(a)(i) (reg. 45(a)(i) at pension age) alongside a separate + valuation for National Savings Certificates; SI 2007/2618 substituted the + regulation in its current form. values: 2006-04-10: 0.1 metadata: @@ -14,3 +17,11 @@ metadata: href: https://www.legislation.gov.uk/uksi/2006/213/regulation/47 - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg. 45(a) href: https://www.legislation.gov.uk/uksi/2006/214/regulation/45 + - title: The Housing Benefit Regulations (Northern Ireland) 2006 reg. 44(a) + href: https://www.legislation.gov.uk/nisr/2006/405/regulation/44 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations (Northern Ireland) 2006 reg. 43(a) + href: https://www.legislation.gov.uk/nisr/2006/406/regulation/43 + - title: Housing Benefit Guidance Manual BW1, para. W1.430 + href: https://assets.publishing.service.gov.uk/media/5a749328ed915d0e8e3994c0/hbgm-bw1-assessment-of-capital.pdf#page=36 + - title: MH v London Borough of Waltham Forest (HB) [2016] UKUT 161 (AAC), para. 43 + href: https://www.gov.uk/administrative-appeals-tribunal-decisions/mh-v-london-borough-of-waltham-forest-hb-2016-ukut-161-aac diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml index cb504ed3c4..ad9b7d587c 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml @@ -1,12 +1,19 @@ description: > Capital sources whose sale would incur expenses, so that Housing Benefit - values them at 90% of market value. Land and property always have costs of - sale. Shares are sold through a broker, so `corporate_wealth` is listed; it - also holds unit trusts, which have no costs of sale, but the dataset does - not separate them. `savings` is not listed because a bank or building - society account is valued at its balance and withdrawing money is not a sale - (HBGM W1.430, W1.530-W1.531, P1.430 and P1.530-P1.531). Sources not counted - as Housing Benefit capital are ignored. + values them at 90% of market value. Costs of sale normally arise where an + agent, broker, auctioneer, solicitor or accountant is needed, and real + property always has them (HBGM W1.430), so `owned_land`, + `other_residential_property_value` and `non_residential_property_value` are + listed. `savings` is not listed: a bank or building society account is + valued at its balance, and the costs of withdrawing money are not costs of + sale (HBGM BW1 Annex F and BP1 F2.03). `corporate_wealth` is listed because + shares are valued less 10% for the costs of selling them (HBGM W1.513). It + is a mixed variable, though: it also holds unit trusts, which have no costs + of sale (HBGM W1.530-W1.531), stocks and shares ISAs and employee share + options, and datasets built before private pension wealth was split out also + fold non-defined-benefit pension wealth into it, which is not capital at + all. The deduction reaches all of these until the data separate them. + Sources not counted as Housing Benefit capital are ignored. metadata: label: Housing Benefit capital sources with sale expenses period: year @@ -16,10 +23,24 @@ metadata: href: https://www.legislation.gov.uk/uksi/2006/213/regulation/47 - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg. 45(a) href: https://www.legislation.gov.uk/uksi/2006/214/regulation/45 - - title: Housing Benefit Guidance Manual BW1, paras. W1.430 and W1.530-W1.531 + - title: The Housing Benefit Regulations (Northern Ireland) 2006 reg. 44(a) + href: https://www.legislation.gov.uk/nisr/2006/405/regulation/44 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations (Northern Ireland) 2006 reg. 43(a) + href: https://www.legislation.gov.uk/nisr/2006/406/regulation/43 + - title: Housing Benefit Guidance Manual BW1, para. W1.430 href: https://assets.publishing.service.gov.uk/media/5a749328ed915d0e8e3994c0/hbgm-bw1-assessment-of-capital.pdf#page=36 - - title: Housing Benefit Guidance Manual BP1, paras. P1.430 and P1.530-P1.531 + - title: Housing Benefit Guidance Manual BP1, para. P1.430 href: https://assets.publishing.service.gov.uk/media/5a7ec5d840f0b6230268b71f/hbgm-bp1-assessment-of-capital.pdf#page=31 + - title: Housing Benefit Guidance Manual BW1, para. W1.513 + href: https://assets.publishing.service.gov.uk/media/5a749328ed915d0e8e3994c0/hbgm-bw1-assessment-of-capital.pdf#page=43 + - title: Housing Benefit Guidance Manual BW1, paras. W1.530-W1.531 + href: https://assets.publishing.service.gov.uk/media/5a749328ed915d0e8e3994c0/hbgm-bw1-assessment-of-capital.pdf#page=43 + - title: Housing Benefit Guidance Manual BP1, paras. P1.513 and P1.530-P1.531 + href: https://assets.publishing.service.gov.uk/media/5a7ec5d840f0b6230268b71f/hbgm-bp1-assessment-of-capital.pdf#page=38 + - title: Housing Benefit Guidance Manual BW1, Annex F + href: https://assets.publishing.service.gov.uk/media/5a749328ed915d0e8e3994c0/hbgm-bw1-assessment-of-capital.pdf#page=93 + - title: Housing Benefit Guidance Manual BP1, paras. F2.00-F2.03 + href: https://assets.publishing.service.gov.uk/media/5a7ec5d840f0b6230268b71f/hbgm-bp1-assessment-of-capital.pdf#page=83 values: 2006-04-10: - owned_land diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/rate.yaml index 7cb86fe853..dd572337df 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/rate.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/rate.yaml @@ -1,8 +1,10 @@ description: > Share of the current market or surrender value deducted from Income Support - capital where there would be expenses attributable to sale. The deduction - is a flat 10% whatever the actual costs, taken before any incumbrance - secured on the asset. + capital where there would be expenses attributable to sale. It is a flat 10% + whatever the actual costs, taken from the gross value before any encumbrance + secured on the asset is deducted. Before 1 October 2007 the same deduction + sat in reg. 49(a)(i) alongside a separate valuation for National Savings + Certificates; SI 2007/2618 substituted the regulation in its current form. values: 2010-01-01: 0.1 metadata: @@ -12,3 +14,7 @@ metadata: reference: - title: Income Support (General) Regulations 1987 reg. 49(a) href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/49 + - title: Income Support (General) Regulations (Northern Ireland) 1987 reg. 49(a), as substituted by S.R. 2007/396 reg. 4(10) + href: https://www.legislation.gov.uk/nisr/2007/396/regulation/4 + - title: Decision Makers' Guide chapter 29, para. 29624 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=125 diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml index 0af0f836b0..baa3504bda 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml @@ -1,12 +1,19 @@ description: > Capital sources whose sale would incur expenses, so that Income Support - values them at 90% of market value. Land and property always have costs of - sale. Shares are sold through a broker, so `corporate_wealth` is listed; it - also holds unit trusts, which have no costs of sale, but the dataset does - not separate them. `savings` is not listed because a bank or building - society account is valued at its balance and withdrawing money is not a sale - (DMG 29620-29623, 29681 and 29685). Sources not counted as Income Support - capital are ignored. + values them at 90% of market value. Costs of sale normally arise where an + agent, broker, auctioneer, solicitor or accountant is needed, and real + property always has them (DMG 29622), so `owned_land`, + `other_residential_property_value` and `non_residential_property_value` are + listed. `savings` is not listed: a bank or building society account is + valued at its balance, and the costs of withdrawing money are not costs of + sale (DMG 29620-29623 and 29685). `corporate_wealth` is listed because + shares are valued less 10% for the costs of selling them (DMG 29671). It is + a mixed variable, though: it also holds unit trusts, which have no costs of + sale (DMG 29681), stocks and shares ISAs and employee share options, and + datasets built before private pension wealth was split out also fold non- + defined-benefit pension wealth into it, which is not capital at all. The + deduction reaches all of these until the data separate them. Sources not + counted as Income Support capital are ignored. metadata: label: Income Support capital sources with sale expenses period: year @@ -14,8 +21,12 @@ metadata: reference: - title: Income Support (General) Regulations 1987 reg. 49(a) href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/49 + - title: Income Support (General) Regulations (Northern Ireland) 1987 reg. 49(a), as substituted by S.R. 2007/396 reg. 4(10) + href: https://www.legislation.gov.uk/nisr/2007/396/regulation/4 - title: Decision Makers' Guide chapter 29, paras. 29620-29623 href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=124 + - title: Decision Makers' Guide chapter 29, para. 29671 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=137 - title: Decision Makers' Guide chapter 29, paras. 29681 and 29685 href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=139 values: diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/rate.yaml index b344e9dcf2..09c5550767 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/rate.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/rate.yaml @@ -1,8 +1,10 @@ description: > Share of the current market or surrender value deducted from Pension Credit - capital where there would be expenses attributable to sale. The deduction - is a flat 10% whatever the actual costs, taken before any encumbrance - secured on the asset. + capital where there would be expenses attributable to sale. It is a flat 10% + whatever the actual costs, taken from the gross value before any encumbrance + secured on the asset is deducted. Before 1 October 2007 the same deduction + sat in reg. 19(a)(i) alongside a separate valuation for National Savings + Certificates; SI 2007/2618 substituted the regulation in its current form. values: 2003-10-06: 0.1 metadata: @@ -12,3 +14,7 @@ metadata: reference: - title: State Pension Credit Regulations 2002 reg. 19(a) href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/19 + - title: State Pension Credit Regulations (Northern Ireland) 2003 reg. 19(a) + href: https://www.legislation.gov.uk/nisr/2003/28/regulation/19 + - title: Decision Makers' Guide chapter 84, paras. 84712-84716 + href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=99 diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml index da4dae9ae1..1dbbd6816f 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml @@ -1,12 +1,19 @@ description: > Capital sources whose sale would incur expenses, so that Pension Credit - values them at 90% of market value. Land and property always have costs of - sale. Shares are sold through a broker, so `corporate_wealth` is listed; it - also holds unit trusts, which have no costs of sale, but the dataset does - not separate them. `savings` is not listed because a bank or building - society account is valued at its balance and withdrawing money is not a sale - (DMG 84712-84716, 84731 and 84772). Sources not counted as Pension Credit - capital are ignored. + values them at 90% of market value. Costs of sale normally arise where an + agent, broker, auctioneer, solicitor or accountant is needed, and real + property always has them (DMG 84714), so `owned_land`, + `other_residential_property_value` and `non_residential_property_value` are + listed. `savings` is not listed: a bank or building society account is + valued at its balance, and the costs of withdrawing money are not costs of + sale (DMG 84712-84715 and 84731). `corporate_wealth` is listed because + shares are valued less 10% for the costs of selling them (DMG 84763). It is + a mixed variable, though: it also holds unit trusts, which have no costs of + sale (DMG 84772), stocks and shares ISAs and employee share options, and + datasets built before private pension wealth was split out also fold non- + defined-benefit pension wealth into it, which is not capital at all. The + deduction reaches all of these until the data separate them. Sources not + counted as Pension Credit capital are ignored. metadata: label: Pension Credit capital sources with sale expenses period: year @@ -14,10 +21,16 @@ metadata: reference: - title: State Pension Credit Regulations 2002 reg. 19(a) href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/19 - - title: Decision Makers' Guide chapter 84, paras. 84712-84716 + - title: State Pension Credit Regulations (Northern Ireland) 2003 reg. 19(a) + href: https://www.legislation.gov.uk/nisr/2003/28/regulation/19 + - title: Decision Makers' Guide chapter 84, paras. 84712-84715 href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=99 - - title: Decision Makers' Guide chapter 84, paras. 84731 and 84772 + - title: Decision Makers' Guide chapter 84, para. 84731 href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=102 + - title: Decision Makers' Guide chapter 84, para. 84763 + href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=107 + - title: Decision Makers' Guide chapter 84, para. 84772 + href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=109 values: 2003-10-06: - owned_land diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml index 150561614f..fe3a72ab6b 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml @@ -1,8 +1,8 @@ description: > Share of the current market or surrender value deducted from Universal - Credit capital where there would be expenses attributable to sale. The - deduction is a flat 10% whatever the actual costs, taken before any - encumbrance secured on the asset. + Credit capital where there would be expenses attributable to sale. It is a + flat 10% whatever the actual costs, taken from the gross value before any + encumbrance secured on the asset is deducted. values: 2013-04-29: 0.1 metadata: @@ -12,5 +12,7 @@ metadata: reference: - title: The Universal Credit Regulations 2013 reg. 49(1)(a) href: https://www.legislation.gov.uk/uksi/2013/376/regulation/49 + - title: The Universal Credit Regulations (Northern Ireland) 2016 reg. 49(1)(a) + href: https://www.legislation.gov.uk/nisr/2016/216/regulation/49 - title: Advice for Decision Making chapter H1, paras. H1602-H1608 href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=44 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml index 969e9ac619..fe25cbe7d8 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml @@ -1,17 +1,19 @@ description: > Capital sources whose sale would incur expenses, so that Universal Credit - values them at 90% of market value. Real property always has costs of sale - (ADM H1606, citing R(IS) 21/93), so `owned_land`, + values them at 90% of market value. Costs of sale normally arise where an + agent, broker, auctioneer, solicitor or accountant is needed, and real + property always has them (ADM H1606), so `owned_land`, `other_residential_property_value` and `non_residential_property_value` are - listed. `savings` is not listed: the 10% applies only where selling needs - an agent, broker, solicitor or accountant (ADM H1604-H1605), the costs of - withdrawing money from a building society are not costs of sale (H1607), - and an account is valued at its balance (H1675). `corporate_wealth` is - listed because quoted shares are valued less 10% for costs of sale (H1665). - It also holds unit trusts, which have no costs of sale (H1674), and stocks - and shares ISAs, which the guidance values at their withdrawal value - (H1656); the dataset does not separate these, so the deduction also - reaches them. Sources not counted as Universal Credit capital are ignored. + listed. `savings` is not listed: a bank or building society account is + valued at its balance, and the costs of withdrawing money are not costs of + sale (ADM H1604-H1607 and H1675). `corporate_wealth` is listed because + shares are valued less 10% for the costs of selling them (ADM H1665). It is + a mixed variable, though: it also holds unit trusts, which have no costs of + sale (ADM H1674), stocks and shares ISAs and employee share options, and + datasets built before private pension wealth was split out also fold non- + defined-benefit pension wealth into it, which is not capital at all. The + deduction reaches all of these until the data separate them. Sources not + counted as Universal Credit capital are ignored. metadata: label: Universal Credit capital sources with sale expenses period: year @@ -19,10 +21,16 @@ metadata: reference: - title: The Universal Credit Regulations 2013 reg. 49(1)(a) href: https://www.legislation.gov.uk/uksi/2013/376/regulation/49 + - title: The Universal Credit Regulations (Northern Ireland) 2016 reg. 49(1)(a) + href: https://www.legislation.gov.uk/nisr/2016/216/regulation/49 - title: Advice for Decision Making chapter H1, paras. H1604-H1607 href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=44 - - title: Advice for Decision Making chapter H1, paras. H1665 and H1673-H1675 + - title: Advice for Decision Making chapter H1, para. H1656 + href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=55 + - title: Advice for Decision Making chapter H1, para. H1665 href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=57 + - title: Advice for Decision Making chapter H1, paras. H1673-H1675 + href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=59 values: 2013-04-29: - owned_land diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml index abe48a7b5d..106352bad5 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml @@ -212,6 +212,6 @@ corporate_wealth: 2_000 private_pension_wealth: 150_000 output: - # Savings at face value; land, shares and property less 10% for the - # expenses of sale (ESA Regs 2008 reg. 113(a)): 1,000 + 90% of 2,000. + # Savings at face value; shares less 10% for the expenses of sale + # (ESA Regs 2008 reg. 113(a)): 1,000 + 90% of 2,000. esa_income_assessable_capital: 2_800 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_capital.yaml index f90e5f1a9d..85f0ecfb12 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_capital.yaml @@ -235,6 +235,6 @@ corporate_wealth: 2000 private_pension_wealth: 150000 output: - # Savings at face value; land, shares and property less 10% for the - # expenses of sale (IS Regs 1987 reg. 49(a)): 1,000 + 90% of 2,000. + # Savings at face value; shares less 10% for the expenses of sale + # (IS Regs 1987 reg. 49(a)): 1,000 + 90% of 2,000. income_support_assessable_capital: 2800 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_capital.yaml index bc50925503..ffea263934 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_capital.yaml @@ -186,6 +186,6 @@ corporate_wealth: 2_000 private_pension_wealth: 150_000 output: - # Savings at face value; land, shares and property less 10% for the - # expenses of sale (JSA Regs 1996 reg. 111(a)): 1,000 + 90% of 2,000. + # Savings at face value; shares less 10% for the expenses of sale + # (JSA Regs 1996 reg. 111(a)): 1,000 + 90% of 2,000. jsa_income_assessable_capital: 2_800 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml index 840b084ec1..1b4870b502 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml @@ -1,8 +1,9 @@ # Capital is valued at market value less 10% where a sale would incur # expenses, and less any encumbrance secured on it: UC Regs 2013 reg. 49(1); # HB Regs 2006 reg. 47; IS Regs 1987 reg. 49; JSA Regs 1996 reg. 111; ESA Regs -# 2008 reg. 113; SPC Regs 2002 reg. 19. Savings in a bank or building society -# have no sale expenses; land, property and shares do (ADM H1604-H1607). +# 2008 reg. 113; SPC Regs 2002 reg. 19. A bank or building society account is +# valued at its balance (ADM H1604-H1607, H1675); land and property always have +# costs of sale (H1606) and shares are valued less 10% (H1665). # Expected values are worked by hand from those provisions. - name: UC values a mortgaged holiday home as in ADM H1602 @@ -216,3 +217,79 @@ pension_credit_assessable_capital: 19_000 # 18 steps of 500 above 10,000 at 1 a week: 18 * 52. pension_credit_deemed_income: 936 + +- name: UC floors each encumbered source at nil on its own + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # The second home is in negative equity (90,000 - 150,000), so it is + # valued at nil; the business premises keep 90% of 10,000. Netting the + # debts across both would wrongly give nil. + other_residential_property_value: 100_000 + other_residential_property_secured_debt: 150_000 + non_residential_property_value: 10_000 + output: + uc_assessable_capital: 9_000 + +- name: Housing Benefit values mortgaged second homes and premises less sale expenses + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + in_social_housing: true + housing_benefit_reported: 1 + benunits: + benunit: + members: person + would_claim_uc: false + households: + household: + members: person + # 1,000 + (90% of 50,000 - 30,000) + max(0, 90% of 20,000 - 25,000) + # = 1,000 + 15,000 + 0 = 16,000, at the limit (HB Regs 2006 reg. 47). + savings: 1_000 + other_residential_property_value: 50_000 + other_residential_property_secured_debt: 30_000 + non_residential_property_value: 20_000 + non_residential_property_secured_debt: 25_000 + output: + housing_benefit_assessable_capital: 16_000 + # 40 steps of 250 above 6,000 at 1 a week: 40 * 52. + housing_benefit_tariff_income: 2_080 + housing_benefit_eligible: true + +- name: Pension-age Housing Benefit values encumbered land less sale expenses + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 70 + benunits: + benunit: + members: person + households: + household: + members: person + # 90% of 200,000 - 50,000 = 130,000 (HB (SPC) Regs 2006 reg. 45). Its + # Pension Credit deemed income, 240 steps of 500 above 10,000 at 1 a + # week (12,480 a year), exceeds the 12,376 guarantee, so there is no + # guarantee credit to disregard the capital. + owned_land: 200_000 + owned_land_secured_debt: 50_000 + output: + pension_credit_assessable_capital: 130_000 + pension_credit_deemed_income: 12_480 + guarantee_credit: 0 + housing_benefit_assessable_capital: 130_000 diff --git a/policyengine_uk/tests/test_capital_valuation_properties.py b/policyengine_uk/tests/test_capital_valuation_properties.py index 76a7aa5d1d..3ccf6d7eb3 100644 --- a/policyengine_uk/tests/test_capital_valuation_properties.py +++ b/policyengine_uk/tests/test_capital_valuation_properties.py @@ -5,8 +5,9 @@ be expenses attributable to sale, and less any encumbrance secured on it (UC Regs 2013 reg. 49(1); HB Regs 2006 reg. 47; HB (SPC) Regs 2006 reg. 45; IS Regs 1987 reg. 49; JSA Regs 1996 reg. 111; ESA Regs 2008 reg. 113; SPC Regs -2002 reg. 19). Savings in a bank or building society incur no sale expenses; -land, property and shares do. +2002 reg. 19). A bank or building society account is valued at its balance +(ADM H1675); land and property always have costs of sale (H1606) and shares +are valued less 10% (H1665). Invariants, for any generated population of single-adult households: @@ -15,11 +16,15 @@ 2. Cash only: a household holding only savings has the same capital with the deduction on or off. 3. Differential: capital equals a direct implementation of the regulation, - savings + sum over sources of max(0, 90% of value - secured debt). + savings + sum over sources of max(0, 90% of value - secured debt), where + the test itself fixes which assets take the 10% (land, property and + shares, not cash), so a wrong parameter list fails it. 4. Monotone: a higher sale-expense rate, or more secured debt, never raises capital. -5. Isolation: debt secured on one asset never takes capital below the value - of the household's other assets, and capital is never negative. +5. Isolation: debt secured on one source never takes capital below the value + of the household's unencumbered sources, and capital is never negative. + (Isolation between two encumbered sources is pinned by the differential and + by a YAML case.) """ from functools import reduce @@ -38,9 +43,10 @@ suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], ) # Assessable capital variable -> parameter node holding its sources and -# sale-expense rule. Pension Credit counts only pension-age adults; the -# others are tested on working-age adults (pension-age Housing Benefit -# disregards all capital for Guarantee Credit recipients). +# sale-expense rule. Pension Credit counts only pension-age adults; Housing +# Benefit is checked at both ages, disregarding all capital for pension-age +# Guarantee Credit recipients (HB (SPC) Regs 2006 reg. 26); the others are +# checked on working-age adults. PROGRAMMES = { "uc_assessable_capital": "gov.dwp.universal_credit.means_test.capital", "housing_benefit_assessable_capital": "gov.dwp.housing_benefit.means_test.capital", @@ -50,6 +56,16 @@ "pension_credit_assessable_capital": "gov.dwp.pension_credit.income.capital", } PENSION_AGE_ONLY = {"pension_credit_assessable_capital"} +# The assets whose sale incurs expenses, fixed here from the law rather than +# read from the parameters: real property always (ADM H1606), shares (H1665); +# never a bank or building society balance (H1675). +SALE_EXPENSE_ASSETS = { + "owned_land", + "other_residential_property_value", + "non_residential_property_value", + "corporate_wealth", +} +LEGAL_RATE = 0.1 ASSETS = [ "savings", "owned_land", @@ -99,14 +115,18 @@ def calculate(units, rate=None, debt_scale=1.0): situation=situation(units, debt_scale), reform=None if rate is None else rate_reform(rate), ) - return sim, {v: np.asarray(sim.calculate(v, YEAR), dtype=float) for v in PROGRAMMES} + values = {v: np.asarray(sim.calculate(v, YEAR), dtype=float) for v in PROGRAMMES} + values["guarantee_credit"] = np.asarray(sim.calculate("guarantee_credit", YEAR)) + return sim, values def parameter_node(sim, path): return reduce(getattr, path.split("."), sim.tax_benefit_system.parameters(YEAR)) -def applies(variable, unit): +def applies(variable, unit, guarantee_credit=0.0): + if variable == "housing_benefit_assessable_capital": + return unit["age"] < 67 or guarantee_credit <= 0 return (unit["age"] >= 67) == (variable in PENSION_AGE_ONLY) @@ -115,8 +135,8 @@ def expected_capital(unit, node): total = 0.0 for source in node.sources: value = unit["assets"][source] - if source in node.sale_expenses.sources: - value *= 1 - node.sale_expenses.rate + if source in SALE_EXPENSE_ASSETS: + value *= 1 - LEGAL_RATE if source in DEBTS: value = max(0.0, value - unit["debts"][DEBTS[source]]) total += value @@ -128,25 +148,31 @@ def close(a, b): @PROPERTY_SETTINGS -@given(st.lists(households(), min_size=1, max_size=30)) +@given(st.lists(households(), min_size=20, max_size=200)) def test_valuation_matches_the_regulation_and_never_adds_capital(units): sim, valued = calculate(units) _, full = calculate(units, rate=0.0, debt_scale=0.0) for variable, path in PROGRAMMES.items(): node = parameter_node(sim, path) - assert node.sale_expenses.rate == 0.1 + assert node.sale_expenses.rate == LEGAL_RATE assert "savings" in node.sources - assert "savings" not in node.sale_expenses.sources + counted = set(node.sources) + assert set(node.sale_expenses.sources) & counted == ( + SALE_EXPENSE_ASSETS & counted + ), variable for i, unit in enumerate(units): + gc = valued["guarantee_credit"][i] assert valued[variable][i] >= 0, (variable, unit) assert valued[variable][i] <= full[variable][i] + 0.01, (variable, unit) - if applies(variable, unit): + if variable == "housing_benefit_assessable_capital" and unit["age"] >= 67: + if gc > 0: + assert valued[variable][i] == 0, unit + if applies(variable, unit, gc): expected = expected_capital(unit, node) assert close(valued[variable][i], expected), (variable, unit) # Debt secured on property never reaches the other assets. floor = sum( - unit["assets"][s] - * (1 - node.sale_expenses.rate * (s in node.sale_expenses.sources)) + unit["assets"][s] * (1 - LEGAL_RATE * (s in SALE_EXPENSE_ASSETS)) for s in node.sources if s not in DEBTS ) @@ -154,20 +180,20 @@ def test_valuation_matches_the_regulation_and_never_adds_capital(units): @PROPERTY_SETTINGS -@given(st.lists(households(cash_only=True), min_size=1, max_size=30)) +@given(st.lists(households(cash_only=True), min_size=20, max_size=200)) def test_cash_only_households_have_no_deduction(units): _, valued = calculate(units) _, full = calculate(units, rate=0.0) for variable in PROGRAMMES: assert np.allclose(valued[variable], full[variable]), variable for i, unit in enumerate(units): - if applies(variable, unit): + if applies(variable, unit, valued["guarantee_credit"][i]): assert close(valued[variable][i], unit["assets"]["savings"]), variable @PROPERTY_SETTINGS @given( - st.lists(households(), min_size=1, max_size=30), + st.lists(households(), min_size=20, max_size=200), st.floats(0, 0.5), st.floats(0, 0.5), st.floats(0, 2), diff --git a/policyengine_uk/tests/test_stock_capital_periods.py b/policyengine_uk/tests/test_stock_capital_periods.py index e694045d13..eead7689d9 100644 --- a/policyengine_uk/tests/test_stock_capital_periods.py +++ b/policyengine_uk/tests/test_stock_capital_periods.py @@ -15,6 +15,7 @@ def test_uc_capital_stocks_are_not_prorated_in_monthly_calculations(): "members": ["person"], "savings": {"2026": 12_000}, "other_residential_property_value": {"2026": 12_000}, + "other_residential_property_secured_debt": {"2026": 5_000}, "corporate_wealth": {"2026": 12_000}, } }, @@ -23,9 +24,14 @@ 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("other_residential_property_secured_debt", "2026-01")[0] + == 5_000 + ) # Savings at face value; property and shares less 10% for the expenses - # of sale (UC Regs 2013 reg. 49(1)(a)): 12,000 + 2 * 10,800. - assert simulation.calculate("uc_assessable_capital", "2026-01")[0] == 33_600 + # of sale, and the property less its mortgage (UC Regs 2013 reg. 49(1)): + # 12,000 + (10,800 - 5,000) + 10,800. + assert simulation.calculate("uc_assessable_capital", "2026-01")[0] == 28_600 assert not simulation.calculate("is_uc_eligible", "2026-01")[0] diff --git a/policyengine_uk/utils/capital_valuation.py b/policyengine_uk/utils/capital_valuation.py index 2e70896a1a..8c76af629d 100644 --- a/policyengine_uk/utils/capital_valuation.py +++ b/policyengine_uk/utils/capital_valuation.py @@ -1,19 +1,22 @@ """Value means-tested capital as the capital-valuation regulations require. -Universal Credit and each legacy means test value capital in the United -Kingdom "at its current market or surrender value less (a) where there would -be expenses attributable to sale, 10% [...]; and (b) the amount of any -encumbrances secured on it" (UC Regs 2013 reg. 49(1) and its legacy -equivalents). Each programme sets its own rate and the capital sources whose -sale would incur expenses in a ``capital.sale_expenses`` parameter node. +Universal Credit calculates capital "at its current market value or surrender +value less— (a) where there would be expenses attributable to sale, 10%; and +(b) the amount of any encumbrances secured on it" (UC Regs 2013 reg. 49(1)). +The legacy means tests have equivalent rules (HB Regs 2006 reg. 47, IS Regs +1987 reg. 49, JSA Regs 1996 reg. 111, ESA Regs 2008 reg. 113, SPC Regs 2002 +reg. 19). Each programme sets its own rate and the capital sources whose sale +would incur expenses in a ``capital.sale_expenses`` parameter node. """ from policyengine_core.model_api import max_ -# Household inputs holding the debts secured on each capital source, such as -# a mortgage on a second property. The encumbrance is deducted only from the -# asset it is secured on, so negative equity in one asset never reduces the -# value of another (ADM H1615-H1616). +# Household inputs holding the debt secured on each capital source, such as a +# mortgage on a second property. The debt is deducted from the source it is +# secured on and that source is floored at nil, so negative equity in one +# source never reduces another (ADM H1615-H1616). Each source is a category +# summed over the household's assets, so within a category the debts on one +# asset still offset equity in another. SECURED_DEBT = { "other_residential_property_value": "other_residential_property_secured_debt", "non_residential_property_value": "non_residential_property_secured_debt", @@ -25,9 +28,9 @@ def valued_capital(value_of, sources, sale_expenses): """Sum ``sources`` at market value less sale expenses and secured debt. ``value_of(variable)`` returns that household variable on the entity the - caller is working with. The sale-expense deduction comes first and the - encumbrance second (ADM H1608), and an encumbered asset is never valued - below nil. + caller is working with. The sale-expense deduction is taken from the gross + value before the encumbrance (ADM H1608), and a source carrying secured + debt is never valued below nil. """ total = 0 for source in sources: diff --git a/policyengine_uk/variables/input/non_residential_property_secured_debt.py b/policyengine_uk/variables/input/non_residential_property_secured_debt.py index 3dc39a2996..f5da202655 100644 --- a/policyengine_uk/variables/input/non_residential_property_secured_debt.py +++ b/policyengine_uk/variables/input/non_residential_property_secured_debt.py @@ -6,8 +6,10 @@ class non_residential_property_secured_debt(Variable): documentation = ( "Amount owed on mortgages and other charges secured on non-residential " "property the household owns. Means tests deduct it from that " - "property's value (UC Regs 2013 reg. 49(1)(b) and its legacy " - "equivalents)." + "property's value after the 10% for sale expenses, flooring the " + "property at nil (UC Regs 2013 reg. 49(1)(b) and its legacy " + "equivalents). The value and the debt are household totals across all " + "such properties, so debt on one property offsets equity in another." ) entity = Household definition_period = YEAR diff --git a/policyengine_uk/variables/input/other_residential_property_secured_debt.py b/policyengine_uk/variables/input/other_residential_property_secured_debt.py index 50ec795c0d..340e8842fd 100644 --- a/policyengine_uk/variables/input/other_residential_property_secured_debt.py +++ b/policyengine_uk/variables/input/other_residential_property_secured_debt.py @@ -7,7 +7,10 @@ class other_residential_property_secured_debt(Variable): "Amount owed on mortgages and other charges secured on residential " "property the household owns other than the main residence, such as a " "buy-to-let mortgage. Means tests deduct it from that property's value " - "(UC Regs 2013 reg. 49(1)(b) and its legacy equivalents)." + "after the 10% for sale expenses, flooring the property at nil (UC Regs " + "2013 reg. 49(1)(b) and its legacy equivalents). The value and the debt " + "are household totals across all such properties, so debt on one " + "property offsets equity in another." ) entity = Household definition_period = YEAR diff --git a/policyengine_uk/variables/input/owned_land_secured_debt.py b/policyengine_uk/variables/input/owned_land_secured_debt.py index b58b570051..5f8380682c 100644 --- a/policyengine_uk/variables/input/owned_land_secured_debt.py +++ b/policyengine_uk/variables/input/owned_land_secured_debt.py @@ -5,8 +5,11 @@ class owned_land_secured_debt(Variable): label = "debt secured on owned land" documentation = ( "Amount owed on mortgages and other charges secured on land-only plots " - "the household owns. Means tests deduct it from that land's value (UC " - "Regs 2013 reg. 49(1)(b) and its legacy equivalents)." + "the household owns. Means tests deduct it from that land's value after " + "the 10% for sale expenses, flooring the land at nil (UC Regs 2013 reg. " + "49(1)(b) and its legacy equivalents). The value and the debt are " + "household totals across all plots, so debt on one plot offsets equity " + "in another." ) entity = Household definition_period = YEAR From e51391f3c4e756a289f2a5e02b89fee57698c26c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 22:18:21 -0400 Subject: [PATCH 03/38] Cite R(IS) 21/93 for real property in the HB sale-expense sources HBGM W1.430 states only the general 10% rule; the rule that real property always has costs of sale comes from R(IS) 21/93, as DMG 29622 applies it. Co-Authored-By: Claude Opus 5.5 --- .../means_test/capital/sale_expenses/sources.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml index ad9b7d587c..f57c9f5bd7 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml @@ -2,7 +2,8 @@ description: > Capital sources whose sale would incur expenses, so that Housing Benefit values them at 90% of market value. Costs of sale normally arise where an agent, broker, auctioneer, solicitor or accountant is needed, and real - property always has them (HBGM W1.430), so `owned_land`, + property always has them (R(IS) 21/93, as DMG 29622 applies it; HBGM W1.430 + states the general 10% rule), so `owned_land`, `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of @@ -31,6 +32,8 @@ metadata: href: https://assets.publishing.service.gov.uk/media/5a749328ed915d0e8e3994c0/hbgm-bw1-assessment-of-capital.pdf#page=36 - title: Housing Benefit Guidance Manual BP1, para. P1.430 href: https://assets.publishing.service.gov.uk/media/5a7ec5d840f0b6230268b71f/hbgm-bp1-assessment-of-capital.pdf#page=31 + - title: Decision Makers' Guide chapter 29, para. 29622 (real property, R(IS) 21/93) + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=124 - title: Housing Benefit Guidance Manual BW1, para. W1.513 href: https://assets.publishing.service.gov.uk/media/5a749328ed915d0e8e3994c0/hbgm-bw1-assessment-of-capital.pdf#page=43 - title: Housing Benefit Guidance Manual BW1, paras. W1.530-W1.531 From 22274cad14581256419fc2c445cb5b6a6df6d5ba Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 22:05:05 -0400 Subject: [PATCH 04/38] Value capital under reg 19 in two Pension Credit tests from main Two tests main added after this branch's earlier base assumed full-value capital: - The CTR savings-credit-only case holds savings 10,000 and a 7,000 second property: capital is 10,000 + 90% of 7,000 = 16,300 (SPC Regs 2002 reg. 19(a)), still over the 16,000 limit, so CTR stays nil. Deemed income is 13 steps x 52 = 676, Pension Credit income 13,314 + 676 = 13,990, and savings credit 60% of (12,376 - 208.07 x 52) less 40% of (13,990 - 12,376) = 288.22. - The reported-capital property test's proxy oracle now values land, property and shares at 90% and savings at face value. Co-Authored-By: Claude Opus 5.5 --- .../council_tax_reduction_pension_credit.yaml | 10 +++++++--- ...t_pension_credit_reported_capital_properties.py | 14 ++++++++++---- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml index 6e52f66d3d..146a5a399e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml @@ -466,12 +466,16 @@ savings: 10_000 other_residential_property_value: 7_000 output: - savings_credit: 267.42 + # Capital 10,000 + 90% of 7,000 = 16,300 (SPC Regs 2002 reg. 19(a)), so + # deemed income is 13 steps x 52 = 676 and Pension Credit income is + # 13,314 + 676 = 13,990. Savings credit: 60% of (12,376 - 208.07 x 52) + # = 933.82, less 40% of (13,990 - 12,376) = 645.60, leaves 288.22. + savings_credit: 288.22 in_receipt_of_savings_credit_only: true # Pension Credit counts the second property as capital; household # savings alone (10,000) are under the limit. Before: CTR 1,999.60. - pension_credit_assessable_capital: 17_000 - council_tax_reduction_assessable_capital: 17_000 + pension_credit_assessable_capital: 16_300 + council_tax_reduction_assessable_capital: 16_300 council_tax_reduction: 0 - name: The same pensioner not claiming Pension Credit is tested on household savings only diff --git a/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py b/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py index cf5fef6f30..d3a1a9b2ca 100644 --- a/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py +++ b/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py @@ -11,9 +11,11 @@ 1. Override identity: a pension-age unit with reported capital R >= 0 has assessable capital R, whatever the household holds. 2. Default differential: with nothing reported, assessable capital equals the - household proxy recomputed here from the inputs (household sources times - the unit's share of the household's pension-age adults, plus person-level - sources of the claimant or partner), the formula before this change. + household proxy recomputed here from the inputs (household sources, valued + under SPC Regs 2002 reg. 19 with savings at face value and the others at + 90%, times the unit's share of the household's pension-age adults, plus + person-level sources of the claimant or partner), the formula before this + change. 3. Locality: reporting capital for one unit never changes another unit's assessable capital. 4. Bounds: assessable capital is never negative, and is 0 for a unit with no @@ -102,7 +104,11 @@ def proxy(people, units, household, index): "non_residential_property_value", "corporate_wealth", ] - household_capital = sum(household.get(s, 0) for s in sources) + # SPC Regs 2002 reg. 19(a): sources whose sale would incur expenses (land, + # property, shares) are valued at 90%; savings at face value. + household_capital = sum( + household.get(s, 0) * (1 if s == "savings" else 0.9) for s in sources + ) sp_age = {name: p["age"] >= 66 for name, p in people.items()} unit = units[index]["members"] unit_sp = sum(sp_age[n] for n in unit) From f3929c1918b4d75bd1e2d0306b6019f4ebaf3c59 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 20:56:28 -0400 Subject: [PATCH 05/38] Value capital under reg 19 in the corporate_wealth CTR case from main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit £20,000 of shares is £18,000 of Pension Credit capital (SPC Regs 2002 reg. 19(a)), still over the CTR limit: deemed income 16 x 52 = 832, Pension Credit income 14,146, savings credit 933.816 - 0.4 x 1,770 = 225.82. Co-Authored-By: Claude Opus 5.5 --- .../council_tax_reduction_pension_credit.yaml | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml index e745af108f..5a29964d97 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml @@ -659,20 +659,21 @@ savings: 0 corporate_wealth: 20_000 output: - # (12,000 + 1,500 - 12,570) x 0.2 = 186. (20,000 - 10,000) / 500 = 20 - # steps of £1 a week; 20 x 52 = 1,040 of deemed income. - pension_credit_assessable_capital: 20_000 - pension_credit_deemed_income: 1_040 - # 13,500 - 186 + 1,040 = 14,354, above the minimum guarantee of 12,376. - pension_credit_income: 14_354 + # (12,000 + 1,500 - 12,570) x 0.2 = 186. Shares are valued at 90% for the + # expenses of sale (SPC Regs 2002 reg. 19(a)): 18,000. (18,000 - 10,000) / + # 500 = 16 steps of £1 a week; 16 x 52 = 832 of deemed income. + pension_credit_assessable_capital: 18_000 + pension_credit_deemed_income: 832 + # 13,500 - 186 + 832 = 14,146, above the minimum guarantee of 12,376. + pension_credit_income: 14_146 guarantee_credit: 0 - # 933.816 - 0.4 x (14,354 - 12,376) = 933.816 - 791.20 = 142.616. - savings_credit: 142.62 + # 933.816 - 0.4 x (14,146 - 12,376) = 933.816 - 708 = 225.816. + savings_credit: 225.82 in_receipt_of_savings_credit_only: true - # The Pension Credit capital is 20,000, over 16,000, with nil savings in + # The Pension Credit capital is 18,000, over 16,000, with nil savings in # the household. The same pensioner with 20,000 of private_pension_wealth # keeps CTR in the next case. - council_tax_reduction_assessable_capital: 20_000 + council_tax_reduction_assessable_capital: 18_000 council_tax_reduction: 0 - name: Savings credit only, private_pension_wealth is not Pension Credit capital, so it does not take the pensioner over the CTR capital limit From 54ed063b1bd93821dfb90373d12842c2918d1da9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 10:48:18 -0400 Subject: [PATCH 06/38] Skip passported families in the HB valuation property Main's Housing Benefit passport (#1927 and the Universal Credit limb) gives a family on a passporting benefit nil HB capital, so the property's independent reg 47 valuation applies only to non-passported families. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_capital_valuation_properties.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/test_capital_valuation_properties.py b/policyengine_uk/tests/test_capital_valuation_properties.py index 3ccf6d7eb3..4bafb0c51d 100644 --- a/policyengine_uk/tests/test_capital_valuation_properties.py +++ b/policyengine_uk/tests/test_capital_valuation_properties.py @@ -117,6 +117,9 @@ def calculate(units, rate=None, debt_scale=1.0): ) values = {v: np.asarray(sim.calculate(v, YEAR), dtype=float) for v in PROGRAMMES} values["guarantee_credit"] = np.asarray(sim.calculate("guarantee_credit", YEAR)) + values["hb_passported"] = np.asarray( + sim.calculate("housing_benefit_on_passporting_benefit", YEAR) + ) return sim, values @@ -124,8 +127,12 @@ def parameter_node(sim, path): return reduce(getattr, path.split("."), sim.tax_benefit_system.parameters(YEAR)) -def applies(variable, unit, guarantee_credit=0.0): +def applies(variable, unit, guarantee_credit=0.0, hb_passported=False): if variable == "housing_benefit_assessable_capital": + # A family on a passporting benefit has nil HB capital + # (housing_benefit_on_passporting_benefit, on main since #1927). + if hb_passported: + return False return unit["age"] < 67 or guarantee_credit <= 0 return (unit["age"] >= 67) == (variable in PENSION_AGE_ONLY) @@ -167,7 +174,7 @@ def test_valuation_matches_the_regulation_and_never_adds_capital(units): if variable == "housing_benefit_assessable_capital" and unit["age"] >= 67: if gc > 0: assert valued[variable][i] == 0, unit - if applies(variable, unit, gc): + if applies(variable, unit, gc, valued["hb_passported"][i]): expected = expected_capital(unit, node) assert close(valued[variable][i], expected), (variable, unit) # Debt secured on property never reaches the other assets. @@ -187,7 +194,12 @@ def test_cash_only_households_have_no_deduction(units): for variable in PROGRAMMES: assert np.allclose(valued[variable], full[variable]), variable for i, unit in enumerate(units): - if applies(variable, unit, valued["guarantee_credit"][i]): + if applies( + variable, + unit, + valued["guarantee_credit"][i], + valued["hb_passported"][i], + ): assert close(valued[variable][i], unit["assets"]["savings"]), variable From 3abaf166b0275ea86816330d1d7332be48088e4c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 13 Oct 2026 08:35:35 -0400 Subject: [PATCH 07/38] Assert nil HB capital for passported units and keep non-passported cover Review of 54ed063b1: half the generated units now decline Universal Credit, so the Housing Benefit passport cannot remove every working-age unit from the reg 47 differential, and a passported unit's HB capital is asserted to be nil rather than skipped. Co-Authored-By: Claude Opus 5.5 --- .../test_capital_valuation_properties.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/test_capital_valuation_properties.py b/policyengine_uk/tests/test_capital_valuation_properties.py index 4bafb0c51d..a78368ba5e 100644 --- a/policyengine_uk/tests/test_capital_valuation_properties.py +++ b/policyengine_uk/tests/test_capital_valuation_properties.py @@ -88,14 +88,25 @@ def households(draw, cash_only=False): if cash_only: assets = {asset: 0.0 for asset in ASSETS} | {"savings": assets["savings"]} debts = {debt: 0.0 for debt in debts} - return dict(age=draw(st.sampled_from([30, 70])), assets=assets, debts=debts) + # About half the units do not claim Universal Credit, so the Housing + # Benefit passport cannot remove every working-age unit from the + # differential below. + return dict( + age=draw(st.sampled_from([30, 70])), + claims_uc=draw(st.booleans()), + assets=assets, + debts=debts, + ) def situation(units, debt_scale=1.0): people, benunits, homes = {}, {}, {} for i, unit in enumerate(units): people[f"p{i}"] = {"age": {YEAR: unit["age"]}} - benunits[f"b{i}"] = {"members": [f"p{i}"]} + benunits[f"b{i}"] = { + "members": [f"p{i}"], + "would_claim_uc": {YEAR: unit["claims_uc"]}, + } homes[f"h{i}"] = { "members": [f"p{i}"], **{k: {YEAR: v} for k, v in unit["assets"].items()}, @@ -171,8 +182,8 @@ def test_valuation_matches_the_regulation_and_never_adds_capital(units): gc = valued["guarantee_credit"][i] assert valued[variable][i] >= 0, (variable, unit) assert valued[variable][i] <= full[variable][i] + 0.01, (variable, unit) - if variable == "housing_benefit_assessable_capital" and unit["age"] >= 67: - if gc > 0: + if variable == "housing_benefit_assessable_capital": + if valued["hb_passported"][i] or (unit["age"] >= 67 and gc > 0): assert valued[variable][i] == 0, unit if applies(variable, unit, gc, valued["hb_passported"][i]): expected = expected_capital(unit, node) From 1efee62b697588f2fbc8f48db3709c5d526d86c0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 13 Oct 2026 09:22:18 -0400 Subject: [PATCH 08/38] Guarantee both Housing Benefit branches are exercised MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delta review of b54167756 (F1): coverage of the HB passport-nil assertion and the working-age reg 47 differential depended on Hypothesis draws. The checks move into check_valuation(), which counts the branches it ran, and a fixed test asserts both run: a UC claimant with £5,000 of savings (passported, nil HB capital) and a non-claimant with a mortgaged second home (valued under reg 47). Co-Authored-By: Claude Opus 5.5 --- .../test_capital_valuation_properties.py | 49 ++++++++++++++++--- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/test_capital_valuation_properties.py b/policyengine_uk/tests/test_capital_valuation_properties.py index a78368ba5e..ad3e253d7e 100644 --- a/policyengine_uk/tests/test_capital_valuation_properties.py +++ b/policyengine_uk/tests/test_capital_valuation_properties.py @@ -165,11 +165,11 @@ def close(a, b): return abs(a - b) <= 1 + 1e-6 * abs(b) -@PROPERTY_SETTINGS -@given(st.lists(households(), min_size=20, max_size=200)) -def test_valuation_matches_the_regulation_and_never_adds_capital(units): +def check_valuation(units): + """Assert the valuation invariants; count which Housing Benefit branches ran.""" sim, valued = calculate(units) _, full = calculate(units, rate=0.0, debt_scale=0.0) + hb_checked = {"passported_nil": 0, "working_age_differential": 0} for variable, path in PROGRAMMES.items(): node = parameter_node(sim, path) assert node.sale_expenses.rate == LEGAL_RATE @@ -178,16 +178,21 @@ def test_valuation_matches_the_regulation_and_never_adds_capital(units): assert set(node.sale_expenses.sources) & counted == ( SALE_EXPENSE_ASSETS & counted ), variable + hb = variable == "housing_benefit_assessable_capital" for i, unit in enumerate(units): gc = valued["guarantee_credit"][i] assert valued[variable][i] >= 0, (variable, unit) assert valued[variable][i] <= full[variable][i] + 0.01, (variable, unit) - if variable == "housing_benefit_assessable_capital": - if valued["hb_passported"][i] or (unit["age"] >= 67 and gc > 0): - assert valued[variable][i] == 0, unit + if hb and valued["hb_passported"][i]: + assert valued[variable][i] == 0, unit + hb_checked["passported_nil"] += 1 + if hb and unit["age"] >= 67 and gc > 0: + assert valued[variable][i] == 0, unit if applies(variable, unit, gc, valued["hb_passported"][i]): expected = expected_capital(unit, node) assert close(valued[variable][i], expected), (variable, unit) + if hb and unit["age"] < 67 and expected > 0: + hb_checked["working_age_differential"] += 1 # Debt secured on property never reaches the other assets. floor = sum( unit["assets"][s] * (1 - LEGAL_RATE * (s in SALE_EXPENSE_ASSETS)) @@ -195,6 +200,38 @@ def test_valuation_matches_the_regulation_and_never_adds_capital(units): if s not in DEBTS ) assert valued[variable][i] >= floor - 1, (variable, unit) + return hb_checked + + +@PROPERTY_SETTINGS +@given(st.lists(households(), min_size=20, max_size=200)) +def test_valuation_matches_the_regulation_and_never_adds_capital(units): + check_valuation(units) + + +def test_housing_benefit_branches_are_both_exercised(): + """Fixed units that a passport cannot hide: one claims UC with £5,000 of + savings (UC passports it to nil HB capital), one declines UC and holds a + mortgaged second home (its HB capital is checked against reg 47).""" + no_debt = {debt: 0.0 for debt in DEBTS.values()} + units = [ + dict( + age=30, + claims_uc=True, + assets={asset: 0.0 for asset in ASSETS} | {"savings": 5_000.0}, + debts=no_debt, + ), + dict( + age=30, + claims_uc=False, + assets={asset: 0.0 for asset in ASSETS} + | {"savings": 2_000.0, "other_residential_property_value": 100_000.0}, + debts=no_debt | {"other_residential_property_secured_debt": 60_000.0}, + ), + ] + hb_checked = check_valuation(units) + assert hb_checked["passported_nil"] >= 1 + assert hb_checked["working_age_differential"] >= 1 @PROPERTY_SETTINGS From 2eb4e526e4a87cfc66a861b896dd94ba04389eb5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 15:53:00 -0400 Subject: [PATCH 09/38] Value the savings-credit-only capital test under reg 19 Savings 12,000 plus a 5,000 property is 12,000 + 4,500 = 16,500 of Pension Credit capital (SPC Regs 2002 reg. 19(a)), still over the 16,000 CTR limit. Deemed income is 13 a week, so the savings credit is 0.6 x 29.93 - 0.4 x 15 = 11.958 a week (621.82). Co-Authored-By: Claude Opus 5.5 --- ...st_savings_credit_only_receipt_properties.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/policyengine_uk/tests/test_savings_credit_only_receipt_properties.py b/policyengine_uk/tests/test_savings_credit_only_receipt_properties.py index 98133c2046..c7bfaf6e8f 100644 --- a/policyengine_uk/tests/test_savings_credit_only_receipt_properties.py +++ b/policyengine_uk/tests/test_savings_credit_only_receipt_properties.py @@ -396,7 +396,8 @@ def test_known_families_detect_main(): ) assert not np.any(fix[FLAG]) # Main keeps the Pension Credit assessment. The owner with capital is - # tested on 17,000 against the 16,000 limit and gets no council tax + # tested on 16,500 (12,000 of savings and 90% of the 5,000 property, + # SPC Regs 2002 reg. 19(a)) against the 16,000 limit and gets no council tax # reduction, where the scheme's own rules give 2,000 on savings of # 12,000. The tenant has no Housing Benefit tariff income, where # Housing Benefit's own rules count 4 a week on savings of 12,000 @@ -499,16 +500,18 @@ def read(simulation, variable): def test_stopping_pension_credit_payments_ends_the_pension_credit_capital_test(): # Savings of 12,000 and other property worth 5,000. The Pension Credit - # assessment of capital counts both (17,000), above the 16,000 limit, so - # the savings-credit-only award leaves no council tax reduction. Deemed - # income is 1 a week for each 500 over 10,000: 14 a week, so the savings - # credit is 0.6 x 29.93 - 0.4 x (254.00 - 238.00) = 11.558 a week (601.02). + # assessment of capital counts both, the property less 10% for the + # expenses of sale (SPC Regs 2002 reg. 19(a)): 12,000 + 4,500 = 16,500, + # above the 16,000 limit, so the savings-credit-only award leaves no + # council tax reduction. Deemed income is 1 a week for each 500 or part + # over 10,000: 13 a week, so the savings credit is 0.6 x 29.93 - 0.4 x + # (253.00 - 238.00) = 11.958 a week (621.82). data = single_pensioner(savings=12_000.0, other_property=5_000.0) unreformed = Simulation(situation=data) - assert abs(unreformed.calculate("savings_credit", YEAR)[0] - 601.02) < 0.01 + assert abs(unreformed.calculate("savings_credit", YEAR)[0] - 621.82) < 0.01 assert unreformed.calculate(FLAG, YEAR).tolist() == [True] capital = "council_tax_reduction_assessable_capital" - assert unreformed.calculate(capital, YEAR)[0] == 17_000 + assert unreformed.calculate(capital, YEAR)[0] == 16_500 assert unreformed.calculate("council_tax_reduction", YEAR)[0] == 0 # With Pension Credit not paid the scheme's own capital rule applies, for From ce8493d7bba8e2c704374dc422891115ca7b4114 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 18:32:36 -0400 Subject: [PATCH 10/38] Consume a recorded total's holding at its counted value; round to the penny MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the GPT-6.1 Sol review of a7d8988e5: - A recorded benunit_reported_capital total holds its owner's reg. 77 holding at full value, while the pool counts that holding at 90%. The report now consumes its known holding part at the counted 90% (household_uc_reported_financial_capital), so it can no longer eat another unit's savings; the owner's own assessment still takes the full holding off the report. The company-override conservation oracle consumes the same way, and a YAML regression pins the reviewer's case: £16,500 of savings and a £10,000 report that is all holding give [0, 16,500]. - valued_capital rounds the 90% value down to the penny in the claimant's favour (ADM H1665), in float64 with a guard against binary noise. A YAML regression puts £1,000 of savings and £16,666.67 of shares at exactly £16,000.00, which passes the limit (WRA 2012 s. 5(1)(a), UC reg. 18(1)). Co-Authored-By: Claude Opus 5.5 --- .../baseline/gov/dwp/capital_valuation.yaml | 23 +++++++++++ .../universal_credit/uc_property_capital.yaml | 40 +++++++++++++++++++ .../test_uc_company_override_properties.py | 31 ++++++++++---- policyengine_uk/utils/capital_valuation.py | 18 +++++++-- ...household_uc_reported_financial_capital.py | 20 +++++++--- 5 files changed, 116 insertions(+), 16 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml index 1b4870b502..a68465995c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml @@ -293,3 +293,26 @@ pension_credit_deemed_income: 12_480 guarantee_credit: 0 housing_benefit_assessable_capital: 130_000 + +- name: UC rounds the share valuation down to the penny, at the capital limit + period: 2026 + absolute_error_margin: 0 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # 90% of 16,666.67 is 15,000.003, rounded down to the penny in the + # claimant's favour (ADM H1665): 15,000.00. With 1,000 of savings the + # capital is exactly the 16,000 limit, which does not exceed it (WRA + # 2012 s. 5(1)(a)). + savings: 1_000 + corporate_wealth: 16_666.67 + output: + uc_assessable_capital: 16_000 + is_uc_eligible: true diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml index 13e0bc9255..7d94546a62 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml @@ -127,3 +127,43 @@ # 19,000 for the other claimant. uc_assessable_capital: [1_000, 19_000] universal_credit: [5_098.80, 0] + +- name: A recorded financial total consumes its owner's company holding at its counted value + 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: + reporting: + members: [owner] + would_claim_uc: true + benunit_reported_capital: 10_000 + missing: + members: [other_claimant] + would_claim_uc: true + households: + household: + members: [owner, other_claimant] + savings: 16_500 + corporate_wealth: 10_000 + output: + # The pool counts the 10,000 of shares at 90% (reg. 49(1)(a)): 16,500 + + # 9,000 = 25,500. The recorded total holds the holding at full value, so + # its holding part is consumed at the counted 9,000, not 10,000: the + # report consumes 10,000 - 1,000 = 9,000. The owner's own assessment takes + # the full holding off the report (10,000 - 10,000 = 0), and the other + # claimant keeps all 16,500 of savings, over the 16,000 limit. The total + # reconciles: 0 + 16,500 + the 9,000 disregarded holding = 25,500. + household_uc_reported_financial_capital: 9_000 + household_uc_pool_company_holdings: 0 + uc_assessable_capital: [0, 16_500] + universal_credit: [5_098.80, 0] diff --git a/policyengine_uk/tests/test_uc_company_override_properties.py b/policyengine_uk/tests/test_uc_company_override_properties.py index ec9fd1731b..c6b426f401 100644 --- a/policyengine_uk/tests/test_uc_company_override_properties.py +++ b/policyengine_uk/tests/test_uc_company_override_properties.py @@ -144,7 +144,6 @@ def test_company_overrides_conserve_capital_and_keep_holdings_out_of_other_units 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 = [ @@ -155,9 +154,19 @@ def test_company_overrides_conserve_capital_and_keep_holdings_out_of_other_units total_holdings = sum(bounded_holdings) + bounded_dependants budget = min(corporate, total_holdings) scale = budget / total_holdings if total_holdings else 0 - effective_holdings = [ - SHARE_VALUE * holding * scale for holding in bounded_holdings - ] + market_holdings = [holding * scale for holding in bounded_holdings] + effective_holdings = [SHARE_VALUE * holding for holding in market_holdings] + # A recorded financial total holds its owners' holdings at full value; + # the pool counts them at 90%, so its known holding part is consumed at + # 90% (reg. 49(1)(a)) and the rest of the report in full. + explicit_consumption = sum( + spec["override"] + if "override" in spec + else max(0, spec["financial"] - (1 - SHARE_VALUE) * market) + if spec.get("financial", -1) >= 0 + else 0 + for spec, market in zip(specs, market_holdings) + ) extra_holding_draw = SHARE_VALUE * bounded_dependants * scale + sum( holding if absent @@ -189,11 +198,17 @@ def test_company_overrides_conserve_capital_and_keep_holdings_out_of_other_units # owners, without scaling that substitution. This identity also # covers inconsistent observations and exhausted residual pools. # A complete override is a countable figure, so the holding comes off - # it at 90%; a recorded financial total is used unvalued, so in full. + # it at 90%. A recorded financial total is used unvalued, so the owner + # loses the holding in full while the pool gives up only its 90% + # value: what the report removes from the pool beyond the owner's own + # remaining capital is its consumption less that capital. owner_substitution = sum( - min(SHARE_VALUE * holding if "override" in spec else holding, reported) - for holding, reported, absent, spec in zip( - holdings, explicit, missing, specs + min(SHARE_VALUE * holding, reported) + if "override" in spec + else max(0, reported - (1 - SHARE_VALUE) * market) + - max(0, reported - holding) + for holding, market, reported, absent, spec in zip( + holdings, market_holdings, explicit, missing, specs ) if not absent ) diff --git a/policyengine_uk/utils/capital_valuation.py b/policyengine_uk/utils/capital_valuation.py index 7851ca440f..a1293d2d87 100644 --- a/policyengine_uk/utils/capital_valuation.py +++ b/policyengine_uk/utils/capital_valuation.py @@ -9,6 +9,7 @@ would incur expenses in a ``capital.sale_expenses`` parameter node. """ +import numpy as np from policyengine_core.model_api import max_ # Household inputs holding the debt secured on each capital source, such as a @@ -29,14 +30,15 @@ def valued_capital(value_of, sources, sale_expenses): ``value_of(variable)`` returns that household variable on the entity the caller is working with. The sale-expense deduction is taken from the gross - value before the encumbrance (ADM H1608), and a source carrying secured - debt is never valued below nil. + value before the encumbrance (ADM H1608) and the result rounded down to the + penny in the claimant's favour, as ADM H1665 does for quoted shares. A + source carrying secured debt is never valued below nil. """ total = 0 for source in sources: value = value_of(source) if source in sale_expenses.sources: - value = value * (1 - sale_expenses.rate) + value = penny_floor(value * (1 - sale_expenses.rate)) if source in SECURED_DEBT: value = max_(0, value - value_of(SECURED_DEBT[source])) total = total + value @@ -47,3 +49,13 @@ def counted_share(sale_expenses, source): """Share of a source's market value counted as capital: 90% where its sale would incur expenses (reg. 49(1)(a)), otherwise all of it.""" return 1 - sale_expenses.rate if source in sale_expenses.sources else 1 + + +def penny_floor(amount): + """Round down to the penny, in the claimant's favour (ADM H1665). + + Computed in float64, and rounded to a millionth of a penny first, so binary + noise just under a whole penny (10,799.9999999 for 90% of 12,000) does not + lose a penny.""" + pence = np.round(np.asarray(amount, dtype=np.float64) * 100, 6) + return np.floor(pence) / 100 diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py index 8ec4160847..6daf295adc 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py @@ -52,14 +52,24 @@ def formula(household, period, parameters): ).astype(holding.dtype) * person.household( "household_uc_company_holding_pool_scale", period ) - # A complete override is a countable figure, so the holdings it must at - # least cover are counted at the reg. 49(1) value of corporate_wealth. + # The pool counts corporate_wealth, and the holdings in it, at the reg. + # 49(1) value. A complete override is a countable figure, so the + # holdings it must at least cover are counted at that value. A recorded + # financial total is used unvalued and holds the holdings at full value, + # so its known holding part is consumed at the counted value too; the + # owner's own assessment still takes the full holding off the report. p = parameters(period).gov.dwp.universal_credit.means_test.capital - own_holdings = own_holdings * counted_share(p.sale_expenses, "corporate_wealth") + valued_holdings = own_holdings * counted_share( + p.sale_expenses, "corporate_wealth" + ) consumed_capital = where( complete_override >= 0, - max_(complete_override, own_holdings), - financial_capital, + max_(complete_override, valued_holdings), + where( + financial_capital >= 0, + max_(0, financial_capital - (own_holdings - valued_holdings)), + financial_capital, + ), ) return household.sum( max_(0, consumed_capital) * person("is_benunit_head", period) From 57f93709861f8dc0ea6c14bcbaec157d885c32b6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 19:24:17 -0400 Subject: [PATCH 11/38] Value capital less sale expenses in float64, to the penny MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inputs are float32, so 90% of £16,001 came to £14,400.8994 and the penny floor gave £14,400.89 (the HB £16,001 case failed). less_sale_expenses takes the value to the penny in float64 before the 10% deduction and rounds down to the penny (ADM H1665). The UC property path now uses it too, so every reg. 49(1)(a) valuation rounds the same way. Adds a Hypothesis property checking 90% of every whole-penny float32 input up to £131,071.99 against integer pence, and a UC YAML case. Co-Authored-By: Claude Opus 5.5 --- .../baseline/gov/dwp/capital_valuation.yaml | 22 +++++++++++++++++++ .../test_capital_valuation_properties.py | 19 +++++++++++++++- policyengine_uk/utils/capital_valuation.py | 13 ++++++++++- .../uc_non_residential_property_capital.py | 3 ++- .../uc_other_residential_property_capital.py | 3 ++- 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml index a68465995c..9f34ce7187 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml @@ -316,3 +316,25 @@ output: uc_assessable_capital: 16_000 is_uc_eligible: true + +- name: UC values property and shares less sale expenses to the penny + period: 2026 + absolute_error_margin: 0.005 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # 90% of 16,001 is 14,400.90 and 90% of 1,000.10 is 900.09, each + # rounded down to the penny (ADM H1665): 15,300.99. Float32 arithmetic + # would give 14,400.89 and 900.08. + other_residential_property_value: 16_001 + corporate_wealth: 1_000.10 + output: + uc_assessable_capital: 15_300.99 + is_uc_eligible: true diff --git a/policyengine_uk/tests/test_capital_valuation_properties.py b/policyengine_uk/tests/test_capital_valuation_properties.py index e0d8775155..430628cb2f 100644 --- a/policyengine_uk/tests/test_capital_valuation_properties.py +++ b/policyengine_uk/tests/test_capital_valuation_properties.py @@ -25,15 +25,19 @@ of the household's unencumbered sources, and capital is never negative. (Isolation between two encumbered sources is pinned by the differential and by a YAML case.) +6. Pennies: 90% of any whole-penny value stored as a float32 input is the + exact figure rounded down to the penny (ADM H1665), checked in integer + pence up to 131,071.99, the largest value float32 holds to the penny. """ from functools import reduce import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation +from policyengine_uk.utils.capital_valuation import less_sale_expenses YEAR = 2026 PROPERTY_SETTINGS = settings( @@ -278,3 +282,16 @@ def test_capital_is_non_increasing_in_rate_and_secured_debt(units, r1, r2, scale for variable in PROGRAMMES: assert np.all(at_high[variable] <= at_low[variable] + 0.01), variable assert np.all(more_debt[variable] <= at_low[variable] + 0.01), variable + + +@settings(max_examples=200, deadline=None, derandomize=True) +@given(st.lists(st.integers(0, 13_107_199), min_size=1, max_size=1_000)) +# 16,001 is 14,400.8994 after a float32 multiplication; 1,000.10 is stored as +# 1,000.0999756; 12,000 is 10,799.9999999 in binary. +@example([1_600_100, 100_010, 1_200_000]) +def test_sale_expenses_round_down_to_the_penny(pence): + stored = np.asarray(pence, dtype=np.int64).astype(np.float32) / np.float32(100) + valued = less_sale_expenses(stored, LEGAL_RATE) + assert valued.dtype == np.float64 + exact = [9 * amount // 10 for amount in pence] + assert np.round(valued * 100).astype(np.int64).tolist() == exact diff --git a/policyengine_uk/utils/capital_valuation.py b/policyengine_uk/utils/capital_valuation.py index a1293d2d87..cd9cb0035c 100644 --- a/policyengine_uk/utils/capital_valuation.py +++ b/policyengine_uk/utils/capital_valuation.py @@ -38,7 +38,7 @@ def valued_capital(value_of, sources, sale_expenses): for source in sources: value = value_of(source) if source in sale_expenses.sources: - value = penny_floor(value * (1 - sale_expenses.rate)) + value = less_sale_expenses(value, sale_expenses.rate) if source in SECURED_DEBT: value = max_(0, value - value_of(SECURED_DEBT[source])) total = total + value @@ -51,6 +51,17 @@ def counted_share(sale_expenses, source): return 1 - sale_expenses.rate if source in sale_expenses.sources else 1 +def less_sale_expenses(value, rate): + """Market value less the sale-expense ``rate`` (reg. 49(1)(a)), rounded + down to the penny in the claimant's favour (ADM H1665). + + Inputs are stored in float32, and a float32 product loses pennies: 90% of + 16,001 is 14,400.8994 in float32 rather than 14,400.90. So the value is + taken to the penny in float64 before the deduction.""" + pounds = np.round(np.asarray(value, dtype=np.float64), 2) + return penny_floor(pounds * (1 - np.asarray(rate, dtype=np.float64))) + + def penny_floor(amount): """Round down to the penny, in the claimant's favour (ADM H1665). 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 index 0de636f749..2d804975a6 100644 --- 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 @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import less_sale_expenses class uc_non_residential_property_capital(Variable): @@ -21,4 +22,4 @@ 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.sale_expenses.rate) - debt) + return max_(0, less_sale_expenses(market_value, p.sale_expenses.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 index 974b5f6825..ed969cb52e 100644 --- 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 @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import less_sale_expenses class uc_other_residential_property_capital(Variable): @@ -21,4 +22,4 @@ 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.sale_expenses.rate) - debt) + return max_(0, less_sale_expenses(market_value, p.sale_expenses.rate) - debt) From b3f6f608b0aca9165b8182bc324d4139818e50cf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 21:50:35 -0400 Subject: [PATCH 12/38] Round capital to the penny as each programme's regulations require Sol review v4b found that rounding the input to the penny first could round up (the ADM H1665 example gave 930.94, not 930.93), that nothing was rounded at the last stage, and that UC rounds to the nearest penny. - capital.round_down per programme: false for UC (UC Regs 2013 reg 6(1); NI reg 7(1)) and HB (HB Regs 2006 reg 80; HB (SPC) reg 61(6); NI 78(8), 59(7)); true for IS (reg 27), JSA (reg 92), ESA (reg 3(a)) and Pension Credit (reg 24A), which count a fraction only to the claimant's advantage. - exact_amount removes float32 storage error only where a value is the stored form of a whole penny; other values are kept exactly. - Each valuation, each UC holding and property share, and each programme's assessed capital (the last stage) is rounded by that rule. - A recorded financial total below its owner's holding now consumes the holding at its counted value, as an override does, so no part of the holding is left for another unit. Tests: the H1665 example (930.93 / 930.94), whole-penny properties for both rules, a never-wrong-way bound for any float32 input, UC half-penny, 16,000.00 after a holding, a three-way split, IS shared-capital rounding, and reports below the holding in the reg 77 conservation grid. Co-Authored-By: Claude Opus 5.5 --- changelog.d/uc-capital-sale-expenses.fixed.md | 2 +- .../dwp/ESA/income/capital/round_down.yaml | 17 +++ .../dwp/JSA/income/capital/round_down.yaml | 17 +++ .../means_test/capital/round_down.yaml | 22 ++++ .../means_test/capital/round_down.yaml | 18 +++ .../income/capital/round_down.yaml | 17 +++ .../means_test/capital/round_down.yaml | 19 +++ .../baseline/gov/dwp/capital_valuation.yaml | 113 ++++++++++++++++-- .../universal_credit/uc_property_capital.yaml | 111 +++++++++++++++++ .../test_capital_valuation_properties.py | 69 +++++++++-- .../test_uc_company_override_properties.py | 15 ++- policyengine_uk/utils/capital_valuation.py | 84 +++++++++---- .../gov/dwp/esa_income_assessable_capital.py | 15 ++- .../housing_benefit_assessable_capital.py | 21 ++-- .../dwp/income_support_assessable_capital.py | 17 ++- .../gov/dwp/jsa_income_assessable_capital.py | 15 ++- .../pension_credit_assessable_capital.py | 19 ++- .../household_uc_pool_company_holdings.py | 17 ++- ...household_uc_reported_financial_capital.py | 50 +++++--- .../universal_credit/uc_assessable_capital.py | 42 +++++-- .../uc_non_residential_property_capital.py | 8 +- .../uc_other_residential_property_capital.py | 8 +- .../universal_credit/uc_property_capital.py | 9 +- 23 files changed, 603 insertions(+), 122 deletions(-) create mode 100644 policyengine_uk/parameters/gov/dwp/ESA/income/capital/round_down.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/JSA/income/capital/round_down.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/round_down.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/round_down.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/round_down.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/round_down.yaml diff --git a/changelog.d/uc-capital-sale-expenses.fixed.md b/changelog.d/uc-capital-sale-expenses.fixed.md index 6b926ace41..b71561b5b9 100644 --- a/changelog.d/uc-capital-sale-expenses.fixed.md +++ b/changelog.d/uc-capital-sale-expenses.fixed.md @@ -1 +1 @@ -Value Universal Credit, Housing Benefit, Income Support, income-based JSA, income-related ESA and Pension Credit capital at market value less 10% where a sale would incur expenses and less any debt secured on the asset, as UC Regs 2013 reg. 49(1) and its legacy equivalents require. The 10% applies to land, property and corporate_wealth, not to cash savings; corporate_wealth also holds unit trusts, which have no costs of sale, and in datasets built before the private pension split it holds pension wealth, so the deduction reaches those too until the data separate them. Adds secured-debt inputs for other residential property, non-residential property and land. +Value Universal Credit, Housing Benefit, Income Support, income-based JSA, income-related ESA and Pension Credit capital at market value less 10% where a sale would incur expenses and less any debt secured on the asset, as UC Regs 2013 reg. 49(1) and its legacy equivalents require. The 10% applies to land, property and corporate_wealth, not to cash savings; corporate_wealth also holds unit trusts, which have no costs of sale, and in datasets built before the private pension split it holds pension wealth, so the deduction reaches those too until the data separate them. Adds secured-debt inputs for other residential property, non-residential property and land. Each programme rounds a fraction of a penny in its capital calculations as its regulations require: to the nearest penny for Universal Credit (UC Regs 2013 reg. 6(1)) and Housing Benefit, and down for the legacy benefits and Pension Credit. diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/round_down.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/round_down.yaml new file mode 100644 index 0000000000..2a17ce9a84 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/round_down.yaml @@ -0,0 +1,17 @@ +description: > + Whether a fraction of a penny in an income-related Employment and Support + Allowance capital calculation is disregarded (true) rather than rounded to + the nearest penny (false). A fraction of a penny is treated as a penny + only where that is to the claimant's advantage, and is otherwise + disregarded; for capital that means rounding down. +values: + 2008-10-27: true +metadata: + label: Income-related ESA capital rounded down to the penny + period: year + unit: bool + reference: + - title: The Employment and Support Allowance Regulations 2008 reg. 3(a) + href: https://www.legislation.gov.uk/uksi/2008/794/regulation/3 + - title: The Employment and Support Allowance Regulations (Northern Ireland) 2008 reg. 3(a) + href: https://www.legislation.gov.uk/nisr/2008/280/regulation/3 diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/round_down.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/round_down.yaml new file mode 100644 index 0000000000..262e1207b7 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/round_down.yaml @@ -0,0 +1,17 @@ +description: > + Whether a fraction of a penny in an income-based Jobseeker's Allowance + capital calculation is disregarded (true) rather than rounded to the + nearest penny (false). A fraction of a penny is treated as a penny only + where that is to the claimant's advantage, and is otherwise disregarded; + for capital that means rounding down. +values: + 1996-10-07: true +metadata: + label: Income-based JSA capital rounded down to the penny + period: year + unit: bool + reference: + - title: The Jobseeker's Allowance Regulations 1996 reg. 92 + href: https://www.legislation.gov.uk/uksi/1996/207/regulation/92 + - title: The Jobseeker's Allowance Regulations (Northern Ireland) 1996 reg. 92 + href: https://www.legislation.gov.uk/nisr/1996/198/regulation/92 diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/round_down.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/round_down.yaml new file mode 100644 index 0000000000..30153e66fb --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/round_down.yaml @@ -0,0 +1,22 @@ +description: > + Whether a fraction of a penny in a Housing Benefit capital calculation is + disregarded (true) rather than rounded to the nearest penny (false). Any + amount determined under the regulations may, if appropriate, be rounded to + the nearest whole penny, disregarding less than half a penny and treating + half a penny or more as a penny. That is the regulations' only rounding + rule, and the model applies it. +values: + 2006-04-10: false +metadata: + label: Housing Benefit capital rounded down to the penny + period: year + unit: bool + reference: + - title: The Housing Benefit Regulations 2006 reg. 80 + href: https://www.legislation.gov.uk/uksi/2006/213/regulation/80 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg. 61(6) + href: https://www.legislation.gov.uk/uksi/2006/214/regulation/61 + - title: The Housing Benefit Regulations (Northern Ireland) 2006 reg. 78(8) + href: https://www.legislation.gov.uk/nisr/2006/405/regulation/78 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations (Northern Ireland) 2006 reg. 59(7) + href: https://www.legislation.gov.uk/nisr/2006/406/regulation/59 diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/round_down.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/round_down.yaml new file mode 100644 index 0000000000..b628746c46 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/round_down.yaml @@ -0,0 +1,18 @@ +description: > + Whether a fraction of a penny in an Income Support capital calculation is + disregarded (true) rather than rounded to the nearest penny (false). A + fraction of a penny is treated as a penny only where that is to the + claimant's advantage, and is otherwise disregarded; for capital that means + rounding down. The Northern Ireland regulation disregards any fraction of + a penny in capital. +values: + 2010-01-01: true +metadata: + label: Income Support capital rounded down to the penny + period: year + unit: bool + reference: + - title: The Income Support (General) Regulations 1987 reg. 27 + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/27 + - title: The Income Support (General) Regulations (Northern Ireland) 1987 reg. 27 + href: https://www.legislation.gov.uk/nisr/1987/459/regulation/27 diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/round_down.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/round_down.yaml new file mode 100644 index 0000000000..4170770dd6 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/round_down.yaml @@ -0,0 +1,17 @@ +description: > + Whether a fraction of a penny in a Pension Credit capital calculation is + disregarded (true) rather than rounded to the nearest penny (false). A + fraction of a penny is treated as a penny only where that is to the + claimant's advantage, and is otherwise disregarded; for capital that means + rounding down. DMG 84763 values shares the same way. +values: + 2003-10-06: true +metadata: + label: Pension Credit capital rounded down to the penny + period: year + unit: bool + reference: + - title: State Pension Credit Regulations 2002 reg. 24A + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/24A + - title: State Pension Credit Regulations (Northern Ireland) 2003 reg. 24A + href: https://www.legislation.gov.uk/nisr/2003/28/regulation/24A diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/round_down.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/round_down.yaml new file mode 100644 index 0000000000..6b34443074 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/round_down.yaml @@ -0,0 +1,19 @@ +description: > + Whether a fraction of a penny in a Universal Credit capital calculation is + disregarded (true) rather than rounded to the nearest penny (false). + Regulation 6(1) disregards a fraction of less than half a penny and treats + any other fraction as a penny, for every amount calculated under the + regulations, including the reg. 49 valuation and the capital tested + against the limit. ADM H1665 rounds a share valuation down in the + claimant's favour, but the regulation governs. +values: + 2013-04-29: false +metadata: + label: Universal Credit capital rounded down to the penny + period: year + unit: bool + reference: + - title: The Universal Credit Regulations 2013 reg. 6(1) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/6 + - title: The Universal Credit Regulations (Northern Ireland) 2016 reg. 7(1) + href: https://www.legislation.gov.uk/nisr/2016/216/regulation/7 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml index 9f34ce7187..6984634c9f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml @@ -294,7 +294,7 @@ guarantee_credit: 0 housing_benefit_assessable_capital: 130_000 -- name: UC rounds the share valuation down to the penny, at the capital limit +- name: UC rounds the share valuation to the nearest penny, at the capital limit period: 2026 absolute_error_margin: 0 input: @@ -307,10 +307,10 @@ households: household: members: person - # 90% of 16,666.67 is 15,000.003, rounded down to the penny in the - # claimant's favour (ADM H1665): 15,000.00. With 1,000 of savings the - # capital is exactly the 16,000 limit, which does not exceed it (WRA - # 2012 s. 5(1)(a)). + # 90% of 16,666.67 is 15,000.003, rounded to the nearest penny (UC Regs + # 2013 reg. 6(1)): 15,000.00. With 1,000 of savings the capital is + # exactly the 16,000 limit, which does not exceed it (WRA 2012 s. + # 5(1)(a)). savings: 1_000 corporate_wealth: 16_666.67 output: @@ -330,11 +330,108 @@ households: household: members: person - # 90% of 16,001 is 14,400.90 and 90% of 1,000.10 is 900.09, each - # rounded down to the penny (ADM H1665): 15,300.99. Float32 arithmetic - # would give 14,400.89 and 900.08. + # 90% of 16,001 is 14,400.90 and 90% of 1,000.10 is 900.09, exactly: + # 15,300.99. Float32 arithmetic would give 14,400.8994 and 900.0899. other_residential_property_value: 16_001 corporate_wealth: 1_000.10 output: uc_assessable_capital: 15_300.99 is_uc_eligible: true + +- name: UC rounds half a penny up, so the capital is a penny over the limit + period: 2026 + absolute_error_margin: 0 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # 90% of 16,666.65 is 14,999.985. UC Regs 2013 reg. 6(1) treats a + # fraction of half a penny or more as a penny: 14,999.99, so the capital + # is 1,000.02 + 14,999.99 = 16,000.01, over the 16,000 limit. Rounding + # down, as ADM H1665 does, would give exactly 16,000.00; the regulation + # governs the guidance. + savings: 1_000.02 + corporate_wealth: 16_666.65 + output: + uc_assessable_capital: 16_000.01 + is_uc_eligible: false + +- name: Pension Credit values shares as in the ADM and DMG worked example + period: 2026 + absolute_error_margin: 0.001 + input: + people: + person: + age: 70 + benunits: + benunit: + members: person + households: + household: + members: person + # 250 shares at 4.1375 are 1,034.375 (ADM H1665; DMG 84763). Less 10% + # for the expenses of sale that is 930.9375, and the fraction of a + # penny is disregarded because counting it would not be to the + # claimant's advantage (SPC Regs 2002 reg. 24A): 930.93. + corporate_wealth: 1_034.375 + output: + pension_credit_assessable_capital: 930.93 + +- name: UC values the worked-example shares to the nearest penny + period: 2026 + absolute_error_margin: 0.001 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # The same 930.9375 is 930.94 to the nearest penny (UC Regs 2013 reg. + # 6(1)), where ADM H1665 gives 930.93. + corporate_wealth: 1_034.375 + output: + uc_assessable_capital: 930.94 + +- name: Income Support rounds a shared capital down to the penny + period: 2021 + absolute_error_margin: 0.001 + input: + people: + first: + age: 30 + is_claimant_or_partner: true + second: + age: 30 + is_claimant_or_partner: true + third: + age: 30 + is_claimant_or_partner: true + benunits: + first_unit: + members: [first] + would_claim_IS: false + second_unit: + members: [second] + would_claim_IS: false + third_unit: + members: [third] + would_claim_IS: false + households: + household: + members: [first, second, third] + # With no Income Support claim in the household, 10,000.01 of savings + # is shared by the three adults: 3,333.3366 each, rounded down at the + # last stage (IS Regs 1987 reg. 27): 3,333.33. + savings: 10_000.01 + output: + income_support_assessable_capital: [3_333.33, 3_333.33, 3_333.33] diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml index 7d94546a62..f522fcb351 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml @@ -167,3 +167,114 @@ household_uc_pool_company_holdings: 0 uc_assessable_capital: [0, 16_500] universal_credit: [5_098.80, 0] + +- name: A capital of exactly 16,000.00 after a company holding leaves the pool is within the limit + period: 2026 + absolute_error_margin: 0 + 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.01 + owned_company_capital: 0 + other_claimant: + age: 30 + is_claimant_or_partner: true + benunits: + reporting: + members: [owner] + would_claim_uc: true + uc_reported_capital: 0 + missing: + members: [other_claimant] + would_claim_uc: true + households: + household: + members: [owner, other_claimant] + savings: 7_000.01 + corporate_wealth: 20_000 + output: + # The pool is 7,000.01 + 90% of 20,000 = 25,000.01. The owner's holding + # leaves it at 90% of 10,000.01 = 9,000.009, which is 9,000.01 to the + # nearest penny (UC Regs 2013 reg. 6(1)), so the other claimant has + # 16,000.00, which does not exceed the 16,000 limit. Unrounded, the + # 16,000.001 would fail it. + household_uc_reported_financial_capital: 9_000.01 + uc_assessable_capital: [0, 16_000] + is_uc_eligible: [true, true] + +- name: A capital pool shared three ways is rounded to the nearest penny at the last stage + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 30 + is_claimant_or_partner: true + second: + age: 30 + is_claimant_or_partner: true + third: + age: 30 + is_claimant_or_partner: true + benunits: + first_unit: + members: [first] + would_claim_uc: true + second_unit: + members: [second] + would_claim_uc: true + third_unit: + members: [third] + would_claim_uc: true + households: + household: + members: [first, second, third] + savings: 48_000.01 + output: + # A third of 48,000.01 is 16,000.0033, which is 16,000.00 to the nearest + # penny (reg. 6(1)), within the limit for each unit. + uc_assessable_capital: [16_000, 16_000, 16_000] + is_uc_eligible: [true, true, true] + +- name: A recorded financial total below its owner's company holding still consumes the holding + 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: + reporting: + members: [owner] + would_claim_uc: true + benunit_reported_capital: 4_000 + missing: + members: [other_claimant] + would_claim_uc: true + households: + household: + members: [owner, other_claimant] + savings: 10_000 + corporate_wealth: 10_000 + output: + # All 10,000 of shares are the owner's holding, which the pool counts at + # 9,000 (reg. 49(1)(a)). The recorded 4,000 is below the holding, so it + # has no part beyond it and consumes the holding at its counted 9,000, + # as an override would. The other claimant keeps only the 10,000 of + # savings: none of the holding is left for them to count. The owner's + # own assessment takes the full holding off the report: max(0, 4,000 - + # 10,000) = 0. + household_uc_reported_financial_capital: 9_000 + household_uc_pool_company_holdings: 0 + uc_assessable_capital: [0, 10_000] diff --git a/policyengine_uk/tests/test_capital_valuation_properties.py b/policyengine_uk/tests/test_capital_valuation_properties.py index 430628cb2f..855a88c59c 100644 --- a/policyengine_uk/tests/test_capital_valuation_properties.py +++ b/policyengine_uk/tests/test_capital_valuation_properties.py @@ -26,10 +26,15 @@ (Isolation between two encumbered sources is pinned by the differential and by a YAML case.) 6. Pennies: 90% of any whole-penny value stored as a float32 input is the - exact figure rounded down to the penny (ADM H1665), checked in integer - pence up to 131,071.99, the largest value float32 holds to the penny. + exact figure rounded down (the legacy benefits) or to the nearest penny + (Universal Credit and Housing Benefit), checked in integer pence up to + 131,071.99, the largest value float32 holds to the penny. +7. Never rounds the wrong way: for any stored value, whole penny or not, the + result is within a penny below 90% of the exact stored value when rounding + down, and within half a penny of it when rounding to the nearest penny. """ +from fractions import Fraction from functools import reduce import numpy as np @@ -37,7 +42,7 @@ from hypothesis import strategies as st from policyengine_uk import Simulation -from policyengine_uk.utils.capital_valuation import less_sale_expenses +from policyengine_uk.utils.capital_valuation import exact_amount, less_sale_expenses YEAR = 2026 PROPERTY_SETTINGS = settings( @@ -287,11 +292,55 @@ def test_capital_is_non_increasing_in_rate_and_secured_debt(units, r1, r2, scale @settings(max_examples=200, deadline=None, derandomize=True) @given(st.lists(st.integers(0, 13_107_199), min_size=1, max_size=1_000)) # 16,001 is 14,400.8994 after a float32 multiplication; 1,000.10 is stored as -# 1,000.0999756; 12,000 is 10,799.9999999 in binary. -@example([1_600_100, 100_010, 1_200_000]) -def test_sale_expenses_round_down_to_the_penny(pence): +# 1,000.0999756; 90% of 2.30 is 206.99999999999997 pence in float64; 90% of +# 16,666.65 is 14,999.985, a half penny. +@example([1_600_100, 100_010, 230, 1_666_665]) +def test_sale_expenses_round_to_the_penny(pence): stored = np.asarray(pence, dtype=np.int64).astype(np.float32) / np.float32(100) - valued = less_sale_expenses(stored, LEGAL_RATE) - assert valued.dtype == np.float64 - exact = [9 * amount // 10 for amount in pence] - assert np.round(valued * 100).astype(np.int64).tolist() == exact + down = less_sale_expenses(stored, LEGAL_RATE, True) + nearest = less_sale_expenses(stored, LEGAL_RATE, False) + assert down.dtype == nearest.dtype == np.float64 + assert np.round(down * 100).astype(np.int64).tolist() == [ + 9 * amount // 10 for amount in pence + ] + assert np.round(nearest * 100).astype(np.int64).tolist() == [ + (9 * amount + 5) // 10 for amount in pence + ] + + +def test_share_valuation_matches_the_adm_worked_example(): + # ADM H1665 and DMG 84763: 250 shares at 4.1375 are 1,034.375, and less + # 10% for the expenses of sale that is 930.9375: 930.93 rounded down, as + # the legacy benefits do, or 930.94 to the nearest penny, as Universal + # Credit does (UC Regs 2013 reg. 6(1)). + for stored in (1034.375, np.float32(1034.375)): + assert less_sale_expenses(stored, LEGAL_RATE, True) == 930.93 + assert less_sale_expenses(stored, LEGAL_RATE, False) == 930.94 + + +@settings(max_examples=200, deadline=None, derandomize=True) +@given( + st.lists( + st.floats(0, 1_000_000, allow_nan=False, width=32), min_size=1, max_size=500 + ) +) +@example([1034.375, 10352.055, 0.005, 999_999.875]) +def test_sale_expenses_never_round_the_wrong_way(values): + stored = np.asarray(values, dtype=np.float32) + exact = exact_amount(stored) + # Storage error is removed only within half a unit of the stored value's + # own precision, so a value is never moved to a penny it was not stored + # from. + assert np.all( + np.abs(exact - stored.astype(np.float64)) + <= np.spacing(np.abs(stored)).astype(np.float64) / 2 + ) + down = less_sale_expenses(stored, LEGAL_RATE, True) + nearest = less_sale_expenses(stored, LEGAL_RATE, False) + for value, low, near in zip(exact.tolist(), down.tolist(), nearest.tolist()): + # Exact rational arithmetic on the value the helper works from, with + # the helper's millionth-of-a-penny allowance for binary noise. + target = Fraction(value) * Fraction(9, 10) + noise = Fraction(1, 100_000_000) + assert target - Fraction(1, 100) - noise < Fraction(low) <= target + noise + assert abs(Fraction(near) - target) <= Fraction(1, 200) + noise diff --git a/policyengine_uk/tests/test_uc_company_override_properties.py b/policyengine_uk/tests/test_uc_company_override_properties.py index c6b426f401..1fac837bf1 100644 --- a/policyengine_uk/tests/test_uc_company_override_properties.py +++ b/policyengine_uk/tests/test_uc_company_override_properties.py @@ -56,7 +56,7 @@ def _company_person(holding=0, dependant=False): def _grid(layout): - """Combine 960 independent three-unit households in one simulation.""" + """Combine 1,280 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) @@ -70,7 +70,7 @@ def _grid(layout): ) in enumerate( product( (0, 4_000, 10_000, 20_000), - (None, -1, 10_000), + (None, -1, 4_000, 10_000), (0, 4_000, 10_000, 20_000), (6_000, 20_000), OTHER_REPORTS, @@ -157,12 +157,14 @@ def test_company_overrides_conserve_capital_and_keep_holdings_out_of_other_units market_holdings = [holding * scale for holding in bounded_holdings] effective_holdings = [SHARE_VALUE * holding for holding in market_holdings] # A recorded financial total holds its owners' holdings at full value; - # the pool counts them at 90%, so its known holding part is consumed at - # 90% (reg. 49(1)(a)) and the rest of the report in full. + # the pool counts them at 90% (reg. 49(1)(a)), so the report consumes + # its part beyond the holdings in full and the holdings at 90%. Like an + # override, a report below the holdings still consumes them: a report + # of 4,000 against a 10,000 holding consumes 9,000. explicit_consumption = sum( spec["override"] if "override" in spec - else max(0, spec["financial"] - (1 - SHARE_VALUE) * market) + else max(0, spec["financial"] - market) + SHARE_VALUE * market if spec.get("financial", -1) >= 0 else 0 for spec, market in zip(specs, market_holdings) @@ -205,7 +207,8 @@ def test_company_overrides_conserve_capital_and_keep_holdings_out_of_other_units owner_substitution = sum( min(SHARE_VALUE * holding, reported) if "override" in spec - else max(0, reported - (1 - SHARE_VALUE) * market) + else max(0, reported - market) + + SHARE_VALUE * market - max(0, reported - holding) for holding, market, reported, absent, spec in zip( holdings, market_holdings, explicit, missing, specs diff --git a/policyengine_uk/utils/capital_valuation.py b/policyengine_uk/utils/capital_valuation.py index cd9cb0035c..10396761dc 100644 --- a/policyengine_uk/utils/capital_valuation.py +++ b/policyengine_uk/utils/capital_valuation.py @@ -6,7 +6,9 @@ The legacy means tests have equivalent rules (HB Regs 2006 reg. 47, IS Regs 1987 reg. 49, JSA Regs 1996 reg. 111, ESA Regs 2008 reg. 113, SPC Regs 2002 reg. 19). Each programme sets its own rate and the capital sources whose sale -would incur expenses in a ``capital.sale_expenses`` parameter node. +would incur expenses in a ``capital.sale_expenses`` parameter node, and how it +rounds a fraction of a penny in ``capital.round_down``: to the nearest penny +for Universal Credit and Housing Benefit, down for the others. """ import numpy as np @@ -25,22 +27,26 @@ } -def valued_capital(value_of, sources, sale_expenses): - """Sum ``sources`` at market value less sale expenses and secured debt. +def valued_capital(value_of, capital): + """Sum ``capital.sources`` at market value less sale expenses and secured debt. ``value_of(variable)`` returns that household variable on the entity the - caller is working with. The sale-expense deduction is taken from the gross - value before the encumbrance (ADM H1608) and the result rounded down to the - penny in the claimant's favour, as ADM H1665 does for quoted shares. A - source carrying secured debt is never valued below nil. + caller is working with, and ``capital`` is the programme's capital + parameter node. The sale-expense deduction is taken from the gross value + before the encumbrance (ADM H1608), and each valuation is rounded to the + penny under the programme's rule. A source carrying secured debt is never + valued below nil. The total is in float64, unrounded: the caller rounds + the capital it assesses at the last stage (``round_to_penny``). """ total = 0 - for source in sources: - value = value_of(source) - if source in sale_expenses.sources: - value = less_sale_expenses(value, sale_expenses.rate) + for source in capital.sources: + value = exact_amount(value_of(source)) + if source in capital.sale_expenses.sources: + value = less_sale_expenses( + value, capital.sale_expenses.rate, capital.round_down + ) if source in SECURED_DEBT: - value = max_(0, value - value_of(SECURED_DEBT[source])) + value = max_(0, value - exact_amount(value_of(SECURED_DEBT[source]))) total = total + value return total @@ -51,22 +57,50 @@ def counted_share(sale_expenses, source): return 1 - sale_expenses.rate if source in sale_expenses.sources else 1 -def less_sale_expenses(value, rate): - """Market value less the sale-expense ``rate`` (reg. 49(1)(a)), rounded - down to the penny in the claimant's favour (ADM H1665). +def less_sale_expenses(value, rate, round_down): + """Market value less the sale-expense ``rate`` (reg. 49(1)(a)), rounded to + the penny under the programme's rule. - Inputs are stored in float32, and a float32 product loses pennies: 90% of - 16,001 is 14,400.8994 in float32 rather than 14,400.90. So the value is - taken to the penny in float64 before the deduction.""" - pounds = np.round(np.asarray(value, dtype=np.float64), 2) - return penny_floor(pounds * (1 - np.asarray(rate, dtype=np.float64))) + The deduction is worked on the exact stored value: 250 shares at £4.1375 + are £1,034.375, and less 10% that is £930.9375, so £930.93 rounded down + (ADM H1665) or £930.94 to the nearest penny (UC Regs 2013 reg. 6(1)).""" + return round_to_penny( + exact_amount(value) * (1 - np.asarray(rate, dtype=np.float64)), round_down + ) -def penny_floor(amount): - """Round down to the penny, in the claimant's favour (ADM H1665). +def exact_amount(amount): + """``amount`` in float64, with float32 storage error removed. + + Inputs are stored in float32, which holds £1,000.10 as 1,000.0999756 and + 90% of £16,001 as 14,400.8994. A value is taken to be a whole penny when + that penny is within half a unit of the value's own precision, so it is + the penny the value was stored from; any other value (a share price to + four decimal places, say) is kept exactly as stored. Above £131,072 + float32 cannot hold every penny, so a penny lost in storage there cannot + be recovered.""" + stored = np.asarray(amount) + wide = stored.astype(np.float64) + pennies = np.round(wide, 2) + # An infinite amount has no precision to compare against and passes through. + with np.errstate(over="ignore", invalid="ignore"): + stored_from_penny = np.abs(pennies - wide) <= np.spacing(np.abs(stored)) / 2 + return np.where(stored_from_penny, pennies, wide) + + +def round_to_penny(amount, round_down): + """Round ``amount`` to the penny: down where ``round_down``, otherwise to + the nearest penny with half a penny rounded up. + + Universal Credit treats a fraction of half a penny or more as a penny and + disregards a smaller one (UC Regs 2013 reg. 6(1)), and Housing Benefit may + do the same (HB Regs 2006 reg. 80). The legacy benefits treat a fraction + as a penny only where that is to the claimant's advantage (IS Regs 1987 + reg. 27, JSA Regs 1996 reg. 92, ESA Regs 2008 reg. 3(a), SPC Regs 2002 + reg. 24A), which for capital means rounding down. Computed in float64, and rounded to a millionth of a penny first, so binary - noise just under a whole penny (10,799.9999999 for 90% of 12,000) does not - lose a penny.""" + noise next to a whole or half penny (206.99999999999997 pence for 90% of + £2.30) does not move it.""" pence = np.round(np.asarray(amount, dtype=np.float64) * 100, 6) - return np.floor(pence) / 100 + return np.where(round_down, np.floor(pence), np.floor(pence + 0.5)) / 100 diff --git a/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py b/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py index 1d760dfb38..fd66242987 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py @@ -1,5 +1,9 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.capital_valuation import valued_capital +from policyengine_uk.utils.capital_valuation import ( + exact_amount, + round_to_penny, + valued_capital, +) class esa_income_assessable_capital(Variable): @@ -27,14 +31,12 @@ class esa_income_assessable_capital(Variable): def formula(benunit, period, parameters): ESA = parameters(period).gov.dwp.ESA.income - sources = ESA.capital.sources person = benunit.members claiming_esa_income = add(benunit, period, ["esa_income_reported"]) > 0 household_capital = valued_capital( lambda variable: benunit.max(person.household(variable, period)), - sources, - ESA.capital.sale_expenses, + ESA.capital, ) # Regulation 83(2) excludes children's and young persons' capital. # The claimant/partner weights approximate otherwise unobserved ownership. @@ -54,10 +56,11 @@ def formula(benunit, period, parameters): ) claimant_or_partner = person("is_claimant_or_partner", period) person_capital = sum( - benunit.sum(person(source, period) * claimant_or_partner) + benunit.sum(exact_amount(person(source, period)) * claimant_or_partner) for source in ESA.capital.person_sources ) household_share = where( household_reporting_claimants > 0, claiming_proxy, fallback_proxy ) - return household_share + person_capital + # A fraction of a penny is disregarded at the last stage (reg. 3(a)). + return round_to_penny(household_share + person_capital, ESA.capital.round_down) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py index ce0b138b81..44c381268e 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py @@ -1,5 +1,9 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.capital_valuation import valued_capital +from policyengine_uk.utils.capital_valuation import ( + exact_amount, + round_to_penny, + valued_capital, +) class housing_benefit_assessable_capital(Variable): @@ -47,11 +51,11 @@ def formula(benunit, period, parameters): ) p = parameters(period).gov.dwp.housing_benefit.means_test.capital household_capital = valued_capital( - lambda variable: household(variable, period), p.sources, p.sale_expenses + lambda variable: household(variable, period), p ) claimant_or_partner = person("is_claimant_or_partner", period) person_capital = sum( - benunit.sum(person(source, period) * claimant_or_partner) + benunit.sum(exact_amount(person(source, period)) * claimant_or_partner) for source in p.person_sources ) benunit_claimants_and_partners = add( @@ -80,7 +84,7 @@ def formula(benunit, period, parameters): ) capital = where( savings_credit_only, - benunit("pension_credit_assessable_capital", period), + exact_amount(benunit("pension_credit_assessable_capital", period)), household_capital_proxy + person_capital, ) # Guarantee Credit passport: SI 2006/214 reg 26 (NI: SR 2006/406 @@ -102,8 +106,9 @@ def formula(benunit, period, parameters): on_passporting_benefit = benunit( "housing_benefit_on_passporting_benefit", period ) - return where( - passported | on_passporting_benefit, - 0, - max_(0, capital), + # Rounded to the nearest penny at the last stage (reg. 80; HB (SPC) + # Regs 2006 reg. 61(6)). + return round_to_penny( + where(passported | on_passporting_benefit, 0, max_(0, capital)), + p.round_down, ) diff --git a/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py b/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py index e382b938f1..33df6b0840 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py @@ -1,5 +1,9 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.capital_valuation import valued_capital +from policyengine_uk.utils.capital_valuation import ( + exact_amount, + round_to_penny, + valued_capital, +) class income_support_assessable_capital(Variable): @@ -26,7 +30,6 @@ class income_support_assessable_capital(Variable): def formula(benunit, period, parameters): IS = parameters(period).gov.dwp.income_support - sources = IS.means_test.capital.sources person = benunit.members would_claim_is = benunit("would_claim_IS", period) @@ -38,8 +41,7 @@ def formula(benunit, period, parameters): # the household allocation itself is a modelling convention. household_capital = valued_capital( lambda variable: benunit.max(person.household(variable, period)), - sources, - IS.means_test.capital.sale_expenses, + IS.means_test.capital, ) benunit_claimants_and_partners = add( benunit, period, ["is_claimant_or_partner"] @@ -60,10 +62,13 @@ def formula(benunit, period, parameters): ) claimant_or_partner = person("is_claimant_or_partner", period) person_capital = sum( - benunit.sum(person(source, period) * claimant_or_partner) + benunit.sum(exact_amount(person(source, period)) * claimant_or_partner) for source in IS.means_test.capital.person_sources ) household_share = where( household_claiming_members > 0, claiming_proxy, fallback_proxy ) - return household_share + person_capital + # A fraction of a penny is disregarded at the last stage (reg. 27). + return round_to_penny( + household_share + person_capital, IS.means_test.capital.round_down + ) diff --git a/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py b/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py index c83148a1b4..1cf104940a 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py @@ -1,5 +1,9 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.capital_valuation import valued_capital +from policyengine_uk.utils.capital_valuation import ( + exact_amount, + round_to_penny, + valued_capital, +) class jsa_income_assessable_capital(Variable): @@ -27,14 +31,12 @@ class jsa_income_assessable_capital(Variable): def formula(benunit, period, parameters): JSA = parameters(period).gov.dwp.JSA.income - sources = JSA.capital.sources person = benunit.members claiming_jsa_income = add(benunit, period, ["jsa_income_reported"]) > 0 household_capital = valued_capital( lambda variable: benunit.max(person.household(variable, period)), - sources, - JSA.capital.sale_expenses, + JSA.capital, ) # Regulation 88(2) excludes children's and young persons' capital. # The claimant/partner weights approximate otherwise unobserved ownership. @@ -54,10 +56,11 @@ def formula(benunit, period, parameters): ) claimant_or_partner = person("is_claimant_or_partner", period) person_capital = sum( - benunit.sum(person(source, period) * claimant_or_partner) + benunit.sum(exact_amount(person(source, period)) * claimant_or_partner) for source in JSA.capital.person_sources ) household_share = where( household_reporting_claimants > 0, claiming_proxy, fallback_proxy ) - return household_share + person_capital + # A fraction of a penny is disregarded at the last stage (reg. 92). + return round_to_penny(household_share + person_capital, JSA.capital.round_down) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py index 3e3914d5be..bf33736ef9 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py @@ -1,5 +1,9 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.capital_valuation import valued_capital +from policyengine_uk.utils.capital_valuation import ( + exact_amount, + round_to_penny, + valued_capital, +) class pension_credit_assessable_capital(Variable): @@ -31,11 +35,11 @@ def formula(benunit, period, parameters): person = benunit.members p = parameters(period).gov.dwp.pension_credit.income.capital household_capital = valued_capital( - lambda variable: household(variable, period), p.sources, p.sale_expenses + lambda variable: household(variable, period), p ) claimant_or_partner = person("is_claimant_or_partner", period) person_capital = sum( - benunit.sum(person(source, period) * claimant_or_partner) + benunit.sum(exact_amount(person(source, period)) * claimant_or_partner) for source in p.person_sources ) any_pension_age = benunit.any( @@ -55,10 +59,15 @@ def formula(benunit, period, parameters): household_capital_proxy = ( household_capital * benunit_pension_age_adults / adult_divisor ) - reported_capital = benunit("pension_credit_reported_capital", period) + reported_capital = exact_amount( + benunit("pension_credit_reported_capital", period) + ) assessed_capital = where( reported_capital >= 0, reported_capital, household_capital_proxy + person_capital, ) - return where(any_pension_age, max_(0, assessed_capital), 0) + # A fraction of a penny is disregarded at the last stage (reg. 24A). + return round_to_penny( + where(any_pension_age, max_(0, assessed_capital), 0), p.round_down + ) 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 622b04a944..c4d6236d29 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 @@ -1,5 +1,9 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.capital_valuation import counted_share +from policyengine_uk.utils.capital_valuation import ( + counted_share, + exact_amount, + round_to_penny, +) class household_uc_pool_company_holdings(Variable): @@ -54,8 +58,10 @@ def formula(household, period, parameters): off_reported_capital = ( has_financial_report | has_complete_override ) & claimant_or_partner - holding = person("uc_company_holding_disregard", period) - upper_bound = max_(0, person.household("corporate_wealth", period)) + holding = exact_amount(person("uc_company_holding_disregard", period)) + upper_bound = max_( + 0, exact_amount(person.household("corporate_wealth", period)) + ) finite_holding = np.where( np.isfinite(holding), holding, np.where(holding > 0, upper_bound, 0) ) @@ -67,4 +73,7 @@ def formula(household, period, parameters): # The holdings sit in corporate_wealth, which the pool counts at the # reg. 49(1) value, so they leave it at that value. p = parameters(period).gov.dwp.universal_credit.means_test.capital - return holdings * counted_share(p.sale_expenses, "corporate_wealth") + return round_to_penny( + holdings * counted_share(p.sale_expenses, "corporate_wealth"), + p.round_down, + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py index 6daf295adc..0a09bcfec1 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py @@ -1,5 +1,9 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.capital_valuation import counted_share +from policyengine_uk.utils.capital_valuation import ( + counted_share, + exact_amount, + round_to_penny, +) class household_uc_reported_financial_capital(Variable): @@ -30,7 +34,12 @@ class household_uc_reported_financial_capital(Variable): "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." + "pool, not regulation 77 substitution in the owner's own assessment. " + "A recorded financial total is unvalued, so it consumes its part " + "beyond its claimants' holdings in full and the holdings at their " + "counted value. Like an override, a total below the holdings still " + "consumes them, so no part of an owner's holding is left in the pool " + "for another unit to count." ) definition_period = YEAR unit = GBP @@ -38,36 +47,41 @@ class household_uc_reported_financial_capital(Variable): def formula(household, period, parameters): person = household.members - financial_capital = person.benunit("benunit_reported_capital", period) - complete_override = person.benunit("uc_reported_capital", period) - holding = person("uc_company_holding_disregard", period) - upper_bound = max_(0, person.household("corporate_wealth", period)) + financial_capital = exact_amount( + person.benunit("benunit_reported_capital", period) + ) + complete_override = exact_amount(person.benunit("uc_reported_capital", period)) + holding = exact_amount(person("uc_company_holding_disregard", period)) + upper_bound = max_( + 0, exact_amount(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 - ) + ) * person.household("household_uc_company_holding_pool_scale", period) # The pool counts corporate_wealth, and the holdings in it, at the reg. - # 49(1) value. A complete override is a countable figure, so the - # holdings it must at least cover are counted at that value. A recorded - # financial total is used unvalued and holds the holdings at full value, - # so its known holding part is consumed at the counted value too; the - # owner's own assessment still takes the full holding off the report. + # 49(1) value, rounded to the nearest penny (reg. 6(1)). A complete + # override is a countable figure, so the holdings it must at least + # cover are counted at that value. A recorded financial total is used + # unvalued and holds the holdings at full value, so it consumes its + # part beyond the holdings in full and the holdings at their counted + # value; a total below the holdings still consumes them, so no part of + # an owner's holding is left for another unit. The owner's own + # assessment still takes the full holding off the report. p = parameters(period).gov.dwp.universal_credit.means_test.capital - valued_holdings = own_holdings * counted_share( - p.sale_expenses, "corporate_wealth" + valued_holdings = round_to_penny( + own_holdings * counted_share(p.sale_expenses, "corporate_wealth"), + p.round_down, ) consumed_capital = where( complete_override >= 0, max_(complete_override, valued_holdings), where( financial_capital >= 0, - max_(0, financial_capital - (own_holdings - valued_holdings)), + max_(0, financial_capital - own_holdings) + valued_holdings, financial_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 86883504da..dbee1c5ab3 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 @@ -1,5 +1,10 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.capital_valuation import counted_share, valued_capital +from policyengine_uk.utils.capital_valuation import ( + counted_share, + exact_amount, + round_to_penny, + valued_capital, +) class uc_assessable_capital(Variable): @@ -33,7 +38,9 @@ class uc_assessable_capital(Variable): "disregarded holding comes off at the value at which it was counted: " "90% in the pool and in a complete override, which is a countable " "figure, and in full from a recorded financial total, which is used " - "without valuation." + "without valuation. Every amount calculated is rounded to the nearest " + "penny (regulation 6(1)), including this capital, which is tested " + "against the limit." ) definition_period = YEAR unit = GBP @@ -49,7 +56,7 @@ def formula(benunit, period, parameters): household = benunit.household p = parameters(period).gov.dwp.universal_credit.means_test.capital household_financial_capital = valued_capital( - lambda variable: household(variable, period), p.sources, p.sale_expenses + lambda variable: household(variable, period), p ) # A reg. 77 holding sits in corporate_wealth, so it is counted at the # same 90% as the shares around it wherever they are valued. @@ -57,19 +64,21 @@ def formula(benunit, period, parameters): 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) + benunit.sum( + exact_amount(benunit.members(source, period)) * claimant_or_partner + ) for source in p.person_sources ) - financial_report = benunit("benunit_reported_capital", period) - complete_override = benunit("uc_reported_capital", period) + financial_report = exact_amount(benunit("benunit_reported_capital", period)) + complete_override = exact_amount(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), + - exact_amount(household("household_uc_reported_financial_capital", period)) + - exact_amount(household("household_uc_pool_company_holdings", period)), ) financial_proxy = where( unreported_claimants > 0, @@ -79,7 +88,7 @@ def formula(benunit, period, parameters): 0, ) own_holdings = benunit.sum( - benunit.members("uc_company_holding_disregard", period) + exact_amount(benunit.members("uc_company_holding_disregard", period)) * claimant_or_partner ) financial_capital = where( @@ -89,10 +98,17 @@ def formula(benunit, period, parameters): ) assessed_capital = where( complete_override >= 0, - max_(0, complete_override - own_holdings * holding_value), - financial_capital + benunit("uc_property_capital", period), + max_( + 0, + complete_override + - round_to_penny(own_holdings * holding_value, p.round_down), + ), + financial_capital + exact_amount(benunit("uc_property_capital", period)), ) company_capital = benunit.sum( - benunit.members("uc_company_capital", period) * claimant_or_partner + exact_amount(benunit.members("uc_company_capital", period)) + * claimant_or_partner ) - return max_(0, assessed_capital) + company_capital + # Every amount is rounded to the nearest penny (reg. 6(1)), the + # capital tested against the limit included. + return round_to_penny(max_(0, assessed_capital) + company_capital, p.round_down) 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 index 2d804975a6..4574f8ad39 100644 --- 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 @@ -1,5 +1,5 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.capital_valuation import less_sale_expenses +from policyengine_uk.utils.capital_valuation import exact_amount, less_sale_expenses class uc_non_residential_property_capital(Variable): @@ -22,4 +22,8 @@ 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, less_sale_expenses(market_value, p.sale_expenses.rate) - debt) + return max_( + 0, + less_sale_expenses(market_value, p.sale_expenses.rate, p.round_down) + - exact_amount(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 index ed969cb52e..fec4d08457 100644 --- 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 @@ -1,5 +1,5 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.capital_valuation import less_sale_expenses +from policyengine_uk.utils.capital_valuation import exact_amount, less_sale_expenses class uc_other_residential_property_capital(Variable): @@ -22,4 +22,8 @@ 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, less_sale_expenses(market_value, p.sale_expenses.rate) - debt) + return max_( + 0, + less_sale_expenses(market_value, p.sale_expenses.rate, p.round_down) + - exact_amount(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 index 7ae4bbb10e..2a673af312 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_property_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_property_capital.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import exact_amount, round_to_penny class uc_property_capital(Variable): @@ -28,11 +29,15 @@ class uc_property_capital(Variable): 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 + exact_amount(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) + return round_to_penny( + household_property * benunit_claimants / max_(1, household_claimants), + p.round_down, + ) From 9b9ef669ac7169c9e7719bcf953f0e68205425ed Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 23:52:09 -0400 Subject: [PATCH 13/38] Keep stored whole pennies through float64 paths; fix the CI failures Sol review v5 at b3f6f608b: - exact_amount raised an underflow warning on zero inputs, failing test_warning_filters. Errors are now ignored inside np.errstate, and twice the gap is compared with the spacing. - IS, JSA and ESA read household values through benunit.max, which is float64, so exact_amount used float64 spacing and never removed float32 storage error: 16,000.01 of savings floored to 16,000.00, at the limit. A value equal to its float32 is now judged at float32 spacing. - From 131,072 float32 cannot hold every penny, so no penny is identified and the stored value is kept; rounding down then never goes against the claimant. - The Lifetime ISA's 75% and UC company capital are worked in float64 from the stored amounts; company capital is rounded under reg. 6(1). - test_uc_capital_allocation expected exact conservation; each share is now its own nearest-penny amount (1.00 three ways is 0.33 each). - Docstrings: the guard's intended tolerance; no report leaves a holding in the pool. Tests: a model-level property that every programme counts whole-penny savings, shares and Lifetime ISA balances to the penny; widened-input and above-131,072 helper checks; YAML for 16,000.01 of savings under IS, JSA and ESA and a 1,000.04 Lifetime ISA. Co-Authored-By: Claude Opus 5.5 --- .../test_uc_capital_allocation.py | 37 +++++- .../baseline/gov/dwp/capital_valuation.yaml | 44 +++++++ .../test_capital_valuation_properties.py | 120 ++++++++++++++++-- policyengine_uk/utils/capital_valuation.py | 36 ++++-- .../gov/dwp/lifetime_isa_countable_capital.py | 5 +- .../company/uc_company_capital.py | 13 +- .../household_uc_pool_company_holdings.py | 6 +- 7 files changed, 229 insertions(+), 32 deletions(-) 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 fe82f6aa46..bfc742c641 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,8 +1,16 @@ -"""The financial proxy conserves residual capital after financial observations.""" +"""The financial proxy conserves residual capital after financial observations. + +Each unreported unit's share of the residual is its own amount, rounded to the +nearest penny with half a penny rounded up (UC Regs 2013 reg. 6(1)), so the +shares add up to the residual to within half a penny per unit: 1.00 shared +three ways is 0.33 each. +""" + +from decimal import ROUND_HALF_UP, Decimal import numpy as np import pytest -from hypothesis import HealthCheck, given, settings, strategies as st +from hypothesis import HealthCheck, example, given, settings, strategies as st from policyengine_core.simulations import Simulation from policyengine_uk.system import system @@ -29,6 +37,10 @@ def household_capital_situations(draw): max_size=3, ) ) + return build_situation(household_specs) + + +def build_situation(household_specs): people, benunits, households, expected = {}, {}, {}, [] for household_index, (capital, unit_specs) in enumerate(household_specs): household_members = [] @@ -56,7 +68,7 @@ def household_capital_situations(draw): if has_reported: reported_values[len(benunits)] = reported_capital else: - unreported_indices.append(len(benunits)) + unreported_indices.append((len(benunits), claimant_count)) benunits[unit_id] = { "members": members, "benunit_reported_capital": { @@ -78,6 +90,10 @@ def household_capital_situations(draw): return {"people": people, "benunits": benunits, "households": households}, expected +def nearest_penny(amount): + return float(amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) + + # Test conservation independently of machine-dependent generation timings. @settings( max_examples=30, @@ -85,6 +101,8 @@ def household_capital_situations(draw): suppress_health_check=[HealthCheck.too_slow], ) @given(household_capital_situations()) +# 1.00 of savings shared by three single claimants is 0.33 each, 0.99 in all. +@example(build_situation([(1, [(1, 0, False, 0)] * 3)])) def test_unreported_capital_shares_sum_to_each_households_residual(case): situation, expected = case # Reuse baseline rules; each example still gets fresh people and inputs. @@ -92,7 +110,16 @@ def test_unreported_capital_shares_sum_to_each_households_residual(case): assessed_capital = simulation.calculate("uc_assessable_capital", 2025) assert np.isfinite(assessed_capital).all() - for unreported_indices, reported_values, residual in expected: - assert assessed_capital[unreported_indices].sum() == pytest.approx(residual) + for unreported_units, reported_values, residual in expected: + claimants = sum(count for _, count in unreported_units) + for index, count in unreported_units: + share = Decimal(residual) * count / claimants + assert assessed_capital[index] == pytest.approx( + nearest_penny(share), abs=0.001 + ) + indices = [index for index, _ in unreported_units] + assert assessed_capital[indices].sum() == pytest.approx( + residual, abs=0.005 * len(indices) + 0.001 + ) for index, reported_capital in reported_values.items(): assert assessed_capital[index] == pytest.approx(reported_capital) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml index 6984634c9f..b3b2efe488 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml @@ -435,3 +435,47 @@ savings: 10_000.01 output: income_support_assessable_capital: [3_333.33, 3_333.33, 3_333.33] + +- name: Legacy benefits count 16,000.01 of savings in full, over the limit + period: 2026 + absolute_error_margin: 0.004 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # Stored in float32 as 16,000.0098, which is the penny 16,000.01: + # rounding down at the last stage (IS Regs 1987 reg. 27; JSA Regs 1996 + # reg. 92; ESA Regs 2008 reg. 3(a)) must not turn the storage error + # into a lost penny, which would put the capital at the 16,000 limit. + savings: 16_000.01 + output: + income_support_assessable_capital: 16_000.01 + jsa_income_assessable_capital: 16_000.01 + esa_income_assessable_capital: 16_000.01 + +- name: A Lifetime ISA counts at 75% of its balance to the penny + period: 2026 + absolute_error_margin: 0.004 + input: + people: + person: + age: 30 + lifetime_isa_balance: 1_000.04 + benunits: + benunit: + members: person + households: + household: + members: person + output: + # 75% of 1,000.04 is 750.03, its surrender value below age 60. + lifetime_isa_countable_capital: 750.03 + income_support_assessable_capital: 750.03 + esa_income_assessable_capital: 750.03 + uc_assessable_capital: 750.03 diff --git a/policyengine_uk/tests/test_capital_valuation_properties.py b/policyengine_uk/tests/test_capital_valuation_properties.py index 855a88c59c..2af8e32aa2 100644 --- a/policyengine_uk/tests/test_capital_valuation_properties.py +++ b/policyengine_uk/tests/test_capital_valuation_properties.py @@ -32,6 +32,12 @@ 7. Never rounds the wrong way: for any stored value, whole penny or not, the result is within a penny below 90% of the exact stored value when rounding down, and within half a penny of it when rounding to the nearest penny. + The same holds for a float32 value widened to float64, as a benefit unit's + maximum over its household's values is. +8. Whole pennies through the model: every programme counts whole-penny + savings, shares and Lifetime ISA balances to the penny: savings in full, + shares at 90% rounded by the programme's rule, and a Lifetime ISA at 75% + below its charge-free age. """ from fractions import Fraction @@ -297,15 +303,18 @@ def test_capital_is_non_increasing_in_rate_and_secured_debt(units, r1, r2, scale @example([1_600_100, 100_010, 230, 1_666_665]) def test_sale_expenses_round_to_the_penny(pence): stored = np.asarray(pence, dtype=np.int64).astype(np.float32) / np.float32(100) - down = less_sale_expenses(stored, LEGAL_RATE, True) - nearest = less_sale_expenses(stored, LEGAL_RATE, False) - assert down.dtype == nearest.dtype == np.float64 - assert np.round(down * 100).astype(np.int64).tolist() == [ - 9 * amount // 10 for amount in pence - ] - assert np.round(nearest * 100).astype(np.int64).tolist() == [ - (9 * amount + 5) // 10 for amount in pence - ] + # A benefit unit's maximum over its household's float32 values is float64. + for value in (stored, stored.astype(np.float64)): + assert np.round(exact_amount(value) * 100).astype(np.int64).tolist() == pence + down = less_sale_expenses(value, LEGAL_RATE, True) + nearest = less_sale_expenses(value, LEGAL_RATE, False) + assert down.dtype == nearest.dtype == np.float64 + assert np.round(down * 100).astype(np.int64).tolist() == [ + 9 * amount // 10 for amount in pence + ] + assert np.round(nearest * 100).astype(np.int64).tolist() == [ + (9 * amount + 5) // 10 for amount in pence + ] def test_share_valuation_matches_the_adm_worked_example(): @@ -324,10 +333,16 @@ def test_share_valuation_matches_the_adm_worked_example(): st.floats(0, 1_000_000, allow_nan=False, width=32), min_size=1, max_size=500 ) ) -@example([1034.375, 10352.055, 0.005, 999_999.875]) +@example([1034.375, 10352.055, 0.005, 999_999.875, 140_000.015625]) def test_sale_expenses_never_round_the_wrong_way(values): stored = np.asarray(values, dtype=np.float32) exact = exact_amount(stored) + assert np.array_equal(exact, exact_amount(stored.astype(np.float64))) + # From 131,072 float32 cannot hold every penny, so no penny is identified + # and the value is kept as stored: 140,000.01 and 140,000.02 are both + # stored as 140,000.015625, which rounds down to 140,000.01. + large = stored >= 131_072 + assert np.array_equal(exact[large], stored[large].astype(np.float64)) # Storage error is removed only within half a unit of the stored value's # own precision, so a value is never moved to a penny it was not stored # from. @@ -344,3 +359,88 @@ def test_sale_expenses_never_round_the_wrong_way(values): noise = Fraction(1, 100_000_000) assert target - Fraction(1, 100) - noise < Fraction(low) <= target + noise assert abs(Fraction(near) - target) <= Fraction(1, 200) + noise + + +ROUNDS_DOWN = { + "uc_assessable_capital": False, + "housing_benefit_assessable_capital": False, + "income_support_assessable_capital": True, + "jsa_income_assessable_capital": True, + "esa_income_assessable_capital": True, + "pension_credit_assessable_capital": True, +} + + +def whole_penny_situation(rows): + people, benunits, homes = {}, {}, {} + for i, (savings, shares, lisa_quarters, age) in enumerate(rows): + people[f"p{i}"] = { + "age": {YEAR: age}, + "lifetime_isa_balance": {YEAR: 4 * lisa_quarters / 100}, + } + benunits[f"b{i}"] = {"members": [f"p{i}"]} + homes[f"h{i}"] = { + "members": [f"p{i}"], + "savings": {YEAR: savings / 100}, + "corporate_wealth": {YEAR: shares / 100}, + } + return {"people": people, "benunits": benunits, "households": homes} + + +@PROPERTY_SETTINGS +@given( + st.lists( + st.tuples( + st.integers(0, 6_000_000), + st.integers(0, 6_000_000), + st.integers(0, 375_000), + st.sampled_from([30, 70]), + ), + min_size=20, + max_size=100, + ) +) +# 16,000.01 of savings is over every 16,000 limit; 1,000.10 and 12,345.67 +# lose a penny if float32 storage error reaches a floor; a Lifetime ISA of +# 0.24 or 1,000.04 counts 0.18 or 750.03 under 60. +@example( + [ + (1_600_001, 0, 0, 30), + (100_010, 0, 0, 30), + (1_234_567, 0, 0, 70), + (159_992, 1_600_010, 0, 30), + (0, 0, 6, 30), + (0, 0, 25_001, 30), + ] + * 4 +) +def test_whole_pennies_are_counted_to_the_penny(rows): + sim = Simulation(situation=whole_penny_situation(rows)) + values = {v: np.asarray(sim.calculate(v, YEAR), dtype=float) for v in PROGRAMMES} + gc = np.asarray(sim.calculate("guarantee_credit", YEAR)) + passported = np.asarray( + sim.calculate("housing_benefit_on_passporting_benefit", YEAR) + ) + savings_credit_only = np.asarray( + sim.calculate("in_receipt_of_savings_credit_only", YEAR) + ) + for variable, round_down in ROUNDS_DOWN.items(): + for i, (savings, shares, lisa_quarters, age) in enumerate(rows): + if not applies(variable, {"age": age}, gc[i], passported[i]): + continue + # Housing Benefit takes Pension Credit's capital for a savings + # credit only award (HB (SPC) Regs 2006 reg. 27(6)). + down = round_down or ( + variable == "housing_benefit_assessable_capital" + and savings_credit_only[i] + ) + share_pence = (9 * shares + (0 if down else 5)) // 10 + lisa_pence = 4 * lisa_quarters if age >= 60 else 3 * lisa_quarters + expected = (savings + share_pence + lisa_pence) / 100 + # The model stores float32, which holds every penny below 131,072. + assert abs(values[variable][i] - expected) < 0.004, ( + variable, + rows[i], + values[variable][i], + expected, + ) diff --git a/policyengine_uk/utils/capital_valuation.py b/policyengine_uk/utils/capital_valuation.py index 10396761dc..8b601731cd 100644 --- a/policyengine_uk/utils/capital_valuation.py +++ b/policyengine_uk/utils/capital_valuation.py @@ -73,18 +73,28 @@ def exact_amount(amount): """``amount`` in float64, with float32 storage error removed. Inputs are stored in float32, which holds £1,000.10 as 1,000.0999756 and - 90% of £16,001 as 14,400.8994. A value is taken to be a whole penny when - that penny is within half a unit of the value's own precision, so it is - the penny the value was stored from; any other value (a share price to - four decimal places, say) is kept exactly as stored. Above £131,072 - float32 cannot hold every penny, so a penny lost in storage there cannot - be recovered.""" - stored = np.asarray(amount) - wide = stored.astype(np.float64) + 90% of £16,001 as 14,400.8994. A float32 value, or one widened from + float32 to float64, is taken to be a whole penny when that penny is within + half its float32 spacing: it is then the penny the value was stored from. + Below £131,072 float32 holds every penny, so at most one penny is that + close. From £131,072 its spacing is wider than a penny, the penny cannot + be identified, and the value is kept as stored, so rounding it down + never goes against the claimant. Any other value (a share price to four + decimal places, or a float64 that is not a float32) is kept exactly.""" + wide = np.asarray(amount).astype(np.float64) + narrow = wide.astype(np.float32) pennies = np.round(wide, 2) - # An infinite amount has no precision to compare against and passes through. - with np.errstate(over="ignore", invalid="ignore"): - stored_from_penny = np.abs(pennies - wide) <= np.spacing(np.abs(stored)) / 2 + # Zero and subnormal values underflow in np.spacing, and an infinite one + # overflows; neither is a penny that needs recovering. + with np.errstate(over="ignore", under="ignore", invalid="ignore"): + precision = np.where( + narrow == wide, + np.spacing(np.abs(narrow)).astype(np.float64), + np.spacing(np.abs(wide)), + ) + stored_from_penny = (2 * np.abs(pennies - wide) <= precision) & ( + precision < 0.01 + ) return np.where(stored_from_penny, pennies, wide) @@ -101,6 +111,8 @@ def round_to_penny(amount, round_down): Computed in float64, and rounded to a millionth of a penny first, so binary noise next to a whole or half penny (206.99999999999997 pence for 90% of - £2.30) does not move it.""" + £2.30) does not move it. That tolerance is intended: an amount within a + millionth of a penny below a whole penny, which only a value that is not + a whole penny can produce, rounds to that penny.""" pence = np.round(np.asarray(amount, dtype=np.float64) * 100, 6) return np.where(round_down, np.floor(pence), np.floor(pence + 0.5)) / 100 diff --git a/policyengine_uk/variables/gov/dwp/lifetime_isa_countable_capital.py b/policyengine_uk/variables/gov/dwp/lifetime_isa_countable_capital.py index 6daf2817c3..ef088bbe9f 100644 --- a/policyengine_uk/variables/gov/dwp/lifetime_isa_countable_capital.py +++ b/policyengine_uk/variables/gov/dwp/lifetime_isa_countable_capital.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import exact_amount class lifetime_isa_countable_capital(Variable): @@ -25,6 +26,8 @@ class lifetime_isa_countable_capital(Variable): def formula(person, period, parameters): p = parameters(period).gov.hmrc.isa.lifetime - balance = person("lifetime_isa_balance", period) + # Worked in float64 from the stored balance, so 75% of a balance in + # whole pennies keeps its pennies (75% of 1,000.04 is 750.03). + balance = exact_amount(person("lifetime_isa_balance", period)) charge_free = person("age", period) >= p.charge_free_age return where(charge_free, balance, balance * (1 - p.withdrawal_charge)) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_capital.py index 541e637805..006a5f46b7 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_capital.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import exact_amount, round_to_penny class uc_company_capital(Variable): @@ -47,5 +48,13 @@ def formula(person, period, parameters): person("owned_company_trade_assets", period), 0, ) - capital = max_(0, person("owned_company_capital", period) - disregarded) - return where(applies, capital, 0) + # Worked in float64 from the stored amounts and rounded to the nearest + # penny (UC Regs 2013 reg. 6(1)): a float32 difference of two large + # amounts can be most of a penny out. + capital = max_( + 0, + exact_amount(person("owned_company_capital", period)) + - exact_amount(disregarded), + ) + p = parameters(period).gov.dwp.universal_credit.means_test.capital + return where(applies, round_to_penny(capital, p.round_down), 0) 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 c4d6236d29..81d6463ecc 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py @@ -28,8 +28,10 @@ class household_uc_pool_company_holdings(Variable): "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 " + "any part of a disregarded holding. A financial report below its " + "holder's known holding still consumes the holding at its counted " + "value (household_uc_reported_financial_capital), so no report leaves " + "a holding 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 " From 81235408518f1d8722166dcdba200f436c93206a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 24 Oct 2026 03:32:11 -0400 Subject: [PATCH 14/38] Value each Lifetime ISA to the penny in float64 under each programme's rule The engine stores lifetime_isa_countable_capital as float32, so 75% of a whole-penny balance that ends in half a penny (75% of 0.26 is 0.195) was read back just below it and rounded the wrong way under UC and HB: 0.26 of LISA and 15,999.81 of savings gave UC capital of 16,000.00, eligible, instead of 16,000.01. valued_person_capital now works a LISA's surrender value out in float64 from the balance and rounds each person's to the penny under the programme's rule, like any other valuation, in all six means tests. The whole-penny property draws any whole-penny balance, with the half-penny cases as examples, and two YAML cases pin the limit case and per-partner rounding. exact_amount's docstring no longer claims that rounding down never goes against the claimant above 131,072: a figure built from a stored value there can be a penny either way. The float32 cast moves inside errstate. Co-Authored-By: Claude Opus 5.5 --- changelog.d/uc-capital-sale-expenses.fixed.md | 2 +- .../baseline/gov/dwp/capital_valuation.yaml | 56 +++++++++++++++++ .../test_capital_valuation_properties.py | 33 ++++++---- policyengine_uk/utils/capital_valuation.py | 61 +++++++++++++++++-- .../gov/dwp/esa_income_assessable_capital.py | 7 +-- .../housing_benefit_assessable_capital.py | 6 +- .../dwp/income_support_assessable_capital.py | 7 +-- .../gov/dwp/jsa_income_assessable_capital.py | 7 +-- .../gov/dwp/lifetime_isa_countable_capital.py | 12 ++-- .../pension_credit_assessable_capital.py | 6 +- .../universal_credit/uc_assessable_capital.py | 8 +-- 11 files changed, 155 insertions(+), 50 deletions(-) diff --git a/changelog.d/uc-capital-sale-expenses.fixed.md b/changelog.d/uc-capital-sale-expenses.fixed.md index b71561b5b9..5193d8d2f3 100644 --- a/changelog.d/uc-capital-sale-expenses.fixed.md +++ b/changelog.d/uc-capital-sale-expenses.fixed.md @@ -1 +1 @@ -Value Universal Credit, Housing Benefit, Income Support, income-based JSA, income-related ESA and Pension Credit capital at market value less 10% where a sale would incur expenses and less any debt secured on the asset, as UC Regs 2013 reg. 49(1) and its legacy equivalents require. The 10% applies to land, property and corporate_wealth, not to cash savings; corporate_wealth also holds unit trusts, which have no costs of sale, and in datasets built before the private pension split it holds pension wealth, so the deduction reaches those too until the data separate them. Adds secured-debt inputs for other residential property, non-residential property and land. Each programme rounds a fraction of a penny in its capital calculations as its regulations require: to the nearest penny for Universal Credit (UC Regs 2013 reg. 6(1)) and Housing Benefit, and down for the legacy benefits and Pension Credit. +Value Universal Credit, Housing Benefit, Income Support, income-based JSA, income-related ESA and Pension Credit capital at market value less 10% where a sale would incur expenses and less any debt secured on the asset, as UC Regs 2013 reg. 49(1) and its legacy equivalents require. The 10% applies to land, property and share-like capital (share_like_capital), not to cash savings; share-like capital also holds unit trusts, which have no costs of sale, and in datasets built before the private pension split it holds pension wealth, so the deduction reaches those too until the data separate them. Adds secured-debt inputs for other residential property, non-residential property and land. Each programme rounds a fraction of a penny in its capital calculations as its regulations require: to the nearest penny for Universal Credit (UC Regs 2013 reg. 6(1)) and Housing Benefit, and down for the legacy benefits and Pension Credit. A Lifetime ISA's surrender value is worked out from its balance and rounded under the same rule. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml index b3b2efe488..521397a82b 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml @@ -479,3 +479,59 @@ income_support_assessable_capital: 750.03 esa_income_assessable_capital: 750.03 uc_assessable_capital: 750.03 + +- name: UC rounds a Lifetime ISA's half penny up, a penny over the limit + period: 2026 + absolute_error_margin: 0.004 + input: + people: + person: + age: 30 + # Below age 60 the surrender value is 75% of the balance: 0.195, half + # a penny, which float32 would store as 0.19499999. UC Regs 2013 reg. + # 6(1) treats it as a penny, so the capital is 15,999.81 + 0.20 = + # 16,000.01, over the 16,000 limit. The legacy benefits disregard it + # (IS Regs 1987 reg. 27; JSA Regs 1996 reg. 92; ESA Regs 2008 reg. + # 3(a)): 15,999.81 + 0.19 = 16,000.00. + lifetime_isa_balance: 0.26 + benunits: + benunit: + members: person + households: + household: + members: person + savings: 15_999.81 + output: + uc_assessable_capital: 16_000.01 + is_uc_eligible: false + income_support_assessable_capital: 16_000 + jsa_income_assessable_capital: 16_000 + esa_income_assessable_capital: 16_000 + +- name: Each partner's Lifetime ISA is valued to the penny on its own + period: 2026 + absolute_error_margin: 0.004 + input: + people: + first: + age: 30 + lifetime_isa_balance: 0.26 + second: + age: 30 + lifetime_isa_balance: 0.26 + benunits: + benunit: + members: [first, second] + households: + household: + members: [first, second] + # Each surrender value is 0.195, a calculation of its own: 0.20 to the + # nearest penny (UC Regs 2013 reg. 6(1)), so 15,999.62 + 0.40 = + # 16,000.02; 0.19 rounded down (IS Regs 1987 reg. 27; JSA Regs 1996 + # reg. 92; ESA Regs 2008 reg. 3(a)), so 15,999.62 + 0.38 = 16,000.00. + savings: 15_999.62 + output: + uc_assessable_capital: 16_000.02 + income_support_assessable_capital: 16_000 + jsa_income_assessable_capital: 16_000 + esa_income_assessable_capital: 16_000 diff --git a/policyengine_uk/tests/test_capital_valuation_properties.py b/policyengine_uk/tests/test_capital_valuation_properties.py index 19b97ffa1b..2f7be49d89 100644 --- a/policyengine_uk/tests/test_capital_valuation_properties.py +++ b/policyengine_uk/tests/test_capital_valuation_properties.py @@ -36,8 +36,8 @@ maximum over its household's values is. 8. Whole pennies through the model: every programme counts whole-penny savings, shares and Lifetime ISA balances to the penny: savings in full, - shares at 90% rounded by the programme's rule, and a Lifetime ISA at 75% - below its charge-free age. + shares at 90% and a Lifetime ISA at 75% below its charge-free age, each + rounded by the programme's rule, and a Lifetime ISA in full from that age. """ from fractions import Fraction @@ -347,8 +347,10 @@ def test_sale_expenses_never_round_the_wrong_way(values): exact = exact_amount(stored) assert np.array_equal(exact, exact_amount(stored.astype(np.float64))) # From 131,072 float32 cannot hold every penny, so no penny is identified - # and the value is kept as stored: 140,000.01 and 140,000.02 are both - # stored as 140,000.015625, which rounds down to 140,000.01. + # and the value is kept as stored. The result is then within a penny of + # the stored value's figure, not of every penny it could have been stored + # from: 140,000.01 and 140,000.02 are both stored as 140,000.015625, and + # 90% of that rounds down to 126,000.01, a penny above 140,000.01's. large = stored >= 131_072 assert np.array_equal(exact[large], stored[large].astype(np.float64)) # Storage error is removed only within half a unit of the stored value's @@ -381,10 +383,10 @@ def test_sale_expenses_never_round_the_wrong_way(values): def whole_penny_situation(rows): people, benunits, homes = {}, {}, {} - for i, (savings, shares, lisa_quarters, age) in enumerate(rows): + for i, (savings, shares, lisa, age) in enumerate(rows): people[f"p{i}"] = { "age": {YEAR: age}, - "lifetime_isa_balance": {YEAR: 4 * lisa_quarters / 100}, + "lifetime_isa_balance": {YEAR: lisa / 100}, } benunits[f"b{i}"] = {"members": [f"p{i}"]} homes[f"h{i}"] = { @@ -401,7 +403,7 @@ def whole_penny_situation(rows): st.tuples( st.integers(0, 6_000_000), st.integers(0, 6_000_000), - st.integers(0, 375_000), + st.integers(0, 1_500_000), st.sampled_from([30, 70]), ), min_size=20, @@ -410,17 +412,22 @@ def whole_penny_situation(rows): ) # 16,000.01 of savings is over every 16,000 limit; 1,000.10 and 12,345.67 # lose a penny if float32 storage error reaches a floor; a Lifetime ISA of -# 0.24 or 1,000.04 counts 0.18 or 750.03 under 60. +# 0.24 or 1,000.04 counts 0.18 or 750.03 under 60. 75% of a 0.26 balance is +# 0.195 and of 1,000.30 is 750.225, half pennies, which float32 stores just +# below: with 15,999.81 or 15,249.77 of savings the capital is 16,000.01 or +# 16,000.00 to the nearest penny, and 16,000.00 or 15,999.99 rounded down. @example( [ (1_600_001, 0, 0, 30), (100_010, 0, 0, 30), (1_234_567, 0, 0, 70), (159_992, 1_600_010, 0, 30), - (0, 0, 6, 30), - (0, 0, 25_001, 30), + (0, 0, 24, 30), + (0, 0, 100_004, 30), + (1_599_981, 0, 26, 30), + (1_524_977, 0, 100_030, 30), ] - * 4 + * 3 ) def test_whole_pennies_are_counted_to_the_penny(rows): sim = Simulation(situation=whole_penny_situation(rows)) @@ -433,7 +440,7 @@ def test_whole_pennies_are_counted_to_the_penny(rows): sim.calculate("in_receipt_of_savings_credit_only", YEAR) ) for variable, round_down in ROUNDS_DOWN.items(): - for i, (savings, shares, lisa_quarters, age) in enumerate(rows): + for i, (savings, shares, lisa, age) in enumerate(rows): if not applies(variable, {"age": age}, gc[i], passported[i]): continue # Housing Benefit takes Pension Credit's capital for a savings @@ -443,7 +450,7 @@ def test_whole_pennies_are_counted_to_the_penny(rows): and savings_credit_only[i] ) share_pence = (9 * shares + (0 if down else 5)) // 10 - lisa_pence = 4 * lisa_quarters if age >= 60 else 3 * lisa_quarters + lisa_pence = lisa if age >= 60 else (3 * lisa + (0 if down else 2)) // 4 expected = (savings + share_pence + lisa_pence) / 100 # The model stores float32, which holds every penny below 131,072. assert abs(values[variable][i] - expected) < 0.004, ( diff --git a/policyengine_uk/utils/capital_valuation.py b/policyengine_uk/utils/capital_valuation.py index 8b601731cd..2b7a6911da 100644 --- a/policyengine_uk/utils/capital_valuation.py +++ b/policyengine_uk/utils/capital_valuation.py @@ -51,6 +51,49 @@ def valued_capital(value_of, capital): return total +def valued_person_capital(benunit, period, parameters, capital, counted): + """Sum ``capital.person_sources`` over the benefit unit's members for whom + ``counted`` is true (its claimant and partner), in float64. + + A source the engine would store with a fraction of a penny is worked out + here from its inputs (``CALCULATED_PERSON_SOURCES``) and rounded to the + penny under the programme's rule, like any other valuation, rather than + read back from float32. Any other source is a stored amount.""" + person = benunit.members + total = 0 + for source in capital.person_sources: + if source in CALCULATED_PERSON_SOURCES: + value = round_to_penny( + CALCULATED_PERSON_SOURCES[source](person, period, parameters), + capital.round_down, + ) + else: + value = exact_amount(person(source, period)) + total = total + benunit.sum(value * counted) + return total + + +def lifetime_isa_surrender_value(person, period, parameters): + """Surrender value of each person's Lifetime ISAs, in float64: the balance + less the withdrawal charge below the charge-free age, and the whole balance + from that age. + + 75% of a whole-penny balance can end in a quarter or half penny (75% of + £0.26 is £0.195), which float32 would store as 0.19499999 and so move a + nearest-penny rounding; 75% of £1,000.04 is £750.03.""" + p = parameters(period).gov.hmrc.isa.lifetime + balance = exact_amount(person("lifetime_isa_balance", period)) + charge_free = person("age", period) >= p.charge_free_age + return np.where(charge_free, balance, balance * (1 - p.withdrawal_charge)) + + +# Person-level capital sources the means tests value from their inputs, each +# mapped to the float64 calculation of its value. +CALCULATED_PERSON_SOURCES = { + "lifetime_isa_countable_capital": lifetime_isa_surrender_value, +} + + def counted_share(sale_expenses, source): """Share of a source's market value counted as capital: 90% where its sale would incur expenses (reg. 49(1)(a)), otherwise all of it.""" @@ -78,15 +121,21 @@ def exact_amount(amount): half its float32 spacing: it is then the penny the value was stored from. Below £131,072 float32 holds every penny, so at most one penny is that close. From £131,072 its spacing is wider than a penny, the penny cannot - be identified, and the value is kept as stored, so rounding it down - never goes against the claimant. Any other value (a share price to four - decimal places, or a float64 that is not a float32) is kept exactly.""" + be identified, and the value is kept as stored. A capital figure built + from it can then be a penny either way: £140,000.01 and £140,000.02 are + both stored as 140,000.015625, and 90% of that rounds down to + £126,000.01, a penny above the £126,000.00 that £140,000.01 gives. The + same holds for any stored value from £262,144, where the spacing exceeds + two pennies. A value in whole pounds below £16,777,216 is stored exactly + and is unaffected. Any other value (a share price to four decimal places, + or a float64 that is not a float32) is kept exactly.""" wide = np.asarray(amount).astype(np.float64) - narrow = wide.astype(np.float32) pennies = np.round(wide, 2) - # Zero and subnormal values underflow in np.spacing, and an infinite one - # overflows; neither is a penny that needs recovering. + # Zero and subnormal values underflow in np.spacing and in the cast to + # float32, and an infinite one overflows; neither is a penny that needs + # recovering. with np.errstate(over="ignore", under="ignore", invalid="ignore"): + narrow = wide.astype(np.float32) precision = np.where( narrow == wide, np.spacing(np.abs(narrow)).astype(np.float64), diff --git a/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py b/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py index fd66242987..77624ac1fb 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py @@ -1,8 +1,8 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.capital_valuation import ( - exact_amount, round_to_penny, valued_capital, + valued_person_capital, ) @@ -55,9 +55,8 @@ def formula(benunit, period, parameters): household_capital * benunit_claimants_and_partners / fallback_divisor ) claimant_or_partner = person("is_claimant_or_partner", period) - person_capital = sum( - benunit.sum(exact_amount(person(source, period)) * claimant_or_partner) - for source in ESA.capital.person_sources + person_capital = valued_person_capital( + benunit, period, parameters, ESA.capital, claimant_or_partner ) household_share = where( household_reporting_claimants > 0, claiming_proxy, fallback_proxy diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py index 44c381268e..3dc5d6b60d 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py @@ -3,6 +3,7 @@ exact_amount, round_to_penny, valued_capital, + valued_person_capital, ) @@ -54,9 +55,8 @@ def formula(benunit, period, parameters): lambda variable: household(variable, period), p ) claimant_or_partner = person("is_claimant_or_partner", period) - person_capital = sum( - benunit.sum(exact_amount(person(source, period)) * claimant_or_partner) - for source in p.person_sources + person_capital = valued_person_capital( + benunit, period, parameters, p, claimant_or_partner ) benunit_claimants_and_partners = add( benunit, period, ["is_claimant_or_partner"] diff --git a/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py b/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py index 33df6b0840..aa38b2279b 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py @@ -1,8 +1,8 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.capital_valuation import ( - exact_amount, round_to_penny, valued_capital, + valued_person_capital, ) @@ -61,9 +61,8 @@ def formula(benunit, period, parameters): household_capital * benunit_claimants_and_partners / fallback_divisor ) claimant_or_partner = person("is_claimant_or_partner", period) - person_capital = sum( - benunit.sum(exact_amount(person(source, period)) * claimant_or_partner) - for source in IS.means_test.capital.person_sources + person_capital = valued_person_capital( + benunit, period, parameters, IS.means_test.capital, claimant_or_partner ) household_share = where( household_claiming_members > 0, claiming_proxy, fallback_proxy diff --git a/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py b/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py index 1cf104940a..3ca5804c58 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py @@ -1,8 +1,8 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.capital_valuation import ( - exact_amount, round_to_penny, valued_capital, + valued_person_capital, ) @@ -55,9 +55,8 @@ def formula(benunit, period, parameters): household_capital * benunit_claimants_and_partners / fallback_divisor ) claimant_or_partner = person("is_claimant_or_partner", period) - person_capital = sum( - benunit.sum(exact_amount(person(source, period)) * claimant_or_partner) - for source in JSA.capital.person_sources + person_capital = valued_person_capital( + benunit, period, parameters, JSA.capital, claimant_or_partner ) household_share = where( household_reporting_claimants > 0, claiming_proxy, fallback_proxy diff --git a/policyengine_uk/variables/gov/dwp/lifetime_isa_countable_capital.py b/policyengine_uk/variables/gov/dwp/lifetime_isa_countable_capital.py index ef088bbe9f..4f5d1e6271 100644 --- a/policyengine_uk/variables/gov/dwp/lifetime_isa_countable_capital.py +++ b/policyengine_uk/variables/gov/dwp/lifetime_isa_countable_capital.py @@ -1,5 +1,5 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.capital_valuation import exact_amount +from policyengine_uk.utils.capital_valuation import lifetime_isa_surrender_value class lifetime_isa_countable_capital(Variable): @@ -25,9 +25,7 @@ class lifetime_isa_countable_capital(Variable): ] def formula(person, period, parameters): - p = parameters(period).gov.hmrc.isa.lifetime - # Worked in float64 from the stored balance, so 75% of a balance in - # whole pennies keeps its pennies (75% of 1,000.04 is 750.03). - balance = exact_amount(person("lifetime_isa_balance", period)) - charge_free = person("age", period) >= p.charge_free_age - return where(charge_free, balance, balance * (1 - p.withdrawal_charge)) + # Stored as float32. The means tests work the value out in float64 + # from the balance instead, and round it under each programme's rule + # (capital_valuation.valued_person_capital). + return lifetime_isa_surrender_value(person, period, parameters) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py index bf33736ef9..adf8ac35de 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py @@ -3,6 +3,7 @@ exact_amount, round_to_penny, valued_capital, + valued_person_capital, ) @@ -38,9 +39,8 @@ def formula(benunit, period, parameters): lambda variable: household(variable, period), p ) claimant_or_partner = person("is_claimant_or_partner", period) - person_capital = sum( - benunit.sum(exact_amount(person(source, period)) * claimant_or_partner) - for source in p.person_sources + person_capital = valued_person_capital( + benunit, period, parameters, p, claimant_or_partner ) any_pension_age = benunit.any( person("has_attained_state_pension_credit_qualifying_age", period) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py index 712650f0ab..e7ece46cdd 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 @@ -4,6 +4,7 @@ exact_amount, round_to_penny, valued_capital, + valued_person_capital, ) from policyengine_uk.variables.gov.dwp.universal_credit.household_uc_company_holding_pool_scale import ( HOLDINGS_SOURCE, @@ -66,11 +67,8 @@ def formula(benunit, period, parameters): holding_value = counted_share(p.sale_expenses, HOLDINGS_SOURCE) claimant_or_partner = benunit.members("is_uc_claimant", period) benunit_claimants = benunit.sum(claimant_or_partner) - person_capital = sum( - benunit.sum( - exact_amount(benunit.members(source, period)) * claimant_or_partner - ) - for source in p.person_sources + person_capital = valued_person_capital( + benunit, period, parameters, p, claimant_or_partner ) financial_report = exact_amount(benunit("benunit_reported_capital", period)) complete_override = exact_amount(benunit("uc_reported_capital", period)) From 825fe3d48b535f607751eda17c3b24bb42cf3ab9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 26 Oct 2026 12:24:02 -0700 Subject: [PATCH 15/38] Tighten capital source validation and document UC report routing Co-Authored-By: Claude Opus 5.5 --- .../engineering/property-wealth-components.md | 2 +- .../means_test/capital/sources.yaml | 4 +-- .../capital/sale_expenses/sources.yaml | 2 +- .../uc_assessable_capital.yaml | 28 +++++++++++++++++++ 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/docs/engineering/property-wealth-components.md b/docs/engineering/property-wealth-components.md index ec3ee64400..e91e544286 100644 --- a/docs/engineering/property-wealth-components.md +++ b/docs/engineering/property-wealth-components.md @@ -182,7 +182,7 @@ citations. Paths are relative to `policyengine_uk/variables/`. | --- | --- | | Residential and worldwide property | `household/wealth/residential_property_value.py` and `property_wealth.py` use the component identities above | | Total wealth and wealth deciles | `household/wealth/total_wealth.py` includes property once; `household_wealth_decile.py` reads total wealth with person-weighted household weights | -| Wealth taxes | `contrib/ubi_center/wealth_tax.py` reads total wealth; `contrib/cec/non_primary_residence_wealth_tax.py` subtracts main residence from total wealth | +| Wealth taxes | `contrib/ubi_center/wealth_tax.py` reads `net_wealth`, which deducts mortgage, consumer and student-loan debt from total wealth; `contrib/cec/non_primary_residence_wealth_tax.py` subtracts main residence from gross total wealth | | UK land and LVT | `household/wealth/household_land_value.py` estimates domestic household land; `land_value.py` adds corporate land; `contrib/ubi_center/land_value_tax.py` applies the configured combined, household and corporate rates | | Domestic property purchases | `household/consumption/main_residential_property_purchased.py`, `additional_residential_property_purchased.py` and `non_residential_property_purchased.py` retain their separate transaction routing | | SDLT | `gov/hmrc/sdlt_on_residential_property_transactions.py` and `sdlt_on_non_residential_property_transactions.py` read transactions; `stamp_duty_land_tax.py` aggregates liabilities and `expected_sdlt.py` carries the household liability | diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml index 6a3ce12d23..1a47b7ef36 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml @@ -38,5 +38,5 @@ values: - share_like_capital - other_residential_property_value - non_residential_property_value - - overseas_property_value - - other_property_value + - overseas_property_value + - other_property_value diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml index 4f2c7285db..7a274f79d1 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml @@ -11,7 +11,7 @@ description: > not listed: the 10% applies where selling normally needs an agent, broker, solicitor or accountant (H1604-H1605), the costs of withdrawing money are not costs of sale (H1607), and an account is valued at its balance (H1675). - Countable property is valued through property_sources with the same rate, + Existing domestic property is valued through property_sources with the same rate, and real property always has costs of sale (H1606). `share_like_capital` retains the supplied `corporate_wealth` aggregate or the larger itemised-share total beyond float32 rounding, without adding 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 4dc1dbbc92..04e43f902d 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 @@ -305,3 +305,31 @@ output: # max(0, 90% of 120,000 - 120,000); no separate buy-to-let input. uc_assessable_capital: 0 + +- name: A financial report overrides overseas property but retains domestic property capital + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + benunit_reported_capital: 0 + overseas_property_value: 18_000 + other_residential_property_value: 10_000 + output: + # The financial observation replaces the new category's proxy, while the + # independent domestic property pool retains 90% of the 10,000 stock. + uc_property_capital: 9_000 + uc_assessable_capital: 9_000 + +- name: A financial report overrides unclassified other property but retains domestic property capital + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + benunit_reported_capital: 0 + other_property_value: 18_000 + non_residential_property_value: 10_000 + output: + # The financial observation replaces the new category's proxy, while the + # independent domestic property pool retains 90% of the 10,000 stock. + uc_property_capital: 9_000 + uc_assessable_capital: 9_000 From bc742b94a7f041bc640960a9044fd6548a07b0d7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 26 Oct 2026 12:30:06 -0700 Subject: [PATCH 16/38] Reuse a read-only model for property metadata assertions Co-Authored-By: Claude Opus 5.5 --- .../tests/test_property_wealth_components.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/test_property_wealth_components.py b/policyengine_uk/tests/test_property_wealth_components.py index 9d94ea9b02..58edf1dfdc 100644 --- a/policyengine_uk/tests/test_property_wealth_components.py +++ b/policyengine_uk/tests/test_property_wealth_components.py @@ -77,6 +77,14 @@ ) +@pytest.fixture(scope="module") +def _property_metadata_system(cloned_uk_tax_benefit_system): + # The dependency installs the clone factory before this read-only fixture. + import policyengine_uk.simulation as simulation_module + + return simulation_module.CountryTaxBenefitSystem() + + def _saved_components(source_values): """The upstream release contract combines second homes and buy-to-let.""" components = dict.fromkeys(PROPERTY_INPUTS, 0.0) @@ -144,10 +152,9 @@ def _component_dataset(components, *, stored_total=None): "variable", ["overseas_property_value", "other_property_value"] ) def test_new_property_inputs_register_gdp_uprating_for_situations_and_datasets( - variable, + variable, _property_metadata_system ): - sim = _simulation({variable: 100_000}) - model_variable = sim.tax_benefit_system.variables[variable] + model_variable = _property_metadata_system.variables[variable] assert model_variable.uprating == GDP_INDEX assert model_variable.entity.key == "household" assert model_variable.default_value == 0 @@ -159,8 +166,10 @@ def test_new_property_inputs_register_gdp_uprating_for_situations_and_datasets( @pytest.mark.parametrize("capital_path", CAPITAL_PATHS) @pytest.mark.parametrize("year", [2024, 2026]) -def test_property_capital_lists_preserve_each_source_once(capital_path, year): - parameters = _simulation({}).tax_benefit_system.parameters +def test_property_capital_lists_preserve_each_source_once( + capital_path, year, _property_metadata_system +): + parameters = _property_metadata_system.parameters sources = list(parameters.get_child(f"{capital_path}.sources")(year)) sale_sources = list( parameters.get_child(f"{capital_path}.sale_expenses.sources")(year) From 199c534351f8ba322e8f77fcf3d55fca749137bf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 26 Oct 2026 12:33:52 -0700 Subject: [PATCH 17/38] Exercise formula properties with fresh lightweight simulations Keep UK wrapper integration coverage for dataset loading, uprating, monthly stocks, purchases and LVT while avoiding repeated setup for formula-only draws. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_property_wealth_components.py | 75 ++++++++++++------- 1 file changed, 48 insertions(+), 27 deletions(-) diff --git a/policyengine_uk/tests/test_property_wealth_components.py b/policyengine_uk/tests/test_property_wealth_components.py index 58edf1dfdc..38bd474e50 100644 --- a/policyengine_uk/tests/test_property_wealth_components.py +++ b/policyengine_uk/tests/test_property_wealth_components.py @@ -3,6 +3,9 @@ WAS has seven gross terms, but second homes and buy-to-let are saved together by policyengine-uk-data. These tests exercise that six-input model contract; licensed-source reconciliation and release persistence belong to the data repo. +The five formula properties use fresh Core simulation holders with one shared +model; uprating, datasets, monthly stocks, LVT and purchases exercise the UK +simulation wrapper separately. """ from pathlib import Path @@ -13,6 +16,7 @@ import yaml from hypothesis import example, given, settings from hypothesis import strategies as st +from policyengine_core.simulations import Simulation as CoreSimulation from policyengine_uk import Simulation from policyengine_uk.data import UKSingleYearDataset @@ -93,24 +97,35 @@ def _saved_components(source_values): return components -def _simulation(assets, *, region="LONDON", reform=None): - return Simulation( - situation={ - "people": {"person": {"age": {BASE_YEAR: 40}}}, - "benunits": {"benunit": {"members": ["person"]}}, - "households": { - "household": { - "members": ["person"], - "region": {BASE_YEAR: region}, - **{ - variable: {BASE_YEAR: value} - for variable, value in assets.items() - }, - } - }, +def _situation(assets, region="LONDON"): + return { + "people": {"person": {"age": {BASE_YEAR: 40}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": { + "household": { + "members": ["person"], + "region": {BASE_YEAR: region}, + **{variable: {BASE_YEAR: value} for variable, value in assets.items()}, + } }, - reform=reform, - ) + } + + +def _simulation(assets, *, region="LONDON", reform=None): + return Simulation(situation=_situation(assets, region), reform=reform) + + +@pytest.fixture(scope="module") +def _formula_simulation(_property_metadata_system): + """Fresh holders per example; unchanged production formula definitions.""" + + def build(assets, *, region="LONDON"): + return CoreSimulation( + tax_benefit_system=_property_metadata_system, + situation=_situation(assets, region), + ) + + return build def _value(simulation, variable, period=BASE_YEAR): @@ -225,8 +240,8 @@ def test_london_mixed_property_has_150000_gross_and_105000_uk_land(): @PROPERTY_SETTINGS @given(st.tuples(*(MONEY for _ in PROPERTY_INPUTS))) @example((60_000, 25_000, 15_000, 20_000, 30_000, 40_000)) -def test_property_wealth_equals_sum_of_saved_inputs(values): - sim = _simulation(dict(zip(PROPERTY_INPUTS, values))) +def test_property_wealth_equals_sum_of_saved_inputs(_formula_simulation, values): + sim = _formula_simulation(dict(zip(PROPERTY_INPUTS, values))) assert _value(sim, "property_wealth") == pytest.approx( _property_sum(sim), rel=2e-6, abs=0.01 @@ -236,8 +251,10 @@ def test_property_wealth_equals_sum_of_saved_inputs(values): @PROPERTY_SETTINGS @given(st.tuples(*(MONEY for _ in PROPERTY_INPUTS)), st.sampled_from(REGIONS)) @example((0, 0, 0, 20_000, 0, 0), "LONDON") -def test_household_land_value_is_at_least_direct_uk_land(values, region): - sim = _simulation(dict(zip(PROPERTY_INPUTS, values)), region=region) +def test_household_land_value_is_at_least_direct_uk_land( + _formula_simulation, values, region +): + sim = _formula_simulation(dict(zip(PROPERTY_INPUTS, values)), region=region) direct_land = _value(sim, "owned_land") land = _value(sim, "household_land_value") # Subtracting independently rounded stocks can lose a few float32 ulps. @@ -249,8 +266,8 @@ def test_household_land_value_is_at_least_direct_uk_land(values, region): @PROPERTY_SETTINGS @given(MONEY, st.sampled_from(REGIONS)) @example(30_000, "LONDON") -def test_overseas_only_households_have_zero_uk_land(value, region): - sim = _simulation({"overseas_property_value": value}, region=region) +def test_overseas_only_households_have_zero_uk_land(_formula_simulation, value, region): + sim = _formula_simulation({"overseas_property_value": value}, region=region) assert _value(sim, "property_wealth") == _value(sim, "overseas_property_value") assert _value(sim, "household_land_value") == 0 @@ -259,8 +276,10 @@ def test_overseas_only_households_have_zero_uk_land(value, region): @PROPERTY_SETTINGS @given(MONEY, st.sampled_from(REGIONS)) @example(20_000, "LONDON") -def test_land_only_households_count_direct_uk_land_once(value, region): - sim = _simulation({"owned_land": value}, region=region) +def test_land_only_households_count_direct_uk_land_once( + _formula_simulation, value, region +): + sim = _formula_simulation({"owned_land": value}, region=region) direct_land = _value(sim, "owned_land") assert _value(sim, "property_wealth") == direct_land @@ -271,8 +290,10 @@ def test_land_only_households_count_direct_uk_land_once(value, region): @PROPERTY_SETTINGS @given(st.tuples(*(MONEY for _ in (*PROPERTY_INPUTS, *OTHER_WEALTH_INPUTS)))) @example((60_000, 25_000, 15_000, 20_000, 30_000, 40_000, 10_000, 5_000, 1_000)) -def test_total_wealth_includes_every_asset_category_once(values): - sim = _simulation(dict(zip((*PROPERTY_INPUTS, *OTHER_WEALTH_INPUTS), values))) +def test_total_wealth_includes_every_asset_category_once(_formula_simulation, values): + sim = _formula_simulation( + dict(zip((*PROPERTY_INPUTS, *OTHER_WEALTH_INPUTS), values)) + ) expected = sum( _value(sim, variable) for variable in (*PROPERTY_INPUTS, *OTHER_WEALTH_INPUTS) ) From 442f0330c8c99ff9965dd76eebfc539dd6f93bc1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 26 Oct 2026 12:42:22 -0700 Subject: [PATCH 18/38] Use the current Towncrier fragment style Co-Authored-By: Claude Opus 5.5 --- changelog.d/property-wealth-overseas-other.added.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/property-wealth-overseas-other.added.md b/changelog.d/property-wealth-overseas-other.added.md index 37b064986d..39d59bc8a8 100644 --- a/changelog.d/property-wealth-overseas-other.added.md +++ b/changelog.d/property-wealth-overseas-other.added.md @@ -1 +1 @@ -- Added overseas and other real estate property inputs, included all property components in worldwide gross wealth and means-tested capital, and counted direct UK land once while excluding identified overseas property from household land. +Added overseas and other real estate property inputs, included all property components in worldwide gross wealth and means-tested capital, and counted direct UK land once while excluding identified overseas property from household land. From 1193614a6436560a443714b7873e7ff26a328e6c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 27 Oct 2026 09:49:38 -0700 Subject: [PATCH 19/38] Fix the company capital conservation rounding oracle Model stored-input precision, holdings scaling and regulation 6(1) penny rounding at each valuation and benefit-unit allocation step. Pin the CI counterexample and the holdings-budget precision boundary. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_uc_company_owner_properties.py | 99 ++++++++++++++++--- 1 file changed, 87 insertions(+), 12 deletions(-) diff --git a/policyengine_uk/tests/test_uc_company_owner_properties.py b/policyengine_uk/tests/test_uc_company_owner_properties.py index 64d158e3dc..85eb2e3818 100644 --- a/policyengine_uk/tests/test_uc_company_owner_properties.py +++ b/policyengine_uk/tests/test_uc_company_owner_properties.py @@ -27,8 +27,9 @@ not report their capital, the household's capital less every owner's holding (up to its corporate wealth) is shared between the units in proportion to their claimants, and each unit adds its own claimants' - company capital; so the units' capital sums to that pool plus the owners' - company capital. + company capital. Each valuation, owner's company capital and unit's final + capital is rounded to the nearest penny, and the household total sums the + rounded units (reg. 6(1)). 4. Earnings identity: earned income before the floor is pay less the person's own income tax and NI on the employment, plus the trading total floored at zero, less their relievable pension contributions, floored at @@ -66,11 +67,17 @@ holding, whatever the generated examples reach. """ +from decimal import Decimal, ROUND_HALF_UP + import numpy as np +import pytest from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation +from policyengine_uk.utils.capital_valuation import exact_amount + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") YEAR = 2026 # corporate_wealth (shares) is valued at 90% for the expenses of sale (UC Regs @@ -124,6 +131,20 @@ def close(a, b): return abs(a - b) <= 1e-2 + 1e-6 * abs(b) +def stored_amount(value): + """Use the same float32 input precision as the simulation.""" + return float(exact_amount(np.float32(value))) + + +def penny(value): + # UC Regs 2013 reg. 6(1): half a penny is rounded up, for every amount + # calculated (https://www.legislation.gov.uk/uksi/2013/376/regulation/6). + # Remove binary arithmetic noise below a millionth of a penny before + # applying the legal rule, independently of the model rounding helper. + amount = Decimal(str(value)).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) + return float(amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) + + @st.composite def companies(draw): company_capital = draw(st.one_of(st.just(0.0), st.floats(0, 12_000))) @@ -268,17 +289,19 @@ def applies(company): def company_capital_of(company): - """Capital treated as possessed under reg. 77(2), net of 77(3)(a).""" + """Reg. 77(2) capital, net of 77(3)(a), rounded per person (reg. 6(1)).""" engaged = ( company["is_engaged_in_owned_company_trade"] or company["owned_company_is_main_employment"] ) disregarded = ( - company["owned_company_trade_assets"] + stored_amount(company["owned_company_trade_assets"]) if company["owned_company_carries_on_trade"] and engaged else 0.0 ) - return max(0.0, company["owned_company_capital"] - disregarded) + return penny( + max(0.0, stored_amount(company["owned_company_capital"]) - disregarded) + ) def pinned_company(**facts): @@ -615,6 +638,34 @@ def test_property_company_gives_no_earnings(unit_list, share): ) ] ) +@example( + [ + ( + pinned_unit( + pinned_adult(), + pinned_adult(), + corporate_wealth=6392.862111833667, + ), + pinned_unit( + pinned_adult( + pinned_company(owned_company_holding_value=6336.697665836761) + ), + pinned_adult(), + ), + ) + ] +) +@example( + [ + ( + pinned_unit( + pinned_adult(pinned_company(owned_company_holding_value=0.40)), + corporate_wealth=0.65, + ), + pinned_unit(pinned_adult(pinned_company(owned_company_holding_value=0.40))), + ) + ] +) def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs): # Each household holds two benefit units: the first unit's adults and the # second's, with the first unit's savings and corporate wealth. No unit @@ -624,7 +675,7 @@ def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs people, benunits, households = {}, {}, {} expected_units, expected_households = [], [] for h, (first, second) in enumerate(pairs): - names, holdings, unit_company_capital = [], 0.0, [] + names, holdings, unit_company_capital = [], [], [] for b, unit in enumerate((first, second)): members, company_capital = [], 0.0 for j, adult in enumerate(unit["adults"]): @@ -637,7 +688,7 @@ def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs **{k: {YEAR: v} for k, v in c.items()}, } if applies(c): - holdings += c["owned_company_holding_value"] + holdings.append(c["owned_company_holding_value"]) company_capital += company_capital_of(c) members.append(name) benunits[f"h{h}_b{b}"] = {"members": members} @@ -648,14 +699,38 @@ def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs "savings": {YEAR: first["savings"]}, "corporate_wealth": {YEAR: first["corporate_wealth"]}, } - disregarded = min(holdings, first["corporate_wealth"]) - pool = first["savings"] + SHARE_VALUE * ( - first["corporate_wealth"] - disregarded + shares = stored_amount(first["corporate_wealth"]) + # The holdings budget uses raw stored inputs and stores its scale as + # float32. Apply that scale to the recovered monetary amounts before + # valuation; an ideal min(total holdings, shares) loses this rounding + # boundary when holdings exceed the budget. + raw_shares = np.float32(first["corporate_wealth"]) + raw_holdings = sum( + float(np.clip(np.float32(holding), np.float32(0), raw_shares)) + for holding in holdings + ) + scale = float( + np.float32(min(1.0, float(raw_shares) / raw_holdings)) + if raw_holdings > 0 + else np.float32(1.0) + ) + disregarded = sum( + min(stored_amount(holding), shares) * scale for holding in holdings + ) + # Reg. 49(1)(a) values the shares and the removed holdings separately; + # reg. 6(1) rounds each valuation, then each unit's final capital. + pool = max( + 0.0, + stored_amount(first["savings"]) + + penny(SHARE_VALUE * shares) + - penny(SHARE_VALUE * disregarded), ) claimants = [len(first["adults"]), len(second["adults"])] for n, company_capital in zip(claimants, unit_company_capital): - expected_units.append(pool * n / sum(claimants) + company_capital) - expected_households.append(pool + sum(unit_company_capital)) + expected_units.append(penny(pool * n / sum(claimants) + company_capital)) + # Allocation rounding belongs to each benefit unit. Summing the + # unrounded household pool would lose up to half a penny per unit. + expected_households.append(sum(expected_units[-2:])) sim = Simulation( situation={"people": people, "benunits": benunits, "households": households} ) From 6c5cbc83b10c4b82e2c97c23e592a18c5f543949 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 27 Oct 2026 10:16:38 -0700 Subject: [PATCH 20/38] Retain property alongside financial-only capital reports Route overseas and other property through UC's separate property pool. Keep Pension Credit property in the final assessment when a financial-only report replaces financial proxies, including HB's savings-credit route. Preserve complete overrides and the existing valuation and company rules. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2688-review-capital.fixed.md | 1 + .../engineering/property-wealth-components.md | 27 ++- policyengine_uk/data/economic_assumptions.py | 4 +- .../income/capital/property_sources.yaml | 19 ++ .../means_test/capital/property_sources.yaml | 5 + .../capital/sale_expenses/sources.yaml | 6 +- .../means_test/capital/sources.yaml | 7 +- .../uc_assessable_capital.yaml | 121 ++++++++++-- .../pension_credit_reported_capital.yaml | 187 ++++++++++++++++++ .../wealth/benunit_reported_capital.yaml | 8 +- ...est_benunit_reported_capital_properties.py | 42 ++-- .../test_capital_valuation_properties.py | 7 +- .../tests/test_projection_horizon.py | 16 +- .../tests/test_property_wealth_components.py | 55 +++++- .../tests/test_uc_property_capital_mock.py | 21 +- policyengine_uk/utils/capital_valuation.py | 10 +- .../pension_credit_assessable_capital.py | 25 ++- .../pension_credit_reported_capital.py | 31 ++- .../universal_credit/uc_assessable_capital.py | 9 +- .../uc_other_property_capital.py | 26 +++ .../uc_overseas_property_capital.py | 26 +++ .../wealth/benunit_reported_capital.py | 4 +- 22 files changed, 555 insertions(+), 102 deletions(-) create mode 100644 changelog.d/2688-review-capital.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/property_sources.yaml create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/uc_other_property_capital.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/uc_overseas_property_capital.py diff --git a/changelog.d/2688-review-capital.fixed.md b/changelog.d/2688-review-capital.fixed.md new file mode 100644 index 0000000000..096110e538 --- /dev/null +++ b/changelog.d/2688-review-capital.fixed.md @@ -0,0 +1 @@ +Retained separately valued overseas and other property alongside financial-only capital reports for Universal Credit and Pension Credit, including Housing Benefit's savings-credit route; preserved complete capital overrides. diff --git a/docs/engineering/property-wealth-components.md b/docs/engineering/property-wealth-components.md index e91e544286..8d1fd4832f 100644 --- a/docs/engineering/property-wealth-components.md +++ b/docs/engineering/property-wealth-components.md @@ -108,7 +108,7 @@ the adjustment through the aggregate. Countable capital reads configured sources, rather than `property_wealth`. The new overseas and other-property inputs enter each affected programme's -configured capital sources once and receive the existing real-property +capital calculation once and receive the existing real-property sale-expense treatment. The shared `policyengine_uk/utils/capital_valuation.py` helper deducts source-specific sale expenses before mapped secured debt, with the existing zero floor for secured-property categories. A source appearing only @@ -130,20 +130,29 @@ Universal Credit keeps financial `sources.yaml` separate from `property_sources.yaml` and `uc_property_capital`. Its existing domestic property valuation variables remain in that property pool, independent of financial `benunit_reported_capital` observations. The new overseas and -unclassified other-property categories enter `sources.yaml` and -`sale_expenses/sources.yaml`, so they use the financial proxy pool: a -nonnegative `benunit_reported_capital` observation replaces them, and a report -or complete override reduces the residual pool shared by unreported units. +unclassified other-property categories follow the same route through +`uc_overseas_property_capital` and `uc_other_property_capital`, which deduct +sale expenses once before `uc_property_capital` allocates their net values. +They are absent from the financial and financial sale-expense source lists. +A financial-only report leaves all separately valued property counted. A complete `uc_reported_capital` override replaces the ordinary assessment -before the existing company-capital substitution. Existing domestic property +before the existing company-capital substitution. All countable property shares remain allocated across all claimant/partner adults, with complete-override units consuming their shares rather than redistributing them. Direct `owned_land` remains outside UC countable sources; adding it requires a separate model decision. -The other five programmes retain their existing financial/property source -lists, reported-capital precedence and household-to-benefit-unit allocation -rules. `share_like_capital` retains the component-aware financial fallback, +Pension Credit retains its source lists and pension-age-adult allocation. +Its assessment combines a financial-only benefit-unit report with separately +valued land and property before final rounding. Its independent complete +override, `pension_credit_reported_capital`, defaults to -1; an explicitly +supplied nonnegative override still replaces the whole total. Combining the +financial report and property directly in the assessment avoids an intermediate +rounded aggregate that could reduce counted property for large values. +Housing Benefit's savings-credit-only route inherits that corrected total. +Ordinary Housing Benefit, Income Support, income-based JSA and income-related +ESA do not replace their source pools with a financial-only report, so their +existing routing is retained. `share_like_capital` retains the component-aware financial fallback, so the new property sources do not restore `corporate_wealth` lists or add the underlying share components twice. Main residence and the separately identified private-pension input remain excluded from countable sources. diff --git a/policyengine_uk/data/economic_assumptions.py b/policyengine_uk/data/economic_assumptions.py index 9b94fe1c65..3feb56f103 100644 --- a/policyengine_uk/data/economic_assumptions.py +++ b/policyengine_uk/data/economic_assumptions.py @@ -114,8 +114,8 @@ # # Past this year policyengine-core serves each stored input from the last # projected year. An input whose variable has a formula is calculated by it -# instead (pension_credit_reported_capital reads benunit_reported_capital, -# which the Enhanced FRS does not store, so it falls to -1); one whose +# instead (the complete pension_credit_reported_capital override defaults +# to -1, while financial observations are assessed separately); one whose # variable has an `uprating` attribute is scaled by that index; any other, # rent and council tax among them, is held at its last projected value. # A finite CGT scenario can shorten the whole dataset horizon; it never diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/property_sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/property_sources.yaml new file mode 100644 index 0000000000..9baa6e68fb --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/property_sources.yaml @@ -0,0 +1,19 @@ +description: > + Household real-property capital retained alongside the benefit unit's + reported financial capital. The financial-only observation excludes land + and property, so it replaces savings and investments while these sources + retain their regulation 19 valuation and pension-age-adult allocation. + Only sources also present in capital.sources are counted. +metadata: + label: Pension Credit property capital sources + unit: list + reference: + - title: State Pension Credit Regulations 2002 reg. 19 + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/19 +values: + 2003-10-06: + - owned_land + - other_residential_property_value + - non_residential_property_value + - overseas_property_value + - other_property_value 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 index 9e6786ee38..97e1b4fd45 100644 --- 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 @@ -10,6 +10,9 @@ description: > 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. + Overseas land and property and unclassified other real estate follow this + same route, so a financial-only report cannot replace their counted values. + Neither category has an identified secured-debt input. metadata: label: Universal Credit countable property sources period: year @@ -23,3 +26,5 @@ values: 2013-04-29: - uc_other_residential_property_capital - uc_non_residential_property_capital + - uc_overseas_property_capital + - uc_other_property_capital diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml index 7a274f79d1..90af7d89d2 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml @@ -11,7 +11,7 @@ description: > not listed: the 10% applies where selling normally needs an agent, broker, solicitor or accountant (H1604-H1605), the costs of withdrawing money are not costs of sale (H1607), and an account is valued at its balance (H1675). - Existing domestic property is valued through property_sources with the same rate, + Countable property is valued through property_sources with the same rate, and real property always has costs of sale (H1606). `share_like_capital` retains the supplied `corporate_wealth` aggregate or the larger itemised-share total beyond float32 rounding, without adding @@ -20,7 +20,7 @@ description: > `overseas_property_value` covers overseas land and property, and `other_property_value` covers other real estate with unspecified geography and type. Both use the real-property sale-expense treatment - when counted. These broad household sources cannot identify individual + through their separate property valuations. These broad household sources cannot identify individual beneficial ownership or every statutory disregard; no separate secured debt is available for either category. metadata: @@ -43,5 +43,3 @@ metadata: values: 2013-04-29: - share_like_capital - - overseas_property_value - - other_property_value 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 db50f8d638..921a3d9ef4 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 @@ -24,7 +24,7 @@ description: > 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. Existing domestic property is valued and shared + 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 @@ -38,8 +38,7 @@ description: > geography and type. These broad household categories cannot identify individual beneficial ownership or additional statutory disregards. No separate secured-debt input is available for either category. They use - this financial proxy pool, so financial reports replace their proxy values - and reported units consume their reports from its residual. + the separate property_sources route, independently of financial reports. metadata: label: Universal Credit financial capital proxy sources period: year @@ -56,5 +55,3 @@ values: - savings - cash_isa - share_like_capital - - overseas_property_value - - other_property_value 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 04e43f902d..e2919f21fd 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 @@ -2,6 +2,9 @@ # UC Regulations 2013 reg.18(2) includes a single claimant's partner's capital. # https://www.legislation.gov.uk/ukpga/2012/5/section/5 # https://www.legislation.gov.uk/uksi/2013/376/regulation/18 +# Property expectations deduct 10% sale expenses, then secured debt, under +# https://www.legislation.gov.uk/uksi/2013/376/regulation/49 +# Capital above the limit fails https://www.legislation.gov.uk/uksi/2013/376/regulation/18 # Allocating unreported household capital by claimant/partner count is a # PolicyEngine convention, not a statutory allocation rule. @@ -220,8 +223,8 @@ members: [first, second, partner] overseas_property_value: 18_000 output: - # The override consumes 4,000 of the net pool; the other unit gets its residual. - uc_assessable_capital: [4_000, 12_200] + # The complete override consumes its property share; it is not redistributed. + uc_assessable_capital: [4_000, 10_800] - name: Universal Credit values unclassified other real estate less sale expenses period: 2026 @@ -279,8 +282,8 @@ members: [first, second, partner] other_property_value: 18_000 output: - # The override consumes 4,000 of the net pool; the other unit gets its residual. - uc_assessable_capital: [4_000, 12_200] + # The complete override consumes its property share; it is not redistributed. + uc_assessable_capital: [4_000, 10_800] - name: Universal Credit deducts sale expenses before combined secured debt for second homes and buy-to-let period: 2026 @@ -306,7 +309,7 @@ # max(0, 90% of 120,000 - 120,000); no separate buy-to-let input. uc_assessable_capital: 0 -- name: A financial report overrides overseas property but retains domestic property capital +- name: A financial report retains overseas and domestic property capital period: 2026 absolute_error_margin: 0 input: @@ -315,12 +318,11 @@ overseas_property_value: 18_000 other_residential_property_value: 10_000 output: - # The financial observation replaces the new category's proxy, while the - # independent domestic property pool retains 90% of the 10,000 stock. - uc_property_capital: 9_000 - uc_assessable_capital: 9_000 + # Regulation 49: 90% of both property stocks, independent of financial reports. + uc_property_capital: 25_200 + uc_assessable_capital: 25_200 -- name: A financial report overrides unclassified other property but retains domestic property capital +- name: A financial report retains unclassified other and domestic property capital period: 2026 absolute_error_margin: 0 input: @@ -329,7 +331,98 @@ other_property_value: 18_000 non_residential_property_value: 10_000 output: - # The financial observation replaces the new category's proxy, while the - # independent domestic property pool retains 90% of the 10,000 stock. - uc_property_capital: 9_000 - uc_assessable_capital: 9_000 + # Regulation 49: 90% of both property stocks, independent of financial reports. + uc_property_capital: 25_200 + uc_assessable_capital: 25_200 + +- name: Overseas property exceeds the UC capital limit without a financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + overseas_property_value: 20_000 + output: + # UC regulation 49(1)-(2): market value less 10% sale expenses, no debt. + uc_property_capital: 18_000 + uc_assessable_capital: 18_000 + is_uc_eligible: false + +- name: A zero financial report preserves overseas property above the UC capital limit + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + overseas_property_value: 20_000 + benunit_reported_capital: 0 + output: + uc_property_capital: 18_000 + uc_assessable_capital: 18_000 + is_uc_eligible: false + +- name: A complete UC override replaces overseas property and the financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + overseas_property_value: 20_000 + benunit_reported_capital: 0 + uc_reported_capital: 4_000 + output: + uc_property_capital: 18_000 + uc_assessable_capital: 4_000 + is_uc_eligible: true + +- name: Unclassified other property exceeds the UC capital limit without a financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + other_property_value: 20_000 + output: + uc_property_capital: 18_000 + uc_assessable_capital: 18_000 + is_uc_eligible: false + +- name: A zero financial report preserves unclassified other property above the UC capital limit + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + other_property_value: 20_000 + benunit_reported_capital: 0 + output: + uc_property_capital: 18_000 + uc_assessable_capital: 18_000 + is_uc_eligible: false + +- name: A complete UC override replaces unclassified other property and the financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + other_property_value: 20_000 + benunit_reported_capital: 0 + uc_reported_capital: 4_000 + output: + uc_property_capital: 18_000 + uc_assessable_capital: 4_000 + is_uc_eligible: true + +- name: A financial report adds to all property with sale expenses and secured debt applied once + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + savings: 50_000 + benunit_reported_capital: 2_000 + overseas_property_value: 20_000 + other_property_value: 10_000 + other_residential_property_value: 20_000 + other_residential_property_secured_debt: 8_000 + non_residential_property_value: 30_000 + non_residential_property_secured_debt: 30_000 + output: + # 18,000 + 9,000 + (18,000 - 8,000) + max(0, 27,000 - 30,000). + uc_property_capital: 37_000 + uc_assessable_capital: 39_000 + is_uc_eligible: false diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_reported_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_reported_capital.yaml index bf814fe328..a7929272dd 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_reported_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_reported_capital.yaml @@ -1,6 +1,8 @@ # Expected values are hand-computed from SPC Regs 2002 reg 15(6): deemed income # is 1 pound a week for every 500 pounds, or part of 500 pounds, of capital # above 10,000 pounds, times 52 weeks a year. +# Property expectations deduct 10% sale expenses followed by secured debt: +# https://www.legislation.gov.uk/uksi/2002/1792/regulation/19 - name: With no reported capital (the default, -1), household capital is the proxy. period: 2025 @@ -316,3 +318,188 @@ output: # max(0, 90% of 120,000 - 120,000); no separate buy-to-let input. pension_credit_assessable_capital: 0 + +- name: Pension Credit counts overseas property with no financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + overseas_property_value: 20_000 + output: + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 18_000 + +- name: Pension Credit retains overseas property with a zero financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + overseas_property_value: 20_000 + benunit_reported_capital: 0 + output: + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 18_000 + +- name: Pension Credit counts other property with no financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + other_property_value: 20_000 + output: + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 18_000 + +- name: Pension Credit retains other property with a zero financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + other_property_value: 20_000 + benunit_reported_capital: 0 + output: + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 18_000 + +- name: Pension Credit adds property to the financial report while replacing financial proxies + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + savings: 50_000 + corporate_wealth: 30_000 + lifetime_isa_countable_capital: 5_000 + overseas_property_value: 20_000 + other_property_value: 10_000 + benunit_reported_capital: 6_000 + output: + # Financial proxies are replaced; each property category is valued once. + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 33_000 + +- name: A complete Pension Credit override still replaces new property and financial reports + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + overseas_property_value: 20_000 + other_property_value: 20_000 + benunit_reported_capital: 6_000 + pension_credit_reported_capital: 0 + output: + pension_credit_assessable_capital: 0 + +- name: Pension Credit financial reports retain domestic property with expenses and debt deducted once + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + benunit_reported_capital: 2_000 + owned_land: 10_000 + owned_land_secured_debt: 3_000 + other_residential_property_value: 20_000 + other_residential_property_secured_debt: 8_000 + non_residential_property_value: 30_000 + non_residential_property_secured_debt: 7_000 + output: + # 2,000 + (9,000 - 3,000) + (18,000 - 8,000) + (27,000 - 7,000). + pension_credit_assessable_capital: 38_000 + +- name: Pension Credit financial reports preserve the pension-age property allocation + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 75 + second: + age: 75 + partner: + age: 75 + benunits: + reporting: + members: first + benunit_reported_capital: 0 + unreported: + members: [second, partner] + households: + household: + members: [first, second, partner] + overseas_property_value: 18_000 + output: + pension_credit_reported_capital: [-1, -1] + pension_credit_assessable_capital: [5_400, 10_800] + +- name: Savings-credit-only Housing Benefit inherits retained overseas property + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + benunit_reported_capital: 0 + overseas_property_value: 20_000 + housing_benefit_pension_age_regulations_apply: true + in_receipt_of_savings_credit_only: true + in_receipt_of_guarantee_credit: false + housing_benefit_on_passporting_benefit: false + output: + pension_credit_assessable_capital: 18_000 + housing_benefit_assessable_capital: 18_000 + +- name: Savings-credit-only Housing Benefit inherits retained other property + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + benunit_reported_capital: 0 + other_property_value: 20_000 + housing_benefit_pension_age_regulations_apply: true + in_receipt_of_savings_credit_only: true + in_receipt_of_guarantee_credit: false + housing_benefit_on_passporting_benefit: false + output: + pension_credit_assessable_capital: 18_000 + housing_benefit_assessable_capital: 18_000 + +- name: Pension Credit values large overseas property without an intermediate reported total + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + overseas_property_value: 145635.68 + output: + # Stored gross 145635.6875 less 10%, rounded down, is 131072.11. + # Final float32 storage gives 131072.109375. + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 131072.109375 + +- name: A zero financial report cannot reduce large overseas property through extra float32 rounding + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + overseas_property_value: 145635.68 + benunit_reported_capital: 0 + output: + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 131072.109375 + +- name: Pension Credit values large other property without an intermediate reported total + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + other_property_value: 145635.68 + output: + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 131072.109375 + +- name: A zero financial report cannot reduce large other property through extra float32 rounding + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + other_property_value: 145635.68 + benunit_reported_capital: 0 + output: + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 131072.109375 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 819ebc010a..b6a709ce7a 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 @@ -3,8 +3,8 @@ # 2013 reg.18) and Pension Credit (SPCA 2002 s.5) assess the claimant's and # 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. +# separate complete override. Pension Credit also retains separately valued +# property unless its own complete override is set directly. # Tariff income: UC Regulations 2013 reg.72, 4.35 pounds a month for each 250 # pounds or part above 6,000. @@ -24,7 +24,7 @@ savings: 50_000 output: uc_reported_capital: -1 - pension_credit_reported_capital: 2_000 + pension_credit_reported_capital: -1 uc_assessable_capital: 2_000 is_uc_eligible: true @@ -65,7 +65,7 @@ savings: 50_000 output: uc_assessable_capital: 3_000 - pension_credit_reported_capital: 20_000 + pension_credit_reported_capital: -1 - name: Recorded capital sets UC tariff income. period: 2025 diff --git a/policyengine_uk/tests/test_benunit_reported_capital_properties.py b/policyengine_uk/tests/test_benunit_reported_capital_properties.py index b1884bf6eb..724dcde71f 100644 --- a/policyengine_uk/tests/test_benunit_reported_capital_properties.py +++ b/policyengine_uk/tests/test_benunit_reported_capital_properties.py @@ -2,14 +2,14 @@ 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. +financial proxies with that observation. `uc_reported_capital` and +`pension_credit_reported_capital` are separate complete overrides and both +default to -1; Pension Credit also assesses the financial input separately. Invariants, over generated households of one or two benefit units: -1. Derivation: Pension Credit's reported capital equals the financial input, - -1 included. UC's complete override remains -1 without an explicit value. +1. Separation: both programmes' complete overrides remain -1 without an + explicit value, including when the financial input is supplied. 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 @@ -27,12 +27,13 @@ 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 use the financial input, when present, - while the complete UC override returns to -1. + while both complete programme overrides return to -1. """ from pathlib import Path import numpy as np +import pytest import yaml from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st @@ -42,6 +43,8 @@ from policyengine_uk import Microsimulation, Simulation from policyengine_uk.data import UKSingleYearDataset +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + YEAR = 2025 SETTINGS = settings( max_examples=15, @@ -157,11 +160,11 @@ def test_financial_capital_matches_complete_override_without_nonfinancial_assets a, b = outputs(via_input), outputs(via_programmes) for i, unit in enumerate(units): assert a["uc_reported_capital"][i] == -1 - assert a["pension_credit_reported_capital"][i] == unit["recorded"] + assert a["pension_credit_reported_capital"][i] == -1 if unit["recorded"] >= 0: assert a["uc_assessable_capital"][i] == unit["recorded"] for v in OUTPUTS: - if v == "uc_reported_capital": + if v in PROGRAMME_VARIABLES: 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"]: @@ -178,7 +181,7 @@ def test_programme_variable_set_directly_wins_for_that_programme(case, base, uc) inputs[0]["uc_reported_capital"] = uc sim = simulate(units, household, inputs) assert sim.calculate("uc_reported_capital", YEAR)[0] == uc - assert sim.calculate("pension_credit_reported_capital", YEAR)[0] == base + assert sim.calculate("pension_credit_reported_capital", YEAR)[0] == -1 assert sim.calculate("uc_assessable_capital", YEAR)[0] == uc @@ -274,13 +277,13 @@ def test_stored_financial_observations_and_explicit_overrides_project_like_savin / sim.calculate("savings", 2025).values ) assert np.all(growth > 1) - projected_names = ["pension_credit_reported_capital"] - if "uc_reported_capital" in columns: - projected_names.append("uc_reported_capital") - else: + projected_names = list(columns) + for name in PROGRAMME_VARIABLES: + if name in columns: + continue for year in [2025, 2027]: np.testing.assert_array_equal( - sim.calculate("uc_reported_capital", year).values, + sim.calculate(name, year).values, [-1, -1, -1], ) for name in projected_names: @@ -303,10 +306,10 @@ def test_stored_financial_observations_and_explicit_overrides_project_like_savin ) -def _single_adult(benunit_inputs): +def _single_adult(benunit_inputs, age=30): return Simulation( situation={ - "people": {"p": {"age": {"2025": 30}}}, + "people": {"p": {"age": {"2025": age}}}, "benunits": {"b": {"members": ["p"], **benunit_inputs}}, "households": {"h": {"members": ["p"], "savings": {"2025": 50_000}}}, } @@ -314,14 +317,17 @@ def _single_adult(benunit_inputs): def test_input_set_once_is_uprated_into_later_years(): - sim = _single_adult({"benunit_reported_capital": {"2025": 10_000}}) + sim = _single_adult({"benunit_reported_capital": {"2025": 10_000}}, age=75) index = sim.tax_benefit_system.parameters.get_child( "gov.economic_assumptions.indices.obr.per_capita.gdp" ) expected = 10_000 * index("2026-01-01") / index("2025-01-01") assert sim.calculate("uc_reported_capital", 2026)[0] == -1 + assert sim.calculate("pension_credit_reported_capital", 2026)[0] == -1 np.testing.assert_allclose( - sim.calculate("pension_credit_reported_capital", 2026)[0], expected, rtol=1e-6 + sim.calculate("pension_credit_assessable_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_capital_valuation_properties.py b/policyengine_uk/tests/test_capital_valuation_properties.py index e3157c2641..6adeba1e7b 100644 --- a/policyengine_uk/tests/test_capital_valuation_properties.py +++ b/policyengine_uk/tests/test_capital_valuation_properties.py @@ -44,12 +44,15 @@ from functools import reduce import numpy as np +import pytest from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation from policyengine_uk.utils.capital_valuation import exact_amount, less_sale_expenses +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + YEAR = 2026 PROPERTY_SETTINGS = settings( max_examples=5, @@ -183,11 +186,13 @@ def expected_capital(unit, node, variable=None): for source in [ "other_residential_property_value", "non_residential_property_value", + "overseas_property_value", + "other_property_value", ]: total += max( 0.0, unit["assets"][source] * (1 - LEGAL_RATE) - - unit["debts"][DEBTS[source]], + - unit["debts"].get(DEBTS.get(source), 0.0), ) for source in node.sources: value = source_value(unit, source) diff --git a/policyengine_uk/tests/test_projection_horizon.py b/policyengine_uk/tests/test_projection_horizon.py index 6883f132e3..808cd23240 100644 --- a/policyengine_uk/tests/test_projection_horizon.py +++ b/policyengine_uk/tests/test_projection_horizon.py @@ -34,6 +34,8 @@ ) from policyengine_uk.system import system +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + BASE_YEAR = 2024 GROWTH = parameters.gov.economic_assumptions.yoy_growth UPRATING_INDICES = yaml.safe_load( @@ -96,9 +98,9 @@ def _dataset(amount=1_000.0, base_year=BASE_YEAR): input set to ``amount`` (or one amount per household), plus rent and council tax. - Recorded Pension Credit capital is twice the benefit unit's recorded - capital, which its formula reads, so the two tell apart a stored value - from a calculated one.""" + Recorded complete Pension Credit capital is twice the benefit unit's + financial report, distinguishing the stored complete override from both + the financial observation and the default no-override sentinel.""" n = len(HOUSEHOLDS) ids = np.arange(1, n + 1) amounts = np.broadcast_to(np.asarray(amount, dtype=float), (n,)).copy() @@ -370,11 +372,11 @@ def test_a_year_past_the_horizon_is_served_from_the_last_projected_year(sim): one with an uprating attribute is scaled by that index from the last projected year, and any other is held at its last projected value.""" last, after = PROJECTION_END_YEAR, PROJECTION_END_YEAR + 1 - # The formula reads benunit_reported_capital, uprated from the last - # projected year, in place of the stored value, which was twice it. + # The complete Pension Credit override expires after the stored horizon. + # Its default is -1; the financial observation remains separately indexed. assert system.variables["pension_credit_reported_capital"].formulas assert sim.calculate("pension_credit_reported_capital", after).values == ( - pytest.approx(sim.calculate("benunit_reported_capital", after).values) + pytest.approx(np.full(len(HOUSEHOLDS), -1)) ) assert sim.calculate("pension_credit_reported_capital", last).values == ( pytest.approx(2 * sim.calculate("benunit_reported_capital", last).values) @@ -400,7 +402,7 @@ def test_legacy_reported_capital_expires_only_after_the_horizon(projected): """Legacy survey files store programme capital without the shared input. Projection preserves that observation through the supported horizon. - Afterward the programme formula uses the absent shared input's sentinel, + Afterward the complete-override formula returns its default sentinel, matching direct-situation semantics rather than carrying the observation. """ dataset = _dataset() diff --git a/policyengine_uk/tests/test_property_wealth_components.py b/policyengine_uk/tests/test_property_wealth_components.py index 38bd474e50..539074753b 100644 --- a/policyengine_uk/tests/test_property_wealth_components.py +++ b/policyengine_uk/tests/test_property_wealth_components.py @@ -3,7 +3,7 @@ WAS has seven gross terms, but second homes and buy-to-let are saved together by policyengine-uk-data. These tests exercise that six-input model contract; licensed-source reconciliation and release persistence belong to the data repo. -The five formula properties use fresh Core simulation holders with one shared +The formula properties use fresh Core simulation holders with one shared model; uprating, datasets, monthly stocks, LVT and purchases exercise the UK simulation wrapper separately. """ @@ -191,7 +191,9 @@ def test_property_capital_lists_preserve_each_source_once( ) for names in (sources, sale_sources): for variable in ("overseas_property_value", "other_property_value"): - assert names.count(variable) == 1 + assert names.count(variable) == ( + 0 if "universal_credit" in capital_path else 1 + ) assert "main_residence_value" not in names assert "private_pension_wealth" not in names assert names.count("share_like_capital") == 1 @@ -200,10 +202,59 @@ def test_property_capital_lists_preserve_each_source_once( # Preserve UC's existing omission; the aggregate change does not decide it. if "universal_credit" in capital_path: assert "owned_land" not in sources + property_sources = list( + parameters.get_child(f"{capital_path}.property_sources")(year) + ) + for variable in ("uc_overseas_property_capital", "uc_other_property_capital"): + assert property_sources.count(variable) == 1 else: assert sources.count("owned_land") == 1 +@pytest.mark.parametrize( + "variable", ["overseas_property_value", "other_property_value"] +) +@PROPERTY_SETTINGS +@given( + property_pence=st.integers(min_value=0, max_value=2_000_000), + report_pence=st.integers(min_value=0, max_value=2_000_000), + holding_pence=st.integers(min_value=0, max_value=2_000_000), +) +@example(property_pence=2_000_000, report_pence=0, holding_pence=0) +def test_financial_reports_preserve_separately_valued_property( + _property_metadata_system, variable, property_pence, report_pence, holding_pence +): + # The only financial proxy is an entirely disregarded company holding. + # A financial-only report adds to the same independently valued property, + # net of that holding, even when the report is zero. + holding = holding_pence / 100 + situation = _situation( + {variable: property_pence / 100, "corporate_wealth": holding} + ) + situation["people"]["person"]["uc_company_holding_disregard"] = {BASE_YEAR: holding} + unreported = CoreSimulation( + tax_benefit_system=_property_metadata_system, situation=situation + ) + baseline = _value(unreported, "uc_assessable_capital") + property_capital = _value(unreported, "uc_property_capital") + report = report_pence / 100 + situation["benunits"]["benunit"]["benunit_reported_capital"] = {BASE_YEAR: report} + reported = CoreSimulation( + tax_benefit_system=_property_metadata_system, situation=situation + ) + counted = _value(reported, "uc_assessable_capital") + + assert counted >= baseline + assert baseline == property_capital + assert _value(reported, "uc_property_capital") == property_capital + # Recover the penny from float32 storage before adding the observed report; + # compare the exact value after the final float32 holder conversion. + net_report = max(0, report - holding) + assert counted == float( + np.float32(round(net_report + round(property_capital, 2), 2)) + ) + + @pytest.mark.parametrize("source,variable", SOURCE_INPUTS) def test_each_was_source_enters_gross_and_total_property_once(source, variable): components = _saved_components({source: 100_000}) diff --git a/policyengine_uk/tests/test_uc_property_capital_mock.py b/policyengine_uk/tests/test_uc_property_capital_mock.py index a84f8278ee..a712f62d80 100644 --- a/policyengine_uk/tests/test_uc_property_capital_mock.py +++ b/policyengine_uk/tests/test_uc_property_capital_mock.py @@ -366,13 +366,17 @@ def test_mock_property_valuation_uses_the_assessment_year(tmp_path): assert simulation.calculate("uc_assessable_capital", 2026)[0] == 24_000 -# With nothing reported, Pension Credit values the property at 90% (SPC Regs -# 2002 reg. 19(a)): 6,000 + 45,000. -@pytest.mark.parametrize("reported, expected", [(6_000, 6_000), (0, 0), (-1, 51_000)]) -def test_mock_pension_credit_keeps_its_existing_reported_capital_contract( - tmp_path, reported, expected +# Pension Credit retains property at 90% alongside financial-only reports +# (SPC Regs 2002 reg. 19(a)): the report plus 45,000, or 6,000 + 45,000 +# when no financial capital is reported. +@pytest.mark.parametrize( + "reported, expected_report, expected", + [(6_000, -1, 51_000), (0, -1, 45_000), (-1, -1, 51_000)], +) +def test_mock_pension_credit_financial_reports_preserve_property( + tmp_path, reported, expected_report, expected ): - """Changing UC's financial boundary does not change Pension Credit.""" + """A financial observation excludes the separately imputed property.""" simulation = _load_mock( tmp_path, _mock_year( @@ -387,7 +391,10 @@ def test_mock_pension_credit_keeps_its_existing_reported_capital_contract( ) assert simulation.calculate("uc_assessable_capital", YEAR)[0] == 0 - assert simulation.calculate("pension_credit_reported_capital", YEAR)[0] == reported + assert ( + simulation.calculate("pension_credit_reported_capital", YEAR)[0] + == expected_report + ) assert ( simulation.calculate("pension_credit_assessable_capital", YEAR)[0] == expected ) diff --git a/policyengine_uk/utils/capital_valuation.py b/policyengine_uk/utils/capital_valuation.py index 8b601731cd..c95e98b2e3 100644 --- a/policyengine_uk/utils/capital_valuation.py +++ b/policyengine_uk/utils/capital_valuation.py @@ -27,7 +27,7 @@ } -def valued_capital(value_of, capital): +def valued_capital(value_of, capital, *, sources=None): """Sum ``capital.sources`` at market value less sale expenses and secured debt. ``value_of(variable)`` returns that household variable on the entity the @@ -37,9 +37,15 @@ def valued_capital(value_of, capital): penny under the programme's rule. A source carrying secured debt is never valued below nil. The total is in float64, unrounded: the caller rounds the capital it assesses at the last stage (``round_to_penny``). + + ``sources`` can restrict the valuation to a subset of the configured + sources, for example property retained alongside a financial-only report. """ total = 0 - for source in capital.sources: + selected_sources = capital.sources if sources is None else sources + for source in selected_sources: + if source not in capital.sources: + continue value = exact_amount(value_of(source)) if source in capital.sale_expenses.sources: value = less_sale_expenses( diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py index bf33736ef9..5882bf8892 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py @@ -18,10 +18,12 @@ class pension_credit_assessable_capital(Variable): "age adults. 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_claimant_or_partner): a dependant's capital " - "is not the claimant's. Where `pension_credit_reported_capital` records " - "the benefit unit's own capital (0 or more), it replaces the " - "household proxy and the person-level sources. Unlike " - "`uc_assessable_capital`, another benefit unit's recorded capital is " + "is not the claimant's. An explicit nonnegative " + "pension_credit_reported_capital replaces the whole assessment. " + "Otherwise a nonnegative benunit_reported_capital replaces financial " + "household and person-level proxies, retaining separately valued " + "land and property with the same pension-age-adult allocation. " + "Unlike `uc_assessable_capital`, another benefit unit's recorded capital is " "not subtracted from the household capital that an unrecorded unit's " "proxy shares out: the recorded figure and the household sources come " "from different measures, so the proxy is left as it was." @@ -59,13 +61,26 @@ def formula(benunit, period, parameters): household_capital_proxy = ( household_capital * benunit_pension_age_adults / adult_divisor ) + household_property = valued_capital( + lambda variable: household(variable, period), + p, + sources=p.property_sources, + ) + property_share = household_property * benunit_pension_age_adults / adult_divisor + # Combine in float64 before final rounding. An intermediate reported + # total stored in float32 can reduce large property values by one ULP. + financial_report = exact_amount(benunit("benunit_reported_capital", period)) reported_capital = exact_amount( benunit("pension_credit_reported_capital", period) ) assessed_capital = where( reported_capital >= 0, reported_capital, - household_capital_proxy + person_capital, + where( + financial_report >= 0, + financial_report + property_share, + household_capital_proxy + person_capital, + ), ) # A fraction of a penny is disregarded at the last stage (reg. 24A). return round_to_penny( diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py index 58fcbcf789..09bb50d516 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py @@ -6,26 +6,25 @@ class pension_credit_reported_capital(Variable): entity = BenUnit label = "reported Pension Credit capital" documentation = ( - "Countable capital of the claimant and partner as recorded for this " - "benefit unit. Pension Credit counts the claimant's capital, and the " - "State Pension Credit Act 2002 s. 5 treats the partner's as the " - "claimant's, which a benefit-unit capital record covers, so this is " - "the benefit unit's recorded capital (benunit_reported_capital) unless " - "set directly. When this is 0 or more it replaces every capital source " - "in Pension Credit assessable capital: the household proxy (savings, " - "land, property and share_like_capital) and the person-level sources such " - "as a Lifetime ISA. The model applies neither the Schedule V disregards " - "nor the reg. 19 valuation to it, so it must already be the countable " - "figure, and reforms to the capital source parameters do not reach a " - "benefit unit that records it. Any negative value, including -1 when " - "nothing is recorded, means the household proxy applies. 0 records no " - "capital." + "Explicit complete countable-capital override for the claimant and " + "partner together. A nonnegative value, including zero, replaces " + "household and person-level capital sources. It must already reflect " + "the regulation 19 valuation and Schedule V disregards. The default " + "-1 means no complete override. In that case a nonnegative " + "benunit_reported_capital replaces only financial proxies, while " + "separately valued land and property remain counted; with neither " + "report the household proxy and person-level sources apply. This " + "variable does not derive from the financial-only observation. An " + "override set directly applies only in the supplied assessment year." ) definition_period = YEAR unit = GBP quantity_type = STOCK default_value = -1 - reference = "https://www.legislation.gov.uk/ukpga/2002/16/section/5" + reference = ( + "https://www.legislation.gov.uk/ukpga/2002/16/section/5", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/19", + ) def formula(benunit, period, parameters): - return benunit("benunit_reported_capital", period) + return -1 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 01fcbca17b..54ae76da40 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 @@ -19,19 +19,18 @@ class uc_assessable_capital(Variable): "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, including overseas and unclassified other " - "property; it does not replace separately valued domestic property. " + "and person-level proxies; it does not replace separately valued " + "domestic, overseas or unclassified other 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. Domestic " - "property " + "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. Domestic property " + "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. " diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_other_property_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_other_property_capital.py new file mode 100644 index 0000000000..5ab875fcf4 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_other_property_capital.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import less_sale_expenses + + +class uc_other_property_capital(Variable): + value_type = float + entity = Household + label = "Universal Credit net unclassified other property capital" + documentation = ( + "Assessment-year value of unclassified other real estate less regulation " + "49 sale expenses. Assumes no Schedule 10 disregard; the broad input " + "does not identify asset-level disregards or secured debt. Valued " + "separately from financial reports and allocated through uc_property_capital." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/49" + + def formula(household, period, parameters): + p = parameters(period).gov.dwp.universal_credit.means_test.capital + return less_sale_expenses( + max_(0, household("other_property_value", period)), + p.sale_expenses.rate, + p.round_down, + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_overseas_property_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_overseas_property_capital.py new file mode 100644 index 0000000000..bf696151d7 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_overseas_property_capital.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import less_sale_expenses + + +class uc_overseas_property_capital(Variable): + value_type = float + entity = Household + label = "Universal Credit net overseas property capital" + documentation = ( + "Assessment-year value of overseas land and property less regulation " + "49 sale expenses. Assumes no Schedule 10 disregard; the broad input " + "does not identify asset-level disregards or secured debt. Valued " + "separately from financial reports and allocated through uc_property_capital." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/49" + + def formula(household, period, parameters): + p = parameters(period).gov.dwp.universal_credit.means_test.capital + return less_sale_expenses( + max_(0, household("overseas_property_value", period)), + p.sale_expenses.rate, + p.round_down, + ) diff --git a/policyengine_uk/variables/household/wealth/benunit_reported_capital.py b/policyengine_uk/variables/household/wealth/benunit_reported_capital.py index f99b7c3d78..65401cb11a 100644 --- a/policyengine_uk/variables/household/wealth/benunit_reported_capital.py +++ b/policyengine_uk/variables/household/wealth/benunit_reported_capital.py @@ -14,7 +14,9 @@ class benunit_reported_capital(Variable): "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. " + "Credit also counts separately valued property alongside this report; " + "setting pension_credit_reported_capital directly " + "supplies a complete override. " "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 " From 798491cb7266fc157c767ca1f73c6921e4345efc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 27 Oct 2026 10:42:59 -0700 Subject: [PATCH 21/38] Sum domestic property directly when estimating household land Avoid subtracting components from rounded worldwide property wealth. Pin the float32 cancellation witness and enforce the exact direct-land lower bound. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2688-review-land.fixed.md | 1 + .../engineering/property-wealth-components.md | 14 +++++++---- .../wealth/household_land_value.yaml | 23 +++++++++++++++++++ .../tests/test_property_wealth_components.py | 7 +++--- .../household/wealth/household_land_value.py | 15 ++++++++---- 5 files changed, 47 insertions(+), 13 deletions(-) create mode 100644 changelog.d/2688-review-land.fixed.md diff --git a/changelog.d/2688-review-land.fixed.md b/changelog.d/2688-review-land.fixed.md new file mode 100644 index 0000000000..899486ae05 --- /dev/null +++ b/changelog.d/2688-review-land.fixed.md @@ -0,0 +1 @@ +Preserved the direct UK land lower bound by summing domestic property components before applying regional land intensity. diff --git a/docs/engineering/property-wealth-components.md b/docs/engineering/property-wealth-components.md index 8d1fd4832f..9ea46dd205 100644 --- a/docs/engineering/property-wealth-components.md +++ b/docs/engineering/property-wealth-components.md @@ -56,8 +56,9 @@ property_wealth = residential_property_value total_wealth = property_wealth + corporate_wealth + private_pension_wealth + savings -household_land_value = (property_wealth - owned_land - - overseas_property_value) * regional_intensity +household_land_value = (residential_property_value + + non_residential_property_value + + other_property_value) * regional_intensity + owned_land ``` @@ -70,7 +71,10 @@ contribute to worldwide wealth and contribute zero to UK household land. adding land, overseas or other property separately would duplicate them. `household_land_value.py` retains the existing regional intensities and the -dwelling-weighted fallback for an unknown region. Unclassified other real +dwelling-weighted fallback for an unknown region. Summing the domestic base +directly avoids subtracting components from an already rounded aggregate, +so float32 cancellation cannot reduce land below directly owned UK land. +Unclassified other real estate uses that regional approximation. Its unknown geography means this can attribute overseas holdings to UK land. Improved source classification, or excluding unclassified holdings from the domestic land base, requires a @@ -214,7 +218,9 @@ compatibility guard must reject a model unable to carry the new components. Existing datasets are not migrated. A stored `property_wealth` input remains authoritative and overrides the formula, even after the model changes. The new release must stop storing it and remove inherited copies; updating only -the installed model cannot repair old saved totals. +the installed model cannot repair old saved totals. Household land reads the +domestic component inputs directly, including when a saved worldwide total +is present, so that total does not determine the domestic land base. Synthetic tests cover isolated and mixed components, aggregate identities, land accounting, household LVT, monthly stocks, situation and dataset diff --git a/policyengine_uk/tests/policy/baseline/household/wealth/household_land_value.yaml b/policyengine_uk/tests/policy/baseline/household/wealth/household_land_value.yaml index 6a65cdf481..55c27a49aa 100644 --- a/policyengine_uk/tests/policy/baseline/household/wealth/household_land_value.yaml +++ b/policyengine_uk/tests/policy/baseline/household/wealth/household_land_value.yaml @@ -141,3 +141,26 @@ property_wealth: 20000 total_wealth: 20000 household_land_value: 17000 + +- name: Large overseas holdings cannot reduce directly owned UK land through float32 cancellation + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + owned_land: 20000.50 + overseas_property_value: 8388608 + output: + household_land_value: 20000.50 + +- name: A saved worldwide aggregate does not replace the domestic land components + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + property_wealth: 900000 + main_residence_value: 100000 + owned_land: 20000 + overseas_property_value: 30000 + output: + property_wealth: 900000 + household_land_value: 105000 diff --git a/policyengine_uk/tests/test_property_wealth_components.py b/policyengine_uk/tests/test_property_wealth_components.py index 539074753b..ef092ea546 100644 --- a/policyengine_uk/tests/test_property_wealth_components.py +++ b/policyengine_uk/tests/test_property_wealth_components.py @@ -302,16 +302,15 @@ def test_property_wealth_equals_sum_of_saved_inputs(_formula_simulation, values) @PROPERTY_SETTINGS @given(st.tuples(*(MONEY for _ in PROPERTY_INPUTS)), st.sampled_from(REGIONS)) @example((0, 0, 0, 20_000, 0, 0), "LONDON") +@example((0, 0, 0, 20_000.50, 8_388_608, 0), "LONDON") def test_household_land_value_is_at_least_direct_uk_land( _formula_simulation, values, region ): sim = _formula_simulation(dict(zip(PROPERTY_INPUTS, values)), region=region) direct_land = _value(sim, "owned_land") land = _value(sim, "household_land_value") - # Subtracting independently rounded stocks can lose a few float32 ulps. - tolerance = 2e-6 * max(1, _property_sum(sim)) - - assert land >= direct_land - tolerance + # A nonnegative domestic base cannot reduce the directly owned land. + assert land >= direct_land @PROPERTY_SETTINGS diff --git a/policyengine_uk/variables/household/wealth/household_land_value.py b/policyengine_uk/variables/household/wealth/household_land_value.py index 69f4615f4e..e93ffd6597 100644 --- a/policyengine_uk/variables/household/wealth/household_land_value.py +++ b/policyengine_uk/variables/household/wealth/household_land_value.py @@ -19,9 +19,16 @@ class household_land_value(Variable): def formula(household, period, parameters): region = household("region", period) Region = region.possible_values - property_wealth = household("property_wealth", period) + domestic_property = add( + household, + period, + [ + "residential_property_value", + "non_residential_property_value", + "other_property_value", + ], + ) owned_land = household("owned_land", period) - overseas_property_value = household("overseas_property_value", period) regional = parameters( period @@ -59,6 +66,4 @@ def formula(household, period, parameters): default=regional.UNKNOWN, ) - return ( - property_wealth - owned_land - overseas_property_value - ) * property_wealth_intensity + owned_land + return domestic_property * property_wealth_intensity + owned_land From a677cb348c219393bdf22ee90a687093568e7d8f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 14:52:59 -0400 Subject: [PATCH 22/38] Preserve supplied and reformed Lifetime ISA capital values Reconstruct the baseline surrender value in float64 only when the active formula is the baseline function and no input was supplied for this period and visible branch. Numeric equality with a float32 formula result cannot establish provenance: it can discard a supplied whole penny. Read the active variable from the tax-benefit system, since an existing holder can retain a definition from before a reform. Reuse supplied-input tracking and cover explicit inputs, float32 collisions, replacement and parameter reforms, late inputs, periods and branches. Reuse the existing independent-clone test fixture for the capital property suite. Co-Authored-By: Claude Opus 5.5 --- changelog.d/uc-capital-sale-expenses.fixed.md | 2 +- .../baseline/gov/dwp/capital_valuation.yaml | 71 +++++++++++++++++ .../test_capital_valuation_properties.py | 76 +++++++++++++++++++ policyengine_uk/utils/capital_valuation.py | 31 +++++--- policyengine_uk/utils/supplied_inputs.py | 1 + .../gov/dwp/lifetime_isa_countable_capital.py | 10 +-- 6 files changed, 175 insertions(+), 16 deletions(-) diff --git a/changelog.d/uc-capital-sale-expenses.fixed.md b/changelog.d/uc-capital-sale-expenses.fixed.md index 5193d8d2f3..0a6ffc7c9a 100644 --- a/changelog.d/uc-capital-sale-expenses.fixed.md +++ b/changelog.d/uc-capital-sale-expenses.fixed.md @@ -1 +1 @@ -Value Universal Credit, Housing Benefit, Income Support, income-based JSA, income-related ESA and Pension Credit capital at market value less 10% where a sale would incur expenses and less any debt secured on the asset, as UC Regs 2013 reg. 49(1) and its legacy equivalents require. The 10% applies to land, property and share-like capital (share_like_capital), not to cash savings; share-like capital also holds unit trusts, which have no costs of sale, and in datasets built before the private pension split it holds pension wealth, so the deduction reaches those too until the data separate them. Adds secured-debt inputs for other residential property, non-residential property and land. Each programme rounds a fraction of a penny in its capital calculations as its regulations require: to the nearest penny for Universal Credit (UC Regs 2013 reg. 6(1)) and Housing Benefit, and down for the legacy benefits and Pension Credit. A Lifetime ISA's surrender value is worked out from its balance and rounded under the same rule. +Value Universal Credit, Housing Benefit, Income Support, income-based JSA, income-related ESA and Pension Credit capital at market value less 10% where a sale would incur expenses and less any debt secured on the asset, as UC Regs 2013 reg. 49(1) and its legacy equivalents require. The 10% applies to land, property and share-like capital (share_like_capital), not to cash savings; share-like capital also holds unit trusts, which have no costs of sale, and in datasets built before the private pension split it holds pension wealth, so the deduction reaches those too until the data separate them. Adds secured-debt inputs for other residential property, non-residential property and land. Each programme rounds a fraction of a penny in its capital calculations as its regulations require: to the nearest penny for Universal Credit (UC Regs 2013 reg. 6(1)) and Housing Benefit, and down for the legacy benefits and Pension Credit. A Lifetime ISA's surrender value is worked out from its balance and rounded under the same rule. Explicit Lifetime ISA capital inputs and reformed valuation formulas are preserved. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml index 521397a82b..503df382a8 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml @@ -535,3 +535,74 @@ income_support_assessable_capital: 16_000 jsa_income_assessable_capital: 16_000 esa_income_assessable_capital: 16_000 + +- name: A Lifetime ISA value given as an input is counted as given + period: 2026 + absolute_error_margin: 0.004 + input: + people: + person: + age: 30 + # The balance's surrender value would be 0.195, but the value counted + # as capital is given directly, so every means test counts that 0.20: + # 15,999.81 + 0.20 = 16,000.01. + lifetime_isa_balance: 0.26 + lifetime_isa_countable_capital: 0.20 + benunits: + benunit: + members: person + households: + household: + members: person + savings: 15_999.81 + output: + uc_assessable_capital: 16_000.01 + is_uc_eligible: false + income_support_assessable_capital: 16_000.01 + jsa_income_assessable_capital: 16_000.01 + esa_income_assessable_capital: 16_000.01 + +- name: A Lifetime ISA input is preserved when its float32 value matches the formula + period: 2026 + absolute_error_margin: 0.004 + input: + people: + person: + age: 30 + # The formula gives 65,536.005. That and the explicit whole-penny + # value 65,536.01 both store as 65,536.0078125, so equality cannot + # tell a formula result from an input. Counting the input as given + # preserves 65,536.01 even under a programme that rounds down. + lifetime_isa_balance: 87_381.34 + lifetime_isa_countable_capital: 65_536.01 + benunits: + benunit: + members: person + housing_benefit_on_passporting_benefit: false + households: + household: + members: person + output: + uc_assessable_capital: 65_536.01 + housing_benefit_assessable_capital: 65_536.01 + income_support_assessable_capital: 65_536.01 + jsa_income_assessable_capital: 65_536.01 + esa_income_assessable_capital: 65_536.01 + +- name: Pension Credit preserves an explicit Lifetime ISA value + period: 2026 + absolute_error_margin: 0.004 + input: + people: + person: + age: 70 + lifetime_isa_balance: 0.26 + lifetime_isa_countable_capital: 0.20 + benunits: + benunit: + members: person + households: + household: + members: person + output: + pension_credit_assessable_capital: 0.20 diff --git a/policyengine_uk/tests/test_capital_valuation_properties.py b/policyengine_uk/tests/test_capital_valuation_properties.py index 2f7be49d89..47dcd3348e 100644 --- a/policyengine_uk/tests/test_capital_valuation_properties.py +++ b/policyengine_uk/tests/test_capital_valuation_properties.py @@ -44,12 +44,18 @@ from functools import reduce import numpy as np +import pytest from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st +from policyengine_core.reforms import Reform from policyengine_uk import Simulation +from policyengine_uk.model_api import Person, Variable +from policyengine_uk.model_api import YEAR as YEAR_PERIOD from policyengine_uk.utils.capital_valuation import exact_amount, less_sale_expenses +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + YEAR = 2026 PROPERTY_SETTINGS = settings( max_examples=5, @@ -459,3 +465,73 @@ def test_whole_pennies_are_counted_to_the_penny(rows): values[variable][i], expected, ) + + +def test_lifetime_isa_reformed_value_is_kept_as_stored(): + # This reform's value and the baseline's 0.195 are the same float32, + # but the reform deliberately values the ISA below the half penny. + class lifetime_isa_countable_capital(Variable): + value_type = float + entity = Person + definition_period = YEAR_PERIOD + + def formula(person, period, parameters): + return person.filled_array(0.19499999) + + class ReplaceLifetimeISA(Reform): + def apply(self): + self.update_variable(lifetime_isa_countable_capital) + + sim = Simulation( + situation=whole_penny_situation([(0, 0, 26, 30)]), + reform=ReplaceLifetimeISA, + ) + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - 0.19) < 0.004 + + +def test_lifetime_isa_baseline_formula_uses_reformed_parameters(): + sim = Simulation( + situation=whole_penny_situation([(0, 0, 26, 30)]), + reform={"gov.hmrc.isa.lifetime.withdrawal_charge": {str(YEAR): 0.5}}, + ) + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - 0.13) < 0.004 + + +@pytest.mark.parametrize("via_holder", [False, True]) +def test_lifetime_isa_input_set_after_construction_is_preserved(via_holder): + sim = Simulation(situation=whole_penny_situation([(0, 0, 26, 30)])) + # Cache the formula first: a later equal-valued input still has input + # provenance, including when the caller uses Holder.set_input directly. + sim.calculate("lifetime_isa_countable_capital", YEAR) + if via_holder: + from policyengine_core import periods + + sim.get_holder("lifetime_isa_countable_capital").set_input( + periods.period(YEAR), np.array([0.195]) + ) + else: + sim.set_input("lifetime_isa_countable_capital", YEAR, np.array([0.195])) + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - 0.19) < 0.004 + + +def test_lifetime_isa_input_for_another_period_does_not_replace_the_formula(): + situation = whole_penny_situation([(0, 0, 26, 30)]) + situation["people"]["p0"]["lifetime_isa_countable_capital"] = {YEAR - 1: 0.195} + sim = Simulation(situation=situation) + # The previous year's supplied value cannot suppress this year's exact + # baseline valuation of 75% of 0.26, rounded nearest to 0.20. + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - 0.20) < 0.004 + + +@pytest.mark.parametrize("inherited", [False, True]) +def test_lifetime_isa_input_is_preserved_on_its_visible_branch(inherited): + situation = whole_penny_situation([(0, 0, 26, 30)]) + if inherited: + situation["people"]["p0"]["lifetime_isa_countable_capital"] = {YEAR: 0.195} + sim = Simulation(situation=situation) + branch = sim.get_branch("entered_lisa") + if not inherited: + branch.set_input("lifetime_isa_countable_capital", YEAR, np.array([0.195])) + assert abs(branch.calculate("uc_assessable_capital", YEAR)[0] - 0.19) < 0.004 + expected_main = 0.19 if inherited else 0.20 + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - expected_main) < 0.004 diff --git a/policyengine_uk/utils/capital_valuation.py b/policyengine_uk/utils/capital_valuation.py index 2b7a6911da..6a8ee61e62 100644 --- a/policyengine_uk/utils/capital_valuation.py +++ b/policyengine_uk/utils/capital_valuation.py @@ -14,6 +14,8 @@ import numpy as np from policyengine_core.model_api import max_ +from policyengine_uk.utils.supplied_inputs import supplied_input + # Household inputs holding the debt secured on each capital source, such as a # mortgage on a second property. The debt is deducted from the source it is # secured on and that source is floored at nil, so negative equity in one @@ -55,20 +57,29 @@ def valued_person_capital(benunit, period, parameters, capital, counted): """Sum ``capital.person_sources`` over the benefit unit's members for whom ``counted`` is true (its claimant and partner), in float64. - A source the engine would store with a fraction of a penny is worked out - here from its inputs (``CALCULATED_PERSON_SOURCES``) and rounded to the - penny under the programme's rule, like any other valuation, rather than - read back from float32. Any other source is a stored amount.""" + A source whose formula can give a fraction of a penny + (``CALCULATED_PERSON_SOURCES``) is valued to the penny under the + programme's rule, like any other valuation. The baseline formula is + worked again in float64, so float32 cannot move the rounding. Explicit + inputs for this period and branch, and values from a reformed formula, + are kept as stored: matching a baseline value in float32 does not prove + where a value came from. Any other source is a stored amount.""" person = benunit.members total = 0 for source in capital.person_sources: + stored = person(source, period) + value = exact_amount(stored) if source in CALCULATED_PERSON_SOURCES: - value = round_to_penny( - CALCULATED_PERSON_SOURCES[source](person, period, parameters), - capital.round_down, - ) - else: - value = exact_amount(person(source, period)) + calculate = CALCULATED_PERSON_SOURCES[source] + # A holder can predate a reform; the system owns the active formula. + variable = person.simulation.tax_benefit_system.get_variable(source) + if ( + not variable.is_neutralized + and variable.get_formula(period) is calculate + and supplied_input(person, source, period) is None + ): + value = calculate(person, period, parameters) + value = round_to_penny(value, capital.round_down) total = total + benunit.sum(value * counted) return total diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index 58bc6f0cef..33499206a8 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -51,6 +51,7 @@ "pip_m_points", "local_bus_trips", "local_bus_uncapped_single_fare", + "lifetime_isa_countable_capital", "ni_class_4_losses_brought_forward", "ni_class_4_trading_loss", "tenure_type", diff --git a/policyengine_uk/variables/gov/dwp/lifetime_isa_countable_capital.py b/policyengine_uk/variables/gov/dwp/lifetime_isa_countable_capital.py index 4f5d1e6271..83ce5d8dea 100644 --- a/policyengine_uk/variables/gov/dwp/lifetime_isa_countable_capital.py +++ b/policyengine_uk/variables/gov/dwp/lifetime_isa_countable_capital.py @@ -24,8 +24,8 @@ class lifetime_isa_countable_capital(Variable): "https://www.legislation.gov.uk/uksi/1998/1870/schedule", ] - def formula(person, period, parameters): - # Stored as float32. The means tests work the value out in float64 - # from the balance instead, and round it under each programme's rule - # (capital_valuation.valued_person_capital). - return lifetime_isa_surrender_value(person, period, parameters) + # The shared function's identity lets the means tests distinguish this + # baseline formula from a reform. They work it again in float64 before + # rounding, while preserving explicit inputs and reformed values + # (capital_valuation.valued_person_capital). + formula = lifetime_isa_surrender_value From d5c2a57e81dda7a55a24369f222e5ff666cb12d3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 14:58:50 -0400 Subject: [PATCH 23/38] Fix the company capital conservation penny-rounding oracle Port of #2688's 1193614a6. Model stored-input precision, the raw holdings budget scale, separately rounded share and holding valuations, each owner's company capital and each benefit unit's final capital. Sum the rounded benefit units for the household expectation under UC regulation 6(1). Pin the original CI witness and the holdings-budget precision boundary, while preserving main's explicit claimant and partner roles. Production calculations and comparison tolerances are unchanged by this port. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_uc_company_owner_properties.py | 99 ++++++++++++++++--- 1 file changed, 87 insertions(+), 12 deletions(-) diff --git a/policyengine_uk/tests/test_uc_company_owner_properties.py b/policyengine_uk/tests/test_uc_company_owner_properties.py index 64d158e3dc..85eb2e3818 100644 --- a/policyengine_uk/tests/test_uc_company_owner_properties.py +++ b/policyengine_uk/tests/test_uc_company_owner_properties.py @@ -27,8 +27,9 @@ not report their capital, the household's capital less every owner's holding (up to its corporate wealth) is shared between the units in proportion to their claimants, and each unit adds its own claimants' - company capital; so the units' capital sums to that pool plus the owners' - company capital. + company capital. Each valuation, owner's company capital and unit's final + capital is rounded to the nearest penny, and the household total sums the + rounded units (reg. 6(1)). 4. Earnings identity: earned income before the floor is pay less the person's own income tax and NI on the employment, plus the trading total floored at zero, less their relievable pension contributions, floored at @@ -66,11 +67,17 @@ holding, whatever the generated examples reach. """ +from decimal import Decimal, ROUND_HALF_UP + import numpy as np +import pytest from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation +from policyengine_uk.utils.capital_valuation import exact_amount + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") YEAR = 2026 # corporate_wealth (shares) is valued at 90% for the expenses of sale (UC Regs @@ -124,6 +131,20 @@ def close(a, b): return abs(a - b) <= 1e-2 + 1e-6 * abs(b) +def stored_amount(value): + """Use the same float32 input precision as the simulation.""" + return float(exact_amount(np.float32(value))) + + +def penny(value): + # UC Regs 2013 reg. 6(1): half a penny is rounded up, for every amount + # calculated (https://www.legislation.gov.uk/uksi/2013/376/regulation/6). + # Remove binary arithmetic noise below a millionth of a penny before + # applying the legal rule, independently of the model rounding helper. + amount = Decimal(str(value)).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) + return float(amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) + + @st.composite def companies(draw): company_capital = draw(st.one_of(st.just(0.0), st.floats(0, 12_000))) @@ -268,17 +289,19 @@ def applies(company): def company_capital_of(company): - """Capital treated as possessed under reg. 77(2), net of 77(3)(a).""" + """Reg. 77(2) capital, net of 77(3)(a), rounded per person (reg. 6(1)).""" engaged = ( company["is_engaged_in_owned_company_trade"] or company["owned_company_is_main_employment"] ) disregarded = ( - company["owned_company_trade_assets"] + stored_amount(company["owned_company_trade_assets"]) if company["owned_company_carries_on_trade"] and engaged else 0.0 ) - return max(0.0, company["owned_company_capital"] - disregarded) + return penny( + max(0.0, stored_amount(company["owned_company_capital"]) - disregarded) + ) def pinned_company(**facts): @@ -615,6 +638,34 @@ def test_property_company_gives_no_earnings(unit_list, share): ) ] ) +@example( + [ + ( + pinned_unit( + pinned_adult(), + pinned_adult(), + corporate_wealth=6392.862111833667, + ), + pinned_unit( + pinned_adult( + pinned_company(owned_company_holding_value=6336.697665836761) + ), + pinned_adult(), + ), + ) + ] +) +@example( + [ + ( + pinned_unit( + pinned_adult(pinned_company(owned_company_holding_value=0.40)), + corporate_wealth=0.65, + ), + pinned_unit(pinned_adult(pinned_company(owned_company_holding_value=0.40))), + ) + ] +) def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs): # Each household holds two benefit units: the first unit's adults and the # second's, with the first unit's savings and corporate wealth. No unit @@ -624,7 +675,7 @@ def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs people, benunits, households = {}, {}, {} expected_units, expected_households = [], [] for h, (first, second) in enumerate(pairs): - names, holdings, unit_company_capital = [], 0.0, [] + names, holdings, unit_company_capital = [], [], [] for b, unit in enumerate((first, second)): members, company_capital = [], 0.0 for j, adult in enumerate(unit["adults"]): @@ -637,7 +688,7 @@ def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs **{k: {YEAR: v} for k, v in c.items()}, } if applies(c): - holdings += c["owned_company_holding_value"] + holdings.append(c["owned_company_holding_value"]) company_capital += company_capital_of(c) members.append(name) benunits[f"h{h}_b{b}"] = {"members": members} @@ -648,14 +699,38 @@ def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs "savings": {YEAR: first["savings"]}, "corporate_wealth": {YEAR: first["corporate_wealth"]}, } - disregarded = min(holdings, first["corporate_wealth"]) - pool = first["savings"] + SHARE_VALUE * ( - first["corporate_wealth"] - disregarded + shares = stored_amount(first["corporate_wealth"]) + # The holdings budget uses raw stored inputs and stores its scale as + # float32. Apply that scale to the recovered monetary amounts before + # valuation; an ideal min(total holdings, shares) loses this rounding + # boundary when holdings exceed the budget. + raw_shares = np.float32(first["corporate_wealth"]) + raw_holdings = sum( + float(np.clip(np.float32(holding), np.float32(0), raw_shares)) + for holding in holdings + ) + scale = float( + np.float32(min(1.0, float(raw_shares) / raw_holdings)) + if raw_holdings > 0 + else np.float32(1.0) + ) + disregarded = sum( + min(stored_amount(holding), shares) * scale for holding in holdings + ) + # Reg. 49(1)(a) values the shares and the removed holdings separately; + # reg. 6(1) rounds each valuation, then each unit's final capital. + pool = max( + 0.0, + stored_amount(first["savings"]) + + penny(SHARE_VALUE * shares) + - penny(SHARE_VALUE * disregarded), ) claimants = [len(first["adults"]), len(second["adults"])] for n, company_capital in zip(claimants, unit_company_capital): - expected_units.append(pool * n / sum(claimants) + company_capital) - expected_households.append(pool + sum(unit_company_capital)) + expected_units.append(penny(pool * n / sum(claimants) + company_capital)) + # Allocation rounding belongs to each benefit unit. Summing the + # unrounded household pool would lose up to half a penny per unit. + expected_households.append(sum(expected_units[-2:])) sim = Simulation( situation={"people": people, "benunits": benunits, "households": households} ) From b168dc509d8d92be485f3664b8c55f9294adb839 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 17:11:52 -0400 Subject: [PATCH 24/38] Give the capital property households a cash ISA Main's ISA fix (feb50e440, merged here in 646743859) added cash_isa to every means test's capital sources. The property file's households had no cash_isa input, so the differential and the fixed Housing Benefit units raised KeyError: 'cash_isa' when they read each source. Households now draw a cash ISA balance. The cash-only property holds savings and a cash ISA, both deposit balances (ADM H1675), and checks each programme counts the two in full, so a source list that drops cash_isa fails it. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_capital_valuation_properties.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/test_capital_valuation_properties.py b/policyengine_uk/tests/test_capital_valuation_properties.py index 47dcd3348e..65837ebc3f 100644 --- a/policyengine_uk/tests/test_capital_valuation_properties.py +++ b/policyengine_uk/tests/test_capital_valuation_properties.py @@ -13,8 +13,8 @@ 1. Non-increasing: every programme's assessable capital is at most the same household's capital with the deduction switched off (rate 0, no debt). -2. Cash only: a household holding only savings has the same capital with the - deduction on or off. +2. Cash only: a household holding only savings and a cash ISA has the same + capital with the deduction on or off, equal to the two balances. 3. Differential: capital equals a direct implementation of the regulation, savings + sum over sources of max(0, 90% of value - secured debt), where the test itself fixes which assets take the 10% (land, property and @@ -89,11 +89,14 @@ LEGAL_RATE = 0.1 ASSETS = [ "savings", + "cash_isa", "owned_land", "corporate_wealth", "other_residential_property_value", "non_residential_property_value", ] +# Ordinary savings and a cash ISA are both deposit balances, counted in full. +CASH_ASSETS = ("savings", "cash_isa") # The capital sources count shares as share_like_capital, which is the # corporate_wealth these households are given. INPUT_OF = {"share_like_capital": "corporate_wealth"} @@ -116,7 +119,9 @@ def households(draw, cash_only=False): assets = {asset: draw(amount) for asset in ASSETS} debts = {debt: draw(amount) for debt in DEBTS.values()} if cash_only: - assets = {asset: 0.0 for asset in ASSETS} | {"savings": assets["savings"]} + assets = {asset: 0.0 for asset in ASSETS} | { + cash: assets[cash] for cash in CASH_ASSETS + } debts = {debt: 0.0 for debt in debts} # About half the units do not claim Universal Credit, so the Housing # Benefit passport cannot remove every working-age unit from the @@ -289,7 +294,8 @@ def test_cash_only_households_have_no_deduction(units): valued["guarantee_credit"][i], valued["hb_passported"][i], ): - assert close(valued[variable][i], unit["assets"]["savings"]), variable + cash = sum(unit["assets"][asset] for asset in CASH_ASSETS) + assert close(valued[variable][i], cash), variable @PROPERTY_SETTINGS From 696ae34ad8ac46278f5bca7468a9d587344a211c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 29 Oct 2026 00:19:40 -0400 Subject: [PATCH 25/38] Keep Core's defined_for mask in the exact Lifetime ISA valuation The float64 recompute called the baseline LISA formula directly, so a reform that only adds a defined_for mask (keeping the baseline formula) still counted the ISA where Core stores the default. A masked 0.26 ISA then added 0.20 to Universal Credit capital, and with 15,999.81 of savings made the claimant ineligible (v7 review, P2-1). Apply the mask the way Core does: where the defined_for variable, mapped to the person, is above zero, keep the float64 value; elsewhere keep the stored value, which is Core's default. New tests cover a mixed masked/counted pair across the six means tests and the UC eligibility case at the limit; both fail on the previous helper. Co-Authored-By: Claude Opus 5.5 --- .../test_capital_valuation_properties.py | 62 +++++++++++++++++++ policyengine_uk/utils/capital_valuation.py | 24 +++++-- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/test_capital_valuation_properties.py b/policyengine_uk/tests/test_capital_valuation_properties.py index 65837ebc3f..6942fd9748 100644 --- a/policyengine_uk/tests/test_capital_valuation_properties.py +++ b/policyengine_uk/tests/test_capital_valuation_properties.py @@ -541,3 +541,65 @@ def test_lifetime_isa_input_is_preserved_on_its_visible_branch(inherited): assert abs(branch.calculate("uc_assessable_capital", YEAR)[0] - 0.19) < 0.004 expected_main = 0.19 if inherited else 0.20 assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - expected_main) < 0.004 + + +def lisa_mask_reform(): + """A reform that keeps the baseline LISA formula and only adds a + ``defined_for`` mask, so the formula identity check still passes.""" + + class lisa_is_counted(Variable): + value_type = bool + entity = Person + definition_period = YEAR_PERIOD + default_value = False + label = "Lifetime ISA counted as capital" + + class lifetime_isa_countable_capital(Variable): + defined_for = "lisa_is_counted" + + class MaskLifetimeISA(Reform): + def apply(self): + self.add_variable(lisa_is_counted) + self.update_variable(lifetime_isa_countable_capital) + + return MaskLifetimeISA + + +def test_lifetime_isa_exact_valuation_respects_a_defined_for_mask(): + # Two units hold 0.26 each. Only p1's ISA is counted: Core stores 0 for + # p0, so p0 must have no capital, and p1 must be valued as without the + # mask (0.195, to the penny under each programme's rule). + situation = whole_penny_situation([(0, 0, 26, 30), (0, 0, 26, 30)]) + unmasked = Simulation(situation=situation) + masked = Simulation(situation=situation, reform=lisa_mask_reform()) + # The reform adds the mask, so the shared situation can't supply it. + masked.set_input("lisa_is_counted", YEAR, np.array([False, True])) + assert list(masked.calculate("lifetime_isa_countable_capital", YEAR)) == [ + 0, + pytest.approx(0.195), + ] + assert abs(unmasked.calculate("uc_assessable_capital", YEAR)[0] - 0.20) < 0.004 + for variable in PROGRAMMES: + before = np.asarray(unmasked.calculate(variable, YEAR), dtype=float) + after = np.asarray(masked.calculate(variable, YEAR), dtype=float) + assert abs(after[0]) < 0.004, (variable, after) + assert abs(after[1] - before[1]) < 0.004, (variable, before, after) + + +def test_a_masked_lifetime_isa_leaves_universal_credit_eligibility(): + # 15,999.81 of savings is within the 16,000 limit. A counted 0.26 ISA + # (0.195, rounded to 0.20) would take it to 16,000.01; masked, it adds + # nothing. + sim = Simulation( + situation=whole_penny_situation([(1_599_981, 0, 26, 30)]), + reform=lisa_mask_reform(), + ) + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - 15_999.81) < 0.004 + assert sim.calculate("is_uc_eligible", YEAR)[0] + sim = Simulation( + situation=whole_penny_situation([(1_599_981, 0, 26, 30)]), + reform=lisa_mask_reform(), + ) + sim.set_input("lisa_is_counted", YEAR, np.array([True])) + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - 16_000.01) < 0.004 + assert not sim.calculate("is_uc_eligible", YEAR)[0] diff --git a/policyengine_uk/utils/capital_valuation.py b/policyengine_uk/utils/capital_valuation.py index 6a8ee61e62..c1e5097bd4 100644 --- a/policyengine_uk/utils/capital_valuation.py +++ b/policyengine_uk/utils/capital_valuation.py @@ -60,10 +60,12 @@ def valued_person_capital(benunit, period, parameters, capital, counted): A source whose formula can give a fraction of a penny (``CALCULATED_PERSON_SOURCES``) is valued to the penny under the programme's rule, like any other valuation. The baseline formula is - worked again in float64, so float32 cannot move the rounding. Explicit - inputs for this period and branch, and values from a reformed formula, - are kept as stored: matching a baseline value in float32 does not prove - where a value came from. Any other source is a stored amount.""" + worked again in float64, so float32 cannot move the rounding, and only + where the variable's ``defined_for`` mask applies it, as Core does. + Explicit inputs for this period and branch, and values from a reformed + formula, are kept as stored: matching a baseline value in float32 does + not prove where a value came from. Any other source is a stored + amount.""" person = benunit.members total = 0 for source in capital.person_sources: @@ -78,7 +80,19 @@ def valued_person_capital(benunit, period, parameters, capital, counted): and variable.get_formula(period) is calculate and supplied_input(person, source, period) is None ): - value = calculate(person, period, parameters) + exact = calculate(person, period, parameters) + if variable.defined_for is not None: + # Core keeps the formula's result only where the + # defined_for variable is above zero and stores the + # default elsewhere; keep that stored default. + applicable = ( + person.simulation.calculate( + variable.defined_for, period, map_to=variable.entity.key + ) + > 0 + ) + exact = np.where(applicable, exact, value) + value = exact value = round_to_penny(value, capital.round_down) total = total + benunit.sum(value * counted) return total From dbc0c587f2f05681008e42567e5636ca1179d528 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 29 Oct 2026 01:27:46 -0400 Subject: [PATCH 26/38] Run no Lifetime ISA formula when its defined_for mask applies to nobody Core computes a variable's defined_for mask first and, when it applies to nobody, stores the default without running the formula. The exact float64 valuation ran the formula before masking, so under a reform that masks everyone it calculated dependencies Core never needs, and could fail where Core returns zero. It now works out the mask first and skips the formula when nobody is applicable. A regression gives the balance a formula that raises and checks every programme's capital. Co-Authored-By: Claude Opus 5.5 --- .../test_capital_valuation_properties.py | 27 +++++++++++++++++++ policyengine_uk/utils/capital_valuation.py | 15 +++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/test_capital_valuation_properties.py b/policyengine_uk/tests/test_capital_valuation_properties.py index 6942fd9748..ed2dfec1c7 100644 --- a/policyengine_uk/tests/test_capital_valuation_properties.py +++ b/policyengine_uk/tests/test_capital_valuation_properties.py @@ -586,6 +586,33 @@ def test_lifetime_isa_exact_valuation_respects_a_defined_for_mask(): assert abs(after[1] - before[1]) < 0.004, (variable, before, after) +def test_a_lifetime_isa_masked_for_everyone_runs_no_formula(): + # Core runs no formula when the mask applies to nobody, so a dependency + # only applicable people need is never calculated. A balance formula + # that raises shows whether the exact valuation calculates it anyway. + class lifetime_isa_balance(Variable): + def formula(person, period, parameters): + raise AssertionError("A masked Lifetime ISA balance was calculated") + + mask = lisa_mask_reform() + + class MaskWithUnavailableBalance(Reform): + def apply(self): + mask.apply(self) + self.update_variable(lifetime_isa_balance) + + without_lisa = Simulation(situation=whole_penny_situation([(1_599_981, 0, 0, 30)])) + situation = whole_penny_situation([(1_599_981, 0, 0, 30)]) + del situation["people"]["p0"]["lifetime_isa_balance"] + sim = Simulation(situation=situation, reform=MaskWithUnavailableBalance) + assert list(sim.calculate("lifetime_isa_countable_capital", YEAR)) == [0] + for variable in PROGRAMMES: + before = without_lisa.calculate(variable, YEAR)[0] + after = sim.calculate(variable, YEAR)[0] + assert abs(after - before) < 0.004, (variable, before, after) + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - 15_999.81) < 0.004 + + def test_a_masked_lifetime_isa_leaves_universal_credit_eligibility(): # 15,999.81 of savings is within the 16,000 limit. A counted 0.26 ISA # (0.195, rounded to 0.20) would take it to 16,000.01; masked, it adds diff --git a/policyengine_uk/utils/capital_valuation.py b/policyengine_uk/utils/capital_valuation.py index c1e5097bd4..23c3d326c7 100644 --- a/policyengine_uk/utils/capital_valuation.py +++ b/policyengine_uk/utils/capital_valuation.py @@ -61,7 +61,8 @@ def valued_person_capital(benunit, period, parameters, capital, counted): (``CALCULATED_PERSON_SOURCES``) is valued to the penny under the programme's rule, like any other valuation. The baseline formula is worked again in float64, so float32 cannot move the rounding, and only - where the variable's ``defined_for`` mask applies it, as Core does. + where the variable's ``defined_for`` mask applies it, as Core does. Like + Core, it runs no formula when the mask applies to nobody. Explicit inputs for this period and branch, and values from a reformed formula, are kept as stored: matching a baseline value in float32 does not prove where a value came from. Any other source is a stored @@ -80,19 +81,23 @@ def valued_person_capital(benunit, period, parameters, capital, counted): and variable.get_formula(period) is calculate and supplied_input(person, source, period) is None ): - exact = calculate(person, period, parameters) + applicable = None if variable.defined_for is not None: # Core keeps the formula's result only where the # defined_for variable is above zero and stores the - # default elsewhere; keep that stored default. + # default elsewhere, and runs no formula when it is + # above zero for nobody; do the same. applicable = ( person.simulation.calculate( variable.defined_for, period, map_to=variable.entity.key ) > 0 ) - exact = np.where(applicable, exact, value) - value = exact + if applicable is None or applicable.any(): + exact = calculate(person, period, parameters) + if applicable is not None: + exact = np.where(applicable, exact, value) + value = exact value = round_to_penny(value, capital.round_down) total = total + benunit.sum(value * counted) return total From fc066bd318f2e059623ab30df56985e3605e24d0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 29 Oct 2026 02:41:13 -0400 Subject: [PATCH 27/38] Expect the sale-expense deduction on a stocks-and-shares ISA in UC capital MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main's test_isa_balances expected Universal Credit capital to count a stored stocks-and-shares ISA at its full projected value. This branch values share_like_capital, which carries that ISA, less UC's 10% sale-expense rate until the data separate ISAs from shares (see the sale_expenses.sources description), so the merged test failed with £6,218.99 against £6,433.44: £5,800 rather than £6,000 times growth. The test now reads the rate from the parameters. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_isa_balances.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/test_isa_balances.py b/policyengine_uk/tests/test_isa_balances.py index a38b6cc2f4..e5a28362aa 100644 --- a/policyengine_uk/tests/test_isa_balances.py +++ b/policyengine_uk/tests/test_isa_balances.py @@ -59,10 +59,16 @@ def test_stored_household_totals_without_person_balances_project_with_savings(): shares = simulation.calculate("stocks_and_shares_isa", 2027).values[0] assert cash == pytest.approx(3_000.0 * growth) assert shares == pytest.approx(2_000.0 * growth) - # Savings, the cash ISA and the stocks-and-shares ISA (through - # share_like_capital) all reach the capital pool. + # Savings and the cash ISA reach the capital pool at their value. The + # stocks-and-shares ISA reaches it through share_like_capital, which + # Universal Credit values less its sale-expense rate until the data + # separate ISAs from shares. + rate = simulation.tax_benefit_system.parameters( + "2027-04-06" + ).gov.dwp.universal_credit.means_test.capital.sale_expenses.rate capital = simulation.calculate("uc_assessable_capital", 2027).values[0] - assert capital == pytest.approx(6_000.0 * growth, rel=1e-5) + expected = (1_000.0 + 3_000.0 + 2_000.0 * (1 - rate)) * growth + assert capital == pytest.approx(expected, rel=1e-5) def test_household_totals_sum_the_person_balances_in_every_year(): From 3a3241cf6a324f318f4a8cce750a39f9bbdabf4a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 08:58:37 -0400 Subject: [PATCH 28/38] Say which means tests deduct land debt UC lists no land among its capital sources, so the land-debt input changes the five legacy means tests only. Co-Authored-By: Claude Opus 5.5 --- .../variables/input/owned_land_secured_debt.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/variables/input/owned_land_secured_debt.py b/policyengine_uk/variables/input/owned_land_secured_debt.py index e3a890ef82..18d93d7c0b 100644 --- a/policyengine_uk/variables/input/owned_land_secured_debt.py +++ b/policyengine_uk/variables/input/owned_land_secured_debt.py @@ -9,10 +9,11 @@ class owned_land_secured_debt(Variable): "Outstanding encumbrances secured specifically on the household's " "land-only plots. Excludes main-home mortgage debt and unsecured debt. " "Deducted after the 10% for sale expenses in the capital valuation of " - "UC regulation 49(1) and its legacy equivalents (HB, IS, JSA, ESA and " - "Pension Credit), flooring the asset class at nil. The value and the " - "debt are household totals across the class, so debt on one asset " - "offsets equity in another. The survey lacks reliable property-specific " + "the legacy means tests (HB, IS, JSA, ESA and Pension Credit), " + "flooring the asset class at nil. UC counts no land, so this input " + "leaves UC capital unchanged. The value and the debt are household " + "totals across the class, so debt on one asset offsets equity in " + "another. The survey lacks reliable property-specific " "debt flags, so the default zero is a modelling approximation unless " "supplied explicitly." ) From 0e5cd487581e10d5d48bb99193ea7cc2e85132da Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:55:56 +0000 Subject: [PATCH 29/38] Pin zero new property inputs to main and state the Pension Credit stored-column limit Adds a hand-built grid pinned from main showing that overseas and other property left at zero, or omitted, reproduce main's wealth, land and capital outputs. Documents that a financial-only total stored in pension_credit_reported_capital acts as a complete override. Combines the pull request's changelog notes into one fragment covering the Pension Credit change for existing property categories and the household land move. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JYZdUfXbhZqeTprYa --- changelog.d/2577.fixed.md | 1 + changelog.d/2688-review-capital.fixed.md | 1 - changelog.d/2688-review-land.fixed.md | 1 - .../property-wealth-overseas-other.added.md | 1 - .../engineering/property-wealth-components.md | 5 + .../tests/test_property_wealth_components.py | 122 ++++++++++++++++++ .../pension_credit_reported_capital.py | 6 +- 7 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 changelog.d/2577.fixed.md delete mode 100644 changelog.d/2688-review-capital.fixed.md delete mode 100644 changelog.d/2688-review-land.fixed.md delete mode 100644 changelog.d/property-wealth-overseas-other.added.md diff --git a/changelog.d/2577.fixed.md b/changelog.d/2577.fixed.md new file mode 100644 index 0000000000..4c79a29d16 --- /dev/null +++ b/changelog.d/2577.fixed.md @@ -0,0 +1 @@ +Added overseas and other real estate as property components, counted them once in worldwide property wealth and in every means test's capital, and kept all separately valued property alongside financial-only capital reports for Universal Credit and Pension Credit. Household land now applies regional intensity to the residential, non-residential and other property components and adds direct UK land once at full value, so on a dataset whose components and saved property_wealth total differ, household land moves by that difference. diff --git a/changelog.d/2688-review-capital.fixed.md b/changelog.d/2688-review-capital.fixed.md deleted file mode 100644 index 096110e538..0000000000 --- a/changelog.d/2688-review-capital.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Retained separately valued overseas and other property alongside financial-only capital reports for Universal Credit and Pension Credit, including Housing Benefit's savings-credit route; preserved complete capital overrides. diff --git a/changelog.d/2688-review-land.fixed.md b/changelog.d/2688-review-land.fixed.md deleted file mode 100644 index 899486ae05..0000000000 --- a/changelog.d/2688-review-land.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Preserved the direct UK land lower bound by summing domestic property components before applying regional land intensity. diff --git a/changelog.d/property-wealth-overseas-other.added.md b/changelog.d/property-wealth-overseas-other.added.md deleted file mode 100644 index 39d59bc8a8..0000000000 --- a/changelog.d/property-wealth-overseas-other.added.md +++ /dev/null @@ -1 +0,0 @@ -Added overseas and other real estate property inputs, included all property components in worldwide gross wealth and means-tested capital, and counted direct UK land once while excluding identified overseas property from household land. diff --git a/docs/engineering/property-wealth-components.md b/docs/engineering/property-wealth-components.md index 9ea46dd205..1ce833cfe6 100644 --- a/docs/engineering/property-wealth-components.md +++ b/docs/engineering/property-wealth-components.md @@ -154,6 +154,11 @@ supplied nonnegative override still replaces the whole total. Combining the financial report and property directly in the assessment avoids an intermediate rounded aggregate that could reduce counted property for large values. Housing Benefit's savings-credit-only route inherits that corrected total. +A dataset that stores a financial-only survey total in +`pension_credit_reported_capital`, rather than in `benunit_reported_capital`, +turns it into a complete override: the formula then counts none of the +household's land or property for that unit. The data release must move such a +total to `benunit_reported_capital` for the property to be counted. Ordinary Housing Benefit, Income Support, income-based JSA and income-related ESA do not replace their source pools with a financial-only report, so their existing routing is retained. `share_like_capital` retains the component-aware financial fallback, diff --git a/policyengine_uk/tests/test_property_wealth_components.py b/policyengine_uk/tests/test_property_wealth_components.py index ef092ea546..8794f8b60a 100644 --- a/policyengine_uk/tests/test_property_wealth_components.py +++ b/policyengine_uk/tests/test_property_wealth_components.py @@ -467,3 +467,125 @@ def test_legacy_stored_total_overrides_formula_until_removed_by_data_release(): sim = Simulation(dataset=components_only) assert _value(sim, "property_wealth") == 150_000 _assert_projected_component_accounting(sim, COMPONENT_VALUES, 2026) + + +# Outputs pinned from a run of these situations on main at 2da212ab, which has +# neither overseas_property_value nor other_property_value. The legacy-benefit +# capital of the last two rows is omitted: the sale-expense valuation of +# other property and corporate_wealth changes it independently of the new +# inputs. +WEALTH_AND_LAND = ("property_wealth", "total_wealth", "household_land_value") +CAPITAL = ( + "uc_assessable_capital", + "housing_benefit_assessable_capital", + "pension_credit_assessable_capital", + "jsa_income_assessable_capital", + "esa_income_assessable_capital", + "income_support_assessable_capital", +) +ZERO_INPUT_GRID = ( + ( + (40,), + "LONDON", + {"savings": 5_000, "main_residence_value": 200_000}, + None, + (200_000, 205_000, 170_000, 5_000, 0, 0, 5_000, 5_000, 5_000), + ), + ( + (40,), + "NORTH_EAST", + {"savings": 17_000}, + None, + (0, 17_000, 0, 17_000, 17_000, 0, 17_000, 17_000, 17_000), + ), + ( + (30, 32), + "WALES", + {"savings": 9_000, "main_residence_value": 150_000}, + None, + (150_000, 159_000, 70_500, 9_000, 0, 0, 9_000, 9_000, 9_000), + ), + ( + (70,), + "SCOTLAND", + {"savings": 12_000, "main_residence_value": 300_000}, + None, + (300_000, 312_000, 132_000, 12_000, 0, 12_000, 12_000, 12_000, 12_000), + ), + ( + (72, 69), + "SOUTH_WEST", + {"savings": 30_000}, + None, + (0, 30_000, 0, 30_000, 0, 30_000, 30_000, 30_000, 30_000), + ), + ( + (40,), + "LONDON", + {"savings": 5_000}, + 2_000, + (0, 5_000, 0, 2_000, 0, 0, 5_000, 5_000, 5_000), + ), + ( + (70,), + "YORKSHIRE", + {"savings": 8_000}, + 4_000, + (0, 8_000, 0, 4_000, 0, 4_000, 8_000, 8_000, 8_000), + ), + ( + (45,), + "LONDON", + { + "main_residence_value": 400_000, + "other_residential_property_value": 150_000, + "non_residential_property_value": 80_000, + }, + None, + (630_000, 630_000, 535_500), + ), + ( + (45,), + "EAST_MIDLANDS", + { + "main_residence_value": 220_000, + "non_residential_property_value": 35_000, + "corporate_wealth": 10_000, + "private_pension_wealth": 90_000, + }, + None, + (255_000, 355_000, 122_400), + ), +) + + +def _grid_situation(ages, region, assets, reported, year=2026): + people = {f"person_{i}": {"age": {year: age}} for i, age in enumerate(ages)} + benunit = {"members": list(people)} + if reported is not None: + benunit["benunit_reported_capital"] = {year: reported} + return { + "people": people, + "benunits": {"benunit": benunit}, + "households": { + "household": { + "members": list(people), + "region": {year: region}, + **{variable: {year: value} for variable, value in assets.items()}, + } + }, + } + + +@pytest.mark.parametrize("explicit_zero", [False, True]) +@pytest.mark.parametrize("ages,region,assets,reported,expected", ZERO_INPUT_GRID) +def test_new_inputs_at_zero_reproduce_main( + ages, region, assets, reported, expected, explicit_zero +): + if explicit_zero: + assets = {**assets, "overseas_property_value": 0, "other_property_value": 0} + sim = Simulation(situation=_grid_situation(ages, region, assets, reported)) + outputs = (*WEALTH_AND_LAND, *CAPITAL)[: len(expected)] + + actual = tuple(_value(sim, variable, 2026) for variable in outputs) + assert actual == pytest.approx(expected, abs=0.01) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py index 09bb50d516..414646bc89 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py @@ -15,7 +15,11 @@ class pension_credit_reported_capital(Variable): "separately valued land and property remain counted; with neither " "report the household proxy and person-level sources apply. This " "variable does not derive from the financial-only observation. An " - "override set directly applies only in the supplied assessment year." + "override set directly applies only in the supplied assessment year. " + "A dataset that stores a financial-only survey total, such as the " + "Family Resources Survey's TOTCAPB4, in this column rather than in " + "benunit_reported_capital makes that total a complete override, so " + "Pension Credit then counts none of the household's land or property." ) definition_period = YEAR unit = GBP From e0413453ebc342c37996208d5e89e0a7cdbae47b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:41:07 +0000 Subject: [PATCH 30/38] Value the stocks-and-shares ISA less sale expenses in the stored-totals ISA test The merged branch values UC share-like capital less the regulation 49(1)(a) sale-expense rate, so the oracle applies that rate to the stocks-and-shares ISA instead of counting it in full. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JYZdUfXbhZqeTprYa --- policyengine_uk/tests/test_isa_balances.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_isa_balances.py b/policyengine_uk/tests/test_isa_balances.py index a38b6cc2f4..1ce7b5e4ac 100644 --- a/policyengine_uk/tests/test_isa_balances.py +++ b/policyengine_uk/tests/test_isa_balances.py @@ -60,9 +60,15 @@ def test_stored_household_totals_without_person_balances_project_with_savings(): assert cash == pytest.approx(3_000.0 * growth) assert shares == pytest.approx(2_000.0 * growth) # Savings, the cash ISA and the stocks-and-shares ISA (through - # share_like_capital) all reach the capital pool. + # share_like_capital) all reach the capital pool. UC values share-like + # capital less the regulation 49(1)(a) sale-expense rate. + rate = simulation.tax_benefit_system.parameters( + "2027-01-01" + ).gov.dwp.universal_credit.means_test.capital.sale_expenses.rate capital = simulation.calculate("uc_assessable_capital", 2027).values[0] - assert capital == pytest.approx(6_000.0 * growth, rel=1e-5) + assert capital == pytest.approx( + (1_000.0 + 3_000.0 + 2_000.0 * (1 - rate)) * growth, rel=1e-5 + ) def test_household_totals_sum_the_person_balances_in_every_year(): From ff0552dedbe8c2eee06f48047b508154e269a09d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:30:04 +0000 Subject: [PATCH 31/38] Pin row 8 UC capital, assert C2's pinned allocations exactly and check property source lists Pin uc_assessable_capital on the zero-input grid's London property row so dropping a UC property source fails. Assert the pinned company-capital allocation cases strictly against the float32 oracle, so a missing final penny rounding or a lost penny fails. Add a test that every shipped property_sources.yaml name is a configured capital source (or, for UC, a variable). State the land move as the regional intensity times the gap, and describe pension_credit_reported_capital's uprating entry as it works. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N2tyHSiibJNTZwjqL7uguP --- changelog.d/2577.fixed.md | 2 +- policyengine_uk/data/uprating_indices.yaml | 5 +- policyengine_uk/tests/test_isa_balances.py | 5 +- .../tests/test_property_wealth_components.py | 51 +++++++++++++++++-- .../tests/test_uc_company_owner_properties.py | 40 ++++++++++----- .../pension_credit_reported_capital.py | 3 +- 6 files changed, 82 insertions(+), 24 deletions(-) diff --git a/changelog.d/2577.fixed.md b/changelog.d/2577.fixed.md index 4c79a29d16..f042137f03 100644 --- a/changelog.d/2577.fixed.md +++ b/changelog.d/2577.fixed.md @@ -1 +1 @@ -Added overseas and other real estate as property components, counted them once in worldwide property wealth and in every means test's capital, and kept all separately valued property alongside financial-only capital reports for Universal Credit and Pension Credit. Household land now applies regional intensity to the residential, non-residential and other property components and adds direct UK land once at full value, so on a dataset whose components and saved property_wealth total differ, household land moves by that difference. +Added overseas and other real estate as property components and counted each once in property wealth and in every means test's capital; a financial-only capital report now leaves them counted for Universal Credit and Pension Credit, and for Pension Credit also leaves owned land, other residential and non-residential property counted. Household land now applies regional land intensity to the residential, non-residential and other property components and adds direct UK land once at full value, so on a dataset whose saved property_wealth total differs from the sum of those components, household land moves by the regional intensity times that difference; on the certified file at data-year values, household land rises from about £5,113bn to £5,521bn (+£408bn, +8.0%). diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index a525793644..1806f342f1 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -138,8 +138,9 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - non_residential_property_purchased - owned_land - owned_land_secured_debt -# A formula of benunit_reported_capital; listed so a dataset that stores it -# directly is uprated like the input. +# The formula returns -1, meaning no complete override, and a financial-only +# report is assessed separately through benunit_reported_capital. This index +# uprates programme observations that a dataset stores directly in the column. - pension_credit_reported_capital - pension_income - private_pension_wealth diff --git a/policyengine_uk/tests/test_isa_balances.py b/policyengine_uk/tests/test_isa_balances.py index 1ce7b5e4ac..f7c071008b 100644 --- a/policyengine_uk/tests/test_isa_balances.py +++ b/policyengine_uk/tests/test_isa_balances.py @@ -60,8 +60,9 @@ def test_stored_household_totals_without_person_balances_project_with_savings(): assert cash == pytest.approx(3_000.0 * growth) assert shares == pytest.approx(2_000.0 * growth) # Savings, the cash ISA and the stocks-and-shares ISA (through - # share_like_capital) all reach the capital pool. UC values share-like - # capital less the regulation 49(1)(a) sale-expense rate. + # share_like_capital) all reach the capital pool. The formula values + # share-like capital for Universal Credit at market value less the + # sale-expense rate (UC Regulations 2013 reg. 49). rate = simulation.tax_benefit_system.parameters( "2027-01-01" ).gov.dwp.universal_credit.means_test.capital.sale_expenses.rate diff --git a/policyengine_uk/tests/test_property_wealth_components.py b/policyengine_uk/tests/test_property_wealth_components.py index 8794f8b60a..82ac9a68c0 100644 --- a/policyengine_uk/tests/test_property_wealth_components.py +++ b/policyengine_uk/tests/test_property_wealth_components.py @@ -211,6 +211,46 @@ def test_property_capital_lists_preserve_each_source_once( assert sources.count("owned_land") == 1 +PARAMETERS_DIR = Path(__file__).parents[1] / "parameters" +PROPERTY_SOURCE_FILES = sorted(PARAMETERS_DIR.rglob("property_sources.yaml")) +# Universal Credit's uc_property_capital formula reads each listed variable +# directly. Every other list goes to valued_capital(..., sources=...), which +# skips a name missing from the programme's capital.sources. +DIRECTLY_READ_PROPERTY_SOURCES = {"gov.dwp.universal_credit.means_test.capital"} + + +def _dated_lists(path): + values = yaml.safe_load(path.read_text())["values"] + return {str(start): names for start, names in values.items()} + + +@pytest.mark.parametrize( + "path", + PROPERTY_SOURCE_FILES, + ids=lambda path: ".".join(path.relative_to(PARAMETERS_DIR).parent.parts), +) +def test_property_source_lists_name_configured_sources(path, _property_metadata_system): + node = ".".join(path.relative_to(PARAMETERS_DIR).parent.parts) + property_lists = _dated_lists(path) + assert property_lists + if node in DIRECTLY_READ_PROPERTY_SOURCES: + variables = _property_metadata_system.variables + for names in property_lists.values(): + assert len(names) == len(set(names)) + unknown = [name for name in names if name not in variables] + assert not unknown, f"{path}: {unknown} are not variables" + return + parameters = _property_metadata_system.parameters + first = min(property_lists) + dates = set(property_lists) | set(_dated_lists(path.with_name("sources.yaml"))) + for date in sorted(d for d in dates if d >= first): + names = list(parameters.get_child(f"{node}.property_sources")(date)) + configured = list(parameters.get_child(f"{node}.sources")(date)) + assert len(names) == len(set(names)) + missing = [name for name in names if name not in configured] + assert not missing, f"{path} at {date}: {missing} missing from sources" + + @pytest.mark.parametrize( "variable", ["overseas_property_value", "other_property_value"] ) @@ -470,10 +510,11 @@ def test_legacy_stored_total_overrides_formula_until_removed_by_data_release(): # Outputs pinned from a run of these situations on main at 2da212ab, which has -# neither overseas_property_value nor other_property_value. The legacy-benefit -# capital of the last two rows is omitted: the sale-expense valuation of -# other property and corporate_wealth changes it independently of the new -# inputs. +# neither overseas_property_value nor other_property_value. Row 8 pins wealth, +# land and Universal Credit capital; row 9 pins wealth and land only. The +# sale-expense valuation of other residential and non-residential property and +# of corporate_wealth changes the other capital outputs of those rows +# independently of the new inputs. WEALTH_AND_LAND = ("property_wealth", "total_wealth", "household_land_value") CAPITAL = ( "uc_assessable_capital", @@ -542,7 +583,7 @@ def test_legacy_stored_total_overrides_formula_until_removed_by_data_release(): "non_residential_property_value": 80_000, }, None, - (630_000, 630_000, 535_500), + (630_000, 630_000, 535_500, 207_000), ), ( (45,), diff --git a/policyengine_uk/tests/test_uc_company_owner_properties.py b/policyengine_uk/tests/test_uc_company_owner_properties.py index 85eb2e3818..f5dd93ba4a 100644 --- a/policyengine_uk/tests/test_uc_company_owner_properties.py +++ b/policyengine_uk/tests/test_uc_company_owner_properties.py @@ -624,9 +624,7 @@ def test_property_company_gives_no_earnings(unit_list, share): np.testing.assert_allclose(a, b, rtol=0, atol=1e-6, err_msg=v) -@PROPERTY_SETTINGS -@given(st.lists(st.tuples(units(), units()), min_size=1, max_size=3)) -@example( +PINNED_ALLOCATIONS = [ [ ( pinned_unit( @@ -636,9 +634,7 @@ def test_property_company_gives_no_earnings(unit_list, share): ), pinned_unit(pinned_adult()), ) - ] -) -@example( + ], [ ( pinned_unit( @@ -653,9 +649,7 @@ def test_property_company_gives_no_earnings(unit_list, share): pinned_adult(), ), ) - ] -) -@example( + ], [ ( pinned_unit( @@ -664,9 +658,12 @@ def test_property_company_gives_no_earnings(unit_list, share): ), pinned_unit(pinned_adult(pinned_company(owned_company_holding_value=0.40))), ) - ] -) -def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs): + ], +] + + +def _allocation_outputs(pairs): + """Return UC capital and the oracle's unit and household totals.""" # Each household holds two benefit units: the first unit's adults and the # second's, with the first unit's savings and corporate wealth. No unit # reports its own capital, so every owner's holding leaves the household @@ -735,10 +732,27 @@ def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs situation={"people": people, "benunits": benunits, "households": households} ) capital = np.asarray(sim.calculate("uc_assessable_capital", YEAR)) + return capital, expected_units, expected_households + + +@PROPERTY_SETTINGS +@given(st.lists(st.tuples(units(), units()), min_size=1, max_size=3)) +@example(PINNED_ALLOCATIONS[0]) +@example(PINNED_ALLOCATIONS[1]) +@example(PINNED_ALLOCATIONS[2]) +def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs): + capital, expected_units, expected_households = _allocation_outputs(pairs) + per_household = capital.reshape(-1, 2).sum(axis=1) assert (capital >= 0).all() + if pairs in PINNED_ALLOCATIONS: + # Pinned cases equal the penny-rounded oracle exactly once it is + # stored as float32, so a missing final rounding or a lost penny fails. + assert capital.dtype == np.float32 + assert capital.tolist() == np.float32(expected_units).tolist() + assert per_household.tolist() == np.float32(expected_households).tolist() + return for got, want in zip(capital, expected_units): assert close(got, want) - per_household = capital.reshape(-1, 2).sum(axis=1) for got, want in zip(per_household, expected_households): assert close(got, want) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py index 414646bc89..e9a84bd123 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py @@ -19,7 +19,8 @@ class pension_credit_reported_capital(Variable): "A dataset that stores a financial-only survey total, such as the " "Family Resources Survey's TOTCAPB4, in this column rather than in " "benunit_reported_capital makes that total a complete override, so " - "Pension Credit then counts none of the household's land or property." + "the formula then counts none of the household's land or property for " + "Pension Credit." ) definition_period = YEAR unit = GBP From 188f64fdf8f086fb5054a058ac034707764b5d84 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 18:37:34 +0000 Subject: [PATCH 32/38] Narrow the zero-input comment, drop the redundant exact household total and name the Pension Credit formula The exact household-total check in the pinned company-capital cases only summed the oracle's own unit values in float32, and would fail a correct model on an unequal split, so the pinned branch now checks units only. The zero-input grid comment names the capital outputs the valuation changes. The pension_credit_reported_capital docstring and uprating comment name the formula that acts. The changelog limits the new categories to means tests that count household property and adds the measured land move's household count, source and the open MHCLG check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N2tyHSiibJNTZwjqL7uguP --- changelog.d/2577.fixed.md | 2 +- policyengine_uk/data/uprating_indices.yaml | 7 ++++--- policyengine_uk/tests/test_property_wealth_components.py | 6 ++++-- .../tests/test_uc_company_owner_properties.py | 9 +++++---- .../pension_credit/pension_credit_reported_capital.py | 4 ++-- 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/changelog.d/2577.fixed.md b/changelog.d/2577.fixed.md index f042137f03..10ff8db3fb 100644 --- a/changelog.d/2577.fixed.md +++ b/changelog.d/2577.fixed.md @@ -1 +1 @@ -Added overseas and other real estate as property components and counted each once in property wealth and in every means test's capital; a financial-only capital report now leaves them counted for Universal Credit and Pension Credit, and for Pension Credit also leaves owned land, other residential and non-residential property counted. Household land now applies regional land intensity to the residential, non-residential and other property components and adds direct UK land once at full value, so on a dataset whose saved property_wealth total differs from the sum of those components, household land moves by the regional intensity times that difference; on the certified file at data-year values, household land rises from about £5,113bn to £5,521bn (+£408bn, +8.0%). +Added overseas and other real estate as property components and counted each once in property wealth and in the capital of every means test that counts household property; a financial-only capital report now leaves them counted for Universal Credit and Pension Credit, and for Pension Credit also leaves owned land, other residential and non-residential property counted. Household land now applies regional land intensity to the residential, non-residential and other property components and adds direct UK land once at full value, so on a dataset whose saved property_wealth total differs from the sum of those components, household land moves by the regional intensity times that difference; on the certified file at data-year values, household land rises from about £5,113bn to £5,521bn (+£408bn, +8.0%), with 12,353 households changing ([measurement](https://github.com/PolicyEngine/policyengine-uk/pull/2688#issuecomment-6449462413)), and the check against the MHCLG land target stays open. diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index 1806f342f1..2863d94dff 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -138,9 +138,10 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - non_residential_property_purchased - owned_land - owned_land_secured_debt -# The formula returns -1, meaning no complete override, and a financial-only -# report is assessed separately through benunit_reported_capital. This index -# uprates programme observations that a dataset stores directly in the column. +# The formula returns -1, meaning no complete override; +# pension_credit_assessable_capital assesses a financial-only report through +# benunit_reported_capital instead. This index uprates programme observations +# that a dataset stores directly in the column. - pension_credit_reported_capital - pension_income - private_pension_wealth diff --git a/policyengine_uk/tests/test_property_wealth_components.py b/policyengine_uk/tests/test_property_wealth_components.py index 82ac9a68c0..558780ae62 100644 --- a/policyengine_uk/tests/test_property_wealth_components.py +++ b/policyengine_uk/tests/test_property_wealth_components.py @@ -513,8 +513,10 @@ def test_legacy_stored_total_overrides_formula_until_removed_by_data_release(): # neither overseas_property_value nor other_property_value. Row 8 pins wealth, # land and Universal Credit capital; row 9 pins wealth and land only. The # sale-expense valuation of other residential and non-residential property and -# of corporate_wealth changes the other capital outputs of those rows -# independently of the new inputs. +# of corporate_wealth changes the Housing Benefit, JSA, ESA and Income Support +# capital of both rows, and row 9's Universal Credit capital, independently of +# the new inputs. Each row pins a prefix of the outputs, so neither pins +# Pension Credit capital, which is nil at age 45. WEALTH_AND_LAND = ("property_wealth", "total_wealth", "household_land_value") CAPITAL = ( "uc_assessable_capital", diff --git a/policyengine_uk/tests/test_uc_company_owner_properties.py b/policyengine_uk/tests/test_uc_company_owner_properties.py index f5dd93ba4a..8f383dd15d 100644 --- a/policyengine_uk/tests/test_uc_company_owner_properties.py +++ b/policyengine_uk/tests/test_uc_company_owner_properties.py @@ -742,17 +742,18 @@ def _allocation_outputs(pairs): @example(PINNED_ALLOCATIONS[2]) def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs): capital, expected_units, expected_households = _allocation_outputs(pairs) - per_household = capital.reshape(-1, 2).sum(axis=1) assert (capital >= 0).all() if pairs in PINNED_ALLOCATIONS: - # Pinned cases equal the penny-rounded oracle exactly once it is - # stored as float32, so a missing final rounding or a lost penny fails. + # Each pinned unit's capital equals the penny-rounded oracle exactly + # once the test converts the oracle to float32, so a missing final + # rounding or a lost penny fails. The household totals are sums of + # these exact unit values, so the test checks no separate total here. assert capital.dtype == np.float32 assert capital.tolist() == np.float32(expected_units).tolist() - assert per_household.tolist() == np.float32(expected_households).tolist() return for got, want in zip(capital, expected_units): assert close(got, want) + per_household = capital.reshape(-1, 2).sum(axis=1) for got, want in zip(per_household, expected_households): assert close(got, want) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py index e9a84bd123..e913c5de43 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py @@ -19,8 +19,8 @@ class pension_credit_reported_capital(Variable): "A dataset that stores a financial-only survey total, such as the " "Family Resources Survey's TOTCAPB4, in this column rather than in " "benunit_reported_capital makes that total a complete override, so " - "the formula then counts none of the household's land or property for " - "Pension Credit." + "the pension_credit_assessable_capital formula then counts none of the " + "household's land or property." ) definition_period = YEAR unit = GBP From f40cb894ce5296717d6492931f162a894a38afb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 18:40:46 +0000 Subject: [PATCH 33/38] Scope the engineering page's unmeasured effects to the compatible data release Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N2tyHSiibJNTZwjqL7uguP --- docs/engineering/property-wealth-components.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/engineering/property-wealth-components.md b/docs/engineering/property-wealth-components.md index 1ce833cfe6..eddf67b84a 100644 --- a/docs/engineering/property-wealth-components.md +++ b/docs/engineering/property-wealth-components.md @@ -235,8 +235,8 @@ The upstream seven-term reconciliation must run on that licensed source before release, with its missing-value report and the second-home/buy-to-let combination checked before imputation. -Population and LVT effects remain unmeasured until the compatible data release -exists and is rebuilt and recalibrated. Existing land target definitions and -weights are unchanged, but calculated land, losses and fitted geographic -weights can change. Production rebuilding, recalibration and impact -measurement are separate release work. +Population and LVT effects with the compatible data release remain unmeasured +until that release exists and is rebuilt and recalibrated. Existing land +target definitions and weights are unchanged, but calculated land, losses and +fitted geographic weights can change. Production rebuilding, recalibration +and impact measurement are separate release work. From c11d7c1d92faeab80bf37a3019463f0b4ecbc103 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:33:16 +0000 Subject: [PATCH 34/38] Value the stocks-and-shares ISA less sale expenses in the stored-totals test The household's stocks-and-shares ISA reaches Universal Credit capital through share_like_capital, which UC values less its sale-expense rate (UC Regulations 2013 reg. 49(1)(a)). Build the expected capital from savings, the cash ISA and the ISA less that rate, read from the parameters, and keep the check that capital keeps one ratio to savings. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6Kxyq1frfihPSQorLM --- .../tests/test_stored_household_totals.py | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/test_stored_household_totals.py b/policyengine_uk/tests/test_stored_household_totals.py index 468ff7c971..52c1ea99e6 100644 --- a/policyengine_uk/tests/test_stored_household_totals.py +++ b/policyengine_uk/tests/test_stored_household_totals.py @@ -216,10 +216,26 @@ def test_stored_household_totals_grow_past_the_projection_horizon(): assert totals[year][0] == pytest.approx( totals[PROJECTION_END_YEAR][0] * growth, rel=1e-6 ) - # Savings, the cash ISA and the stocks-and-shares ISA (through - # share_like_capital) make up the capital, each grown alike. + # Savings, the cash ISA and the stocks-and-shares ISA make up the capital. + # Savings and the cash ISA count at their value. The stocks-and-shares ISA + # reaches the capital through share_like_capital, which Universal Credit + # values less its sale-expense rate (Universal Credit Regulations 2013 + # reg. 49(1)(a)). The parts grow alike, so capital keeps the same ratio to + # savings in every year: 5.8 for this household. + cash_isa = _values(simulation, "cash_isa") + shares_isa = _values(simulation, "stocks_and_shares_isa") + ratio = capital[DATA_YEAR][0] / savings[DATA_YEAR][0] for year in YEARS: - assert capital[year][0] == pytest.approx(6 * savings[year][0], rel=1e-5) + rate = simulation.tax_benefit_system.parameters( + period(year).start + ).gov.dwp.universal_credit.means_test.capital.sale_expenses.rate + expected = ( + savings[year][0] + cash_isa[year][0] + shares_isa[year][0] * (1 - rate) + ) + assert capital[year][0] == pytest.approx(expected, rel=1e-5), year + face_value = savings[year][0] + cash_isa[year][0] + shares_isa[year][0] + assert capital[year][0] < face_value - 0.01, year + assert capital[year][0] == pytest.approx(ratio * savings[year][0], rel=1e-5) @pytest.mark.parametrize( From 35a2971859ecd781a11d9aa3550070b12bb72410 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 06:53:56 +0000 Subject: [PATCH 35/38] Shorten the overseas and other real estate changelog fragment Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HEwBvXi5Dex2fdn9w --- changelog.d/2577.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/2577.fixed.md b/changelog.d/2577.fixed.md index 10ff8db3fb..b1a75a1512 100644 --- a/changelog.d/2577.fixed.md +++ b/changelog.d/2577.fixed.md @@ -1 +1 @@ -Added overseas and other real estate as property components and counted each once in property wealth and in the capital of every means test that counts household property; a financial-only capital report now leaves them counted for Universal Credit and Pension Credit, and for Pension Credit also leaves owned land, other residential and non-residential property counted. Household land now applies regional land intensity to the residential, non-residential and other property components and adds direct UK land once at full value, so on a dataset whose saved property_wealth total differs from the sum of those components, household land moves by the regional intensity times that difference; on the certified file at data-year values, household land rises from about £5,113bn to £5,521bn (+£408bn, +8.0%), with 12,353 households changing ([measurement](https://github.com/PolicyEngine/policyengine-uk/pull/2688#issuecomment-6449462413)), and the check against the MHCLG land target stays open. +The model now counts overseas and other real estate in property wealth and in every means test that counts household property. Household land applies regional land intensity to residential, non-residential and other real estate and adds UK land once at full value, which raises the certified file's land from about £5,113bn to £5,521bn ([measurement](https://github.com/PolicyEngine/policyengine-uk/pull/2688#issuecomment-6449462413)). From 1aacd327b615b20ecfe43562cf890c032e1d4dd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 06:54:39 +0000 Subject: [PATCH 36/38] Drop the duplicate HOLDINGS_SOURCE import the merge left in uc_assessable_capital Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HEwBvXi5Dex2fdn9w --- .../gov/dwp/universal_credit/uc_assessable_capital.py | 3 --- 1 file changed, 3 deletions(-) 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 297c83fccb..4b3e7816ca 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 @@ -9,9 +9,6 @@ from policyengine_uk.variables.gov.dwp.universal_credit.household_uc_company_holding_pool_scale import ( HOLDINGS_SOURCE, ) -from policyengine_uk.variables.gov.dwp.universal_credit.household_uc_company_holding_pool_scale import ( - HOLDINGS_SOURCE, -) class uc_assessable_capital(Variable): From 0d760fccdd5131dedc709fbe8a5b4b0293f01420 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 3 Nov 2026 06:21:54 -0500 Subject: [PATCH 37/38] Describe the Pension Credit fix in the changelog and correct six comments - changelog.d/2577.fixed.md names the categories that a financial-only Pension Credit report no longer replaces, and says what the household land formula now reads. - The zero-input grid comment credits row 9's Universal Credit change to the corporate_wealth deduction alone. - The pension_credit_reported_capital docstring names the formula that treats a stored financial-only total as a complete override. - The Universal Credit sale-expense rate description lists all four property routes. - The engineering page drops two passives, and the two ISA test comments name the formula that values share_like_capital. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2577.fixed.md | 2 +- docs/engineering/property-wealth-components.md | 8 ++++---- .../means_test/capital/sale_expenses/rate.yaml | 10 +++++----- policyengine_uk/tests/test_isa_balances.py | 2 +- .../tests/test_property_wealth_components.py | 18 ++++++++++-------- .../tests/test_stored_household_totals.py | 4 ++-- .../pension_credit_reported_capital.py | 8 ++++---- 7 files changed, 27 insertions(+), 25 deletions(-) diff --git a/changelog.d/2577.fixed.md b/changelog.d/2577.fixed.md index b1a75a1512..f7bddddff8 100644 --- a/changelog.d/2577.fixed.md +++ b/changelog.d/2577.fixed.md @@ -1 +1 @@ -The model now counts overseas and other real estate in property wealth and in every means test that counts household property. Household land applies regional land intensity to residential, non-residential and other real estate and adds UK land once at full value, which raises the certified file's land from about £5,113bn to £5,521bn ([measurement](https://github.com/PolicyEngine/policyengine-uk/pull/2688#issuecomment-6449462413)). +The model now counts overseas and other real estate in property wealth and in every means-tested capital assessment that includes household property. The Pension Credit formula now adds owned land and other residential and non-residential property to a financial-only capital report instead of letting the report replace them. The household land formula now applies regional land intensity to residential, non-residential and other real estate instead of property_wealth, and adds direct UK land once at full value. On the certified file, whose producer imputed the saved property_wealth total and the components separately, household land at data-year values rises from about £5,113bn to £5,521bn ([measurement](https://github.com/PolicyEngine/policyengine-uk/pull/2688#issuecomment-6449462413)). diff --git a/docs/engineering/property-wealth-components.md b/docs/engineering/property-wealth-components.md index eddf67b84a..1068f10751 100644 --- a/docs/engineering/property-wealth-components.md +++ b/docs/engineering/property-wealth-components.md @@ -235,8 +235,8 @@ The upstream seven-term reconciliation must run on that licensed source before release, with its missing-value report and the second-home/buy-to-let combination checked before imputation. -Population and LVT effects with the compatible data release remain unmeasured -until that release exists and is rebuilt and recalibrated. Existing land -target definitions and weights are unchanged, but calculated land, losses and -fitted geographic weights can change. Production rebuilding, recalibration +Population and LVT effects with the compatible data release stay unmeasured +until that release exists and its rebuild and recalibration finish. Existing +land target definitions and weights stay the same, but calculated land, losses +and fitted geographic weights can change. Production rebuilding, recalibration and impact measurement are separate release work. diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml index 394de65fac..7e07399b0f 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml @@ -1,11 +1,11 @@ description: > Share of the current market or surrender value deducted from Universal Credit capital where there would be expenses attributable to sale. It is a - flat 10% whatever the actual costs, taken from the gross value before any - encumbrance secured on the asset is deducted. It applies to the countable - property in property_sources (other residential and non-residential - property, assumed to have costs of sale) and to the financial sources listed - in sale_expenses.sources. + flat 10% whatever the actual costs, taken from the gross value before + deducting any encumbrance secured on the asset. It applies to the countable + property in property_sources (other residential, non-residential, overseas + and unclassified other property, assumed to have costs of sale) and to the + financial sources listed in sale_expenses.sources. values: 2013-04-29: 0.1 metadata: diff --git a/policyengine_uk/tests/test_isa_balances.py b/policyengine_uk/tests/test_isa_balances.py index e5a28362aa..5579a205eb 100644 --- a/policyengine_uk/tests/test_isa_balances.py +++ b/policyengine_uk/tests/test_isa_balances.py @@ -61,7 +61,7 @@ def test_stored_household_totals_without_person_balances_project_with_savings(): assert shares == pytest.approx(2_000.0 * growth) # Savings and the cash ISA reach the capital pool at their value. The # stocks-and-shares ISA reaches it through share_like_capital, which - # Universal Credit values less its sale-expense rate until the data + # uc_assessable_capital values less the sale-expense rate until the data # separate ISAs from shares. rate = simulation.tax_benefit_system.parameters( "2027-04-06" diff --git a/policyengine_uk/tests/test_property_wealth_components.py b/policyengine_uk/tests/test_property_wealth_components.py index 558780ae62..d8248a4b0e 100644 --- a/policyengine_uk/tests/test_property_wealth_components.py +++ b/policyengine_uk/tests/test_property_wealth_components.py @@ -509,14 +509,16 @@ def test_legacy_stored_total_overrides_formula_until_removed_by_data_release(): _assert_projected_component_accounting(sim, COMPONENT_VALUES, 2026) -# Outputs pinned from a run of these situations on main at 2da212ab, which has -# neither overseas_property_value nor other_property_value. Row 8 pins wealth, -# land and Universal Credit capital; row 9 pins wealth and land only. The -# sale-expense valuation of other residential and non-residential property and -# of corporate_wealth changes the Housing Benefit, JSA, ESA and Income Support -# capital of both rows, and row 9's Universal Credit capital, independently of -# the new inputs. Each row pins a prefix of the outputs, so neither pins -# Pension Credit capital, which is nil at age 45. +# These outputs come from a run of these situations on main at 2da212ab, which +# has neither overseas_property_value nor other_property_value. Row 8 pins +# wealth, land and Universal Credit capital; row 9 pins wealth and land only. +# Unlike main at 2da212ab, the Housing Benefit, JSA, ESA and Income Support +# formulas here take sale expenses off other residential and non-residential +# property and off corporate_wealth, and the Universal Credit formula takes +# them off corporate_wealth. That changes the legacy capital of both rows, and +# row 9's Universal Credit capital, independently of the new inputs. Rows 8 +# and 9 stop before Pension Credit capital, which the formula sets to nil while +# no member has reached the qualifying age. WEALTH_AND_LAND = ("property_wealth", "total_wealth", "household_land_value") CAPITAL = ( "uc_assessable_capital", diff --git a/policyengine_uk/tests/test_stored_household_totals.py b/policyengine_uk/tests/test_stored_household_totals.py index 52c1ea99e6..4172704c5a 100644 --- a/policyengine_uk/tests/test_stored_household_totals.py +++ b/policyengine_uk/tests/test_stored_household_totals.py @@ -218,8 +218,8 @@ def test_stored_household_totals_grow_past_the_projection_horizon(): ) # Savings, the cash ISA and the stocks-and-shares ISA make up the capital. # Savings and the cash ISA count at their value. The stocks-and-shares ISA - # reaches the capital through share_like_capital, which Universal Credit - # values less its sale-expense rate (Universal Credit Regulations 2013 + # reaches the capital through share_like_capital, which uc_assessable_capital + # values less the sale-expense rate (Universal Credit Regulations 2013 # reg. 49(1)(a)). The parts grow alike, so capital keeps the same ratio to # savings in every year: 5.8 for this household. cash_isa = _values(simulation, "cash_isa") diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py index e913c5de43..29cb6584fa 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py @@ -16,11 +16,11 @@ class pension_credit_reported_capital(Variable): "report the household proxy and person-level sources apply. This " "variable does not derive from the financial-only observation. An " "override set directly applies only in the supplied assessment year. " - "A dataset that stores a financial-only survey total, such as the " + "If a dataset stores a financial-only survey total, such as the " "Family Resources Survey's TOTCAPB4, in this column rather than in " - "benunit_reported_capital makes that total a complete override, so " - "the pension_credit_assessable_capital formula then counts none of the " - "household's land or property." + "benunit_reported_capital, the pension_credit_assessable_capital " + "formula treats that total as a complete override and counts none of " + "the household's land or property." ) definition_period = YEAR unit = GBP From 80e0d344fd578042da5be96b48dcf11c2f453aed Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 13:59:02 +0000 Subject: [PATCH 38/38] Correct the Pension Credit override text, test comments and legacy citations The pension_credit_reported_capital documentation now says that the model uprates a stored value into each projected year. The changelog, two test comments, the UC sale-expense rate description and the engineering page take the review's wording, and each legacy and Pension Credit sale-expense list cites the regulation for capital outside the United Kingdom. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JUKtCvbBcX1Pd9TLH --- changelog.d/2577.fixed.md | 2 +- docs/engineering/property-wealth-components.md | 14 +++++++------- .../ESA/income/capital/sale_expenses/sources.yaml | 2 ++ .../JSA/income/capital/sale_expenses/sources.yaml | 2 ++ .../means_test/capital/sale_expenses/sources.yaml | 4 ++++ .../means_test/capital/sale_expenses/sources.yaml | 2 ++ .../income/capital/sale_expenses/sources.yaml | 2 ++ .../means_test/capital/sale_expenses/rate.yaml | 15 ++++++++------- policyengine_uk/tests/test_isa_balances.py | 4 ++-- .../tests/test_property_wealth_components.py | 11 ++++++----- .../pension_credit_reported_capital.py | 7 +++++-- 11 files changed, 41 insertions(+), 24 deletions(-) diff --git a/changelog.d/2577.fixed.md b/changelog.d/2577.fixed.md index f7bddddff8..728a3642b5 100644 --- a/changelog.d/2577.fixed.md +++ b/changelog.d/2577.fixed.md @@ -1 +1 @@ -The model now counts overseas and other real estate in property wealth and in every means-tested capital assessment that includes household property. The Pension Credit formula now adds owned land and other residential and non-residential property to a financial-only capital report instead of letting the report replace them. The household land formula now applies regional land intensity to residential, non-residential and other real estate instead of property_wealth, and adds direct UK land once at full value. On the certified file, whose producer imputed the saved property_wealth total and the components separately, household land at data-year values rises from about £5,113bn to £5,521bn ([measurement](https://github.com/PolicyEngine/policyengine-uk/pull/2688#issuecomment-6449462413)). +The model now counts overseas and other real estate in property wealth and in every means-tested capital assessment that includes household property. The Pension Credit formula now adds owned land, non-residential property and residential property other than the main residence to a financial-only capital report instead of letting the report replace them. The household land formula now applies regional land intensity to residential, non-residential and other real estate instead of property_wealth, and adds direct UK land once at full value. On the certified file, whose producer imputed the saved property_wealth total and the components separately, household land at data-year values rises from about £5,113bn to £5,521bn ([measurement](https://github.com/PolicyEngine/policyengine-uk/pull/2688#issuecomment-6449462413)). diff --git a/docs/engineering/property-wealth-components.md b/docs/engineering/property-wealth-components.md index 1068f10751..d0ed2cdfae 100644 --- a/docs/engineering/property-wealth-components.md +++ b/docs/engineering/property-wealth-components.md @@ -154,11 +154,11 @@ supplied nonnegative override still replaces the whole total. Combining the financial report and property directly in the assessment avoids an intermediate rounded aggregate that could reduce counted property for large values. Housing Benefit's savings-credit-only route inherits that corrected total. -A dataset that stores a financial-only survey total in -`pension_credit_reported_capital`, rather than in `benunit_reported_capital`, -turns it into a complete override: the formula then counts none of the -household's land or property for that unit. The data release must move such a -total to `benunit_reported_capital` for the property to be counted. +If a dataset stores a financial-only survey total in +`pension_credit_reported_capital` rather than in `benunit_reported_capital`, +the formula treats that total as a complete override and counts none of the +household's land or property for that unit. The data producer must move such a +total to `benunit_reported_capital` before the formula can count the property. Ordinary Housing Benefit, Income Support, income-based JSA and income-related ESA do not replace their source pools with a financial-only report, so their existing routing is retained. `share_like_capital` retains the component-aware financial fallback, @@ -237,6 +237,6 @@ combination checked before imputation. Population and LVT effects with the compatible data release stay unmeasured until that release exists and its rebuild and recalibration finish. Existing -land target definitions and weights stay the same, but calculated land, losses -and fitted geographic weights can change. Production rebuilding, recalibration +land target definitions and target weights stay the same, but calculated land, +losses and fitted geographic weights can change. Production rebuilding, recalibration and impact measurement are separate release work. diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml index 5ce12f0325..f0ec363c41 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml @@ -28,6 +28,8 @@ metadata: reference: - title: Employment and Support Allowance Regulations 2008 reg. 113(a) href: https://www.legislation.gov.uk/uksi/2008/794/regulation/113 + - title: Employment and Support Allowance Regulations 2008 reg. 114 + href: https://www.legislation.gov.uk/uksi/2008/794/regulation/114 - title: Employment and Support Allowance Regulations (Northern Ireland) 2008 reg. 113(a) href: https://www.legislation.gov.uk/nisr/2008/280/regulation/113 - title: Decision Makers' Guide chapter 52, paras. 52620-52622 diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml index 0abec317c7..3a6b46c6ab 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml @@ -28,6 +28,8 @@ metadata: reference: - title: Jobseeker's Allowance Regulations 1996 reg. 111(a) href: https://www.legislation.gov.uk/uksi/1996/207/regulation/111 + - title: Jobseeker's Allowance Regulations 1996 reg. 112 + href: https://www.legislation.gov.uk/uksi/1996/207/regulation/112 - title: Jobseeker's Allowance Regulations (Northern Ireland) 1996 reg. 111(a), as substituted by S.R. 2007/396 reg. 7(11) href: https://www.legislation.gov.uk/nisr/2007/396/regulation/7 - title: Decision Makers' Guide chapter 29, paras. 29620-29623 diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml index dea74d3c7f..4dae861f35 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml @@ -29,8 +29,12 @@ metadata: reference: - title: The Housing Benefit Regulations 2006 reg. 47(a) href: https://www.legislation.gov.uk/uksi/2006/213/regulation/47 + - title: The Housing Benefit Regulations 2006 reg. 48 + href: https://www.legislation.gov.uk/uksi/2006/213/regulation/48 - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg. 45(a) href: https://www.legislation.gov.uk/uksi/2006/214/regulation/45 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg. 46 + href: https://www.legislation.gov.uk/uksi/2006/214/regulation/46 - title: The Housing Benefit Regulations (Northern Ireland) 2006 reg. 44(a) href: https://www.legislation.gov.uk/nisr/2006/405/regulation/44 - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations (Northern Ireland) 2006 reg. 43(a) diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml index 2c8e696b12..1e5b7bfecc 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml @@ -28,6 +28,8 @@ metadata: reference: - title: Income Support (General) Regulations 1987 reg. 49(a) href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/49 + - title: Income Support (General) Regulations 1987 reg. 50 + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/50 - title: Income Support (General) Regulations (Northern Ireland) 1987 reg. 49(a), as substituted by S.R. 2007/396 reg. 4(10) href: https://www.legislation.gov.uk/nisr/2007/396/regulation/4 - title: Decision Makers' Guide chapter 29, paras. 29620-29623 diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml index fe3d5d1137..e963fbee05 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml @@ -28,6 +28,8 @@ metadata: reference: - title: State Pension Credit Regulations 2002 reg. 19(a) href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/19 + - title: State Pension Credit Regulations 2002 reg. 20 + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/20 - title: State Pension Credit Regulations (Northern Ireland) 2003 reg. 19(a) href: https://www.legislation.gov.uk/nisr/2003/28/regulation/19 - title: Decision Makers' Guide chapter 84, paras. 84712-84715 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml index 7e07399b0f..8ea5180e07 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml @@ -1,11 +1,12 @@ description: > - Share of the current market or surrender value deducted from Universal - Credit capital where there would be expenses attributable to sale. It is a - flat 10% whatever the actual costs, taken from the gross value before - deducting any encumbrance secured on the asset. It applies to the countable - property in property_sources (other residential, non-residential, overseas - and unclassified other property, assumed to have costs of sale) and to the - financial sources listed in sale_expenses.sources. + Share of the current market or surrender value that the Universal Credit + formulas deduct from capital where there would be expenses attributable to + sale. The formulas deduct a flat 10% whatever the actual costs, from the + gross value and before any encumbrance secured on the asset. They apply it + to the countable property in property_sources (other residential, + non-residential, overseas and unclassified other property, which they treat + as having costs of sale) and to the financial sources in + sale_expenses.sources. values: 2013-04-29: 0.1 metadata: diff --git a/policyengine_uk/tests/test_isa_balances.py b/policyengine_uk/tests/test_isa_balances.py index 5579a205eb..2468bef265 100644 --- a/policyengine_uk/tests/test_isa_balances.py +++ b/policyengine_uk/tests/test_isa_balances.py @@ -61,8 +61,8 @@ def test_stored_household_totals_without_person_balances_project_with_savings(): assert shares == pytest.approx(2_000.0 * growth) # Savings and the cash ISA reach the capital pool at their value. The # stocks-and-shares ISA reaches it through share_like_capital, which - # uc_assessable_capital values less the sale-expense rate until the data - # separate ISAs from shares. + # uc_assessable_capital values less the sale-expense rate whether + # share_like_capital holds shares or, as here, only the ISA. rate = simulation.tax_benefit_system.parameters( "2027-04-06" ).gov.dwp.universal_credit.means_test.capital.sale_expenses.rate diff --git a/policyengine_uk/tests/test_property_wealth_components.py b/policyengine_uk/tests/test_property_wealth_components.py index d8248a4b0e..1e5039f7f8 100644 --- a/policyengine_uk/tests/test_property_wealth_components.py +++ b/policyengine_uk/tests/test_property_wealth_components.py @@ -514,11 +514,12 @@ def test_legacy_stored_total_overrides_formula_until_removed_by_data_release(): # wealth, land and Universal Credit capital; row 9 pins wealth and land only. # Unlike main at 2da212ab, the Housing Benefit, JSA, ESA and Income Support # formulas here take sale expenses off other residential and non-residential -# property and off corporate_wealth, and the Universal Credit formula takes -# them off corporate_wealth. That changes the legacy capital of both rows, and -# row 9's Universal Credit capital, independently of the new inputs. Rows 8 -# and 9 stop before Pension Credit capital, which the formula sets to nil while -# no member has reached the qualifying age. +# property and off share_like_capital, which holds row 9's corporate_wealth, +# and the Universal Credit formula takes them off share_like_capital. These +# formulas therefore change the Housing Benefit, JSA, ESA and Income Support +# capital of both rows, and row 9's Universal Credit capital, independently +# of the new inputs. Rows 8 and 9 stop before Pension Credit capital, which +# the formula sets to nil while no member has reached the qualifying age. WEALTH_AND_LAND = ("property_wealth", "total_wealth", "household_land_value") CAPITAL = ( "uc_assessable_capital", diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py index 29cb6584fa..adb0e7b784 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py @@ -14,8 +14,11 @@ class pension_credit_reported_capital(Variable): "benunit_reported_capital replaces only financial proxies, while " "separately valued land and property remain counted; with neither " "report the household proxy and person-level sources apply. This " - "variable does not derive from the financial-only observation. An " - "override set directly applies only in the supplied assessment year. " + "variable does not derive from the financial-only observation. A " + "value that a user sets in a situation applies only in the years the " + "user sets it for. The model uprates a value that a dataset stores in " + "this column into each later year it projects the dataset to, and the " + "formula returns -1 after the last of those years. " "If a dataset stores a financial-only survey total, such as the " "Family Resources Survey's TOTCAPB4, in this column rather than in " "benunit_reported_capital, the pension_credit_assessable_capital "