From 098c29fed1a2c3e724e570503555397e85b7441d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 8 Oct 2026 10:46:20 -0400 Subject: [PATCH 1/3] Record benefit-unit capital once; derive UC and Pension Credit reported capital from it Adds benunit_reported_capital, an input for the benefit unit's recorded savings and investments (-1 when nothing is recorded). uc_reported_capital and pension_credit_reported_capital become formulas that default to it, so a dataset fills the observed figure once and each means test derives its own. Either can still be set directly. Unchanged for data without the input. Co-Authored-By: Claude Opus 5.5 --- changelog.d/benunit-reported-capital.added.md | 1 + policyengine_uk/data/uprating_indices.yaml | 6 + .../means_test/capital/sources.yaml | 5 +- .../wealth/benunit_reported_capital.yaml | 101 +++++++++ ...est_benunit_reported_capital_properties.py | 212 ++++++++++++++++++ ...sion_credit_reported_capital_properties.py | 6 +- .../pension_credit_reported_capital.py | 27 ++- .../universal_credit/uc_assessable_capital.py | 6 +- .../universal_credit/uc_reported_capital.py | 21 +- .../wealth/benunit_reported_capital.py | 22 ++ 10 files changed, 386 insertions(+), 21 deletions(-) create mode 100644 changelog.d/benunit-reported-capital.added.md create mode 100644 policyengine_uk/tests/policy/baseline/household/wealth/benunit_reported_capital.yaml create mode 100644 policyengine_uk/tests/test_benunit_reported_capital_properties.py create mode 100644 policyengine_uk/variables/household/wealth/benunit_reported_capital.py diff --git a/changelog.d/benunit-reported-capital.added.md b/changelog.d/benunit-reported-capital.added.md new file mode 100644 index 0000000000..947d6a8b30 --- /dev/null +++ b/changelog.d/benunit-reported-capital.added.md @@ -0,0 +1 @@ +- Added `benunit_reported_capital`, the benefit unit's recorded savings and investments (such as the FRS's TOTCAPB4; -1 when nothing is recorded). `uc_reported_capital` and `pension_credit_reported_capital` are now formulas that default to it, so a dataset records the observed capital once and each means test derives its own; either can still be set directly. The input uprates like `savings`, and a dataset that stores either programme variable directly is still uprated the same way. Results are unchanged for data that do not supply the new input. diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index cd5b2f2382..7cebef7f9a 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -92,6 +92,7 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - capital_gains_badr - capital_gains_residential_property - capital_gains_carried_interest +- benunit_reported_capital - corporate_wealth - dividend_income - gross_financial_wealth @@ -107,6 +108,8 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - other_investment_income - other_residential_property_value - owned_land +# A formula of benunit_reported_capital; listed so a dataset that stores it +# directly is uprated like the input. - pension_credit_reported_capital - pension_income - private_pension_wealth @@ -116,6 +119,9 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - savings - savings_interest_income - sublet_income +# A formula of benunit_reported_capital; listed so a dataset that stores it +# directly is uprated like the input. +- uc_reported_capital gov.economic_assumptions.yoy_growth.obr.per_capita.mixed_income: - self_employment_income gov.economic_assumptions.yoy_growth.obr.private_pension_index: 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 f64474cebb..0909fa260e 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 @@ -21,8 +21,9 @@ description: > benunits that are not themselves UC claimants. That proxy is a data approximation, not a legislated allocation rule. Regulation 47 is cited only as related context on jointly held capital. If some benunits provide - `uc_reported_capital`, the proxy is applied only to the residual household - capital for benunits that remain unreported. + `uc_reported_capital` (by default the benefit unit's recorded capital, + `benunit_reported_capital`), the proxy is applied only to the residual + household capital for benunits that remain unreported. metadata: label: Universal Credit countable capital sources period: year diff --git a/policyengine_uk/tests/policy/baseline/household/wealth/benunit_reported_capital.yaml b/policyengine_uk/tests/policy/baseline/household/wealth/benunit_reported_capital.yaml new file mode 100644 index 0000000000..af109ef283 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/household/wealth/benunit_reported_capital.yaml @@ -0,0 +1,101 @@ +# benunit_reported_capital records the benefit unit's savings and investments +# (-1 when nothing is recorded). Universal Credit (WRA 2012 s.5; UC Regulations +# 2013 reg.18) and Pension Credit (SPCA 2002 s.5) assess the claimant's and +# partner's capital together, so both programmes take the recorded figure in +# place of the household capital proxy unless their own variable is set. +# Tariff income: UC Regulations 2013 reg.72, 4.35 pounds a month for each 250 +# pounds or part above 6,000. + +- name: Recorded capital replaces the household proxy for both programmes. + period: 2025 + input: + people: + claimant: + age: 30 + benunits: + benunit: + members: [claimant] + benunit_reported_capital: 2_000 + households: + household: + members: [claimant] + savings: 50_000 + output: + uc_reported_capital: 2_000 + pension_credit_reported_capital: 2_000 + uc_assessable_capital: 2_000 + is_uc_eligible: true + +- name: With nothing recorded the household proxy applies. + period: 2025 + input: + people: + claimant: + age: 30 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + savings: 50_000 + output: + benunit_reported_capital: -1 + uc_reported_capital: -1 + uc_assessable_capital: 50_000 + is_uc_eligible: false + +- name: A programme variable set directly overrides the recorded capital for that programme only. + period: 2025 + input: + people: + claimant: + age: 30 + benunits: + benunit: + members: [claimant] + benunit_reported_capital: 20_000 + uc_reported_capital: 3_000 + households: + household: + members: [claimant] + savings: 50_000 + output: + uc_assessable_capital: 3_000 + pension_credit_reported_capital: 20_000 + +- name: Recorded capital sets UC tariff income. + period: 2025 + input: + people: + claimant: + age: 30 + benunits: + benunit: + members: [claimant] + benunit_reported_capital: 6_251 + households: + household: + members: [claimant] + savings: 50_000 + output: + uc_assessable_capital: 6_251 + # 251 pounds above 6,000 is two steps: 2 * 4.35 * 12 = 104.40. + uc_tariff_income: 104.4 + +- name: Recorded capital replaces the household proxy for Pension Credit. + period: 2025 + input: + people: + pensioner: + age: 75 + benunits: + benunit: + members: [pensioner] + benunit_reported_capital: 12_000 + households: + household: + members: [pensioner] + savings: 200_000 + output: + pension_credit_assessable_capital: 12_000 diff --git a/policyengine_uk/tests/test_benunit_reported_capital_properties.py b/policyengine_uk/tests/test_benunit_reported_capital_properties.py new file mode 100644 index 0000000000..7b6720c98f --- /dev/null +++ b/policyengine_uk/tests/test_benunit_reported_capital_properties.py @@ -0,0 +1,212 @@ +"""Properties of `benunit_reported_capital`, the benefit unit's recorded capital. + +A dataset records what it observes (the benefit unit's savings and +investments, -1 when nothing is recorded), and each means test derives its +own reported capital from it: `uc_reported_capital` and +`pension_credit_reported_capital` default to it and can still be set directly. + +Invariants, over generated households of one or two benefit units: + +1. Derivation: with only the recorded input set, both programmes' reported + capital equals it, -1 included. +2. Differential: recording R once gives the same assessable capital, eligibility + and entitlement for Universal Credit and Pension Credit as setting R on both + programme variables directly, which is how they were supplied before the + input existed. With nothing recorded this is the old default (-1), so the + input changes nothing for data that do not supply it. +3. Precedence: a programme variable set directly wins for that programme only. +4. Override identity and bounds: a unit with recorded R >= 0 has Universal + Credit assessable capital R, whatever the household holds, and assessable + capital is never negative. +5. Monotonicity: Universal Credit tariff income never falls, and eligibility and + the award never rise, as recorded capital rises. +6. Uprating: the input and both programme variables uprate like `savings`, so a + dataset that stores any of them projects it the same way. +""" + +from pathlib import Path + +import numpy as np +import yaml +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2025 +SETTINGS = settings( + max_examples=15, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +recorded = st.one_of(st.just(-1), st.integers(min_value=0, max_value=40_000)) +PROGRAMME_VARIABLES = ["uc_reported_capital", "pension_credit_reported_capital"] +OUTPUTS = [ + "uc_reported_capital", + "pension_credit_reported_capital", + "uc_assessable_capital", + "pension_credit_assessable_capital", + "is_uc_eligible", + "uc_tariff_income", + "universal_credit", + "pension_credit", +] + + +@st.composite +def households(draw): + units = [] + for _ in range(draw(st.integers(min_value=1, max_value=2))): + units.append( + { + "ages": draw( + st.lists( + st.sampled_from([25, 40, 58, 70, 82]), min_size=1, max_size=2 + ) + ), + "children": draw(st.integers(min_value=0, max_value=2)), + "earnings": draw(st.sampled_from([0, 0, 9_000, 30_000])), + "state_pension": draw(st.sampled_from([0, 9_000])), + "recorded": draw(recorded), + } + ) + household = { + "savings": draw(st.sampled_from([0, 4_000, 30_000, 120_000])), + "corporate_wealth": draw(st.sampled_from([0, 50_000])), + "rent": draw(st.sampled_from([0, 7_200])), + } + return units, household + + +def simulate(units, household, benunit_inputs): + year = str(YEAR) + people, benunits, members = {}, {}, [] + for i, unit in enumerate(units): + names = [] + for j, age in enumerate(unit["ages"]): + name = f"a{i}_{j}" + people[name] = { + "age": {year: age}, + "is_claimant_or_partner": {year: True}, + "employment_income": {year: unit["earnings"] if j == 0 else 0}, + "state_pension": {year: unit["state_pension"] if age >= 66 else 0}, + } + names.append(name) + for k in range(unit["children"]): + name = f"c{i}_{k}" + people[name] = { + "age": {year: 4 + 3 * k}, + "is_claimant_or_partner": {year: False}, + } + names.append(name) + benunits[f"b{i}"] = { + "members": names, + **{k: {year: v} for k, v in benunit_inputs[i].items()}, + } + members += names + situation = { + "people": people, + "benunits": benunits, + "households": { + "h": { + "members": members, + "tenure_type": { + year: "RENT_PRIVATELY" if household["rent"] else "OWNED_OUTRIGHT" + }, + **{k: {year: v} for k, v in household.items()}, + } + }, + } + return Simulation(situation=situation) + + +def outputs(sim): + return {v: np.asarray(sim.calculate(v, YEAR), dtype=float) for v in OUTPUTS} + + +@SETTINGS +@given(households()) +def test_recorded_capital_is_equivalent_to_setting_each_programme(case): + units, household = case + via_input = simulate( + units, + household, + [{"benunit_reported_capital": u["recorded"]} for u in units], + ) + via_programmes = simulate( + units, + household, + [{v: u["recorded"] for v in PROGRAMME_VARIABLES} for u in units], + ) + a, b = outputs(via_input), outputs(via_programmes) + for i, unit in enumerate(units): + for v in PROGRAMME_VARIABLES: + assert a[v][i] == unit["recorded"] + if unit["recorded"] >= 0: + assert a["uc_assessable_capital"][i] == unit["recorded"] + for v in OUTPUTS: + np.testing.assert_allclose(a[v], b[v], rtol=0, atol=1e-6, err_msg=v) + for v in ["uc_assessable_capital", "pension_credit_assessable_capital"]: + assert np.all(a[v] >= 0) + + +@SETTINGS +@given(households(), recorded, st.integers(min_value=0, max_value=40_000)) +def test_programme_variable_set_directly_wins_for_that_programme(case, base, uc): + units, household = case + inputs = [{"benunit_reported_capital": base}] + [ + {"benunit_reported_capital": u["recorded"]} for u in units[1:] + ] + inputs[0]["uc_reported_capital"] = uc + sim = simulate(units, household, inputs) + assert sim.calculate("uc_reported_capital", YEAR)[0] == uc + assert sim.calculate("pension_credit_reported_capital", YEAR)[0] == base + assert sim.calculate("uc_assessable_capital", YEAR)[0] == uc + + +@SETTINGS +@given( + households(), + st.integers(min_value=0, max_value=40_000), + st.integers(min_value=0, max_value=40_000), +) +def test_uc_never_rises_with_recorded_capital(case, x, y): + units, household = case + low, high = sorted([x, y]) + + def run(value): + inputs = [{"benunit_reported_capital": value}] + [ + {"benunit_reported_capital": u["recorded"]} for u in units[1:] + ] + return outputs(simulate(units, household, inputs)) + + lo, hi = run(low), run(high) + assert hi["uc_tariff_income"][0] >= lo["uc_tariff_income"][0] + assert hi["is_uc_eligible"][0] <= lo["is_uc_eligible"][0] + assert hi["universal_credit"][0] <= lo["universal_credit"][0] + 1e-6 + + +def test_recorded_capital_uprates_with_savings(): + """The input uprates like `savings`. The programme variables are formulas, + which cannot also carry an uprating index, but stay in the same + uprating_indices.yaml group so a dataset that stores them directly (such + as one built before the input existed) projects them the same way.""" + from policyengine_uk.system import system + + variables = system.variables + indices = yaml.safe_load( + (Path(__file__).parents[1] / "data" / "uprating_indices.yaml").read_text() + ) + group = [k for k, v in indices.items() if "savings" in v] + assert len(group) == 1 + recorded = variables["benunit_reported_capital"] + assert recorded.uprating == variables["savings"].uprating + assert recorded.default_value == -1 + assert not recorded.formulas + for name in ["benunit_reported_capital", *PROGRAMME_VARIABLES]: + assert name in indices[group[0]] + for name in PROGRAMME_VARIABLES: + assert variables[name].formulas + assert variables[name].uprating is None + assert variables[name].default_value == -1 diff --git a/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py b/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py index 5d49ce944e..ba0189280c 100644 --- a/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py +++ b/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py @@ -241,9 +241,11 @@ def test_reported_capital_uprates_with_savings(): from policyengine_uk.system import system variables = system.variables + # The variable is a formula of benunit_reported_capital, which carries the + # uprating; a dataset that stores it directly is uprated through + # uprating_indices.yaml. assert ( - variables["pension_credit_reported_capital"].uprating - == variables["savings"].uprating + variables["benunit_reported_capital"].uprating == variables["savings"].uprating ) assert variables["pension_credit_reported_capital"].default_value == -1 indices = yaml.safe_load( diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py index 6e964e1a05..536c490645 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py @@ -7,22 +7,25 @@ class pension_credit_reported_capital(Variable): label = "reported Pension Credit capital" documentation = ( "Countable capital of the claimant and partner as recorded for this " - "benefit unit, such as a survey's benefit-unit capital measure. Pension " - "Credit counts the claimant's capital, and the State Pension Credit Act " - "2002 s. 5 treats the partner's as the claimant's. When this is 0 or " - "more it replaces every capital source in Pension Credit assessable " - "capital: the household proxy (savings, land, property and " - "corporate_wealth) and the person-level sources such as a Lifetime ISA. " - "The model applies neither the Schedule V disregards nor the reg. 19 " - "valuation to it, so it must already be the countable figure, and " - "reforms to the capital source parameters do not reach a benefit unit " - "that records it. Any negative value, including the default -1, means " - "none is recorded and the household proxy applies. 0 records no " + "benefit unit. Pension Credit counts the claimant's capital, and the " + "State Pension Credit Act 2002 s. 5 treats the partner's as the " + "claimant's, which a benefit-unit capital record covers, so this is " + "the benefit unit's recorded capital (benunit_reported_capital) unless " + "set directly. When this is 0 or more it replaces every capital source " + "in Pension Credit assessable capital: the household proxy (savings, " + "land, property and corporate_wealth) and the person-level sources such " + "as a Lifetime ISA. The model applies neither the Schedule V disregards " + "nor the reg. 19 valuation to it, so it must already be the countable " + "figure, and reforms to the capital source parameters do not reach a " + "benefit unit that records it. Any negative value, including -1 when " + "nothing is recorded, means the household proxy applies. 0 records no " "capital." ) definition_period = YEAR unit = GBP quantity_type = STOCK default_value = -1 - uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" reference = "https://www.legislation.gov.uk/ukpga/2002/16/section/5" + + def formula(benunit, period, parameters): + return benunit("benunit_reported_capital", period) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py index 292789c1d2..a821162b77 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py @@ -6,8 +6,10 @@ class uc_assessable_capital(Variable): entity = BenUnit label = "Universal Credit assessable capital" documentation = ( - "Universal Credit capital counted from the configured capital sources, " - "with benunit-reported overrides when available. PolicyEngine allocates " + "Universal Credit capital counted from the configured capital sources. " + "Where the benefit unit's capital is recorded (uc_reported_capital, by " + "default benunit_reported_capital), that figure replaces them. " + "PolicyEngine allocates " "the remaining household capital between unreported benefit units in " "proportion to their claimant and partner counts, including units that " "do not receive Universal Credit. This allocation is a modelling " diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py index f7bde48ec2..f78adf9589 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py @@ -6,11 +6,26 @@ class uc_reported_capital(Variable): entity = BenUnit label = "reported Universal Credit capital" documentation = ( - "Claimant-level capital for Universal Credit when household-level asset " - "data cannot be attributed across multiple benefit units. Set to 0 to " - "explicitly override the household proxy with zero assessable capital." + "Capital of the claimant and any partner as recorded for this benefit " + "unit. Universal Credit assesses a couple's capital together (Welfare " + "Reform Act 2012 s. 5; UC Regulations 2013 reg. 18(2)), which a " + "benefit-unit capital record covers, so this is the benefit unit's " + "recorded capital (benunit_reported_capital) unless set directly. " + "When it is 0 or more it replaces the household proxy and the " + "person-level sources in uc_assessable_capital, and the model applies " + "no Schedule 10 disregard to it, so it must already be the countable " + "figure. Any negative value, including -1 when nothing is recorded, " + "means the household proxy applies. Set it to 0 to override the " + "household proxy with zero assessable capital." ) definition_period = YEAR unit = GBP quantity_type = STOCK default_value = -1 + reference = ( + "https://www.legislation.gov.uk/ukpga/2012/5/section/5", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/18", + ) + + def formula(benunit, period, parameters): + return benunit("benunit_reported_capital", period) diff --git a/policyengine_uk/variables/household/wealth/benunit_reported_capital.py b/policyengine_uk/variables/household/wealth/benunit_reported_capital.py new file mode 100644 index 0000000000..0a7227e02e --- /dev/null +++ b/policyengine_uk/variables/household/wealth/benunit_reported_capital.py @@ -0,0 +1,22 @@ +from policyengine_uk.model_api import * + + +class benunit_reported_capital(Variable): + value_type = float + entity = BenUnit + label = "reported benefit unit capital" + documentation = ( + "Savings and investments of the benefit unit's adults (the claimant " + "and any partner) as recorded, such as the Family Resources Survey's " + "benefit-unit total (TOTCAPB4). It is an input that records what was " + "observed; each means test derives its own capital from it " + "(uc_reported_capital, pension_credit_reported_capital). Any negative " + "value, including the default -1, means none is recorded, and each " + "means test falls back to its household capital proxy. 0 records no " + "capital." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + default_value = -1 + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" From 328a28162ab5a3280897bed631c810c2c983071d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 8 Oct 2026 23:31:55 -0400 Subject: [PATCH 2/3] Pin the per-period semantics and stored-column uprating; document countable capital Review r1 follow-ups: a programme variable set directly now applies only to the years it is set (later years derive from benunit_reported_capital, which uprates); tests pin that, the input's uprating into later years, and that a dataset storing the input or either programme column projects it like savings. The input's documentation says each programme treats it as countable capital. Changelog notes the behaviour change under Changed. Co-Authored-By: Claude Opus 5.5 --- changelog.d/benunit-reported-capital.added.md | 2 +- .../benunit-reported-capital.changed.md | 1 + .../wealth/benunit_reported_capital.yaml | 18 ++++ ...est_benunit_reported_capital_properties.py | 95 ++++++++++++++++++- .../wealth/benunit_reported_capital.py | 12 ++- 5 files changed, 120 insertions(+), 8 deletions(-) create mode 100644 changelog.d/benunit-reported-capital.changed.md diff --git a/changelog.d/benunit-reported-capital.added.md b/changelog.d/benunit-reported-capital.added.md index 947d6a8b30..39863c3721 100644 --- a/changelog.d/benunit-reported-capital.added.md +++ b/changelog.d/benunit-reported-capital.added.md @@ -1 +1 @@ -- Added `benunit_reported_capital`, the benefit unit's recorded savings and investments (such as the FRS's TOTCAPB4; -1 when nothing is recorded). `uc_reported_capital` and `pension_credit_reported_capital` are now formulas that default to it, so a dataset records the observed capital once and each means test derives its own; either can still be set directly. The input uprates like `savings`, and a dataset that stores either programme variable directly is still uprated the same way. Results are unchanged for data that do not supply the new input. +- Added `benunit_reported_capital`, the benefit unit's recorded savings and investments (such as the FRS's TOTCAPB4; -1 when nothing is recorded). `uc_reported_capital` and `pension_credit_reported_capital` are now formulas that default to it, so a dataset records the observed capital once and each means test derives its own; either can still be set directly. The input uprates like `savings`, and a dataset that stores either programme variable directly is still uprated the same way. Results are unchanged for datasets and situations that do not supply the new input, except as noted under Changed. diff --git a/changelog.d/benunit-reported-capital.changed.md b/changelog.d/benunit-reported-capital.changed.md new file mode 100644 index 0000000000..eff2829615 --- /dev/null +++ b/changelog.d/benunit-reported-capital.changed.md @@ -0,0 +1 @@ +- `uc_reported_capital` and `pension_credit_reported_capital` set directly in a situation now apply only to the years they are set, as for any formula variable; later years derive from `benunit_reported_capital`, which is uprated from the year it is set. Before, a value set for one year carried into later years (uprated, for Pension Credit). Datasets, which store every year, are unaffected. diff --git a/policyengine_uk/tests/policy/baseline/household/wealth/benunit_reported_capital.yaml b/policyengine_uk/tests/policy/baseline/household/wealth/benunit_reported_capital.yaml index af109ef283..af64ec0b3d 100644 --- a/policyengine_uk/tests/policy/baseline/household/wealth/benunit_reported_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/household/wealth/benunit_reported_capital.yaml @@ -42,6 +42,7 @@ output: benunit_reported_capital: -1 uc_reported_capital: -1 + pension_credit_reported_capital: -1 uc_assessable_capital: 50_000 is_uc_eligible: false @@ -99,3 +100,20 @@ savings: 200_000 output: pension_credit_assessable_capital: 12_000 + +- name: With nothing recorded Pension Credit uses the household proxy. + period: 2025 + input: + people: + pensioner: + age: 75 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + savings: 200_000 + output: + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 200_000 diff --git a/policyengine_uk/tests/test_benunit_reported_capital_properties.py b/policyengine_uk/tests/test_benunit_reported_capital_properties.py index 7b6720c98f..f9460d6e41 100644 --- a/policyengine_uk/tests/test_benunit_reported_capital_properties.py +++ b/policyengine_uk/tests/test_benunit_reported_capital_properties.py @@ -20,8 +20,13 @@ capital is never negative. 5. Monotonicity: Universal Credit tariff income never falls, and eligibility and the award never rise, as recorded capital rises. -6. Uprating: the input and both programme variables uprate like `savings`, so a - dataset that stores any of them projects it the same way. +6. Uprating: a dataset that stores the input, or either programme variable + directly as datasets built before the input did, projects it to later years + exactly as it projects `savings`. +7. Periods: the input set for one year is uprated into later years. A + programme variable set directly applies only to the years it is set, as for + any formula variable; later years derive from the input (intended: the + input carries the uprating). """ from pathlib import Path @@ -31,7 +36,10 @@ from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st -from policyengine_uk import Simulation +import pandas as pd + +from policyengine_uk import Microsimulation, Simulation +from policyengine_uk.data import UKSingleYearDataset YEAR = 2025 SETTINGS = settings( @@ -210,3 +218,84 @@ def test_recorded_capital_uprates_with_savings(): assert variables[name].formulas assert variables[name].uprating is None assert variables[name].default_value == -1 + + +def _dataset(benunit_columns, savings): + """Three single adults, one per benefit unit and household, aged 30, 50 + and 75 so that both programmes assess them.""" + ids = np.arange(1, 4) + person = pd.DataFrame( + { + "person_id": ids, + "person_benunit_id": ids, + "person_household_id": ids, + "age": [30, 50, 75], + } + ) + household = pd.DataFrame( + { + "household_id": ids, + "household_weight": 1.0, + "region": "LONDON", + "rent": 0.0, + "tenure_type": "OWNED_OUTRIGHT", + "council_tax": 0.0, + "savings": savings, + } + ) + benunit = pd.DataFrame({"benunit_id": ids, **benunit_columns}) + return UKSingleYearDataset( + person=person, benunit=benunit, household=household, fiscal_year=2025 + ) + + +def test_stored_columns_project_like_savings(): + recorded = [10_000.0, -1.0, 0.0] + savings = [10_000.0, 20_000.0, 30_000.0] + for columns in [ + {"benunit_reported_capital": recorded}, + # As a dataset built before the input existed stores them. + {"uc_reported_capital": recorded, "pension_credit_reported_capital": recorded}, + ]: + sim = Microsimulation(dataset=_dataset(columns, savings)) + growth = ( + sim.calculate("savings", 2027).values + / sim.calculate("savings", 2025).values + ) + assert np.all(growth > 1) + for name in PROGRAMME_VARIABLES: + first = sim.calculate(name, 2025).values + later = sim.calculate(name, 2027).values + np.testing.assert_array_equal(first, recorded) + np.testing.assert_allclose(later, np.array(recorded) * growth, rtol=1e-6) + # A stored -1 stays negative, so "nothing recorded" survives. + assert later[1] < 0 + + +def _single_adult(benunit_inputs): + return Simulation( + situation={ + "people": {"p": {"age": {"2025": 30}}}, + "benunits": {"b": {"members": ["p"], **benunit_inputs}}, + "households": {"h": {"members": ["p"], "savings": {"2025": 50_000}}}, + } + ) + + +def test_input_set_once_is_uprated_into_later_years(): + sim = _single_adult({"benunit_reported_capital": {"2025": 10_000}}) + index = sim.tax_benefit_system.parameters.get_child( + "gov.economic_assumptions.indices.obr.per_capita.gdp" + ) + expected = 10_000 * index("2026-01-01") / index("2025-01-01") + for name in PROGRAMME_VARIABLES: + assert sim.calculate(name, 2026)[0] == np.float32(expected) + assert sim.calculate("uc_assessable_capital", 2026)[0] == np.float32(expected) + + +def test_programme_variable_set_directly_applies_to_its_year_only(): + """Intended: set the input to have a value carried into later years.""" + for name in PROGRAMME_VARIABLES: + sim = _single_adult({name: {"2025": 3_000}}) + assert sim.calculate(name, 2025)[0] == 3_000 + assert sim.calculate(name, 2026)[0] == -1 diff --git a/policyengine_uk/variables/household/wealth/benunit_reported_capital.py b/policyengine_uk/variables/household/wealth/benunit_reported_capital.py index 0a7227e02e..16ade5a62b 100644 --- a/policyengine_uk/variables/household/wealth/benunit_reported_capital.py +++ b/policyengine_uk/variables/household/wealth/benunit_reported_capital.py @@ -10,10 +10,14 @@ class benunit_reported_capital(Variable): "and any partner) as recorded, such as the Family Resources Survey's " "benefit-unit total (TOTCAPB4). It is an input that records what was " "observed; each means test derives its own capital from it " - "(uc_reported_capital, pension_credit_reported_capital). Any negative " - "value, including the default -1, means none is recorded, and each " - "means test falls back to its household capital proxy. 0 records no " - "capital." + "(uc_reported_capital, pension_credit_reported_capital) and treats it " + "as countable capital, with no disregard or valuation rule applied, so " + "a recorded gross total such as TOTCAPB4 is an approximation of it; " + "set a programme's variable directly where its countable figure is " + "known. A value set for one year is uprated into later years. Any " + "negative value, including the default -1, means none is recorded, " + "and each means test falls back to its household capital proxy. 0 " + "records no capital." ) definition_period = YEAR unit = GBP From 34490e0a93ea54bae6952e8412ee7c27385ea2fe Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 8 Oct 2026 23:45:10 -0400 Subject: [PATCH 3/3] Correct the changelog on stored UC columns; tighten the period tests Review r2 follow-ups: a dataset storing uc_reported_capital is now uprated like savings (it was held flat), so the changelog says so instead of 'unaffected'. The later-year test uses a tolerance and checks that UC falls back to the household proxy; the duplicate Pension Credit uprating test is dropped in favour of the end-to-end one. Co-Authored-By: Claude Opus 5.5 --- changelog.d/benunit-reported-capital.added.md | 2 +- .../benunit-reported-capital.changed.md | 2 +- ...est_benunit_reported_capital_properties.py | 14 ++++++++++-- ...sion_credit_reported_capital_properties.py | 22 ------------------- 4 files changed, 14 insertions(+), 26 deletions(-) diff --git a/changelog.d/benunit-reported-capital.added.md b/changelog.d/benunit-reported-capital.added.md index 39863c3721..cded7d97d6 100644 --- a/changelog.d/benunit-reported-capital.added.md +++ b/changelog.d/benunit-reported-capital.added.md @@ -1 +1 @@ -- Added `benunit_reported_capital`, the benefit unit's recorded savings and investments (such as the FRS's TOTCAPB4; -1 when nothing is recorded). `uc_reported_capital` and `pension_credit_reported_capital` are now formulas that default to it, so a dataset records the observed capital once and each means test derives its own; either can still be set directly. The input uprates like `savings`, and a dataset that stores either programme variable directly is still uprated the same way. Results are unchanged for datasets and situations that do not supply the new input, except as noted under Changed. +- Added `benunit_reported_capital`, the benefit unit's recorded savings and investments (such as the FRS's TOTCAPB4; -1 when nothing is recorded). `uc_reported_capital` and `pension_credit_reported_capital` are now formulas that default to it, so a dataset records the observed capital once and each means test derives its own; either can still be set directly. The input uprates like `savings`, as does either programme variable when a dataset stores it directly. Results are unchanged for datasets and situations that do not supply the new input, except as noted under Changed. diff --git a/changelog.d/benunit-reported-capital.changed.md b/changelog.d/benunit-reported-capital.changed.md index eff2829615..e619c18f49 100644 --- a/changelog.d/benunit-reported-capital.changed.md +++ b/changelog.d/benunit-reported-capital.changed.md @@ -1 +1 @@ -- `uc_reported_capital` and `pension_credit_reported_capital` set directly in a situation now apply only to the years they are set, as for any formula variable; later years derive from `benunit_reported_capital`, which is uprated from the year it is set. Before, a value set for one year carried into later years (uprated, for Pension Credit). Datasets, which store every year, are unaffected. +- `uc_reported_capital` and `pension_credit_reported_capital` set directly in a situation now apply only to the years they are set, as for any formula variable; later years derive from `benunit_reported_capital`, which is uprated from the year it is set. Before, a value set for one year carried into later years (uprated, for Pension Credit). A dataset that stores `uc_reported_capital` now has it uprated by per-capita GDP, like `savings`, where it used to be held flat; stored `pension_credit_reported_capital` uprates as before. diff --git a/policyengine_uk/tests/test_benunit_reported_capital_properties.py b/policyengine_uk/tests/test_benunit_reported_capital_properties.py index f9460d6e41..495e4da4bc 100644 --- a/policyengine_uk/tests/test_benunit_reported_capital_properties.py +++ b/policyengine_uk/tests/test_benunit_reported_capital_properties.py @@ -289,8 +289,10 @@ def test_input_set_once_is_uprated_into_later_years(): ) expected = 10_000 * index("2026-01-01") / index("2025-01-01") for name in PROGRAMME_VARIABLES: - assert sim.calculate(name, 2026)[0] == np.float32(expected) - assert sim.calculate("uc_assessable_capital", 2026)[0] == np.float32(expected) + np.testing.assert_allclose(sim.calculate(name, 2026)[0], expected, rtol=1e-6) + np.testing.assert_allclose( + sim.calculate("uc_assessable_capital", 2026)[0], expected, rtol=1e-6 + ) def test_programme_variable_set_directly_applies_to_its_year_only(): @@ -299,3 +301,11 @@ def test_programme_variable_set_directly_applies_to_its_year_only(): sim = _single_adult({name: {"2025": 3_000}}) assert sim.calculate(name, 2025)[0] == 3_000 assert sim.calculate(name, 2026)[0] == -1 + # With nothing recorded for 2026, UC falls back to the household proxy. + sim = _single_adult({"uc_reported_capital": {"2025": 3_000}}) + np.testing.assert_allclose( + sim.calculate("uc_assessable_capital", 2026)[0], + sim.calculate("savings", 2026)[0], + rtol=1e-6, + ) + assert sim.calculate("savings", 2026)[0] > 50_000 diff --git a/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py b/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py index ba0189280c..0c60e1dcaa 100644 --- a/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py +++ b/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py @@ -35,7 +35,6 @@ import math import numpy as np -import yaml from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st @@ -233,24 +232,3 @@ def entitlement(capital): return sim.calculate("pension_credit_entitlement", YEAR)[0] assert np.isclose(entitlement(10_500) - entitlement(10_000), 0.2 * WEEKS_IN_YEAR) - - -def test_reported_capital_uprates_with_savings(): - from pathlib import Path - - from policyengine_uk.system import system - - variables = system.variables - # The variable is a formula of benunit_reported_capital, which carries the - # uprating; a dataset that stores it directly is uprated through - # uprating_indices.yaml. - assert ( - variables["benunit_reported_capital"].uprating == variables["savings"].uprating - ) - assert variables["pension_credit_reported_capital"].default_value == -1 - indices = yaml.safe_load( - (Path(__file__).parents[1] / "data" / "uprating_indices.yaml").read_text() - ) - group = [k for k, v in indices.items() if "savings" in v] - assert len(group) == 1 - assert "pension_credit_reported_capital" in indices[group[0]]