Skip to content

Value means-tested capital less 10% sale expenses and secured debt - #1969

Open
MaxGhenis wants to merge 38 commits into
mainfrom
uc-capital-sale-expenses
Open

MaxGhenis wants to merge 38 commits into
mainfrom
uc-capital-sale-expenses

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #1960.

The model now takes the statutory 10% for sale expenses, and any secured debt, off capital in Universal Credit, Housing Benefit, Income Support, income-based JSA, income-related ESA and Pension Credit. It counts savings and cash ISAs at face value. The helper floors each encumbered source at nil, so negative equity in one source can't reduce another.

Reproducer: policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml::UC values a mortgaged holiday home as in ADM H1602, policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml::UC shares valued less sale expenses bring capital within the limit, policyengine_uk/tests/test_capital_valuation_properties.py::test_valuation_matches_the_regulation_and_never_adds_capital, policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml::A Lifetime ISA value given as an input is counted as given, policyengine_uk/tests/test_capital_valuation_properties.py::test_lifetime_isa_reformed_value_is_kept_as_stored

The law

Programme Great Britain provision Northern Ireland equivalent
Universal Credit UC Regs 2013 reg 49(1) S.R. 2016/216 reg 49(1)
Housing Benefit HB Regs 2006 reg 47; HB (SPC) Regs 2006 reg 45 S.R. 2006/405 reg 44; S.R. 2006/406 reg 43
Income Support IS Regs 1987 reg 49 S.R. 1987/459 reg 49
Income-based JSA JSA Regs 1996 reg 111 S.R. 1996/198 reg 111
Income-related ESA ESA Regs 2008 reg 113 S.R. 2008/280 reg 113
Pension Credit SPC Regs 2002 reg 19 S.R. 2003/28 reg 19

The decision maker takes the 10% from gross value before debt, only where sale incurs expenses, and real property always has sale costs (ADM H1604–H1608). The decision maker then deducts only debt secured on that source (H1615–H1616). Each programme's parameters start from its existing capital-source list and cite the provision in its own instrument, including the later UC and ESA instruments.

What changes

  • Each programme has capital.sale_expenses.rate: 0.1, an explicit list of sources, and its own capital.round_down rule. valued_capital applies the deduction before secured debt, with float64 arithmetic.
  • UC and HB round to the nearest penny, half up (UC reg 6(1); HB reg 80 / SPC HB reg 61(6)). IS, JSA, ESA and PC round down under their respective regulations. Each valuation and final assessment follows that rule; UC holdings and property allocations also follow its penny rule.
  • exact_amount recovers an unambiguous whole penny from float32 storage below £131,072. It preserves other stored amounts. Above that boundary, original pennies can be ambiguous; the result can differ by a penny in either direction. This PR can't recover every digit that float32 storage lost before valuation.
  • The model values Lifetime ISAs with the baseline surrender-value calculation in float64, and rounds each person's value under each programme's rule before summing. A £0.26 balance below the charge-free age gives £0.195: UC/HB count £0.20 and the down-rounding programmes count £0.19. The whole-penny property draws every penny, including this half-penny example.
  • The valuation counts explicit LISA inputs as given. Replacement formulas keep their stored results, and neutralising the variable still works. The model recalculates a Lifetime ISA only where the baseline formula applies and no input supplies the value for that period and branch. Equality with the baseline float32 value cannot establish provenance: an explicit £0.195 also stores as 0.19499999284744263, but nearest-penny rounding of that supplied amount gives £0.19 where the baseline calculation gives £0.20. A regression covers this collision.
  • The float64 LISA recomputation now applies the active variable's defined_for mask through Core's entity mapping. It keeps the stored result outside the mask. Regressions cover mixed masked/counted people across all six means tests and UC eligibility at the £16,000 capital limit. Commit dbc0c587f calculates the Lifetime ISA mask before its high-precision recomputation. It runs the formula only when at least one person applies and keeps Core's stored value outside the mask. The regression makes lifetime_isa_balance raise when a reform masks everyone and checks capital for all six programmes against a run without a Lifetime ISA.
  • The capital property suite now generates cash_isa as well as savings. Its cash-only property checks their sum at face value. Direct source lookup still raises a KeyError for a source the oracle omits; cash ISAs stay outside both the sale-expense and secured-debt mappings.
  • All six lists name Itemise corporate_wealth's share-like components for the means tests #1974's share_like_capital as the sale-expense source, and corporate_wealth stays its backing household input. UC company-holding deductions use HOLDINGS_SOURCE consistently, including pool and reported-capital budgets read through exact_amount. Property oracles map the renamed source to the backing input.
  • Since main's Count each person's ISAs in their own benefit unit's capital #2839, the formulas count each cash and stocks and shares ISA balance the data tie to a person in the holder's own benefit unit (cash_isa_countable_capital, stocks_and_shares_isa_countable_capital) and subtract it from the household source that holds it (cash_isa, share_like_capital) before they allocate the rest. Each of the six formulas passes valued_capital each household source less those balances (pooled_person_capital), so it values the remainder as before: less 10% where sale_expenses.sources lists the source, then less any debt secured on it. valued_person_capital counts the claimant's and partner's balances in their own unit and values each as valued_capital values its household source: a stocks and shares ISA balance less 10%, because all six lists name share_like_capital, and a cash ISA balance at face value. A pound of a stocks and shares ISA therefore counts at 90% whether it stays in the pool or moves to its holder. uc_assessable_capital keeps main's ~covered mask, so a reporting unit's claimant's and partner's ISAs stay in the pool, valued with it, and the formula subtracts the report.
  • UC's existing property route shares the same sale-expense rate. uc_assessable_capital takes a disregarded company holding off at its counted value. In its place, it adds the claimant's or partner's share of the company's capital (reg 77(2)), outside the sale-expense helper. Recorded financial totals and complete reported-capital overrides keep their existing distinct meanings.
  • Three household inputs hold the debt secured on other residential property, non-residential property and land. Retain property in UC capital alongside financial reports #2447 put the first two on main; this PR adds owned_land_secured_debt and deducts all three in every programme that counts the asset. UC counts no land, so the land input changes the five legacy means tests only. Each defaults to zero, and uprating_indices.yaml lists each once.
  • The council tax reduction formula takes Pension Credit's capital on a savings-credit-only award, and UC's capital for a working-age applicant with a UC award outside Scotland's scheme from April 2022, so it inherits their valuation there. Outside those routes and the routes that disregard all capital, since main's Treat income derived from capital as capital in the legacy means tests #1995 the council_tax_reduction_assessable_capital formula takes its own household route, on which it counts savings at face value and land and property other than the home less 10% and less the debt secured on them. This PR adds owned_land_secured_debt to that route's SECURED_DEBT, so the formula deducts the debt secured on land, as councils do under SI 2012/2885 Sch 1 para 32(b) and its equivalents. The route floors a negative source or debt at nil, works in float32 and keeps fractions of a penny, where valued_capital uses a negative savings, cash ISA or share-like source, or a negative debt, as given, works in float64 and rounds each valuation to the penny; both take the 10% before the debt and floor each encumbered source at nil. No test shows a wrong result from those differences, so this PR leaves them.

Which sources take the 10%

Source Deduction Basis / limitation
Savings and cash ISAs None Account balance / withdrawal value; the decision maker doesn't count incidental withdrawal costs as sale costs (ADM H1675, H1607).
Land and other residential/non-residential property 10% Real property has sale costs (H1606); the model takes supplied property values as gross.
share_like_capital 10% Direct shares have sale costs (H1665). The aggregate can also include unit trusts and stocks-and-shares ISAs, whose legal treatment differs (H1674, H1656). We state this limit in the parameter descriptions, and the data need a source split.
Stocks and shares ISA balances counted in their holders' units (stocks_and_shares_isa_countable_capital) 10% The formulas move each balance out of share_like_capital and value it as that source, so the same limitation applies. Under #2131, the formulas value itemised ISAs without the 10%.

Existing tests whose expected values move

  • test_isa_balances.py (main's Model the ISA subscription limits, with per-person ISA subscriptions and balances #2712): UC capital for a household with £1,000 of savings, a £3,000 cash ISA and a £2,000 stocks-and-shares ISA moves from £6,000 × growth to (1,000 + 3,000 + 2,000 × (1 − rate)) × growth, £5,800 × growth. The ISA reaches capital through share_like_capital, which UC values less its sale-expense rate. fc066bd31 made this change.
  • test_stored_household_totals.py::test_stored_household_totals_grow_past_the_projection_horizon (main's Keep stored household ISA totals after 2032 #2781): the same household, stored as household ISA totals. UC capital moves from 6 × savings (£6,000 in 2025) to savings + cash ISA + stocks-and-shares ISA × (1 − rate), 5.8 × savings (£5,800 in 2025), in every year from 2025 to 2040, for the same reason (UC Regulations 2013 reg. 49(1)(a)). The test reads each part and the rate from the simulation, checks that capital stays more than a penny below the three parts at face value, and still checks that capital keeps one ratio to savings in every year.
  • person_isa_capital.yaml (main's Count each person's ISAs in their own benefit unit's capital #2839): seven cases count each stocks and shares ISA balance, and the share_like_capital left in the pool, less 10%. For example, Case 3's adult child holds a £6,000 ISA inside £9,000 of corporate_wealth: the pool's £3,000 counts as £2,700, split £1,800 to the parents and £900 to the child's unit, which adds the ISA at £5,400, so UC capital moves from [2,000, 7,000] to [1,800, 6,300]. Cases 14 and 15 raise the household stocks and shares ISA totals to £17,777.78 and £6,666.67, so that 90% of each, rounded to the nearest penny, still lands on the £16,000 limit and the £6,000 threshold.
  • test_person_isa_capital.py (main's Count each person's ISAs in their own benefit unit's capital #2839): the pound-counts-once checks value share_like_capital, and each stocks and shares ISA moved out of it, at each programme's sale-expense share, and allow each programme's penny rounding. The worked examples move with them: in test_person_balances_without_household_totals_count_with_their_holders, UC capital moves from [11,000, 9,000, 14,000, 11,000] to [10,666.67, 8,833.33, 13,800, 10,300] and Pension Credit's from 19,000 to 18,700. In test_both_forms_in_one_dataset_add_up_to_the_stored_total, UC and Housing Benefit round each unit's capital to the nearest penny, so savings of £1,268.69 in 2032 split between two units as £634.345 each and come to a penny over the household's total; the test allows that penny.
  • pension_credit_income.yaml Case 4 (main's Treat income derived from capital as capital in the legacy means tests #1995): the £55,370 let property counts less 10%, so capital moves from £55,825 to £50,288, deemed income from £92 to £81 a week (£4,212 a year) and guarantee credit from £7,592 to (238 − 81) × 52 = £8,164. The case follows main's Treat income derived from capital as capital in the legacy means tests #1995, under which the formula doesn't count the rent as Pension Credit income (the case cites SPC Regs 2002 Sch IV para 18); this PR's earlier version of the case counted it.
  • test_savings_credit_only_receipt_properties.py (main's Treat income derived from capital as capital in the legacy means tests #1995): main gave the owner with capital £12,000 of savings and £4,400 of property, so that Pension Credit's assessment (£16,400) exceeds the £16,000 limit while council tax reduction's own route (£12,000 + £3,960 = £15,960) doesn't. With this PR, Pension Credit counts the property at £3,960 too, so the test also gives the owner £500 of shares (corporate_wealth), which Pension Credit counts at £450 and council tax reduction's household route leaves out. Pension Credit's capital comes to £16,410, the route's to £15,960, and the savings credit stays £621.82 a year (13 a week of deemed income).

Review responses and integration

Finding Response
v6: LISA half pennies lost on the float32 round-trip Compute the baseline surrender value in float64 and round each person's value under the programme's rule. Widen the whole-penny property beyond multiples of 4p and cover UC boundaries, explicit inputs and equal-float32 provenance collisions.
v6: Incorrect claimant-favourable claim for large float32 values Correct the documentation and test commentary: ambiguous pennies from £131,072 can produce a penny either way after valuation; plain amounts from £262,144 have the same issue.
v6: Float32 cast outside errstate Put the cast inside the overflow/underflow/invalid guard.
v7: Baseline LISA recomputation bypassed defined_for Apply the active mask to the float64 result and keep Core's stored result outside it (696ae34ad). Add mixed-person and UC-limit regressions. The v8 follow-up below also preserves Core's all-masked short circuit.
v7: Property generator omitted main's new cash_isa source Generate cash ISAs, count them at face value, and check savings plus cash ISAs in the cash-only property (b168dc509). Keep direct lookup and existing tolerances.
v8: all-masked LISA recomputation evaluated unused dependencies (P2) dbc0c587f evaluates the mask first and runs the formula only when someone applies. The new raising-dependency regression checks all six programmes.
v9: no finding (APPROVE at dbc0c587f) No change.
CI at dbc0c587f: main's new test_isa_balances.py expected a stocks-and-shares ISA at full value in UC capital fc066bd31 expects UC's sale-expense deduction on it: the ISA reaches capital through share_like_capital, which UC values less 10% until the data separate ISAs from shares. Under #2131, the formulas then value itemised ISAs without the 10%.
v10: no P1 or P2 on this PR; one optional nit in the edited test Optional: read the rate at the instant the formula uses. v10 also records one defect already on main (below) for a separate issue.
Review at b4e872273: the description named c11d7c1d9 and an open #2765 conflict (P2), plus three wording points (P3) Describe b4e872273's merge of #2765 and the runs at that head. Correct the sentence on what exact_amount can't recover, name test_isa_balances.py as the mutated test, and cite ADM H1675 and reg 77(2) in brackets.

Validation

We ran each command below on its own at 3dbdb00276578089c68949b31658239386af9420, on a clean checkout after uv sync --frozen --extra dev, and loaded no survey data. The current head, 59a1c4ad217b3d054bab75dfa7388186ffd89f5b, changes only the changelog fragment from it.

Command Result
uv run --frozen policyengine-core test policyengine_uk/tests/policy/baseline/gov/dwp -c policyengine_uk 997 passed
uv run --frozen policyengine-core test policyengine_uk/tests/policy/baseline/finance/benefit/family -c policyengine_uk 1,761 passed
uv run --frozen policyengine-core test policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction -c policyengine_uk 352 passed
uv run --frozen pytest -n 4 policyengine_uk/tests/test_capital_valuation_properties.py 18 passed (246 s)
uv run --frozen pytest -n 4 policyengine_uk/tests/test_stock_capital_periods.py 2 passed
uv run --frozen pytest -n 4 policyengine_uk/tests/test_uc_property_capital.py 65 passed
uv run --frozen pytest -n 4 policyengine_uk/tests/test_uc_property_capital_mock.py 26 passed
uv run --frozen pytest -n 4 policyengine_uk/tests/test_person_isa_capital.py 32 passed
uv run --frozen pytest -n 4 policyengine_uk/tests/test_savings_credit_only_receipt_properties.py 6 passed
uv run --frozen pytest -n 4 policyengine_uk/tests/test_stored_household_totals.py 42 passed
uv run --frozen pytest -n 4 policyengine_uk/tests/code_health 3,181 passed (203 s)
.github/check_fix_pr.py on this title and body, as pr_fix_evidence.yaml runs it, with the diff from the merge base 95dfc62f8 passed, with 26 changed executable tests
uvx ruff@0.17.2 format --check . 2,229 files already formatted
uvx ruff@0.17.2 check . and uvx ruff@0.17.2 check --select F811,F821 policyengine_uk all checks passed
git diff --check b4e872273..3dbdb0027 clean
git merge-tree --write-tree --name-only 59a1c4ad2 origin/main, with main at 040e9a5f0 no conflict

Without owned_land_secured_debt in council tax reduction's SECURED_DEBT, the land case in council_tax_reduction_capital_valuation.yaml gives £45,000 against £25,000 and fails. CI at 59a1c4ad2 passed every job, including the seven Python shards, the YAML job and Test / Result.

A review of b4e872273c4695cf728d0d2811670b7300504ba4 ran these checks after uv sync --frozen --extra dev, one command at a time, with no survey data.

Command Result
uv run pytest -n 4 policyengine_uk/tests/test_stored_household_totals.py 42 passed (214 s)
uv run pytest -n 4 policyengine_uk/tests/test_isa_balances.py 2 passed
uv run pytest -n 4 policyengine_uk/tests/test_capital_valuation_properties.py 18 passed (328 s)
uv run policyengine-core test policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml -c policyengine_uk 26 cases passed
uv run pytest -n 4 policyengine_uk/tests/code_health 3,110 passed
.github/check_fix_pr.py, as pr_fix_evidence.yaml runs it passed, with 23 changed executable tests
uvx ruff@0.17.2 format --check ., ruff check ., ruff check --select F811,F821 policyengine_uk clean
git diff --check 3a3241cf6..b4e872273 clean

CI at b4e872273 passed every job, including the seven Python shards, the YAML job and Test / Result.

Local runs at c11d7c1d92faeab80bf37a3019463f0b4ecbc103, after uv sync --frozen --extra dev, one command at a time. We loaded no survey data and skipped no test.

Command Result
uv run pytest -n 4 policyengine_uk/tests/test_stored_household_totals.py 42 passed (161 s)
uv run pytest policyengine_uk/tests/test_capital_share_components.py::test_capital_matches_the_original_lists 1 passed (1,837 s)
uv run pytest -n 4 policyengine_uk/tests/test_capital_share_components.py --deselect …::test_capital_matches_the_original_lists 13 passed (226 s)
uv run pytest -n 4 policyengine_uk/tests/test_capital_valuation_properties.py 18 passed (296 s)
uv run pytest -n 4 policyengine_uk/tests/test_isa_balances.py 2 passed
uv run pytest -n 4 policyengine_uk/tests/test_pension_credit_reported_capital_properties.py 4 passed
uv run pytest -n 4 policyengine_uk/tests/test_savings_credit_only_receipt_properties.py 6 passed
uv run pytest -n 4 policyengine_uk/tests/test_stock_capital_periods.py 2 passed
uv run pytest -n 4 policyengine_uk/tests/test_uc_company_override_properties.py 11 passed
uv run pytest -n 4 policyengine_uk/tests/test_uc_company_owner_properties.py 7 passed
uv run pytest -n 4 policyengine_uk/tests/test_uc_property_capital.py 65 passed
uv run pytest -n 4 policyengine_uk/tests/test_uc_property_capital_mock.py 26 passed
uv run pytest -n 4 …/universal_credit/test_uc_capital_allocation.py 1 passed
uv run policyengine-core test <file> -c policyengine_uk for the 12 YAML files this PR adds or changes and every other *capital*.yaml file under the UC, HB, IS, JSA, ESA and PC tests and household/wealth (22 files) 238 cases passed, none failed
uv run pytest -n 4 policyengine_uk/tests/code_health 3,108 passed
uvx ruff@0.17.2 format --check ., ruff check ., ruff check --select F811,F821 policyengine_uk clean
git diff --check 3a3241cf6..HEAD clean

At the merge commit without the test change, test_stored_household_totals_grow_past_the_projection_horizon fails at line 222 with £5,800.00 against £6,000.00 ± 0.06; it passes on main at 0080274d7. When we set UC's sale-expense rate to 0 in a scratch copy, the edited test fails (capital of £6,000.00 doesn't fall below the £6,000.00 face value) and main's version passes; once we restore the rate, the edited test passes.

A review of the merge at 30d48c01b reran the changed tests one file at a time, and every test that finished passed.

Check at 30d48c01b Result
test_capital_valuation_properties.py 18 passed
test_capital_share_components.py 14 passed
test_uc_property_capital.py and test_uc_property_capital_mock.py 91 passed
test_uc_company_owner_properties.py and test_uc_company_override_properties.py 18 passed
test_lifetime_isa_capital.py and lifetime_isa_capital.yaml 3 passed, 24 cases passed
test_uc_tax_free_childcare_choice.py and the two Tax-Free Childcare YAML files 20 passed, 12 cases passed
test_input_records.py 11 passed
test_projection_horizon.py 14 passed
test_fiscal_partition.py and test_aggregate_impact_partition.py 43 and 11 passed
HB accommodation disregard YAML file 50 cases passed

Three files hit local time caps under host load before finishing: test_household_subset.py, test_housing_benefit_accommodation_dates.py and the pair test_isa_accounting.py and test_wealth_components.py. None of those that ran failed, and CI ran them all at 3a3241cf6 and passed.

An earlier review (v10) ran these checks against fc066bd318f2e059623ab30df56985e3605e24d0, one file at a time, and every unmutated test passed.

Check Result
test_isa_balances.py 2 passed
test_wealth_components.py 3 passed
test_capital_valuation_properties.py 18 passed
test_lifetime_isa_capital.py 3 passed
test_household_subset.py (main's #2732) 9 passed
17 YAML files that git grep -l finds naming cash_isa, stocks_and_shares_isa, share_like_capital or corporate_wealth 210 cases passed
lifetime_isa_capital.yaml 24 cases passed
Six-programme ISA probes on small in-memory datasets 165 of 176 checks match; the other 11 fail identically on main (below)
Head against main on the same 176 checks head never exceeds main; main − head equals 10% of share_like_capital to within 2.8p of penny rounding

The probes cover UC, HB, PC, IS, income-based JSA and income-related ESA in 2025, 2027, 2032 and 2033. They use four kinds of data: stored household ISA totals, person balances, person balances with a Lifetime ISA, and corporate_wealth that already holds the ISA. Each programme counts savings and cash ISAs in full, takes 10% off share_like_capital, and counts a Lifetime ISA once, through valued_person_capital. No source counts twice.

The review made four mutations in place on test_isa_balances.py, and restored the file after each:

  • Dropping the stocks-and-shares ISA from share_like_capital failed it at £4,288.96 (£4,000 × growth).
  • Taking the 10% twice failed it at £6,025.99 (£5,620 × growth).
  • Removing share_like_capital from UC's sale-expense sources failed it at £6,433.44 (£6,000 × growth).
  • A nil sale-expense rate passed it, because the test reads the rate, but capital_valuation.yaml failed 8 cases.

cash_isa and stocks_and_shares_isa sum person balances through adds, so after the 2032 projection horizon main loses both on a dataset that stores only the household totals, and this PR leaves that as it stands. In the 2033 probe, main and this PR both count £1,310.81 of UC capital, the savings alone, where the stored totals give £7,602.69. policyengine-uk-data's Enhanced FRS writes neither total, but Microcosm's UK exports store both, so their runs for 2033 and later lose them. #2740 tracks it: #2712 replaced the totals' uprating attribute with adds.

CI at 3a3241cf6 passed: both required checks, all six Python shards and the YAML job.

Impact on the certified data

The aggregate-impact job at 59a1c4ad2 compared main 47a2817e2 with 91591cf69, which merges 59a1c4ad2 into that main, both on enhanced_frs_2024_25.h5 (policyengine-uk-data 1.58.1). The job passed: no check broke, sign:vat fails on both sides, and the head fixes no failing check.

Change 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Universal Credit +£222.8m +£324.3m +£543.0m +£1.26bn +£1.53bn +£1.08bn +£1.09bn
Universal Credit caseload +12.5k +35.0k +51.0k +91.4k +96.8k +73.8k +69.3k
Housing Benefit +£0.9m +£0.3m +£0.4m +£2.8m +£19.9m +£42.6m +£16.1m
Pension Credit +£1.0m +£1.0m +£1.1m +£1.0m +£0.9m +£0.8m +£0.7m
ESA (income-related) +£0.4m +£0.4m +£0.4m +£2.6m +£0.4m +£0.4m +£0.5m
JSA (income-based) +£13,640 0 0 0 0 0 0
Income Support +£43,929 0 0 0 0 0 0
Council Tax Reduction +£0.8m +£0.8m +£0.3m +£0.3m +£1.1m −£29,889 −£31,042
Scottish Child Payment +£0.3m 0 0 0 +£6.0m +£11.0m +£24.1m
Tax-Free Childcare −£0.6m −£1.4m −£0.7m 0 −£10.2m −£11.2m −£1.7m
Targeted childcare entitlement 0 0 0 0 0 0 +£7.0m
Household net income † +£225.7m +£325.7m +£544.8m +£1.27bn +£1.55bn +£1.13bn +£1.14bn
Government balance † −£225.7m −£325.7m −£545.2m −£1.27bn −£1.55bn −£1.13bn −£1.14bn
Absolute poverty AHC, children −0.01pp −0.17pp −0.21pp −0.27pp −0.28pp −0.09pp −0.35pp

† The report rebuilds these totals without the Disabled Students' Allowance figure, which it withholds.

The model now assesses less capital, so it pays the same or more of each of the six benefits in every year. The report shows 0.0% of people losing over £1 in every year, and every base-run decile gains or holds. The moves the other way follow from higher awards:

  • Council Tax Reduction counts the UC award as income in the default-scheme formula (council_tax_reduction/_legacy.py:50-53), so a larger award lowers it.
  • Tax-Free Childcare excludes any family with universal_credit_pre_benefit_cap above nil (tax_free_childcare_program_eligible.py; disqualifying_benefits.yaml), unless the family gives up a smaller award through forgoes_universal_credit_for_tax_free_childcare.
  • The TV licence fee falls by £0.1m to £0.3m a year: a household with someone aged 75 or over on Pension Credit gets a free licence (tv_licence_free_licence_share.py).
  • VAT on private school fees moves both ways, from −£1.6m to +£2.0m, because attendance ranks households by net income including benefits (attends_private_school.py).
  • Relative poverty rises in a few cells, such as +0.14pp for children before housing costs in 2030-31, because each run sets its line at 60% of its own median. Absolute poverty falls or holds in every cell.
  • The report's own-decile means move both ways as households change deciles.

The targeted childcare entitlement moves only in 2031-32: a UC award above nil gives a family one route to it (meets_universal_credit_criteria_for_targeted_childcare_entitlement.py).

share_like_capital can also hold unit trusts and stocks-and-shares ISAs, so these aggregates take 10% off some sources whose legal treatment differs.

Coordination

axiom: TheAxiomFoundation/rulespec-uk#395 queued

🤖 Generated with Claude Code

UC Regs 2013 reg. 49(1) and its legacy equivalents (HB Regs 2006 reg. 47,
HB (SPC) Regs 2006 reg. 45, IS Regs 1987 reg. 49, JSA Regs 1996 reg. 111,
ESA Regs 2008 reg. 113, SPC Regs 2002 reg. 19) calculate capital at market
or surrender value less 10% where a sale would incur expenses and less any
encumbrance secured on it. The model summed every capital source at full
value.

Each programme gets a capital.sale_expenses node (rate 0.1 and the sources
whose sale incurs expenses: land, property and corporate_wealth, not cash
savings). A shared helper applies the 10% before the encumbrance and never
values an encumbered asset below nil. New household inputs carry debt
secured on other residential property, non-residential property and land;
the Enhanced FRS has none, so they default to zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 2 commits September 30, 2026 22:07
- Each programme's sale-expense parameters now cite its GB and NI
  regulations, the guidance paragraph that orders the 10% before the
  encumbrance, and per-asset guidance with exact PDF page anchors (DMG ch 52
  for ESA, HBGM Annex F for HB savings, H1656/H1665/H1673-H1675 for UC).
- The source-list descriptions and changelog say plainly that
  corporate_wealth also holds unit trusts, ISAs, employee share options and,
  in datasets built before the pension split, non-DB pension wealth.
- The helper and secured-debt docs say debts net within a source category.
- The property-test oracle fixes the sale-expense assets and the 10% itself
  instead of reading the parameters, checks pension-age HB, and runs on
  larger populations. New hand-computed cases cover HB second homes and
  premises, per-source flooring between two encumbered sources, pension-age
  HB, and a monthly secured debt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HBGM W1.430 states only the general 10% rule; the rule that real property
always has costs of sale comes from R(IS) 21/93, as DMG 29622 applies it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Coordination note from #1950 (UC capital income). If this PR lands after #1950, please update the "reg 49 is #1960" notes #1950 adds:

  • in uc_income_from_capital.yaml, the header and both let-property cases (GB and NI);
  • in uc_unearned_income.yaml, the savings-0 let-property case.

Those cases enter £10,000 of property as capital. At 90% that is 12 tariff steps, not 16, so either update the expected values or restate the input as a property value of £11,111.11. If #1950 lands second, I'll do it on rebase. #1950's merge is waiting on a decision from Max (over the £1bn/yr line).

🤖 Generated with Claude Code

@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

#1950 merged (770bc06), so this PR is second. Please update the "reg 49 is #1960" notes:

  • in uc_income_from_capital.yaml, the header and the GB and NI let-property cases;
  • in uc_unearned_income.yaml, the savings-0 let-property case.

At 90% valuation, £10,000 of property is 12 tariff steps, not 16, so either update the expected values or enter £11,111.11.

🤖 Generated with Claude Code

Max Ghenis and others added 2 commits October 3, 2026 17:20
Resolve the capital variables onto main's claimant/partner allocation and
person-level sources, valuing household sources through valued_capital.
Update the let-property tests #1950 added: £10,000 of property is now £9,000
of capital under reg. 49(1), so 12 tariff steps, not 16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two tests main added after this branch's earlier base assumed full-value
capital:

- The CTR savings-credit-only case holds savings 10,000 and a 7,000 second
  property: capital is 10,000 + 90% of 7,000 = 16,300 (SPC Regs 2002 reg.
  19(a)), still over the 16,000 limit, so CTR stays nil. Deemed income is 13
  steps x 52 = 676, Pension Credit income 13,314 + 676 = 13,990, and savings
  credit 60% of (12,376 - 208.07 x 52) less 40% of (13,990 - 12,376) =
  288.22.
- The reported-capital property test's proxy oracle now values land,
  property and shares at 90% and savings at face value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 4, 2026
The six capital sources lists (UC, HB, IS, JSA, ESA, PC) name
directly_held_shares, unit_and_investment_trusts and stocks_and_shares_isa
in place of corporate_wealth, plus a new formula variable,
unitemised_corporate_wealth = max(0, corporate_wealth - components), for
whatever a dataset does not itemise. Datasets without the components keep
counting corporate_wealth in full; datasets that build corporate_wealth as
the components' exact sum count the same total, now itemised, so each
component can carry its own valuation rule (the sale-expense deduction stacked
on #1969 exempts unit trusts and ISAs). No dataset's capital changes: a
differential property test checks that every programme's assessable capital
equals the same holdings entered as one corporate_wealth.

Review nits: the growth-factor sentence now matches uprating_indices.yaml
exactly; the variable docs cite the DMG for the legacy benefits, the expert
valuation of unquoted shares and the quoted-share rule for investment trusts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Stacked follow-up: #2131 lists only directly_held_shares and unitemised_corporate_wealth as share-like sources with sale expenses, so unit and investment trusts (ADM H1673-H1674) and stocks and shares ISAs (ADM H1656) count at full value. It builds on this branch's head 22274ca and includes #1974, which itemises corporate_wealth in the six capital lists. Datasets that don't itemise keep this PR's flat treatment exactly.

🤖 Generated with Claude Code

@vahid-ahmadi vahid-ahmadi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Max. Reviewed at head 22274cad.

The law is right in every programme. I re-fetched each provision from legislation.gov.uk:

  • UC Regs 2013 reg 49(1): "Capital is to be calculated at its current market value or surrender value less— (a) where there would be expenses attributable to sale, 10%; and (b) the amount of any encumbrances secured on it."

  • The same rule appears, in older wording ("10 per cent", "encumbrance/incumbrance"), in:

    • SPC Regs 2002 reg 19;
    • HB Regs 2006 reg 47 and HB (SPC) Regs 2006 reg 45;
    • IS Regs 1987 reg 49;
    • JSA Regs 1996 reg 111;
    • ESA Regs 2008 reg 113.

    The regulation numbers are right (JSA 111 and ESA 113).

  • CTR Prescribed Requirements SI 2012/2885 Sch 1 para 32 has the same 10% rule for pension-age CTR.

  • SPC Sch V has no disregard that touches share, unit trust or ISA valuation. Para 9 is business assets only.

The helper follows the guidance. valued_capital takes the 10% off gross value before the debt (ADM H1608) and floors each encumbered source on its own (H1615-H1616).

Probes all match hand arithmetic:

  • savings £5,000 + corporate_wealth £12,000 gives £15,800 for UC, HB and IS, and £15,800 for PC at 70 (main: £17,000);
  • a £100,000 second home with £95,000 debt plus £3,000 savings gives £3,000;
  • a second home of £20,000 with £25,000 debt plus premises of £10,000 gives £9,000.

Tests:

  • At the head: properties, PC reported capital and stock periods, 9 passed. DWP, family and CTR YAML, 967 passed.
  • Merged into current main b5b09231 (the merge is clean; the branch is 280 commits behind): the same YAML set, 1,274 passed. But two of the new property tests fail; see finding 1. The green CI is from the stale merge ref.

Findings:

  1. Blocking (rebase). On main + this branch, test_valuation_matches_the_regulation_and_never_adds_capital and test_cash_only_households_have_no_deduction fail (2 failed, 8 passed). The valuation isn't the problem; the test's oracle is out of date.

    • Main now sets HB capital to nil for anyone on UC, IS, income-based JSA or income-related ESA (HB Regs Sch 6 para 5, housing_benefit_on_passporting_benefit). It also switches the pension-age rules on housing_benefit_pension_age_regulations_apply rather than age 67.
    • So a working-age single person with £2,441.62 of cash now has HB capital of nil (on main too), while applies() at tests/test_capital_valuation_properties.py:127 still expects their savings.
    • I reproduced the cash-only failure directly. The other failing test uses the same applies() predicate, so it is most likely the same cause.
    • Fix: rebase and key the pension-age branch on housing_benefit_pension_age_regulations_apply. Then either skip units where housing_benefit_on_passporting_benefit is true, or give the generated units enough earnings to stay off UC. Skipping alone would hollow out the working-age HB branch, because the strategy's no-income singles are nearly all on UC.
  2. Blocking (merge order with #1974). Git merges this branch and #1974 cleanly, but together they undo this PR:

    • #1974 takes corporate_wealth out of every capital.sources list, while this PR's sale_expenses.sources still names it. So the 10% on shares silently stops.
    • On a 1969 + 1974 tree, the probe above goes back to £17,000. Seven YAML cases fail: 3 in capital_valuation.yaml, and 2 each in the IS and ESA capital files.
    • #2131 fixes this. So either merge #1974 first and then this PR and #2131 together, or merge this PR and then #2131 retargeted to main (it carries #1974).
    • A cheap guard in tests/test_capital_valuation_properties.py: assert set(sale_expenses.sources) <= set(capital.sources) for each programme. The existing check at line 160 intersects both sides with counted, so it can't see a stale name.
  3. Should fix (body, disclosure). In "A few records carry each figure", the UC, PC and HB bullets, and the poverty bullet, attribute most of each year's change to one or a handful of records, with percentages and counts. Read against the impact table, that is effectively a record-level weighted amount. Keep the caveat ("driven by a handful of records; not an estimate") and drop the percentages, record counts and the median gain.

  4. Nit. parameters/gov/dwp/income_support/means_test/capital/sale_expenses/rate.yaml:9. This starts on 2010-01-01, to match sources. IS reg 49 has applied the 10% since 11.4.1988. Add a sentence saying the date follows the sources list, as the HB and PC files already explain their own dates.

  5. Nit. utils/capital_valuation.py:18-19. Netting debt across assets within a category is documented. H1616 actually allows it where one debt is secured on several assets. You could cite that, so the approximation reads as partly lawful rather than purely a data limitation.

  6. Coordination with #1965. Merged with #1965 there is no textual conflict, but the holding comes off at 100% while it is counted at 90%: savings £5,000 + a £10,000 holding gives £4,000 instead of £5,000. I've put the fix on #1965, since it rebases second.

Max Ghenis and others added 4 commits October 11, 2026 20:55
Keep main's recorded benefit-unit capital (benunit_reported_capital), which
replaces the valued household sources as recorded, and register the
secured-debt inputs beside main's uprating entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
£20,000 of shares is £18,000 of Pension Credit capital (SPC Regs 2002
reg. 19(a)), still over the CTR limit: deemed income 16 x 52 = 832,
Pension Credit income 14,146, savings credit 933.816 - 0.4 x 1,770 =
225.82.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's Housing Benefit passport (#1927 and the Universal Credit limb) gives a
family on a passporting benefit nil HB capital, so the property's
independent reg 47 valuation applies only to non-passported families.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

We've moved this back to draft. Since #2781 landed on main today, this PR fails main's test_stored_household_totals.py::test_stored_household_totals_grow_past_the_projection_horizon. That test expects the household's capital to equal six times its savings. On this branch the model values the household's £2,000 stocks and shares ISA less 10% for sale expenses, so the capital comes to £5,800 against £6,000. Git merges the two PRs cleanly, so GitHub shows no conflict, and this PR's last CI run predates #2781. We're updating the test to the new valuation and merging main in, and we'll mark the PR ready again once CI passes at the new head.

#2765 conflicts with this PR in policyengine_uk/utils/supplied_inputs.py, so whichever lands second needs a resolution. We'll post one here.

@MaxGhenis
MaxGhenis marked this pull request as draft November 2, 2026 20:26
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Picking this up for the UK hub. Reply here if you're mid-change and I'll stop.


Generated by Claude Code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6Kxyq1frfihPSQorLM
…ls test

The household's stocks-and-shares ISA reaches Universal Credit capital
through share_like_capital, which UC values less its sale-expense rate
(UC Regulations 2013 reg. 49(1)(a)). Build the expected capital from
savings, the cash ISA and the ISA less that rate, read from the
parameters, and keep the check that capital keeps one ratio to savings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6Kxyq1frfihPSQorLM
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

PR_TITLE.txt

Value means-tested capital less 10% sale expenses and secured debt (UC reg 49 and legacy equivalents)

Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

PR_BODY.md

Fixes #1960.

Apply the statutory 10% sale-expense deduction and secured debt when valuing capital for Universal Credit, Housing Benefit, Income Support, income-based JSA, income-related ESA and Pension Credit. Savings and cash ISAs remain at face value. The helper floors each encumbered source at nil so negative equity in one source cannot reduce another.

Reproducer: `policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml::UC values a mortgaged holiday home as in ADM H1602`, `policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml::UC shares valued less sale expenses bring capital within the limit`, `policyengine_uk/tests/test_capital_valuation_properties.py::test_valuation_matches_the_regulation_and_never_adds_capital`, `policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml::A Lifetime ISA value given as an input is counted as given`, `policyengine_uk/tests/test_capital_valuation_properties.py::test_lifetime_isa_reformed_value_is_kept_as_stored`

## The law

| Programme | Great Britain provision | Northern Ireland equivalent |
|---|---|---|
| Universal Credit | [UC Regs 2013 reg 49(1)](https://www.legislation.gov.uk/uksi/2013/376/regulation/49) | S.R. 2016/216 reg 49(1) |
| Housing Benefit | [HB Regs 2006 reg 47](https://www.legislation.gov.uk/uksi/2006/213/regulation/47); [HB (SPC) Regs 2006 reg 45](https://www.legislation.gov.uk/uksi/2006/214/regulation/45) | S.R. 2006/405 reg 44; S.R. 2006/406 reg 43 |
| Income Support | [IS Regs 1987 reg 49](https://www.legislation.gov.uk/uksi/1987/1967/regulation/49) | S.R. 1987/459 reg 49 |
| Income-based JSA | [JSA Regs 1996 reg 111](https://www.legislation.gov.uk/uksi/1996/207/regulation/111) | S.R. 1996/198 reg 111 |
| Income-related ESA | [ESA Regs 2008 reg 113](https://www.legislation.gov.uk/uksi/2008/794/regulation/113) | S.R. 2008/280 reg 113 |
| Pension Credit | [SPC Regs 2002 reg 19](https://www.legislation.gov.uk/uksi/2002/1792/regulation/19) | S.R. 2003/28 reg 19 |

The decision maker takes the 10% from gross value before debt, only where sale incurs expenses, and real property always has sale costs (ADM H1604–H1608). The decision maker then deducts only debt secured on that source (H1615–H1616). Parameters start with each programme's existing capital-source list. Each programme uses the provision in its own instrument, including the later UC and ESA instruments.

## What changes

- Each programme has `capital.sale_expenses.rate: 0.1`, an explicit list of sources, and its own `capital.round_down` rule. `valued_capital` applies the deduction before secured debt, with float64 arithmetic.
- UC and HB round to the nearest penny, half up (UC reg 6(1); HB reg 80 / SPC HB reg 61(6)). IS, JSA, ESA and PC round down under their respective regulations. Each valuation and final assessment follows that rule; UC holdings and property allocations also follow its penny rule.
- `exact_amount` recovers an unambiguous whole penny from float32 storage below £131,072. It preserves other stored amounts. Above that boundary, original pennies can be ambiguous; the result can differ by a penny in either direction. This PR cannot recover arbitrary precision lost before valuation.
- Lifetime ISAs use the baseline surrender-value calculation in float64, rounded **per person under each programme's rule** before summing. A £0.26 balance below the charge-free age gives £0.195: UC/HB count £0.20 and the down-rounding programmes count £0.19. The whole-penny property draws every penny, including this half-penny example.
- The valuation counts explicit LISA inputs as given. Replacement formulas retain their stored results, and neutralization remains effective. Recalculation requires the active baseline formula and the absence of a supplied input for that period and branch. Equality with the baseline float32 value cannot establish provenance: an explicit £0.195 also stores as 0.19499999284744263, but nearest-penny rounding of that supplied amount gives £0.19 where the baseline calculation gives £0.20. A regression covers this collision.
- The float64 LISA recomputation now applies the active variable's `defined_for` mask through Core's entity mapping. It keeps the stored result outside the mask. Regressions cover mixed masked/counted people across all six means tests and UC eligibility at the £16,000 capital limit. Commit `dbc0c587f` calculates the Lifetime ISA mask before its high-precision recomputation. It runs the formula only when at least one person applies and keeps Core's stored value outside the mask. The regression makes `lifetime_isa_balance` raise when a reform masks everyone and checks capital for all six programmes against a run without a Lifetime ISA.
- The capital property suite now generates `cash_isa` as well as `savings`. Its cash-only property checks their sum at face value. Direct source lookup still exposes a source omitted by the oracle as a `KeyError`; cash ISAs stay outside both the sale-expense and secured-debt mappings.
- All six lists name #1974's `share_like_capital` as the sale-expense source, and `corporate_wealth` stays its backing household input. UC company-holding deductions use `HOLDINGS_SOURCE` consistently, including pool and reported-capital budgets read through `exact_amount`. Property oracles map the renamed source to the backing input.
- UC's existing property route shares the same sale-expense rate. Regulation 77 removes a disregarded holding at its counted value, while company capital substituted under reg 77(2) stays outside the sale-expense helper. Recorded financial totals and complete reported-capital overrides keep their existing distinct meanings.
- Three household inputs hold the debt secured on other residential property, non-residential property and land. #2447 put the first two on main; this PR adds `owned_land_secured_debt` and deducts all three in every programme that counts the asset. UC counts no land, so the land input changes the five legacy means tests only. Each defaults to zero, and `uprating_indices.yaml` lists each once.
- CTR schemes using UC capital inherit its valuation. National schemes testing only savings retain that scope.

### Which sources take the 10%

| Source | Deduction | Basis / limitation |
|---|---|---|
| Savings and cash ISAs | None | Account balance / withdrawal value; incidental withdrawal costs are not sale costs (ADM H1675, H1607). |
| Land and other residential/non-residential property | 10% | Real property has sale costs (H1606); supplied property values are gross. |
| `share_like_capital` | 10% | Direct shares have sale costs (H1665). The aggregate can also include unit trusts and stocks-and-shares ISAs, whose legal treatment differs (H1674, H1656). The parameter descriptions state this limit, and the data need a source split. |

### Existing tests whose expected values move

- `test_isa_balances.py` (main's #2712): UC capital for a household with £1,000 of savings, a £3,000 cash ISA and a £2,000 stocks-and-shares ISA moves from £6,000 × growth to (1,000 + 3,000 + 2,000 × (1 − rate)) × growth, £5,800 × growth. The ISA reaches capital through `share_like_capital`, which UC values less its sale-expense rate. `fc066bd31` made this change.
- `test_stored_household_totals.py::test_stored_household_totals_grow_past_the_projection_horizon` (main's #2781): the same household, stored as household ISA totals. UC capital moves from 6 × savings (£6,000 in 2025) to savings + cash ISA + stocks-and-shares ISA × (1 − rate), 5.8 × savings (£5,800 in 2025), in every year from 2025 to 2040, for the same reason (UC Regulations 2013 reg. 49(1)(a)). The test reads each part and the rate from the simulation, checks that capital stays strictly below the three parts at face value, and still checks that capital keeps one ratio to savings in every year.

## Review responses and integration

| Finding | Response |
|---|---|
| v6: LISA half pennies lost on the float32 round-trip | Compute the baseline surrender value in float64 and round each person's value under the programme's rule. Widen the whole-penny property beyond multiples of 4p and cover UC boundaries, explicit inputs and equal-float32 provenance collisions. |
| v6: Incorrect claimant-favourable claim for large float32 values | Correct the documentation and test commentary: ambiguous pennies from £131,072 can produce a penny either way after valuation; plain amounts from £262,144 have the same issue. |
| v6: Float32 cast outside `errstate` | Put the cast inside the overflow/underflow/invalid guard. |
| v7: Baseline LISA recomputation bypassed `defined_for` | Apply the active mask to the float64 result and keep Core's stored result outside it (`696ae34ad`). Add mixed-person and UC-limit regressions. The v8 follow-up below also preserves Core's all-masked short circuit. |
| v7: Property generator omitted main's new `cash_isa` source | Generate cash ISAs, count them at face value, and check savings plus cash ISAs in the cash-only property (`b168dc509`). Keep direct lookup and existing tolerances. |
| v8: all-masked LISA recomputation evaluated unused dependencies (P2) | `dbc0c587f` evaluates the mask first and runs the formula only when someone applies. The new raising-dependency regression checks all six programmes. |
| v9: no finding (APPROVE at `dbc0c587f`) | No change. |
| CI at `dbc0c587f`: main's new `test_isa_balances.py` expected a stocks-and-shares ISA at full value in UC capital | `fc066bd31` expects UC's sale-expense deduction on it: the ISA reaches capital through `share_like_capital`, which UC values less 10% until the data separate ISAs from shares. #2131 then values itemised ISAs without the 10%. |
| v10: no P1 or P2 on this PR; one optional nit in the edited test | Optional: read the rate at the instant the formula uses. v10 also records one defect already on main (below) for a separate issue. |

- `646743859` merges main at `41fa5ddb5`. The supplied-input registry retains both this PR's LISA entry and main's `monthly_domestic_energy_consumption` entry.
- `d5c2a57e8` ports #2688's company-capital conservation oracle correction from `1193614a6`. The file matches that source revision byte for byte and keeps main's claimant/partner roles. The oracle reflects stored-input precision, holdings-budget scaling, separately rounded valuations and company capital, and the sum of rounded benefit units. The pinned example yields £25.28 + £25.28 = £50.56. The port changes no production code or tolerance. All seven company-owner properties passed in v7's CI.
- `5efb62cef` merges main at `2d437ebba`. Of 3,660 paths, only `policyengine_uk/data/uprating_indices.yaml` changed on both sides since `41fa5ddb`, and the merge commit's combined diff shows no hand-resolved hunk in it. The merged file keeps main's new `cash_isa_balance` and `stocks_and_shares_isa_balance`, drops main's duplicate `cash_isa` and `stocks_and_shares_isa` entries, and keeps all three secured-debt inputs. It lists each of its 124 variables once, under 14 distinct indices. The merge keeps every one-sided change: 59 files from this branch and 123 from main.
- `fc066bd31` changes only `test_isa_balances.py`. Main's #2712 (issue #2710) added the test; the edit makes it expect UC capital of (1,000 + 3,000 + 2,000 × (1 − rate)) × growth, which is £5,800 × 1.07224 = £6,218.99 in 2027. It reads the rate from the parameters. The test still checks first that the stored household totals grow with savings.
- Main's other changes since `41fa5ddb` leave the valuation alone. #2712 changes how `cash_isa` and `stocks_and_shares_isa` read their values but not which sources the means tests count, and its subscription limits move no balance. #2717 changes growth rates, not sources or deductions. #2732's household subsets slice every stored array and `_model_values` to the kept households, and its tests, which cover the ancestor walk that `supplied_input` uses, pass here. #2092, #2229 and #2716 touch no capital code.
- `30d48c01b` merges main at `2da212abc`. Only `uprating_indices.yaml` conflicted. The merged file keeps main's move of `other_investment_income`, `savings_interest_income` and `non_cash_isa_cash_interest` to `per_capita.savings_income` and main's new company-car and `income_not_otherwise_charged` entries. It adds `owned_land_secured_debt` under `per_capita.gdp`, next to `owned_land`, and lists each of its 128 variables once, under 15 indices. Against main's file, the only added line is `owned_land_secured_debt`.
- Main's #2754 lets a family give up a UC award worth less than its Tax-Free Childcare top-up. A London lone parent earning £44,000, with £10,000 of childcare and £17,000 of `corporate_wealth`, had £588.70 of UC and no top-up on this PR's old base, £1,411.30 worse off. At the merge the family counts £15,300 of capital, gives up the award and takes the £2,000 top-up. In eight cases, UC, Tax-Free Childcare and Scottish Child Payment together never fall below main at the merge.
- `3a3241cf6` corrects the `owned_land_secured_debt` documentation, which named UC. It changes no calculation.
- The review covered `30d48c01b54532603c0fad233a30935adab9280c`, and that documentation commit follows it.
- `ba4de95d6` merges main at `0080274d78e2b48e57682ac6a035fda6e11334e9`, 86 commits after `2da212abc`, #2781 among them. Git merged it with no conflict. Only `policyengine_uk/data/uprating_indices.yaml` changed on both sides since `2da212abc`; against main's file, the merged file adds only `owned_land_secured_debt`.
- `c11d7c1d9` changes only `test_stored_household_totals.py`, which main's #2781 added: it now expects the stocks-and-shares ISA in UC capital less the sale-expense rate (see "Existing tests whose expected values move"). Main's other test changes since `2da212abc` read no means-tested capital variable.
- #2131 (head `6a0d26d61`) contains this PR's earlier head `3a3241cf6` and waits on this PR. Git merges #2131 with this head without conflict, but on that merge `test_stored_household_totals_grow_past_the_projection_horizon` fails with £6,000 against the expected £5,800, because #2131 values itemised stocks-and-shares ISAs at their withdrawal value without the 10%. When #2131 takes this head, it needs to expect savings + cash ISA + stocks-and-shares ISA at face value (6 × savings) and drop the check that capital stays below face value, as it already does in `test_isa_balances.py`.
- #2765 (head `edda47ea2`) conflicts with this PR in `policyengine_uk/utils/supplied_inputs.py`. Both PRs add a name at the same place in `SUPPLIED_INPUT_VARIABLES`: this PR adds `lifetime_isa_countable_capital` and #2765 adds `miscellaneous_income_trading_allowance_deduction`. The set is a `frozenset`, so the resolution keeps both lines, in either order. #2765's other two entries (`self_employment_income`, `trading_allowance_deduction`) merge without conflict.

## Validation

Local runs at `c11d7c1d92faeab80bf37a3019463f0b4ecbc103`, after `uv sync --frozen --extra dev`, one command at a time. No survey data were loaded and no test was skipped.

| Command | Result |
| --- | --- |
| `uv run pytest -n 4 policyengine_uk/tests/test_stored_household_totals.py` | 42 passed (161 s) |
| `uv run pytest policyengine_uk/tests/test_capital_share_components.py::test_capital_matches_the_original_lists` | 1 passed (1,837 s) |
| `uv run pytest -n 4 policyengine_uk/tests/test_capital_share_components.py --deselect …::test_capital_matches_the_original_lists` | 13 passed (226 s) |
| `uv run pytest -n 4 policyengine_uk/tests/test_capital_valuation_properties.py` | 18 passed (296 s) |
| `uv run pytest -n 4 policyengine_uk/tests/test_isa_balances.py` | 2 passed |
| `uv run pytest -n 4 policyengine_uk/tests/test_pension_credit_reported_capital_properties.py` | 4 passed |
| `uv run pytest -n 4 policyengine_uk/tests/test_savings_credit_only_receipt_properties.py` | 6 passed |
| `uv run pytest -n 4 policyengine_uk/tests/test_stock_capital_periods.py` | 2 passed |
| `uv run pytest -n 4 policyengine_uk/tests/test_uc_company_override_properties.py` | 11 passed |
| `uv run pytest -n 4 policyengine_uk/tests/test_uc_company_owner_properties.py` | 7 passed |
| `uv run pytest -n 4 policyengine_uk/tests/test_uc_property_capital.py` | 65 passed |
| `uv run pytest -n 4 policyengine_uk/tests/test_uc_property_capital_mock.py` | 26 passed |
| `uv run pytest -n 4 …/universal_credit/test_uc_capital_allocation.py` | 1 passed |
| `uv run policyengine-core test <file> -c policyengine_uk` for the 12 YAML files this PR adds or changes and every other `*capital*.yaml` file under the UC, HB, IS, JSA, ESA and PC tests and `household/wealth` (22 files) | 238 cases passed, none failed |
| `uv run pytest -n 4 policyengine_uk/tests/code_health` | 3,108 passed |
| `uvx ruff@0.17.2 format --check .`, `ruff check .`, `ruff check --select F811,F821 policyengine_uk` | clean |
| `git diff --check 3a3241cf6..HEAD` | clean |

At the merge commit without the test change, `test_stored_household_totals_grow_past_the_projection_horizon` fails at line 222 with £5,800.00 against £6,000.00 ± 0.06; it passes on main at `0080274d7`. With UC's sale-expense rate set to 0 in a scratch copy, the edited test fails (capital £6,000.00 is not below the £6,000.00 face value) and main's version passes; with the rate restored, the edited test passes.

An independent Opus 5.5 review of the merge and a separate run of the changed tests covered `30d48c01b`, one file at a time. Every test that finished passed.

| Check at `30d48c01b` | Result |
| --- | --- |
| `test_capital_valuation_properties.py` | 18 passed |
| `test_capital_share_components.py` | 14 passed |
| `test_uc_property_capital.py` and `test_uc_property_capital_mock.py` | 91 passed |
| `test_uc_company_owner_properties.py` and `test_uc_company_override_properties.py` | 18 passed |
| `test_lifetime_isa_capital.py` and `lifetime_isa_capital.yaml` | 3 passed, 24 cases passed |
| `test_uc_tax_free_childcare_choice.py` and the two Tax-Free Childcare YAML files | 20 passed, 12 cases passed |
| `test_input_records.py` | 11 passed |
| `test_projection_horizon.py` | 14 passed |
| `test_fiscal_partition.py` and `test_aggregate_impact_partition.py` | 43 and 11 passed |
| HB accommodation disregard YAML file | 50 cases passed |

Three files hit local time caps under host load before finishing: `test_household_subset.py`, `test_housing_benefit_accommodation_dates.py` and the pair `test_isa_accounting.py` and `test_wealth_components.py`. None of those that ran failed, and CI ran them all at `3a3241cf6` and passed.

An earlier independent review (v10, Opus 5.5) ran these checks against `fc066bd318f2e059623ab30df56985e3605e24d0`, one file at a time. Every unmutated test passed.

| Check | Result |
| --- | --- |
| `test_isa_balances.py` | 2 passed |
| `test_wealth_components.py` | 3 passed |
| `test_capital_valuation_properties.py` | 18 passed |
| `test_lifetime_isa_capital.py` | 3 passed |
| `test_household_subset.py` (main's #2732) | 9 passed |
| 17 YAML files that `git grep -l` finds naming `cash_isa`, `stocks_and_shares_isa`, `share_like_capital` or `corporate_wealth` | 210 cases passed |
| `lifetime_isa_capital.yaml` | 24 cases passed |
| Six-programme ISA probes on small in-memory datasets | 165 of 176 checks match; the other 11 fail identically on main (below) |
| Head against main on the same 176 checks | head never exceeds main; main − head equals 10% of `share_like_capital` to within 2.8p of penny rounding |

The probes cover UC, HB, PC, IS, income-based JSA and income-related ESA in 2025, 2027, 2032 and 2033. They use four kinds of data: stored household ISA totals, person balances, person balances with a Lifetime ISA, and `corporate_wealth` that already holds the ISA. Each programme counts savings and cash ISAs in full, takes 10% off `share_like_capital`, and counts a Lifetime ISA once, through `valued_person_capital`. No source counts twice.

The review ran four scratch mutations on the edited test, made each in place, and restored it byte for byte, checking SHA-256 before and after:

- Dropping the stocks-and-shares ISA from `share_like_capital` failed it at £4,288.96 (£4,000 × growth).
- Taking the 10% twice failed it at £6,025.99 (£5,620 × growth).
- Removing `share_like_capital` from UC's sale-expense sources failed it at £6,433.44 (£6,000 × growth).
- A nil sale-expense rate passed it, because the test reads the rate, but `capital_valuation.yaml` failed 8 cases.

`cash_isa` and `stocks_and_shares_isa` sum person balances through `adds`, so after the 2032 projection horizon main loses both on a dataset that stores only the household totals, and this PR leaves that as it stands. In the 2033 probe, main and this PR both count £1,310.81 of UC capital, the savings alone, where the stored totals give £7,602.69. policyengine-uk-data's Enhanced FRS writes neither total, but Microcosm's UK exports store both, so their runs for 2033 and later lose them. [#2740](https://github.com/PolicyEngine/policyengine-uk/issues/2740) tracks it: #2712 replaced the totals' `uprating` attribute with `adds`.

The review ran model commands one at a time under a 4 GB and 1,500-second cap, ran mutations under 2 GB, and loaded no survey data.

[CI at `3a3241cf6`](https://github.com/PolicyEngine/policyengine-uk/actions/runs/41203464649) passed: both required checks, all six Python shards and the YAML job.

## Impact on the certified data

The [aggregate-impact comment](https://github.com/PolicyEngine/policyengine-uk/pull/1969#issuecomment-6277352471), updated at 2026-10-31T14:10:13Z, compares main `2da212abc` with `2eb144a64`, which merges `3a3241cf6` into that main. The [workflow run](https://github.com/PolicyEngine/policyengine-uk/actions/runs/41203464563) reports `3a3241cf6` as its head, and both sides use `enhanced_frs_2024_25.h5` (policyengine-uk-data 1.58.1). The job passed: no check broke, `sign:vat` fails on both sides, and none got fixed.

| Change | 2025-26 | 2026-27 | 2027-28 | 2028-29 | 2029-30 | 2030-31 | 2031-32 |
|---|---:|---:|---:|---:|---:|---:|---:|
| Universal Credit | +£222.8m | +£324.3m | +£543.0m | +£1.26bn | +£1.53bn | +£1.08bn | +£1.09bn |
| Universal Credit caseload | +12.5k | +35.0k | +51.0k | +91.4k | +96.8k | +73.8k | +69.3k |
| Housing Benefit | +£0.9m | +£0.3m | +£0.4m | +£2.8m | +£19.9m | +£42.6m | +£16.1m |
| Pension Credit | +£1.0m | +£1.0m | +£1.1m | +£1.0m | +£0.9m | +£0.8m | +£0.7m |
| ESA (income-related) | +£0.4m | +£0.4m | +£0.4m | +£2.6m | +£0.4m | +£0.4m | +£0.5m |
| JSA (income-based) | +£13,640 | 0 | 0 | 0 | 0 | 0 | 0 |
| Income Support | +£43,929 | 0 | 0 | 0 | 0 | 0 | 0 |
| Council Tax Reduction | +£0.8m | +£0.8m | +£0.3m | +£0.3m | +£1.0m | −£55,441 | −£51,533 |
| Scottish Child Payment | +£0.3m | 0 | 0 | 0 | +£6.0m | +£11.0m | +£24.1m |
| Tax-Free Childcare | −£0.6m | −£1.4m | −£0.7m | 0 | −£10.2m | −£11.2m | −£1.7m |
| Targeted childcare entitlement | 0 | 0 | 0 | 0 | 0 | 0 | +£7.0m |
| Household net income † | +£225.7m | +£325.6m | +£544.7m | +£1.27bn | +£1.55bn | +£1.13bn | +£1.14bn |
| Government balance † | −£225.7m | −£325.6m | −£545.2m | −£1.27bn | −£1.55bn | −£1.13bn | −£1.14bn |
| Absolute poverty AHC, children | −0.01pp | −0.17pp | −0.21pp | −0.27pp | −0.28pp | −0.09pp | −0.35pp |

† The report rebuilds these totals without the suppressed Disabled Students' Allowance figure.

The model now assesses less capital, so it pays the same or more of each of the six benefits in every year. The report shows 0.0% of people losing over £1 in every year, and every base-run decile gains or holds. The moves the other way follow from higher awards:

- Council Tax Reduction counts the UC award as income in the default-scheme formula (`council_tax_reduction/_legacy.py:50-53`), so a larger award lowers it.
- Tax-Free Childcare excludes any family with `universal_credit_pre_benefit_cap` above nil (`tax_free_childcare_program_eligible.py`; `disqualifying_benefits.yaml`), unless the family gives up a smaller award through `forgoes_universal_credit_for_tax_free_childcare`.
- The TV licence fee falls by £0.1m to £0.3m a year: a household with someone aged 75 or over on Pension Credit gets a free licence (`tv_licence_free_licence_share.py`).
- VAT on private school fees moves both ways, from −£1.6m to +£2.0m, because attendance ranks households by net income including benefits (`attends_private_school.py`).
- Relative poverty rises in a few cells, such as +0.14pp for children before housing costs in 2030-31, because each run sets its line at 60% of its own median. Absolute poverty falls or holds in every cell.
- The report's own-decile means move both ways as households change deciles.

The targeted childcare entitlement moves only in 2031-32: a UC award above nil gives a family one route to it (`meets_universal_credit_criteria_for_targeted_childcare_entitlement.py`).

`share_like_capital` can also hold unit trusts and stocks-and-shares ISAs, so these aggregates take 10% off some sources whose legal treatment differs.

## Coordination

- This PR incorporates #1974's source rename. Keep `share_like_capital` in programme lists and retain `corporate_wealth` only as the backing input or an explanation of it.
- #2688 targets main and lands after this PR. This PR ports only #2688's company-capital rounding oracle correction (`1193614a6`).
- #1950's capital-income cases and #1965's company-substitution rules remain integrated with valued capital.
- Later PRs should split direct shares, funds, ISAs and pension wealth in the data; export secured property debt; extend national CTR capital scope; review omitted UC land; and add independent rulespec capital valuation rather than feeding model capital into the oracle.

axiom: TheAxiomFoundation/rulespec-uk#395 queued

- rulespec-uk's pinned signed corpus holds none of these valuation provisions yet. The queued issue carries the legal text, ingestion path and hand-computed companion cases, and the encoder can't run on them until someone ingests them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)



Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Ready for hub review

Head c11d7c1d92faeab80bf37a3019463f0b4ecbc103 on branch claude/capital-sale-expenses-test-rd9p8x (the harness pins pushes to claude/...). It starts from 3a3241cf6. I did not push to uc-capital-sale-expenses, opened no PR, and CI has not run at this head. Before starting I checked the PR: no push since 3a3241cf6 (31 Oct), and the latest comment was the hub's own draft notice, so I posted the pick-up note.

Commits:

  • ba4de95d651524434334da98292ec76673336345: merges main at 0080274d78e2b48e57682ac6a035fda6e11334e9. No conflict. Only policyengine_uk/data/uprating_indices.yaml changed on both sides since 2da212abc. Git merged it on its own, and against main's file the merged file adds only owned_land_secured_debt.
  • c11d7c1d9: changes only policyengine_uk/tests/test_stored_household_totals.py (+19 −3).

Findings

1. Cause of the #2781 failure: confirmed. The model is right and the test was out of date.

  • uc_assessable_capital (policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py) values the household pool through valued_capital (policyengine_uk/utils/capital_valuation.py:32-53). For each source in capital.sources, that function takes less_sale_expenses(value, capital.sale_expenses.rate, …) when the source is in capital.sale_expenses.sources (:46-48).
  • UC's capital.sources lists savings, cash_isa and share_like_capital. sale_expenses.sources lists only share_like_capital. The rate is gov.dwp.universal_credit.means_test.capital.sale_expenses.rate (0.1 from 2013-04-29, parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml).
  • share_like_capital takes the itemised sum (directly held shares, unit and investment trusts, stocks_and_shares_isa) when it exceeds corporate_wealth (variables/household/wealth/share_like_capital.py). The test household has no corporate_wealth, so its £2,000 stocks-and-shares ISA total is the whole of share_like_capital and loses £200. Savings (£1,000) and the cash ISA (£3,000) count in full. The household Lifetime ISA total does not reach capital, because the means tests read the person lifetime_isa_balance, which this dataset does not store. Total: £5,800.
  • I found no fault in the model and edited none.

2. Reproduced. At ba4de95d6, the node fails at line 222: assert np.float64(5800.0) == 6000.0 ± 0.06. On main 0080274d7 it passes (1 passed, 6.3 s).

3. Test fix (test_stored_household_totals.py:219-238). The test now reads cash_isa and stocks_and_shares_isa for each year, and reads UC's sale-expense rate at period(year).start, the instant the formula's parameters(period) reads. It expects savings + cash ISA + stocks-and-shares ISA × (1 − rate), with no bare 5.8. It also asserts that capital stays below those three parts at face value by more than a penny, and keeps the check that capital holds one ratio to savings in every year. The comment now names the parts, says the shares ISA counts less its sale expenses, and cites "Universal Credit Regulations 2013 reg. 49(1)(a)", the form this PR's rate.yaml uses.

  • Judgement call: the brief asked for both "build the expected value from the rate the model reads" and "with the rate at 0 your new assertion must fail". An expected value built from the rate also follows a nil rate, which v10 noted for test_isa_balances.py. The below-face-value assertion is what makes a nil rate fail. Drop it if the hub prefers the rate-only form.
  • Other tests in the file that read capital, run alone with -n 4 at c11d7c1d9: test_stored_household_totals_grow_past_the_projection_horizon passed; test_stored_totals_project_exactly_like_what_they_are_projected_with[current_law] and [no_growth] (reads share_like_capital) passed; test_a_stored_zero_total_takes_precedence_over_same_year_balances (CAPITAL_YEARS) passed; test_reforms_that_declare_a_total_and_its_part_again_change_nothing[replace] and [update] (UC capital over CAPITAL_YEARS) passed; test_stored_totals_and_person_balances_agree_in_every_year (all seven means-test capitals over CAPITAL_YEARS) passed. That makes 7 passed, from the pre-format commit; the whole file then passed at the final head (below).

4. Other tests #1969 might move. git diff --name-only 2da212abc 0080274d7 -- policyengine_uk/tests lists 19 paths. A grep finds that only test_stored_household_totals.py reads uc_assessable_capital, share_like_capital or any other means-tested capital variable. The others mention Pension Credit, Housing Benefit or savings only through parameters, income tax or non-capital variables (test_process_time_reforms.py, test_triple_lock_statutory_inputs.py, test_dwp_published_rates.py, test_statutory_paternity_pay_income_tax_properties.py, test_other_investment_income_household_income_properties.py, test_aggregate_impact_withheld_reasons.py, test_uc_work_related_groups_properties.py, oct_2026_budget/pension_tax_allowance.yaml), so I did not run them. The programmes have no dedicated capital/ test directories, so I ran every *capital*.yaml under the UC, HB, IS, JSA, ESA, PC, CTR-PC and household/wealth tests, together with the 12 YAML files #1969 changes: 22 files, 238 cases, all passed (counts per file below). No other failure, and no other fix.

5. Mutation check. In a scratch worktree I set sale_expenses/rate.yaml to 2013-04-29: 0. The new test failed: assert np.float64(6000.0) < (np.float64(6000.0) - 0.01), year 2025. Main's version of the test passed: 1 passed. With the rate restored, the new test passed. Then I removed the worktree.

6. #2765 (juaristi22, head edda47ea2a0d99ca2c0e727b32120ef417c9e3e6; not touched):

git merge-tree --write-tree c11d7c1d9 edda47ea2 (tree 49d9e435f) reports one conflict, in policyengine_uk/utils/supplied_inputs.py, SUPPLIED_INPUT_VARIABLES:

        "local_bus_uncapped_single_fare",
<<<<<<< c11d7c1d9 (#1969)
        "lifetime_isa_countable_capital",
=======
        "miscellaneous_income_trading_allowance_deduction",
>>>>>>> edda47ea2 (#2765)
        "monthly_domestic_energy_consumption",

Resolution that keeps both (the set is a frozenset, so order does not matter):

        "local_bus_uncapped_single_fare",
        "lifetime_isa_countable_capital",
        "miscellaneous_income_trading_allowance_deduction",
        "monthly_domestic_energy_consumption",

#2765's other additions to the same set (self_employment_income, trading_allowance_deduction) and to .github/workflows/test-suite.yaml merge without conflict. With the resolution above, the file parses, and against #2765's version it adds only lifetime_isa_countable_capital; against this head it adds only #2765's three names. I committed no resolution.

7. #2131: a follow-on the hub should know about. #2131 (head 6a0d26d618e018d673e23adaaf777cbd2a498d35) contains 3a3241cf6, and git merge-tree of it against this head is clean (tree f6ec327e1). I built that merge locally from the pre-format head; the format fix only re-wraps two lines of the test. On that merge, test_stored_household_totals_grow_past_the_projection_horizon fails the other way: 6000.0 == 5800.0 ± 0.058. #2131 values itemised stocks-and-shares ISAs at withdrawal value without the 10%, and it already changes test_isa_balances.py in the same way. When #2131 takes this head, it needs to expect the three parts at face value (6 × savings) and drop the below-face-value assertion. The body's merge notes say this. I did not touch #2131.

Tests at c11d7c1d9

All with uv sync --frozen --extra dev. No survey data and no skips.

Command Result
pytest -n 4 policyengine_uk/tests/test_stored_household_totals.py 42 passed, 161 s
pytest …/test_capital_share_components.py::test_capital_matches_the_original_lists (alone, 45-min cap) 1 passed, 1,837 s (wall 1,846 s), alongside the YAML and other Python runs on 4 cores
pytest -n 4 …/test_capital_share_components.py without that node 13 passed, 226 s
test_capital_valuation_properties.py 18 passed, 296 s
test_isa_balances.py 2 passed
test_pension_credit_reported_capital_properties.py 4 passed, 234 s
test_savings_credit_only_receipt_properties.py 6 passed, 195 s
test_stock_capital_periods.py 2 passed
test_uc_company_override_properties.py 11 passed
test_uc_company_owner_properties.py 7 passed, 225 s
test_uc_property_capital.py 65 passed, 263 s
test_uc_property_capital_mock.py 26 passed
policy/…/universal_credit/test_uc_capital_allocation.py 1 passed
YAML (each file alone): esa_income_capital 10, esa legacy_capital 3, housing_benefit_capital 13, housing_benefit_residential_capital 7, HB legacy_capital 2, income_support_capital 10, IS legacy_capital 3, jsa_income_capital 9, JSA legacy_capital 3, uc_company_analogous_business 26, uc_income_from_capital 7, is_UC_eligible 17, uc_assessable_capital 6, capital_valuation 26, cash_isa_capital 8, lifetime_isa_capital 24, pension_credit_deemed_income 7, pension_credit_income 10, pension_credit_reported_capital 9, uc_property_capital 9, council_tax_reduction_pension_credit 23, benunit_reported_capital 6 238 passed, 0 failed
pytest -n 4 policyengine_uk/tests/code_health 3,108 passed
.github/check_fix_pr.py on the final title and body, with the change list from merge-base 0080274d7 to the head, as pr_fix_evidence.yaml builds it PASS: "Fix PR has valid reproducer evidence. Changed executable tests: 23."
uvx ruff@0.17.2 format --check . / check . / check --select F811,F821 policyengine_uk clean
git diff --check 3a3241cf6..HEAD clean

Incident, and what I reran

A scratch command whose cd into a worktree failed fell through to a git checkout --detach in the main checkout. That left the working tree on the scratch merge of this head with #2131. During that window, one YAML run (housing_benefit_residential_capital.yaml: 2 failed) and an earlier start of the long node ran against the wrong tree, and the formatting amend briefly produced a local commit called tmp on that tree. That commit was never pushed. I restored the branch at d5efa536, reapplied the format fix as c11d7c1d9, and reran every reported result on the correct tree: all 22 YAML files, the long node, the Python files, code_health, the stored-totals file, ruff and diff --check. The table above holds only those reruns. housing_benefit_residential_capital.yaml passes 7 of 7 at the correct head. The mutation check ran in its own worktree before the incident.

Not done, and judgement calls

  • I left the title as it is: 102 characters, over the 70 the guidance names. The brief says to keep each document's first lines.
  • I left the aggregate impact table unchanged and added no changelog fragment.
  • I did not move uc-capital-sale-expenses. The hub moves it after reviewing.
  • The Existing tests whose expected values move subsection is new, so I also put test_isa_balances.py (from fc066bd31) in it, to keep the list complete.
  • I posted both hub documents above this comment.

Generated by Claude Code

@MaxGhenis MaxGhenis changed the title Value means-tested capital less 10% sale expenses and secured debt (UC reg 49 and legacy equivalents) Value means-tested capital less 10% sale expenses and secured debt Nov 2, 2026
Keeps both names where #2765 and this PR each added one to SUPPLIED_INPUT_VARIABLES in policyengine_uk/utils/supplied_inputs.py: lifetime_isa_countable_capital (this PR) and miscellaneous_income_trading_allowance_deduction (#2765).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Ready for hub review

Verdict: APPROVE WITH NITS at b4e872273c4695cf728d0d2811670b7300504ba4

Scope: git show --remerge-diff ba4de95d6, git diff ba4de95d6..c11d7c1d9, and the hub's merge git show --remerge-diff b4e872273 (main at 52b30809f). I made no edits and pushed nothing. I ran everything below in a detached scratch worktree at the head, with uv sync --frozen --extra dev, and with HUGGING_FACE_TOKEN and POLICYENGINE_UK_DEFAULT_DATASET unset. Before starting, I checked the PR: the last comment is the author's hand-off at 21:20:30Z on 2 Nov, and nobody posted a review after 21:44Z.

Findings

No P1.

P2. The body describes c11d7c1d9 and an unresolved #2765 conflict, but the head is b4e872273

PR body, "Review responses and integration", last bullet; "Validation", first line. The bullet says "#2765 (head edda47ea2) conflicts with this PR in policyengine_uk/utils/supplied_inputs.py…". #2765 merged into main at 01:20:51Z on 3 Nov (be73a74d4), and b4e872273 has already resolved the conflict. A reader landing either PR would look for a conflict that no longer exists. No bullet mentions b4e872273, and the "Local runs at" heading names c11d7c1d9 only.

Evidence:

  • git show --remerge-diff b4e872273 shows one hunk, in supplied_inputs.py. It keeps both lifetime_isa_countable_capital and miscellaneous_income_trading_allowance_deduction and drops the markers. Nothing else.
  • git diff 52b30809 b4e872273 -- policyengine_uk/utils/supplied_inputs.py adds only + "lifetime_isa_countable_capital",. git diff c11d7c1d9 b4e872273 -- …supplied_inputs.py adds only Add a trading allowance claim status, so allowance reforms reach claimants without turnover data #2765's three names (self_employment_income, miscellaneous_income_trading_allowance_deduction, trading_allowance_deduction).

Replace the #2765 bullet with:

In the #2131 bullet, the sentence "Git merges #2131 with this head without conflict" still holds at b4e872273 (see below), so it can stay. Label the existing table "Local runs at c11d7c1d92faeab80bf37a3019463f0b4ecbc103", and add a table for b4e872273 from the hub's own runs or from CI once it finishes. The runs I made are listed under "Checked and sound".

P3. The voice pass makes the exact_amount bullet contradict itself

PR body, "What changes", third bullet. The author wrote "This PR cannot recover arbitrary precision lost before valuation." The hub's version reads "This PR can't recover precision that float32 storage lost before valuation." The sentence before it says exact_amount does recover a whole penny from float32 storage below £131,072. This is the only one of the hub's edits I found that changes a fact (diffed by hand against PR_BODY.author.md). Replace with:

This PR can't recover every digit that float32 storage lost before valuation.

P3. "The edited test" in the v10 mutation list now has two possible meanings

PR body, "Validation", the paragraph "The review made four mutations in place on the edited test…". That list comes from v10 at fc066bd31, so "the edited test" means test_isa_balances.py. The £4,288.96 = £4,000 × 1.07224 figure is for 2027. This PR now has a second edited test that a nil rate does kill. A reader could take "A nil sale-expense rate passed it" to describe test_stored_household_totals.py. I reran the nil rate at the head: test_isa_balances.py and the pre-delta stored-totals assertion pass (3 passed), and the new stored-totals test fails. Replace the paragraph's opening with:

The review made four mutations in place on test_isa_balances.py, and restored the file after each:

P3. Two of the hub's rewrites make a document act

PR body, "Which sources take the 10%" table and "What changes". Under the writing rules, a document or regulation doesn't act; cite it in brackets.

  • "the ADM doesn't treat incidental withdrawal costs as sale costs (H1675, H1607)" → "the decision maker doesn't count incidental withdrawal costs as sale costs (ADM H1675, H1607)."
  • "while the company capital that reg 77(2) substitutes stays outside the sale-expense helper" → "while the company capital that the formula substitutes for it (reg 77(2)) stays outside the sale-expense helper."

Checked and sound

Merge ba4de95d6. git show --remerge-diff ba4de95d6 prints only the commit header, so its remerge diff is empty. git diff 0080274d7 ba4de95d6 -- policyengine_uk/data/uprating_indices.yaml adds the single line +- owned_land_secured_debt under per_capita.gdp, after owned_land. List entries: 143 on main, 144 merged. sort | uniq -d over the entries is empty, so each variable appears once. The diff is the same at 52b30809 → b4e872273.

Merge b4e872273. Its parents are c11d7c1d9 and 52b30809. The remerge diff has only the supplied_inputs.py hunk (above). All four names are in the set at the head, and SUPPLIED_INPUT_VARIABLES imports (45 names). From 0080274d7 to 52b30809, main has 4 first-parent commits and 35 changed files. No added line names uc_assessable_capital, share_like_capital, a *_capital variable, capital.sources, sale_expenses, cash_isa or stocks_and_shares_isa.

Cause (hand-off finding 1): confirmed from the code.

  • uc_assessable_capital.formula reads parameters(period).gov.dwp.universal_credit.means_test.capital and calls valued_capital(lambda v: household(v, period), p).
  • valued_capital (policyengine_uk/utils/capital_valuation.py:32-53) applies less_sale_expenses(value, capital.sale_expenses.rate, capital.round_down) to each source in capital.sale_expenses.sources (:46-49).
  • UC's capital/sources.yaml lists savings, cash_isa and share_like_capital. sale_expenses/sources.yaml lists only share_like_capital. sale_expenses/rate.yaml is 0.1 from 2013-04-29 and cites "The Universal Credit Regulations 2013 reg. 49(1)(a)".
  • share_like_capital returns the itemised sum (directly_held_shares, unit_and_investment_trusts, stocks_and_shares_isa) when it exceeds corporate_wealth by more than 1e-6 relative.

That leaves £1,000 + £3,000 + £2,000 × 0.9 = £5,800.

Reproduction.

  • At ba4de95d6, the node fails: assert np.float64(5800.0) == 6000.0 ± 0.06 at assert capital[year][0] == pytest.approx(6 * savings[year][0], rel=1e-5).
  • At 0080274d7, it gives 1 passed in 6.86s.

Both runs used the head's virtualenv, with PYTHONPATH set to each worktree. I confirmed that policyengine_uk.__file__ resolved to that worktree.

The new test (test_stored_household_totals.py:219-238). The test reads the rate at period(year).start. The formula reads it at the same instant, because parameters(period) on a YEAR period resolves at the period's start. period is imported at :41. Each assertion can fail. I ran each mutant in a scratch worktree and restored it (git status --short empty afterwards):

Mutant Result
sale_expenses/rate.yaml 2013-04-29: 0 killed: assert np.float64(6000.0) < (np.float64(6000.0) - 0.01), year 2025
drop "stocks_and_shares_isa" from share_like_capital's itemised list killed: assert np.float64(4000.0) == 5800.0 ± 0.058, year 2025
UC sale_expenses/sources.yaml set to [] killed: assert np.float64(6000.0) == 5800.0 ± 0.058, year 2025
nil rate, with test_isa_balances.py and the ba4de95d6 version of the test 3 passed (survives, as the body says)

The ratio assertion holds only by construction in DATA_YEAR, and it can fail only if the parts grow at different rates. That is its purpose, so I mutated nothing for it.

#2131 and #2765.

Body and title.

  • Title: "Value means-tested capital less 10% sale expenses and secured debt", 66 characters.
  • I read the hub's body against PR_BODY.author.md line by line. Apart from the exact_amount sentence (P3), the edits change wording, not facts. One edit swaps "strictly below" for "more than a penny below", which matches the code: capital < face_value - 0.01. The voice pass also drops three things: the review's memory and time caps, the SHA-256 detail, and the closing rulespec-uk bullet. None of these is a claim about the code.
  • I checked the new sections against what I reran: the £5,800 and £6,000 figures, the failure line, the Value share-like capital by asset type for the sale-expense deduction #2131 failure, the Add a trading allowance claim status, so allowance reforms reach claimants without turnover data #2765 hunk and the three names, uprating_indices.yaml, and the test's assertions.
  • "Main's other test changes since 2da212abc read no means-tested capital variable" holds. Of the 19 test paths main changed from 2da212abc to 0080274d7, only test_stored_household_totals.py names one.
  • Every backticked name in the live body exists at the head. That covers files, variables, parameters and the test node. Every SHA resolves, including 1193614a6 and 2eb144a64 through the REST API.

Local runs at b4e872273.

Command Result
uv run pytest -n 4 policyengine_uk/tests/test_stored_household_totals.py 42 passed (214 s)
uv run pytest -n 4 policyengine_uk/tests/test_isa_balances.py 2 passed (19 s)
uv run pytest -n 4 policyengine_uk/tests/test_capital_valuation_properties.py 18 passed (328 s)
uv run policyengine-core test policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml -c policyengine_uk 26 passed
uv run pytest -n 4 policyengine_uk/tests/code_health 3,110 passed (287 s)
.github/check_fix_pr.py on the live title, body and labels, with changes from merge-base(52b30809, b4e872273) to the head, as pr_fix_evidence.yaml builds them Fix evidence: PASS — "Fix PR has valid reproducer evidence. Changed executable tests: 23."
uvx ruff@0.17.2 format --check . / check . / check --select F811,F821 policyengine_uk 2,177 files already formatted / All checks passed / All checks passed
git diff --check 3a3241cf6..b4e872273 clean

CI at b4e872273 (commits/b4e872273…/check-runs, read at 07:23Z on 3 Nov):
Every finished job passed. That covers all seven Python shards (the last finished at 07:23:12Z), Test / Result, Test / YAML, Lint, Changes, Changelog fragments, Fix evidence, Documentation build, Release build and the four install smoke-imports. Nothing failed or was cancelled. Pending: Aggregate impact (job 125760423375), in progress since 06:16:00Z. The body's impact table still describes 3a3241cf6, and the hub replaces it from that job's comment.

At c11d7c1d9, Fix evidence shows one cancelled run (125569655066, 21:41:34Z to 21:41:35Z). A second run started 6 s later (125569671160) and passed, so the cancelled one was superseded and was not a failure. Every other job there passed, including Test / Result.

What I couldn't check

  • I did not rerun test_capital_share_components.py::test_capital_matches_the_original_lists (about 30 min), the rest of that file, the other Python files in the body's table, or the 22 YAML capital files other than capital_valuation.yaml at b4e872273. CI's shards cover them; all seven shards and the YAML job passed at the head.
  • I did not rerun anything that rests only on the hub's machine: v10's ISA probes, the Keep Tax-Free Childcare over a smaller UC award, and take no deductions from awards only a reform creates #2754 Tax-Free Childcare cases, or the aggregate-impact table, which the hub replaces from the CI comment.
  • I didn't diff the two bodies with a tool, because the author's body exists only in the brief, so I compared them by reading. I counted the edits but did not reconcile them against the hub's figure of 21.

Generated by Claude Code

@MaxGhenis
MaxGhenis marked this pull request as ready for review November 3, 2026 10:43
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Picking this up for the UK hub. Reply here if you're mid-change and I'll stop.


Generated by Claude Code

Resolve the six assessable-capital formulas so each values every household
source through valued_capital after subtracting the ISA balances main's
person-capital pool moves to their holders, and values each moved stocks
and shares ISA balance as its household source. Rework main's ISA and
savings-credit cases by hand for the 10% sale-expense deduction, and
deduct the debt secured on land in council tax reduction's own household
capital.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6K8DguS6nS1jwFZ8Zi
The pound-counts-once and worked-example checks in test_person_isa_capital.py
now value share_like_capital, and each stocks and shares ISA balance moved
out of it, less the programme's sale-expense rate, and allow for each unit's
penny rounding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6K8DguS6nS1jwFZ8Zi
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Picking this up for the UK hub. Reply here if you're mid-change and I'll stop.


Generated by Claude Code

Git merged main without conflict. Since af93611, main's #2744, #2760,
#2763, #2768, #2769, #2786, #2787, #2788, #2794, #2809 and #2810 change no
file this branch changes, no capital source list and no assessable-capital
formula. #2788's legacy tariff tests set assessable capital directly, so the
sale-expense valuation leaves them unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H3DAwvyhPgY7sU1r3AiycF
uprating_indices.yaml grows owned_land_secured_debt with per capita GDP, but
the growth factors page left it out of that list. The page's list now
matches the index file's per capita GDP entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H3DAwvyhPgY7sU1r3AiycF
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

PR_BODY_1969.md

Fixes #1960.

The model now takes the statutory 10% for sale expenses, and any secured debt, off capital in Universal Credit, Housing Benefit, Income Support, income-based JSA, income-related ESA and Pension Credit. It counts savings and cash ISAs at face value. The helper floors each encumbered source at nil, so negative equity in one source can't reduce another.

Reproducer: `policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml::UC values a mortgaged holiday home as in ADM H1602`, `policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml::UC shares valued less sale expenses bring capital within the limit`, `policyengine_uk/tests/test_capital_valuation_properties.py::test_valuation_matches_the_regulation_and_never_adds_capital`, `policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml::A Lifetime ISA value given as an input is counted as given`, `policyengine_uk/tests/test_capital_valuation_properties.py::test_lifetime_isa_reformed_value_is_kept_as_stored`

## The law

| Programme | Great Britain provision | Northern Ireland equivalent |
|---|---|---|
| Universal Credit | [UC Regs 2013 reg 49(1)](https://www.legislation.gov.uk/uksi/2013/376/regulation/49) | S.R. 2016/216 reg 49(1) |
| Housing Benefit | [HB Regs 2006 reg 47](https://www.legislation.gov.uk/uksi/2006/213/regulation/47); [HB (SPC) Regs 2006 reg 45](https://www.legislation.gov.uk/uksi/2006/214/regulation/45) | S.R. 2006/405 reg 44; S.R. 2006/406 reg 43 |
| Income Support | [IS Regs 1987 reg 49](https://www.legislation.gov.uk/uksi/1987/1967/regulation/49) | S.R. 1987/459 reg 49 |
| Income-based JSA | [JSA Regs 1996 reg 111](https://www.legislation.gov.uk/uksi/1996/207/regulation/111) | S.R. 1996/198 reg 111 |
| Income-related ESA | [ESA Regs 2008 reg 113](https://www.legislation.gov.uk/uksi/2008/794/regulation/113) | S.R. 2008/280 reg 113 |
| Pension Credit | [SPC Regs 2002 reg 19](https://www.legislation.gov.uk/uksi/2002/1792/regulation/19) | S.R. 2003/28 reg 19 |

The decision maker takes the 10% from gross value before debt, only where sale incurs expenses, and real property always has sale costs (ADM H1604–H1608). The decision maker then deducts only debt secured on that source (H1615–H1616). Each programme's parameters start from its existing capital-source list and cite the provision in its own instrument, including the later UC and ESA instruments.

## What changes

- Each programme has `capital.sale_expenses.rate: 0.1`, an explicit list of sources, and its own `capital.round_down` rule. `valued_capital` applies the deduction before secured debt, with float64 arithmetic.
- UC and HB round to the nearest penny, half up (UC reg 6(1); HB reg 80 / SPC HB reg 61(6)). IS, JSA, ESA and PC round down under their respective regulations. Each valuation and final assessment follows that rule; UC holdings and property allocations also follow its penny rule.
- `exact_amount` recovers an unambiguous whole penny from float32 storage below £131,072. It preserves other stored amounts. Above that boundary, original pennies can be ambiguous; the result can differ by a penny in either direction. This PR can't recover every digit that float32 storage lost before valuation.
- The model values Lifetime ISAs with the baseline surrender-value calculation in float64, and rounds each person's value under each programme's rule before summing. A £0.26 balance below the charge-free age gives £0.195: UC/HB count £0.20 and the down-rounding programmes count £0.19. The whole-penny property draws every penny, including this half-penny example.
- The valuation counts explicit LISA inputs as given. Replacement formulas keep their stored results, and neutralising the variable still works. The model recalculates a Lifetime ISA only where the baseline formula applies and no input supplies the value for that period and branch. Equality with the baseline float32 value cannot establish provenance: an explicit £0.195 also stores as 0.19499999284744263, but nearest-penny rounding of that supplied amount gives £0.19 where the baseline calculation gives £0.20. A regression covers this collision.
- The float64 LISA recomputation now applies the active variable's `defined_for` mask through Core's entity mapping. It keeps the stored result outside the mask. Regressions cover mixed masked/counted people across all six means tests and UC eligibility at the £16,000 capital limit. Commit `dbc0c587f` calculates the Lifetime ISA mask before its high-precision recomputation. It runs the formula only when at least one person applies and keeps Core's stored value outside the mask. The regression makes `lifetime_isa_balance` raise when a reform masks everyone and checks capital for all six programmes against a run without a Lifetime ISA.
- The capital property suite now generates `cash_isa` as well as `savings`. Its cash-only property checks their sum at face value. Direct source lookup still raises a `KeyError` for a source the oracle omits; cash ISAs stay outside both the sale-expense and secured-debt mappings.
- All six lists name #1974's `share_like_capital` as the sale-expense source, and `corporate_wealth` stays its backing household input. UC company-holding deductions use `HOLDINGS_SOURCE` consistently, including pool and reported-capital budgets read through `exact_amount`. Property oracles map the renamed source to the backing input.
- Main's #2839 counts each cash and stocks and shares ISA balance the data tie to a person in the holder's own benefit unit (`cash_isa_countable_capital`, `stocks_and_shares_isa_countable_capital`) and subtracts it from the household source that holds it (`cash_isa`, `share_like_capital`) before the formula allocates the rest. Each of the six formulas passes `valued_capital` each household source less those balances (`pooled_person_capital`), so it values the remainder as before: less 10% where `sale_expenses.sources` lists the source, then less any debt secured on it. `valued_person_capital` counts the claimant's and partner's balances in their own unit and values each as `valued_capital` values its household source: a stocks and shares ISA balance less 10%, because all six lists name `share_like_capital`, and a cash ISA balance at face value. A pound of a stocks and shares ISA therefore counts at 90% whether it stays in the pool or moves to its holder. `uc_assessable_capital` keeps main's `~covered` mask, so a reporting unit's claimant's and partner's ISAs stay in the pool, valued with it, and the formula subtracts the report.
- UC's existing property route shares the same sale-expense rate. `uc_assessable_capital` takes a disregarded company holding off at its counted value. In its place, it adds the claimant's or partner's share of the company's capital (reg 77(2)), outside the sale-expense helper. Recorded financial totals and complete reported-capital overrides keep their existing distinct meanings.
- Three household inputs hold the debt secured on other residential property, non-residential property and land. #2447 put the first two on main; this PR adds `owned_land_secured_debt` and deducts all three in every programme that counts the asset. UC counts no land, so the land input changes the five legacy means tests only. Each defaults to zero, and `uprating_indices.yaml` lists each once.
- Council tax reduction takes Pension Credit's capital on a savings-credit-only award, and UC's capital for a working-age applicant with a UC award outside Scotland's scheme from April 2022, so it inherits their valuation there. Outside those routes and the routes that disregard all capital, main's #1995 gives `council_tax_reduction_assessable_capital` its own household route, which counts savings at face value and land and property other than the home less 10% and less the debt secured on them. This PR adds `owned_land_secured_debt` to that route's `SECURED_DEBT`, so it deducts the debt secured on land as the schemes require (SI 2012/2885 Sch 1 para 32(b) and its equivalents). The route floors a negative source or debt at nil, works in float32 and keeps fractions of a penny, where `valued_capital` uses a negative source or debt as given, works in float64 and rounds each valuation to the penny; both take the 10% before the debt and floor each encumbered source at nil. No test shows a wrong result from those differences, so this PR leaves them.

### Which sources take the 10%

| Source | Deduction | Basis / limitation |
|---|---|---|
| Savings and cash ISAs | None | Account balance / withdrawal value; the decision maker doesn't count incidental withdrawal costs as sale costs (ADM H1675, H1607). |
| Land and other residential/non-residential property | 10% | Real property has sale costs (H1606); the model takes supplied property values as gross. |
| `share_like_capital` | 10% | Direct shares have sale costs (H1665). The aggregate can also include unit trusts and stocks-and-shares ISAs, whose legal treatment differs (H1674, H1656). We state this limit in the parameter descriptions, and the data need a source split. |
| Stocks and shares ISA balances counted in their holders' units (`stocks_and_shares_isa_countable_capital`) | 10% | The formulas move each balance out of `share_like_capital` and value it as that source, so the same limitation applies. #2131 values itemised ISAs without the 10%. |

### Existing tests whose expected values move

- `test_isa_balances.py` (main's #2712): UC capital for a household with £1,000 of savings, a £3,000 cash ISA and a £2,000 stocks-and-shares ISA moves from £6,000 × growth to (1,000 + 3,000 + 2,000 × (1 − rate)) × growth, £5,800 × growth. The ISA reaches capital through `share_like_capital`, which UC values less its sale-expense rate. `fc066bd31` made this change.
- `test_stored_household_totals.py::test_stored_household_totals_grow_past_the_projection_horizon` (main's #2781): the same household, stored as household ISA totals. UC capital moves from 6 × savings (£6,000 in 2025) to savings + cash ISA + stocks-and-shares ISA × (1 − rate), 5.8 × savings (£5,800 in 2025), in every year from 2025 to 2040, for the same reason (UC Regulations 2013 reg. 49(1)(a)). The test reads each part and the rate from the simulation, checks that capital stays more than a penny below the three parts at face value, and still checks that capital keeps one ratio to savings in every year.
- `person_isa_capital.yaml` (main's #2839): seven cases count each stocks and shares ISA balance, and the `share_like_capital` left in the pool, less 10%. For example, Case 3's adult child holds a £6,000 ISA inside £9,000 of `corporate_wealth`: the pool's £3,000 counts as £2,700, split £1,800 to the parents and £900 to the child's unit, which adds the ISA at £5,400, so UC capital moves from [2,000, 7,000] to [1,800, 6,300]. Cases 14 and 15 raise the household stocks and shares ISA totals to £17,777.78 and £6,666.67, so that 90% of each, rounded to the nearest penny, still lands on the £16,000 limit and the £6,000 threshold.
- `test_person_isa_capital.py` (main's #2839): the pound-counts-once checks value `share_like_capital`, and each stocks and shares ISA moved out of it, at each programme's sale-expense share, and allow each programme's penny rounding. The worked examples move with them: in `test_person_balances_without_household_totals_count_with_their_holders`, UC capital moves from [11,000, 9,000, 14,000, 11,000] to [10,666.67, 8,833.33, 13,800, 10,300] and Pension Credit's from 19,000 to 18,700. In `test_both_forms_in_one_dataset_add_up_to_the_stored_total`, UC and Housing Benefit round each unit's capital to the nearest penny, so savings of £1,268.69 in 2032 split between two units as £634.345 each and come to a penny over the household's total; the test allows that penny.
- `pension_credit_income.yaml` Case 4 (main's #1995): the £55,370 let property counts less 10%, so capital moves from £55,825 to £50,288, deemed income from £92 to £81 a week (£4,212 a year) and guarantee credit from £7,592 to (238 − 81) × 52 = £8,164. The case follows main's #1995, under which the formula doesn't count the rent as Pension Credit income (the case cites SPC Regs 2002 Sch IV para 18); this PR's earlier version of the case counted it.
- `test_savings_credit_only_receipt_properties.py` (main's #1995): main gave the owner with capital £12,000 of savings and £4,400 of property, so that Pension Credit's assessment (£16,400) exceeds the £16,000 limit while council tax reduction's own route (£12,000 + £3,960 = £15,960) doesn't. With this PR, Pension Credit counts the property at £3,960 too, so the test also gives the owner £500 of shares (`corporate_wealth`), which Pension Credit counts at £450 and council tax reduction's household route leaves out. Pension Credit's capital comes to £16,410, the route's to £15,960, and the savings credit stays £621.82 a year (13 a week of deemed income).

## Review responses and integration

| Finding | Response |
|---|---|
| v6: LISA half pennies lost on the float32 round-trip | Compute the baseline surrender value in float64 and round each person's value under the programme's rule. Widen the whole-penny property beyond multiples of 4p and cover UC boundaries, explicit inputs and equal-float32 provenance collisions. |
| v6: Incorrect claimant-favourable claim for large float32 values | Correct the documentation and test commentary: ambiguous pennies from £131,072 can produce a penny either way after valuation; plain amounts from £262,144 have the same issue. |
| v6: Float32 cast outside `errstate` | Put the cast inside the overflow/underflow/invalid guard. |
| v7: Baseline LISA recomputation bypassed `defined_for` | Apply the active mask to the float64 result and keep Core's stored result outside it (`696ae34ad`). Add mixed-person and UC-limit regressions. The v8 follow-up below also preserves Core's all-masked short circuit. |
| v7: Property generator omitted main's new `cash_isa` source | Generate cash ISAs, count them at face value, and check savings plus cash ISAs in the cash-only property (`b168dc509`). Keep direct lookup and existing tolerances. |
| v8: all-masked LISA recomputation evaluated unused dependencies (P2) | `dbc0c587f` evaluates the mask first and runs the formula only when someone applies. The new raising-dependency regression checks all six programmes. |
| v9: no finding (APPROVE at `dbc0c587f`) | No change. |
| CI at `dbc0c587f`: main's new `test_isa_balances.py` expected a stocks-and-shares ISA at full value in UC capital | `fc066bd31` expects UC's sale-expense deduction on it: the ISA reaches capital through `share_like_capital`, which UC values less 10% until the data separate ISAs from shares. #2131 then values itemised ISAs without the 10%. |
| v10: no P1 or P2 on this PR; one optional nit in the edited test | Optional: read the rate at the instant the formula uses. v10 also records one defect already on main (below) for a separate issue. |
| Review at `b4e872273`: the description named `c11d7c1d9` and an open #2765 conflict (P2), plus three wording points (P3) | Describe `b4e872273`'s merge of #2765 and the runs at that head. Correct the sentence on what `exact_amount` can't recover, name `test_isa_balances.py` as the mutated test, and cite ADM H1675 and reg 77(2) in brackets. |

- `646743859` merges main at `41fa5ddb5`. The supplied-input registry retains both this PR's LISA entry and main's `monthly_domestic_energy_consumption` entry.
- `d5c2a57e8` ports #2688's company-capital conservation oracle correction from `1193614a6`. The file matches that source revision byte for byte and keeps main's claimant/partner roles. The oracle reflects stored-input precision, holdings-budget scaling, separately rounded valuations and company capital, and the sum of rounded benefit units. The pinned example yields £25.28 + £25.28 = £50.56. The port changes no production code or tolerance. All seven company-owner properties passed in v7's CI.
- `5efb62cef` merges main at `2d437ebba`. Of 3,660 paths, only `policyengine_uk/data/uprating_indices.yaml` changed on both sides since `41fa5ddb`, and the merge commit's combined diff shows no hand-resolved hunk in it. The merged file keeps main's new `cash_isa_balance` and `stocks_and_shares_isa_balance`, drops main's duplicate `cash_isa` and `stocks_and_shares_isa` entries, and keeps all three secured-debt inputs. It lists each of its 124 variables once, under 14 distinct indices. The merge keeps every one-sided change: 59 files from this branch and 123 from main.
- `fc066bd31` changes only `test_isa_balances.py`. Main's #2712 (issue #2710) added the test; the edit makes it expect UC capital of (1,000 + 3,000 + 2,000 × (1 − rate)) × growth, which comes to £5,800 × 1.07224 = £6,218.99 in 2027. It reads the rate from the parameters. The test still checks first that the stored household totals grow with savings.
- Main's other changes since `41fa5ddb` leave the valuation alone. #2712 changes how `cash_isa` and `stocks_and_shares_isa` read their values but not which sources the means tests count, and its subscription limits move no balance. #2717 changes growth rates, not sources or deductions. #2732's household subsets slice every stored array and `_model_values` to the kept households, and its tests, which cover the ancestor walk that `supplied_input` uses, pass here. #2092, #2229 and #2716 touch no capital code.
- `30d48c01b` merges main at `2da212abc`. Only `uprating_indices.yaml` conflicted. The merged file keeps main's move of `other_investment_income`, `savings_interest_income` and `non_cash_isa_cash_interest` to `per_capita.savings_income` and main's new company-car and `income_not_otherwise_charged` entries. It adds `owned_land_secured_debt` under `per_capita.gdp`, next to `owned_land`, and lists each of its 128 variables once, under 15 indices. Against main's file, the only added line is `owned_land_secured_debt`.
- Main's #2754 lets a family give up a UC award worth less than its Tax-Free Childcare top-up. A London lone parent earning £44,000, with £10,000 of childcare and £17,000 of `corporate_wealth`, had £588.70 of UC and no top-up on this PR's old base, £1,411.30 worse off. At the merge the family counts £15,300 of capital, gives up the award and takes the £2,000 top-up. In eight cases, UC, Tax-Free Childcare and Scottish Child Payment together never fall below main at the merge.
- `3a3241cf6` corrects the `owned_land_secured_debt` documentation, which named UC. It changes no calculation.
- The review covered `30d48c01b54532603c0fad233a30935adab9280c`, and that documentation commit follows it.
- `ba4de95d6` merges main at `0080274d78e2b48e57682ac6a035fda6e11334e9`, 86 commits after `2da212abc`, #2781 among them. Git merged it with no conflict. Only `policyengine_uk/data/uprating_indices.yaml` changed on both sides since `2da212abc`; against main's file, the merged file adds only `owned_land_secured_debt`.
- `c11d7c1d9` changes only `test_stored_household_totals.py`, which main's #2781 added: it now expects the stocks-and-shares ISA in UC capital less the sale-expense rate (see "Existing tests whose expected values move"). Main's other test changes since `2da212abc` read no means-tested capital variable.
- `b4e872273` merges main at `52b30809ff5681ebfede4d559e72a44f4c893cd2`, which includes #2765. Git stopped on one hunk in `SUPPLIED_INPUT_VARIABLES` (`policyengine_uk/utils/supplied_inputs.py`), where both PRs added a name at the same place. The merge keeps this PR's `lifetime_isa_countable_capital` and #2765's `miscellaneous_income_trading_allowance_deduction`. Main's other changes since `0080274d7` name no means-tested capital variable or parameter.
- `154f92286` merges main at `af9361114425a1457545503f2801c68fc6ed5d47`, which includes #2839 (`d3c06225d`) and #1995 (`8717d5a26`). Git stopped on eight files: the six assessable-capital formulas, `pension_credit_income.yaml` and `test_savings_credit_only_receipt_properties.py`. Each formula combines the two changes as "What changes" describes, and the six `capital/sources.yaml` lists, which git merged, keep every entry from both sides. The two test files keep both sides' cases and work each moved value by hand (see "Existing tests whose expected values move"). The merge also values the stocks and shares ISA balances that `valued_person_capital` counts as their household source, moves main's person ISA tests to the 10%, adds `owned_land_secured_debt` to council tax reduction's household route, and adds two cases. In `capital_valuation.yaml`, one benefit unit with a £4,000 cash ISA, £3,000 of savings and a £100,000 second home carrying a £60,000 mortgage counts £3,000 + £4,000 + (£100,000 − £10,000 − £60,000) = £37,000 in all six programmes. In `council_tax_reduction_capital_valuation.yaml`, £50,000 of land carrying a £20,000 debt counts £50,000 − £5,000 − £20,000 = £25,000 on council tax reduction's household route; without the `SECURED_DEBT` entry the case gives £45,000 and fails.
- `76242a05b` changes only `test_person_isa_capital.py`: `test_both_forms_in_one_dataset_add_up_to_the_stored_total` allows the penny each unit's nearest-penny rounding adds.
- `3c395c3e4` merges main at `95dfc62f818058007f28e42d7c550ad00578eaef`. Git merged it without conflict. Main's #2744, #2760, #2763, #2768, #2769, #2786, #2787, #2788, #2794, #2809 and #2810 change no file this PR changes, no capital source list and no assessable-capital formula. #2788's legacy tariff tests set assessable capital directly.
- `3dbdb0027` adds `owned_land_secured_debt` to the per capita GDP list on the growth factors page, which now matches `uprating_indices.yaml`'s per capita GDP entry.
- #2131 (head `ce91e92ea`) merges this PR at `b4e872273` and expects the stocks-and-shares ISA at face value in the stored-totals test. `git merge-tree --write-tree --name-only ce91e92ea 3dbdb0027` stops on `docs/book/assumptions/growthfactors.md` and on `uc_assessable_capital.py`'s documentation string. One more change won't show as a conflict. #2131 replaces `share_like_capital` in every programme's `sources` with `share_like_capital_with_sale_expenses` and `share_like_capital_without_sale_expenses`, and main's `counted_person_sources` counts `stocks_and_shares_isa_countable_capital` only while `sources` lists `share_like_capital` (`HOUSEHOLD_SOURCE_OF`). On that merge the formulas stop counting stocks and shares ISA balances in their holders' units and leave them in the pool: with the two documentation conflicts resolved to this head's text, `person_isa_capital.yaml` fails 10 of 21 cases and `test_person_isa_capital.py` 24 of 32 tests. When #2131 takes this head, it needs to count those balances against the source that holds them, for example by mapping `stocks_and_shares_isa_countable_capital` to `share_like_capital_without_sale_expenses`, which holds the itemised stocks and shares ISAs, and to expect the balances at face value in main's person ISA tests.

## Validation

We ran each command below on its own at `3dbdb00276578089c68949b31658239386af9420`, on a clean checkout after `uv sync --frozen --extra dev`, and loaded no survey data.

| Command | Result |
| --- | --- |
| `uv run --frozen policyengine-core test policyengine_uk/tests/policy/baseline/gov/dwp -c policyengine_uk` | 997 passed |
| `uv run --frozen policyengine-core test policyengine_uk/tests/policy/baseline/finance/benefit/family -c policyengine_uk` | 1,761 passed |
| `uv run --frozen policyengine-core test policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction -c policyengine_uk` | 352 passed |
| `uv run --frozen pytest -n 4 policyengine_uk/tests/test_capital_valuation_properties.py` | 18 passed (246 s) |
| `uv run --frozen pytest -n 4 policyengine_uk/tests/test_stock_capital_periods.py` | 2 passed |
| `uv run --frozen pytest -n 4 policyengine_uk/tests/test_uc_property_capital.py` | 65 passed |
| `uv run --frozen pytest -n 4 policyengine_uk/tests/test_uc_property_capital_mock.py` | 26 passed |
| `uv run --frozen pytest -n 4 policyengine_uk/tests/test_person_isa_capital.py` | 32 passed |
| `uv run --frozen pytest -n 4 policyengine_uk/tests/test_savings_credit_only_receipt_properties.py` | 6 passed |
| `uv run --frozen pytest -n 4 policyengine_uk/tests/test_stored_household_totals.py` | 42 passed |
| `uv run --frozen pytest -n 4 policyengine_uk/tests/code_health` | 3,181 passed (203 s) |
| `.github/check_fix_pr.py` on this title and body, as `pr_fix_evidence.yaml` runs it, with the diff from the merge base `95dfc62f8` | passed, with 26 changed executable tests |
| `uvx ruff@0.17.2 format --check .` | 2,229 files already formatted |
| `uvx ruff@0.17.2 check .` and `uvx ruff@0.17.2 check --select F811,F821 policyengine_uk` | all checks passed |
| `git diff --check b4e872273..3dbdb0027` | clean |
| `git merge-tree --write-tree --name-only 3dbdb0027 origin/main`, with `main` at `95dfc62f8` | no conflict |

Without `owned_land_secured_debt` in council tax reduction's `SECURED_DEBT`, the land case in `council_tax_reduction_capital_valuation.yaml` gives £45,000 against £25,000 and fails. CI hasn't run at this head.

A review of `b4e872273c4695cf728d0d2811670b7300504ba4` ran these checks after `uv sync --frozen --extra dev`, one command at a time, with no survey data.

| Command | Result |
| --- | --- |
| `uv run pytest -n 4 policyengine_uk/tests/test_stored_household_totals.py` | 42 passed (214 s) |
| `uv run pytest -n 4 policyengine_uk/tests/test_isa_balances.py` | 2 passed |
| `uv run pytest -n 4 policyengine_uk/tests/test_capital_valuation_properties.py` | 18 passed (328 s) |
| `uv run policyengine-core test policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml -c policyengine_uk` | 26 cases passed |
| `uv run pytest -n 4 policyengine_uk/tests/code_health` | 3,110 passed |
| `.github/check_fix_pr.py`, as `pr_fix_evidence.yaml` runs it | passed, with 23 changed executable tests |
| `uvx ruff@0.17.2 format --check .`, `ruff check .`, `ruff check --select F811,F821 policyengine_uk` | clean |
| `git diff --check 3a3241cf6..b4e872273` | clean |

[CI at `b4e872273`](https://github.com/PolicyEngine/policyengine-uk/actions/runs/41588793430) passed every job, including the seven Python shards, the YAML job and `Test / Result`.

Local runs at `c11d7c1d92faeab80bf37a3019463f0b4ecbc103`, after `uv sync --frozen --extra dev`, one command at a time. We loaded no survey data and skipped no test.

| Command | Result |
| --- | --- |
| `uv run pytest -n 4 policyengine_uk/tests/test_stored_household_totals.py` | 42 passed (161 s) |
| `uv run pytest policyengine_uk/tests/test_capital_share_components.py::test_capital_matches_the_original_lists` | 1 passed (1,837 s) |
| `uv run pytest -n 4 policyengine_uk/tests/test_capital_share_components.py --deselect …::test_capital_matches_the_original_lists` | 13 passed (226 s) |
| `uv run pytest -n 4 policyengine_uk/tests/test_capital_valuation_properties.py` | 18 passed (296 s) |
| `uv run pytest -n 4 policyengine_uk/tests/test_isa_balances.py` | 2 passed |
| `uv run pytest -n 4 policyengine_uk/tests/test_pension_credit_reported_capital_properties.py` | 4 passed |
| `uv run pytest -n 4 policyengine_uk/tests/test_savings_credit_only_receipt_properties.py` | 6 passed |
| `uv run pytest -n 4 policyengine_uk/tests/test_stock_capital_periods.py` | 2 passed |
| `uv run pytest -n 4 policyengine_uk/tests/test_uc_company_override_properties.py` | 11 passed |
| `uv run pytest -n 4 policyengine_uk/tests/test_uc_company_owner_properties.py` | 7 passed |
| `uv run pytest -n 4 policyengine_uk/tests/test_uc_property_capital.py` | 65 passed |
| `uv run pytest -n 4 policyengine_uk/tests/test_uc_property_capital_mock.py` | 26 passed |
| `uv run pytest -n 4 …/universal_credit/test_uc_capital_allocation.py` | 1 passed |
| `uv run policyengine-core test <file> -c policyengine_uk` for the 12 YAML files this PR adds or changes and every other `*capital*.yaml` file under the UC, HB, IS, JSA, ESA and PC tests and `household/wealth` (22 files) | 238 cases passed, none failed |
| `uv run pytest -n 4 policyengine_uk/tests/code_health` | 3,108 passed |
| `uvx ruff@0.17.2 format --check .`, `ruff check .`, `ruff check --select F811,F821 policyengine_uk` | clean |
| `git diff --check 3a3241cf6..HEAD` | clean |

At the merge commit without the test change, `test_stored_household_totals_grow_past_the_projection_horizon` fails at line 222 with £5,800.00 against £6,000.00 ± 0.06; it passes on main at `0080274d7`. When we set UC's sale-expense rate to 0 in a scratch copy, the edited test fails (capital of £6,000.00 doesn't fall below the £6,000.00 face value) and main's version passes; once we restore the rate, the edited test passes.

A review of the merge at `30d48c01b` reran the changed tests one file at a time, and every test that finished passed.

| Check at `30d48c01b` | Result |
| --- | --- |
| `test_capital_valuation_properties.py` | 18 passed |
| `test_capital_share_components.py` | 14 passed |
| `test_uc_property_capital.py` and `test_uc_property_capital_mock.py` | 91 passed |
| `test_uc_company_owner_properties.py` and `test_uc_company_override_properties.py` | 18 passed |
| `test_lifetime_isa_capital.py` and `lifetime_isa_capital.yaml` | 3 passed, 24 cases passed |
| `test_uc_tax_free_childcare_choice.py` and the two Tax-Free Childcare YAML files | 20 passed, 12 cases passed |
| `test_input_records.py` | 11 passed |
| `test_projection_horizon.py` | 14 passed |
| `test_fiscal_partition.py` and `test_aggregate_impact_partition.py` | 43 and 11 passed |
| HB accommodation disregard YAML file | 50 cases passed |

Three files hit local time caps under host load before finishing: `test_household_subset.py`, `test_housing_benefit_accommodation_dates.py` and the pair `test_isa_accounting.py` and `test_wealth_components.py`. None of those that ran failed, and CI ran them all at `3a3241cf6` and passed.

An earlier review (v10) ran these checks against `fc066bd318f2e059623ab30df56985e3605e24d0`, one file at a time, and every unmutated test passed.

| Check | Result |
| --- | --- |
| `test_isa_balances.py` | 2 passed |
| `test_wealth_components.py` | 3 passed |
| `test_capital_valuation_properties.py` | 18 passed |
| `test_lifetime_isa_capital.py` | 3 passed |
| `test_household_subset.py` (main's #2732) | 9 passed |
| 17 YAML files that `git grep -l` finds naming `cash_isa`, `stocks_and_shares_isa`, `share_like_capital` or `corporate_wealth` | 210 cases passed |
| `lifetime_isa_capital.yaml` | 24 cases passed |
| Six-programme ISA probes on small in-memory datasets | 165 of 176 checks match; the other 11 fail identically on main (below) |
| Head against main on the same 176 checks | head never exceeds main; main − head equals 10% of `share_like_capital` to within 2.8p of penny rounding |

The probes cover UC, HB, PC, IS, income-based JSA and income-related ESA in 2025, 2027, 2032 and 2033. They use four kinds of data: stored household ISA totals, person balances, person balances with a Lifetime ISA, and `corporate_wealth` that already holds the ISA. Each programme counts savings and cash ISAs in full, takes 10% off `share_like_capital`, and counts a Lifetime ISA once, through `valued_person_capital`. No source counts twice.

The review made four mutations in place on `test_isa_balances.py`, and restored the file after each:

- Dropping the stocks-and-shares ISA from `share_like_capital` failed it at £4,288.96 (£4,000 × growth).
- Taking the 10% twice failed it at £6,025.99 (£5,620 × growth).
- Removing `share_like_capital` from UC's sale-expense sources failed it at £6,433.44 (£6,000 × growth).
- A nil sale-expense rate passed it, because the test reads the rate, but `capital_valuation.yaml` failed 8 cases.

`cash_isa` and `stocks_and_shares_isa` sum person balances through `adds`, so after the 2032 projection horizon main loses both on a dataset that stores only the household totals, and this PR leaves that as it stands. In the 2033 probe, main and this PR both count £1,310.81 of UC capital, the savings alone, where the stored totals give £7,602.69. policyengine-uk-data's Enhanced FRS writes neither total, but Microcosm's UK exports store both, so their runs for 2033 and later lose them. [#2740](https://github.com/PolicyEngine/policyengine-uk/issues/2740) tracks it: #2712 replaced the totals' `uprating` attribute with `adds`.

[CI at `3a3241cf6`](https://github.com/PolicyEngine/policyengine-uk/actions/runs/41203464649) passed: both required checks, all six Python shards and the YAML job.

## Impact on the certified data

The [aggregate-impact comment](https://github.com/PolicyEngine/policyengine-uk/pull/1969#issuecomment-6277352471), updated at 2026-11-03T07:24:43Z, compares main `52b30809f` with `6396ca1dc`, which merges `b4e872273` into that main and has the same tree. The [workflow run](https://github.com/PolicyEngine/policyengine-uk/actions/runs/41588793376) reports `b4e872273` as its head, and both sides use `enhanced_frs_2024_25.h5` (policyengine-uk-data 1.58.1). The job passed: no check broke, `sign:vat` fails on both sides, and the head fixes no failing check. Every figure below matches the run at `3a3241cf6` against main `2da212abc`. The job hasn't measured `3dbdb0027` against `main` at `95dfc62f8`.

| Change | 2025-26 | 2026-27 | 2027-28 | 2028-29 | 2029-30 | 2030-31 | 2031-32 |
|---|---:|---:|---:|---:|---:|---:|---:|
| Universal Credit | +£222.8m | +£324.3m | +£543.0m | +£1.26bn | +£1.53bn | +£1.08bn | +£1.09bn |
| Universal Credit caseload | +12.5k | +35.0k | +51.0k | +91.4k | +96.8k | +73.8k | +69.3k |
| Housing Benefit | +£0.9m | +£0.3m | +£0.4m | +£2.8m | +£19.9m | +£42.6m | +£16.1m |
| Pension Credit | +£1.0m | +£1.0m | +£1.1m | +£1.0m | +£0.9m | +£0.8m | +£0.7m |
| ESA (income-related) | +£0.4m | +£0.4m | +£0.4m | +£2.6m | +£0.4m | +£0.4m | +£0.5m |
| JSA (income-based) | +£13,640 | 0 | 0 | 0 | 0 | 0 | 0 |
| Income Support | +£43,929 | 0 | 0 | 0 | 0 | 0 | 0 |
| Council Tax Reduction | +£0.8m | +£0.8m | +£0.3m | +£0.3m | +£1.0m | −£55,441 | −£51,533 |
| Scottish Child Payment | +£0.3m | 0 | 0 | 0 | +£6.0m | +£11.0m | +£24.1m |
| Tax-Free Childcare | −£0.6m | −£1.4m | −£0.7m | 0 | −£10.2m | −£11.2m | −£1.7m |
| Targeted childcare entitlement | 0 | 0 | 0 | 0 | 0 | 0 | +£7.0m |
| Household net income † | +£225.7m | +£325.6m | +£544.7m | +£1.27bn | +£1.55bn | +£1.13bn | +£1.14bn |
| Government balance † | −£225.7m | −£325.6m | −£545.2m | −£1.27bn | −£1.55bn | −£1.13bn | −£1.14bn |
| Absolute poverty AHC, children | −0.01pp | −0.17pp | −0.21pp | −0.27pp | −0.28pp | −0.09pp | −0.35pp |

† The report rebuilds these totals without the Disabled Students' Allowance figure, which it withholds.

The model now assesses less capital, so it pays the same or more of each of the six benefits in every year. The report shows 0.0% of people losing over £1 in every year, and every base-run decile gains or holds. The moves the other way follow from higher awards:

- Council Tax Reduction counts the UC award as income in the default-scheme formula (`council_tax_reduction/_legacy.py:50-53`), so a larger award lowers it.
- Tax-Free Childcare excludes any family with `universal_credit_pre_benefit_cap` above nil (`tax_free_childcare_program_eligible.py`; `disqualifying_benefits.yaml`), unless the family gives up a smaller award through `forgoes_universal_credit_for_tax_free_childcare`.
- The TV licence fee falls by £0.1m to £0.3m a year: a household with someone aged 75 or over on Pension Credit gets a free licence (`tv_licence_free_licence_share.py`).
- VAT on private school fees moves both ways, from −£1.6m to +£2.0m, because attendance ranks households by net income including benefits (`attends_private_school.py`).
- Relative poverty rises in a few cells, such as +0.14pp for children before housing costs in 2030-31, because each run sets its line at 60% of its own median. Absolute poverty falls or holds in every cell.
- The report's own-decile means move both ways as households change deciles.

The targeted childcare entitlement moves only in 2031-32: a UC award above nil gives a family one route to it (`meets_universal_credit_criteria_for_targeted_childcare_entitlement.py`).

`share_like_capital` can also hold unit trusts and stocks-and-shares ISAs, so these aggregates take 10% off some sources whose legal treatment differs.

## Coordination

- This PR incorporates #1974's source rename. Keep `share_like_capital` in programme lists and retain `corporate_wealth` only as the backing input or an explanation of it.
- #2688 targets main and lands after this PR. This PR ports only #2688's company-capital rounding oracle correction (`1193614a6`).
- This PR keeps #1950's capital-income cases and #1965's company-substitution rules on valued capital.
- Later PRs should split direct shares, funds, ISAs and pension wealth in the data; export secured property debt; extend council tax reduction's household capital to ISAs and shares; review omitted UC land; and add independent rulespec capital valuation rather than feeding model capital into the oracle.

axiom: TheAxiomFoundation/rulespec-uk#395 queued

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Ready for hub review

Head: 3dbdb00276578089c68949b31658239386af9420 on claude/merge-main-council-tax-m72rh7-1969, four commits on #1969's b4e872273. I didn't push to uc-capital-sale-expenses or to claude/merge-main-council-tax-dgxwjl-1969. The new PR_BODY_1969.md is in the comment above; the title stays "Value means-tested capital less 10% sale expenses and secured debt", so there's no PR_TITLE_1969.txt.

Commit What it does
154f92286fafeadf5fc5d525f10d506a15f487cd Brief 1031's merge of main at af9361114, kept as it was after the review below
76242a05bba75b73c007226f348d96293e15f795 Brief 1031's penny tolerance in test_person_isa_capital.py, kept
3c395c3e4ffa873bbae481fd7fe73e36f2a52530 Merges main at 95dfc62f8; no conflict
3dbdb00276578089c68949b31658239386af9420 Adds owned_land_secured_debt to the growth factors page's per capita GDP list (docs only)

Late writer

Before I started (03:23Z), at each push, and again before posting, claude/merge-main-council-tax-dgxwjl-1969 pointed at 76242a05b, uc-capital-sale-expenses at b4e872273 and property-wealth-overseas-other at 80e0d344f. No comment or review on #1969 or #2688 after 14:22:43Z on 4 Nov held a hub document or a hand-off; the only newer comments are my two Picking this up comments (6648811730, 6648811951).

Review of brief 1031's commits

I read every resolution in git show --remerge-diff 154f92286 and both commits' diffs. Both hold; I made no fix on top.

  • The six formulas (esa_income_, jsa_income_, income_support_, housing_benefit_, pension_credit_ and uc_assessable_capital.py). Each passes valued_capital a value_of that returns the household source less pooled_person_capital, so Value means-tested capital less 10% sale expenses and secured debt #1969's per-source valuation (10% off listed sources, then secured debt, floored at nil) applies to the remainder Count each person's ISAs in their own benefit unit's capital #2839 leaves in the pool. Each counts person sources through valued_person_capital, which now iterates counted_person_sources (Count each person's ISAs in their own benefit unit's capital #2839) and values a source that HOUSEHOLD_SOURCE_OF maps to a listed sale-expense source less the rate (capital_valuation.py:83-90). UC keeps Count each person's ISAs in their own benefit unit's capital #2839's ~covered mask (uc_assessable_capital.py:72-82). Each formula's code matches main's pooling call and Value means-tested capital less 10% sale expenses and secured debt #1969's valuation call exactly; only the documentation strings were hand-written, and they read correctly (two of them keep a stray "The " string fragment, cosmetic only).
  • The lists. For all six programmes, every dated entry in sources.yaml, person_sources.yaml, property_sources.yaml and sale_expenses/sources.yaml from either b4e872273 or af9361114 survives in the merge, with no extras (script over yaml.safe_load of each file at the three commits).
  • pension_credit_income.yaml Case 4. By hand: capital 455 + 0.9 × 55,370 = 50,288; deemed income ceil((50,288 − 10,000) / 500) = 81 a week, 4,212 a year; rent disregarded, so income 4,212; guarantee credit (238 − 81) × 52 = 8,164; savings credit nil (81 a week of qualifying income). All six outputs match.
  • test_savings_credit_only_receipt_properties.py. With Value means-tested capital less 10% sale expenses and secured debt #1969, main's owner (savings 12,000, property 4,400) would give Pension Credit 12,000 + 3,960 = 15,960, the same as council tax reduction's own route, so the test would no longer tell the routes apart. The merge adds 500 of corporate_wealth: Pension Credit counts 450, the route counts none, so 16,410 against 15,960. Savings credit by hand: deemed income ceil(6,410 / 500) = 13 a week, so 0.6 × (238.00 − 208.07) − 0.4 × (253.00 − 238.00) = 11.958 a week, 621.82 a year, as asserted.
  • Main's ISA tests. I reworked the worked examples by hand. test_person_balances_without_household_totals_count_with_their_holders: first pool 7,000 + 0.9 × 2,000 = 8,800, 2,933.33 per claimant; couple 5,866.67 + 3,000 + 1,800 = 10,666.67; other unit 2,933.33 + 5,000 + 900 = 8,833.33; second pool 12,000 + 0.9 × 3,000 = 14,700, so 9,800 + 4,000 = 13,800 and 4,900 + 5,400 = 10,300; Pension Credit 14,700 + 4,000 = 18,700. person_isa_capital.yaml Cases 2, 3, 6, 7, 9, 13 and 18 check the same way, and Cases 14 and 15 land on 16,000.00 and 6,000.00 (0.9 × 17,777.78 = 16,000.002 and 0.9 × 6,666.67 = 6,000.003, nearest penny).
  • The merge's other edits. Council tax reduction's sale_expenses/rate.yaml description now names land among the secured-debt inputs, and the changelog fragment adds that council tax reduction's household capital deducts the debt secured on land. Both match the code.
  • 76242a05b. Its code is right: savings of 1,268.69 in 2032 split between two units as 634.345 each, and UC and Housing Benefit round each to 634.35, a penny over the household's 1,268.69 (rerun: UC and HB both give 634.35 and 634.35). Its message overstates the commit: the oracle changes it describes ("now value share_like_capital, and each stocks and shares ISA balance moved out of it, less the programme's sale-expense rate") are in 154f92286; 76242a05b only adds the penny tolerances. I left the history alone.

Task 1's benefit unit

One person aged 70 with a £4,000 cash ISA, £3,000 of savings and a £100,000 second home carrying a £60,000 mortgage. By hand: the cash ISA moves to its holder at face value (4,000), savings count 3,000, and the home counts 100,000 − 10,000 − 60,000 = 30,000, so 37,000 in UC, HB, IS, JSA, ESA and PC. The merge's case in capital_valuation.yaml ("Every means test counts a cash ISA and savings at face value and a mortgaged second home less sale expenses and the mortgage") pins exactly this mix, so I added no case. The model gives 37,000 in all six, at age 70 and, except Pension Credit (0, no pension-age adult), at age 40. Council tax reduction's own route gives 33,000 for the same unit: its capital/sources.yaml lists no cash ISA (main's scope, unchanged here).

Council tax reduction's household route against #1969's valuation

Run on hand-built households at the head (ctr_compare.py):

Case DWP programmes (valued_capital) CTR household route
Land 50,000 with 20,000 secured on it 25,000 25,000 with the merge's SECURED_DEBT entry; 45,000 without it
Second home 100,000, mortgage 150,000, savings 5,000 (negative equity) 5,000 5,000
Land 50,000, land debt 80,000, savings 5,000 5,000 5,000
Savings −1,000, land 50,000 44,000 45,000 (floors the source at nil)
Land 50,000, land debt −20,000 65,000 45,000 (floors the debt at nil)
Land 1,000.05 HB 900.05, IS/JSA/ESA 900.04 900.045 (no penny rounding)
Land 16,001 14,400.9004 (float64, stored as float32) 14,400.8994 (float32 arithmetic)
  • Order of deductions: the same in both (10% of the gross value, then the debt, then the floor at nil per source).
  • Negative equity: the same (each encumbered source floors at nil, so it can't reduce savings).
  • Land: main's route had no land debt because main has no owned_land_secured_debt. council_tax_reduction_capital_valuation.yaml's land case gives 25,000 with 154f92286's change to council_tax_reduction_assessable_capital.py (passes) and 45,000 without it (fails, "differs from 25000.0"), run in a scratch copy with main's version of the file. The schemes deduct "the amount of any encumbrance secured on it" (SI 2012/2885 Sch 1 para 32(b) and equivalents), so the change stands.
  • Negative inputs, rounding, precision: different, as in the table, but no test shows a wrong legal result, so I changed nothing.

main at 95dfc62f8

main merged eleven PRs since af9361114, not seven: #2744, #2760, #2763, #2768, #2769, #2786, #2787 and also #2788, #2794, #2809 and #2810. None changes a file this branch changes (the intersection of git diff --name-only af9361114 95dfc62f8 with git diff --name-only 95dfc62f8 3dbdb0027 is empty), a capital source list or an assessable-capital formula. #2788's new legacy tariff tests set *_assessable_capital as an input, so the valuation can't move them. Git merged main without conflict.

#2131

ce91e92ea now merges #1969 at b4e872273. git merge-tree --write-tree --name-only ce91e92ea 3dbdb0027 stops on docs/book/assumptions/growthfactors.md and on uc_assessable_capital.py (its documentation string only). The merge also breaks something git won't flag: #2131's lists replace share_like_capital with share_like_capital_with_sale_expenses and share_like_capital_without_sale_expenses, and #2839's counted_person_sources keeps stocks_and_shares_isa_countable_capital only while sources lists share_like_capital. In a scratch trial merge (both conflicts taken from this head), counted_person_sources for UC, HB and PC drops the stocks and shares ISA source, person_isa_capital.yaml fails 10 of 21 cases and test_person_isa_capital.py 24 of 32. #2131 needs to count those balances against the source that holds them, for example by mapping the person source to share_like_capital_without_sale_expenses in HOUSEHOLD_SOURCE_OF, and to expect them at face value in main's person ISA tests. I didn't touch #2131. The body's #2131 note says this.

Tests at 3dbdb0027

Each command on its own, clean checkout, uv sync --frozen --extra dev, no survey data:

  • policyengine-core test: gov/dwp 997 passed, finance/benefit/family 1,761 passed, council tax reduction 352 passed.
  • pytest -n 4: test_capital_valuation_properties.py 18, test_stock_capital_periods.py 2, test_uc_property_capital.py 65, test_uc_property_capital_mock.py 26, test_person_isa_capital.py 32, test_savings_credit_only_receipt_properties.py 6, test_stored_household_totals.py 42, all passed; code_health 3,181 passed.
  • .github/check_fix_pr.py with main's checker as pr_fix_evidence.yaml runs it (title and body without a trailing newline, empty labels, git diff --name-status --no-renames -z 95dfc62f8 3dbdb0027): PASS, 26 changed executable tests.
  • uvx ruff@0.17.2 format --check .: 2,229 files already formatted; ruff check . and --select F811,F821 policyengine_uk: all checks passed; git diff --check b4e872273..3dbdb0027: clean.
  • git merge-tree --write-tree --name-only 3dbdb0027 origin/main (95dfc62f8): no conflict.
  • main moved to 47a2817e2 while I worked: it merged Count Maternity Allowance in the benefit cap #2811 (benefit cap and Maternity Allowance), which changes no file either branch changes. git merge-tree --write-tree --name-only merges both heads with 47a2817e2 without conflict. I didn't merge it, since the job names 95dfc62f8.

The same YAML directories and Python files gave the same counts at 3c395c3e4 (the head before the docs commit), and the four YAML files the first merge touched passed at 76242a05b (1, 27, 10 and 21 cases), with test_person_isa_capital.py 32 and test_savings_credit_only_receipt_properties.py 6.

Skipped

  • Tests that need a survey dataset, the full suite and the documentation build: CI covers them once the branch moves.
  • The Aggregate impact job at this head: the body says the job hasn't measured 3dbdb0027.

Judgment calls


Generated by Claude Code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Nov 5, 2026
The merge brings in #1969's changelog fragment and changes nothing else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis requested a review from juaristi22 November 5, 2026 11:22

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

UC capital ignores reg 49 valuation (10% sale costs and secured debts)

3 participants