From 87b086193bffdd45ed661513aa289bdbe45d88a6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 00:49:53 -0400 Subject: [PATCH 01/27] Count person cash and stocks and shares ISAs in the holder's benefit unit Each means test now counts cash_isa_balance and stocks_and_shares_isa_balance through person_sources, for the claimant and partner in their own benefit unit, and takes them out of the household pool it shares between benefit units. A person source counts only while sources lists the household source that holds it, so a reform that drops cash_isa or share_like_capital drops both. Household data with no balances keep today's results. Co-Authored-By: Claude Opus 5.5 --- .../ESA/income/capital/person_sources.yaml | 22 +- .../gov/dwp/ESA/income/capital/sources.yaml | 3 + .../JSA/income/capital/person_sources.yaml | 21 +- .../gov/dwp/JSA/income/capital/sources.yaml | 3 + .../means_test/capital/person_sources.yaml | 22 +- .../means_test/capital/sources.yaml | 3 + .../means_test/capital/person_sources.yaml | 22 +- .../means_test/capital/sources.yaml | 3 + .../income/capital/person_sources.yaml | 24 +- .../income/capital/sources.yaml | 3 + .../means_test/capital/person_sources.yaml | 38 +- .../means_test/capital/sources.yaml | 3 + .../baseline/gov/dwp/person_isa_capital.yaml | 374 ++++++++++++++++++ .../variables/gov/dwp/_person_capital_pool.py | 70 ++++ .../gov/dwp/cash_isa_countable_capital.py | 37 ++ .../gov/dwp/esa_income_assessable_capital.py | 11 +- .../housing_benefit_assessable_capital.py | 12 +- .../dwp/income_support_assessable_capital.py | 12 +- .../gov/dwp/jsa_income_assessable_capital.py | 11 +- .../pension_credit_assessable_capital.py | 12 +- ...stocks_and_shares_isa_countable_capital.py | 43 ++ .../universal_credit/uc_assessable_capital.py | 22 +- 22 files changed, 734 insertions(+), 37 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml create mode 100644 policyengine_uk/variables/gov/dwp/_person_capital_pool.py create mode 100644 policyengine_uk/variables/gov/dwp/cash_isa_countable_capital.py create mode 100644 policyengine_uk/variables/gov/dwp/stocks_and_shares_isa_countable_capital.py diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml index 3addab21fc..0f8c26c1a8 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml @@ -8,7 +8,17 @@ description: > ISA at its surrender value: regulation 113 values capital at its current market or surrender value, and DMG 52665 counts a Lifetime ISA at 75% of its value under 60 and in full from 60. Lifetime ISAs exist from 6 April 2017, - and the list is empty before then. + and the list counts them from then. `cash_isa_countable_capital` and + `stocks_and_shares_isa_countable_capital` are a member's cash and stocks and + shares ISA balances. An ISA has one holder, who owns its investments + beneficially (Individual Savings Account Regulations 1998 reg. 4(1B)(c) and + (6)(a)), and a child holds their own junior ISA (reg. 2B). Each counts only + while `sources` lists the household source that already holds it + (`cash_isa`, `share_like_capital`), and the household pool then leaves it + out of that source, so it counts once, in its holder's own benefit unit, and + a list without the household source counts neither. Household data with no + balances keep the whole household total in the pool. ISAs exist from 6 April + 1999 (reg. 1). metadata: label: Income-related ESA person-level capital sources period: year @@ -20,7 +30,15 @@ metadata: href: https://www.legislation.gov.uk/uksi/2008/794/regulation/113 - title: Decision Makers' Guide, Chapter 52 (Capital), para. 52665 href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf + - title: The Individual Savings Account Regulations 1998 reg. 4 + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/4 + - title: The Individual Savings Account Regulations 1998 reg. 2B + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/2B values: - 2008-10-27: [] + 2008-10-27: + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_capital 2017-04-06: - lifetime_isa_countable_capital + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_capital diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml index 5892c52c74..da1645b62a 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml @@ -1,6 +1,9 @@ description: > Ordinary cash ISA balances are separate from savings and are included at their accessible cash value, without a generic cash sale-cost deduction. + Where the data tie a cash ISA or stocks and shares ISA balance to a member, + `person_sources` counts it in the holder's own benefit unit, and the pool + takes it out of `cash_isa` or `share_like_capital` before sharing the rest. Model variables currently mapped to countable income-related Employment and Support Allowance capital. The Employment and Support Allowance Regulations 2008 treat capital as assessable unless it is disregarded under Schedule 9. diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml index ff5a7f2459..f6884f9c62 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml @@ -9,7 +9,17 @@ description: > Lifetime ISA at its surrender value: regulation 111 values capital at its current market or surrender value, and DMG 29665 counts a Lifetime ISA at 75% of its value under 60 and in full from 60. Lifetime ISAs exist from 6 - April 2017, and the list is empty before then. + April 2017, and the list counts them from then. `cash_isa_countable_capital` + and `stocks_and_shares_isa_countable_capital` are a member's cash and stocks + and shares ISA balances. An ISA has one holder, who owns its investments + beneficially (Individual Savings Account Regulations 1998 reg. 4(1B)(c) and + (6)(a)), and a child holds their own junior ISA (reg. 2B). Each counts only + while `sources` lists the household source that already holds it + (`cash_isa`, `share_like_capital`), and the household pool then leaves it + out of that source, so it counts once, in its holder's own benefit unit, and + a list without the household source counts neither. Household data with no + balances keep the whole household total in the pool. ISAs exist from 6 April + 1999 (reg. 1). metadata: label: Income-based JSA person-level capital sources period: year @@ -21,7 +31,16 @@ metadata: href: https://www.legislation.gov.uk/uksi/1996/207/regulation/111 - title: Decision Makers' Guide, Chapter 29 (Capital), para. 29665 href: https://assets.publishing.service.gov.uk/media/6894a69d3080e72710b2e237/dmg-ch-29.pdf + - title: The Individual Savings Account Regulations 1998 reg. 4 + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/4 + - title: The Individual Savings Account Regulations 1998 reg. 2B + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/2B values: 1996-10-07: [] + 1999-04-06: + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_capital 2017-04-06: - lifetime_isa_countable_capital + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_capital diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml index 163e3aa1a9..1a11620e57 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml @@ -1,6 +1,9 @@ description: > Ordinary cash ISA balances are separate from savings and are included at their accessible cash value, without a generic cash sale-cost deduction. + Where the data tie a cash ISA or stocks and shares ISA balance to a member, + `person_sources` counts it in the holder's own benefit unit, and the pool + takes it out of `cash_isa` or `share_like_capital` before sharing the rest. Model variables currently mapped to countable income-based Jobseeker's Allowance capital. The Jobseeker's Allowance Regulations 1996 treat capital as assessable unless it is disregarded under Schedule 8. The current diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml index f7d5c3e439..f95014864a 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml @@ -9,7 +9,17 @@ description: > pension-age regulations) values capital at its current market or surrender value, in the words of the DWP benefits' regulations, for which DWP guidance counts a Lifetime ISA at 75% of its value under 60 and in full from 60. - Lifetime ISAs exist from 6 April 2017, and the list is empty before then. + Lifetime ISAs exist from 6 April 2017, and the list counts them from then. + `cash_isa_countable_capital` and `stocks_and_shares_isa_countable_capital` + are a member's cash and stocks and shares ISA balances. An ISA has one + holder, who owns its investments beneficially (Individual Savings Account + Regulations 1998 reg. 4(1B)(c) and (6)(a)), and a child holds their own + junior ISA (reg. 2B). Each counts only while `sources` lists the household + source that already holds it (`cash_isa`, `share_like_capital`), and the + household pool then leaves it out of that source, so it counts once, in its + holder's own benefit unit, and a list without the household source counts + neither. Household data with no balances keep the whole household total in + the pool. ISAs exist from 6 April 1999 (reg. 1). metadata: label: Housing Benefit person-level capital sources period: year @@ -23,7 +33,15 @@ metadata: href: https://www.legislation.gov.uk/uksi/2006/214/regulation/45 - title: Decision Makers' Guide, Chapter 29 (Capital), para. 29665 (Income Support and JSA, whose valuation rule has the same words) href: https://assets.publishing.service.gov.uk/media/6894a69d3080e72710b2e237/dmg-ch-29.pdf + - title: The Individual Savings Account Regulations 1998 reg. 4 + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/4 + - title: The Individual Savings Account Regulations 1998 reg. 2B + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/2B values: - 2006-04-10: [] + 2006-04-10: + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_capital 2017-04-06: - lifetime_isa_countable_capital + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_capital diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml index eddb604c4b..99d25116f7 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml @@ -1,6 +1,9 @@ description: > Ordinary cash ISA balances are separate from savings and are included at their accessible cash value, without a generic cash sale-cost deduction. + Where the data tie a cash ISA or stocks and shares ISA balance to a member, + `person_sources` counts it in the holder's own benefit unit, and the pool + takes it out of `cash_isa` or `share_like_capital` before sharing the rest. Model variables currently mapped to countable Housing Benefit capital. The Housing Benefit Regulations 2006 treat the whole of a claimant's capital as assessable unless it is disregarded under Schedule 6. The current mappings diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml index b5042804a5..a467bb2865 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml @@ -9,7 +9,17 @@ description: > ISA at its surrender value: regulation 49 values capital at its current market or surrender value, and DMG 29665 counts a Lifetime ISA at 75% of its value under 60 and in full from 60. Lifetime ISAs exist from 6 April 2017, - and the list is empty before then. + and the list counts them from then. `cash_isa_countable_capital` and + `stocks_and_shares_isa_countable_capital` are a member's cash and stocks and + shares ISA balances. An ISA has one holder, who owns its investments + beneficially (Individual Savings Account Regulations 1998 reg. 4(1B)(c) and + (6)(a)), and a child holds their own junior ISA (reg. 2B). Each counts only + while `sources` lists the household source that already holds it + (`cash_isa`, `share_like_capital`), and the household pool then leaves it + out of that source, so it counts once, in its holder's own benefit unit, and + a list without the household source counts neither. Household data with no + balances keep the whole household total in the pool. ISAs exist from 6 April + 1999 (reg. 1). metadata: label: Income Support person-level capital sources period: year @@ -21,7 +31,15 @@ metadata: href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/49 - title: Decision Makers' Guide, Chapter 29 (Capital), para. 29665 href: https://assets.publishing.service.gov.uk/media/6894a69d3080e72710b2e237/dmg-ch-29.pdf + - title: The Individual Savings Account Regulations 1998 reg. 4 + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/4 + - title: The Individual Savings Account Regulations 1998 reg. 2B + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/2B values: - 2010-01-01: [] + 2010-01-01: + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_capital 2017-04-06: - lifetime_isa_countable_capital + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_capital diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml index 4db2982412..7b29a0ffd6 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml @@ -1,6 +1,9 @@ description: > Ordinary cash ISA balances are separate from savings and are included at their accessible cash value, without a generic cash sale-cost deduction. + Where the data tie a cash ISA or stocks and shares ISA balance to a member, + `person_sources` counts it in the holder's own benefit unit, and the pool + takes it out of `cash_isa` or `share_like_capital` before sharing the rest. Capital sources included in the Income Support capital test. `private_pension_wealth` is excluded because Schedule 10 paragraphs 23 and 23A disregard the value of the right to receive an occupational or personal diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml index 06b93fc22f..c91ff892c6 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml @@ -8,8 +8,18 @@ description: > ISA at its surrender value: regulation 19 values capital at its current market or surrender value, and DMG 84751 counts a Lifetime ISA at 75% of its value under 60 and in full from 60, so a claimant's own Lifetime ISA counts - in full. Lifetime ISAs exist from 6 April 2017, and the list is empty before - then. + in full. Lifetime ISAs exist from 6 April 2017, and the list counts them + from then. `cash_isa_countable_capital` and + `stocks_and_shares_isa_countable_capital` are a member's cash and stocks and + shares ISA balances. An ISA has one holder, who owns its investments + beneficially (Individual Savings Account Regulations 1998 reg. 4(1B)(c) and + (6)(a)), and a child holds their own junior ISA (reg. 2B). Each counts only + while `sources` lists the household source that already holds it + (`cash_isa`, `share_like_capital`), and the household pool then leaves it + out of that source, so it counts once, in its holder's own benefit unit, and + a list without the household source counts neither. Household data with no + balances keep the whole household total in the pool. ISAs exist from 6 April + 1999 (reg. 1). metadata: label: Pension Credit person-level capital sources period: year @@ -21,7 +31,15 @@ metadata: href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/19 - title: Decision Makers' Guide, Chapter 84 (Deemed weekly income from capital), para. 84751 href: https://assets.publishing.service.gov.uk/media/69d8f133daa863aa5db665a5/dmg-Ch-84.pdf + - title: The Individual Savings Account Regulations 1998 reg. 4 + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/4 + - title: The Individual Savings Account Regulations 1998 reg. 2B + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/2B values: - 2003-10-06: [] + 2003-10-06: + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_capital 2017-04-06: - lifetime_isa_countable_capital + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_capital diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml index 60c43a3138..d13dee5871 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml @@ -1,6 +1,9 @@ description: > Ordinary cash ISA balances are separate from savings and are included at their accessible cash value, without a generic cash sale-cost deduction. + Where the data tie a cash ISA or stocks and shares ISA balance to a member, + `person_sources` counts it in the holder's own benefit unit, and the pool + takes it out of `cash_isa` or `share_like_capital` before sharing the rest. PolicyEngine approximation of Pension Credit capital sources that count toward deemed income from capital, excluding the claimant's main home. `private_pension_wealth` is excluded because Schedule V paragraphs 22 and 23 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml index dc6970037a..4aa50904fc 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml @@ -4,15 +4,25 @@ description: > and partner (`is_uc_claimant`), so they need none of the household proxy that the household `sources` use, and a dependant's holding is left out: section 5 of the Welfare Reform Act 2012 tests the claimant's, or the joint - claimants' combined, capital. `lifetime_isa_countable_capital` is a - Lifetime ISA at its surrender value: regulation 49(1) values capital at its - current market or surrender value, and ADM H1659 counts a Lifetime ISA at - 75% of its value under 60 and in full from 60. Lifetime ISAs exist from 6 - April 2017, and the list is empty before then. A nonnegative financial - observation (benunit_reported_capital) replaces these covered financial - sources to avoid counting a Lifetime ISA twice. A complete UC override - replaces them together with property and the household financial proxy, - before the existing regulation 77 company substitution. + claimants' combined, capital. `lifetime_isa_countable_capital` is a Lifetime + ISA at its surrender value: regulation 49(1) values capital at its current + market or surrender value, and ADM H1659 counts a Lifetime ISA at 75% of its + value under 60 and in full from 60. Lifetime ISAs exist from 6 April 2017, + and the list counts them from then. A nonnegative financial observation + (benunit_reported_capital) replaces these covered financial sources to avoid + counting an ISA twice. A complete UC override replaces them together with + property and the household financial proxy, before the existing regulation + 77 company substitution. `cash_isa_countable_capital` and + `stocks_and_shares_isa_countable_capital` are a member's cash and stocks and + shares ISA balances. An ISA has one holder, who owns its investments + beneficially (Individual Savings Account Regulations 1998 reg. 4(1B)(c) and + (6)(a)), and a child holds their own junior ISA (reg. 2B). Each counts only + while `sources` lists the household source that already holds it + (`cash_isa`, `share_like_capital`), and the household pool then leaves it + out of that source, so it counts once, in its holder's own benefit unit, and + a list without the household source counts neither. Household data with no + balances keep the whole household total in the pool. ISAs exist from 6 April + 1999 (reg. 1). metadata: label: Universal Credit person-level capital sources period: year @@ -24,7 +34,15 @@ metadata: href: https://www.legislation.gov.uk/uksi/2013/376/regulation/49/1 - title: Advice for Decision Making, Chapter H1 (Capital), para. H1659 href: https://assets.publishing.service.gov.uk/media/693ace15c72b0f8ccf33d609/adm-ch-H1.pdf + - title: The Individual Savings Account Regulations 1998 reg. 4 + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/4 + - title: The Individual Savings Account Regulations 1998 reg. 2B + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/2B values: - 2013-04-29: [] + 2013-04-29: + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_capital 2017-04-06: - lifetime_isa_countable_capital + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_capital diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml index 9e3e5dbcbc..92ec3339ac 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml @@ -1,6 +1,9 @@ description: > Ordinary cash ISA balances are separate from savings and are included at their accessible cash value, without a generic cash sale-cost deduction. + Where the data tie a cash ISA or stocks and shares ISA balance to a member, + `person_sources` counts it in the holder's own benefit unit, and the pool + takes it out of `cash_isa` or `share_like_capital` before sharing the rest. Household financial proxies for Universal Credit: savings and share-like financial wealth. `share_like_capital` is `corporate_wealth` as supplied, or the sum of its itemised components (`directly_held_shares`, diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml new file mode 100644 index 0000000000..f45f5620e9 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml @@ -0,0 +1,374 @@ +# Cash and stocks and shares ISA balances count in the holder's own benefit +# unit (#2729). An ISA has one holder, who owns its investments beneficially +# (ISA Regulations 1998 reg 4(1B)(c) and (6)(a)), and a child holds their own +# junior ISA (reg 2B). The means tests count the claimant's and partner's +# capital (WRA 2012 s 5; HB Regs 2006 reg 25; SPCA 2002 s 5; IS Regs 1987 +# reg 23; JSA Regs 1996 reg 88; ESA Regs 2008 reg 83), and not a child's or +# young person's (HB reg 25(3) and 45; IS reg 23(2); JSA reg 88(2); ESA reg +# 83(2)). The household pool shares the rest of the household's capital. + +- name: Case 1, a lodger's cash ISA stays out of the main benefit unit's capital. + period: 2025 + absolute_error_margin: 0 + input: + people: + tenant: + age: 30 + lodger: + age: 30 + cash_isa_balance: 10_000 + benunits: + tenant_unit: + members: tenant + would_claim_uc: false # not on Universal Credit, which passports HB + lodger_unit: + members: lodger + would_claim_uc: false + households: + household: + members: [tenant, lodger] + savings: 2_000 + output: + cash_isa: 10_000 + cash_isa_countable_capital: [0, 10_000] + # The pool is savings 2,000 plus cash_isa 10,000 less the lodger's + # 10,000, shared 1,000 each by the two claimants; the lodger adds the + # 10,000 ISA. + uc_assessable_capital: [1_000, 11_000] + housing_benefit_assessable_capital: [1_000, 11_000] + +- name: Case 2, an adult child's stocks and shares ISA stays out of the parents' capital. + period: 2025 + absolute_error_margin: 0 + input: + people: + parent_1: + age: 50 + parent_2: + age: 48 + adult_child: + age: 25 + stocks_and_shares_isa_balance: 6_000 + benunits: + parents: + members: [parent_1, parent_2] + would_claim_uc: false + child: + members: adult_child + would_claim_uc: false + households: + household: + members: [parent_1, parent_2, adult_child] + savings: 9_000 + output: + # share_like_capital is the itemised 6,000, all of it the child's ISA. + share_like_capital: 6_000 + stocks_and_shares_isa_countable_capital: [0, 0, 6_000] + # The pool is savings 9,000 plus share_like_capital 6,000 less the + # child's 6,000. Three claimants share it: 6,000 to the parents and + # 3,000 to the child, who adds the 6,000 ISA. + uc_assessable_capital: [6_000, 9_000] + housing_benefit_assessable_capital: [6_000, 9_000] + +- name: Case 3, an adult child's ISA inside corporate wealth stays out of the parents' capital. + period: 2025 + absolute_error_margin: 0 + input: + people: + parent_1: + age: 50 + parent_2: + age: 48 + adult_child: + age: 25 + stocks_and_shares_isa_balance: 6_000 + benunits: + parents: + members: [parent_1, parent_2] + would_claim_uc: false + child: + members: adult_child + would_claim_uc: false + households: + household: + members: [parent_1, parent_2, adult_child] + # 3,000 of directly held shares and the child's 6,000 ISA. + corporate_wealth: 9_000 + output: + share_like_capital: 9_000 + # The pool is corporate wealth 9,000 less the child's 6,000 ISA: 3,000, + # shared 2,000 to the parents and 1,000 to the child. + uc_assessable_capital: [2_000, 7_000] + housing_benefit_assessable_capital: [2_000, 7_000] + +- name: Case 4, a parent's Income Support claim does not count an adult child's cash ISA. + period: 2025 + absolute_error_margin: 0 + input: + people: + parent: + age: 45 + income_support_reported: true + child: + age: 4 + adult_child: + age: 22 + cash_isa_balance: 5_000 + benunits: + parent_and_child: + members: [parent, child] + would_claim_IS: true + adult_child: + members: adult_child + households: + household: + members: [parent, child, adult_child] + savings: 2_000 + output: + # The claiming benunit takes the whole pool, savings 2,000 plus cash_isa + # 5,000 less the child's 5,000; the ISA stays with its holder. + income_support_assessable_capital: [2_000, 5_000] + +- name: Case 5, a parent's income-based JSA claim does not count an adult child's cash ISA. + period: 2025 + absolute_error_margin: 0 + input: + people: + parent: + age: 45 + jsa_income_reported: 3_000 + adult_child: + age: 22 + cash_isa_balance: 5_000 + benunits: + parent: + members: parent + adult_child: + members: adult_child + households: + household: + members: [parent, adult_child] + savings: 2_000 + output: + jsa_income_assessable_capital: [2_000, 5_000] + +- name: Case 6, a parent's income-related ESA claim does not count an adult child's stocks and shares ISA. + period: 2025 + absolute_error_margin: 0 + input: + people: + parent: + age: 45 + esa_income_reported: 3_000 + adult_child: + age: 22 + stocks_and_shares_isa_balance: 5_000 + benunits: + parent: + members: parent + would_claim_uc: false + adult_child: + members: adult_child + households: + household: + members: [parent, adult_child] + savings: 2_000 + output: + esa_income_assessable_capital: [2_000, 5_000] + +- name: Case 7, a pensioner couple's Pension Credit counts their own ISAs and not a lodger's. + period: 2025 + absolute_error_margin: 0 + input: + people: + pensioner_1: + age: 70 + cash_isa_balance: 3_000 + pensioner_2: + age: 68 + stocks_and_shares_isa_balance: 2_000 + lodger: + age: 30 + cash_isa_balance: 6_000 + benunits: + pensioners: + members: [pensioner_1, pensioner_2] + lodger: + members: lodger + households: + household: + members: [pensioner_1, pensioner_2, lodger] + savings: 4_000 + output: + # The pool is savings 4,000 plus cash_isa 9,000 and share_like_capital + # 2,000 less all three balances: 4,000, shared by the two pension-age + # adults. The couple add their own 3,000 and 2,000. + pension_credit_assessable_capital: [9_000, 0] + +- name: Case 8, a dependent child's junior ISA counts in no benefit unit. + period: 2025 + absolute_error_margin: 0 + input: + people: + parent: + age: 40 + child: + age: 10 + cash_isa_balance: 3_000 + stocks_and_shares_isa_balance: 1_500 + benunits: + family: + members: [parent, child] + would_claim_uc: false + households: + household: + members: [parent, child] + savings: 1_000 + output: + is_claimant_or_partner: [true, false] + # The pool is savings 1,000 once the child's 3,000 and 1,500 leave it. + uc_assessable_capital: 1_000 + housing_benefit_assessable_capital: 1_000 + income_support_assessable_capital: 1_000 + jsa_income_assessable_capital: 1_000 + esa_income_assessable_capital: 1_000 + +- name: Case 9, a couple's own ISAs count together in their benefit unit. + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 35 + cash_isa_balance: 4_000 + partner: + age: 33 + stocks_and_shares_isa_balance: 3_000 + lodger: + age: 30 + benunits: + couple: + members: [claimant, partner] + would_claim_uc: false + lodger: + members: lodger + would_claim_uc: false + households: + household: + members: [claimant, partner, lodger] + savings: 6_000 + output: + # Joint claimants' combined capital (WRA 2012 s 5(2)(a)): the pool of + # savings 6,000, shared 4,000 and 2,000, plus the couple's 4,000 and + # 3,000. + uc_assessable_capital: [11_000, 2_000] + housing_benefit_assessable_capital: [11_000, 2_000] + +- name: Case 10, a household total above the balances keeps the rest in the pool. + period: 2025 + absolute_error_margin: 0 + input: + people: + tenant: + age: 30 + lodger: + age: 30 + cash_isa_balance: 10_000 + benunits: + tenant_unit: + members: tenant + would_claim_uc: false + lodger_unit: + members: lodger + would_claim_uc: false + households: + household: + members: [tenant, lodger] + cash_isa: 15_000 + output: + cash_isa: 15_000 + cash_isa_countable_capital: [0, 10_000] + # The 5,000 of the total no balance explains is shared 2,500 each. + uc_assessable_capital: [2_500, 12_500] + housing_benefit_assessable_capital: [2_500, 12_500] + +- name: Case 11, balances above the household total are scaled down to it. + period: 2025 + absolute_error_margin: 0 + input: + people: + tenant: + age: 30 + cash_isa_balance: 3_000 + lodger: + age: 30 + cash_isa_balance: 9_000 + benunits: + tenant_unit: + members: tenant + would_claim_uc: false + lodger_unit: + members: lodger + would_claim_uc: false + households: + household: + members: [tenant, lodger] + cash_isa: 6_000 + output: + # The balances add up to 12,000, twice the 6,000 total, so each counts + # half and the pool keeps nothing. + cash_isa_countable_capital: [1_500, 4_500] + uc_assessable_capital: [1_500, 4_500] + housing_benefit_assessable_capital: [1_500, 4_500] + +- name: Case 12, a Universal Credit report covers its own claimant's ISA. + period: 2025 + absolute_error_margin: 0 + input: + people: + parent_1: + age: 50 + parent_2: + age: 48 + adult_child: + age: 25 + cash_isa_balance: 10_000 + benunits: + parents: + members: [parent_1, parent_2] + child: + members: adult_child + # The child's own financial capital: the 10,000 ISA and 2,000 of + # the household's savings. + benunit_reported_capital: 12_000 + households: + household: + members: [parent_1, parent_2, adult_child] + savings: 9_000 + output: + # The report consumes 12,000 of the 19,000 pool, ISA included, and the + # parents take the other 7,000. + uc_assessable_capital: [7_000, 12_000] + +- name: Case 13, a stocks and shares ISA above the UC capital limit counts for its holder only. + period: 2025 + absolute_error_margin: 0 + input: + people: + tenant: + age: 30 + lodger: + age: 30 + stocks_and_shares_isa_balance: 34_000 + benunits: + tenant_unit: + members: tenant + lodger_unit: + members: lodger + households: + household: + members: [tenant, lodger] + output: + # Shared by the two claimants, the ISA put 17,000 in each unit, above + # reg 18's 16,000 limit. Now only its holder's unit counts it. + uc_assessable_capital: [0, 34_000] diff --git a/policyengine_uk/variables/gov/dwp/_person_capital_pool.py b/policyengine_uk/variables/gov/dwp/_person_capital_pool.py new file mode 100644 index 0000000000..c258d273ce --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/_person_capital_pool.py @@ -0,0 +1,70 @@ +"""Person-level capital that a household capital source already holds.""" + +from policyengine_uk.model_api import * + +# Each person-level capital source whose holdings sit inside a household +# capital source, with that source. A cash ISA balance is part of cash_isa, +# and a stocks and shares ISA balance part of share_like_capital (through +# stocks_and_shares_isa, inside corporate_wealth or the itemised sum). A +# person source with no entry here, such as a Lifetime ISA, sits in none. +HOUSEHOLD_SOURCE_OF = { + "cash_isa_countable_capital": "cash_isa", + "stocks_and_shares_isa_countable_capital": "share_like_capital", +} + + +def counted_person_sources(capital) -> list: + """The person sources in ``capital.person_sources`` that count. + + A person source that sits inside a household source counts only while + ``capital.sources`` lists that household source: it moves the part of the + source the data tie to a person into the holder's own benefit unit, so a + list without the household source counts neither. + """ + sources = set(capital.sources) + return [ + source + for source in capital.person_sources + if source not in HOUSEHOLD_SOURCE_OF or HOUSEHOLD_SOURCE_OF[source] in sources + ] + + +def pooled_person_capital(benunit, source, period, capital, leaves_pool=None): + """The part of the household ``source`` that counted person sources hold. + + The household sum, for each benefit unit, of the counted person sources + in ``capital.person_sources`` that sit inside ``source``, over the + members ``leaves_pool`` selects (all members by default). The household + pool takes it out of ``source`` before sharing the rest between benefit + units, so each of those pounds counts only in its holder's own unit. + """ + person = benunit.members + amount = 0 + for person_source in counted_person_sources(capital): + if HOUSEHOLD_SOURCE_OF.get(person_source) == source: + held = person(person_source, period) + if leaves_pool is not None: + held = held * leaves_pool + amount = amount + person.household.sum(held) + if isinstance(amount, int): + return 0 + return benunit.max(amount) + + +def bounded_person_holding(person, balance, household_total): + """A person's ``balance``, scaled so the household's never exceed its total. + + ``household_total`` is the household's total for each member. A negative + or missing balance holds nothing, and an infinite one the whole total. + Where the members' balances add up to more than the nonnegative total, + each is scaled down by the same proportion, so the members together hold + the total in proportion to their balances. Otherwise each holds its + balance, and the rest of the total is held by no one the data name. + """ + total = max_(0, household_total) + finite = np.where(np.isfinite(balance), balance, np.where(balance > 0, total, 0)) + held = max_(0, finite) + household_held = person.household.sum(held) + over = household_held > total + scale = where(over, total / where(over, household_held, 1), 1) + return held * scale diff --git a/policyengine_uk/variables/gov/dwp/cash_isa_countable_capital.py b/policyengine_uk/variables/gov/dwp/cash_isa_countable_capital.py new file mode 100644 index 0000000000..9d9ab2dc1a --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/cash_isa_countable_capital.py @@ -0,0 +1,37 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp._person_capital_pool import ( + bounded_person_holding, +) + + +class cash_isa_countable_capital(Variable): + label = "Cash ISA value counted as means-tested capital" + documentation = ( + "The person's cash ISA balance (cash_isa_balance) as the means tests " + "count it, in the holder's own benefit unit. An ISA has one holder, " + "who owns its investments beneficially, and a child holds their own " + "junior ISA. The household's cash_isa already counts the balance, so " + "each means test takes it out of the household pool it shares between " + "benefit units. Where the household's balances add up to more than " + "cash_isa, the total the model holds for the household, each is " + "scaled down by the same proportion, so no household counts more cash " + "ISA than cash_isa. Where they add up to less, the rest of cash_isa " + "stays in the pool. A household with no balances keeps all of " + "cash_isa in the pool." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = STOCK + reference = [ + "https://www.legislation.gov.uk/uksi/1998/1870/regulation/4", + "https://www.legislation.gov.uk/uksi/1998/1870/regulation/2B", + ] + + def formula(person, period, parameters): + return bounded_person_holding( + person, + person("cash_isa_balance", period), + person.household("cash_isa", period), + ) 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 5487fc42a9..438803cf36 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,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp._person_capital_pool import ( + counted_person_sources, + pooled_person_capital, +) class esa_income_assessable_capital(Variable): @@ -25,12 +29,13 @@ 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 = sum( - benunit.max(person.household(source, period)) for source in sources + benunit.max(person.household(source, period)) + - pooled_person_capital(benunit, source, period, ESA.capital) + for source in ESA.capital.sources ) # Regulation 83(2) excludes children's and young persons' capital. # The claimant/partner weights approximate otherwise unobserved ownership. @@ -51,7 +56,7 @@ 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) - for source in ESA.capital.person_sources + for source in counted_person_sources(ESA.capital) ) 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 2af26611c4..cc7e3e59b7 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,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp._person_capital_pool import ( + counted_person_sources, + pooled_person_capital, +) class housing_benefit_assessable_capital(Variable): @@ -42,11 +46,15 @@ def formula(benunit, period, parameters): "housing_benefit_pension_age_regulations_apply", period ) p = parameters(period).gov.dwp.housing_benefit.means_test.capital - household_capital = sum(household(source, period) for source in p.sources) + household_capital = sum( + household(source, period) + - pooled_person_capital(benunit, source, period, p) + for source in p.sources + ) claimant_or_partner = person("is_claimant_or_partner", period) person_capital = sum( benunit.sum(person(source, period) * claimant_or_partner) - for source in p.person_sources + for source in counted_person_sources(p) ) 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 55c6f2aad1..9233738b42 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,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp._person_capital_pool import ( + counted_person_sources, + pooled_person_capital, +) class income_support_assessable_capital(Variable): @@ -24,7 +28,7 @@ class income_support_assessable_capital(Variable): def formula(benunit, period, parameters): IS = parameters(period).gov.dwp.income_support - sources = IS.means_test.capital.sources + capital = IS.means_test.capital person = benunit.members would_claim_is = benunit("would_claim_IS", period) @@ -35,7 +39,9 @@ def formula(benunit, period, parameters): # weights. Regulation 23 excludes children's and young persons' capital; # the household allocation itself is a modelling convention. household_capital = sum( - benunit.max(person.household(source, period)) for source in sources + benunit.max(person.household(source, period)) + - pooled_person_capital(benunit, source, period, capital) + for source in capital.sources ) benunit_claimants_and_partners = add( benunit, period, ["is_claimant_or_partner"] @@ -57,7 +63,7 @@ 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) - for source in IS.means_test.capital.person_sources + for source in counted_person_sources(capital) ) 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 ec3a6631ee..e90b05f219 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,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp._person_capital_pool import ( + counted_person_sources, + pooled_person_capital, +) class jsa_income_assessable_capital(Variable): @@ -25,12 +29,13 @@ 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 = sum( - benunit.max(person.household(source, period)) for source in sources + benunit.max(person.household(source, period)) + - pooled_person_capital(benunit, source, period, JSA.capital) + for source in JSA.capital.sources ) # Regulation 88(2) excludes children's and young persons' capital. # The claimant/partner weights approximate otherwise unobserved ownership. @@ -51,7 +56,7 @@ 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) - for source in JSA.capital.person_sources + for source in counted_person_sources(JSA.capital) ) household_share = where( household_reporting_claimants > 0, claiming_proxy, fallback_proxy 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 5803c56af2..2ca50c8cb4 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,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp._person_capital_pool import ( + counted_person_sources, + pooled_person_capital, +) class pension_credit_assessable_capital(Variable): @@ -28,11 +32,15 @@ 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 = sum( + household(source, period) + - pooled_person_capital(benunit, source, period, p) + for source in p.sources + ) claimant_or_partner = person("is_claimant_or_partner", period) person_capital = sum( benunit.sum(person(source, period) * claimant_or_partner) - for source in p.person_sources + for source in counted_person_sources(p) ) any_pension_age = benunit.any( person("has_attained_state_pension_credit_qualifying_age", period) diff --git a/policyengine_uk/variables/gov/dwp/stocks_and_shares_isa_countable_capital.py b/policyengine_uk/variables/gov/dwp/stocks_and_shares_isa_countable_capital.py new file mode 100644 index 0000000000..9092720f57 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/stocks_and_shares_isa_countable_capital.py @@ -0,0 +1,43 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp._person_capital_pool import ( + bounded_person_holding, +) + + +class stocks_and_shares_isa_countable_capital(Variable): + label = "Stocks and shares ISA value counted as means-tested capital" + documentation = ( + "The person's stocks and shares ISA balance " + "(stocks_and_shares_isa_balance) as the means tests count it, in the " + "holder's own benefit unit. An ISA has one holder, who owns its " + "investments beneficially, and a child holds their own junior ISA. " + "The household's share_like_capital already counts the balance, " + "through stocks_and_shares_isa within corporate_wealth or the " + "itemised sum, so each means test takes it out of the household pool " + "it shares between benefit units. Where the household's balances add " + "up to more than the smaller of stocks_and_shares_isa and " + "share_like_capital, each is scaled down by the same proportion, so " + "no household counts more stocks and shares ISA than it holds. Where " + "they add up to less, the rest stays in the pool. A household with no " + "balances keeps all of share_like_capital in the pool." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = STOCK + reference = [ + "https://www.legislation.gov.uk/uksi/1998/1870/regulation/4", + "https://www.legislation.gov.uk/uksi/1998/1870/regulation/2B", + ] + + def formula(person, period, parameters): + household_total = min_( + person.household("stocks_and_shares_isa", period), + person.household("share_like_capital", period), + ) + return bounded_person_holding( + person, + person("stocks_and_shares_isa_balance", period), + household_total, + ) 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 564f64588d..d9050d037e 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,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp._person_capital_pool import ( + counted_person_sources, + pooled_person_capital, +) class uc_assessable_capital(Variable): @@ -41,14 +45,24 @@ class uc_assessable_capital(Variable): def formula(benunit, period, parameters): household = benunit.household p = parameters(period).gov.dwp.universal_credit.means_test.capital + person = benunit.members + claimant_or_partner = person("is_uc_claimant", period) + # A report or complete override already covers its claimant's and + # partner's ISAs, as it does their company holdings, and consumes + # them from the pool. Every other person's ISA leaves the pool here. + covered = claimant_or_partner & ( + (person.benunit("benunit_reported_capital", period) >= 0) + | (person.benunit("uc_reported_capital", period) >= 0) + ) household_financial_capital = sum( - household(source, period) for source in p.sources + household(source, period) + - pooled_person_capital(benunit, source, period, p, ~covered) + for source in p.sources ) - claimant_or_partner = benunit.members("is_uc_claimant", period) benunit_claimants = benunit.sum(claimant_or_partner) person_capital = sum( - benunit.sum(benunit.members(source, period) * claimant_or_partner) - for source in p.person_sources + benunit.sum(person(source, period) * claimant_or_partner) + for source in counted_person_sources(p) ) financial_report = benunit("benunit_reported_capital", period) complete_override = benunit("uc_reported_capital", period) From cb78a0095a58d0dc97cd24ee2055cc6e301c45d6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 01:25:39 -0500 Subject: [PATCH 02/27] Test that each ISA pound counts once, in its holder's benefit unit Adds the Python tests for #2729: household-only data keep today's capital in 2025, 2032, 2033 and 2040; a stored total and smaller balances add up to the total; balances alone and totals alone agree for one benefit unit; a pound counts once across the means tests; and reforms that edit either list keep the two consistent. Documents the change and rounds the pool's household sums as Core stores a total. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2729.fixed.md | 1 + .../isa-inputs-and-capital-flows.md | 18 +- .../tests/test_person_isa_capital.py | 509 ++++++++++++++++++ .../variables/gov/dwp/_person_capital_pool.py | 12 +- .../gov/dwp/esa_income_assessable_capital.py | 4 +- .../housing_benefit_assessable_capital.py | 4 +- .../dwp/income_support_assessable_capital.py | 4 +- .../gov/dwp/jsa_income_assessable_capital.py | 4 +- .../pension_credit_assessable_capital.py | 4 +- .../universal_credit/uc_assessable_capital.py | 3 + .../variables/input/cash_isa_balance.py | 4 +- .../input/stocks_and_shares_isa_balance.py | 5 +- 12 files changed, 560 insertions(+), 12 deletions(-) create mode 100644 changelog.d/2729.fixed.md create mode 100644 policyengine_uk/tests/test_person_isa_capital.py diff --git a/changelog.d/2729.fixed.md b/changelog.d/2729.fixed.md new file mode 100644 index 0000000000..155c9d4591 --- /dev/null +++ b/changelog.d/2729.fixed.md @@ -0,0 +1 @@ +Universal Credit, Housing Benefit, Pension Credit, Income Support, income-based JSA and income-related ESA count each person's cash ISA and stocks and shares ISA balances in their own benefit unit, so a lodger's or an adult child's ISA no longer adds to the main family's capital. diff --git a/docs/book/assumptions/isa-inputs-and-capital-flows.md b/docs/book/assumptions/isa-inputs-and-capital-flows.md index ce1d7c2c18..7ec3e93c71 100644 --- a/docs/book/assumptions/isa-inputs-and-capital-flows.md +++ b/docs/book/assumptions/isa-inputs-and-capital-flows.md @@ -107,9 +107,21 @@ respectively, including years after the dataset projection ends, unless the dataset holds the person balances for a later year than the total, or a reform changes what the total sums or stops a person balance reading its inputs. A household input in a situation applies only to its own year; the person -balances carry the variable-level uprating to later years. The means tests read -the household totals, so they pool a person's balance with the household's -other financial capital rather than attribute it to their own benefit unit. +balances carry the variable-level uprating to later years. + +Each means test counts a member's cash and stocks and shares ISA balances in +the member's own benefit unit, through `cash_isa_countable_capital` and +`stocks_and_shares_isa_countable_capital` in its `person_sources`, and only for +the claimant and partner. A dependant's balance, a child's junior ISA among +them, counts in no benefit unit. The household pool leaves those balances out +of `cash_isa` and `share_like_capital` and shares the rest between benefit +units. Where the balances add up to less than the total the model holds for +the year, the rest stays in the pool. Where they add up to more, each counts in +proportion, so the household counts no more than that total. Data that store +only household totals hold no balances, so the pool keeps the whole total, as +before. A balance counts only while the programme's `sources` lists the +household source that holds it, so a reform that drops `cash_isa` or +`share_like_capital` from `sources` drops the balances with it. Ordinary accessible cash ISA balances enter the capital sources for Universal Credit, Housing Benefit, Pension Credit, Income Support, income-based JSA and diff --git a/policyengine_uk/tests/test_person_isa_capital.py b/policyengine_uk/tests/test_person_isa_capital.py new file mode 100644 index 0000000000..5915dfd6d3 --- /dev/null +++ b/policyengine_uk/tests/test_person_isa_capital.py @@ -0,0 +1,509 @@ +"""Person cash and stocks and shares ISAs count in the holder's benefit unit (#2729). + +Each means test counts cash_isa_countable_capital and +stocks_and_shares_isa_countable_capital through person_sources, for the +claimant and partner, and takes them out of the household pool (cash_isa and +share_like_capital) before sharing the rest between benefit units. + +Invariants, for any nonnegative amounts: + +1. A pound counts once. Where the pool is shared by claimant weights (or, for + Pension Credit, pension-age adults), the capital the household's benefit + units count, plus the ISAs of members no unit counts (dependants, and for + Pension Credit holders outside a pension-age unit), equals savings, + cash_isa and share_like_capital together. +2. The household counts min(sum of balances, total) of each ISA kind, each + member at most their balance, and members over a total in proportion to + their balances. +3. With no person balances, every means test gives what the lists without + the ISA person sources give, in 2025, 2032, 2033 and 2040. +4. Differential: person balances alone and the household totals alone give + the same capital to a one-benefit-unit household. +5. A dataset that stores a total and smaller balances counts the balances in + their holders' units and the rest in the pool, adding up to the stored + total #2781 keeps, in every year. +6. A reform that drops the household source drops the person source with it, + and one that empties person_sources returns the ISAs to the pool. +""" + +import numpy as np +import pandas as pd +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.data.dataset_schema import UKSingleYearDataset +from policyengine_uk.data.economic_assumptions import PROJECTION_END_YEAR +from policyengine_uk.system import system +from policyengine_uk.variables.gov.dwp._person_capital_pool import ( + HOUSEHOLD_SOURCE_OF, +) + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2025 +YEARS = (YEAR, PROJECTION_END_YEAR, PROJECTION_END_YEAR + 1, 2040) +CAPITAL = { + "uc_assessable_capital": "gov.dwp.universal_credit.means_test.capital", + "housing_benefit_assessable_capital": "gov.dwp.housing_benefit.means_test.capital", + "pension_credit_assessable_capital": "gov.dwp.pension_credit.income.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", +} +MEANS_TEST_CAPITAL = (*CAPITAL, "council_tax_reduction_assessable_capital") +ISA_SOURCES = tuple(HOUSEHOLD_SOURCE_OF) +BALANCE = { + "cash_isa_countable_capital": "cash_isa_balance", + "stocks_and_shares_isa_countable_capital": "stocks_and_shares_isa_balance", +} +# The person_sources lists before #2729: the Lifetime ISA alone. +WITHOUT_ISA_SOURCES = { + f"{path}.person_sources": { + "2017-04-06.2100-12-31": ["lifetime_isa_countable_capital"] + } + for path in CAPITAL.values() +} +SETTINGS = settings( + max_examples=6, + deadline=None, + derandomize=True, + database=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def _node(path): + node = system.parameters + for part in path.split("."): + node = getattr(node, part) + return node + + +def _dataset(households, year=YEAR, claims=True): + """A dataset with one household per entry. + + Each household is a dict with ``units``, a list of benefit units, each a + list of people as ``(age, person columns)``, and household columns. + Without ``claims``, no benefit unit claims Universal Credit or Income + Support, so Housing Benefit assesses capital rather than passporting it. + """ + people, benunits = [], [] + for h, household in enumerate(households, start=1): + for unit in household["units"]: + benunits.append(len(benunits) + 1) + for age, columns in unit: + people.append( + { + "person_id": len(people) + 1, + "person_benunit_id": benunits[-1], + "person_household_id": h, + "age": age, + **columns, + } + ) + people = pd.DataFrame(people).fillna(0.0) + columns = sorted({name for h in households for name in h if name != "units"}) + count = len(households) + benunit = pd.DataFrame({"benunit_id": benunits}) + if not claims: + benunit["would_claim_uc"] = False + benunit["would_claim_IS"] = False + return UKSingleYearDataset( + person=people, + benunit=benunit, + household=pd.DataFrame( + { + "household_id": range(1, count + 1), + "household_weight": [1.0] * count, + "region": ["LONDON"] * count, + "tenure_type": ["RENT_PRIVATELY"] * count, + "council_tax": [1_500.0] * count, + "rent": [12_000.0] * count, + **{ + name: [float(h.get(name, 0.0)) for h in households] + for name in columns + }, + } + ), + fiscal_year=year, + ) + + +# A couple and a lodger; pensioners and their adult son; a parent and child. +HOUSEHOLD_ONLY = [ + { + "units": [[(30, {}), (28, {})], [(40, {})]], + "savings": 3_000, + "cash_isa": 4_000, + "stocks_and_shares_isa": 2_000, + "corporate_wealth": 5_000, + }, + { + "units": [[(70, {}), (68, {})], [(30, {})]], + "savings": 12_000, + "cash_isa": 6_000, + }, + { + "units": [[(35, {}), (8, {})]], + "savings": 500, + "cash_isa": 1_000, + }, +] + + +def test_household_only_data_keep_todays_capital(): + """Invariant 3: no balances leave the household totals in the pool.""" + data = _dataset(HOUSEHOLD_ONLY) + simulation = Simulation(dataset=data) + before = Simulation(dataset=data, reform=WITHOUT_ISA_SOURCES) + for year in YEARS: + for name in ISA_SOURCES: + assert not simulation.calculate(name, year).any(), (name, year) + for name in MEANS_TEST_CAPITAL: + assert np.array_equal( + simulation.calculate(name, year), before.calculate(name, year) + ), (name, year) + # The first household's pool: savings 3,000, cash_isa 4,000 and + # corporate_wealth 5,000 (which holds the 2,000 ISA), shared by three + # claimants, two of them the couple. + uc = simulation.calculate("uc_assessable_capital", YEAR) + assert list(uc[:2]) == [8_000, 4_000] + + +def test_both_forms_in_one_dataset_add_up_to_the_stored_total(): + """Invariant 5: a stored total above the balances keeps the rest pooled.""" + households = [ + { + "units": [ + [(30, {"cash_isa_balance": 0.0}), (28, {})], + [(40, {"cash_isa_balance": 10_000.0})], + ], + "savings": 4_000, + "cash_isa": 15_000, + }, + # The dataset's cash_isa column stores 0 here, and #2781 keeps a + # stored zero over the same year's balance, so the balance counts for + # nothing. + { + "units": [[(35, {"cash_isa_balance": 2_000.0})], [(45, {})]], + "savings": 1_000, + }, + ] + simulation = Simulation(dataset=_dataset(households, claims=False)) + benunit_household = np.array([0, 0, 1, 1]) + for year in YEARS: + balances = simulation.calculate("cash_isa_balance", year) + counted = simulation.calculate("cash_isa_countable_capital", year) + assert counted == pytest.approx(balances * [1, 1, 1, 0, 0], rel=1e-6) + holdings = simulation.calculate("cash_isa", year) + simulation.calculate( + "savings", year + ) + uc = simulation.calculate("uc_assessable_capital", year) + hb = simulation.calculate("housing_benefit_assessable_capital", year) + for capital in (uc, hb): + by_household = np.bincount(benunit_household, weights=capital) + assert by_household == pytest.approx(holdings, rel=1e-6), year + # The lodger counts their own balance on top of a third of the pool. + pool = holdings[0] - counted[2] + assert uc[1] == pytest.approx(pool / 3 + counted[2], rel=1e-6), year + assert uc[0] == pytest.approx(2 * pool / 3, rel=1e-6), year + # #2781 keeps the stored 15,000: savings 4,000 and its unexplained 5,000 + # make a pool of 9,000, shared 6,000 and 3,000. The second household's + # savings are its whole pool. + assert list(simulation.calculate("cash_isa", YEAR)) == [15_000, 0] + assert list(simulation.calculate("uc_assessable_capital", YEAR)) == [ + 6_000, + 13_000, + 500, + 500, + ] + + +money = st.integers(min_value=0, max_value=2_000_000).map(lambda pence: pence / 100) +# One benefit unit: a claimant, perhaps a partner, each with a cash and a +# stocks and shares ISA, and a child; with savings and other shares. +ONE_UNIT_HOUSEHOLDS = st.lists( + st.tuples(st.lists(st.tuples(money, money), min_size=1, max_size=2), money, money), + min_size=1, + max_size=4, +) +DATASET_SETTINGS = settings(SETTINGS, max_examples=3) + + +@DATASET_SETTINGS +@given(households=ONE_UNIT_HOUSEHOLDS) +def test_balances_alone_and_totals_alone_give_one_unit_the_same_capital( + households, +): + """Invariant 4, differential: the two forms of the same holdings.""" + balances, totals = [], [] + for adults, other_shares, savings in households: + shares = sum(amounts[1] for amounts in adults) + common = {"savings": savings, "corporate_wealth": other_shares + shares} + people = [ + (40 + i, {"cash_isa_balance": c, "stocks_and_shares_isa_balance": s}) + for i, (c, s) in enumerate(adults) + ] + balances.append({"units": [people + [(8, {})]], **common}) + totals.append( + { + "units": [[(40 + i, {}) for i in range(len(adults))] + [(8, {})]], + "cash_isa": sum(amounts[0] for amounts in adults), + "stocks_and_shares_isa": shares, + **common, + } + ) + balances_only = Simulation(dataset=_dataset(balances)) + totals_only = Simulation(dataset=_dataset(totals)) + for year in (YEAR, PROJECTION_END_YEAR + 1): + for name in MEANS_TEST_CAPITAL: + assert balances_only.calculate(name, year) == pytest.approx( + totals_only.calculate(name, year), rel=1e-6, abs=0.01 + ), (name, year) + + +def _situation(units, assets, claim_uc=False): + """One household of the given benefit units of ``(age, cash, shares)``.""" + people, benunits = {}, {} + for u, unit in enumerate(units): + members = [] + for i, (age, cash, shares) in enumerate(unit): + name = f"p{u}_{i}" + members.append(name) + people[name] = { + "age": {YEAR: age}, + "cash_isa_balance": {YEAR: cash}, + "stocks_and_shares_isa_balance": {YEAR: shares}, + } + benunits[f"b{u}"] = { + "members": members, + "would_claim_uc": {YEAR: claim_uc}, + "would_claim_IS": {YEAR: False}, + } + household = {"members": list(people)} + household |= {name: {YEAR: value} for name, value in assets.items()} + return {"people": people, "benunits": benunits, "households": {"h": household}} + + +def _unit(adult_ages): + """A benefit unit of one or two adults and up to two children.""" + person = st.tuples(money, money) + adults = st.lists(st.sampled_from(adult_ages), min_size=1, max_size=2) + children = st.lists(st.just(10), min_size=0, max_size=2) + return st.tuples(adults, children).flatmap( + lambda ages: st.tuples( + *[st.tuples(st.just(age), money, money) for age in ages[0] + ages[1]] + ) + ) + + +HOLDINGS = st.fixed_dictionaries( + { + "savings": money, + "other_shares": money, + "cash_extra": money, + "shares_extra": money, + "stored": st.sampled_from(["none", "above", "below"]), + } +) + + +def _assets(units, holdings): + """Household inputs: stored totals above the balances, below them, or none.""" + people = [person for unit in units for person in unit] + cash = sum(person[1] for person in people) + shares = sum(person[2] for person in people) + assets = {"savings": holdings["savings"]} + assets["corporate_wealth"] = holdings["other_shares"] + shares + if holdings["stored"] == "above": + assets["cash_isa"] = cash + holdings["cash_extra"] + assets["stocks_and_shares_isa"] = shares + holdings["shares_extra"] + assets["corporate_wealth"] += holdings["shares_extra"] + elif holdings["stored"] == "below": + assets["cash_isa"] = cash / 2 + assets["stocks_and_shares_isa"] = shares / 2 + return assets + + +def _check_counted_isas(simulation, units, assets): + """Invariant 2: the household counts min(balances, total) of each kind.""" + totals = { + "cash_isa_countable_capital": simulation.calculate("cash_isa", YEAR)[0], + "stocks_and_shares_isa_countable_capital": min( + simulation.calculate("stocks_and_shares_isa", YEAR)[0], + simulation.calculate("share_like_capital", YEAR)[0], + ), + } + counted = {} + for name, total in totals.items(): + balance = simulation.calculate(BALANCE[name], YEAR) + counted[name] = simulation.calculate(name, YEAR) + assert (counted[name] >= 0).all() + assert (counted[name] <= balance * (1 + 1e-6) + 1e-3).all() + assert counted[name].sum() == pytest.approx( + min(balance.sum(), max(0, total)), rel=1e-5, abs=0.01 + ) + if balance.sum() > total > 0: + share = counted[name] / total + assert share == pytest.approx(balance / balance.sum(), abs=1e-5) + return counted + + +def _holdings(simulation): + return sum( + simulation.calculate(name, YEAR)[0] + for name in ("savings", "cash_isa", "share_like_capital") + ) + + +@SETTINGS +@given( + units=st.lists(_unit([30, 38]), min_size=1, max_size=3), + holdings=HOLDINGS, +) +def test_a_pound_counts_once_in_working_age_means_tests(units, holdings): + """Invariant 1 for UC, HB, IS, JSA and ESA, nobody on a claim path.""" + assets = _assets(units, holdings) + simulation = Simulation(situation=_situation(units, assets)) + counted = _check_counted_isas(simulation, units, assets) + dependants = ~simulation.calculate("is_claimant_or_partner", YEAR) + uncounted = sum((amount * dependants).sum() for amount in counted.values()) + for name in ( + "uc_assessable_capital", + "housing_benefit_assessable_capital", + "income_support_assessable_capital", + "jsa_income_assessable_capital", + "esa_income_assessable_capital", + ): + capital = simulation.calculate(name, YEAR) + assert capital.sum() + uncounted == pytest.approx( + _holdings(simulation), rel=1e-5, abs=0.05 + ), name + # Each unit counts at least its own claimant's and partner's ISAs. + own = sum( + simulation.map_result(amount * ~dependants, "person", "benunit", how="sum") + for amount in counted.values() + ) + assert (capital >= own * (1 - 1e-6) - 0.01).all(), name + + +@SETTINGS +@given( + pensioners=st.lists(_unit([70, 75]), min_size=1, max_size=2), + others=st.lists(_unit([30]), min_size=0, max_size=1), + holdings=HOLDINGS, +) +def test_a_pound_counts_once_in_pension_credit(pensioners, others, holdings): + """Invariant 1 for Pension Credit, shared by pension-age adults only.""" + units = pensioners + others + assets = _assets(units, holdings) + simulation = Simulation(situation=_situation(units, assets)) + counted = _check_counted_isas(simulation, units, assets) + pension_age = simulation.calculate( + "has_attained_state_pension_credit_qualifying_age", YEAR + ) + in_pension_unit = ( + simulation.map_result( + simulation.map_result(pension_age, "person", "benunit", how="sum") > 0, + "benunit", + "person", + ) + > 0 + ) + counts = simulation.calculate("is_claimant_or_partner", YEAR) & in_pension_unit + uncounted = sum((amount * ~counts).sum() for amount in counted.values()) + capital = simulation.calculate("pension_credit_assessable_capital", YEAR) + assert capital.sum() + uncounted == pytest.approx( + _holdings(simulation), rel=1e-5, abs=0.05 + ) + + +LODGER = { + "people": { + "tenant": {"age": {YEAR: 30}}, + "lodger": { + "age": {YEAR: 30}, + "cash_isa_balance": {YEAR: 10_000}, + "stocks_and_shares_isa_balance": {YEAR: 4_000}, + }, + }, + "benunits": { + "tenant_unit": {"members": ["tenant"], "would_claim_uc": {YEAR: False}}, + "lodger_unit": {"members": ["lodger"], "would_claim_uc": {YEAR: False}}, + }, + "households": { + "household": {"members": ["tenant", "lodger"], "savings": {YEAR: 2_000}} + }, +} + + +def _uc_sources_without(source): + path = CAPITAL["uc_assessable_capital"] + ".sources" + kept = [name for name in _node(path)(f"{YEAR}-01-01") if name != source] + return {path: {f"{YEAR}-01-01.2100-12-31": kept}} + + +@pytest.mark.parametrize( + "source, expected", + [ + # Cash ISAs disregarded: savings 2,000 shared, and the lodger keeps + # their 4,000 stocks and shares ISA. + ("cash_isa", [1_000, 5_000]), + # Share-like capital disregarded: the 10,000 cash ISA stays theirs. + ("share_like_capital", [1_000, 11_000]), + ], +) +def test_a_reform_that_drops_a_household_source_drops_its_person_source( + source, expected +): + """Invariant 6: a person ISA counts only while its household source does.""" + reformed = Simulation(situation=LODGER, reform=_uc_sources_without(source)) + assert list(reformed.calculate("uc_assessable_capital", YEAR)) == expected + baseline = Simulation(situation=LODGER) + assert list(baseline.calculate("uc_assessable_capital", YEAR)) == [1_000, 15_000] + assert list(baseline.calculate("housing_benefit_assessable_capital", YEAR)) == [ + 1_000, + 15_000, + ] + + +def test_emptying_person_sources_returns_the_isas_to_the_pool(): + """Invariant 6: without the person sources the household proxy stands.""" + path = CAPITAL["uc_assessable_capital"] + ".person_sources" + reformed = Simulation( + situation=LODGER, reform={path: {f"{YEAR}-01-01.2100-12-31": []}} + ) + # Savings 2,000 and both ISAs, 16,000 in all, shared by two claimants. + assert list(reformed.calculate("uc_assessable_capital", YEAR)) == [8_000, 8_000] + # Housing Benefit keeps its own lists. + assert list(reformed.calculate("housing_benefit_assessable_capital", YEAR)) == [ + 1_000, + 15_000, + ] + + +@pytest.mark.parametrize("variable", CAPITAL) +@pytest.mark.parametrize("date", ["2016-04-05", "2025-01-01", "2040-01-01"]) +def test_lists_count_each_isa_once(variable, date): + """Each person ISA source sits beside the household source that holds it.""" + node = _node(CAPITAL[variable]) + person_sources = list(node.person_sources(date)) + sources = list(node.sources(date)) + assert [name for name in person_sources if name in ISA_SOURCES] == list(ISA_SOURCES) + assert ("lifetime_isa_countable_capital" in person_sources) == ( + date >= "2017-04-06" + ) + for name in ISA_SOURCES: + assert HOUSEHOLD_SOURCE_OF[name] in sources + assert name in system.variables + # The household pool never names the balances or the ISA total itself. + assert not {*BALANCE.values(), "stocks_and_shares_isa"} & set(sources) + + +def test_jsa_counts_isas_from_when_they_existed(): + """ISAs exist from 6 April 1999 (SI 1998/1870 reg 1).""" + node = _node(CAPITAL["jsa_income_assessable_capital"]) + assert list(node.person_sources("1999-04-05")) == [] + assert list(node.person_sources("1999-04-06")) == list(ISA_SOURCES) diff --git a/policyengine_uk/variables/gov/dwp/_person_capital_pool.py b/policyengine_uk/variables/gov/dwp/_person_capital_pool.py index c258d273ce..190752d4b4 100644 --- a/policyengine_uk/variables/gov/dwp/_person_capital_pool.py +++ b/policyengine_uk/variables/gov/dwp/_person_capital_pool.py @@ -48,7 +48,7 @@ def pooled_person_capital(benunit, source, period, capital, leaves_pool=None): amount = amount + person.household.sum(held) if isinstance(amount, int): return 0 - return benunit.max(amount) + return _as_stored(benunit.max(amount)) def bounded_person_holding(person, balance, household_total): @@ -64,7 +64,15 @@ def bounded_person_holding(person, balance, household_total): total = max_(0, household_total) finite = np.where(np.isfinite(balance), balance, np.where(balance > 0, total, 0)) held = max_(0, finite) - household_held = person.household.sum(held) + household_held = _as_stored(person.household.sum(held)) over = household_held > total scale = where(over, total / where(over, household_held, 1), 1) return held * scale + + +def _as_stored(amount): + # Core holds float variables in float32 but sums and maximises members in + # float64. Round the result as Core stores a household total such as + # cash_isa, so balances that make up a total net it to exactly zero and + # the pool keeps its float32 arithmetic. + return amount.astype(np.float32) 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 438803cf36..ddce4884cc 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py @@ -20,7 +20,9 @@ class esa_income_assessable_capital(Variable): "Person-level sources, such as a Lifetime ISA, count only for the " "holder's own benunit, and only when the holder is its claimant or " "partner (is_claimant_or_partner): a dependant's capital is not the " - "claimant's." + "claimant's. A cash or stocks and shares ISA balance tied to a person " + "counts the same way, and leaves the household capital the proxy shares " + "out." ) definition_period = YEAR unit = GBP 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 cc7e3e59b7..5dcf2a0835 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 @@ -17,7 +17,9 @@ class housing_benefit_assessable_capital(Variable): "no weight. Person-level sources, such as a Lifetime ISA, count only for " "the holder's own benunit, and only when the holder is its claimant or " "partner (is_claimant_or_partner): a dependant's capital is not the " - "claimant's. Where the Pension Credit award is savings credit only, the " + "claimant's. A cash or stocks and shares ISA balance tied to a person " + "counts the same way, and leaves the household capital the proxy shares " + "out. Where the Pension Credit award is savings credit only, the " "Pension Credit assessment of capital is used instead. It is nil for a " "family on Universal Credit, Income Support, income-based Jobseeker's " "Allowance or income-related Employment and Support Allowance " 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 9233738b42..3aa658f224 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py @@ -19,7 +19,9 @@ class income_support_assessable_capital(Variable): "Person-level sources, such as a Lifetime ISA, count only for the " "holder's own benunit, and only when the holder is its claimant or " "partner (is_claimant_or_partner): a dependant's capital is not the " - "claimant's." + "claimant's. A cash or stocks and shares ISA balance tied to a person " + "counts the same way, and leaves the household capital the proxy shares " + "out." ) definition_period = YEAR unit = GBP 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 e90b05f219..33da25a102 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py @@ -20,7 +20,9 @@ class jsa_income_assessable_capital(Variable): "Person-level sources, such as a Lifetime ISA, count only for the " "holder's own benunit, and only when the holder is its claimant or " "partner (is_claimant_or_partner): a dependant's capital is not the " - "claimant's." + "claimant's. A cash or stocks and shares ISA balance tied to a person " + "counts the same way, and leaves the household capital the proxy shares " + "out." ) definition_period = YEAR unit = GBP 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 2ca50c8cb4..9739899f37 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 @@ -16,7 +16,9 @@ class pension_credit_assessable_capital(Variable): "age adults. Person-level sources, such as a Lifetime ISA, count " "only for the holder's own benunit, and only when the holder is its " "claimant or partner (is_claimant_or_partner): a dependant's capital " - "is not the claimant's. Where `pension_credit_reported_capital` records " + "is not the claimant's. A cash or stocks and shares ISA balance tied to " + "a person counts the same way, and leaves the household capital the " + "proxy shares out. Where `pension_credit_reported_capital` records " "the benefit unit's own capital (0 or more), it replaces the " "household proxy and the person-level sources. Unlike " "`uc_assessable_capital`, another benefit unit's recorded capital is " 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 d9050d037e..1fd59613b2 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 @@ -29,6 +29,9 @@ class uc_assessable_capital(Variable): "independent of other units' financial reports. These " "allocations are assumptions about unobserved ownership, not law. " "Person-level proxies count only claimants/partners, not dependants. " + "A cash or stocks and shares ISA balance tied to a person leaves the " + "financial pool, unless a report or override of the holder's own unit " + "already covers it. " "Under regulation 77, disregarded company holdings are replaced by " "the claimant/partner's share of company capital." ) diff --git a/policyengine_uk/variables/input/cash_isa_balance.py b/policyengine_uk/variables/input/cash_isa_balance.py index 152cbde845..2d6c7d7f54 100644 --- a/policyengine_uk/variables/input/cash_isa_balance.py +++ b/policyengine_uk/variables/input/cash_isa_balance.py @@ -8,7 +8,9 @@ class cash_isa_balance(Variable): "(lifetime_isa_balance). The household's cash_isa sums it over its " "members, so a dataset that stores cash_isa itself keeps that stored " "total instead. It is separate from savings, the household's ordinary " - "savings." + "savings. A child's junior cash ISA is the child's own. The means tests " + "count the balance in its holder's benefit unit, for the claimant and " + "partner only (cash_isa_countable_capital)." ) entity = Person definition_period = YEAR diff --git a/policyengine_uk/variables/input/stocks_and_shares_isa_balance.py b/policyengine_uk/variables/input/stocks_and_shares_isa_balance.py index 23f0d1a8fd..48c90a2b85 100644 --- a/policyengine_uk/variables/input/stocks_and_shares_isa_balance.py +++ b/policyengine_uk/variables/input/stocks_and_shares_isa_balance.py @@ -9,7 +9,10 @@ class stocks_and_shares_isa_balance(Variable): "stocks_and_shares_isa sums it over its members, so a dataset that " "stores stocks_and_shares_isa itself keeps that stored total instead. " "Like that total, it is part of corporate_wealth, so the two must " - "never be summed." + "never be summed. A child's junior stocks and shares ISA is the " + "child's own. The means tests count the balance in its holder's " + "benefit unit, for the claimant and partner only " + "(stocks_and_shares_isa_countable_capital)." ) entity = Person definition_period = YEAR From f6b37deb8dec5ac548499bbc254a11cb893c9632 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 01:53:30 -0500 Subject: [PATCH 03/27] Run fewer examples of the whole-means-test properties and rename a stale case Each property example runs every means test, so the situation properties take four examples and the dataset differential three. The cash ISA case that said the balance enters capital through the household total now says it counts as the holder's own benefit unit's capital; its values are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../tests/policy/baseline/gov/dwp/cash_isa_capital.yaml | 2 +- policyengine_uk/tests/test_person_isa_capital.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/cash_isa_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/cash_isa_capital.yaml index 3b858806e2..2a2fa18f4a 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/cash_isa_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/cash_isa_capital.yaml @@ -100,7 +100,7 @@ output: pension_credit_assessable_capital: [0, 5_000] -- name: A person's cash ISA balance enters capital through the household total +- name: A person's cash ISA balance counts as their own benefit unit's capital period: 2026 absolute_error_margin: 0.01 input: diff --git a/policyengine_uk/tests/test_person_isa_capital.py b/policyengine_uk/tests/test_person_isa_capital.py index 5915dfd6d3..e3d381cee8 100644 --- a/policyengine_uk/tests/test_person_isa_capital.py +++ b/policyengine_uk/tests/test_person_isa_capital.py @@ -229,7 +229,9 @@ def test_both_forms_in_one_dataset_add_up_to_the_stored_total(): min_size=1, max_size=4, ) +# Each example runs whole means tests, so keep the examples few. DATASET_SETTINGS = settings(SETTINGS, max_examples=3) +SITUATION_SETTINGS = settings(SETTINGS, max_examples=4) @DATASET_SETTINGS @@ -358,7 +360,7 @@ def _holdings(simulation): ) -@SETTINGS +@SITUATION_SETTINGS @given( units=st.lists(_unit([30, 38]), min_size=1, max_size=3), holdings=HOLDINGS, @@ -389,7 +391,7 @@ def test_a_pound_counts_once_in_working_age_means_tests(units, holdings): assert (capital >= own * (1 - 1e-6) - 0.01).all(), name -@SETTINGS +@SITUATION_SETTINGS @given( pensioners=st.lists(_unit([70, 75]), min_size=1, max_size=2), others=st.lists(_unit([30]), min_size=0, max_size=1), From cb6e5a87533a93ac205c5cd5e425dd3259059c4c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 02:10:03 -0500 Subject: [PATCH 04/27] Restate UC person_sources' own 2013 value in the dated-reform example The example restates a parameter's own value and expects no change. From 2013-04-29 the UC person_sources list now holds the cash and stocks and shares ISA sources, so the example restates that list instead of the empty one. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_dated_reform_routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_dated_reform_routes.py b/policyengine_uk/tests/test_dated_reform_routes.py index dec77f72fe..8112cdfcf5 100644 --- a/policyengine_uk/tests/test_dated_reform_routes.py +++ b/policyengine_uk/tests/test_dated_reform_routes.py @@ -1955,7 +1955,7 @@ def restatements(draw): "gov.dwp.universal_credit.means_test.capital.person_sources", datetime.date(2013, 4, 29), datetime.date(2013, 4, 29), - [], + ["cash_isa_countable_capital", "stocks_and_shares_isa_countable_capital"], "reform_dict", ) ) From c3a1026abf28a05d288b5ccfd245f3e6dbf251b1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 05:30:18 -0500 Subject: [PATCH 05/27] Keep each ISA holding within its household total in float32 bounded_person_holding scaled balances by a float32 ratio, so a sole holder of 16,790 under a 16,000 total held 16,000.0009765625: UC eligibility flipped at the capital limit, a tariff appeared at 6,000, and the pool's remainder went negative for an unrelated unit in IS, JSA and ESA. bounded_holdings now compares the balances' sum as Core stores it, divides in float64, where the product of two float32 amounts is exact, and rounds each share down to float32. Hypothesis properties on the helper alone check the cap, the nonnegative remainder, the exact sole holder, exact balances within a total, proportions to float32 spacing and determinism. YAML Cases 14 to 17 pin the limits exactly; Cases 18 to 21 pin the share-pool bound, a reporting parent's child and the inherited report overrides. Co-Authored-By: Claude Opus 5.5 --- .../baseline/gov/dwp/person_isa_capital.yaml | 309 ++++++++++++++++++ .../tests/test_person_isa_capital.py | 144 +++++++- .../variables/gov/dwp/_person_capital_pool.py | 60 +++- 3 files changed, 497 insertions(+), 16 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml index f45f5620e9..d6c0fde5bd 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml @@ -372,3 +372,312 @@ # Shared by the two claimants, the ISA put 17,000 in each unit, above # reg 18's 16,000 limit. Now only its holder's unit counts it. uc_assessable_capital: [0, 34_000] + +# Cases 14 to 17: balances above a household total count exactly the total, +# so a holder at a capital limit or a tariff threshold stays on its boundary. +# The model stores amounts in float32 (spacing 0.0009765625 at 16,000), and +# absolute_error_margin 0 fails on one unit in the last place. + +- name: Case 14, an ISA above its household total counts exactly 16,000 at UC's capital limit. + period: 2025 + absolute_error_margin: 0 + input: + people: + cash_holder: + age: 30 + cash_isa_balance: 16_790 + stocks_holder: + age: 30 + stocks_and_shares_isa_balance: 16_790 + benunits: + cash_unit: + members: cash_holder + would_claim_uc: false + would_claim_IS: false + stocks_unit: + members: stocks_holder + would_claim_uc: false + would_claim_IS: false + households: + cash_home: + members: cash_holder + cash_isa: 16_000 + stocks_home: + members: stocks_holder + stocks_and_shares_isa: 16_000 + output: + cash_isa_countable_capital: [16_000, 0] + stocks_and_shares_isa_countable_capital: [0, 16_000] + # WRA 2012 s 5(1)(a) requires capital not greater than the amount reg + # 18(1)(a) prescribes, 16,000, so capital of exactly 16,000 keeps + # entitlement. Reg 72(1) deems 4.35 a month for each 250 above 6,000: + # 40 steps, 2,088 a year. + uc_assessable_capital: [16_000, 16_000] + is_uc_eligible: [true, true] + uc_tariff_income: [2_088, 2_088] + +- name: Case 15, an ISA above its household total counts exactly 6,000 at UC's tariff threshold. + period: 2025 + absolute_error_margin: 0 + input: + people: + cash_holder: + age: 30 + cash_isa_balance: 8_341 + stocks_holder: + age: 30 + stocks_and_shares_isa_balance: 8_341 + benunits: + cash_unit: + members: cash_holder + would_claim_uc: false + would_claim_IS: false + stocks_unit: + members: stocks_holder + would_claim_uc: false + would_claim_IS: false + households: + cash_home: + members: cash_holder + cash_isa: 6_000 + stocks_home: + members: stocks_holder + stocks_and_shares_isa: 6_000 + output: + cash_isa_countable_capital: [6_000, 0] + stocks_and_shares_isa_countable_capital: [0, 6_000] + # Reg 72(1) deems no income from capital of 6,000 or less. + uc_assessable_capital: [6_000, 6_000] + uc_tariff_income: [0, 0] + +- name: Case 16, a holder at the total leaves an unrelated couple nothing in every working-age test. + period: 2025 + absolute_error_margin: 0 + input: + people: + holder: + age: 30 + cash_isa_balance: 16_790 + partner_1: + age: 30 + partner_2: + age: 30 + benunits: + holder_unit: + members: holder + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + in_receipt_of_guarantee_credit: false + in_receipt_of_savings_credit_only: false + housing_benefit_on_passporting_benefit: false + couple: + members: [partner_1, partner_2] + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + in_receipt_of_guarantee_credit: false + in_receipt_of_savings_credit_only: false + housing_benefit_on_passporting_benefit: false + households: + household: + members: [holder, partner_1, partner_2] + cash_isa: 16_000 + output: + # The holder counts the whole 16,000, so the pool keeps exactly nothing + # for the couple, never a negative amount. + cash_isa_countable_capital: [16_000, 0, 0] + uc_assessable_capital: [16_000, 0] + housing_benefit_assessable_capital: [16_000, 0] + income_support_assessable_capital: [16_000, 0] + jsa_income_assessable_capital: [16_000, 0] + esa_income_assessable_capital: [16_000, 0] + +- name: Case 17, a pension-age holder at the total counts exactly 10,000 in Pension Credit. + period: 2025 + absolute_error_margin: 0 + input: + people: + holder: + age: 70 + cash_isa_balance: 16_450 + partner_1: + age: 70 + partner_2: + age: 70 + benunits: + holder_unit: + members: holder + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + in_receipt_of_guarantee_credit: false + in_receipt_of_savings_credit_only: false + housing_benefit_on_passporting_benefit: false + couple: + members: [partner_1, partner_2] + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + in_receipt_of_guarantee_credit: false + in_receipt_of_savings_credit_only: false + housing_benefit_on_passporting_benefit: false + households: + household: + members: [holder, partner_1, partner_2] + cash_isa: 10_000 + output: + cash_isa_countable_capital: [10_000, 0, 0] + pension_credit_assessable_capital: [10_000, 0] + # SPC Regs 2002 reg 15(6) deems 1 a week for each 500 above 10,000, so + # capital of exactly 10,000 deems nothing. + pension_credit_deemed_income: [0, 0] + housing_benefit_assessable_capital: [10_000, 0] + +- name: Case 18, a stocks and shares ISA counts no more than the share-like capital that holds it. + period: 2025 + absolute_error_margin: 0 + input: + people: + holder: + age: 30 + stocks_and_shares_isa_balance: 1_000 + benunits: + holder_unit: + members: holder + would_claim_uc: false + would_claim_IS: false + households: + household: + members: holder + stocks_and_shares_isa: 1_000 + # The means tests count share-like capital, and an input or reform + # can set it below the ISA total. + share_like_capital: 100 + output: + stocks_and_shares_isa_countable_capital: 100 + uc_assessable_capital: 100 + +- name: Case 19, a child's ISA counts in no unit when the parent reports the family's capital. + period: 2025 + absolute_error_margin: 0 + input: + people: + parent: + age: 40 + child: + age: 10 + cash_isa_balance: 10_000 + lodger: + age: 30 + benunits: + family: + members: [parent, child] + # The parent's own capital: nothing. + benunit_reported_capital: 0 + lodger_unit: + members: lodger + households: + household: + members: [parent, child, lodger] + output: + # The report covers the claimant and partner, not the child, whose + # 10,000 ISA leaves the pool, so the lodger's unit counts none of it. + uc_assessable_capital: [0, 0] + +- name: Case 20, a UC report keeps the inherited override, so a reporter's unabsorbed ISA stays in the pool. + period: 2025 + absolute_error_margin: 0 + input: + people: + holder_1: + age: 30 + cash_isa_balance: 1 + other_1: + age: 30 + holder_2: + age: 30 + cash_isa_balance: 1 + other_2: + age: 30 + holder_3: + age: 30 + cash_isa_balance: 10_000 + other_3: + age: 30 + holder_4: + age: 30 + cash_isa_balance: 10_000 + other_4: + age: 30 + benunits: + holder_unit_1: + members: holder_1 + benunit_reported_capital: 0 + other_unit_1: + members: other_1 + holder_unit_2: + members: holder_2 + uc_reported_capital: 0 + other_unit_2: + members: other_2 + holder_unit_3: + members: holder_3 + benunit_reported_capital: 0 + other_unit_3: + members: other_3 + holder_unit_4: + members: holder_4 + benunit_reported_capital: 6_000 + other_unit_4: + members: other_4 + households: + household_1: + members: [holder_1, other_1] + household_2: + members: [holder_2, other_2] + household_3: + members: [holder_3, other_3] + household_4: + members: [holder_4, other_4] + output: + # A reporting unit's own ISA stays in the pool, and the report + # consumes only its reported amount: the unit without a report counts + # the rest of the household's capital. + uc_assessable_capital: [0, 1, 0, 1, 0, 10_000, 6_000, 4_000] + +- name: Case 21, a Pension Credit report replaces the reporting unit's capital and shares none of it. + period: 2025 + absolute_error_margin: 0 + input: + people: + holder_1: + age: 70 + cash_isa_balance: 10_000 + other_1: + age: 70 + holder_2: + age: 70 + cash_isa_balance: 10_000 + other_2: + age: 70 + benunits: + holder_unit_1: + members: holder_1 + pension_credit_reported_capital: 0 + other_unit_1: + members: other_1 + holder_unit_2: + members: holder_2 + pension_credit_reported_capital: 6_000 + other_unit_2: + members: other_2 + households: + household_1: + members: [holder_1, other_1] + household_2: + members: [holder_2, other_2] + output: + # The holder's ISA leaves the pool, and the report replaces the + # holder's unit's capital, so the other pension-age unit counts none. + pension_credit_assessable_capital: [0, 0, 6_000, 0] diff --git a/policyengine_uk/tests/test_person_isa_capital.py b/policyengine_uk/tests/test_person_isa_capital.py index e3d381cee8..912b52c815 100644 --- a/policyengine_uk/tests/test_person_isa_capital.py +++ b/policyengine_uk/tests/test_person_isa_capital.py @@ -24,20 +24,32 @@ total #2781 keeps, in every year. 6. A reform that drops the household source drops the person source with it, and one that empties person_sources returns the ISAs to the pool. +7. The allocation arithmetic (bounded_holdings), for every float32 balance + and finite total: no holding exceeds the total; the household's stored sum + of holdings never exceeds it, so every remainder stays at or above zero; a + sole holder whose balance reaches the total holds exactly the total; + balances whose stored sum is within the total keep their exact values; + holdings over a total lie within float32 spacing of their proportional + shares; and the same input gives the same output. """ +from fractions import Fraction + import numpy as np import pandas as pd import pytest -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st +from policyengine_core.populations import GroupPopulation, Population from policyengine_uk import Simulation from policyengine_uk.data.dataset_schema import UKSingleYearDataset from policyengine_uk.data.economic_assumptions import PROJECTION_END_YEAR +from policyengine_uk.entities import Household, Person from policyengine_uk.system import system from policyengine_uk.variables.gov.dwp._person_capital_pool import ( HOUSEHOLD_SOURCE_OF, + bounded_holdings, ) pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") @@ -131,6 +143,136 @@ def _dataset(households, year=YEAR, claims=True): ) +def _core_household_sum(household_of): + """Core's person.household.sum, for people in the given households. + + Builds Core's own populations by hand, with no Simulation, so the + allocation properties run in milliseconds. + """ + person = Population(Person) + person.count = len(household_of) + household = GroupPopulation(Household, person) + household.members_entity_id = np.asarray(household_of, dtype=np.intp) + household.count = int(max(household_of)) + 1 + return lambda array: household.project(household.sum(array)) + + +ANY_FLOAT32 = st.floats(width=32) +FINITE_FLOAT32 = st.floats(width=32, allow_nan=False, allow_infinity=False) +POUNDS = st.integers(min_value=0, max_value=4_000_000).map( + lambda pence: float(np.float32(pence / 100)) +) +# Up to three households of up to six people; amounts anywhere in float32 or +# in pounds and pence, where ISAs and capital limits sit. +ALLOCATIONS = st.integers(min_value=1, max_value=3).flatmap( + lambda count: st.tuples( + st.lists(st.one_of(POUNDS, FINITE_FLOAT32), min_size=count, max_size=count), + st.lists( + st.tuples( + st.integers(min_value=0, max_value=count - 1), + st.one_of(POUNDS, ANY_FLOAT32), + ), + min_size=1, + max_size=6, + ), + ) +) +ALLOCATION_SETTINGS = settings(SETTINGS, max_examples=300) + + +@ALLOCATION_SETTINGS +@given(allocation=ALLOCATIONS) +# A sole holder over the total at UC's capital limit, its tariff threshold +# and Pension Credit's tariff threshold, alone and beside two others. +@example(allocation=([16_000.0], [(0, 16_790.0)])) +@example(allocation=([6_000.0], [(0, 8_341.0)])) +@example(allocation=([10_000.0], [(0, 16_450.0), (0, 0.0), (0, 0.0)])) +# Balances whose float64 sum exceeds the total but rounds to it in float32. +@example(allocation=([16_000.0], [(0, 8_000.00048828125), (0, 8_000.0)])) +# A balance too small to change the float64 sum, beside one over the total. +@example(allocation=([1.0], [(0, 1e-30), (0, 3.0)])) +# Balances whose sum exceeds float32's range. +@example(allocation=([3e38], [(0, 3e38), (0, 3e38)])) +# Infinite, missing and negative balances, and a negative total. +@example( + allocation=( + [5.0, -7.0], + [(0, float("inf")), (1, float("nan")), (1, -float("inf")), (0, -3.0)], + ) +) +def test_holdings_stay_within_the_household_total(allocation): + """Invariant 7: the arithmetic every ISA person source shares.""" + totals, members = allocation + household_of = np.array([h for h, _ in members]) + household_sum = _core_household_sum(household_of) + balance = np.array([b for _, b in members], np.float32) + total = np.array(totals, np.float32)[household_of] + holding = bounded_holdings(balance, total, household_sum) + assert holding.dtype == np.float32 + + cap = np.maximum(np.float32(0), total) + held = np.where(np.isposinf(balance), cap, np.where(balance > 0, balance, 0)) + held = held.astype(np.float32) + # No holding is negative or exceeds the household total. + assert ((0 <= holding) & (holding <= cap)).all() + # Core stores the household's sum of holdings, as each means test nets it + # from the household source, within the total. + with np.errstate(over="ignore"): + stored = household_sum(holding).astype(np.float32) + within = household_sum(held).astype(np.float32) <= cap + assert (cap - stored >= 0).all() + # Balances whose stored sum is within the total keep their exact values. + assert np.array_equal(holding[within], held[within]) + # A sole holder whose balance reaches the total holds exactly the total. + holders = household_sum((held > 0).astype(np.float32)) + sole = (held > 0) & (holders == 1) & (held >= cap) + assert np.array_equal(holding[sole], cap[sole]) + # Over a total, each holding lies within float32 spacing (plus float64 + # rounding) of its exact proportional share, and together they fall + # short of the total by at most one float32 spacing per member. + for i in np.flatnonzero(~within): + same = np.flatnonzero(household_of == household_of[i]) + exact = ( + Fraction(float(held[i])) + * Fraction(float(cap[i])) + / sum(Fraction(float(held[j])) for j in same) + ) + spacing = Fraction(float(np.spacing(np.float32(float(exact))))) + assert abs(Fraction(float(holding[i])) - exact) <= spacing + exact / 2**48 + assert cap[i] - stored[i] <= (len(same) + 1) * np.spacing(cap[i]) + # The same input gives the same output, and one household's holdings + # never depend on another's. + again = bounded_holdings(balance.copy(), total.copy(), household_sum) + assert np.array_equal(holding, again) + for h in np.unique(household_of): + alone = household_of == h + result = bounded_holdings( + balance[alone], + total[alone], + _core_household_sum(np.zeros(alone.sum(), dtype=int)), + ) + assert np.array_equal(result, holding[alone]) + + +@pytest.mark.parametrize("limit", [6_000, 10_000, 16_000]) +def test_a_sole_holder_over_a_limit_holds_exactly_the_limit(limit): + """Invariant 7, exhaustively near three capital limits. + + Every float32 balance from the limit to four times it, each a sole + holder under a household total equal to the limit, and the next 1,000 + float32 balances above the limit. + """ + above = np.nextafter(np.float32(limit), np.float32(np.inf)) + steps = np.arange(1_000, dtype=np.float32) * (above - np.float32(limit)) + balance = np.concatenate( + [np.arange(limit, 4 * limit, dtype=np.float32), np.float32(limit) + steps] + ) + total = np.full_like(balance, limit) + household_sum = _core_household_sum(np.arange(balance.size)) + holding = bounded_holdings(balance, total, household_sum) + assert (holding == np.float32(limit)).all() + + # A couple and a lodger; pensioners and their adult son; a parent and child. HOUSEHOLD_ONLY = [ { diff --git a/policyengine_uk/variables/gov/dwp/_person_capital_pool.py b/policyengine_uk/variables/gov/dwp/_person_capital_pool.py index 190752d4b4..c3ac11d118 100644 --- a/policyengine_uk/variables/gov/dwp/_person_capital_pool.py +++ b/policyengine_uk/variables/gov/dwp/_person_capital_pool.py @@ -52,22 +52,45 @@ def pooled_person_capital(benunit, source, period, capital, leaves_pool=None): def bounded_person_holding(person, balance, household_total): - """A person's ``balance``, scaled so the household's never exceed its total. - - ``household_total`` is the household's total for each member. A negative - or missing balance holds nothing, and an infinite one the whole total. - Where the members' balances add up to more than the nonnegative total, - each is scaled down by the same proportion, so the members together hold - the total in proportion to their balances. Otherwise each holds its - balance, and the rest of the total is held by no one the data name. + """Each member's ``balance``, bounded by ``household_total``. + + ``household_total`` is the household's total for each member. See + ``bounded_holdings``, which this calls with Core's household sum. + """ + return bounded_holdings(balance, household_total, person.household.sum) + + +def bounded_holdings(balance, household_total, household_sum): + """Each member's holding: their ``balance``, bounded by the household total. + + ``household_total`` gives the household's total for each member, and + ``household_sum`` maps a member array to each member's household sum, as + Core's ``person.household.sum`` does. A negative or missing balance holds + nothing, and an infinite one the whole total. Where the household's + balances add up, stored as Core stores a total, to the nonnegative total + or less, each member holds their balance exactly, and no one the data + name holds the rest. Where they add up to more, the function scales each + balance by the total over their sum and rounds it down to float32, as + Core stores the result. For every float32 balance and finite total: + + - no holding exceeds the household total; + - the household's stored sum of holdings never exceeds the total, so the + pool's remainder stays at or above zero; + - a sole holder whose balance reaches the total holds exactly the total; + - each holding lies within float32 spacing of its proportional share; + - the same input gives the same output. """ - total = max_(0, household_total) + total = np.maximum(np.float32(0), np.asarray(household_total, np.float32)) + balance = np.asarray(balance, np.float32) finite = np.where(np.isfinite(balance), balance, np.where(balance > 0, total, 0)) - held = max_(0, finite) - household_held = _as_stored(person.household.sum(held)) - over = household_held > total - scale = where(over, total / where(over, household_held, 1), 1) - return held * scale + held = np.maximum(np.float32(0), finite).astype(np.float32) + held_sum = np.asarray(household_sum(held), np.float64) + over = _as_stored(held_sum) > total + # Float64 holds the product of two float32 amounts exactly, so each share + # rounds once, in the division. A sole holder's share is then the total + # itself, and rounding every share down keeps their sum within the total. + share = held * total.astype(np.float64) / np.where(over, held_sum, 1) + return np.where(over, _round_down_to_float32(share), held) def _as_stored(amount): @@ -75,4 +98,11 @@ def _as_stored(amount): # float64. Round the result as Core stores a household total such as # cash_isa, so balances that make up a total net it to exactly zero and # the pool keeps its float32 arithmetic. - return amount.astype(np.float32) + with np.errstate(over="ignore"): + return np.asarray(amount).astype(np.float32) + + +def _round_down_to_float32(amount): + # The largest float32 at or below each nonnegative float64 amount. + rounded = amount.astype(np.float32) + return np.where(rounded > amount, np.nextafter(rounded, np.float32(0)), rounded) From 42130d558072c82198add17df8d3ce4cd5859afe Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 06:30:33 -0500 Subject: [PATCH 06/27] Test person ISA balances with no household totals, as Microcosm writes them Microcosm's producer writes adults' cash and stocks and shares ISA balances and a corporate_wealth that sums the itemised shares, and leaves out the household cash_isa and stocks_and_shares_isa. Core then sums the balances into each total, so every balance counts with its holder and the pool keeps savings and the non-ISA shares. The test checks that shape exactly in 2025 and by its identities in 2033. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_person_isa_capital.py | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/policyengine_uk/tests/test_person_isa_capital.py b/policyengine_uk/tests/test_person_isa_capital.py index 912b52c815..ffa39866e5 100644 --- a/policyengine_uk/tests/test_person_isa_capital.py +++ b/policyengine_uk/tests/test_person_isa_capital.py @@ -363,6 +363,97 @@ def test_both_forms_in_one_dataset_add_up_to_the_stored_total(): ] +# The shape Microcosm's producer writes (microcosm main b979d46b): adults' +# cash and stocks and shares ISA balances, and a household corporate_wealth +# that sums directly_held_shares, unit_and_investment_trusts and the stocks +# and shares ISAs, with no household cash_isa or stocks_and_shares_isa. +PRODUCER_SHAPE = [ + { + "units": [ + [ + (30, {"cash_isa_balance": 3_000.0}), + (28, {"stocks_and_shares_isa_balance": 2_000.0}), + ], + [ + ( + 40, + { + "cash_isa_balance": 5_000.0, + "stocks_and_shares_isa_balance": 1_000.0, + }, + ) + ], + ], + "savings": 7_000, + "directly_held_shares": 1_500, + "unit_and_investment_trusts": 500, + "corporate_wealth": 5_000, + }, + { + "units": [ + [(70, {"cash_isa_balance": 4_000.0}), (68, {})], + [(30, {"stocks_and_shares_isa_balance": 6_000.0})], + ], + "savings": 12_000, + "unit_and_investment_trusts": 3_000, + "corporate_wealth": 9_000, + }, +] + + +def _household_capital(simulation, year): + return sum( + simulation.calculate(name, year) + for name in ("savings", "cash_isa", "share_like_capital") + ) + + +def test_person_balances_without_household_totals_count_with_their_holders(): + """Core sums the balances into each total, so every balance counts.""" + simulation = Simulation(dataset=_dataset(PRODUCER_SHAPE, claims=False)) + person_household = np.array([0, 0, 0, 1, 1, 1]) + benunit_household = np.array([0, 0, 1, 1]) + for year in (YEAR, PROJECTION_END_YEAR + 1): + for name, balance_name in BALANCE.items(): + balance = simulation.calculate(balance_name, year) + assert np.array_equal(simulation.calculate(name, year), balance) + for total, balance_name in ( + ("cash_isa", "cash_isa_balance"), + ("stocks_and_shares_isa", "stocks_and_shares_isa_balance"), + ): + sums = np.bincount( + person_household, weights=simulation.calculate(balance_name, year) + ) + assert simulation.calculate(total, year) == pytest.approx(sums, rel=1e-6) + # The itemised shares add up to corporate_wealth, which stays the pool. + assert np.array_equal( + simulation.calculate("share_like_capital", year), + simulation.calculate("corporate_wealth", year), + ) + holdings = _household_capital(simulation, year) + uc = simulation.calculate("uc_assessable_capital", year) + assert np.bincount(benunit_household, weights=uc) == pytest.approx( + holdings, rel=1e-6 + ) + # Savings 7,000 and 2,000 of non-ISA shares make the first pool, 3,000 for + # each claimant, and each unit adds its members' 5,000 and 6,000 of ISAs. + # The second pool, savings 12,000 and 3,000 of trusts, gives the parents + # 10,000 and their son 5,000 in UC, and the pension-age parents all + # 15,000 in Pension Credit. + expected_uc = [11_000, 9_000, 14_000, 11_000] + assert list(simulation.calculate("uc_assessable_capital", YEAR)) == expected_uc + for name in CAPITAL: + if name != "pension_credit_assessable_capital": + capital = simulation.calculate(name, YEAR) + assert list(capital[:2]) == expected_uc[:2], name + assert list(simulation.calculate("pension_credit_assessable_capital", YEAR)) == [ + 0, + 0, + 19_000, + 0, + ] + + money = st.integers(min_value=0, max_value=2_000_000).map(lambda pence: pence / 100) # One benefit unit: a claimant, perhaps a partner, each with a cash and a # stocks and shares ISA, and a child; with savings and other shares. From 8223ae37fe5e5aaf1fd83a8948b30a74175826e2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 06:48:13 -0500 Subject: [PATCH 07/27] Change UC's capital lists in place in one lodger test The two source-dropping cases and the emptied person_sources case built five situation simulations, about 230 seconds under local load. One simulation that updates each list for 2025, resets its caches and restores the list checks the same values in 15 seconds. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_person_isa_capital.py | 82 ++++++++++--------- 1 file changed, 43 insertions(+), 39 deletions(-) diff --git a/policyengine_uk/tests/test_person_isa_capital.py b/policyengine_uk/tests/test_person_isa_capital.py index ffa39866e5..0a0f5d2fcf 100644 --- a/policyengine_uk/tests/test_person_isa_capital.py +++ b/policyengine_uk/tests/test_person_isa_capital.py @@ -33,6 +33,7 @@ shares; and the same input gives the same output. """ +from contextlib import contextmanager from fractions import Fraction import numpy as np @@ -674,49 +675,52 @@ def test_a_pound_counts_once_in_pension_credit(pensioners, others, holdings): } -def _uc_sources_without(source): - path = CAPITAL["uc_assessable_capital"] + ".sources" - kept = [name for name in _node(path)(f"{YEAR}-01-01") if name != source] - return {path: {f"{YEAR}-01-01.2100-12-31": kept}} +@contextmanager +def _law(simulation, path, value): + """Change one parameter in place for YEAR, then restore it.""" + parameters = simulation.tax_benefit_system.parameters + original = list(parameters.get_child(path)(f"{YEAR}-01-01")) + def update(new): + parameters.get_child(path).update(period=str(YEAR), value=new) + simulation.tax_benefit_system.reset_parameter_caches() + simulation.reset_calculations() -@pytest.mark.parametrize( - "source, expected", - [ - # Cash ISAs disregarded: savings 2,000 shared, and the lodger keeps - # their 4,000 stocks and shares ISA. - ("cash_isa", [1_000, 5_000]), - # Share-like capital disregarded: the 10,000 cash ISA stays theirs. - ("share_like_capital", [1_000, 11_000]), - ], -) -def test_a_reform_that_drops_a_household_source_drops_its_person_source( - source, expected -): - """Invariant 6: a person ISA counts only while its household source does.""" - reformed = Simulation(situation=LODGER, reform=_uc_sources_without(source)) - assert list(reformed.calculate("uc_assessable_capital", YEAR)) == expected - baseline = Simulation(situation=LODGER) - assert list(baseline.calculate("uc_assessable_capital", YEAR)) == [1_000, 15_000] - assert list(baseline.calculate("housing_benefit_assessable_capital", YEAR)) == [ - 1_000, - 15_000, - ] + update(value) + try: + yield + finally: + update(original) -def test_emptying_person_sources_returns_the_isas_to_the_pool(): - """Invariant 6: without the person sources the household proxy stands.""" - path = CAPITAL["uc_assessable_capital"] + ".person_sources" - reformed = Simulation( - situation=LODGER, reform={path: {f"{YEAR}-01-01.2100-12-31": []}} - ) - # Savings 2,000 and both ISAs, 16,000 in all, shared by two claimants. - assert list(reformed.calculate("uc_assessable_capital", YEAR)) == [8_000, 8_000] - # Housing Benefit keeps its own lists. - assert list(reformed.calculate("housing_benefit_assessable_capital", YEAR)) == [ - 1_000, - 15_000, - ] +def test_reforms_to_either_list_keep_each_isa_with_its_household_source(): + """Invariant 6, on one simulation whose law changes in place.""" + simulation = Simulation(situation=LODGER) + uc_path = CAPITAL["uc_assessable_capital"] + + def capital(name): + return list(simulation.calculate(name, YEAR)) + + assert capital("uc_assessable_capital") == [1_000, 15_000] + assert capital("housing_benefit_assessable_capital") == [1_000, 15_000] + sources = list(_node(uc_path + ".sources")(f"{YEAR}-01-01")) + # Dropping cash_isa from UC's sources: savings 2,000 shared, and the + # lodger keeps their 4,000 stocks and shares ISA. Dropping + # share_like_capital: the 10,000 cash ISA stays theirs. + for dropped, expected in ( + ("cash_isa", [1_000, 5_000]), + ("share_like_capital", [1_000, 11_000]), + ): + kept = [name for name in sources if name != dropped] + with _law(simulation, uc_path + ".sources", kept): + assert capital("uc_assessable_capital") == expected, dropped + # Emptying UC's person_sources returns both ISAs to the pool: savings + # 2,000 and the ISAs, 16,000 in all, shared by two claimants. Housing + # Benefit keeps its own lists. + with _law(simulation, uc_path + ".person_sources", []): + assert capital("uc_assessable_capital") == [8_000, 8_000] + assert capital("housing_benefit_assessable_capital") == [1_000, 15_000] + assert capital("uc_assessable_capital") == [1_000, 15_000] @pytest.mark.parametrize("variable", CAPITAL) From fdbae65e3517af670943edf62be4ad337095de77 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 07:03:19 -0500 Subject: [PATCH 08/27] Describe the ISA attribution with the formulas and the model as actors The parameter, variable and formula descriptions and the assumptions page now say who does each step: the formulas count each balance in its holder's unit and subtract the counted balances before they share the pool, and the model scales balances above a total and rounds each down to float32. The UC text states that a reporting unit leaves its own ISAs in the pool and the formula subtracts the report instead. Co-Authored-By: Claude Opus 5.5 --- .../isa-inputs-and-capital-flows.md | 34 ++++++++++++------- .../ESA/income/capital/person_sources.yaml | 21 ++++++------ .../gov/dwp/ESA/income/capital/sources.yaml | 5 +-- .../JSA/income/capital/person_sources.yaml | 21 ++++++------ .../gov/dwp/JSA/income/capital/sources.yaml | 5 +-- .../means_test/capital/person_sources.yaml | 17 +++++----- .../means_test/capital/sources.yaml | 5 +-- .../means_test/capital/person_sources.yaml | 21 ++++++------ .../means_test/capital/sources.yaml | 5 +-- .../income/capital/person_sources.yaml | 21 ++++++------ .../income/capital/sources.yaml | 5 +-- .../means_test/capital/person_sources.yaml | 21 ++++++------ .../means_test/capital/sources.yaml | 5 +-- .../variables/gov/dwp/_person_capital_pool.py | 30 ++++++++-------- .../gov/dwp/cash_isa_countable_capital.py | 17 +++++----- .../gov/dwp/esa_income_assessable_capital.py | 6 ++-- .../housing_benefit_assessable_capital.py | 6 ++-- .../dwp/income_support_assessable_capital.py | 6 ++-- .../gov/dwp/jsa_income_assessable_capital.py | 6 ++-- .../pension_credit_assessable_capital.py | 6 ++-- ...stocks_and_shares_isa_countable_capital.py | 17 +++++----- .../universal_credit/uc_assessable_capital.py | 14 ++++---- 22 files changed, 160 insertions(+), 134 deletions(-) diff --git a/docs/book/assumptions/isa-inputs-and-capital-flows.md b/docs/book/assumptions/isa-inputs-and-capital-flows.md index 7ec3e93c71..9621ef3845 100644 --- a/docs/book/assumptions/isa-inputs-and-capital-flows.md +++ b/docs/book/assumptions/isa-inputs-and-capital-flows.md @@ -109,19 +109,27 @@ changes what the total sums or stops a person balance reading its inputs. A household input in a situation applies only to its own year; the person balances carry the variable-level uprating to later years. -Each means test counts a member's cash and stocks and shares ISA balances in -the member's own benefit unit, through `cash_isa_countable_capital` and -`stocks_and_shares_isa_countable_capital` in its `person_sources`, and only for -the claimant and partner. A dependant's balance, a child's junior ISA among -them, counts in no benefit unit. The household pool leaves those balances out -of `cash_isa` and `share_like_capital` and shares the rest between benefit -units. Where the balances add up to less than the total the model holds for -the year, the rest stays in the pool. Where they add up to more, each counts in -proportion, so the household counts no more than that total. Data that store -only household totals hold no balances, so the pool keeps the whole total, as -before. A balance counts only while the programme's `sources` lists the -household source that holds it, so a reform that drops `cash_isa` or -`share_like_capital` from `sources` drops the balances with it. +Each means-test formula counts the claimant's and partner's cash and stocks and +shares ISA balances in their own benefit unit, through +`cash_isa_countable_capital` and `stocks_and_shares_isa_countable_capital` in +its `person_sources`. A dependant's balance, a child's junior ISA among them, +counts in no benefit unit. The formulas subtract a household's counted balances +from `cash_isa` and `share_like_capital` before they share the remainder +between benefit units. Where the balances add up to less than the total the +model holds for the year, the remainder stays in the pool. Where they add up to +more, the model scales each balance by the total over their sum and rounds it +down to the float32 value it stores, so the household never counts more than +the total and the pool never falls below zero; a sole holder whose balance +reaches the total counts exactly the total. For stocks and shares ISAs the +model takes the smaller of `stocks_and_shares_isa` and `share_like_capital` as +the total. Data that store only household totals hold no balances, so the pool +keeps the whole total. The formulas count a balance only while the programme's +`sources` lists the household source that holds it, so a reform that drops +`cash_isa` or `share_like_capital` from `sources` drops the balances with it. +In Universal Credit, a unit that reports its capital +(`benunit_reported_capital` or `uc_reported_capital`, zero or more) leaves its +claimant's and partner's ISAs in the pool, and the formula subtracts the report +from the pool instead, as it does for company holdings. Ordinary accessible cash ISA balances enter the capital sources for Universal Credit, Housing Benefit, Pension Credit, Income Support, income-based JSA and diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml index 0f8c26c1a8..cee548a16e 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml @@ -8,17 +8,18 @@ description: > ISA at its surrender value: regulation 113 values capital at its current market or surrender value, and DMG 52665 counts a Lifetime ISA at 75% of its value under 60 and in full from 60. Lifetime ISAs exist from 6 April 2017, - and the list counts them from then. `cash_isa_countable_capital` and - `stocks_and_shares_isa_countable_capital` are a member's cash and stocks and - shares ISA balances. An ISA has one holder, who owns its investments + and the formulas count them from then. `cash_isa_countable_capital` and + `stocks_and_shares_isa_countable_capital` hold a member's cash and stocks and + shares ISA balances. Each ISA has one holder, who owns its investments beneficially (Individual Savings Account Regulations 1998 reg. 4(1B)(c) and - (6)(a)), and a child holds their own junior ISA (reg. 2B). Each counts only - while `sources` lists the household source that already holds it - (`cash_isa`, `share_like_capital`), and the household pool then leaves it - out of that source, so it counts once, in its holder's own benefit unit, and - a list without the household source counts neither. Household data with no - balances keep the whole household total in the pool. ISAs exist from 6 April - 1999 (reg. 1). + (6)(a)), and a child holds their own junior ISA (reg. 2B). The formulas count + each balance only while `sources` lists the household source that already + holds it (`cash_isa`, `share_like_capital`), and subtract the counted + balances from that source before they share the pool, so each balance counts + once, in its holder's own benefit unit. When `sources` drops the household + source, the formulas count neither balance. Household data with no balances + leave the whole household total in the pool. ISAs exist from 6 April 1999 + (reg. 1). metadata: label: Income-related ESA person-level capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml index da1645b62a..8660cab79c 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml @@ -2,8 +2,9 @@ description: > Ordinary cash ISA balances are separate from savings and are included at their accessible cash value, without a generic cash sale-cost deduction. Where the data tie a cash ISA or stocks and shares ISA balance to a member, - `person_sources` counts it in the holder's own benefit unit, and the pool - takes it out of `cash_isa` or `share_like_capital` before sharing the rest. + the formulas count it through `person_sources` in the holder's own benefit + unit and subtract it from `cash_isa` or `share_like_capital` before they + share the rest. Model variables currently mapped to countable income-related Employment and Support Allowance capital. The Employment and Support Allowance Regulations 2008 treat capital as assessable unless it is disregarded under Schedule 9. diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml index f6884f9c62..9d7b829012 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml @@ -9,17 +9,18 @@ description: > Lifetime ISA at its surrender value: regulation 111 values capital at its current market or surrender value, and DMG 29665 counts a Lifetime ISA at 75% of its value under 60 and in full from 60. Lifetime ISAs exist from 6 - April 2017, and the list counts them from then. `cash_isa_countable_capital` - and `stocks_and_shares_isa_countable_capital` are a member's cash and stocks - and shares ISA balances. An ISA has one holder, who owns its investments + April 2017, and the formulas count them from then. `cash_isa_countable_capital` + and `stocks_and_shares_isa_countable_capital` hold a member's cash and stocks + and shares ISA balances. Each ISA has one holder, who owns its investments beneficially (Individual Savings Account Regulations 1998 reg. 4(1B)(c) and - (6)(a)), and a child holds their own junior ISA (reg. 2B). Each counts only - while `sources` lists the household source that already holds it - (`cash_isa`, `share_like_capital`), and the household pool then leaves it - out of that source, so it counts once, in its holder's own benefit unit, and - a list without the household source counts neither. Household data with no - balances keep the whole household total in the pool. ISAs exist from 6 April - 1999 (reg. 1). + (6)(a)), and a child holds their own junior ISA (reg. 2B). The formulas count + each balance only while `sources` lists the household source that already + holds it (`cash_isa`, `share_like_capital`), and subtract the counted + balances from that source before they share the pool, so each balance counts + once, in its holder's own benefit unit. When `sources` drops the household + source, the formulas count neither balance. Household data with no balances + leave the whole household total in the pool. ISAs exist from 6 April 1999 + (reg. 1). metadata: label: Income-based JSA person-level capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml index 1a11620e57..130d21de5d 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml @@ -2,8 +2,9 @@ description: > Ordinary cash ISA balances are separate from savings and are included at their accessible cash value, without a generic cash sale-cost deduction. Where the data tie a cash ISA or stocks and shares ISA balance to a member, - `person_sources` counts it in the holder's own benefit unit, and the pool - takes it out of `cash_isa` or `share_like_capital` before sharing the rest. + the formulas count it through `person_sources` in the holder's own benefit + unit and subtract it from `cash_isa` or `share_like_capital` before they + share the rest. Model variables currently mapped to countable income-based Jobseeker's Allowance capital. The Jobseeker's Allowance Regulations 1996 treat capital as assessable unless it is disregarded under Schedule 8. The current diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml index f95014864a..69f1d7d0a5 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml @@ -9,17 +9,18 @@ description: > pension-age regulations) values capital at its current market or surrender value, in the words of the DWP benefits' regulations, for which DWP guidance counts a Lifetime ISA at 75% of its value under 60 and in full from 60. - Lifetime ISAs exist from 6 April 2017, and the list counts them from then. + Lifetime ISAs exist from 6 April 2017, and the formulas count them from then. `cash_isa_countable_capital` and `stocks_and_shares_isa_countable_capital` - are a member's cash and stocks and shares ISA balances. An ISA has one + hold a member's cash and stocks and shares ISA balances. Each ISA has one holder, who owns its investments beneficially (Individual Savings Account Regulations 1998 reg. 4(1B)(c) and (6)(a)), and a child holds their own - junior ISA (reg. 2B). Each counts only while `sources` lists the household - source that already holds it (`cash_isa`, `share_like_capital`), and the - household pool then leaves it out of that source, so it counts once, in its - holder's own benefit unit, and a list without the household source counts - neither. Household data with no balances keep the whole household total in - the pool. ISAs exist from 6 April 1999 (reg. 1). + junior ISA (reg. 2B). The formulas count each balance only while `sources` + lists the household source that already holds it (`cash_isa`, + `share_like_capital`), and subtract the counted balances from that source + before they share the pool, so each balance counts once, in its holder's own + benefit unit. When `sources` drops the household source, the formulas count + neither balance. Household data with no balances leave the whole household + total in the pool. ISAs exist from 6 April 1999 (reg. 1). metadata: label: Housing Benefit person-level capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml index 99d25116f7..b901fb5be0 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml @@ -2,8 +2,9 @@ description: > Ordinary cash ISA balances are separate from savings and are included at their accessible cash value, without a generic cash sale-cost deduction. Where the data tie a cash ISA or stocks and shares ISA balance to a member, - `person_sources` counts it in the holder's own benefit unit, and the pool - takes it out of `cash_isa` or `share_like_capital` before sharing the rest. + the formulas count it through `person_sources` in the holder's own benefit + unit and subtract it from `cash_isa` or `share_like_capital` before they + share the rest. Model variables currently mapped to countable Housing Benefit capital. The Housing Benefit Regulations 2006 treat the whole of a claimant's capital as assessable unless it is disregarded under Schedule 6. The current mappings diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml index a467bb2865..0276d2dde3 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml @@ -9,17 +9,18 @@ description: > ISA at its surrender value: regulation 49 values capital at its current market or surrender value, and DMG 29665 counts a Lifetime ISA at 75% of its value under 60 and in full from 60. Lifetime ISAs exist from 6 April 2017, - and the list counts them from then. `cash_isa_countable_capital` and - `stocks_and_shares_isa_countable_capital` are a member's cash and stocks and - shares ISA balances. An ISA has one holder, who owns its investments + and the formulas count them from then. `cash_isa_countable_capital` and + `stocks_and_shares_isa_countable_capital` hold a member's cash and stocks and + shares ISA balances. Each ISA has one holder, who owns its investments beneficially (Individual Savings Account Regulations 1998 reg. 4(1B)(c) and - (6)(a)), and a child holds their own junior ISA (reg. 2B). Each counts only - while `sources` lists the household source that already holds it - (`cash_isa`, `share_like_capital`), and the household pool then leaves it - out of that source, so it counts once, in its holder's own benefit unit, and - a list without the household source counts neither. Household data with no - balances keep the whole household total in the pool. ISAs exist from 6 April - 1999 (reg. 1). + (6)(a)), and a child holds their own junior ISA (reg. 2B). The formulas count + each balance only while `sources` lists the household source that already + holds it (`cash_isa`, `share_like_capital`), and subtract the counted + balances from that source before they share the pool, so each balance counts + once, in its holder's own benefit unit. When `sources` drops the household + source, the formulas count neither balance. Household data with no balances + leave the whole household total in the pool. ISAs exist from 6 April 1999 + (reg. 1). metadata: label: Income Support person-level capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml index 7b29a0ffd6..132f0352ec 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml @@ -2,8 +2,9 @@ description: > Ordinary cash ISA balances are separate from savings and are included at their accessible cash value, without a generic cash sale-cost deduction. Where the data tie a cash ISA or stocks and shares ISA balance to a member, - `person_sources` counts it in the holder's own benefit unit, and the pool - takes it out of `cash_isa` or `share_like_capital` before sharing the rest. + the formulas count it through `person_sources` in the holder's own benefit + unit and subtract it from `cash_isa` or `share_like_capital` before they + share the rest. Capital sources included in the Income Support capital test. `private_pension_wealth` is excluded because Schedule 10 paragraphs 23 and 23A disregard the value of the right to receive an occupational or personal diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml index c91ff892c6..7c171f5f4f 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml @@ -8,18 +8,19 @@ description: > ISA at its surrender value: regulation 19 values capital at its current market or surrender value, and DMG 84751 counts a Lifetime ISA at 75% of its value under 60 and in full from 60, so a claimant's own Lifetime ISA counts - in full. Lifetime ISAs exist from 6 April 2017, and the list counts them + in full. Lifetime ISAs exist from 6 April 2017, and the formulas count them from then. `cash_isa_countable_capital` and - `stocks_and_shares_isa_countable_capital` are a member's cash and stocks and - shares ISA balances. An ISA has one holder, who owns its investments + `stocks_and_shares_isa_countable_capital` hold a member's cash and stocks and + shares ISA balances. Each ISA has one holder, who owns its investments beneficially (Individual Savings Account Regulations 1998 reg. 4(1B)(c) and - (6)(a)), and a child holds their own junior ISA (reg. 2B). Each counts only - while `sources` lists the household source that already holds it - (`cash_isa`, `share_like_capital`), and the household pool then leaves it - out of that source, so it counts once, in its holder's own benefit unit, and - a list without the household source counts neither. Household data with no - balances keep the whole household total in the pool. ISAs exist from 6 April - 1999 (reg. 1). + (6)(a)), and a child holds their own junior ISA (reg. 2B). The formulas count + each balance only while `sources` lists the household source that already + holds it (`cash_isa`, `share_like_capital`), and subtract the counted + balances from that source before they share the pool, so each balance counts + once, in its holder's own benefit unit. When `sources` drops the household + source, the formulas count neither balance. Household data with no balances + leave the whole household total in the pool. ISAs exist from 6 April 1999 + (reg. 1). metadata: label: Pension Credit person-level capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml index d13dee5871..3b40373c4f 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml @@ -2,8 +2,9 @@ description: > Ordinary cash ISA balances are separate from savings and are included at their accessible cash value, without a generic cash sale-cost deduction. Where the data tie a cash ISA or stocks and shares ISA balance to a member, - `person_sources` counts it in the holder's own benefit unit, and the pool - takes it out of `cash_isa` or `share_like_capital` before sharing the rest. + the formulas count it through `person_sources` in the holder's own benefit + unit and subtract it from `cash_isa` or `share_like_capital` before they + share the rest. PolicyEngine approximation of Pension Credit capital sources that count toward deemed income from capital, excluding the claimant's main home. `private_pension_wealth` is excluded because Schedule V paragraphs 22 and 23 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml index 4aa50904fc..b45cefcd1a 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml @@ -8,21 +8,22 @@ description: > ISA at its surrender value: regulation 49(1) values capital at its current market or surrender value, and ADM H1659 counts a Lifetime ISA at 75% of its value under 60 and in full from 60. Lifetime ISAs exist from 6 April 2017, - and the list counts them from then. A nonnegative financial observation + and the formulas count them from then. A nonnegative financial observation (benunit_reported_capital) replaces these covered financial sources to avoid counting an ISA twice. A complete UC override replaces them together with property and the household financial proxy, before the existing regulation 77 company substitution. `cash_isa_countable_capital` and - `stocks_and_shares_isa_countable_capital` are a member's cash and stocks and - shares ISA balances. An ISA has one holder, who owns its investments + `stocks_and_shares_isa_countable_capital` hold a member's cash and stocks and + shares ISA balances. Each ISA has one holder, who owns its investments beneficially (Individual Savings Account Regulations 1998 reg. 4(1B)(c) and - (6)(a)), and a child holds their own junior ISA (reg. 2B). Each counts only - while `sources` lists the household source that already holds it - (`cash_isa`, `share_like_capital`), and the household pool then leaves it - out of that source, so it counts once, in its holder's own benefit unit, and - a list without the household source counts neither. Household data with no - balances keep the whole household total in the pool. ISAs exist from 6 April - 1999 (reg. 1). + (6)(a)), and a child holds their own junior ISA (reg. 2B). The formulas count + each balance only while `sources` lists the household source that already + holds it (`cash_isa`, `share_like_capital`), and subtract the counted + balances from that source before they share the pool, so each balance counts + once, in its holder's own benefit unit. When `sources` drops the household + source, the formulas count neither balance. Household data with no balances + leave the whole household total in the pool. ISAs exist from 6 April 1999 + (reg. 1). metadata: label: Universal Credit person-level capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml index 92ec3339ac..e2c26366e9 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml @@ -2,8 +2,9 @@ description: > Ordinary cash ISA balances are separate from savings and are included at their accessible cash value, without a generic cash sale-cost deduction. Where the data tie a cash ISA or stocks and shares ISA balance to a member, - `person_sources` counts it in the holder's own benefit unit, and the pool - takes it out of `cash_isa` or `share_like_capital` before sharing the rest. + the formulas count it through `person_sources` in the holder's own benefit + unit and subtract it from `cash_isa` or `share_like_capital` before they + share the rest. Household financial proxies for Universal Credit: savings and share-like financial wealth. `share_like_capital` is `corporate_wealth` as supplied, or the sum of its itemised components (`directly_held_shares`, diff --git a/policyengine_uk/variables/gov/dwp/_person_capital_pool.py b/policyengine_uk/variables/gov/dwp/_person_capital_pool.py index c3ac11d118..4c5f163d45 100644 --- a/policyengine_uk/variables/gov/dwp/_person_capital_pool.py +++ b/policyengine_uk/variables/gov/dwp/_person_capital_pool.py @@ -2,11 +2,11 @@ from policyengine_uk.model_api import * -# Each person-level capital source whose holdings sit inside a household -# capital source, with that source. A cash ISA balance is part of cash_isa, -# and a stocks and shares ISA balance part of share_like_capital (through -# stocks_and_shares_isa, inside corporate_wealth or the itemised sum). A -# person source with no entry here, such as a Lifetime ISA, sits in none. +# Maps each person-level capital source to the household capital source that +# already holds it: cash_isa holds each cash ISA balance, and +# share_like_capital each stocks and shares ISA balance (through +# stocks_and_shares_isa, inside corporate_wealth or the itemised sum). No +# household source holds a person source missing here, such as a Lifetime ISA. HOUSEHOLD_SOURCE_OF = { "cash_isa_countable_capital": "cash_isa", "stocks_and_shares_isa_countable_capital": "share_like_capital", @@ -14,12 +14,13 @@ def counted_person_sources(capital) -> list: - """The person sources in ``capital.person_sources`` that count. + """The person sources in ``capital.person_sources`` the formulas count. - A person source that sits inside a household source counts only while - ``capital.sources`` lists that household source: it moves the part of the - source the data tie to a person into the holder's own benefit unit, so a - list without the household source counts neither. + The formulas count a person source that a household source holds only + while ``capital.sources`` lists that household source. Counting it moves + the part of the household source the data tie to a person into the + holder's own benefit unit, so when the list drops the household source the + formulas count neither. """ sources = set(capital.sources) return [ @@ -33,10 +34,11 @@ def pooled_person_capital(benunit, source, period, capital, leaves_pool=None): """The part of the household ``source`` that counted person sources hold. The household sum, for each benefit unit, of the counted person sources - in ``capital.person_sources`` that sit inside ``source``, over the - members ``leaves_pool`` selects (all members by default). The household - pool takes it out of ``source`` before sharing the rest between benefit - units, so each of those pounds counts only in its holder's own unit. + in ``capital.person_sources`` that ``source`` holds, over the members + ``leaves_pool`` selects (all members by default), as Core stores a total. + The formulas subtract it from ``source`` before they share the remainder + between benefit units, so each of those pounds counts only in its holder's + own unit. """ person = benunit.members amount = 0 diff --git a/policyengine_uk/variables/gov/dwp/cash_isa_countable_capital.py b/policyengine_uk/variables/gov/dwp/cash_isa_countable_capital.py index 9d9ab2dc1a..1ccde2a088 100644 --- a/policyengine_uk/variables/gov/dwp/cash_isa_countable_capital.py +++ b/policyengine_uk/variables/gov/dwp/cash_isa_countable_capital.py @@ -10,14 +10,15 @@ class cash_isa_countable_capital(Variable): "The person's cash ISA balance (cash_isa_balance) as the means tests " "count it, in the holder's own benefit unit. An ISA has one holder, " "who owns its investments beneficially, and a child holds their own " - "junior ISA. The household's cash_isa already counts the balance, so " - "each means test takes it out of the household pool it shares between " - "benefit units. Where the household's balances add up to more than " - "cash_isa, the total the model holds for the household, each is " - "scaled down by the same proportion, so no household counts more cash " - "ISA than cash_isa. Where they add up to less, the rest of cash_isa " - "stays in the pool. A household with no balances keeps all of " - "cash_isa in the pool." + "junior ISA. The household's cash_isa already holds the balance, so " + "each means test subtracts it from the household pool before sharing " + "the pool between benefit units. Where the household's balances add up " + "to more than cash_isa, the total the model holds for the household, " + "the model scales each by cash_isa over their sum and rounds it down " + "to float32, so the household never counts more cash ISA than " + "cash_isa. Where they add up to less, the rest of cash_isa stays in " + "the pool, and a household with no balances keeps all of cash_isa " + "there." ) entity = Person definition_period = YEAR 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 ddce4884cc..b05cd338fb 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py @@ -20,9 +20,9 @@ class esa_income_assessable_capital(Variable): "Person-level sources, such as a Lifetime ISA, count only for the " "holder's own benunit, and only when the holder is its claimant or " "partner (is_claimant_or_partner): a dependant's capital is not the " - "claimant's. A cash or stocks and shares ISA balance tied to a person " - "counts the same way, and leaves the household capital the proxy shares " - "out." + "claimant's. The formula counts a person's cash and stocks and shares ISA " + "balances the same way and subtracts them from the household capital " + "before sharing it out." ) definition_period = YEAR unit = GBP 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 5dcf2a0835..b87f1d0f15 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 @@ -17,9 +17,9 @@ class housing_benefit_assessable_capital(Variable): "no weight. Person-level sources, such as a Lifetime ISA, count only for " "the holder's own benunit, and only when the holder is its claimant or " "partner (is_claimant_or_partner): a dependant's capital is not the " - "claimant's. A cash or stocks and shares ISA balance tied to a person " - "counts the same way, and leaves the household capital the proxy shares " - "out. Where the Pension Credit award is savings credit only, the " + "claimant's. The formula counts a person's cash and stocks and shares ISA " + "balances the same way and subtracts them from the household capital " + "before sharing it out. Where the Pension Credit award is savings credit only, the " "Pension Credit assessment of capital is used instead. It is nil for a " "family on Universal Credit, Income Support, income-based Jobseeker's " "Allowance or income-related Employment and Support Allowance " 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 3aa658f224..7a1b051ac1 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py @@ -19,9 +19,9 @@ class income_support_assessable_capital(Variable): "Person-level sources, such as a Lifetime ISA, count only for the " "holder's own benunit, and only when the holder is its claimant or " "partner (is_claimant_or_partner): a dependant's capital is not the " - "claimant's. A cash or stocks and shares ISA balance tied to a person " - "counts the same way, and leaves the household capital the proxy shares " - "out." + "claimant's. The formula counts a person's cash and stocks and shares ISA " + "balances the same way and subtracts them from the household capital " + "before sharing it out." ) definition_period = YEAR unit = GBP 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 33da25a102..2882faf466 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py @@ -20,9 +20,9 @@ class jsa_income_assessable_capital(Variable): "Person-level sources, such as a Lifetime ISA, count only for the " "holder's own benunit, and only when the holder is its claimant or " "partner (is_claimant_or_partner): a dependant's capital is not the " - "claimant's. A cash or stocks and shares ISA balance tied to a person " - "counts the same way, and leaves the household capital the proxy shares " - "out." + "claimant's. The formula counts a person's cash and stocks and shares ISA " + "balances the same way and subtracts them from the household capital " + "before sharing it out." ) definition_period = YEAR unit = GBP 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 9739899f37..b1a431f3c1 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 @@ -16,9 +16,9 @@ class pension_credit_assessable_capital(Variable): "age adults. Person-level sources, such as a Lifetime ISA, count " "only for the holder's own benunit, and only when the holder is its " "claimant or partner (is_claimant_or_partner): a dependant's capital " - "is not the claimant's. A cash or stocks and shares ISA balance tied to " - "a person counts the same way, and leaves the household capital the " - "proxy shares out. Where `pension_credit_reported_capital` records " + "is not the claimant's. The formula counts a person's cash and stocks and " + "shares ISA balances the same way and subtracts them from the household " + "capital before sharing it out. Where `pension_credit_reported_capital` records " "the benefit unit's own capital (0 or more), it replaces the " "household proxy and the person-level sources. Unlike " "`uc_assessable_capital`, another benefit unit's recorded capital is " diff --git a/policyengine_uk/variables/gov/dwp/stocks_and_shares_isa_countable_capital.py b/policyengine_uk/variables/gov/dwp/stocks_and_shares_isa_countable_capital.py index 9092720f57..c2a60adfc4 100644 --- a/policyengine_uk/variables/gov/dwp/stocks_and_shares_isa_countable_capital.py +++ b/policyengine_uk/variables/gov/dwp/stocks_and_shares_isa_countable_capital.py @@ -11,15 +11,16 @@ class stocks_and_shares_isa_countable_capital(Variable): "(stocks_and_shares_isa_balance) as the means tests count it, in the " "holder's own benefit unit. An ISA has one holder, who owns its " "investments beneficially, and a child holds their own junior ISA. " - "The household's share_like_capital already counts the balance, " + "The household's share_like_capital already holds the balance, " "through stocks_and_shares_isa within corporate_wealth or the " - "itemised sum, so each means test takes it out of the household pool " - "it shares between benefit units. Where the household's balances add " - "up to more than the smaller of stocks_and_shares_isa and " - "share_like_capital, each is scaled down by the same proportion, so " - "no household counts more stocks and shares ISA than it holds. Where " - "they add up to less, the rest stays in the pool. A household with no " - "balances keeps all of share_like_capital in the pool." + "itemised sum, so each means test subtracts it from the household " + "pool before sharing the pool between benefit units. Where the " + "household's balances add up to more than the smaller of " + "stocks_and_shares_isa and share_like_capital, the model scales each " + "by that total over their sum and rounds it down to float32, so the " + "household never counts more stocks and shares ISA than it holds. " + "Where they add up to less, the rest stays in the pool, and a " + "household with no balances keeps all of share_like_capital there." ) entity = Person definition_period = YEAR 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 1fd59613b2..08f20a43a2 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 @@ -29,9 +29,11 @@ class uc_assessable_capital(Variable): "independent of other units' financial reports. These " "allocations are assumptions about unobserved ownership, not law. " "Person-level proxies count only claimants/partners, not dependants. " - "A cash or stocks and shares ISA balance tied to a person leaves the " - "financial pool, unless a report or override of the holder's own unit " - "already covers it. " + "The formula subtracts each person's cash and stocks and shares ISA " + "balances from the financial pool and counts them in the holder's " + "unit, except where the holder's unit reports its capital: there it " + "leaves its claimant's and partner's ISAs in the pool and subtracts " + "the report instead. " "Under regulation 77, disregarded company holdings are replaced by " "the claimant/partner's share of company capital." ) @@ -50,9 +52,9 @@ def formula(benunit, period, parameters): p = parameters(period).gov.dwp.universal_credit.means_test.capital person = benunit.members claimant_or_partner = person("is_uc_claimant", period) - # A report or complete override already covers its claimant's and - # partner's ISAs, as it does their company holdings, and consumes - # them from the pool. Every other person's ISA leaves the pool here. + # For a unit with a report or complete override, leave its claimant's + # and partner's ISAs in the pool and subtract the report, as for their + # company holdings. Subtract every other person's ISA here. covered = claimant_or_partner & ( (person.benunit("benunit_reported_capital", period) >= 0) | (person.benunit("uc_reported_capital", period) >= 0) From e2d52d2da8d641000fcaba397900dcc6a0389409 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 07:16:52 -0500 Subject: [PATCH 09/27] List the new slow ISA tests in the shard table Five tests in test_person_isa_capital.py took 27 to 117 seconds locally, one at a time at load 40 to 55, so the shards now deal them out by duration instead of by hash. The changelog fragment names the model as the actor. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2729.fixed.md | 2 +- policyengine_uk/tests/shard_durations.toml | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/changelog.d/2729.fixed.md b/changelog.d/2729.fixed.md index 155c9d4591..65c113faa5 100644 --- a/changelog.d/2729.fixed.md +++ b/changelog.d/2729.fixed.md @@ -1 +1 @@ -Universal Credit, Housing Benefit, Pension Credit, Income Support, income-based JSA and income-related ESA count each person's cash ISA and stocks and shares ISA balances in their own benefit unit, so a lodger's or an adult child's ISA no longer adds to the main family's capital. +The model now counts each person's cash ISA and stocks and shares ISA balances in their own benefit unit for Universal Credit, Housing Benefit, Pension Credit, Income Support, income-based JSA and income-related ESA, so a lodger's or an adult child's ISA no longer adds to the main family's capital. diff --git a/policyengine_uk/tests/shard_durations.toml b/policyengine_uk/tests/shard_durations.toml index 2c52eb6701..fc12807835 100644 --- a/policyengine_uk/tests/shard_durations.toml +++ b/policyengine_uk/tests/shard_durations.toml @@ -579,3 +579,10 @@ "policyengine_uk/tests/test_disable_simulated_benefits_winter_heating.py::test_a_household_that_moves_is_paid_under_each_years_scheme[LONDON-SCOTLAND]" = 20 "policyengine_uk/tests/test_capital_gains_responses.py::test_revenue_falls_short_of_the_static_estimate" = 20 "policyengine_uk/tests/test_capital_gains_response_default.py::test_employer_cost_branches_hold_the_realisation_response" = 20 +# test_person_isa_capital.py (#2729): local timings on 1 November 2026, one +# test at a time at load 40 to 55, until CI logs replace them. +"policyengine_uk/tests/test_person_isa_capital.py::test_balances_alone_and_totals_alone_give_one_unit_the_same_capital" = 117 +"policyengine_uk/tests/test_person_isa_capital.py::test_household_only_data_keep_todays_capital" = 73 +"policyengine_uk/tests/test_person_isa_capital.py::test_a_pound_counts_once_in_working_age_means_tests" = 60 +"policyengine_uk/tests/test_person_isa_capital.py::test_both_forms_in_one_dataset_add_up_to_the_stored_total" = 38 +"policyengine_uk/tests/test_person_isa_capital.py::test_person_balances_without_household_totals_count_with_their_holders" = 27 From c1098e8f1a9383bfc5d6cc4e9b9864adf2f85e4e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 08:20:03 -0500 Subject: [PATCH 10/27] Run two examples of the two slowest ISA properties The Pension Credit property took 191 seconds for four examples locally, and each balances-alone example builds two simulations. Two generated examples each keep their strategies and halve the work; the shard table records the new timings, 89 and 221 seconds at load 80 to 90. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/shard_durations.toml | 5 +++-- policyengine_uk/tests/test_person_isa_capital.py | 9 ++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/shard_durations.toml b/policyengine_uk/tests/shard_durations.toml index fc12807835..59896675e7 100644 --- a/policyengine_uk/tests/shard_durations.toml +++ b/policyengine_uk/tests/shard_durations.toml @@ -580,8 +580,9 @@ "policyengine_uk/tests/test_capital_gains_responses.py::test_revenue_falls_short_of_the_static_estimate" = 20 "policyengine_uk/tests/test_capital_gains_response_default.py::test_employer_cost_branches_hold_the_realisation_response" = 20 # test_person_isa_capital.py (#2729): local timings on 1 November 2026, one -# test at a time at load 40 to 55, until CI logs replace them. -"policyengine_uk/tests/test_person_isa_capital.py::test_balances_alone_and_totals_alone_give_one_unit_the_same_capital" = 117 +# test at a time at load 40 to 90, until CI logs replace them. +"policyengine_uk/tests/test_person_isa_capital.py::test_balances_alone_and_totals_alone_give_one_unit_the_same_capital" = 221 +"policyengine_uk/tests/test_person_isa_capital.py::test_a_pound_counts_once_in_pension_credit" = 89 "policyengine_uk/tests/test_person_isa_capital.py::test_household_only_data_keep_todays_capital" = 73 "policyengine_uk/tests/test_person_isa_capital.py::test_a_pound_counts_once_in_working_age_means_tests" = 60 "policyengine_uk/tests/test_person_isa_capital.py::test_both_forms_in_one_dataset_add_up_to_the_stored_total" = 38 diff --git a/policyengine_uk/tests/test_person_isa_capital.py b/policyengine_uk/tests/test_person_isa_capital.py index 0a0f5d2fcf..0f24fe39b0 100644 --- a/policyengine_uk/tests/test_person_isa_capital.py +++ b/policyengine_uk/tests/test_person_isa_capital.py @@ -463,9 +463,12 @@ def test_person_balances_without_household_totals_count_with_their_holders(): min_size=1, max_size=4, ) -# Each example runs whole means tests, so keep the examples few. -DATASET_SETTINGS = settings(SETTINGS, max_examples=3) +# Each example runs whole means tests, so keep the examples few: a dataset +# example builds two simulations, and a Pension Credit example took about 45 +# seconds locally. +DATASET_SETTINGS = settings(SETTINGS, max_examples=2) SITUATION_SETTINGS = settings(SETTINGS, max_examples=4) +PENSION_CREDIT_SETTINGS = settings(SETTINGS, max_examples=2) @DATASET_SETTINGS @@ -625,7 +628,7 @@ def test_a_pound_counts_once_in_working_age_means_tests(units, holdings): assert (capital >= own * (1 - 1e-6) - 0.01).all(), name -@SITUATION_SETTINGS +@PENSION_CREDIT_SETTINGS @given( pensioners=st.lists(_unit([70, 75]), min_size=1, max_size=2), others=st.lists(_unit([30]), min_size=0, max_size=1), From 960272fd0b83a7fd728ce1e524bc8c13890c95c0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 08:52:15 -0500 Subject: [PATCH 11/27] Give the ISA documentation strings real verbs A child owns their own junior ISA, the helper's total gives each member's household total, and the assumptions page names the UC formula as the actor for a reporting unit. Co-Authored-By: Claude Opus 5.5 --- .../assumptions/isa-inputs-and-capital-flows.md | 8 ++++---- .../variables/gov/dwp/_person_capital_pool.py | 15 ++++++++------- .../variables/input/cash_isa_balance.py | 2 +- .../input/stocks_and_shares_isa_balance.py | 4 ++-- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/docs/book/assumptions/isa-inputs-and-capital-flows.md b/docs/book/assumptions/isa-inputs-and-capital-flows.md index 9621ef3845..58162a4c8a 100644 --- a/docs/book/assumptions/isa-inputs-and-capital-flows.md +++ b/docs/book/assumptions/isa-inputs-and-capital-flows.md @@ -126,10 +126,10 @@ the total. Data that store only household totals hold no balances, so the pool keeps the whole total. The formulas count a balance only while the programme's `sources` lists the household source that holds it, so a reform that drops `cash_isa` or `share_like_capital` from `sources` drops the balances with it. -In Universal Credit, a unit that reports its capital -(`benunit_reported_capital` or `uc_reported_capital`, zero or more) leaves its -claimant's and partner's ISAs in the pool, and the formula subtracts the report -from the pool instead, as it does for company holdings. +For a Universal Credit unit that reports its capital +(`benunit_reported_capital` or `uc_reported_capital`, zero or more), the +formula leaves the unit's claimant's and partner's ISAs in the pool and +subtracts the report instead, as it does for their company holdings. Ordinary accessible cash ISA balances enter the capital sources for Universal Credit, Housing Benefit, Pension Credit, Income Support, income-based JSA and diff --git a/policyengine_uk/variables/gov/dwp/_person_capital_pool.py b/policyengine_uk/variables/gov/dwp/_person_capital_pool.py index 4c5f163d45..aa77a505b1 100644 --- a/policyengine_uk/variables/gov/dwp/_person_capital_pool.py +++ b/policyengine_uk/variables/gov/dwp/_person_capital_pool.py @@ -56,7 +56,7 @@ def pooled_person_capital(benunit, source, period, capital, leaves_pool=None): def bounded_person_holding(person, balance, household_total): """Each member's ``balance``, bounded by ``household_total``. - ``household_total`` is the household's total for each member. See + ``household_total`` gives the household's total for each member. See ``bounded_holdings``, which this calls with Core's household sum. """ return bounded_holdings(balance, household_total, person.household.sum) @@ -69,11 +69,12 @@ def bounded_holdings(balance, household_total, household_sum): ``household_sum`` maps a member array to each member's household sum, as Core's ``person.household.sum`` does. A negative or missing balance holds nothing, and an infinite one the whole total. Where the household's - balances add up, stored as Core stores a total, to the nonnegative total - or less, each member holds their balance exactly, and no one the data - name holds the rest. Where they add up to more, the function scales each - balance by the total over their sum and rounds it down to float32, as - Core stores the result. For every float32 balance and finite total: + balances, summed as Core stores a total, come to the nonnegative total or + less, each member holds their balance exactly, and the rest of the total + stays with no one the data name. Where they come to more, the function + scales each balance by the total over their sum and rounds it down to + float32, as Core stores the result. For every float32 balance and finite + total: - no holding exceeds the household total; - the household's stored sum of holdings never exceeds the total, so the @@ -89,7 +90,7 @@ def bounded_holdings(balance, household_total, household_sum): held_sum = np.asarray(household_sum(held), np.float64) over = _as_stored(held_sum) > total # Float64 holds the product of two float32 amounts exactly, so each share - # rounds once, in the division. A sole holder's share is then the total + # rounds once, in the division. A sole holder's share then equals the total # itself, and rounding every share down keeps their sum within the total. share = held * total.astype(np.float64) / np.where(over, held_sum, 1) return np.where(over, _round_down_to_float32(share), held) diff --git a/policyengine_uk/variables/input/cash_isa_balance.py b/policyengine_uk/variables/input/cash_isa_balance.py index 2d6c7d7f54..d290b1cdb3 100644 --- a/policyengine_uk/variables/input/cash_isa_balance.py +++ b/policyengine_uk/variables/input/cash_isa_balance.py @@ -8,7 +8,7 @@ class cash_isa_balance(Variable): "(lifetime_isa_balance). The household's cash_isa sums it over its " "members, so a dataset that stores cash_isa itself keeps that stored " "total instead. It is separate from savings, the household's ordinary " - "savings. A child's junior cash ISA is the child's own. The means tests " + "savings. A child owns their own junior cash ISA. The means tests " "count the balance in its holder's benefit unit, for the claimant and " "partner only (cash_isa_countable_capital)." ) diff --git a/policyengine_uk/variables/input/stocks_and_shares_isa_balance.py b/policyengine_uk/variables/input/stocks_and_shares_isa_balance.py index 48c90a2b85..eed2587cb8 100644 --- a/policyengine_uk/variables/input/stocks_and_shares_isa_balance.py +++ b/policyengine_uk/variables/input/stocks_and_shares_isa_balance.py @@ -9,8 +9,8 @@ class stocks_and_shares_isa_balance(Variable): "stocks_and_shares_isa sums it over its members, so a dataset that " "stores stocks_and_shares_isa itself keeps that stored total instead. " "Like that total, it is part of corporate_wealth, so the two must " - "never be summed. A child's junior stocks and shares ISA is the " - "child's own. The means tests count the balance in its holder's " + "never be summed. A child owns their own junior stocks and shares " + "ISA. The means tests count the balance in its holder's " "benefit unit, for the claimant and partner only " "(stocks_and_shares_isa_countable_capital)." ) From f1f5477c30db9b44d96cb382b569e7908d78dc4b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 08:55:57 -0500 Subject: [PATCH 12/27] Name the UC formula as the actor in the report test cases Co-Authored-By: Claude Opus 5.5 --- .../policy/baseline/gov/dwp/person_isa_capital.yaml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml index d6c0fde5bd..38d888c030 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml @@ -581,11 +581,12 @@ household: members: [parent, child, lodger] output: - # The report covers the claimant and partner, not the child, whose - # 10,000 ISA leaves the pool, so the lodger's unit counts none of it. + # The formula takes the report as the claimant's and partner's capital, + # so the child's 10,000 ISA leaves the pool and the lodger's unit counts + # none of it. uc_assessable_capital: [0, 0] -- name: Case 20, a UC report keeps the inherited override, so a reporter's unabsorbed ISA stays in the pool. +- name: Case 20, a UC report keeps the inherited override, so the part of a reporter's ISA above the report stays in the pool. period: 2025 absolute_error_margin: 0 input: @@ -641,9 +642,9 @@ household_4: members: [holder_4, other_4] output: - # A reporting unit's own ISA stays in the pool, and the report - # consumes only its reported amount: the unit without a report counts - # the rest of the household's capital. + # The formula leaves a reporting unit's own ISA in the pool and + # subtracts only the reported amount, so the unit without a report + # counts the rest of the household's capital. uc_assessable_capital: [0, 1, 0, 1, 0, 10_000, 6_000, 4_000] - name: Case 21, a Pension Credit report replaces the reporting unit's capital and shares none of it. From da3c824cf0f61693e34f0ed9079e83f2d0a1a417 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 13:20:17 -0500 Subject: [PATCH 13/27] Sum an ISA total's scaled holdings to the total exactly Where a household's ISA balances exceed its total, round each proportional share down to a whole step of the total's float32 spacing and give the steps left over one each to the members whose shares lost most in rounding. The holdings then sum to the total exactly, so the pool keeps nothing of it, and no holding passes a capital limit its share stays under. Test the helper's invariants with a numpy household rank, pin an unrelated adult's capital, tariff and eligibility at UC's 6,000 and 16,000 and Pension Credit's 10,000 against reference households, and replace the two-example properties with explicit cases. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_person_isa_capital.py | 490 ++++++++++++++---- .../variables/gov/dwp/_person_capital_pool.py | 79 ++- 2 files changed, 450 insertions(+), 119 deletions(-) diff --git a/policyengine_uk/tests/test_person_isa_capital.py b/policyengine_uk/tests/test_person_isa_capital.py index 0f24fe39b0..f8db259077 100644 --- a/policyengine_uk/tests/test_person_isa_capital.py +++ b/policyengine_uk/tests/test_person_isa_capital.py @@ -2,19 +2,26 @@ Each means test counts cash_isa_countable_capital and stocks_and_shares_isa_countable_capital through person_sources, for the -claimant and partner, and takes them out of the household pool (cash_isa and -share_like_capital) before sharing the rest between benefit units. - -Invariants, for any nonnegative amounts: - -1. A pound counts once. Where the pool is shared by claimant weights (or, for - Pension Credit, pension-age adults), the capital the household's benefit - units count, plus the ISAs of members no unit counts (dependants, and for - Pension Credit holders outside a pension-age unit), equals savings, - cash_isa and share_like_capital together. -2. The household counts min(sum of balances, total) of each ISA kind, each - member at most their balance, and members over a total in proportion to - their balances. +claimant and partner. The formulas subtract them from the household source +that holds them (cash_isa and share_like_capital) and allocate the remainder +between benefit units. + +For the households below, with no reported capital, the formulas allocate the +remainder by claimant weights or, for Pension Credit, pension-age adult +counts. A UC unit that reports its capital leaves its claimant's and +partner's ISAs in the pool, and a Pension Credit report replaces the unit's +calculated capital; the YAML cases cover both. The invariants: + +1. A pound counts once. The capital the household's benefit units count, + plus the ISAs of members no unit counts (dependants, and for Pension + Credit holders outside a pension-age unit), equals savings, cash_isa and + share_like_capital together, up to float32 rounding of the remainder's + shares. Where balances exceed a total, the holdings sum to it exactly, so + that source leaves nothing in the remainder: an adult with no ISA counts + exactly the capital they count without the other members' ISAs. +2. Where the balances' stored sum exceeds the total, the holdings sum to + the total exactly, each in proportion to its balance within the step + invariant 7 gives; otherwise each member counts their balance exactly. 3. With no person balances, every means test gives what the lists without the ISA person sources give, in 2025, 2032, 2033 and 2040. 4. Differential: person balances alone and the household totals alone give @@ -25,14 +32,18 @@ 6. A reform that drops the household source drops the person source with it, and one that empties person_sources returns the ISAs to the pool. 7. The allocation arithmetic (bounded_holdings), for every float32 balance - and finite total: no holding exceeds the total; the household's stored sum - of holdings never exceeds it, so every remainder stays at or above zero; a - sole holder whose balance reaches the total holds exactly the total; - balances whose stored sum is within the total keep their exact values; - holdings over a total lie within float32 spacing of their proportional - shares; and the same input gives the same output. + and finite total: no holding exceeds the total or falls below zero; where + the balances' stored sum exceeds the total, the holdings' float64 sum + equals it; where it doesn't, each holding equals the balance; a sole + holder whose balance reaches the total holds exactly the total; each + holding is its proportional share rounded down or up to a whole step, + the total's float32 spacing, allowing for float64 sum and division + rounding, so it lies within one step of the share and passes a capital + limit only where the share does; and the same input gives the same + output. """ +import math from contextlib import contextmanager from fractions import Fraction @@ -144,8 +155,8 @@ def _dataset(households, year=YEAR, claims=True): ) -def _core_household_sum(household_of): - """Core's person.household.sum, for people in the given households. +def _core_household(household_of): + """Core's person population, and its household, for the given households. Builds Core's own populations by hand, with no Simulation, so the allocation properties run in milliseconds. @@ -155,9 +166,38 @@ def _core_household_sum(household_of): household = GroupPopulation(Household, person) household.members_entity_id = np.asarray(household_of, dtype=np.intp) household.count = int(max(household_of)) + 1 + return person, household + + +def _core_household_sum(household_of): + """Core's person.household.sum, for people in the given households.""" + _, household = _core_household(household_of) return lambda array: household.project(household.sum(array)) +def _household_rank(household_of): + """Each member's rank in their household, lowest first, ties in member order. + + Plain numpy, for the allocation properties; Core's + person.get_rank(person.household, ...) gives the same ranks. + """ + household_of = np.asarray(household_of) + index = np.arange(household_of.size) + + def rank(criteria): + # Sort by household, then criteria, then member order, and count each + # member's place from their household's first place. + order = np.lexsort((index, np.asarray(criteria), household_of)) + grouped = household_of[order] + first = np.r_[True, grouped[1:] != grouped[:-1]] + place = np.arange(order.size) + ranks = np.empty(order.size, dtype=int) + ranks[order] = place - np.maximum.accumulate(np.where(first, place, 0)) + return ranks + + return rank + + ANY_FLOAT32 = st.floats(width=32) FINITE_FLOAT32 = st.floats(width=32, allow_nan=False, allow_infinity=False) POUNDS = st.integers(min_value=0, max_value=4_000_000).map( @@ -188,6 +228,24 @@ def _core_household_sum(household_of): @example(allocation=([16_000.0], [(0, 16_790.0)])) @example(allocation=([6_000.0], [(0, 8_341.0)])) @example(allocation=([10_000.0], [(0, 16_450.0), (0, 0.0), (0, 0.0)])) +# A couple over the total beside an adult with no ISA, whose shares round +# down to one step short of it at the UC tariff threshold and limit. +@example(allocation=([32_000.0], [(0, 0.0), (0, 12_000.0), (0, 24_000.0)])) +@example(allocation=([64_000.0], [(0, 0.0), (0, 24_000.0), (0, 48_000.0)])) +# Three equal balances with steps left over for some of them. +@example(allocation=([10.0], [(0, 3.0), (0, 3.0), (0, 3.0)])) +# Three holders of exactly 16,000 and of 6,000 under totals one step short +# of their sum: each share falls just short of the balance, and no holding +# may pass it. +@example(allocation=([47_999.99609375], [(0, 16_000.0)] * 3)) +@example(allocation=([17_999.998046875], [(0, 6_000.0)] * 3)) +# Shares that round to the nearest float32 overshoot the total. +@example( + allocation=( + [46_000.98046875], + [(0, 10_000.0), (0, 10_000.0), (0, 16_001.0), (0, 10_000.0)], + ) +) # Balances whose float64 sum exceeds the total but rounds to it in float32. @example(allocation=([16_000.0], [(0, 8_000.00048828125), (0, 8_000.0)])) # A balance too small to change the float64 sum, beside one over the total. @@ -206,51 +264,63 @@ def test_holdings_stay_within_the_household_total(allocation): totals, members = allocation household_of = np.array([h for h, _ in members]) household_sum = _core_household_sum(household_of) + household_rank = _household_rank(household_of) balance = np.array([b for _, b in members], np.float32) total = np.array(totals, np.float32)[household_of] - holding = bounded_holdings(balance, total, household_sum) + holding = bounded_holdings(balance, total, household_sum, household_rank) assert holding.dtype == np.float32 cap = np.maximum(np.float32(0), total) held = np.where(np.isposinf(balance), cap, np.where(balance > 0, balance, 0)) held = held.astype(np.float32) + # The plain numpy rank agrees with Core's get_rank. + person, household = _core_household(household_of) + assert np.array_equal(household_rank(-held), person.get_rank(household, -held)) # No holding is negative or exceeds the household total. assert ((0 <= holding) & (holding <= cap)).all() - # Core stores the household's sum of holdings, as each means test nets it - # from the household source, within the total. with np.errstate(over="ignore"): - stored = household_sum(holding).astype(np.float32) - within = household_sum(held).astype(np.float32) <= cap - assert (cap - stored >= 0).all() + scales = household_sum(held).astype(np.float32) > cap + # Over a total, the holdings' float64 sum equals it exactly, so each + # means test nets the whole total from the household source and leaves + # nothing of it in the pool. + assert np.array_equal(household_sum(holding)[scales], cap[scales]) # Balances whose stored sum is within the total keep their exact values. - assert np.array_equal(holding[within], held[within]) + assert np.array_equal(holding[~scales], held[~scales]) # A sole holder whose balance reaches the total holds exactly the total. holders = household_sum((held > 0).astype(np.float32)) sole = (held > 0) & (holders == 1) & (held >= cap) assert np.array_equal(holding[sole], cap[sole]) - # Over a total, each holding lies within float32 spacing (plus float64 - # rounding) of its exact proportional share, and together they fall - # short of the total by at most one float32 spacing per member. - for i in np.flatnonzero(~within): + # Over a total, each holding is its exact proportional share rounded down + # or up to a whole step, the total's float32 spacing, allowing for float64 + # sum and division rounding. So it lies within one step of the share and + # passes a multiple of the step, such as a capital limit, only where the + # share does. + for i in np.flatnonzero(scales): same = np.flatnonzero(household_of == household_of[i]) exact = ( Fraction(float(held[i])) * Fraction(float(cap[i])) / sum(Fraction(float(held[j])) for j in same) ) - spacing = Fraction(float(np.spacing(np.float32(float(exact))))) - assert abs(Fraction(float(holding[i])) - exact) <= spacing + exact / 2**48 - assert cap[i] - stored[i] <= (len(same) + 1) * np.spacing(cap[i]) + step = Fraction(float(np.spacing(cap[i]))) + slack = exact / 2**48 + below = math.floor((exact - slack) / step) * step + above = math.ceil((exact + slack) / step) * step + assert below <= Fraction(float(holding[i])) <= above # The same input gives the same output, and one household's holdings # never depend on another's. - again = bounded_holdings(balance.copy(), total.copy(), household_sum) + again = bounded_holdings( + balance.copy(), total.copy(), household_sum, household_rank + ) assert np.array_equal(holding, again) for h in np.unique(household_of): alone = household_of == h + own_household = np.zeros(alone.sum(), dtype=int) result = bounded_holdings( balance[alone], total[alone], - _core_household_sum(np.zeros(alone.sum(), dtype=int)), + _core_household_sum(own_household), + _household_rank(own_household), ) assert np.array_equal(result, holding[alone]) @@ -259,9 +329,9 @@ def test_holdings_stay_within_the_household_total(allocation): def test_a_sole_holder_over_a_limit_holds_exactly_the_limit(limit): """Invariant 7, exhaustively near three capital limits. - Every float32 balance from the limit to four times it, each a sole - holder under a household total equal to the limit, and the next 1,000 - float32 balances above the limit. + Whole-pound balances from the limit up to, but excluding, four times it, + plus 1,000 adjacent float32 values starting at the limit, each a sole + holder under a household total equal to the limit. """ above = np.nextafter(np.float32(limit), np.float32(np.inf)) steps = np.arange(1_000, dtype=np.float32) * (above - np.float32(limit)) @@ -269,8 +339,10 @@ def test_a_sole_holder_over_a_limit_holds_exactly_the_limit(limit): [np.arange(limit, 4 * limit, dtype=np.float32), np.float32(limit) + steps] ) total = np.full_like(balance, limit) - household_sum = _core_household_sum(np.arange(balance.size)) - holding = bounded_holdings(balance, total, household_sum) + households = np.arange(balance.size) + holding = bounded_holdings( + balance, total, _core_household_sum(households), _household_rank(households) + ) assert (holding == np.float32(limit)).all() @@ -455,30 +527,27 @@ def test_person_balances_without_household_totals_count_with_their_holders(): ] -money = st.integers(min_value=0, max_value=2_000_000).map(lambda pence: pence / 100) -# One benefit unit: a claimant, perhaps a partner, each with a cash and a -# stocks and shares ISA, and a child; with savings and other shares. -ONE_UNIT_HOUSEHOLDS = st.lists( - st.tuples(st.lists(st.tuples(money, money), min_size=1, max_size=2), money, money), - min_size=1, - max_size=4, -) -# Each example runs whole means tests, so keep the examples few: a dataset -# example builds two simulations, and a Pension Credit example took about 45 -# seconds locally. -DATASET_SETTINGS = settings(SETTINGS, max_examples=2) -SITUATION_SETTINGS = settings(SETTINGS, max_examples=4) -PENSION_CREDIT_SETTINGS = settings(SETTINGS, max_examples=2) +# One benefit unit each, with a child: the adults' cash and stocks and shares +# ISA balances, other shares and savings. +ONE_UNIT_CASES = [ + # Nothing at all. + ([(0.0, 0.0)], 0.0, 0.0), + # A cash ISA alone, beside savings. + ([(5_000.0, 0.0)], 0.0, 2_000.0), + # A stocks and shares ISA alone, beside other shares. + ([(0.0, 7_000.0)], 1_500.0, 0.0), + # A couple's ISAs of both kinds, beside savings and other shares. + ([(3_000.0, 2_000.0), (4_000.25, 1_000.75)], 2_500.0, 6_000.0), + # A couple's balances whose float64 sum, 16,000.00048828125, Core stores + # as a 16,000 total, in both kinds. + ([(8_000.00048828125, 8_000.00048828125), (8_000.0, 8_000.0)], 0.0, 0.0), +] -@DATASET_SETTINGS -@given(households=ONE_UNIT_HOUSEHOLDS) -def test_balances_alone_and_totals_alone_give_one_unit_the_same_capital( - households, -): +def test_balances_alone_and_totals_alone_give_one_unit_the_same_capital(): """Invariant 4, differential: the two forms of the same holdings.""" balances, totals = [], [] - for adults, other_shares, savings in households: + for adults, other_shares, savings in ONE_UNIT_CASES: shares = sum(amounts[1] for amounts in adults) common = {"savings": savings, "corporate_wealth": other_shares + shares} people = [ @@ -498,9 +567,20 @@ def test_balances_alone_and_totals_alone_give_one_unit_the_same_capital( totals_only = Simulation(dataset=_dataset(totals)) for year in (YEAR, PROJECTION_END_YEAR + 1): for name in MEANS_TEST_CAPITAL: + # Float32 sums in either order round alike to within a penny. assert balances_only.calculate(name, year) == pytest.approx( totals_only.calculate(name, year), rel=1e-6, abs=0.01 ), (name, year) + # The couple at the rounding boundary keep their exact balances. + assert list(balances_only.calculate("cash_isa_countable_capital", YEAR)[-3:]) == [ + 8_000.00048828125, + 8_000.0, + 0.0, + ] + + +money = st.integers(min_value=0, max_value=2_000_000).map(lambda pence: pence / 100) +SITUATION_SETTINGS = settings(SETTINGS, max_examples=4) def _situation(units, assets, claim_uc=False): @@ -528,7 +608,6 @@ def _situation(units, assets, claim_uc=False): def _unit(adult_ages): """A benefit unit of one or two adults and up to two children.""" - person = st.tuples(money, money) adults = st.lists(st.sampled_from(adult_ages), min_size=1, max_size=2) children = st.lists(st.just(10), min_size=0, max_size=2) return st.tuples(adults, children).flatmap( @@ -566,33 +645,66 @@ def _assets(units, holdings): return assets -def _check_counted_isas(simulation, units, assets): - """Invariant 2: the household counts min(balances, total) of each kind.""" +def _by_household(simulation, entity, array): + """Sum a person or benefit unit array over each household, in float64.""" + person_household = simulation.populations["household"].members_entity_id + if entity == "person": + index = person_household + else: + index = np.zeros(simulation.populations["benunit"].count, dtype=int) + index[simulation.populations["benunit"].members_entity_id] = person_household + return np.bincount( + index, + weights=np.asarray(array, np.float64), + minlength=simulation.populations["household"].count, + ) + + +def _check_counted_isas(simulation): + """Invariant 2, in each household of the simulation. + + Where the balances' stored sum exceeds the total, the holdings sum to it + exactly, each within one step of the total of its balance; otherwise each + member counts their balance exactly. + """ totals = { - "cash_isa_countable_capital": simulation.calculate("cash_isa", YEAR)[0], - "stocks_and_shares_isa_countable_capital": min( - simulation.calculate("stocks_and_shares_isa", YEAR)[0], - simulation.calculate("share_like_capital", YEAR)[0], + "cash_isa_countable_capital": simulation.calculate("cash_isa", YEAR), + "stocks_and_shares_isa_countable_capital": np.minimum( + simulation.calculate("stocks_and_shares_isa", YEAR), + simulation.calculate("share_like_capital", YEAR), ), } + person_household = simulation.populations["household"].members_entity_id counted = {} for name, total in totals.items(): balance = simulation.calculate(BALANCE[name], YEAR) counted[name] = simulation.calculate(name, YEAR) - assert (counted[name] >= 0).all() - assert (counted[name] <= balance * (1 + 1e-6) + 1e-3).all() - assert counted[name].sum() == pytest.approx( - min(balance.sum(), max(0, total)), rel=1e-5, abs=0.01 + total = np.maximum(np.float32(0), total) + scales = _by_household(simulation, "person", balance).astype(np.float32) > total + assert np.array_equal( + _by_household(simulation, "person", counted[name])[scales], + total[scales].astype(np.float64), + ), name + member_scales = scales[person_household] + step = np.spacing(total)[person_household] + sums = _by_household(simulation, "person", balance)[person_household] + share = ( + balance + * total[person_household].astype(np.float64) + / np.where(member_scales, sums, 1) + ) + assert (counted[name] >= 0).all(), name + assert (abs(counted[name] - share) <= step)[member_scales].all(), name + assert (counted[name] <= balance + step).all(), name + assert np.array_equal(counted[name][~member_scales], balance[~member_scales]), ( + name ) - if balance.sum() > total > 0: - share = counted[name] / total - assert share == pytest.approx(balance / balance.sum(), abs=1e-5) return counted def _holdings(simulation): return sum( - simulation.calculate(name, YEAR)[0] + simulation.calculate(name, YEAR) for name in ("savings", "cash_isa", "share_like_capital") ) @@ -606,7 +718,7 @@ def test_a_pound_counts_once_in_working_age_means_tests(units, holdings): """Invariant 1 for UC, HB, IS, JSA and ESA, nobody on a claim path.""" assets = _assets(units, holdings) simulation = Simulation(situation=_situation(units, assets)) - counted = _check_counted_isas(simulation, units, assets) + counted = _check_counted_isas(simulation) dependants = ~simulation.calculate("is_claimant_or_partner", YEAR) uncounted = sum((amount * dependants).sum() for amount in counted.values()) for name in ( @@ -617,8 +729,9 @@ def test_a_pound_counts_once_in_working_age_means_tests(units, holdings): "esa_income_assessable_capital", ): capital = simulation.calculate(name, YEAR) + # Float32 shares of the remainder round to within a few pence. assert capital.sum() + uncounted == pytest.approx( - _holdings(simulation), rel=1e-5, abs=0.05 + _holdings(simulation)[0], rel=1e-5, abs=0.05 ), name # Each unit counts at least its own claimant's and partner's ISAs. own = sum( @@ -628,18 +741,97 @@ def test_a_pound_counts_once_in_working_age_means_tests(units, holdings): assert (capital >= own * (1 - 1e-6) - 0.01).all(), name -@PENSION_CREDIT_SETTINGS -@given( - pensioners=st.lists(_unit([70, 75]), min_size=1, max_size=2), - others=st.lists(_unit([30]), min_size=0, max_size=1), - holdings=HOLDINGS, -) -def test_a_pound_counts_once_in_pension_credit(pensioners, others, holdings): +# Households for Pension Credit's pool: each a list of benefit units of +# (age, person columns), with household columns. A dataset that stores a +# total for one household stores it for all, so households with no stored +# totals take a dataset of their own. +NO_STORED_TOTALS = [ + # A pensioner couple, both with ISAs, and an unrelated working-age holder. + { + "units": [ + [ + ( + 70, + { + "cash_isa_balance": 3_000.0, + "stocks_and_shares_isa_balance": 1_000.0, + }, + ), + (68, {"cash_isa_balance": 2_000.0}), + ], + [ + ( + 30, + { + "cash_isa_balance": 4_000.0, + "stocks_and_shares_isa_balance": 500.0, + }, + ) + ], + ], + "savings": 5_000, + "corporate_wealth": 2_500 + 1_500, + }, +] +STORED_TOTALS = [ + # Totals above the balances: a pensioner with a child who holds a junior + # ISA, and a separate pension-age unit. + { + "units": [ + [ + ( + 75, + { + "cash_isa_balance": 1_000.0, + "stocks_and_shares_isa_balance": 2_000.0, + }, + ), + (10, {"cash_isa_balance": 700.0}), + ], + [(72, {"stocks_and_shares_isa_balance": 3_000.0})], + ], + "savings": 4_000, + "cash_isa": 1_700 + 2_500, + "stocks_and_shares_isa": 5_000 + 1_500, + "corporate_wealth": 2_000 + 5_000 + 1_500, + }, + # Totals below the balances, so the formulas scale them: two separate + # pension-age units and an unrelated working-age holder. + { + "units": [ + [ + ( + 70, + { + "cash_isa_balance": 6_000.0, + "stocks_and_shares_isa_balance": 4_000.0, + }, + ) + ], + [(71, {"cash_isa_balance": 3_000.0})], + [ + ( + 30, + { + "cash_isa_balance": 2_000.0, + "stocks_and_shares_isa_balance": 2_000.0, + }, + ) + ], + ], + "savings": 1_000, + "cash_isa": 11_000 / 3, + "stocks_and_shares_isa": 2_000, + "corporate_wealth": 6_000 + 500, + }, +] + + +@pytest.mark.parametrize("households", [NO_STORED_TOTALS, STORED_TOTALS]) +def test_a_pound_counts_once_in_pension_credit(households): """Invariant 1 for Pension Credit, shared by pension-age adults only.""" - units = pensioners + others - assets = _assets(units, holdings) - simulation = Simulation(situation=_situation(units, assets)) - counted = _check_counted_isas(simulation, units, assets) + simulation = Simulation(dataset=_dataset(households, claims=False)) + counted = _check_counted_isas(simulation) pension_age = simulation.calculate( "has_attained_state_pension_credit_qualifying_age", YEAR ) @@ -652,11 +844,125 @@ def test_a_pound_counts_once_in_pension_credit(pensioners, others, holdings): > 0 ) counts = simulation.calculate("is_claimant_or_partner", YEAR) & in_pension_unit - uncounted = sum((amount * ~counts).sum() for amount in counted.values()) + uncounted = sum( + _by_household(simulation, "person", amount * ~counts) + for amount in counted.values() + ) capital = simulation.calculate("pension_credit_assessable_capital", YEAR) - assert capital.sum() + uncounted == pytest.approx( + # Float32 shares of the remainder round to within a few pence. + assert _by_household(simulation, "benunit", capital) + uncounted == pytest.approx( _holdings(simulation), rel=1e-5, abs=0.05 ) + if households is STORED_TOTALS: + # The last household's ISA holdings sum exactly to its totals, so its + # pool keeps savings of 1,000 and the 4,500 of share_like_capital + # beyond its 2,000 of ISAs: 2,750 for each pension-age adult. + own = sum(counted.values()) + assert list(capital[-3:]) == pytest.approx( + [2_750 + own[-3], 2_750 + own[-2], 0], rel=1e-6 + ) + + +# An adult with no ISA beside a couple whose cash ISAs exceed the household's +# cash_isa: (adults' age, cash_isa, the couple's balances, savings). The +# savings leave the adult exactly at a threshold: UC's tariff threshold of +# 6,000 and capital limit of 16,000, and Pension Credit's deemed income +# threshold of 10,000. +THRESHOLDS = { + "uc_tariff": (30, 32_000, (12_000, 24_000), 18_000), + "uc_limit": (30, 64_000, (24_000, 48_000), 48_000), + "pension_credit_deemed_income": (70, 32_000, (12_000, 24_000), 30_000), +} +THRESHOLD_OUTPUTS = ( + "uc_assessable_capital", + "uc_tariff_income", + "is_uc_eligible", + "pension_credit_assessable_capital", + "pension_credit_deemed_income", + "is_pension_credit_eligible", +) +NO_CLAIMS = { + name: {YEAR: False} + for name in ( + "would_claim_uc", + "would_claim_IS", + "would_claim_pc", + "housing_benefit_on_passporting_benefit", + ) +} + + +def _threshold_situation(): + """Each threshold household, then the same without the couple's ISAs. + + A last household holds three single adults, each with a 16,000 cash ISA, + under a cash_isa one float32 step short of their 48,000. + """ + people, benunits, households = {}, {}, {} + for case, (age, total, balances, savings) in THRESHOLDS.items(): + for form, keeps_isas in (("isas", True), ("reference", False)): + home = f"{case}_{form}" + names = [f"{home}_adult", f"{home}_holder_a", f"{home}_holder_b"] + for name, balance in zip(names, (0, *balances)): + people[name] = { + "age": {YEAR: age}, + "cash_isa_balance": {YEAR: balance if keeps_isas else 0}, + } + benunits[f"{home}_adult"] = {"members": names[:1], **NO_CLAIMS} + benunits[f"{home}_couple"] = {"members": names[1:], **NO_CLAIMS} + households[home] = { + "members": names, + "savings": {YEAR: savings}, + "cash_isa": {YEAR: total if keeps_isas else 0}, + } + names = [f"holder_{i}" for i in range(3)] + for name in names: + people[name] = {"age": {YEAR: 30}, "cash_isa_balance": {YEAR: 16_000}} + benunits[name] = {"members": [name], **NO_CLAIMS} + households["holders"] = { + "members": names, + "cash_isa": {YEAR: float(np.nextafter(np.float32(48_000), np.float32(0)))}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def test_isas_over_a_total_leave_an_unrelated_adult_exactly_at_a_threshold(): + """Invariant 1, exactly, at three thresholds. + + Rounding each scaled holding down would leave a remainder that the pool + shares with the adult: UC capital of 6,000.00048828125 and annual tariff + income of 52.20 rather than 0. The holdings sum exactly to cash_isa, so + the adult counts exactly what they count without the couple's ISAs. + """ + simulation = Simulation(situation=_threshold_situation()) + outputs = {name: simulation.calculate(name, YEAR) for name in THRESHOLD_OUTPUTS} + cash = simulation.calculate("cash_isa_countable_capital", YEAR) + for c, (case, (age, total, balances, savings)) in enumerate(THRESHOLDS.items()): + # Each case takes four units (adult and couple, with ISAs and without) + # and six people. + adult, reference = 4 * c, 4 * c + 2 + couple = cash[6 * c + 1 : 6 * c + 3] + assert couple.sum(dtype=np.float64) == total, case + for name, values in outputs.items(): + assert values[adult] == values[reference], (case, name) + capital = ( + "pension_credit_assessable_capital" + if age >= 66 + else "uc_assessable_capital" + ) + assert outputs[capital][adult] == savings / 3, case + assert outputs["uc_tariff_income"][0] == 0 + assert outputs["is_uc_eligible"][4] + assert outputs["pension_credit_deemed_income"][8] == 0 + # The three holders' shares come to 15,999.9987 each. Each holding is + # that share rounded down or up to a step of 1/256, so none passes the + # 16,000 limit its share stays under, and the three sum to cash_isa. + holders = cash[-3:] + assert list(holders) == [16_000, 16_000, 16_000 - 2**-8] + assert holders.sum(dtype=np.float64) == np.nextafter( + np.float32(48_000), np.float32(0) + ) + assert outputs["is_uc_eligible"][-3:].all() LODGER = { diff --git a/policyengine_uk/variables/gov/dwp/_person_capital_pool.py b/policyengine_uk/variables/gov/dwp/_person_capital_pool.py index aa77a505b1..196c92403c 100644 --- a/policyengine_uk/variables/gov/dwp/_person_capital_pool.py +++ b/policyengine_uk/variables/gov/dwp/_person_capital_pool.py @@ -57,30 +57,47 @@ def bounded_person_holding(person, balance, household_total): """Each member's ``balance``, bounded by ``household_total``. ``household_total`` gives the household's total for each member. See - ``bounded_holdings``, which this calls with Core's household sum. + ``bounded_holdings``, which this calls with Core's household sum and + household rank. """ - return bounded_holdings(balance, household_total, person.household.sum) + return bounded_holdings( + balance, + household_total, + person.household.sum, + lambda criteria: person.get_rank(person.household, criteria), + ) -def bounded_holdings(balance, household_total, household_sum): +def bounded_holdings(balance, household_total, household_sum, household_rank): """Each member's holding: their ``balance``, bounded by the household total. - ``household_total`` gives the household's total for each member, and + ``household_total`` gives the household's total for each member. ``household_sum`` maps a member array to each member's household sum, as - Core's ``person.household.sum`` does. A negative or missing balance holds - nothing, and an infinite one the whole total. Where the household's - balances, summed as Core stores a total, come to the nonnegative total or - less, each member holds their balance exactly, and the rest of the total - stays with no one the data name. Where they come to more, the function - scales each balance by the total over their sum and rounds it down to - float32, as Core stores the result. For every float32 balance and finite - total: - - - no holding exceeds the household total; - - the household's stored sum of holdings never exceeds the total, so the - pool's remainder stays at or above zero; + Core's ``person.household.sum`` does, and ``household_rank`` maps one to + each member's rank in their household, lowest first and ties in member + order, as Core's ``person.get_rank(person.household, ...)`` does. + + A negative or missing balance holds nothing, and an infinite one the + whole total. Where the household's balances, summed as Core stores a + total, come to the nonnegative total or less, each member holds their + balance exactly, and the rest of the total stays with no one the data + name. Where they come to more, the function scales the balances to the + total in whole steps of the total's float32 spacing. It rounds each + proportional share down to a step, then gives the steps left over one + each to the members whose shares lost the most in rounding, the first of + them on a tie. Every multiple of the step up to the total is an exact + float32, so the holdings sum to the total exactly. For every float32 + balance and finite total: + + - no holding exceeds the household total, and none is negative; + - where the balances' stored sum exceeds the total, the holdings' float64 + sum equals the total, so the pool keeps nothing of it; + - where it doesn't, each holding equals the balance; - a sole holder whose balance reaches the total holds exactly the total; - - each holding lies within float32 spacing of its proportional share; + - each holding is its proportional share, computed in float64, rounded + down or up to a whole step, so it lies within one step of that share + and passes a multiple of the step, such as a whole-pound capital + limit, only where the share does; - the same input gives the same output. """ total = np.maximum(np.float32(0), np.asarray(household_total, np.float32)) @@ -89,11 +106,25 @@ def bounded_holdings(balance, household_total, household_sum): held = np.maximum(np.float32(0), finite).astype(np.float32) held_sum = np.asarray(household_sum(held), np.float64) over = _as_stored(held_sum) > total + if not over.any(): + return held + # A household that doesn't scale takes a total of 0 and a step of 1 here, + # so an infinite total there stays quiet. Elsewhere the step is the + # total's own float32 spacing, as float64. + scaled = np.where(over, total, np.float32(0)) + step = np.spacing(np.where(over, total, np.float32(1))).astype(np.float64) # Float64 holds the product of two float32 amounts exactly, so each share - # rounds once, in the division. A sole holder's share then equals the total - # itself, and rounding every share down keeps their sum within the total. - share = held * total.astype(np.float64) / np.where(over, held_sum, 1) - return np.where(over, _round_down_to_float32(share), held) + # rounds once, in the division, and never exceeds the total. + share = held * scaled.astype(np.float64) / np.where(over, held_sum, 1) + steps = np.floor(share / step) + lost = share / step - steps + # The total over its spacing is a whole number, and the shares' whole + # steps never sum above it, so the steps left over are a whole number at + # or above zero, and no more than the members whose shares lost part of a + # step. + left_over = scaled / step - household_sum(steps) + takes_a_step = over & (household_rank(-lost) < left_over) + return np.where(over, ((steps + takes_a_step) * step).astype(np.float32), held) def _as_stored(amount): @@ -103,9 +134,3 @@ def _as_stored(amount): # the pool keeps its float32 arithmetic. with np.errstate(over="ignore"): return np.asarray(amount).astype(np.float32) - - -def _round_down_to_float32(amount): - # The largest float32 at or below each nonnegative float64 amount. - rounded = amount.astype(np.float32) - return np.where(rounded > amount, np.nextafter(rounded, np.float32(0)), rounded) From bc3d7123519a0058e93c3f1c05a1534604e46e3d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 13:49:27 -0500 Subject: [PATCH 14/27] Describe the ISA attribution with the formulas and DWP as actors Carry the report, passport and rounding qualifications into the six person_sources descriptions, and say what the scaled holdings now guarantee: they sum to the total exactly, each its share rounded to a whole float32 step of the total. Rewrite the capital variables' documentation strings and the pool helper's comments with the formulas, DWP and local authorities as actors. Co-Authored-By: Claude Opus 5.5 --- .../ESA/income/capital/person_sources.yaml | 39 +++++++-------- .../gov/dwp/ESA/income/capital/sources.yaml | 18 +++---- .../JSA/income/capital/person_sources.yaml | 41 ++++++++-------- .../gov/dwp/JSA/income/capital/sources.yaml | 17 +++---- .../means_test/capital/person_sources.yaml | 44 +++++++++-------- .../means_test/capital/sources.yaml | 12 ++--- .../means_test/capital/person_sources.yaml | 40 ++++++++-------- .../means_test/capital/sources.yaml | 6 +-- .../income/capital/person_sources.yaml | 44 ++++++++--------- .../income/capital/sources.yaml | 6 +-- .../means_test/capital/person_sources.yaml | 47 ++++++++++--------- .../means_test/capital/sources.yaml | 6 +-- .../variables/gov/dwp/_person_capital_pool.py | 33 ++++++------- .../gov/dwp/cash_isa_countable_capital.py | 23 +++++---- .../gov/dwp/esa_income_assessable_capital.py | 24 +++++----- .../housing_benefit_assessable_capital.py | 29 +++++------- .../dwp/income_support_assessable_capital.py | 20 ++++---- .../gov/dwp/jsa_income_assessable_capital.py | 24 +++++----- .../pension_credit_assessable_capital.py | 27 +++++------ ...stocks_and_shares_isa_countable_capital.py | 27 +++++------ .../universal_credit/uc_assessable_capital.py | 45 ++++++++---------- 21 files changed, 283 insertions(+), 289 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml index cee548a16e..f3ec91b61c 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml @@ -1,25 +1,26 @@ description: > Person-level model variables counted as income-related Employment and - Support Allowance capital of the person's own benefit unit. They are summed - over the benefit unit's claimant and partner (`is_claimant_or_partner`), so - they need none of the household proxy that the household `sources` use, and - a dependant's holding is left out: regulation 83 adds only the partner's - capital to the claimant's. `lifetime_isa_countable_capital` is a Lifetime - ISA at its surrender value: regulation 113 values capital at its current - market or surrender value, and DMG 52665 counts a Lifetime ISA at 75% of its - value under 60 and in full from 60. Lifetime ISAs exist from 6 April 2017, - and the formulas count them from then. `cash_isa_countable_capital` and - `stocks_and_shares_isa_countable_capital` hold a member's cash and stocks and - shares ISA balances. Each ISA has one holder, who owns its investments + Support Allowance capital of the person's own benefit unit. The formulas sum + these sources over the benefit unit's claimant and partner + (`is_claimant_or_partner`) and exclude dependants: regulation 83 treats only + the partner's capital as the claimant's. `lifetime_isa_countable_capital` + values a Lifetime ISA at its surrender value: DWP values capital at its + current market or surrender value under regulation 113, and DMG 52665 counts + a Lifetime ISA at 75% of its value under 60 and in full from 60. Lifetime + ISAs exist from 6 April 2017, and the formulas count them from then. + `cash_isa_countable_capital` and `stocks_and_shares_isa_countable_capital` + hold a member's cash and stocks and shares ISA balances, bounded by the + household total. Each ISA has one holder, who owns its investments beneficially (Individual Savings Account Regulations 1998 reg. 4(1B)(c) and - (6)(a)), and a child holds their own junior ISA (reg. 2B). The formulas count - each balance only while `sources` lists the household source that already - holds it (`cash_isa`, `share_like_capital`), and subtract the counted - balances from that source before they share the pool, so each balance counts - once, in its holder's own benefit unit. When `sources` drops the household - source, the formulas count neither balance. Household data with no balances - leave the whole household total in the pool. ISAs exist from 6 April 1999 - (reg. 1). + (6)(a)), and a child holds their own junior ISA (reg. 2B). The formulas + subtract each bounded ISA balance from its household source (`cash_isa`, + `share_like_capital`) and count it in the holder's unit. When a household's + balances exceed its total, the model scales them to sum to the total + exactly, rounding each share down or up to a whole float32 step of the + total. When a reform removes a household source from `sources`, the formulas + exclude the corresponding ISA person source. With household totals alone, + the formulas leave the whole total in the pool. The ISA Regulations came + into force for all other purposes on 6 April 1999 (reg. 1). metadata: label: Income-related ESA person-level capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml index 8660cab79c..4faf6de706 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml @@ -1,10 +1,10 @@ description: > - Ordinary cash ISA balances are separate from savings and are included at - their accessible cash value, without a generic cash sale-cost deduction. + The formulas count ordinary accessible cash ISAs separately from savings, + at their cash value, without a generic cash sale-cost deduction. Where the data tie a cash ISA or stocks and shares ISA balance to a member, the formulas count it through `person_sources` in the holder's own benefit unit and subtract it from `cash_isa` or `share_like_capital` before they - share the rest. + allocate the rest. Model variables currently mapped to countable income-related Employment and Support Allowance capital. The Employment and Support Allowance Regulations 2008 treat capital as assessable unless it is disregarded under Schedule 9. @@ -23,12 +23,12 @@ description: > occupational or personal pension and the value of funds held under a personal pension scheme. Other Schedule 9 disregards and the higher residential-care capital disregard are not yet modeled because the current - dataset does not isolate those states or asset holdings. Because the dataset - only provides household-level capital stocks, the model allocates the full - household total to benunits 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 claim path. That allocation is a data approximation, not a - legislated ownership rule. + dataset does not isolate those states or asset holdings. For capital whose + owner the data do not identify, the model allocates the full household + remainder to benunits with a reported income-related ESA award and falls + back to claimant and partner shares when nobody in the household is on that + reported claim path. No regulation sets that allocation; it approximates + ownership the data don't record. metadata: label: Income-related ESA countable capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml index 9d7b829012..6566960805 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml @@ -1,25 +1,26 @@ description: > Person-level model variables counted as income-based Jobseeker's Allowance - capital of the person's own benefit unit. They are summed over the benefit - unit's claimant and partner (`is_claimant_or_partner`), so they need none of - the household proxy that the household `sources` use, and a dependant's - holding is left out: regulation 109 kept a child's or young person's capital - out of the claimant's, and since children moved to child tax credit they are - outside the assessment altogether. `lifetime_isa_countable_capital` is a - Lifetime ISA at its surrender value: regulation 111 values capital at its - current market or surrender value, and DMG 29665 counts a Lifetime ISA at - 75% of its value under 60 and in full from 60. Lifetime ISAs exist from 6 - April 2017, and the formulas count them from then. `cash_isa_countable_capital` - and `stocks_and_shares_isa_countable_capital` hold a member's cash and stocks - and shares ISA balances. Each ISA has one holder, who owns its investments - beneficially (Individual Savings Account Regulations 1998 reg. 4(1B)(c) and - (6)(a)), and a child holds their own junior ISA (reg. 2B). The formulas count - each balance only while `sources` lists the household source that already - holds it (`cash_isa`, `share_like_capital`), and subtract the counted - balances from that source before they share the pool, so each balance counts - once, in its holder's own benefit unit. When `sources` drops the household - source, the formulas count neither balance. Household data with no balances - leave the whole household total in the pool. ISAs exist from 6 April 1999 + capital of the person's own benefit unit. The formulas sum these sources + over the benefit unit's claimant and partner (`is_claimant_or_partner`) and + exclude dependants: DWP excludes children's capital under regulation 109 + and, once SI 2003/455 applies, regulation 88(2). + `lifetime_isa_countable_capital` values a Lifetime ISA at its surrender + value: DWP values capital at its current market or surrender value under + regulation 111, and DMG 29665 counts a Lifetime ISA at 75% of its value + under 60 and in full from 60. Lifetime ISAs exist from 6 April 2017, and the + formulas count them from then. `cash_isa_countable_capital` and + `stocks_and_shares_isa_countable_capital` hold a member's cash and stocks + and shares ISA balances, bounded by the household total. Each ISA has one + holder, who owns its investments beneficially (Individual Savings Account + Regulations 1998 reg. 4(1B)(c) and (6)(a)), and a child holds their own + junior ISA (reg. 2B). The formulas subtract each bounded ISA balance from + its household source (`cash_isa`, `share_like_capital`) and count it in the + holder's unit. When a household's balances exceed its total, the model + scales them to sum to the total exactly, rounding each share down or up to a + whole float32 step of the total. When a reform removes a household source + from `sources`, the formulas exclude the corresponding ISA person source. + With household totals alone, the formulas leave the whole total in the pool. + The ISA Regulations came into force for all other purposes on 6 April 1999 (reg. 1). metadata: label: Income-based JSA person-level capital sources diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml index 130d21de5d..b76a44a12d 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml @@ -1,10 +1,10 @@ description: > - Ordinary cash ISA balances are separate from savings and are included at - their accessible cash value, without a generic cash sale-cost deduction. + The formulas count ordinary accessible cash ISAs separately from savings, + at their cash value, without a generic cash sale-cost deduction. Where the data tie a cash ISA or stocks and shares ISA balance to a member, the formulas count it through `person_sources` in the holder's own benefit unit and subtract it from `cash_isa` or `share_like_capital` before they - share the rest. + allocate the rest. Model variables currently mapped to countable income-based Jobseeker's Allowance capital. The Jobseeker's Allowance Regulations 1996 treat capital as assessable unless it is disregarded under Schedule 8. The current @@ -24,11 +24,12 @@ description: > personal pension scheme. Other Schedule 8 disregards, the higher residential-care capital disregard, and the separate child-capital disregard are not yet modeled because the current dataset does not isolate those - states or asset holdings. Because the dataset only provides household-level - capital stocks, the model allocates the full household total to benunits - 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 claim path. That - allocation is a data approximation, not a legislated ownership rule. + states or asset holdings. For capital whose owner the data do not identify, + the model allocates the full household remainder to benunits with a + reported income-based JSA award and falls back to claimant and partner + shares when nobody in the household is on that reported claim path. No + regulation sets that allocation; it approximates ownership the data don't + record. metadata: label: Income-based JSA countable capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml index 69f1d7d0a5..818425bc18 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml @@ -1,26 +1,30 @@ description: > Person-level model variables counted as Housing Benefit capital of the - person's own benefit unit. They are summed over the benefit unit's claimant - and partner (`is_claimant_or_partner`), so they need none of the household - proxy that the household `sources` use, and a dependant's holding is left - out: regulation 45 says the capital of a child or young person in the - claimant's family is not the claimant's. `lifetime_isa_countable_capital` is - a Lifetime ISA at its surrender value: regulation 47 (regulation 45 of the - pension-age regulations) values capital at its current market or surrender - value, in the words of the DWP benefits' regulations, for which DWP guidance - counts a Lifetime ISA at 75% of its value under 60 and in full from 60. - Lifetime ISAs exist from 6 April 2017, and the formulas count them from then. + person's own benefit unit. The formulas sum these sources over the benefit + unit's claimant and partner (`is_claimant_or_partner`) and exclude + dependants: local authorities exclude children's capital under regulation + 45. `lifetime_isa_countable_capital` values a Lifetime ISA at its surrender + value: local authorities value capital at its current market or surrender + value under regulation 47 (regulation 45 of the pension-age regulations), in + the words of the DWP benefits' regulations, for which DWP guidance counts a + Lifetime ISA at 75% of its value under 60 and in full from 60. Lifetime ISAs + exist from 6 April 2017, and the formulas count them from then. `cash_isa_countable_capital` and `stocks_and_shares_isa_countable_capital` - hold a member's cash and stocks and shares ISA balances. Each ISA has one - holder, who owns its investments beneficially (Individual Savings Account - Regulations 1998 reg. 4(1B)(c) and (6)(a)), and a child holds their own - junior ISA (reg. 2B). The formulas count each balance only while `sources` - lists the household source that already holds it (`cash_isa`, - `share_like_capital`), and subtract the counted balances from that source - before they share the pool, so each balance counts once, in its holder's own - benefit unit. When `sources` drops the household source, the formulas count - neither balance. Household data with no balances leave the whole household - total in the pool. ISAs exist from 6 April 1999 (reg. 1). + hold a member's cash and stocks and shares ISA balances, bounded by the + household total. Each ISA has one holder, who owns its investments + beneficially (Individual Savings Account Regulations 1998 reg. 4(1B)(c) and + (6)(a)), and a child holds their own junior ISA (reg. 2B). In the ordinary + calculation, the formula subtracts each bounded ISA balance from its + household source (`cash_isa`, `share_like_capital`) and counts it in the + holder's unit. For a family on a passporting benefit, or at pension age on a + guarantee credit, the formula returns zero capital, and for a + savings-credit-only award it uses the Pension Credit capital assessment. + When a household's balances exceed its total, the model scales them to sum + to the total exactly, rounding each share down or up to a whole float32 step + of the total. When a reform removes a household source from `sources`, the + formulas exclude the corresponding ISA person source. With household totals + alone, the formulas leave the whole total in the pool. The ISA Regulations + came into force for all other purposes on 6 April 1999 (reg. 1). metadata: label: Housing Benefit person-level capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml index b901fb5be0..26b59f3395 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml @@ -1,10 +1,10 @@ description: > - Ordinary cash ISA balances are separate from savings and are included at - their accessible cash value, without a generic cash sale-cost deduction. + The formulas count ordinary accessible cash ISAs separately from savings, + at their cash value, without a generic cash sale-cost deduction. Where the data tie a cash ISA or stocks and shares ISA balance to a member, the formulas count it through `person_sources` in the holder's own benefit unit and subtract it from `cash_isa` or `share_like_capital` before they - share the rest. + allocate the rest. Model variables currently mapped to countable Housing Benefit capital. The Housing Benefit Regulations 2006 treat the whole of a claimant's capital as assessable unless it is disregarded under Schedule 6. The current mappings @@ -26,9 +26,9 @@ description: > regulations make the same disregard). Datasets built before private pension wealth was split out of `corporate_wealth` still carry it inside `share_like_capital`. Other Schedule 6 disregards within these broad asset - classes are not yet split out separately. Because the dataset only provides - household-level capital stocks, the model allocates them across benunits - using an adult-share proxy. + classes are not yet split out separately. For capital whose owner the data + do not identify, the model allocates the household remainder across + benunits by claimant and partner counts. metadata: label: Housing Benefit countable capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml index 0276d2dde3..a96489b17c 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml @@ -1,26 +1,26 @@ description: > Person-level model variables counted as Income Support capital of the - person's own benefit unit. They are summed over the benefit unit's claimant - and partner (`is_claimant_or_partner`), so they need none of the household - proxy that the household `sources` use, and a dependant's holding is left - out: regulation 47 kept a child's or young person's capital out of the - claimant's, and since children moved to child tax credit they are outside - the assessment altogether. `lifetime_isa_countable_capital` is a Lifetime - ISA at its surrender value: regulation 49 values capital at its current - market or surrender value, and DMG 29665 counts a Lifetime ISA at 75% of its - value under 60 and in full from 60. Lifetime ISAs exist from 6 April 2017, - and the formulas count them from then. `cash_isa_countable_capital` and - `stocks_and_shares_isa_countable_capital` hold a member's cash and stocks and - shares ISA balances. Each ISA has one holder, who owns its investments + person's own benefit unit. The formulas sum these sources over the benefit + unit's claimant and partner (`is_claimant_or_partner`) and exclude + dependants: DWP excludes children's capital under regulation 47 and, once SI + 2003/455 applies, regulation 23(2). `lifetime_isa_countable_capital` values + a Lifetime ISA at its surrender value: DWP values capital at its current + market or surrender value under regulation 49, and DMG 29665 counts a + Lifetime ISA at 75% of its value under 60 and in full from 60. Lifetime ISAs + exist from 6 April 2017, and the formulas count them from then. + `cash_isa_countable_capital` and `stocks_and_shares_isa_countable_capital` + hold a member's cash and stocks and shares ISA balances, bounded by the + household total. Each ISA has one holder, who owns its investments beneficially (Individual Savings Account Regulations 1998 reg. 4(1B)(c) and - (6)(a)), and a child holds their own junior ISA (reg. 2B). The formulas count - each balance only while `sources` lists the household source that already - holds it (`cash_isa`, `share_like_capital`), and subtract the counted - balances from that source before they share the pool, so each balance counts - once, in its holder's own benefit unit. When `sources` drops the household - source, the formulas count neither balance. Household data with no balances - leave the whole household total in the pool. ISAs exist from 6 April 1999 - (reg. 1). + (6)(a)), and a child holds their own junior ISA (reg. 2B). The formulas + subtract each bounded ISA balance from its household source (`cash_isa`, + `share_like_capital`) and count it in the holder's unit. When a household's + balances exceed its total, the model scales them to sum to the total + exactly, rounding each share down or up to a whole float32 step of the + total. When a reform removes a household source from `sources`, the formulas + exclude the corresponding ISA person source. With household totals alone, + the formulas leave the whole total in the pool. The ISA Regulations came + into force for all other purposes on 6 April 1999 (reg. 1). metadata: label: Income Support person-level capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml index 132f0352ec..e4ea10e98b 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml @@ -1,10 +1,10 @@ description: > - Ordinary cash ISA balances are separate from savings and are included at - their accessible cash value, without a generic cash sale-cost deduction. + The formulas count ordinary accessible cash ISAs separately from savings, + at their cash value, without a generic cash sale-cost deduction. Where the data tie a cash ISA or stocks and shares ISA balance to a member, the formulas count it through `person_sources` in the holder's own benefit unit and subtract it from `cash_isa` or `share_like_capital` before they - share the rest. + allocate the rest. Capital sources included in the Income Support capital test. `private_pension_wealth` is excluded because Schedule 10 paragraphs 23 and 23A disregard the value of the right to receive an occupational or personal diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml index 7c171f5f4f..a9a5370b24 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml @@ -1,26 +1,28 @@ description: > Person-level model variables counted as Pension Credit capital of the - person's own benefit unit. They are summed over the benefit unit's claimant - and partner (`is_claimant_or_partner`), so they need none of the household - proxy that the household `sources` use, and a dependant's holding is left - out: section 5 of the State Pension Credit Act 2002 adds only the partner's - capital to the claimant's. `lifetime_isa_countable_capital` is a Lifetime - ISA at its surrender value: regulation 19 values capital at its current - market or surrender value, and DMG 84751 counts a Lifetime ISA at 75% of its - value under 60 and in full from 60, so a claimant's own Lifetime ISA counts - in full. Lifetime ISAs exist from 6 April 2017, and the formulas count them - from then. `cash_isa_countable_capital` and - `stocks_and_shares_isa_countable_capital` hold a member's cash and stocks and - shares ISA balances. Each ISA has one holder, who owns its investments - beneficially (Individual Savings Account Regulations 1998 reg. 4(1B)(c) and - (6)(a)), and a child holds their own junior ISA (reg. 2B). The formulas count - each balance only while `sources` lists the household source that already - holds it (`cash_isa`, `share_like_capital`), and subtract the counted - balances from that source before they share the pool, so each balance counts - once, in its holder's own benefit unit. When `sources` drops the household - source, the formulas count neither balance. Household data with no balances - leave the whole household total in the pool. ISAs exist from 6 April 1999 - (reg. 1). + person's own benefit unit. The formulas sum these sources over the benefit + unit's claimant and partner (`is_claimant_or_partner`) and exclude + dependants: section 5 of the State Pension Credit Act 2002 treats only the + partner's capital as the claimant's. `lifetime_isa_countable_capital` values + a Lifetime ISA at its surrender value: DWP values capital at its current + market or surrender value under regulation 19, and DMG 84751 counts a + Lifetime ISA at 75% of its value under 60 and in full from 60, so a + claimant's own Lifetime ISA counts in full. Lifetime ISAs exist from 6 April + 2017, and the formulas count them from then. `cash_isa_countable_capital` + and `stocks_and_shares_isa_countable_capital` hold a member's cash and + stocks and shares ISA balances, bounded by the household total. Each ISA has + one holder, who owns its investments beneficially (Individual Savings + Account Regulations 1998 reg. 4(1B)(c) and (6)(a)), and a child holds their + own junior ISA (reg. 2B). Where no unit reports capital, the formula + subtracts each bounded ISA balance from its household source (`cash_isa`, + `share_like_capital`) and counts it in the holder's unit. A nonnegative + `pension_credit_reported_capital` replaces the unit's calculated capital. + When a household's balances exceed its total, the model scales them to sum + to the total exactly, rounding each share down or up to a whole float32 step + of the total. When a reform removes a household source from `sources`, the + formulas exclude the corresponding ISA person source. With household totals + alone, the formulas leave the whole total in the pool. The ISA Regulations + came into force for all other purposes on 6 April 1999 (reg. 1). metadata: label: Pension Credit person-level capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml index 3b40373c4f..6c5a9bd5ca 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml @@ -1,10 +1,10 @@ description: > - Ordinary cash ISA balances are separate from savings and are included at - their accessible cash value, without a generic cash sale-cost deduction. + The formulas count ordinary accessible cash ISAs separately from savings, + at their cash value, without a generic cash sale-cost deduction. Where the data tie a cash ISA or stocks and shares ISA balance to a member, the formulas count it through `person_sources` in the holder's own benefit unit and subtract it from `cash_isa` or `share_like_capital` before they - share the rest. + allocate the rest. PolicyEngine approximation of Pension Credit capital sources that count toward deemed income from capital, excluding the claimant's main home. `private_pension_wealth` is excluded because Schedule V paragraphs 22 and 23 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml index b45cefcd1a..6b6533a8c6 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml @@ -1,29 +1,32 @@ description: > Person-level model variables counted as Universal Credit capital of the - person's own benefit unit. They are summed over the benefit unit's claimant - and partner (`is_uc_claimant`), so they need none of the household proxy - that the household `sources` use, and a dependant's holding is left out: + person's own benefit unit. The formulas sum these sources over the benefit + unit's claimant and partner (`is_uc_claimant`) and exclude dependants: section 5 of the Welfare Reform Act 2012 tests the claimant's, or the joint - claimants' combined, capital. `lifetime_isa_countable_capital` is a Lifetime - ISA at its surrender value: regulation 49(1) values capital at its current - market or surrender value, and ADM H1659 counts a Lifetime ISA at 75% of its - value under 60 and in full from 60. Lifetime ISAs exist from 6 April 2017, - and the formulas count them from then. A nonnegative financial observation - (benunit_reported_capital) replaces these covered financial sources to avoid - counting an ISA twice. A complete UC override replaces them together with - property and the household financial proxy, before the existing regulation - 77 company substitution. `cash_isa_countable_capital` and - `stocks_and_shares_isa_countable_capital` hold a member's cash and stocks and - shares ISA balances. Each ISA has one holder, who owns its investments + claimants' combined, capital. `lifetime_isa_countable_capital` values a + Lifetime ISA at its surrender value: DWP values capital at its current + market or surrender value under regulation 49(1), and ADM H1659 counts a + Lifetime ISA at 75% of its value under 60 and in full from 60. Lifetime ISAs + exist from 6 April 2017, and the formulas count them from then. A + nonnegative `benunit_reported_capital` replaces these financial sources for + its unit, so the formula counts no ISA twice, and a complete + `uc_reported_capital` override replaces them together with property and the + household financial proxy, before the regulation 77 company substitution. + `cash_isa_countable_capital` and `stocks_and_shares_isa_countable_capital` + hold a member's cash and stocks and shares ISA balances, bounded by the + household total. Each ISA has one holder, who owns its investments beneficially (Individual Savings Account Regulations 1998 reg. 4(1B)(c) and - (6)(a)), and a child holds their own junior ISA (reg. 2B). The formulas count - each balance only while `sources` lists the household source that already - holds it (`cash_isa`, `share_like_capital`), and subtract the counted - balances from that source before they share the pool, so each balance counts - once, in its holder's own benefit unit. When `sources` drops the household - source, the formulas count neither balance. Household data with no balances - leave the whole household total in the pool. ISAs exist from 6 April 1999 - (reg. 1). + (6)(a)), and a child holds their own junior ISA (reg. 2B). Where no unit + reports capital, the formula subtracts each bounded ISA balance from its + household source and counts it in the holder's unit. For a reporting UC + unit, the formula leaves its claimant's and partner's ISAs in the pool and + subtracts the report. When a household's balances exceed its total, the + model scales them to sum to the total exactly, rounding each share down or + up to a whole float32 step of the total. When a reform removes a household + source from `sources`, the formulas exclude the corresponding ISA person + source. With household totals alone, the formulas leave the whole total in + the pool. The ISA Regulations came into force for all other purposes on 6 + April 1999 (reg. 1). metadata: label: Universal Credit person-level capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml index e2c26366e9..a7a116c5f3 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml @@ -1,10 +1,10 @@ description: > - Ordinary cash ISA balances are separate from savings and are included at - their accessible cash value, without a generic cash sale-cost deduction. + The formulas count ordinary accessible cash ISAs separately from savings, + at their cash value, without a generic cash sale-cost deduction. Where the data tie a cash ISA or stocks and shares ISA balance to a member, the formulas count it through `person_sources` in the holder's own benefit unit and subtract it from `cash_isa` or `share_like_capital` before they - share the rest. + allocate the rest. Household financial proxies for Universal Credit: savings and share-like financial wealth. `share_like_capital` is `corporate_wealth` as supplied, or the sum of its itemised components (`directly_held_shares`, diff --git a/policyengine_uk/variables/gov/dwp/_person_capital_pool.py b/policyengine_uk/variables/gov/dwp/_person_capital_pool.py index 196c92403c..f5c83ce38b 100644 --- a/policyengine_uk/variables/gov/dwp/_person_capital_pool.py +++ b/policyengine_uk/variables/gov/dwp/_person_capital_pool.py @@ -1,12 +1,13 @@ -"""Person-level capital that a household capital source already holds.""" +"""Person-level capital that a household capital source already includes.""" from policyengine_uk.model_api import * -# Maps each person-level capital source to the household capital source that -# already holds it: cash_isa holds each cash ISA balance, and -# share_like_capital each stocks and shares ISA balance (through -# stocks_and_shares_isa, inside corporate_wealth or the itemised sum). No -# household source holds a person source missing here, such as a Lifetime ISA. +# The formulas map each person source to the household source that already +# includes it, subtract its stored household sum, and allocate the remainder. +# cash_isa includes each cash ISA balance, and share_like_capital each stocks +# and shares ISA balance (through stocks_and_shares_isa, inside +# corporate_wealth or the itemised sum). No household source includes a person +# source missing here, such as a Lifetime ISA. HOUSEHOLD_SOURCE_OF = { "cash_isa_countable_capital": "cash_isa", "stocks_and_shares_isa_countable_capital": "share_like_capital", @@ -16,11 +17,11 @@ def counted_person_sources(capital) -> list: """The person sources in ``capital.person_sources`` the formulas count. - The formulas count a person source that a household source holds only - while ``capital.sources`` lists that household source. Counting it moves - the part of the household source the data tie to a person into the - holder's own benefit unit, so when the list drops the household source the - formulas count neither. + The formulas count a person source that a household source includes only + while ``capital.sources`` lists that household source. They move the part + of the household source the data tie to a person into the holder's own + benefit unit, so when the list drops the household source they count + neither. """ sources = set(capital.sources) return [ @@ -31,14 +32,14 @@ def counted_person_sources(capital) -> list: def pooled_person_capital(benunit, source, period, capital, leaves_pool=None): - """The part of the household ``source`` that counted person sources hold. + """The part of the household ``source`` that counted person sources make up. The household sum, for each benefit unit, of the counted person sources - in ``capital.person_sources`` that ``source`` holds, over the members + in ``capital.person_sources`` that ``source`` includes, over the members ``leaves_pool`` selects (all members by default), as Core stores a total. - The formulas subtract it from ``source`` before they share the remainder - between benefit units, so each of those pounds counts only in its holder's - own unit. + The formulas subtract it from ``source`` before they allocate the + remainder between benefit units, so they count each of those pounds only + in its holder's own unit. """ person = benunit.members amount = 0 diff --git a/policyengine_uk/variables/gov/dwp/cash_isa_countable_capital.py b/policyengine_uk/variables/gov/dwp/cash_isa_countable_capital.py index 1ccde2a088..4d45c33616 100644 --- a/policyengine_uk/variables/gov/dwp/cash_isa_countable_capital.py +++ b/policyengine_uk/variables/gov/dwp/cash_isa_countable_capital.py @@ -7,18 +7,17 @@ class cash_isa_countable_capital(Variable): label = "Cash ISA value counted as means-tested capital" documentation = ( - "The person's cash ISA balance (cash_isa_balance) as the means tests " - "count it, in the holder's own benefit unit. An ISA has one holder, " - "who owns its investments beneficially, and a child holds their own " - "junior ISA. The household's cash_isa already holds the balance, so " - "each means test subtracts it from the household pool before sharing " - "the pool between benefit units. Where the household's balances add up " - "to more than cash_isa, the total the model holds for the household, " - "the model scales each by cash_isa over their sum and rounds it down " - "to float32, so the household never counts more cash ISA than " - "cash_isa. Where they add up to less, the rest of cash_isa stays in " - "the pool, and a household with no balances keeps all of cash_isa " - "there." + "The person's cash ISA balance (cash_isa_balance) as the means tests count it, " + "in the holder's own benefit unit. An ISA has one holder, who owns its " + "investments beneficially, and a child holds their own junior ISA. The " + "household's cash_isa already includes the balance, so each means test " + "subtracts it from the household pool before allocating the pool between " + "benefit units. The formula bounds the person's balance by cash_isa, the " + "household total. When the household's stored balance sum exceeds that total, " + "it scales the balances to sum to the total exactly, rounding each " + "proportional share down or up to a whole float32 step of the total. When the " + "balances sum to less, the rest of cash_isa stays in the pool, and a household " + "with no balances keeps all of cash_isa there." ) entity = Person definition_period = YEAR 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 b05cd338fb..97718bee1b 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py @@ -10,19 +10,17 @@ class esa_income_assessable_capital(Variable): entity = BenUnit 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 " - "household level, the model allocates full household capital to any " - "benunit with a reported income-related ESA award and only falls back to " - "a claimant-and-partner share when nobody in the household is on that " - "reported claim path. This allocation is a PolicyEngine convention; " - "the data cannot identify ownership of capital. " - "Person-level sources, such as a Lifetime ISA, count only for the " - "holder's own benunit, and only when the holder is its claimant or " - "partner (is_claimant_or_partner): a dependant's capital is not the " - "claimant's. The formula counts a person's cash and stocks and shares ISA " - "balances the same way and subtracts them from the household capital " - "before sharing it out." + "Household capital apportioned to the benefit unit for the income-related ESA " + "capital test. For capital whose owner the data do not identify, the model " + "allocates the full household remainder to any benunit with a reported " + "income-related ESA award and falls back to a claimant-and-partner share when " + "nobody in the household is on that reported claim path. No regulation sets " + "that allocation; it approximates ownership the data don't record. The formula " + "counts person-level sources, such as a Lifetime ISA and the cash and stocks " + "and shares ISAs, only in the holder's own benunit and only for its claimant " + "or partner (is_claimant_or_partner), and excludes dependants' capital. It " + "subtracts the ISA balances from the household capital before allocating the " + "remainder." ) definition_period = YEAR unit = GBP 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 b87f1d0f15..b8e4fdfd59 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 @@ -10,22 +10,19 @@ class housing_benefit_assessable_capital(Variable): entity = BenUnit label = "Housing Benefit assessable capital" documentation = ( - "Housing Benefit capital counted from the configured capital sources. " - "Household sources are allocated across benunits in proportion to their " - "claimants and partners, a PolicyEngine convention because household " - "capital data cannot identify ownership; children and young persons add " - "no weight. Person-level sources, such as a Lifetime ISA, count only for " - "the holder's own benunit, and only when the holder is its claimant or " - "partner (is_claimant_or_partner): a dependant's capital is not the " - "claimant's. The formula counts a person's cash and stocks and shares ISA " - "balances the same way and subtracts them from the household capital " - "before sharing it out. Where the Pension Credit award is savings credit only, the " - "Pension Credit assessment of capital is used instead. It is nil for a " - "family on Universal Credit, Income Support, income-based Jobseeker's " - "Allowance or income-related Employment and Support Allowance " - "(housing_benefit_on_passporting_benefit), and for a pension-age " - "family receiving a guarantee credit (the guarantee credit " - "passport)." + "Housing Benefit capital counted from the configured capital sources. The " + "formula counts person-level sources, such as a Lifetime ISA and the cash and " + "stocks and shares ISAs, only in the holder's own benunit and only for its " + "claimant or partner (is_claimant_or_partner), and excludes dependants' " + "capital. It subtracts the ISA balances from the household capital before " + "allocating the remainder. The formula allocates the unidentified household " + "remainder by claimant and partner counts; children and young persons add no " + "weight. For savings credit alone, it uses the Pension Credit capital " + "assessment. For a family on Universal Credit, Income Support, income-based " + "Jobseeker's Allowance or income-related Employment and Support Allowance " + "(housing_benefit_on_passporting_benefit), and for a pension-age family " + "receiving a guarantee credit (the guarantee credit passport), it returns " + "zero." ) definition_period = YEAR unit = GBP 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 7a1b051ac1..c74c8f6704 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py @@ -11,17 +11,15 @@ 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 " - "level, the model allocates full household capital to any benunit on the " - "IS claim path and only falls back to a claimant-and-partner share when " - "nobody in the household is on that path. This allocation is a " - "PolicyEngine convention; the data cannot identify ownership of capital. " - "Person-level sources, such as a Lifetime ISA, count only for the " - "holder's own benunit, and only when the holder is its claimant or " - "partner (is_claimant_or_partner): a dependant's capital is not the " - "claimant's. The formula counts a person's cash and stocks and shares ISA " - "balances the same way and subtracts them from the household capital " - "before sharing it out." + "capital test. For capital whose owner the data do not identify, the model " + "allocates the full household remainder to any benunit on the IS claim path " + "and falls back to a claimant-and-partner share when nobody in the household " + "is on that path. No regulation sets that allocation; it approximates " + "ownership the data don't record. The formula counts person-level sources, " + "such as a Lifetime ISA and the cash and stocks and shares ISAs, only in the " + "holder's own benunit and only for its claimant or partner " + "(is_claimant_or_partner), and excludes dependants' capital. It subtracts the " + "ISA balances from the household capital before allocating the remainder." ) definition_period = YEAR unit = GBP 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 2882faf466..84088a2a00 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py @@ -10,19 +10,17 @@ class jsa_income_assessable_capital(Variable): entity = BenUnit 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 " - "household level, the model allocates full household capital to any " - "benunit with a reported income-based JSA award and only falls back to " - "a claimant-and-partner share when nobody in the household is on that " - "reported claim path. This allocation is a PolicyEngine convention; " - "the data cannot identify ownership of capital. " - "Person-level sources, such as a Lifetime ISA, count only for the " - "holder's own benunit, and only when the holder is its claimant or " - "partner (is_claimant_or_partner): a dependant's capital is not the " - "claimant's. The formula counts a person's cash and stocks and shares ISA " - "balances the same way and subtracts them from the household capital " - "before sharing it out." + "Household capital apportioned to the benefit unit for the income-based JSA " + "capital test. For capital whose owner the data do not identify, the model " + "allocates the full household remainder to any benunit with a reported " + "income-based JSA award and falls back to a claimant-and-partner share when " + "nobody in the household is on that reported claim path. No regulation sets " + "that allocation; it approximates ownership the data don't record. The formula " + "counts person-level sources, such as a Lifetime ISA and the cash and stocks " + "and shares ISAs, only in the holder's own benunit and only for its claimant " + "or partner (is_claimant_or_partner), and excludes dependants' capital. It " + "subtracts the ISA balances from the household capital before allocating the " + "remainder." ) definition_period = YEAR unit = GBP 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 b1a431f3c1..1e009aa3a3 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 @@ -10,21 +10,18 @@ class pension_credit_assessable_capital(Variable): entity = BenUnit 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 " - "couples pool capital together without dilution by unrelated working-" - "age adults. Person-level sources, such as a Lifetime ISA, count " - "only for the holder's own benunit, and only when the holder is its " - "claimant or partner (is_claimant_or_partner): a dependant's capital " - "is not the claimant's. The formula counts a person's cash and stocks and " - "shares ISA balances the same way and subtracts them from the household " - "capital before sharing it out. Where `pension_credit_reported_capital` records " - "the benefit unit's own capital (0 or more), it replaces the " - "household proxy and the person-level sources. Unlike " - "`uc_assessable_capital`, another benefit unit's recorded capital is " - "not subtracted from the household capital that an unrecorded unit's " - "proxy shares out: the recorded figure and the household sources come " - "from different measures, so the proxy is left as it was." + "Pension Credit capital counted from the configured capital sources. The " + "formula allocates the household remainder only across the household's " + "pension-age adults, so unrelated working-age adults leave a pensioner " + "couple's share undiluted. The formula counts person-level sources, such as a " + "Lifetime ISA and the cash and stocks and shares ISAs, only in the holder's " + "own benunit and only for its claimant or partner (is_claimant_or_partner), " + "and excludes dependants' capital. It subtracts the ISA balances from the " + "household capital before allocating the remainder. A nonnegative " + "`pension_credit_reported_capital` replaces the unit's calculated capital. The " + "formula does not subtract another unit's capital report from the household " + "remainder before allocating that remainder. The report and household sources " + "measure different amounts." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/gov/dwp/stocks_and_shares_isa_countable_capital.py b/policyengine_uk/variables/gov/dwp/stocks_and_shares_isa_countable_capital.py index c2a60adfc4..6238167e9f 100644 --- a/policyengine_uk/variables/gov/dwp/stocks_and_shares_isa_countable_capital.py +++ b/policyengine_uk/variables/gov/dwp/stocks_and_shares_isa_countable_capital.py @@ -7,20 +7,19 @@ class stocks_and_shares_isa_countable_capital(Variable): label = "Stocks and shares ISA value counted as means-tested capital" documentation = ( - "The person's stocks and shares ISA balance " - "(stocks_and_shares_isa_balance) as the means tests count it, in the " - "holder's own benefit unit. An ISA has one holder, who owns its " - "investments beneficially, and a child holds their own junior ISA. " - "The household's share_like_capital already holds the balance, " - "through stocks_and_shares_isa within corporate_wealth or the " - "itemised sum, so each means test subtracts it from the household " - "pool before sharing the pool between benefit units. Where the " - "household's balances add up to more than the smaller of " - "stocks_and_shares_isa and share_like_capital, the model scales each " - "by that total over their sum and rounds it down to float32, so the " - "household never counts more stocks and shares ISA than it holds. " - "Where they add up to less, the rest stays in the pool, and a " - "household with no balances keeps all of share_like_capital there." + "The person's stocks and shares ISA balance (stocks_and_shares_isa_balance) as " + "the means tests count it, in the holder's own benefit unit. An ISA has one " + "holder, who owns its investments beneficially, and a child holds their own " + "junior ISA. The household's share_like_capital already includes the balance, " + "through stocks_and_shares_isa within corporate_wealth or the itemised sum, so " + "each means test subtracts it from the household pool before allocating the " + "pool between benefit units. The formula bounds the person's balance by the " + "smaller of stocks_and_shares_isa and share_like_capital, so the household " + "never counts more stocks and shares ISA than it holds. When the household's " + "stored balance sum exceeds that total, it scales the balances to sum to the " + "total exactly, rounding each proportional share down or up to a whole float32 " + "step of the total. When the balances sum to less, the rest stays in the pool, " + "and a household with no balances keeps all of share_like_capital there." ) entity = Person definition_period = YEAR 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 08f20a43a2..11ca5eac23 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py @@ -11,31 +11,26 @@ class uc_assessable_capital(Variable): label = "Universal Credit assessable capital" documentation = ( "Countable capital of the claimant and partner. An explicit nonnegative " - "uc_reported_capital, including zero, replaces ordinary capital sources " - "before the existing regulation 77 company substitution. Otherwise " - "a nonnegative benunit_reported_capital replaces financial household " - "and person-level proxies only; it does not replace countable property. " - "Complete overrides, otherwise observed financial totals, reduce the " - "household financial pool before " - "its residual is allocated by claimant/partner counts to units with " - "neither report. Under this inherited financial convention, another " - "unit's financial report or complete override can change a " - "missing-report unit's capital. Subtracting a complete override is " - "a pool-allocation convention, not an observed asset breakdown. Property " - "is valued separately in the assessment year and allocated across ALL " - "claimant/partner adults, including non-UC units and complete-override " - "units, whose shares are consumed rather than redistributed. Property " - "allocation depends only on claimant/partner adult counts and is " - "independent of other units' financial reports. These " - "allocations are assumptions about unobserved ownership, not law. " - "Person-level proxies count only claimants/partners, not dependants. " - "The formula subtracts each person's cash and stocks and shares ISA " - "balances from the financial pool and counts them in the holder's " - "unit, except where the holder's unit reports its capital: there it " - "leaves its claimant's and partner's ISAs in the pool and subtracts " - "the report instead. " - "Under regulation 77, disregarded company holdings are replaced by " - "the claimant/partner's share of company capital." + "uc_reported_capital, including zero, replaces ordinary capital sources before " + "the regulation 77 company substitution. Otherwise a nonnegative " + "benunit_reported_capital replaces financial household and person-level " + "proxies only; it does not replace countable property. The formula subtracts " + "complete overrides, otherwise observed financial totals, from the household " + "financial pool, and allocates the financial remainder by claimant and partner " + "counts among units without either report. Under this inherited convention, " + "another unit's financial report or complete override can change a " + "missing-report unit's capital. The formula values property separately in the " + "assessment year and allocates it across all claimant and partner adults, " + "including non-UC units and complete-override units, whose shares it doesn't " + "redistribute; other units' financial reports leave that allocation unchanged. " + "These allocations assume ownership the data don't record, and no regulation " + "sets them. Person-level proxies count only claimants and partners, not " + "dependants. The formula subtracts each bounded cash and stocks and shares ISA " + "balance from its household source and counts it in the holder's unit, except " + "for the claimant and partner of a unit that reports its capital: it leaves " + "their ISAs in the pool and subtracts the report, as for their company " + "holdings. Under regulation 77, it replaces disregarded company holdings with " + "the claimant's or partner's share of company capital." ) definition_period = YEAR unit = GBP From 38da30fb88fc8008a056ce1c3af7373952f73733 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 13:56:59 -0500 Subject: [PATCH 15/27] Name the formula as the actor in the ISA test comments and the ISA note Rewrite the YAML case comments and the assumptions note so the formula subtracts and allocates, keep each comment's hand-computed amounts, and describe the scaled holdings' exact sum and step rounding. Co-Authored-By: Claude Opus 5.5 --- .../isa-inputs-and-capital-flows.md | 39 +++++---- .../baseline/gov/dwp/person_isa_capital.yaml | 82 +++++++++++-------- 2 files changed, 67 insertions(+), 54 deletions(-) diff --git a/docs/book/assumptions/isa-inputs-and-capital-flows.md b/docs/book/assumptions/isa-inputs-and-capital-flows.md index 58162a4c8a..f253502dd2 100644 --- a/docs/book/assumptions/isa-inputs-and-capital-flows.md +++ b/docs/book/assumptions/isa-inputs-and-capital-flows.md @@ -112,24 +112,27 @@ balances carry the variable-level uprating to later years. Each means-test formula counts the claimant's and partner's cash and stocks and shares ISA balances in their own benefit unit, through `cash_isa_countable_capital` and `stocks_and_shares_isa_countable_capital` in -its `person_sources`. A dependant's balance, a child's junior ISA among them, -counts in no benefit unit. The formulas subtract a household's counted balances -from `cash_isa` and `share_like_capital` before they share the remainder -between benefit units. Where the balances add up to less than the total the -model holds for the year, the remainder stays in the pool. Where they add up to -more, the model scales each balance by the total over their sum and rounds it -down to the float32 value it stores, so the household never counts more than -the total and the pool never falls below zero; a sole holder whose balance -reaches the total counts exactly the total. For stocks and shares ISAs the -model takes the smaller of `stocks_and_shares_isa` and `share_like_capital` as -the total. Data that store only household totals hold no balances, so the pool -keeps the whole total. The formulas count a balance only while the programme's -`sources` lists the household source that holds it, so a reform that drops -`cash_isa` or `share_like_capital` from `sources` drops the balances with it. -For a Universal Credit unit that reports its capital -(`benunit_reported_capital` or `uc_reported_capital`, zero or more), the -formula leaves the unit's claimant's and partner's ISAs in the pool and -subtracts the report instead, as it does for their company holdings. +its `person_sources`, and excludes a dependant's balance, a child's junior ISA +among them. The formulas subtract a household's counted balances from `cash_isa` +and `share_like_capital` before they allocate the remainder between benefit +units. Where the balances sum to less than the total the model holds for the +year, the remainder stays in the pool. Where their stored sum exceeds the total, +the model scales them to sum to the total exactly: it rounds each proportional +share down to a whole step of the total's float32 spacing and gives the steps +left over, one each, to the members whose shares lost most in rounding. The pool +then keeps nothing of that total, each holding passes a capital limit only where +its proportional share does, and a sole holder whose balance reaches the total +counts exactly the total. For stocks and shares ISAs the model takes the smaller +of `stocks_and_shares_isa` and `share_like_capital` as the total. Data that +store only household totals hold no balances, so the pool keeps the whole total. +The formulas count a balance only while the programme's `sources` lists the +household source that includes it, so a reform that drops `cash_isa` or +`share_like_capital` from `sources` drops the balances with it. For a Universal +Credit unit that reports its capital (`benunit_reported_capital` or +`uc_reported_capital`, zero or more), the formula leaves the unit's claimant's +and partner's ISAs in the pool and subtracts the report, as it does for their +company holdings. A nonnegative `pension_credit_reported_capital` replaces the +unit's calculated Pension Credit capital. Ordinary accessible cash ISA balances enter the capital sources for Universal Credit, Housing Benefit, Pension Credit, Income Support, income-based JSA and diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml index 38d888c030..b7e77b8da7 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml @@ -5,7 +5,9 @@ # capital (WRA 2012 s 5; HB Regs 2006 reg 25; SPCA 2002 s 5; IS Regs 1987 # reg 23; JSA Regs 1996 reg 88; ESA Regs 2008 reg 83), and not a child's or # young person's (HB reg 25(3) and 45; IS reg 23(2); JSA reg 88(2); ESA reg -# 83(2)). The household pool shares the rest of the household's capital. +# 83(2)). The formula subtracts the identified ISA balances from the +# household's capital, then allocates the remainder by claimant and partner +# counts. - name: Case 1, a lodger's cash ISA stays out of the main benefit unit's capital. period: 2025 @@ -31,9 +33,9 @@ output: cash_isa: 10_000 cash_isa_countable_capital: [0, 10_000] - # The pool is savings 2,000 plus cash_isa 10,000 less the lodger's - # 10,000, shared 1,000 each by the two claimants; the lodger adds the - # 10,000 ISA. + # The formula subtracts the lodger's 10,000 from savings 2,000 plus + # cash_isa 10,000 and allocates the remaining 2,000 by claimant counts, + # 1,000 to each unit; the lodger's unit adds the 10,000 ISA. uc_assessable_capital: [1_000, 11_000] housing_benefit_assessable_capital: [1_000, 11_000] @@ -64,9 +66,10 @@ # share_like_capital is the itemised 6,000, all of it the child's ISA. share_like_capital: 6_000 stocks_and_shares_isa_countable_capital: [0, 0, 6_000] - # The pool is savings 9,000 plus share_like_capital 6,000 less the - # child's 6,000. Three claimants share it: 6,000 to the parents and - # 3,000 to the child, who adds the 6,000 ISA. + # The formula subtracts the child's 6,000 from savings 9,000 plus + # share_like_capital 6,000 and allocates the remaining 9,000 by claimant + # counts: 6,000 to the parents and 3,000 to the child's unit, which adds + # the 6,000 ISA. uc_assessable_capital: [6_000, 9_000] housing_benefit_assessable_capital: [6_000, 9_000] @@ -96,8 +99,9 @@ corporate_wealth: 9_000 output: share_like_capital: 9_000 - # The pool is corporate wealth 9,000 less the child's 6,000 ISA: 3,000, - # shared 2,000 to the parents and 1,000 to the child. + # The formula subtracts the child's 6,000 ISA from corporate wealth + # 9,000 and allocates the remaining 3,000: 2,000 to the parents and 1,000 + # to the child's unit. uc_assessable_capital: [2_000, 7_000] housing_benefit_assessable_capital: [2_000, 7_000] @@ -125,8 +129,9 @@ members: [parent, child, adult_child] savings: 2_000 output: - # The claiming benunit takes the whole pool, savings 2,000 plus cash_isa - # 5,000 less the child's 5,000; the ISA stays with its holder. + # The formula subtracts the adult child's 5,000 from savings 2,000 plus + # cash_isa 5,000 and gives the claiming benunit the whole 2,000 + # remainder; the ISA counts in its holder's unit. income_support_assessable_capital: [2_000, 5_000] - name: Case 5, a parent's income-based JSA claim does not count an adult child's cash ISA. @@ -200,9 +205,10 @@ members: [pensioner_1, pensioner_2, lodger] savings: 4_000 output: - # The pool is savings 4,000 plus cash_isa 9,000 and share_like_capital - # 2,000 less all three balances: 4,000, shared by the two pension-age - # adults. The couple add their own 3,000 and 2,000. + # The formula subtracts all three balances from savings 4,000, cash_isa + # 9,000 and share_like_capital 2,000, and allocates the remaining 4,000 + # between the two pension-age adults. The couple's unit adds their own + # 3,000 and 2,000. pension_credit_assessable_capital: [9_000, 0] - name: Case 8, a dependent child's junior ISA counts in no benefit unit. @@ -226,7 +232,8 @@ savings: 1_000 output: is_claimant_or_partner: [true, false] - # The pool is savings 1,000 once the child's 3,000 and 1,500 leave it. + # The formula subtracts the child's 3,000 and 1,500, leaving savings of + # 1,000 in the pool. uc_assessable_capital: 1_000 housing_benefit_assessable_capital: 1_000 income_support_assessable_capital: 1_000 @@ -258,9 +265,9 @@ members: [claimant, partner, lodger] savings: 6_000 output: - # Joint claimants' combined capital (WRA 2012 s 5(2)(a)): the pool of - # savings 6,000, shared 4,000 and 2,000, plus the couple's 4,000 and - # 3,000. + # Joint claimants' combined capital (WRA 2012 s 5(2)(a)): the formula + # allocates savings of 6,000 by claimant counts, 4,000 to the couple and + # 2,000 to the lodger, and the couple's unit adds their 4,000 and 3,000. uc_assessable_capital: [11_000, 2_000] housing_benefit_assessable_capital: [11_000, 2_000] @@ -288,11 +295,12 @@ output: cash_isa: 15_000 cash_isa_countable_capital: [0, 10_000] - # The 5,000 of the total no balance explains is shared 2,500 each. + # The formula allocates the 5,000 of the total that no balance explains + # by claimant counts, 2,500 to each unit. uc_assessable_capital: [2_500, 12_500] housing_benefit_assessable_capital: [2_500, 12_500] -- name: Case 11, balances above the household total are scaled down to it. +- name: Case 11, the model scales balances above the household total down to it. period: 2025 absolute_error_margin: 0 input: @@ -315,8 +323,8 @@ members: [tenant, lodger] cash_isa: 6_000 output: - # The balances add up to 12,000, twice the 6,000 total, so each counts - # half and the pool keeps nothing. + # The balances sum to 12,000, twice the 6,000 total, so the model counts + # half of each, and the formula leaves nothing in the pool. cash_isa_countable_capital: [1_500, 4_500] uc_assessable_capital: [1_500, 4_500] housing_benefit_assessable_capital: [1_500, 4_500] @@ -346,8 +354,8 @@ members: [parent_1, parent_2, adult_child] savings: 9_000 output: - # The report consumes 12,000 of the 19,000 pool, ISA included, and the - # parents take the other 7,000. + # The formula subtracts the 12,000 report from the 19,000 pool, ISA + # included, and allocates the other 7,000 to the parents. uc_assessable_capital: [7_000, 12_000] - name: Case 13, a stocks and shares ISA above the UC capital limit counts for its holder only. @@ -369,12 +377,13 @@ household: members: [tenant, lodger] output: - # Shared by the two claimants, the ISA put 17,000 in each unit, above - # reg 18's 16,000 limit. Now only its holder's unit counts it. + # Pooling this ISA would give each claimant 17,000, above reg 18's + # 16,000 limit. The formula attributes it to its holder. uc_assessable_capital: [0, 34_000] -# Cases 14 to 17: balances above a household total count exactly the total, -# so a holder at a capital limit or a tariff threshold stays on its boundary. +# Cases 14 to 17: where balances exceed a household total, the model counts +# exactly the total, so a holder at a capital limit or a tariff threshold +# stays on its boundary. # The model stores amounts in float32 (spacing 0.0009765625 at 16,000), and # absolute_error_margin 0 fails on one unit in the last place. @@ -484,8 +493,8 @@ members: [holder, partner_1, partner_2] cash_isa: 16_000 output: - # The holder counts the whole 16,000, so the pool keeps exactly nothing - # for the couple, never a negative amount. + # The holder counts the whole 16,000, so the formula leaves exactly + # nothing in the pool for the couple. cash_isa_countable_capital: [16_000, 0, 0] uc_assessable_capital: [16_000, 0] housing_benefit_assessable_capital: [16_000, 0] @@ -534,7 +543,7 @@ pension_credit_deemed_income: [0, 0] housing_benefit_assessable_capital: [10_000, 0] -- name: Case 18, a stocks and shares ISA counts no more than the share-like capital that holds it. +- name: Case 18, a stocks and shares ISA counts no more than the share-like capital that includes it. period: 2025 absolute_error_margin: 0 input: @@ -581,9 +590,9 @@ household: members: [parent, child, lodger] output: - # The formula takes the report as the claimant's and partner's capital, - # so the child's 10,000 ISA leaves the pool and the lodger's unit counts - # none of it. + # The formula takes the report as the claimant's and partner's capital + # and subtracts the child's 10,000 ISA from the pool, so the lodger's unit + # counts none of it. uc_assessable_capital: [0, 0] - name: Case 20, a UC report keeps the inherited override, so the part of a reporter's ISA above the report stays in the pool. @@ -679,6 +688,7 @@ household_2: members: [holder_2, other_2] output: - # The holder's ISA leaves the pool, and the report replaces the - # holder's unit's capital, so the other pension-age unit counts none. + # The formula subtracts the holder's ISA from the pool and replaces the + # holder's unit's capital with the report, so the other pension-age unit + # counts none of the ISA. pension_credit_assessable_capital: [0, 0, 6_000, 0] From ee7375dfb4a6fc7899b487c669a624fbc20b6852 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 14:14:39 -0500 Subject: [PATCH 16/27] List the new slow ISA test nodes in the shard table The threshold test and the first Pension Credit dataset each took over a minute locally; the Pension Credit test's node ids now carry their parameter. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/shard_durations.toml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/shard_durations.toml b/policyengine_uk/tests/shard_durations.toml index 59896675e7..5e04dd9d37 100644 --- a/policyengine_uk/tests/shard_durations.toml +++ b/policyengine_uk/tests/shard_durations.toml @@ -580,10 +580,12 @@ "policyengine_uk/tests/test_capital_gains_responses.py::test_revenue_falls_short_of_the_static_estimate" = 20 "policyengine_uk/tests/test_capital_gains_response_default.py::test_employer_cost_branches_hold_the_realisation_response" = 20 # test_person_isa_capital.py (#2729): local timings on 1 November 2026, one -# test at a time at load 40 to 90, until CI logs replace them. +# test at a time at load 40 to 90, and the threshold and first Pension Credit +# nodes in one run of the file at load 90 to 165, until CI logs replace them. "policyengine_uk/tests/test_person_isa_capital.py::test_balances_alone_and_totals_alone_give_one_unit_the_same_capital" = 221 -"policyengine_uk/tests/test_person_isa_capital.py::test_a_pound_counts_once_in_pension_credit" = 89 +"policyengine_uk/tests/test_person_isa_capital.py::test_isas_over_a_total_leave_an_unrelated_adult_exactly_at_a_threshold" = 109 "policyengine_uk/tests/test_person_isa_capital.py::test_household_only_data_keep_todays_capital" = 73 +"policyengine_uk/tests/test_person_isa_capital.py::test_a_pound_counts_once_in_pension_credit[households0]" = 65 "policyengine_uk/tests/test_person_isa_capital.py::test_a_pound_counts_once_in_working_age_means_tests" = 60 "policyengine_uk/tests/test_person_isa_capital.py::test_both_forms_in_one_dataset_add_up_to_the_stored_total" = 38 "policyengine_uk/tests/test_person_isa_capital.py::test_person_balances_without_household_totals_count_with_their_holders" = 27 From 1ad916e1533c4aa648a77179f3c50d7253e078df Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 14:38:17 -0500 Subject: [PATCH 17/27] Keep the sources descriptions' last lines, where #2688 appends #2688 appends its property sentences after the last line of the ESA, JSA and Housing Benefit sources descriptions, so this branch keeps that line and merges with it cleanly. Co-Authored-By: Claude Opus 5.5 --- .../parameters/gov/dwp/ESA/income/capital/sources.yaml | 4 ++-- .../parameters/gov/dwp/JSA/income/capital/sources.yaml | 5 ++--- .../gov/dwp/housing_benefit/means_test/capital/sources.yaml | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml index 4faf6de706..c0d6e66e31 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml @@ -27,8 +27,8 @@ description: > owner the data do not identify, the model allocates the full household remainder to benunits with a reported income-related ESA award and falls back to claimant and partner shares when nobody in the household is on that - reported claim path. No regulation sets that allocation; it approximates - ownership the data don't record. + reported claim path. That allocation is a data approximation, not a + legislated ownership rule. metadata: label: Income-related ESA countable capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml index b76a44a12d..2e07241d34 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml @@ -27,9 +27,8 @@ description: > states or asset holdings. For capital whose owner the data do not identify, the model allocates the full household remainder to benunits with a reported income-based JSA award and falls back to claimant and partner - shares when nobody in the household is on that reported claim path. No - regulation sets that allocation; it approximates ownership the data don't - record. + shares when nobody in the household is on that reported claim path. That + allocation is a data approximation, not a legislated ownership rule. metadata: label: Income-based JSA countable capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml index 26b59f3395..bd01c601ac 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml @@ -27,8 +27,8 @@ description: > wealth was split out of `corporate_wealth` still carry it inside `share_like_capital`. Other Schedule 6 disregards within these broad asset classes are not yet split out separately. For capital whose owner the data - do not identify, the model allocates the household remainder across - benunits by claimant and partner counts. + do not identify, the model allocates the household remainder across benunits + using an adult-share proxy. metadata: label: Housing Benefit countable capital sources period: year From 4df1a2d8ea52756435773891ed46a2a4ad22a1bc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 14:41:12 -0500 Subject: [PATCH 18/27] Compare the unrelated adult's outputs before the holdings' sum A failure then lists every output that differs from the household without the couple's ISAs. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_person_isa_capital.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/policyengine_uk/tests/test_person_isa_capital.py b/policyengine_uk/tests/test_person_isa_capital.py index f8db259077..7ef255e780 100644 --- a/policyengine_uk/tests/test_person_isa_capital.py +++ b/policyengine_uk/tests/test_person_isa_capital.py @@ -937,20 +937,24 @@ def test_isas_over_a_total_leave_an_unrelated_adult_exactly_at_a_threshold(): simulation = Simulation(situation=_threshold_situation()) outputs = {name: simulation.calculate(name, YEAR) for name in THRESHOLD_OUTPUTS} cash = simulation.calculate("cash_isa_countable_capital", YEAR) + # Each case takes four units (adult and couple, with ISAs and without) + # and six people. + differences = { + (case, name): (values[4 * c], values[4 * c + 2]) + for c, case in enumerate(THRESHOLDS) + for name, values in outputs.items() + if values[4 * c] != values[4 * c + 2] + } + assert not differences for c, (case, (age, total, balances, savings)) in enumerate(THRESHOLDS.items()): - # Each case takes four units (adult and couple, with ISAs and without) - # and six people. - adult, reference = 4 * c, 4 * c + 2 - couple = cash[6 * c + 1 : 6 * c + 3] - assert couple.sum(dtype=np.float64) == total, case - for name, values in outputs.items(): - assert values[adult] == values[reference], (case, name) capital = ( "pension_credit_assessable_capital" if age >= 66 else "uc_assessable_capital" ) - assert outputs[capital][adult] == savings / 3, case + assert outputs[capital][4 * c] == savings / 3, case + couple = cash[6 * c + 1 : 6 * c + 3] + assert couple.sum(dtype=np.float64) == total, case assert outputs["uc_tariff_income"][0] == 0 assert outputs["is_uc_eligible"][4] assert outputs["pension_credit_deemed_income"][8] == 0 From b729799a32fdb144396933bdf9e7e1f90a5c92de Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 14:43:57 -0500 Subject: [PATCH 19/27] State ISA ownership as the regulations do, for ordinary and junior ISAs Co-Authored-By: Claude Opus 5.5 --- .../ESA/income/capital/person_sources.yaml | 6 ++--- .../JSA/income/capital/person_sources.yaml | 24 ++++++++--------- .../means_test/capital/person_sources.yaml | 10 +++---- .../means_test/capital/person_sources.yaml | 6 ++--- .../income/capital/person_sources.yaml | 27 ++++++++++--------- .../means_test/capital/person_sources.yaml | 6 ++--- .../baseline/gov/dwp/person_isa_capital.yaml | 18 ++++++------- .../gov/dwp/cash_isa_countable_capital.py | 14 +++++----- ...stocks_and_shares_isa_countable_capital.py | 25 ++++++++--------- 9 files changed, 69 insertions(+), 67 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml index f3ec91b61c..284e6420bd 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml @@ -10,9 +10,9 @@ description: > ISAs exist from 6 April 2017, and the formulas count them from then. `cash_isa_countable_capital` and `stocks_and_shares_isa_countable_capital` hold a member's cash and stocks and shares ISA balances, bounded by the - household total. Each ISA has one holder, who owns its investments - beneficially (Individual Savings Account Regulations 1998 reg. 4(1B)(c) and - (6)(a)), and a child holds their own junior ISA (reg. 2B). The formulas + household total. The account investor beneficially owns an ordinary ISA's + investments, and the named child owns a junior ISA's investments (Individual + Savings Account Regulations 1998 reg. 4(6)(a) and reg. 2B(c)). The formulas subtract each bounded ISA balance from its household source (`cash_isa`, `share_like_capital`) and count it in the holder's unit. When a household's balances exceed its total, the model scales them to sum to the total diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml index 6566960805..604c8a0421 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml @@ -10,18 +10,18 @@ description: > under 60 and in full from 60. Lifetime ISAs exist from 6 April 2017, and the formulas count them from then. `cash_isa_countable_capital` and `stocks_and_shares_isa_countable_capital` hold a member's cash and stocks - and shares ISA balances, bounded by the household total. Each ISA has one - holder, who owns its investments beneficially (Individual Savings Account - Regulations 1998 reg. 4(1B)(c) and (6)(a)), and a child holds their own - junior ISA (reg. 2B). The formulas subtract each bounded ISA balance from - its household source (`cash_isa`, `share_like_capital`) and count it in the - holder's unit. When a household's balances exceed its total, the model - scales them to sum to the total exactly, rounding each share down or up to a - whole float32 step of the total. When a reform removes a household source - from `sources`, the formulas exclude the corresponding ISA person source. - With household totals alone, the formulas leave the whole total in the pool. - The ISA Regulations came into force for all other purposes on 6 April 1999 - (reg. 1). + and shares ISA balances, bounded by the household total. The account + investor beneficially owns an ordinary ISA's investments, and the named + child owns a junior ISA's investments (Individual Savings Account + Regulations 1998 reg. 4(6)(a) and reg. 2B(c)). The formulas subtract each + bounded ISA balance from its household source (`cash_isa`, + `share_like_capital`) and count it in the holder's unit. When a household's + balances exceed its total, the model scales them to sum to the total + exactly, rounding each share down or up to a whole float32 step of the + total. When a reform removes a household source from `sources`, the formulas + exclude the corresponding ISA person source. With household totals alone, + the formulas leave the whole total in the pool. The ISA Regulations came + into force for all other purposes on 6 April 1999 (reg. 1). metadata: label: Income-based JSA person-level capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml index 818425bc18..d172cf10ca 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml @@ -11,11 +11,11 @@ description: > exist from 6 April 2017, and the formulas count them from then. `cash_isa_countable_capital` and `stocks_and_shares_isa_countable_capital` hold a member's cash and stocks and shares ISA balances, bounded by the - household total. Each ISA has one holder, who owns its investments - beneficially (Individual Savings Account Regulations 1998 reg. 4(1B)(c) and - (6)(a)), and a child holds their own junior ISA (reg. 2B). In the ordinary - calculation, the formula subtracts each bounded ISA balance from its - household source (`cash_isa`, `share_like_capital`) and counts it in the + household total. The account investor beneficially owns an ordinary ISA's + investments, and the named child owns a junior ISA's investments (Individual + Savings Account Regulations 1998 reg. 4(6)(a) and reg. 2B(c)). In the + ordinary calculation, the formula subtracts each bounded ISA balance from + its household source (`cash_isa`, `share_like_capital`) and counts it in the holder's unit. For a family on a passporting benefit, or at pension age on a guarantee credit, the formula returns zero capital, and for a savings-credit-only award it uses the Pension Credit capital assessment. diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml index a96489b17c..a3b159f8d3 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml @@ -10,9 +10,9 @@ description: > exist from 6 April 2017, and the formulas count them from then. `cash_isa_countable_capital` and `stocks_and_shares_isa_countable_capital` hold a member's cash and stocks and shares ISA balances, bounded by the - household total. Each ISA has one holder, who owns its investments - beneficially (Individual Savings Account Regulations 1998 reg. 4(1B)(c) and - (6)(a)), and a child holds their own junior ISA (reg. 2B). The formulas + household total. The account investor beneficially owns an ordinary ISA's + investments, and the named child owns a junior ISA's investments (Individual + Savings Account Regulations 1998 reg. 4(6)(a) and reg. 2B(c)). The formulas subtract each bounded ISA balance from its household source (`cash_isa`, `share_like_capital`) and count it in the holder's unit. When a household's balances exceed its total, the model scales them to sum to the total diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml index a9a5370b24..adad15ccd2 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml @@ -10,19 +10,20 @@ description: > claimant's own Lifetime ISA counts in full. Lifetime ISAs exist from 6 April 2017, and the formulas count them from then. `cash_isa_countable_capital` and `stocks_and_shares_isa_countable_capital` hold a member's cash and - stocks and shares ISA balances, bounded by the household total. Each ISA has - one holder, who owns its investments beneficially (Individual Savings - Account Regulations 1998 reg. 4(1B)(c) and (6)(a)), and a child holds their - own junior ISA (reg. 2B). Where no unit reports capital, the formula - subtracts each bounded ISA balance from its household source (`cash_isa`, - `share_like_capital`) and counts it in the holder's unit. A nonnegative - `pension_credit_reported_capital` replaces the unit's calculated capital. - When a household's balances exceed its total, the model scales them to sum - to the total exactly, rounding each share down or up to a whole float32 step - of the total. When a reform removes a household source from `sources`, the - formulas exclude the corresponding ISA person source. With household totals - alone, the formulas leave the whole total in the pool. The ISA Regulations - came into force for all other purposes on 6 April 1999 (reg. 1). + stocks and shares ISA balances, bounded by the household total. The account + investor beneficially owns an ordinary ISA's investments, and the named + child owns a junior ISA's investments (Individual Savings Account + Regulations 1998 reg. 4(6)(a) and reg. 2B(c)). Where no unit reports + capital, the formula subtracts each bounded ISA balance from its household + source (`cash_isa`, `share_like_capital`) and counts it in the holder's + unit. A nonnegative `pension_credit_reported_capital` replaces the unit's + calculated capital. When a household's balances exceed its total, the model + scales them to sum to the total exactly, rounding each share down or up to a + whole float32 step of the total. When a reform removes a household source + from `sources`, the formulas exclude the corresponding ISA person source. + With household totals alone, the formulas leave the whole total in the pool. + The ISA Regulations came into force for all other purposes on 6 April 1999 + (reg. 1). metadata: label: Pension Credit person-level capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml index 6b6533a8c6..ba27ab1298 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml @@ -14,9 +14,9 @@ description: > household financial proxy, before the regulation 77 company substitution. `cash_isa_countable_capital` and `stocks_and_shares_isa_countable_capital` hold a member's cash and stocks and shares ISA balances, bounded by the - household total. Each ISA has one holder, who owns its investments - beneficially (Individual Savings Account Regulations 1998 reg. 4(1B)(c) and - (6)(a)), and a child holds their own junior ISA (reg. 2B). Where no unit + household total. The account investor beneficially owns an ordinary ISA's + investments, and the named child owns a junior ISA's investments (Individual + Savings Account Regulations 1998 reg. 4(6)(a) and reg. 2B(c)). Where no unit reports capital, the formula subtracts each bounded ISA balance from its household source and counts it in the holder's unit. For a reporting UC unit, the formula leaves its claimant's and partner's ISAs in the pool and diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml index b7e77b8da7..1dc2a188d6 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml @@ -1,13 +1,13 @@ # Cash and stocks and shares ISA balances count in the holder's own benefit -# unit (#2729). An ISA has one holder, who owns its investments beneficially -# (ISA Regulations 1998 reg 4(1B)(c) and (6)(a)), and a child holds their own -# junior ISA (reg 2B). The means tests count the claimant's and partner's -# capital (WRA 2012 s 5; HB Regs 2006 reg 25; SPCA 2002 s 5; IS Regs 1987 -# reg 23; JSA Regs 1996 reg 88; ESA Regs 2008 reg 83), and not a child's or -# young person's (HB reg 25(3) and 45; IS reg 23(2); JSA reg 88(2); ESA reg -# 83(2)). The formula subtracts the identified ISA balances from the -# household's capital, then allocates the remainder by claimant and partner -# counts. +# unit (#2729). The account investor beneficially owns an ordinary ISA's +# investments, and the named child owns a junior ISA's investments (ISA +# Regulations 1998 reg 4(6)(a) and reg 2B(c)). The means tests count the +# claimant's and partner's capital (WRA 2012 s 5; HB Regs 2006 reg 25; SPCA +# 2002 s 5; IS Regs 1987 reg 23; JSA Regs 1996 reg 88; ESA Regs 2008 reg 83), +# and not a child's or young person's (HB reg 25(3) and 45; IS reg 23(2); JSA +# reg 88(2); ESA reg 83(2)). The formula subtracts the identified ISA balances +# from the household's capital, then allocates the remainder by claimant and +# partner counts. - name: Case 1, a lodger's cash ISA stays out of the main benefit unit's capital. period: 2025 diff --git a/policyengine_uk/variables/gov/dwp/cash_isa_countable_capital.py b/policyengine_uk/variables/gov/dwp/cash_isa_countable_capital.py index 4d45c33616..7f3f66c465 100644 --- a/policyengine_uk/variables/gov/dwp/cash_isa_countable_capital.py +++ b/policyengine_uk/variables/gov/dwp/cash_isa_countable_capital.py @@ -8,13 +8,13 @@ class cash_isa_countable_capital(Variable): label = "Cash ISA value counted as means-tested capital" documentation = ( "The person's cash ISA balance (cash_isa_balance) as the means tests count it, " - "in the holder's own benefit unit. An ISA has one holder, who owns its " - "investments beneficially, and a child holds their own junior ISA. The " - "household's cash_isa already includes the balance, so each means test " - "subtracts it from the household pool before allocating the pool between " - "benefit units. The formula bounds the person's balance by cash_isa, the " - "household total. When the household's stored balance sum exceeds that total, " - "it scales the balances to sum to the total exactly, rounding each " + "in the holder's own benefit unit. The account investor beneficially owns an " + "ordinary ISA's investments, and the named child owns a junior ISA's " + "investments. The household's cash_isa already includes the balance, so each " + "means test subtracts it from the household pool before allocating the pool " + "between benefit units. The formula bounds the person's balance by cash_isa, " + "the household total. When the household's stored balance sum exceeds that " + "total, it scales the balances to sum to the total exactly, rounding each " "proportional share down or up to a whole float32 step of the total. When the " "balances sum to less, the rest of cash_isa stays in the pool, and a household " "with no balances keeps all of cash_isa there." diff --git a/policyengine_uk/variables/gov/dwp/stocks_and_shares_isa_countable_capital.py b/policyengine_uk/variables/gov/dwp/stocks_and_shares_isa_countable_capital.py index 6238167e9f..38e90fab4d 100644 --- a/policyengine_uk/variables/gov/dwp/stocks_and_shares_isa_countable_capital.py +++ b/policyengine_uk/variables/gov/dwp/stocks_and_shares_isa_countable_capital.py @@ -8,18 +8,19 @@ class stocks_and_shares_isa_countable_capital(Variable): label = "Stocks and shares ISA value counted as means-tested capital" documentation = ( "The person's stocks and shares ISA balance (stocks_and_shares_isa_balance) as " - "the means tests count it, in the holder's own benefit unit. An ISA has one " - "holder, who owns its investments beneficially, and a child holds their own " - "junior ISA. The household's share_like_capital already includes the balance, " - "through stocks_and_shares_isa within corporate_wealth or the itemised sum, so " - "each means test subtracts it from the household pool before allocating the " - "pool between benefit units. The formula bounds the person's balance by the " - "smaller of stocks_and_shares_isa and share_like_capital, so the household " - "never counts more stocks and shares ISA than it holds. When the household's " - "stored balance sum exceeds that total, it scales the balances to sum to the " - "total exactly, rounding each proportional share down or up to a whole float32 " - "step of the total. When the balances sum to less, the rest stays in the pool, " - "and a household with no balances keeps all of share_like_capital there." + "the means tests count it, in the holder's own benefit unit. The account " + "investor beneficially owns an ordinary ISA's investments, and the named child " + "owns a junior ISA's investments. The household's share_like_capital already " + "includes the balance, through stocks_and_shares_isa within corporate_wealth " + "or the itemised sum, so each means test subtracts it from the household pool " + "before allocating the pool between benefit units. The formula bounds the " + "person's balance by the smaller of stocks_and_shares_isa and " + "share_like_capital, so the household never counts more stocks and shares ISA " + "than it holds. When the household's stored balance sum exceeds that total, it " + "scales the balances to sum to the total exactly, rounding each proportional " + "share down or up to a whole float32 step of the total. When the balances sum " + "to less, the rest stays in the pool, and a household with no balances keeps " + "all of share_like_capital there." ) entity = Person definition_period = YEAR From 53e081e9f962b80b095bf628f50cfb12f2af53f3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 14:59:12 -0500 Subject: [PATCH 20/27] Give DWP and local authorities the verbs in the ISA descriptions Co-Authored-By: Claude Opus 5.5 --- .../ESA/income/capital/person_sources.yaml | 12 +++--- .../means_test/capital/person_sources.yaml | 41 ++++++++++--------- .../income/capital/person_sources.yaml | 19 +++++---- .../means_test/capital/person_sources.yaml | 18 ++++---- .../variables/input/cash_isa_balance.py | 2 +- .../input/stocks_and_shares_isa_balance.py | 2 +- 6 files changed, 48 insertions(+), 46 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml index 284e6420bd..b3c563348f 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml @@ -2,12 +2,12 @@ description: > Person-level model variables counted as income-related Employment and Support Allowance capital of the person's own benefit unit. The formulas sum these sources over the benefit unit's claimant and partner - (`is_claimant_or_partner`) and exclude dependants: regulation 83 treats only - the partner's capital as the claimant's. `lifetime_isa_countable_capital` - values a Lifetime ISA at its surrender value: DWP values capital at its - current market or surrender value under regulation 113, and DMG 52665 counts - a Lifetime ISA at 75% of its value under 60 and in full from 60. Lifetime - ISAs exist from 6 April 2017, and the formulas count them from then. + (`is_claimant_or_partner`) and exclude dependants: DWP excludes children's + capital under regulation 83(2). `lifetime_isa_countable_capital` values a + Lifetime ISA at its surrender value: DWP values capital at its current + market or surrender value under regulation 113, and DMG 52665 counts a + Lifetime ISA at 75% of its value under 60 and in full from 60. Lifetime ISAs + exist from 6 April 2017, and the formulas count them from then. `cash_isa_countable_capital` and `stocks_and_shares_isa_countable_capital` hold a member's cash and stocks and shares ISA balances, bounded by the household total. The account investor beneficially owns an ordinary ISA's diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml index d172cf10ca..c216001de1 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml @@ -5,26 +5,27 @@ description: > dependants: local authorities exclude children's capital under regulation 45. `lifetime_isa_countable_capital` values a Lifetime ISA at its surrender value: local authorities value capital at its current market or surrender - value under regulation 47 (regulation 45 of the pension-age regulations), in - the words of the DWP benefits' regulations, for which DWP guidance counts a - Lifetime ISA at 75% of its value under 60 and in full from 60. Lifetime ISAs - exist from 6 April 2017, and the formulas count them from then. - `cash_isa_countable_capital` and `stocks_and_shares_isa_countable_capital` - hold a member's cash and stocks and shares ISA balances, bounded by the - household total. The account investor beneficially owns an ordinary ISA's - investments, and the named child owns a junior ISA's investments (Individual - Savings Account Regulations 1998 reg. 4(6)(a) and reg. 2B(c)). In the - ordinary calculation, the formula subtracts each bounded ISA balance from - its household source (`cash_isa`, `share_like_capital`) and counts it in the - holder's unit. For a family on a passporting benefit, or at pension age on a - guarantee credit, the formula returns zero capital, and for a - savings-credit-only award it uses the Pension Credit capital assessment. - When a household's balances exceed its total, the model scales them to sum - to the total exactly, rounding each share down or up to a whole float32 step - of the total. When a reform removes a household source from `sources`, the - formulas exclude the corresponding ISA person source. With household totals - alone, the formulas leave the whole total in the pool. The ISA Regulations - came into force for all other purposes on 6 April 1999 (reg. 1). + value under regulation 47 (regulation 45 of the pension-age regulations), + whose words match the DWP benefits' valuation rules, and DWP guidance on + those rules counts a Lifetime ISA at 75% of its value under 60 and in full + from 60. Lifetime ISAs exist from 6 April 2017, and the formulas count them + from then. `cash_isa_countable_capital` and + `stocks_and_shares_isa_countable_capital` hold a member's cash and stocks + and shares ISA balances, bounded by the household total. The account + investor beneficially owns an ordinary ISA's investments, and the named + child owns a junior ISA's investments (Individual Savings Account + Regulations 1998 reg. 4(6)(a) and reg. 2B(c)). In the ordinary calculation, + the formula subtracts each bounded ISA balance from its household source + (`cash_isa`, `share_like_capital`) and counts it in the holder's unit. For a + family on a passporting benefit, or at pension age on a guarantee credit, + the formula returns zero capital, and for a savings-credit-only award it + uses the Pension Credit capital assessment. When a household's balances + exceed its total, the model scales them to sum to the total exactly, + rounding each share down or up to a whole float32 step of the total. When a + reform removes a household source from `sources`, the formulas exclude the + corresponding ISA person source. With household totals alone, the formulas + leave the whole total in the pool. The ISA Regulations came into force for + all other purposes on 6 April 1999 (reg. 1). metadata: label: Housing Benefit person-level capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml index adad15ccd2..88424fa8e7 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml @@ -2,15 +2,16 @@ description: > Person-level model variables counted as Pension Credit capital of the person's own benefit unit. The formulas sum these sources over the benefit unit's claimant and partner (`is_claimant_or_partner`) and exclude - dependants: section 5 of the State Pension Credit Act 2002 treats only the - partner's capital as the claimant's. `lifetime_isa_countable_capital` values - a Lifetime ISA at its surrender value: DWP values capital at its current - market or surrender value under regulation 19, and DMG 84751 counts a - Lifetime ISA at 75% of its value under 60 and in full from 60, so a - claimant's own Lifetime ISA counts in full. Lifetime ISAs exist from 6 April - 2017, and the formulas count them from then. `cash_isa_countable_capital` - and `stocks_and_shares_isa_countable_capital` hold a member's cash and - stocks and shares ISA balances, bounded by the household total. The account + dependants: DWP treats only the partner's capital as the claimant's under + section 5 of the State Pension Credit Act 2002. + `lifetime_isa_countable_capital` values a Lifetime ISA at its surrender + value: DWP values capital at its current market or surrender value under + regulation 19, and DMG 84751 counts a Lifetime ISA at 75% of its value under + 60 and in full from 60, so a claimant's own Lifetime ISA counts in full. + Lifetime ISAs exist from 6 April 2017, and the formulas count them from + then. `cash_isa_countable_capital` and + `stocks_and_shares_isa_countable_capital` hold a member's cash and stocks + and shares ISA balances, bounded by the household total. The account investor beneficially owns an ordinary ISA's investments, and the named child owns a junior ISA's investments (Individual Savings Account Regulations 1998 reg. 4(6)(a) and reg. 2B(c)). Where no unit reports diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml index ba27ab1298..d0eb32a853 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml @@ -1,15 +1,15 @@ description: > Person-level model variables counted as Universal Credit capital of the person's own benefit unit. The formulas sum these sources over the benefit - unit's claimant and partner (`is_uc_claimant`) and exclude dependants: - section 5 of the Welfare Reform Act 2012 tests the claimant's, or the joint - claimants' combined, capital. `lifetime_isa_countable_capital` values a - Lifetime ISA at its surrender value: DWP values capital at its current - market or surrender value under regulation 49(1), and ADM H1659 counts a - Lifetime ISA at 75% of its value under 60 and in full from 60. Lifetime ISAs - exist from 6 April 2017, and the formulas count them from then. A - nonnegative `benunit_reported_capital` replaces these financial sources for - its unit, so the formula counts no ISA twice, and a complete + unit's claimant and partner (`is_uc_claimant`) and exclude dependants: DWP + tests the claimant's, or the joint claimants' combined, capital under + section 5 of the Welfare Reform Act 2012. `lifetime_isa_countable_capital` + values a Lifetime ISA at its surrender value: DWP values capital at its + current market or surrender value under regulation 49(1), and ADM H1659 + counts a Lifetime ISA at 75% of its value under 60 and in full from 60. + Lifetime ISAs exist from 6 April 2017, and the formulas count them from + then. A nonnegative `benunit_reported_capital` replaces these financial + sources for its unit, so the formula counts no ISA twice, and a complete `uc_reported_capital` override replaces them together with property and the household financial proxy, before the regulation 77 company substitution. `cash_isa_countable_capital` and `stocks_and_shares_isa_countable_capital` diff --git a/policyengine_uk/variables/input/cash_isa_balance.py b/policyengine_uk/variables/input/cash_isa_balance.py index d290b1cdb3..f63b25458e 100644 --- a/policyengine_uk/variables/input/cash_isa_balance.py +++ b/policyengine_uk/variables/input/cash_isa_balance.py @@ -8,7 +8,7 @@ class cash_isa_balance(Variable): "(lifetime_isa_balance). The household's cash_isa sums it over its " "members, so a dataset that stores cash_isa itself keeps that stored " "total instead. It is separate from savings, the household's ordinary " - "savings. A child owns their own junior cash ISA. The means tests " + "savings. A child owns their own junior cash ISA. The means-test formulas " "count the balance in its holder's benefit unit, for the claimant and " "partner only (cash_isa_countable_capital)." ) diff --git a/policyengine_uk/variables/input/stocks_and_shares_isa_balance.py b/policyengine_uk/variables/input/stocks_and_shares_isa_balance.py index eed2587cb8..0c47cf61df 100644 --- a/policyengine_uk/variables/input/stocks_and_shares_isa_balance.py +++ b/policyengine_uk/variables/input/stocks_and_shares_isa_balance.py @@ -10,7 +10,7 @@ class stocks_and_shares_isa_balance(Variable): "stores stocks_and_shares_isa itself keeps that stored total instead. " "Like that total, it is part of corporate_wealth, so the two must " "never be summed. A child owns their own junior stocks and shares " - "ISA. The means tests count the balance in its holder's " + "ISA. The means-test formulas count the balance in its holder's " "benefit unit, for the claimant and partner only " "(stocks_and_shares_isa_countable_capital)." ) From 8d7d480799e76d1fd1d262b1ca677874a11272d5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 15:00:21 -0500 Subject: [PATCH 21/27] Say why households with no stored totals take their own dataset Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_person_isa_capital.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/test_person_isa_capital.py b/policyengine_uk/tests/test_person_isa_capital.py index 7ef255e780..087104d13c 100644 --- a/policyengine_uk/tests/test_person_isa_capital.py +++ b/policyengine_uk/tests/test_person_isa_capital.py @@ -742,9 +742,10 @@ def test_a_pound_counts_once_in_working_age_means_tests(units, holdings): # Households for Pension Credit's pool: each a list of benefit units of -# (age, person columns), with household columns. A dataset that stores a -# total for one household stores it for all, so households with no stored -# totals take a dataset of their own. +# (age, person columns), with household columns. _dataset gives every +# household each column any household has, and #2781 keeps a stored zero +# over the balances, so households with no stored totals take a dataset of +# their own. NO_STORED_TOTALS = [ # A pensioner couple, both with ISAs, and an unrelated working-age holder. { From 50c56c6f56321136279fd876059520848b75fce4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 15:03:11 -0500 Subject: [PATCH 22/27] Say what the counted-ISA check compares each holding with Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_person_isa_capital.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_person_isa_capital.py b/policyengine_uk/tests/test_person_isa_capital.py index 087104d13c..db3319d96d 100644 --- a/policyengine_uk/tests/test_person_isa_capital.py +++ b/policyengine_uk/tests/test_person_isa_capital.py @@ -664,8 +664,8 @@ def _check_counted_isas(simulation): """Invariant 2, in each household of the simulation. Where the balances' stored sum exceeds the total, the holdings sum to it - exactly, each within one step of the total of its balance; otherwise each - member counts their balance exactly. + exactly, each within one step of the total of its proportional share; + otherwise each member counts their balance exactly. """ totals = { "cash_isa_countable_capital": simulation.calculate("cash_isa", YEAR), From 7bcd324a9fbbc4b9d3c791c705fd548479c7de13 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 15:33:12 -0500 Subject: [PATCH 23/27] Give the allocation prose real verbs Co-Authored-By: Claude Opus 5.5 --- .../gov/dwp/ESA/income/capital/sources.yaml | 4 ++-- .../gov/dwp/JSA/income/capital/sources.yaml | 2 +- .../tests/test_person_isa_capital.py | 14 ++++++------- .../variables/gov/dwp/_person_capital_pool.py | 20 +++++++++---------- .../gov/dwp/esa_income_assessable_capital.py | 4 ++-- .../dwp/income_support_assessable_capital.py | 2 +- .../gov/dwp/jsa_income_assessable_capital.py | 4 ++-- 7 files changed, 25 insertions(+), 25 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml index c0d6e66e31..8665590684 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml @@ -26,8 +26,8 @@ description: > dataset does not isolate those states or asset holdings. For capital whose owner the data do not identify, the model allocates the full household remainder to benunits with a reported income-related ESA award and falls - back to claimant and partner shares when nobody in the household is on that - reported claim path. That allocation is a data approximation, not a + back to claimant and partner shares when nobody in the household reports + such an award. That allocation is a data approximation, not a legislated ownership rule. metadata: label: Income-related ESA countable capital sources diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml index 2e07241d34..d30ee0a503 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml @@ -27,7 +27,7 @@ description: > states or asset holdings. For capital whose owner the data do not identify, the model allocates the full household remainder to benunits with a reported income-based JSA award and falls back to claimant and partner - shares when nobody in the household is on that reported claim path. That + shares when nobody in the household reports such an award. That allocation is a data approximation, not a legislated ownership rule. metadata: label: Income-based JSA countable capital sources diff --git a/policyengine_uk/tests/test_person_isa_capital.py b/policyengine_uk/tests/test_person_isa_capital.py index db3319d96d..69244a696c 100644 --- a/policyengine_uk/tests/test_person_isa_capital.py +++ b/policyengine_uk/tests/test_person_isa_capital.py @@ -36,7 +36,7 @@ the balances' stored sum exceeds the total, the holdings' float64 sum equals it; where it doesn't, each holding equals the balance; a sole holder whose balance reaches the total holds exactly the total; each - holding is its proportional share rounded down or up to a whole step, + holding equals its proportional share rounded down or up to a whole step, the total's float32 spacing, allowing for float64 sum and division rounding, so it lies within one step of the share and passes a capital limit only where the share does; and the same input gives the same @@ -290,11 +290,11 @@ def test_holdings_stay_within_the_household_total(allocation): holders = household_sum((held > 0).astype(np.float32)) sole = (held > 0) & (holders == 1) & (held >= cap) assert np.array_equal(holding[sole], cap[sole]) - # Over a total, each holding is its exact proportional share rounded down - # or up to a whole step, the total's float32 spacing, allowing for float64 - # sum and division rounding. So it lies within one step of the share and - # passes a multiple of the step, such as a capital limit, only where the - # share does. + # Over a total, each holding equals its exact proportional share rounded + # down or up to a whole step, the total's float32 spacing, allowing for + # float64 sum and division rounding. So it lies within one step of the + # share and passes a multiple of the step, such as a capital limit, only + # where the share does. for i in np.flatnonzero(scales): same = np.flatnonzero(household_of == household_of[i]) exact = ( @@ -959,7 +959,7 @@ def test_isas_over_a_total_leave_an_unrelated_adult_exactly_at_a_threshold(): assert outputs["uc_tariff_income"][0] == 0 assert outputs["is_uc_eligible"][4] assert outputs["pension_credit_deemed_income"][8] == 0 - # The three holders' shares come to 15,999.9987 each. Each holding is + # The three holders' shares come to 15,999.9987 each. Each holding equals # that share rounded down or up to a step of 1/256, so none passes the # 16,000 limit its share stays under, and the three sum to cash_isa. holders = cash[-3:] diff --git a/policyengine_uk/variables/gov/dwp/_person_capital_pool.py b/policyengine_uk/variables/gov/dwp/_person_capital_pool.py index f5c83ce38b..dce4966697 100644 --- a/policyengine_uk/variables/gov/dwp/_person_capital_pool.py +++ b/policyengine_uk/variables/gov/dwp/_person_capital_pool.py @@ -86,16 +86,16 @@ def bounded_holdings(balance, household_total, household_sum, household_rank): total in whole steps of the total's float32 spacing. It rounds each proportional share down to a step, then gives the steps left over one each to the members whose shares lost the most in rounding, the first of - them on a tie. Every multiple of the step up to the total is an exact - float32, so the holdings sum to the total exactly. For every float32 - balance and finite total: + them on a tie. Float32 represents every multiple of the step up to the + total exactly, so the holdings sum to the total exactly. For every + float32 balance and finite total: - - no holding exceeds the household total, and none is negative; + - no holding exceeds the household total, and none falls below zero; - where the balances' stored sum exceeds the total, the holdings' float64 sum equals the total, so the pool keeps nothing of it; - where it doesn't, each holding equals the balance; - a sole holder whose balance reaches the total holds exactly the total; - - each holding is its proportional share, computed in float64, rounded + - each holding equals its proportional share, computed in float64, rounded down or up to a whole step, so it lies within one step of that share and passes a multiple of the step, such as a whole-pound capital limit, only where the share does; @@ -110,7 +110,7 @@ def bounded_holdings(balance, household_total, household_sum, household_rank): if not over.any(): return held # A household that doesn't scale takes a total of 0 and a step of 1 here, - # so an infinite total there stays quiet. Elsewhere the step is the + # so an infinite total there stays quiet. Elsewhere the step equals the # total's own float32 spacing, as float64. scaled = np.where(over, total, np.float32(0)) step = np.spacing(np.where(over, total, np.float32(1))).astype(np.float64) @@ -119,10 +119,10 @@ def bounded_holdings(balance, household_total, household_sum, household_rank): share = held * scaled.astype(np.float64) / np.where(over, held_sum, 1) steps = np.floor(share / step) lost = share / step - steps - # The total over its spacing is a whole number, and the shares' whole - # steps never sum above it, so the steps left over are a whole number at - # or above zero, and no more than the members whose shares lost part of a - # step. + # The total over its spacing gives a whole number, and the shares' whole + # steps never sum above it, so the steps left over come to a whole number + # at or above zero, and no more than the members whose shares lost part + # of a step. left_over = scaled / step - household_sum(steps) takes_a_step = over & (household_rank(-lost) < left_over) return np.where(over, ((steps + takes_a_step) * step).astype(np.float32), held) 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 97718bee1b..c4d0991e28 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py @@ -14,8 +14,8 @@ class esa_income_assessable_capital(Variable): "capital test. For capital whose owner the data do not identify, the model " "allocates the full household remainder to any benunit with a reported " "income-related ESA award and falls back to a claimant-and-partner share when " - "nobody in the household is on that reported claim path. No regulation sets " - "that allocation; it approximates ownership the data don't record. The formula " + "nobody in the household reports such an award. No regulation sets that " + "allocation; it approximates ownership the data don't record. The formula " "counts person-level sources, such as a Lifetime ISA and the cash and stocks " "and shares ISAs, only in the holder's own benunit and only for its claimant " "or partner (is_claimant_or_partner), and excludes dependants' capital. It " 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 c74c8f6704..82fd5a0cfe 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py @@ -14,7 +14,7 @@ class income_support_assessable_capital(Variable): "capital test. For capital whose owner the data do not identify, the model " "allocates the full household remainder to any benunit on the IS claim path " "and falls back to a claimant-and-partner share when nobody in the household " - "is on that path. No regulation sets that allocation; it approximates " + "claims on that path. No regulation sets that allocation; it approximates " "ownership the data don't record. The formula counts person-level sources, " "such as a Lifetime ISA and the cash and stocks and shares ISAs, only in the " "holder's own benunit and only for its claimant or partner " 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 84088a2a00..2cf466c410 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py @@ -14,8 +14,8 @@ class jsa_income_assessable_capital(Variable): "capital test. For capital whose owner the data do not identify, the model " "allocates the full household remainder to any benunit with a reported " "income-based JSA award and falls back to a claimant-and-partner share when " - "nobody in the household is on that reported claim path. No regulation sets " - "that allocation; it approximates ownership the data don't record. The formula " + "nobody in the household reports such an award. No regulation sets that " + "allocation; it approximates ownership the data don't record. The formula " "counts person-level sources, such as a Lifetime ISA and the cash and stocks " "and shares ISAs, only in the holder's own benunit and only for its claimant " "or partner (is_claimant_or_partner), and excludes dependants' capital. It " From 7d6a937ed1d636efaaa774bda2ce56a9e783d17d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 15:45:53 -0500 Subject: [PATCH 24/27] Name the formula as the actor in the ISA test module's summary Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_person_isa_capital.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/test_person_isa_capital.py b/policyengine_uk/tests/test_person_isa_capital.py index 69244a696c..94bc3d3946 100644 --- a/policyengine_uk/tests/test_person_isa_capital.py +++ b/policyengine_uk/tests/test_person_isa_capital.py @@ -1,16 +1,17 @@ """Person cash and stocks and shares ISAs count in the holder's benefit unit (#2729). -Each means test counts cash_isa_countable_capital and +Each means-test formula counts cash_isa_countable_capital and stocks_and_shares_isa_countable_capital through person_sources, for the claimant and partner. The formulas subtract them from the household source -that holds them (cash_isa and share_like_capital) and allocate the remainder -between benefit units. +that includes them (cash_isa and share_like_capital) and allocate the +remainder between benefit units. For the households below, with no reported capital, the formulas allocate the remainder by claimant weights or, for Pension Credit, pension-age adult -counts. A UC unit that reports its capital leaves its claimant's and -partner's ISAs in the pool, and a Pension Credit report replaces the unit's -calculated capital; the YAML cases cover both. The invariants: +counts. For a UC unit that reports its capital, the formula leaves its +claimant's and partner's ISAs in the pool, and a Pension Credit report +replaces the unit's calculated capital; the YAML cases cover both. The +invariants: 1. A pound counts once. The capital the household's benefit units count, plus the ISAs of members no unit counts (dependants, and for Pension From 6cd22fd526188ecb857b30b240d5057639b3eef0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:59:14 +0000 Subject: [PATCH 25/27] Cap the ISA allocation step at float32's largest spacing At a household total of float32's largest value, np.spacing gives infinity, so bounded_holdings returned NaN. The formula now caps the step at 2**104, which still divides every such total, and the property test adds that total as an explicit example and checks that a holding exceeds its balance by less than one step, and only where the balance falls between steps. The person_sources descriptions, the YAML header and the docs take the corrected citations and wording. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6GZZ6Rh8TR2FwUfcAA --- .../isa-inputs-and-capital-flows.md | 2 +- .../JSA/income/capital/person_sources.yaml | 2 +- .../means_test/capital/person_sources.yaml | 3 +- .../means_test/capital/person_sources.yaml | 4 +-- .../income/capital/person_sources.yaml | 11 +++---- .../baseline/gov/dwp/person_isa_capital.yaml | 13 ++++---- .../tests/test_person_isa_capital.py | 31 ++++++++++++------- .../variables/gov/dwp/_person_capital_pool.py | 24 +++++++++----- .../input/stocks_and_shares_isa_balance.py | 4 +-- 9 files changed, 56 insertions(+), 38 deletions(-) diff --git a/docs/book/assumptions/isa-inputs-and-capital-flows.md b/docs/book/assumptions/isa-inputs-and-capital-flows.md index f253502dd2..53f3df25dd 100644 --- a/docs/book/assumptions/isa-inputs-and-capital-flows.md +++ b/docs/book/assumptions/isa-inputs-and-capital-flows.md @@ -121,7 +121,7 @@ the model scales them to sum to the total exactly: it rounds each proportional share down to a whole step of the total's float32 spacing and gives the steps left over, one each, to the members whose shares lost most in rounding. The pool then keeps nothing of that total, each holding passes a capital limit only where -its proportional share does, and a sole holder whose balance reaches the total +its proportional share does for totals under £268 million, and a sole holder whose balance reaches the total counts exactly the total. For stocks and shares ISAs the model takes the smaller of `stocks_and_shares_isa` and `share_like_capital` as the total. Data that store only household totals hold no balances, so the pool keeps the whole total. diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml index 604c8a0421..fdbd6f8d02 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml @@ -3,7 +3,7 @@ description: > capital of the person's own benefit unit. The formulas sum these sources over the benefit unit's claimant and partner (`is_claimant_or_partner`) and exclude dependants: DWP excludes children's capital under regulation 109 - and, once SI 2003/455 applies, regulation 88(2). + until SI 2003/455 applies, and regulation 88(2) from then. `lifetime_isa_countable_capital` values a Lifetime ISA at its surrender value: DWP values capital at its current market or surrender value under regulation 111, and DMG 29665 counts a Lifetime ISA at 75% of its value diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml index c216001de1..9a019c1541 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml @@ -3,7 +3,8 @@ description: > person's own benefit unit. The formulas sum these sources over the benefit unit's claimant and partner (`is_claimant_or_partner`) and exclude dependants: local authorities exclude children's capital under regulation - 45. `lifetime_isa_countable_capital` values a Lifetime ISA at its surrender + 45 (regulation 23(3) of the pension-age regulations). + `lifetime_isa_countable_capital` values a Lifetime ISA at its surrender value: local authorities value capital at its current market or surrender value under regulation 47 (regulation 45 of the pension-age regulations), whose words match the DWP benefits' valuation rules, and DWP guidance on diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml index a3b159f8d3..85ee8acc63 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml @@ -2,8 +2,8 @@ description: > Person-level model variables counted as Income Support capital of the person's own benefit unit. The formulas sum these sources over the benefit unit's claimant and partner (`is_claimant_or_partner`) and exclude - dependants: DWP excludes children's capital under regulation 47 and, once SI - 2003/455 applies, regulation 23(2). `lifetime_isa_countable_capital` values + dependants: DWP excludes children's capital under regulation 47 until SI + 2003/455 applies, and regulation 23(2) from then. `lifetime_isa_countable_capital` values a Lifetime ISA at its surrender value: DWP values capital at its current market or surrender value under regulation 49, and DMG 29665 counts a Lifetime ISA at 75% of its value under 60 and in full from 60. Lifetime ISAs diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml index 88424fa8e7..a88098bd81 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml @@ -2,7 +2,7 @@ description: > Person-level model variables counted as Pension Credit capital of the person's own benefit unit. The formulas sum these sources over the benefit unit's claimant and partner (`is_claimant_or_partner`) and exclude - dependants: DWP treats only the partner's capital as the claimant's under + dependants: DWP treats the partner's capital as the claimant's under section 5 of the State Pension Credit Act 2002. `lifetime_isa_countable_capital` values a Lifetime ISA at its surrender value: DWP values capital at its current market or surrender value under @@ -14,11 +14,10 @@ description: > and shares ISA balances, bounded by the household total. The account investor beneficially owns an ordinary ISA's investments, and the named child owns a junior ISA's investments (Individual Savings Account - Regulations 1998 reg. 4(6)(a) and reg. 2B(c)). Where no unit reports - capital, the formula subtracts each bounded ISA balance from its household - source (`cash_isa`, `share_like_capital`) and counts it in the holder's - unit. A nonnegative `pension_credit_reported_capital` replaces the unit's - calculated capital. When a household's balances exceed its total, the model + Regulations 1998 reg. 4(6)(a) and reg. 2B(c)). The formula subtracts each + bounded ISA balance from its household source (`cash_isa`, + `share_like_capital`) and counts it in the holder's unit. A nonnegative + `pension_credit_reported_capital` replaces that unit's calculated capital. When a household's balances exceed its total, the model scales them to sum to the total exactly, rounding each share down or up to a whole float32 step of the total. When a reform removes a household source from `sources`, the formulas exclude the corresponding ISA person source. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml index 1dc2a188d6..2b88881ea0 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml @@ -1,13 +1,14 @@ # Cash and stocks and shares ISA balances count in the holder's own benefit # unit (#2729). The account investor beneficially owns an ordinary ISA's # investments, and the named child owns a junior ISA's investments (ISA -# Regulations 1998 reg 4(6)(a) and reg 2B(c)). The means tests count the -# claimant's and partner's capital (WRA 2012 s 5; HB Regs 2006 reg 25; SPCA +# Regulations 1998 reg 4(6)(a) and reg 2B(c)). DWP and local authorities count +# the claimant's and partner's capital (WRA 2012 s 5; HB Regs 2006 reg 25; SPCA # 2002 s 5; IS Regs 1987 reg 23; JSA Regs 1996 reg 88; ESA Regs 2008 reg 83), # and not a child's or young person's (HB reg 25(3) and 45; IS reg 23(2); JSA # reg 88(2); ESA reg 83(2)). The formula subtracts the identified ISA balances # from the household's capital, then allocates the remainder by claimant and -# partner counts. +# partner counts, between pension-age adults for Pension Credit, or wholly to +# a unit on the IS, JSA or ESA claim path. - name: Case 1, a lodger's cash ISA stays out of the main benefit unit's capital. period: 2025 @@ -63,7 +64,7 @@ members: [parent_1, parent_2, adult_child] savings: 9_000 output: - # share_like_capital is the itemised 6,000, all of it the child's ISA. + # share_like_capital sums the itemised 6,000, all of it the child's ISA. share_like_capital: 6_000 stocks_and_shares_isa_countable_capital: [0, 0, 6_000] # The formula subtracts the child's 6,000 from savings 9,000 plus @@ -560,8 +561,8 @@ household: members: holder stocks_and_shares_isa: 1_000 - # The means tests count share-like capital, and an input or reform - # can set it below the ISA total. + # The formulas count share-like capital, and an input or reform can + # set it below the ISA total. share_like_capital: 100 output: stocks_and_shares_isa_countable_capital: 100 diff --git a/policyengine_uk/tests/test_person_isa_capital.py b/policyengine_uk/tests/test_person_isa_capital.py index 94bc3d3946..eb3d1f1beb 100644 --- a/policyengine_uk/tests/test_person_isa_capital.py +++ b/policyengine_uk/tests/test_person_isa_capital.py @@ -38,10 +38,12 @@ equals it; where it doesn't, each holding equals the balance; a sole holder whose balance reaches the total holds exactly the total; each holding equals its proportional share rounded down or up to a whole step, - the total's float32 spacing, allowing for float64 sum and division - rounding, so it lies within one step of the share and passes a capital - limit only where the share does; and the same input gives the same - output. + the total's float32 spacing (at most 2**104, the largest spacing of a + finite float32), allowing for float64 sum and division rounding, so it + lies within one step of the share and passes a capital limit only where + the share does; a holding exceeds its balance by less than one step, and + only where the balance falls between steps; and the same input gives the + same output. """ import math @@ -253,6 +255,8 @@ def rank(criteria): @example(allocation=([1.0], [(0, 1e-30), (0, 3.0)])) # Balances whose sum exceeds float32's range. @example(allocation=([3e38], [(0, 3e38), (0, 3e38)])) +# A total of float32's largest value, whose next float32 is infinite. +@example(allocation=([3.4028235e38], [(0, 3.4028235e38), (0, 3.4028235e38)])) # Infinite, missing and negative balances, and a negative total. @example( allocation=( @@ -277,7 +281,7 @@ def test_holdings_stay_within_the_household_total(allocation): # The plain numpy rank agrees with Core's get_rank. person, household = _core_household(household_of) assert np.array_equal(household_rank(-held), person.get_rank(household, -held)) - # No holding is negative or exceeds the household total. + # No holding falls below zero or exceeds the household total. assert ((0 <= holding) & (holding <= cap)).all() with np.errstate(over="ignore"): scales = household_sum(held).astype(np.float32) > cap @@ -285,7 +289,7 @@ def test_holdings_stay_within_the_household_total(allocation): # means test nets the whole total from the household source and leaves # nothing of it in the pool. assert np.array_equal(household_sum(holding)[scales], cap[scales]) - # Balances whose stored sum is within the total keep their exact values. + # Balances whose stored sum doesn't exceed the total keep their exact values. assert np.array_equal(holding[~scales], held[~scales]) # A sole holder whose balance reaches the total holds exactly the total. holders = household_sum((held > 0).astype(np.float32)) @@ -295,7 +299,8 @@ def test_holdings_stay_within_the_household_total(allocation): # down or up to a whole step, the total's float32 spacing, allowing for # float64 sum and division rounding. So it lies within one step of the # share and passes a multiple of the step, such as a capital limit, only - # where the share does. + # where the share does. A holding exceeds its balance by less than one + # step, and only where the balance falls between steps. for i in np.flatnonzero(scales): same = np.flatnonzero(household_of == household_of[i]) exact = ( @@ -303,11 +308,15 @@ def test_holdings_stay_within_the_household_total(allocation): * Fraction(float(cap[i])) / sum(Fraction(float(held[j])) for j in same) ) - step = Fraction(float(np.spacing(cap[i]))) + step = Fraction(float(min(np.spacing(cap[i]), np.float32(2.0**104)))) slack = exact / 2**48 below = math.floor((exact - slack) / step) * step above = math.ceil((exact + slack) / step) * step assert below <= Fraction(float(holding[i])) <= above + excess = Fraction(float(holding[i])) - Fraction(float(held[i])) + assert excess < step + if (Fraction(float(held[i])) / step).denominator == 1: + assert excess <= 0 # The same input gives the same output, and one household's holdings # never depend on another's. again = bounded_holdings( @@ -425,9 +434,9 @@ def test_both_forms_in_one_dataset_add_up_to_the_stored_total(): pool = holdings[0] - counted[2] assert uc[1] == pytest.approx(pool / 3 + counted[2], rel=1e-6), year assert uc[0] == pytest.approx(2 * pool / 3, rel=1e-6), year - # #2781 keeps the stored 15,000: savings 4,000 and its unexplained 5,000 - # make a pool of 9,000, shared 6,000 and 3,000. The second household's - # savings are its whole pool. + # #2781 keeps the stored 15,000, so the formula allocates savings of 4,000 + # and the unexplained 5,000 as 6,000 and 3,000. The second household's + # pool holds only its savings. assert list(simulation.calculate("cash_isa", YEAR)) == [15_000, 0] assert list(simulation.calculate("uc_assessable_capital", YEAR)) == [ 6_000, diff --git a/policyengine_uk/variables/gov/dwp/_person_capital_pool.py b/policyengine_uk/variables/gov/dwp/_person_capital_pool.py index dce4966697..61a49e1fad 100644 --- a/policyengine_uk/variables/gov/dwp/_person_capital_pool.py +++ b/policyengine_uk/variables/gov/dwp/_person_capital_pool.py @@ -78,12 +78,13 @@ def bounded_holdings(balance, household_total, household_sum, household_rank): each member's rank in their household, lowest first and ties in member order, as Core's ``person.get_rank(person.household, ...)`` does. - A negative or missing balance holds nothing, and an infinite one the - whole total. Where the household's balances, summed as Core stores a - total, come to the nonnegative total or less, each member holds their - balance exactly, and the rest of the total stays with no one the data - name. Where they come to more, the function scales the balances to the - total in whole steps of the total's float32 spacing. It rounds each + A negative or missing balance holds nothing, and an infinite one counts + as the whole total before scaling. Where the household's balances, summed + as Core stores a total, come to the nonnegative total or less, each member + holds their balance exactly, and the formulas leave the rest of the total + in the pool. Where they come to more, the function scales the balances to + the total in whole steps of the total's float32 spacing, which it caps at + 2**104, the largest spacing of a finite float32. It rounds each proportional share down to a step, then gives the steps left over one each to the members whose shares lost the most in rounding, the first of them on a tie. Float32 represents every multiple of the step up to the @@ -99,6 +100,8 @@ def bounded_holdings(balance, household_total, household_sum, household_rank): down or up to a whole step, so it lies within one step of that share and passes a multiple of the step, such as a whole-pound capital limit, only where the share does; + - a holding exceeds its balance by less than one step, and only where the + balance falls between steps; - the same input gives the same output. """ total = np.maximum(np.float32(0), np.asarray(household_total, np.float32)) @@ -111,9 +114,14 @@ def bounded_holdings(balance, household_total, household_sum, household_rank): return held # A household that doesn't scale takes a total of 0 and a step of 1 here, # so an infinite total there stays quiet. Elsewhere the step equals the - # total's own float32 spacing, as float64. + # total's own float32 spacing, as float64. At float32's largest value the + # next float32 is infinite, so np.spacing gives infinity there; the cap at + # 2**104, the spacing of every float32 from 2**127 up, keeps that step + # finite and still divides the total. scaled = np.where(over, total, np.float32(0)) - step = np.spacing(np.where(over, total, np.float32(1))).astype(np.float64) + step = np.minimum( + np.spacing(np.where(over, total, np.float32(1))), np.float32(2.0**104) + ).astype(np.float64) # Float64 holds the product of two float32 amounts exactly, so each share # rounds once, in the division, and never exceeds the total. share = held * scaled.astype(np.float64) / np.where(over, held_sum, 1) diff --git a/policyengine_uk/variables/input/stocks_and_shares_isa_balance.py b/policyengine_uk/variables/input/stocks_and_shares_isa_balance.py index 0c47cf61df..b6907ada4e 100644 --- a/policyengine_uk/variables/input/stocks_and_shares_isa_balance.py +++ b/policyengine_uk/variables/input/stocks_and_shares_isa_balance.py @@ -8,8 +8,8 @@ class stocks_and_shares_isa_balance(Variable): "Lifetime ISAs excluded (lifetime_isa_balance). The household's " "stocks_and_shares_isa sums it over its members, so a dataset that " "stores stocks_and_shares_isa itself keeps that stored total instead. " - "Like that total, it is part of corporate_wealth, so the two must " - "never be summed. A child owns their own junior stocks and shares " + "corporate_wealth includes it, as it includes that total, so no " + "formula may add the two. A child owns their own junior stocks and shares " "ISA. The means-test formulas count the balance in its holder's " "benefit unit, for the claimant and partner only " "(stocks_and_shares_isa_countable_capital)." From 4fae1bf282fc4ac9ec0bd22fd16e1591c13826d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:36:50 +0000 Subject: [PATCH 26/27] Check the ISA allocation step against frexp and silence spacing's overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The property test now derives each total's float32 step from math.frexp instead of repeating the helper's np.spacing and 2**104 cap, and adds an explicit example whose shares exceed 2**127, so a smaller cap fails it. The helper computes the step under np.errstate(over="ignore"), so a total of float32's largest value no longer warns. Three descriptions take a reflow, and the ISA assumptions page moves its £268 million qualifier to the start of its clause. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N2tyHQQaNwZbVNQGoYkogY --- .../isa-inputs-and-capital-flows.md | 27 ++++++++++--------- .../means_test/capital/person_sources.yaml | 11 ++++---- .../income/capital/person_sources.yaml | 14 +++++----- .../tests/test_person_isa_capital.py | 4 ++- .../variables/gov/dwp/_person_capital_pool.py | 14 +++++----- 5 files changed, 38 insertions(+), 32 deletions(-) diff --git a/docs/book/assumptions/isa-inputs-and-capital-flows.md b/docs/book/assumptions/isa-inputs-and-capital-flows.md index 53f3df25dd..d49faca1fe 100644 --- a/docs/book/assumptions/isa-inputs-and-capital-flows.md +++ b/docs/book/assumptions/isa-inputs-and-capital-flows.md @@ -120,19 +120,20 @@ year, the remainder stays in the pool. Where their stored sum exceeds the total, the model scales them to sum to the total exactly: it rounds each proportional share down to a whole step of the total's float32 spacing and gives the steps left over, one each, to the members whose shares lost most in rounding. The pool -then keeps nothing of that total, each holding passes a capital limit only where -its proportional share does for totals under £268 million, and a sole holder whose balance reaches the total -counts exactly the total. For stocks and shares ISAs the model takes the smaller -of `stocks_and_shares_isa` and `share_like_capital` as the total. Data that -store only household totals hold no balances, so the pool keeps the whole total. -The formulas count a balance only while the programme's `sources` lists the -household source that includes it, so a reform that drops `cash_isa` or -`share_like_capital` from `sources` drops the balances with it. For a Universal -Credit unit that reports its capital (`benunit_reported_capital` or -`uc_reported_capital`, zero or more), the formula leaves the unit's claimant's -and partner's ISAs in the pool and subtracts the report, as it does for their -company holdings. A nonnegative `pension_credit_reported_capital` replaces the -unit's calculated Pension Credit capital. +then keeps nothing of that total, for totals under £268 million each holding +passes a capital limit only where its proportional share does, and a sole holder +whose balance reaches the total counts exactly the total. For stocks and shares +ISAs the model takes the smaller of `stocks_and_shares_isa` and +`share_like_capital` as the total. Data that store only household totals hold no +balances, so the pool keeps the whole total. The formulas count a balance only +while the programme's `sources` lists the household source that includes it, so +a reform that drops `cash_isa` or `share_like_capital` from `sources` drops the +balances with it. For a Universal Credit unit that reports its capital +(`benunit_reported_capital` or `uc_reported_capital`, zero or more), the formula +leaves the unit's claimant's and partner's ISAs in the pool and subtracts the +report, as it does for their company holdings. A nonnegative +`pension_credit_reported_capital` replaces the unit's calculated Pension Credit +capital. Ordinary accessible cash ISA balances enter the capital sources for Universal Credit, Housing Benefit, Pension Credit, Income Support, income-based JSA and diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml index 85ee8acc63..2fad42f98d 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml @@ -3,11 +3,12 @@ description: > person's own benefit unit. The formulas sum these sources over the benefit unit's claimant and partner (`is_claimant_or_partner`) and exclude dependants: DWP excludes children's capital under regulation 47 until SI - 2003/455 applies, and regulation 23(2) from then. `lifetime_isa_countable_capital` values - a Lifetime ISA at its surrender value: DWP values capital at its current - market or surrender value under regulation 49, and DMG 29665 counts a - Lifetime ISA at 75% of its value under 60 and in full from 60. Lifetime ISAs - exist from 6 April 2017, and the formulas count them from then. + 2003/455 applies, and regulation 23(2) from then. + `lifetime_isa_countable_capital` values a Lifetime ISA at its surrender + value: DWP values capital at its current market or surrender value under + regulation 49, and DMG 29665 counts a Lifetime ISA at 75% of its value under + 60 and in full from 60. Lifetime ISAs exist from 6 April 2017, and the + formulas count them from then. `cash_isa_countable_capital` and `stocks_and_shares_isa_countable_capital` hold a member's cash and stocks and shares ISA balances, bounded by the household total. The account investor beneficially owns an ordinary ISA's diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml index a88098bd81..194718020a 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml @@ -17,13 +17,13 @@ description: > Regulations 1998 reg. 4(6)(a) and reg. 2B(c)). The formula subtracts each bounded ISA balance from its household source (`cash_isa`, `share_like_capital`) and counts it in the holder's unit. A nonnegative - `pension_credit_reported_capital` replaces that unit's calculated capital. When a household's balances exceed its total, the model - scales them to sum to the total exactly, rounding each share down or up to a - whole float32 step of the total. When a reform removes a household source - from `sources`, the formulas exclude the corresponding ISA person source. - With household totals alone, the formulas leave the whole total in the pool. - The ISA Regulations came into force for all other purposes on 6 April 1999 - (reg. 1). + `pension_credit_reported_capital` replaces that unit's calculated capital. + When a household's balances exceed its total, the model scales them to sum + to the total exactly, rounding each share down or up to a whole float32 step + of the total. When a reform removes a household source from `sources`, the + formulas exclude the corresponding ISA person source. With household totals + alone, the formulas leave the whole total in the pool. The ISA Regulations + came into force for all other purposes on 6 April 1999 (reg. 1). metadata: label: Pension Credit person-level capital sources period: year diff --git a/policyengine_uk/tests/test_person_isa_capital.py b/policyengine_uk/tests/test_person_isa_capital.py index eb3d1f1beb..9b105fd5b4 100644 --- a/policyengine_uk/tests/test_person_isa_capital.py +++ b/policyengine_uk/tests/test_person_isa_capital.py @@ -257,6 +257,8 @@ def rank(criteria): @example(allocation=([3e38], [(0, 3e38), (0, 3e38)])) # A total of float32's largest value, whose next float32 is infinite. @example(allocation=([3.4028235e38], [(0, 3.4028235e38), (0, 3.4028235e38)])) +# Shares above 2**127, where every float32 lies on a grid of 2**104. +@example(allocation=([3e38], [(0, 3e38), (0, 2e38)])) # Infinite, missing and negative balances, and a negative total. @example( allocation=( @@ -308,7 +310,7 @@ def test_holdings_stay_within_the_household_total(allocation): * Fraction(float(cap[i])) / sum(Fraction(float(held[j])) for j in same) ) - step = Fraction(float(min(np.spacing(cap[i]), np.float32(2.0**104)))) + step = Fraction(2) ** max(math.frexp(float(cap[i]))[1] - 24, -149) slack = exact / 2**48 below = math.floor((exact - slack) / step) * step above = math.ceil((exact + slack) / step) * step diff --git a/policyengine_uk/variables/gov/dwp/_person_capital_pool.py b/policyengine_uk/variables/gov/dwp/_person_capital_pool.py index 61a49e1fad..94683e99d6 100644 --- a/policyengine_uk/variables/gov/dwp/_person_capital_pool.py +++ b/policyengine_uk/variables/gov/dwp/_person_capital_pool.py @@ -115,13 +115,15 @@ def bounded_holdings(balance, household_total, household_sum, household_rank): # A household that doesn't scale takes a total of 0 and a step of 1 here, # so an infinite total there stays quiet. Elsewhere the step equals the # total's own float32 spacing, as float64. At float32's largest value the - # next float32 is infinite, so np.spacing gives infinity there; the cap at - # 2**104, the spacing of every float32 from 2**127 up, keeps that step - # finite and still divides the total. + # next float32 is infinite, so np.spacing gives infinity there and warns + # of an overflow, which the formula silences. The cap at 2**104, the + # spacing of every float32 from 2**127 up, keeps that step finite and + # still divides the total. scaled = np.where(over, total, np.float32(0)) - step = np.minimum( - np.spacing(np.where(over, total, np.float32(1))), np.float32(2.0**104) - ).astype(np.float64) + with np.errstate(over="ignore"): + step = np.minimum( + np.spacing(np.where(over, total, np.float32(1))), np.float32(2.0**104) + ).astype(np.float64) # Float64 holds the product of two float32 amounts exactly, so each share # rounds once, in the division, and never exceeds the total. share = held * scaled.astype(np.float64) / np.where(over, held_sum, 1) From 3aa7917ab24fbe86964a6779f0f338dfcfd225a4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 12:09:51 -0500 Subject: [PATCH 27/27] Separate the three clauses of the ISA pool sentence Co-Authored-By: Claude Opus 5.5 --- docs/book/assumptions/isa-inputs-and-capital-flows.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/book/assumptions/isa-inputs-and-capital-flows.md b/docs/book/assumptions/isa-inputs-and-capital-flows.md index d49faca1fe..c6e0836479 100644 --- a/docs/book/assumptions/isa-inputs-and-capital-flows.md +++ b/docs/book/assumptions/isa-inputs-and-capital-flows.md @@ -120,8 +120,8 @@ year, the remainder stays in the pool. Where their stored sum exceeds the total, the model scales them to sum to the total exactly: it rounds each proportional share down to a whole step of the total's float32 spacing and gives the steps left over, one each, to the members whose shares lost most in rounding. The pool -then keeps nothing of that total, for totals under £268 million each holding -passes a capital limit only where its proportional share does, and a sole holder +then keeps nothing of that total; for totals under £268 million, each holding +passes a capital limit only where its proportional share does; and a sole holder whose balance reaches the total counts exactly the total. For stocks and shares ISAs the model takes the smaller of `stocks_and_shares_isa` and `share_like_capital` as the total. Data that store only household totals hold no