From 7a5076c4a13b6949a98177961c6c46702b262834 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 17:28:09 -0400 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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 a677cb348c219393bdf22ee90a687093568e7d8f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 14:52:59 -0400 Subject: [PATCH 15/21] 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 16/21] 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 17/21] 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 18/21] 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 19/21] 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 20/21] 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 21/21] 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." )