From 46a48ecef7b9784e37968f2aea7f0b4071bd1e1d Mon Sep 17 00:00:00 2001 From: "nikhil@policyengine.org" Date: Wed, 8 Apr 2026 14:06:30 +0100 Subject: [PATCH 1/3] feat: add Wales infrastructure and Green Manifesto scoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the foundational Wales-specific model components needed to score Welsh manifesto policies, plus the first scoreable reforms. Infrastructure: - `is_in_wales` flag on Person, derived from household region (parallel to `is_in_scotland`) - `Region::is_wales()` method - Welsh Land Transaction Tax (LTT) as a separate parameter block, applied to Welsh households instead of SDLT via `calculate_property_transaction_tax()` - Nation-specific council tax Band D rates (`wales_average_band_d`, `scotland_average_band_d`) so CT reforms can be scoped to a single nation - `FreeSchoolMealsParams` with `wales_primary_universal` / `wales_secondary_universal` flags; calculated FSM flows through to net income when params are present - OBR fiscal headroom context parameters (`obr_fiscal_headroom_bn: 9.9`, `obr_headroom_sensitivity: 1.0`) — UK-wide metadata, not used in simulation computation Parameters (2025/26): - LTT residential bands (Welsh Government rates: 0%/6%/7.5%/10%/12%) - Wales Band D CT: £1,955; Scotland: £1,528 - Baseline FSM: `wales_primary_universal: true` (current policy since Jan 2024) - OBR Spring Statement 2026 headroom: £9.9bn Council tax now enters `total_tax` and uses `council_tax_calculated` for both fiscal accounting and AHC housing costs, with reform changes flowing through to BHC net income via a CT adjustment. Reform: `reforms/wales_green_manifesto.yaml` — scores: 1. Abolish Wales council tax (`wales_average_band_d: 0.0`): -£3.7bn, 7.4% gain, avg +£1,779/yr 2. Extend universal FSM to secondary pupils in Wales (`wales_secondary_universal: true`) Co-Authored-By: Claude Opus 4.6 --- parameters/2025_26.yaml | 45 +++++++++++++- reforms/wales_green_manifesto.yaml | 38 ++++++++++++ src/data/clean.rs | 12 +++- src/data/frs.rs | 2 + src/engine/entities.rs | 6 ++ src/engine/simulation.rs | 47 ++++++++++----- src/parameters/mod.rs | 87 ++++++++++++++++++++++++++- src/variables/benefits.rs | 60 +++++++++++++++++++ src/variables/wealth_taxes.rs | 95 ++++++++++++++++++++++++++---- tests/parameter_impact.rs | 10 ++++ 10 files changed, 372 insertions(+), 30 deletions(-) create mode 100644 reforms/wales_green_manifesto.yaml diff --git a/parameters/2025_26.yaml b/parameters/2025_26.yaml index f397da1..ff44a89 100644 --- a/parameters/2025_26.yaml +++ b/parameters/2025_26.yaml @@ -236,7 +236,11 @@ tobacco_duty: council_tax: # Local Government Finance Act 1992 s.1-5 # DLUHC Council Tax levels statistics 2025/26: England average Band D = £2,280 + # Wales 2025/26 average Band D: £1,955 (Stats Wales / DLUHC) + # Scotland 2025/26 average Band D: £1,528 (Scottish Government) average_band_d: 2280.0 + wales_average_band_d: 1955.0 + scotland_average_band_d: 1528.0 capital_gains_tax: # Taxation of Chargeable Gains Act 1992; Finance Act 2024 s.7 (rate increases) @@ -247,7 +251,8 @@ capital_gains_tax: realisation_rate: 0.50 stamp_duty: - # Finance Act 2003 s.55, as amended; bands from 1 April 2025 + # Finance Act 2003 s.55, as amended; bands from 1 April 2025 (England and NI only) + # Wales uses LTT (see land_transaction_tax below); Scotland uses LBTT (not yet separate) bands: - { rate: 0.0, threshold: 0 } - { rate: 0.02, threshold: 125001 } @@ -256,6 +261,35 @@ stamp_duty: - { rate: 0.12, threshold: 1500001 } annual_purchase_probability: 0.043 # ~1/23 year average holding period +land_transaction_tax: + # Land Transaction Tax and Anti-avoidance of Devolved Taxes (Wales) Act 2017 (ANAW 2017/1) + # Welsh Government rates from 10 October 2022 (still current 2025/26) + # Welsh Revenue Authority: https://www.wra.gov.wales/land-transaction-tax/ + # Residential main rates (not higher rates surcharge): + # 0% £0–£225,000 + # 6% £225,001–£400,000 + # 7.5% £400,001–£750,000 + # 10% £750,001–£1,500,000 + # 12% over £1,500,000 + bands: + - { rate: 0.0, threshold: 0 } + - { rate: 0.06, threshold: 225001 } + - { rate: 0.075, threshold: 400001 } + - { rate: 0.10, threshold: 750001 } + - { rate: 0.12, threshold: 1500001 } + annual_purchase_probability: 0.043 + +free_school_meals: + # Welsh Government: universal free school meals for primary pupils (reception–year 6) + # rolled out from January 2024. Source: https://www.gov.wales/universal-primary-free-school-meals + # Cost per meal: approx £3.00 (Welsh Government estimate); 190 school days per year. + primary_annual_value: 570.0 # £3.00 × 190 days + secondary_annual_value: 570.0 # same unit cost assumption + # Baseline: Wales primary already universal + wales_primary_universal: true + # Baseline: Wales secondary NOT universal (Green proposal to extend it) + wales_secondary_universal: false + wealth_tax: # Hypothetical — no current UK legislation. Disabled by default. # Wealth Tax Commission (2020) proposed 1% above £10m. @@ -263,6 +297,15 @@ wealth_tax: threshold: 10000000.0 rate: 0.01 +# OBR fiscal headroom (UK-wide — not devolved). +# Source: OBR Spring Statement March 2026, Economic and Fiscal Outlook. +# Headroom against the supplementary debt rule: PSND ex (ex. BoE) falling as a % of GDP +# in the fifth year of the forecast (2029/30). At Spring Statement 2026: £9.9bn. +# Each £1bn of net new borrowing reduces this headroom by approximately £1bn +# (obr_headroom_sensitivity = 1.0 is the OBR's central assumption absent dynamic scoring). +obr_fiscal_headroom_bn: 9.9 +obr_headroom_sensitivity: 1.0 + labour_supply: # OBR labour supply elasticities (Slutsky decomposition). # Source: OBR (2023) "Costing a cut in National Insurance contributions: diff --git a/reforms/wales_green_manifesto.yaml b/reforms/wales_green_manifesto.yaml new file mode 100644 index 0000000..8794ece --- /dev/null +++ b/reforms/wales_green_manifesto.yaml @@ -0,0 +1,38 @@ +# Wales Green Party Manifesto 2026 — scoreable policies +# +# This reform covers the Tier 1 and Tier 2 policies from the Wales Green +# Manifesto that can be modelled as parameter changes. Applies for 2025/26. +# +# Policies included: +# 1. Abolish council tax (Wales) — replace with LVT (Tier 3, not yet modelled); +# the abolition side is scored here by zeroing the calculated Band D rate. +# Revenue loss is captured in the fiscal impact output. +# 2. Extend free school meals to secondary pupils in Wales (universal, +# wales_secondary_universal: true). Baseline already has universal primary. +# +# Policies NOT yet included (require additional model components): +# - LHA unfreeze: LHA cap not yet modelled (uses reported rent directly) +# - LVT replacement: new tax base — Tier 3 structural reform +# - Bus fare subsidies: no transport cost variable yet +# - Childcare expansion: childcare offer not yet parameterised +# - Rent freeze: rent is exogenous input + +council_tax: + # Abolish council tax in Wales only. Sets wales_average_band_d to zero, which + # scales Welsh households' CT to £0 while leaving England/Scotland unchanged. + # The fiscal cost is the Welsh CT revenue only (~5% of UK total CT). + # Source: Wales Green Manifesto 2026 — "council tax is unfair... we will replace + # it with a land value tax that cuts the average bill" + average_band_d: 2280.0 + wales_average_band_d: 0.0 + scotland_average_band_d: 1528.0 + +free_school_meals: + # Extend universal FSM entitlement to secondary pupils (ages 11–15) in Wales. + # Baseline: universal primary already active (wales_primary_universal: true). + # Source: Wales Green Manifesto 2026 — "extend universal free school meals to + # secondary schools, helping reduce inequality" + wales_primary_universal: true + wales_secondary_universal: true + primary_annual_value: 570.0 + secondary_annual_value: 570.0 diff --git a/src/data/clean.rs b/src/data/clean.rs index 13d1e71..100727c 100644 --- a/src/data/clean.rs +++ b/src/data/clean.rs @@ -34,7 +34,7 @@ fn write_persons(dataset: &Dataset, output_dir: &Path) -> anyhow::Result<()> { "property_income", "maintenance_income", "miscellaneous_income", "other_income", // Employment - "is_in_scotland", "hours_worked_annual", + "is_in_scotland", "is_in_wales", "hours_worked_annual", // Disability rate-band flags "dla_care_low", "dla_care_mid", "dla_care_high", "dla_mob_low", "dla_mob_high", @@ -86,6 +86,7 @@ fn write_persons(dataset: &Dataset, output_dir: &Path) -> anyhow::Result<()> { format!("{:.2}", p.miscellaneous_income), format!("{:.2}", p.other_income), p.is_in_scotland.to_string(), + p.is_in_wales.to_string(), format!("{:.1}", p.hours_worked), p.dla_care_low.to_string(), p.dla_care_mid.to_string(), @@ -340,7 +341,7 @@ fn write_microdata_csv_persons( "property_income", "maintenance_income", "miscellaneous_income", "other_income", // Employment - "is_in_scotland", "hours_worked_annual", + "is_in_scotland", "is_in_wales", "hours_worked_annual", // Status "is_disabled", "is_carer", // Contributions @@ -384,6 +385,7 @@ fn write_microdata_csv_persons( format!("{:.2}", p.miscellaneous_income), format!("{:.2}", p.other_income), p.is_in_scotland.to_string(), + p.is_in_wales.to_string(), format!("{:.1}", p.hours_worked), p.is_disabled.to_string(), p.is_carer.to_string(), @@ -672,12 +674,15 @@ pub fn assemble_dataset( bu.would_claim_jsa = bu.person_ids.iter().any(|&pid| people.get(pid).map_or(false, |p| p.jsa_income > 0.0)); } } - // Auto-derive is_in_scotland from household region + // Auto-derive is_in_scotland / is_in_wales from household region for p in &mut people { if let Some(hh) = households.get(p.household_id) { if hh.region.is_scotland() { p.is_in_scotland = true; } + if hh.region.is_wales() { + p.is_in_wales = true; + } } } Dataset { @@ -838,6 +843,7 @@ pub fn parse_persons_csv(reader: R) -> anyhow::Result bool { + matches!(self, Region::Wales) + } + pub fn from_frs_code(code: i32) -> Self { match code { 1 => Region::NorthEast, diff --git a/src/engine/simulation.rs b/src/engine/simulation.rs index e39ab2c..46b8946 100644 --- a/src/engine/simulation.rs +++ b/src/engine/simulation.rs @@ -45,6 +45,10 @@ pub struct BenUnitResult { pub total_benefits: f64, pub uc_max_amount: f64, pub uc_income_reduction: f64, + /// Calculated free school meals value. When `free_school_meals` params are present, + /// this reflects eligibility-based modelling (e.g. Wales universal extension). + /// Falls back to the FRS-reported amount if no params provided. + pub free_school_meals: f64, } /// Results for a household @@ -262,11 +266,18 @@ impl Simulation { .map(|&pid| self.people[pid].employee_pension_contributions + self.people[pid].personal_pension_contributions) .sum(); - // In-kind benefits included in HBAI net income + // In-kind benefits included in HBAI net income. + // When FSM params are present, use the calculated modelled amount rather + // than the raw FRS-reported figure so that reform scenarios apply correctly. let in_kind_benefits: f64 = hh.benunit_ids.iter() .map(|&bid| { let bu = &self.benunits[bid]; - bu.free_school_meals + bu.free_school_fruit_veg + bu.free_school_milk + let fsm = if self.parameters.free_school_meals.is_some() { + benunit_results[bid].free_school_meals + } else { + bu.free_school_meals + }; + fsm + bu.free_school_fruit_veg + bu.free_school_milk + bu.healthy_start_vouchers + bu.free_tv_licence }) .sum(); @@ -299,10 +310,13 @@ impl Simulation { .map(|&pid| person_results[pid].capital_gains_tax) .sum(); - // Stamp duty (annualised) - let stamp_duty = self.parameters.stamp_duty.as_ref() - .map(|p| variables::wealth_taxes::calculate_stamp_duty(hh, p)) - .unwrap_or(0.0); + // Property transaction tax: LTT for Wales, SDLT for England/NI. + // Routes automatically based on household region. + let stamp_duty = variables::wealth_taxes::calculate_property_transaction_tax( + hh, + self.parameters.stamp_duty.as_ref(), + self.parameters.land_transaction_tax.as_ref(), + ); // Wealth tax let wealth_tax = self.parameters.wealth_tax.as_ref() @@ -314,14 +328,19 @@ impl Simulation { .map(|p| variables::wealth_taxes::calculate_council_tax(hh, p)) .unwrap_or(hh.council_tax); + // Council tax enters total_tax for fiscal accounting, and housing costs + // for AHC. Both use council_tax_calculated so that reform scenarios + // (e.g. abolition or revaluation) flow through correctly. let total_tax = direct_tax + vat + fuel_duty + alcohol_duty + tobacco_duty - + cgt + stamp_duty + wealth_tax; - // HBAI net income: gross minus direct taxes and pension contributions, plus benefits. - // Excludes indirect taxes (VAT, duties) and transaction/wealth taxes (SDLT, wealth tax) - // to match the government HBAI definition used for poverty and distributional analysis. - let net_income = net_income_before_vat; + + cgt + stamp_duty + wealth_tax + council_tax_calculated; + // HBAI net income (BHC): gross minus direct taxes and pension contributions, plus benefits. + // Council tax is a housing cost subtracted in the AHC measure (not BHC), per HBAI convention. + // We adjust BHC net income for the *change* in council tax relative to the FRS-reported + // baseline, so that CT reform scenarios correctly shift households' disposable income. + let ct_adjustment = council_tax_calculated - hh.council_tax; + let net_income = net_income_before_vat - ct_adjustment; // Extended net income: subtracts indirect and wealth taxes for fiscal analysis. - let extended_net_income = net_income_before_vat - vat - stamp_duty - wealth_tax; + let extended_net_income = net_income - vat - stamp_duty - wealth_tax; // Modified OECD equivalisation scale (used by HBAI): // First adult: 0.67, additional adults (14+): 0.33, children (<14): 0.20 @@ -338,8 +357,8 @@ impl Simulation { 0.67 + (adults.saturating_sub(1) as f64) * 0.33 + (children as f64) * 0.20 }; - // AHC: subtract rent and council tax (housing costs), using HBAI net income - let housing_costs = hh.rent + hh.council_tax; + // AHC: subtract rent and council tax (housing costs), using calculated CT. + let housing_costs = hh.rent + council_tax_calculated; let net_income_ahc = net_income - housing_costs; HouseholdResult { diff --git a/src/parameters/mod.rs b/src/parameters/mod.rs index f63eefd..d9d4ad3 100644 --- a/src/parameters/mod.rs +++ b/src/parameters/mod.rs @@ -58,6 +58,26 @@ pub struct Parameters { /// Annual wealth tax (hypothetical — disabled by default). #[serde(default)] pub wealth_tax: Option, + /// Welsh Land Transaction Tax (LTT). Devolved from SDLT since 1 April 2018. + /// Land Transaction Tax and Anti-avoidance of Devolved Taxes (Wales) Act 2017. + /// Applies to Welsh property purchases; mutually exclusive with SDLT. + #[serde(default)] + pub land_transaction_tax: Option, + /// Free school meals parameters (eligibility by nation/age). + #[serde(default)] + pub free_school_meals: Option, + /// OBR fiscal headroom: headroom against the debt rule (£bn). + /// Source: OBR Spring Statement, March 2026 EFO — £9.9bn against the + /// supplementary debt rule (PSND ex falling as a % of GDP in 2029/30). + /// Used to contextualise the fiscal cost of reforms relative to the UK's + /// binding fiscal constraint. UK-wide; not devolved. + #[serde(default)] + pub obr_fiscal_headroom_bn: Option, + /// OBR assumption: each additional £1bn of borrowing reduces fiscal headroom + /// by approximately £1bn (i.e. a 1:1 mapping from spending to headroom erosion + /// absent any dynamic scoring). Used in reform impact summaries. + #[serde(default = "default_headroom_sensitivity")] + pub obr_headroom_sensitivity: f64, /// OBR labour supply response elasticities. /// When enabled, the Slutsky-decomposition elasticities from OBR (2023) are applied /// to estimate intensive-margin labour supply responses to tax-benefit reforms. @@ -66,6 +86,8 @@ pub struct Parameters { pub labour_supply: LabourSupplyParams, } +fn default_headroom_sensitivity() -> f64 { 1.0 } + /// UC managed migration rates by legacy benefit type. /// Fraction of legacy claimants who have been migrated to UC by the modelled year. @@ -384,10 +406,21 @@ pub struct TobaccoDutyParams { /// Local Government Finance Act 1992. Council tax is currently reported from the FRS. /// These parameters allow modelling reforms (e.g. changing the Band D rate) while /// keeping the baseline as the reported amount. +/// +/// Nation-specific Band D rates allow modelling devolved reforms (e.g. Wales abolition). +/// If a nation-specific rate is absent (`None`), the `average_band_d` is used as fallback. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CouncilTaxParams { - /// Average Band D rate (£/year). England average £2,280 for 2025/26. + /// Average Band D rate (£/year). England/default rate. pub average_band_d: f64, + /// Wales-specific average Band D rate (£/year). + /// 2025/26 Wales average: ~£1,955 (DLUHC Council Tax Statistics 2025). + /// Set to 0.0 in a reform to abolish council tax in Wales only. + #[serde(default)] + pub wales_average_band_d: Option, + /// Scotland-specific average Band D rate (£/year). + #[serde(default)] + pub scotland_average_band_d: Option, /// Band multipliers as fractions of Band D: A=6/9, B=7/9, ... H=18/9. #[serde(default = "default_band_multipliers")] pub band_multipliers: Vec, @@ -564,6 +597,58 @@ impl Default for LabourSupplyParams { } } +/// Welsh Land Transaction Tax (LTT) parameters. +/// +/// Devolved from SDLT to Wales from 1 April 2018 under the Land Transaction Tax +/// and Anti-avoidance of Devolved Taxes (Wales) Act 2017 (ANAW 2017/1). +/// Rates and bands set by Welsh Government; different from English SDLT. +/// Applies only to Welsh properties; English/Scottish SDLT/LBTT apply elsewhere. +/// +/// 2025/26 residential rates (Welsh Revenue Authority, April 2025): +/// 0% £0–£225,000 +/// 6% £225,001–£400,000 +/// 7.5% £400,001–£750,000 +/// 10% £750,001–£1,500,000 +/// 12% over £1,500,000 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LandTransactionTaxParams { + /// Marginal rate bands (sorted ascending by threshold). + pub bands: Vec, + /// Annual purchase probability — same as SDLT (1 / average holding period). + #[serde(default = "default_purchase_probability")] + pub annual_purchase_probability: f64, +} + +/// Free school meals eligibility parameters (by nation). +/// +/// England: means-tested (UC threshold or legacy benefits). Scotland: universal P1–P5 +/// (expanding). Wales: universal primary (reception–year 6) since January 2024; +/// means-tested secondary (years 7–11). Northern Ireland: means-tested. +/// +/// Source: Wales — Welsh Government FSM guidance (January 2024 rollout); +/// https://www.gov.wales/universal-primary-free-school-meals +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FreeSchoolMealsParams { + /// Estimated annual value per primary pupil (meals × 190 days × cost/meal). + /// Approx £3.00/meal × 190 days = £570/year. + #[serde(default = "default_fsm_primary_annual")] + pub primary_annual_value: f64, + /// Estimated annual value per secondary pupil. + #[serde(default = "default_fsm_secondary_annual")] + pub secondary_annual_value: f64, + /// Wales: universal FSM for primary-age children (ages 4–11) regardless of income. + /// Active since January 2024. Source: Welsh Government. + #[serde(default)] + pub wales_primary_universal: bool, + /// Wales: extend universal FSM to secondary-age children (ages 11–16). + /// This is the Green Party manifesto proposal — not current policy. + #[serde(default)] + pub wales_secondary_universal: bool, +} + +fn default_fsm_primary_annual() -> f64 { 570.0 } // £3.00 × 190 days +fn default_fsm_secondary_annual() -> f64 { 570.0 } // same rate assumption + /// Annual wealth tax parameters (hypothetical — disabled by default). /// /// No current UK wealth tax exists. These parameters support modelling diff --git a/src/variables/benefits.rs b/src/variables/benefits.rs index 127649d..fd3faac 100644 --- a/src/variables/benefits.rs +++ b/src/variables/benefits.rs @@ -114,6 +114,8 @@ pub fn calculate_benunit( let modelled_benefits = (pre_cap_benefits - benefit_cap_reduction).max(0.0); let total_benefits = modelled_benefits + passthrough_benefits; + let free_school_meals = calculate_free_school_meals(bu, people, household, params); + BenUnitResult { universal_credit: uc.0, child_benefit, @@ -132,6 +134,7 @@ pub fn calculate_benunit( total_benefits, uc_max_amount: uc.1, uc_income_reduction: uc.2, + free_school_meals, } } @@ -867,6 +870,63 @@ fn calculate_carers_allowance( /// Scottish Child Payment: £26.70/week per eligible child under 16. /// Only available in Scotland to UC/legacy benefit claimants. +/// Calculate free school meals value for a benefit unit. +/// +/// When `free_school_meals` parameters are present, eligibility is calculated from +/// children's ages and the region-specific rules. When absent, falls back to zero +/// (the simulation uses the FRS-reported `bu.free_school_meals` in that case). +/// +/// Age ranges: primary = 4–10 (reception to year 6); secondary = 11–15 (years 7–11). +/// +/// Current policy (Wales): universal primary since January 2024; means-tested secondary. +/// Green proposal: extend universal entitlement to secondary age also. +fn calculate_free_school_meals( + bu: &BenUnit, + people: &[Person], + household: &Household, + params: &Parameters, +) -> f64 { + let fsm_params = match ¶ms.free_school_meals { + Some(p) => p, + None => return bu.free_school_meals, // passthrough FRS amount + }; + + let is_wales = household.region.is_wales(); + + let primary_value: f64 = bu.person_ids.iter() + .filter(|&&pid| { + let age = people[pid].age; + age >= 4.0 && age < 11.0 // reception–year 6 + }) + .map(|_| { + if is_wales && fsm_params.wales_primary_universal { + fsm_params.primary_annual_value + } else { + 0.0 // means-tested — use FRS reported, do not model take-up + } + }) + .sum(); + + let secondary_value: f64 = bu.person_ids.iter() + .filter(|&&pid| { + let age = people[pid].age; + age >= 11.0 && age < 16.0 // years 7–11 + }) + .map(|_| { + if is_wales && fsm_params.wales_secondary_universal { + fsm_params.secondary_annual_value + } else { + 0.0 // not universal — use FRS reported + } + }) + .sum(); + + // For any children covered by the modelled universal rules, use the + // calculated amount. For others, fall back to proportional FRS reported. + let modelled = primary_value + secondary_value; + if modelled > 0.0 { modelled } else { bu.free_school_meals } +} + fn calculate_scottish_child_payment( bu: &BenUnit, people: &[Person], diff --git a/src/variables/wealth_taxes.rs b/src/variables/wealth_taxes.rs index 8203c3e..bf45d4b 100644 --- a/src/variables/wealth_taxes.rs +++ b/src/variables/wealth_taxes.rs @@ -1,5 +1,8 @@ use crate::engine::entities::{Household, Person}; -use crate::parameters::{CouncilTaxParams, CapitalGainsTaxParams, StampDutyParams, WealthTaxParams}; +use crate::parameters::{ + CouncilTaxParams, CapitalGainsTaxParams, LandTransactionTaxParams, + StampDutyParams, WealthTaxParams, +}; /// Determine the council tax band (0=A .. 7=H) from a 1991 property value. /// @@ -15,15 +18,35 @@ pub fn council_tax_band(property_value: f64, thresholds: &[f64]) -> usize { 0 } -/// Calculate council tax from parameters (for reform modelling). +/// Calculate council tax for a household under a reform scenario. /// -/// Returns the Band D rate multiplied by the band multiplier for this household's -/// property value. For baseline runs, the simulation uses the reported `hh.council_tax` -/// instead. +/// Uses the nation-specific Band D override if present for the household's region, +/// scaling proportionally from the reported FRS amount (so we get the right +/// distributional spread without relying on inaccurate 1991 band thresholds). +/// Falls back to the reported `hh.council_tax` if no applicable override is set. +/// +/// This means: setting `wales_average_band_d: 0.0` abolishes council tax only for +/// Welsh households; setting `average_band_d` to a new value rescales all others. pub fn calculate_council_tax(hh: &Household, params: &CouncilTaxParams) -> f64 { - let band = council_tax_band(hh.main_residence_value, ¶ms.band_thresholds); - let multiplier = params.band_multipliers.get(band).copied().unwrap_or(1.0); - params.average_band_d * multiplier + // Pick the relevant Band D rate for this household's nation. + let nation_band_d: Option = if hh.region.is_wales() { + params.wales_average_band_d + } else if hh.region.is_scotland() { + params.scotland_average_band_d + } else { + None + }; + + let band_d = nation_band_d.unwrap_or(params.average_band_d); + + // Scale the reported FRS council tax by (reform_band_d / baseline_band_d). + // This preserves the within-nation distributional spread while applying the + // reform rate, rather than recalculating from inaccurate 1991 property bands. + if params.average_band_d <= 0.0 || hh.council_tax <= 0.0 { + // No baseline rate to scale from — use reported amount directly. + return hh.council_tax; + } + hh.council_tax * (band_d / params.average_band_d) } /// Calculate capital gains tax for a person. @@ -73,7 +96,7 @@ fn marginal_sdlt(property_value: f64, bands: &[crate::parameters::StampDutyBand] tax } -/// Calculate annualised stamp duty for a household. +/// Calculate annualised stamp duty (SDLT) for an English/NI household. /// /// Multiplies the one-off SDLT liability by the annual purchase probability /// (1 / average holding period) to get an expected annual amount. @@ -82,6 +105,37 @@ pub fn calculate_stamp_duty(hh: &Household, params: &StampDutyParams) -> f64 { sdlt * params.annual_purchase_probability } +/// Calculate annualised Land Transaction Tax (LTT) for a Welsh household. +/// +/// LTT replaced SDLT in Wales from 1 April 2018. Uses the same marginal-rate +/// calculation as SDLT but with Welsh Government bands and rates. +/// Source: Land Transaction Tax and Anti-avoidance of Devolved Taxes (Wales) Act 2017. +pub fn calculate_land_transaction_tax(hh: &Household, params: &LandTransactionTaxParams) -> f64 { + let ltt = marginal_sdlt(hh.main_residence_value, ¶ms.bands); + ltt * params.annual_purchase_probability +} + +/// Calculate the appropriate property transaction tax for a household, +/// routing to LTT (Wales) or SDLT (England/NI) based on region. +/// +/// Scottish LBTT is also distinct; not yet modelled separately — falls through +/// to SDLT as a conservative approximation until LBTT params are added. +pub fn calculate_property_transaction_tax( + hh: &Household, + sdlt_params: Option<&StampDutyParams>, + ltt_params: Option<&LandTransactionTaxParams>, +) -> f64 { + if hh.region.is_wales() { + if let Some(ltt) = ltt_params { + return calculate_land_transaction_tax(hh, ltt); + } + } + if let Some(sdlt) = sdlt_params { + return calculate_stamp_duty(hh, sdlt); + } + 0.0 +} + /// Calculate annual wealth tax for a household. /// /// Hypothetical flat-rate tax on net wealth above a threshold. @@ -114,15 +168,34 @@ mod tests { #[test] fn council_tax_calculation() { + // With no nation-specific override, the reported CT is preserved 1:1 + // (reform rate = baseline rate → scaling factor = 1.0). let params = CouncilTaxParams { average_band_d: 2280.0, + wales_average_band_d: None, + scotland_average_band_d: None, band_multipliers: vec![6.0/9.0, 7.0/9.0, 8.0/9.0, 1.0, 11.0/9.0, 13.0/9.0, 15.0/9.0, 18.0/9.0], band_thresholds: vec![0.0, 40001.0, 52001.0, 68001.0, 88001.0, 120001.0, 160001.0, 320001.0], }; let mut hh = Household::default(); - hh.main_residence_value = 80000.0; // Band D + hh.council_tax = 2280.0; let ct = calculate_council_tax(&hh, ¶ms); - assert!((ct - 2280.0).abs() < 1.0); // Band D = 1.0 * band_d + assert!((ct - 2280.0).abs() < 1.0); // no change when reform rate = baseline + + // Wales abolition: wales_average_band_d = 0 → Welsh CT goes to zero + let params_wales_zero = CouncilTaxParams { + wales_average_band_d: Some(0.0), + ..params.clone() + }; + let mut hh_wales = Household::default(); + hh_wales.region = crate::engine::entities::Region::Wales; + hh_wales.council_tax = 1955.0; + let ct_wales = calculate_council_tax(&hh_wales, ¶ms_wales_zero); + assert_eq!(ct_wales, 0.0); // abolished + + // England unaffected + let ct_england = calculate_council_tax(&hh, ¶ms_wales_zero); + assert!((ct_england - 2280.0).abs() < 1.0); } #[test] diff --git a/tests/parameter_impact.rs b/tests/parameter_impact.rs index 672e4ef..1c625e0 100644 --- a/tests/parameter_impact.rs +++ b/tests/parameter_impact.rs @@ -202,6 +202,16 @@ const SKIP_PARAMS: &[&str] = &[ // These configure how employment income adjusts to policy changes; they have // no effect on a static run, by design. "labour_supply", + // LTT: Welsh property transaction tax. Requires Welsh households with non-zero + // property values; like SDLT it has no effect on a plain FRS net income run. + "land_transaction_tax", + // FSM secondary_annual_value: only activates when wales_secondary_universal=true. + // In the baseline (wales_secondary_universal=false) the value is unreachable. + "free_school_meals.secondary_annual_value", + // OBR fiscal headroom: metadata parameters for reform impact summaries. + // Not used in the microsimulation calculation — they contextualise fiscal costs. + "obr_fiscal_headroom_bn", + "obr_headroom_sensitivity", ]; fn is_array_element(path: &str) -> bool { From b7b355661878d35fd863cfb0412844f5f44c2575 Mon Sep 17 00:00:00 2001 From: "nikhil@policyengine.org" Date: Wed, 8 Apr 2026 15:33:12 +0100 Subject: [PATCH 2/3] feat: implement LHA bedroom entitlement and cap for HB/UC housing element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds proper Local Housing Allowance cap logic for private renters on HB and UC housing element. The model previously used full reported rent as eligible rent for all tenures, overstating HB/UC for private renters above their LHA cap. Changes: - LhaParams struct with region×bedroom-category monthly rate table (12 regions × 5 categories A–E), private_rent_index multiplier for reform scenarios, and enabled flag - lha_bedroom_entitlement() implements UC Regs 2013 Sch.4 / HB Regs 2006 Sch.B1: same-sex children over 10 share first, remainder share in pairs - lha_monthly_cap() applies cap only to TenureType::RentPrivately - Both calculate_housing_benefit() and calculate_universal_credit() now apply the LHA cap to eligible rent for private renters - 2025/26 rates from VOA list of rents (2020 data, uprated to April 2024 using ONS IPHRP index, frozen at that level for 2025/26 re-freeze) - 6 new unit tests covering bedroom entitlement and cap behaviour EFRS calibration needs to be rerun to recalibrate weights against the updated simulated HB/UC values: python scripts/rebuild_all.py --only efrs Co-Authored-By: Claude Opus 4.6 --- LEGISLATIVE_REFERENCE.md | 17 ++- parameters/2025_26.yaml | 26 +++++ src/engine/entities.rs | 20 ++++ src/parameters/mod.rs | 67 +++++++++++ src/variables/benefits.rs | 229 +++++++++++++++++++++++++++++++++++++- tests/parameter_impact.rs | 5 + 6 files changed, 358 insertions(+), 6 deletions(-) diff --git a/LEGISLATIVE_REFERENCE.md b/LEGISLATIVE_REFERENCE.md index 193e7fd..b7a033c 100644 --- a/LEGISLATIVE_REFERENCE.md +++ b/LEGISLATIVE_REFERENCE.md @@ -415,7 +415,22 @@ yet migrated (governed by `uc_migration.housing_benefit` rates). Source: SI 2006/213 reg.70 ([`uksi/2006/213/regulation/70`](https://www.legislation.gov.uk/uksi/2006/213/regulation/70)) Maximum HB = 100% of eligible rent (subject to LHA caps for private renters). The model does -not compute LHA caps; it uses reported rent directly. +not compute LHA caps; it uses reported rent directly as eligible rent for all tenures. + +LHA cap is implemented in `src/variables/benefits.rs` via `lha_monthly_cap()`. When `params.lha` +is present and enabled, eligible rent for private renters (TenureType::RentPrivately) is capped +at the regional LHA rate for the household's bedroom entitlement, computed by +`lha_bedroom_entitlement()` (implements UC Regs 2013 Sch.4 / HB Regs 2006 Sch.B1). + +The LHA rates are stored as region×category monthly amounts in `params.lha.rates_monthly`. +Since the FRS suppresses BRMA identifiers for disclosure reasons, the model uses region-level +30th percentile rates (derived from the VOA list of rents via policyengine-uk, uprated using the +ONS Index of Private Housing Rental Prices). This understates within-region variation but +captures the main regional gradient. The 2025/26 baseline uses rates frozen at the 2024/25 +reset level (April 2024 reset to 30th percentile, re-frozen April 2025). + +Reform scenarios can vary `params.lha.private_rent_index` (multiplicative uprating of all rates, +e.g. 1.10 = 10% increase) without changing the underlying rate table. ### 7.2 Applicable Amount diff --git a/parameters/2025_26.yaml b/parameters/2025_26.yaml index ff44a89..e5e74fe 100644 --- a/parameters/2025_26.yaml +++ b/parameters/2025_26.yaml @@ -303,6 +303,32 @@ wealth_tax: # in the fifth year of the forecast (2029/30). At Spring Statement 2026: £9.9bn. # Each £1bn of net new borrowing reduces this headroom by approximately £1bn # (obr_headroom_sensitivity = 1.0 is the OBR's central assumption absent dynamic scoring). +lha: + # Local Housing Allowance rates for 2025/26. + # LHA was re-frozen in April 2025 at the 2024/25 reset rates. + # 2024/25 rates: 30th percentile of private rents in each BRMA, reset from freeze. + # Source: VOA list of rents (2019–2020 data), uprated to April 2024 using ONS Index of + # Private Housing Rental Prices (index: April 2020 = 109.0, April 2024 ≈ 125.0; + # uprate factor = 1.1468). Aggregated to GOR (region) as FRS suppresses BRMA identifiers. + # Categories: [A=shared, B=1-bed, C=2-bed, D=3-bed, E=4+bed] — monthly amounts (£). + # Row order: NE, NW, Yorkshire, E.Midlands, W.Midlands, E.ofEngland, London, + # S.East, S.West, Wales, Scotland, N.Ireland. + enabled: true + private_rent_index: 1.0 # 1.0 = frozen (no uprating). Reform: set >1.0 to unfreeze. + rates_monthly: + - [323.01, 428.86, 486.06, 566.12, 766.23] # North East + - [332.95, 457.44, 566.12, 657.60, 860.11] # North West + - [323.01, 451.72, 543.26, 600.41, 857.72] # Yorkshire + - [372.71, 486.06, 600.41, 686.18, 914.92] # East Midlands + - [390.10, 514.63, 629.03, 743.38, 972.12] # West Midlands + - [360.23, 629.03, 771.95, 886.35, 1252.29] # East of England + - [580.97, 1200.81, 1469.61, 1735.40, 2172.93] # London + - [392.58, 737.66, 914.92, 1086.46, 1543.90] # South East + - [390.10, 571.83, 737.66, 874.87, 1143.66] # South West + - [299.41, 419.92, 514.63, 566.12, 686.18] # Wales + - [323.01, 468.91, 543.26, 629.03, 886.35] # Scotland + - [273.42, 361.97, 428.86, 514.63, 566.12] # Northern Ireland + obr_fiscal_headroom_bn: 9.9 obr_headroom_sensitivity: 1.0 diff --git a/src/engine/entities.rs b/src/engine/entities.rs index 0dc8e74..9391fb6 100644 --- a/src/engine/entities.rs +++ b/src/engine/entities.rs @@ -566,6 +566,26 @@ impl Region { } } + /// LHA region index for rate table lookup (0–11, matching rates_monthly row order). + /// Order: NE=0, NW=1, Yorks=2, EM=3, WM=4, EofE=5, London=6, SE=7, SW=8, + /// Wales=9, Scotland=10, NI=11. + pub fn to_lha_region_idx(&self) -> usize { + match self { + Region::NorthEast => 0, + Region::NorthWest => 1, + Region::Yorkshire => 2, + Region::EastMidlands => 3, + Region::WestMidlands => 4, + Region::EastOfEngland => 5, + Region::London => 6, + Region::SouthEast => 7, + Region::SouthWest => 8, + Region::Wales => 9, + Region::Scotland => 10, + Region::NorthernIreland => 11, + } + } + pub fn name(&self) -> &'static str { match self { Region::NorthEast => "North East", diff --git a/src/parameters/mod.rs b/src/parameters/mod.rs index d9d4ad3..5607057 100644 --- a/src/parameters/mod.rs +++ b/src/parameters/mod.rs @@ -66,6 +66,11 @@ pub struct Parameters { /// Free school meals parameters (eligibility by nation/age). #[serde(default)] pub free_school_meals: Option, + /// Local Housing Allowance cap parameters. + /// When present, caps eligible rent for private renters at the regional LHA rate + /// for their bedroom entitlement category. Authority: HB Regs 2006 reg.13D. + #[serde(default)] + pub lha: Option, /// OBR fiscal headroom: headroom against the debt rule (£bn). /// Source: OBR Spring Statement, March 2026 EFO — £9.9bn against the /// supplementary debt rule (PSND ex falling as a % of GDP in 2029/30). @@ -649,6 +654,68 @@ pub struct FreeSchoolMealsParams { fn default_fsm_primary_annual() -> f64 { 570.0 } // £3.00 × 190 days fn default_fsm_secondary_annual() -> f64 { 570.0 } // same rate assumption +/// Local Housing Allowance (LHA) parameters. +/// +/// LHA caps the eligible rent for private renters on HB/UC at the 30th percentile of +/// local rents in each Broad Rental Market Area (BRMA), by bedroom entitlement category +/// (A = shared, B = 1-bed, C = 2-bed, D = 3-bed, E = 4+-bed). +/// +/// Since the FRS suppresses BRMA identifiers for disclosure control, we use region-level +/// 30th percentile rates derived from the VOA rent data (same underlying source as BRMA +/// rates, aggregated to GOR). This understates within-region variation but captures the +/// main regional gradient. +/// +/// Rate history: +/// - Frozen: April 2020 – March 2024 (SI 2019/1303) +/// - Reset to 30th percentile: April 2024 (gov.uk announcement, 28 Nov 2023) +/// - Re-frozen: April 2025 (OBR EFO March 2025 assumption) +/// +/// Uprating: `private_rent_index` uprates the baseline rates for reform scenarios; +/// e.g. setting `private_rent_index: 1.10` models a 10% LHA increase. +/// For the baseline (frozen), this field is 1.0. +/// +/// Source: VOA list of rents (via policyengine-uk), uprated using ONS Index of Private +/// Housing Rental Prices (base: April 2020 = 100, April 2024 ≈ 114.7). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LhaParams { + /// Whether LHA cap is active. If false, eligible rent = actual rent (pre-reform default). + #[serde(default = "default_true_lha")] + pub enabled: bool, + /// Multiplier applied to all rates for reform scenarios (e.g. 1.1 = 10% increase). + /// For baseline frozen scenario this is 1.0. + #[serde(default = "default_one")] + pub private_rent_index: f64, + /// LHA rates by region (11 regions matching Region enum) then by bedroom category + /// (index 0=shared/A, 1=1-bed/B, 2=2-bed/C, 3=3-bed/D, 4=4+bed/E). + /// Values are monthly amounts (£). + /// Order: NorthEast, NorthWest, Yorkshire, EastMidlands, WestMidlands, + /// EastOfEngland, London, SouthEast, SouthWest, Wales, Scotland, NorthernIreland. + pub rates_monthly: Vec<[f64; 5]>, +} + +fn default_true_lha() -> bool { true } +fn default_one() -> f64 { 1.0 } + +impl LhaParams { + /// Return the monthly LHA cap (£) for a given region and bedroom entitlement. + /// + /// `region_idx` maps to Region::to_rf_code() (0=NE, 1=NW, 2=Yorks, 3=EM, 4=WM, + /// 5=EofE, 6=London, 7=SE, 8=SW, 9=Wales/NI, 10=Scotland, 11=NI). + /// `bedrooms` is the LHA bedroom entitlement (1–4+), or 0 for shared accommodation. + /// Returns `None` if rates_monthly is empty or region_idx out of range. + pub fn monthly_cap(&self, region_idx: usize, bedrooms: u32) -> Option { + let row = self.rates_monthly.get(region_idx)?; + let col = match bedrooms { + 0 => 0, // shared accommodation (Category A) + 1 => 1, + 2 => 2, + 3 => 3, + _ => 4, // 4+ bedrooms → Category E + }; + Some(row[col] * self.private_rent_index) + } +} + /// Annual wealth tax parameters (hypothetical — disabled by default). /// /// No current UK wealth tax exists. These parameters support modelling diff --git a/src/variables/benefits.rs b/src/variables/benefits.rs index fd3faac..adcf13b 100644 --- a/src/variables/benefits.rs +++ b/src/variables/benefits.rs @@ -42,7 +42,7 @@ pub fn calculate_benunit( let (uc, pension_credit, housing_benefit, ctc, wtc, income_support, esa_ir, jsa_ib, scp); if on_uc_system { let would_claim = bu.would_claim_uc || migrated_hb || migrated_tc || migrated_is; - let raw_uc = calculate_universal_credit(bu, people, person_results, params); + let raw_uc = calculate_universal_credit(bu, people, person_results, household, params); uc = if would_claim { raw_uc } else { (0.0, raw_uc.1, raw_uc.2) }; pension_credit = calculate_pension_credit(bu, people, params); housing_benefit = 0.0; @@ -56,7 +56,7 @@ pub fn calculate_benunit( // Not yet migrated: still on legacy system uc = (0.0, 0.0, 0.0); pension_credit = calculate_pension_credit(bu, people, params); - let raw_hb = calculate_housing_benefit(bu, people, person_results, params); + let raw_hb = calculate_housing_benefit(bu, people, person_results, household, params); housing_benefit = if raw_hb > 0.0 && bu.would_claim_hb { raw_hb } else { 0.0 }; let tc = calculate_tax_credits(bu, people, person_results, params); ctc = if tc.0 > 0.0 && bu.would_claim_ctc { tc.0 } else { 0.0 }; @@ -171,6 +171,7 @@ fn calculate_universal_credit( bu: &BenUnit, people: &[Person], person_results: &[PersonResult], + household: &Household, params: &Parameters, ) -> (f64, f64, f64) { // Basic eligibility: at least one working-age adult (not SP age) @@ -237,8 +238,16 @@ fn calculate_universal_credit( .any(|&pid| people[pid].is_carer); let carer_monthly = if has_carer { uc.carer_element } else { 0.0 }; - // Housing element - let housing_element_monthly = bu.rent_monthly; + // Housing element — UC Regs 2013 reg.25/Sch.4. + // For private renters, capped at the LHA rate for the household's region and bedroom + // entitlement (30th percentile of local rents; SI 2010/2591 / HB Regs 2006 reg.13D). + // Social renters are not subject to LHA — the bedroom tax (reg.B13) applies separately + // but is not modelled here. + let housing_element_monthly = if let Some(cap) = lha_monthly_cap(bu, people, household, params) { + bu.rent_monthly.min(cap) + } else { + bu.rent_monthly + }; let max_amount_monthly = standard_allowance_monthly + child_element_monthly @@ -415,6 +424,88 @@ fn calculate_pension_credit(bu: &BenUnit, people: &[Person], params: &Parameters amount } +/// Calculate LHA bedroom entitlement for a benefit unit. +/// +/// Implements UC Regs 2013 Sch.4 / HB Regs 2006 Sch.B1. +/// Rules: +/// - 1 room for the benefit unit adults (single or couple) +/// - 1 room per non-dependant over 16 living in the same household but outside the benunit +/// - Children under 16 must share unless same-gender sharing is impossible: +/// * Children 10–15 share in same-gender pairs first +/// * Spaces left by an odd-numbered gender group can be filled by under-10s +/// * Remaining under-10s share in mixed pairs +/// +/// Returns bedroom entitlement (1–4+; 0 = shared accommodation for single under threshold). +/// The shared accommodation rate (0) is not applied here — callers handle it separately. +pub fn lha_bedroom_entitlement(bu: &BenUnit, people: &[Person], household: &Household) -> u32 { + // Non-dependants: household members aged 16+ not in this benefit unit + let non_dependants = household.person_ids.iter() + .filter(|&&pid| { + people[pid].age >= 16.0 && people[pid].benunit_id != bu.id + }) + .count() as u32; + + // Children: under-16s in this benefit unit + let boys_over_10: u32 = bu.person_ids.iter() + .filter(|&&pid| { + let p = &people[pid]; + p.age >= 10.0 && p.age < 16.0 && p.gender == Gender::Male + }) + .count() as u32; + let girls_over_10: u32 = bu.person_ids.iter() + .filter(|&&pid| { + let p = &people[pid]; + p.age >= 10.0 && p.age < 16.0 && p.gender == Gender::Female + }) + .count() as u32; + let boys_under_10: u32 = bu.person_ids.iter() + .filter(|&&pid| { + let p = &people[pid]; + p.age < 10.0 && p.gender == Gender::Male + }) + .count() as u32; + let girls_under_10: u32 = bu.person_ids.iter() + .filter(|&&pid| { + let p = &people[pid]; + p.age < 10.0 && p.gender == Gender::Female + }) + .count() as u32; + + // Over-10s share in same-gender pairs + let over_10_rooms = (boys_over_10 + 1) / 2 + (girls_over_10 + 1) / 2; + + // Spaces available in over-10 rooms for under-10s of the same gender + let space_for_boy_under_10 = boys_over_10 % 2; + let space_for_girl_under_10 = girls_over_10 % 2; + + let leftover_boys = boys_under_10.saturating_sub(space_for_boy_under_10); + let leftover_girls = girls_under_10.saturating_sub(space_for_girl_under_10); + + // Remaining under-10s share in pairs (mixed is allowed for under-10s) + let under_10_rooms = (leftover_boys + leftover_girls + 1) / 2; + + let bedrooms = 1 + non_dependants + over_10_rooms + under_10_rooms; + bedrooms.min(4) // Cap at 4 (Category E covers 4+) +} + +/// Return the monthly LHA cap for a benefit unit, or None if LHA doesn't apply. +/// +/// LHA applies only to private renters (TenureType::RentPrivately). +/// Social renters (council / HA) and owner-occupiers are not subject to LHA caps. +fn lha_monthly_cap( + bu: &BenUnit, + people: &[Person], + household: &Household, + params: &Parameters, +) -> Option { + let lha = params.lha.as_ref()?; + if !lha.enabled { return None; } + if household.tenure_type != TenureType::RentPrivately { return None; } + let bedrooms = lha_bedroom_entitlement(bu, people, household); + let region_idx = household.region.to_lha_region_idx(); + lha.monthly_cap(region_idx, bedrooms) +} + /// Housing Benefit (legacy system). /// /// HB = max(0, eligible_rent - max(0, (income - applicable_amount) * 65%)) @@ -424,6 +515,7 @@ fn calculate_housing_benefit( bu: &BenUnit, people: &[Person], _person_results: &[PersonResult], + household: &Household, params: &Parameters, ) -> f64 { let hb_params = match ¶ms.housing_benefit { @@ -431,7 +523,15 @@ fn calculate_housing_benefit( None => return 0.0, }; - let eligible_rent = bu.rent_monthly * 12.0; + // For private renters, eligible rent is capped at the LHA rate for the household's + // region and bedroom entitlement (HB Regs 2006 reg.13D; 30th percentile from SI 2010/2591). + // For social renters and owner-occupiers, full rent is used (no LHA cap). + let rent_monthly_capped = if let Some(cap) = lha_monthly_cap(bu, people, household, params) { + bu.rent_monthly.min(cap) + } else { + bu.rent_monthly + }; + let eligible_rent = rent_monthly_capped * 12.0; if eligible_rent <= 0.0 { return 0.0; } @@ -1971,4 +2071,123 @@ mod parameter_impact_tests { let reformed = calc(¶ms, &[p], &bu, &hh); assert!(reformed.universal_credit > 0.0, "IS claimant past migration threshold should switch to UC"); } + + // ── LHA bedroom entitlement tests ──────────────────────────────────────── + + #[test] + fn lha_bedroom_single_adult() { + // Single adult, no children → 1 bedroom (Category B) + let p = Person::default(); + let bu = BenUnit { id: 0, household_id: 0, person_ids: vec![0], ..BenUnit::default() }; + let hh = Household { id: 0, benunit_ids: vec![0], person_ids: vec![0], ..Household::default() }; + assert_eq!(lha_bedroom_entitlement(&bu, &[p], &hh), 1); + } + + #[test] + fn lha_bedroom_couple_no_children() { + // Couple, no children → 1 bedroom + let mut p1 = Person::default(); p1.id = 0; p1.age = 30.0; + let mut p2 = Person::default(); p2.id = 1; p2.age = 28.0; + let bu = BenUnit { id: 0, household_id: 0, person_ids: vec![0, 1], ..BenUnit::default() }; + let hh = Household { id: 0, benunit_ids: vec![0], person_ids: vec![0, 1], ..Household::default() }; + assert_eq!(lha_bedroom_entitlement(&bu, &[p1, p2], &hh), 1); + } + + #[test] + fn lha_bedroom_two_same_sex_children_under_10() { + // Single adult + 2 boys under 10 → 1 (adults) + 1 (2 boys share) = 2 bedrooms + let mut p = Person::default(); p.id = 0; p.age = 30.0; + let mut c1 = Person::default(); c1.id = 1; c1.age = 7.0; c1.gender = Gender::Male; + let mut c2 = Person::default(); c2.id = 2; c2.age = 5.0; c2.gender = Gender::Male; + let bu = BenUnit { id: 0, household_id: 0, person_ids: vec![0, 1, 2], ..BenUnit::default() }; + let hh = Household { id: 0, benunit_ids: vec![0], person_ids: vec![0, 1, 2], ..Household::default() }; + assert_eq!(lha_bedroom_entitlement(&bu, &[p, c1, c2], &hh), 2); + } + + #[test] + fn lha_bedroom_boy_over_10_and_girl_over_10() { + // Single adult + boy 12 + girl 13 → can't share (opposite sex, both over 10) + // → 1 (adult) + 1 (boy) + 1 (girl) = 3 bedrooms + let mut p = Person::default(); p.id = 0; p.age = 35.0; + let mut c1 = Person::default(); c1.id = 1; c1.age = 12.0; c1.gender = Gender::Male; + let mut c2 = Person::default(); c2.id = 2; c2.age = 13.0; c2.gender = Gender::Female; + let bu = BenUnit { id: 0, household_id: 0, person_ids: vec![0, 1, 2], ..BenUnit::default() }; + let hh = Household { id: 0, benunit_ids: vec![0], person_ids: vec![0, 1, 2], ..Household::default() }; + assert_eq!(lha_bedroom_entitlement(&bu, &[p, c1, c2], &hh), 3); + } + + #[test] + fn lha_cap_applied_for_private_renter() { + // Private renter with rent above LHA cap should have UC housing element capped. + let mut params = Parameters::for_year(2025).unwrap(); + let mut p = Person::default(); p.age = 30.0; p.employment_income = 0.0; + + // London 1-bed LHA cap = £1,200.81/month. Set rent to £2,000/month. + let bu = BenUnit { + id: 0, household_id: 0, person_ids: vec![0], + migration_seed: 0.0, on_uc: true, + rent_monthly: 2000.0, would_claim_uc: true, + ..BenUnit::default() + }; + let hh = Household { + id: 0, benunit_ids: vec![0], person_ids: vec![0], + weight: 1.0, region: Region::London, + tenure_type: TenureType::RentPrivately, + rent: 24000.0, council_tax: 0.0, + ..Household::default() + }; + let pr: Vec = vec![crate::variables::income_tax::calculate(&p, ¶ms, 0.0)]; + let result = calculate_benunit(&bu, &[p.clone()], &pr, &hh, ¶ms, 0.0, 2025); + + // UC housing element should be capped at 1-bed London LHA rate (£1,200.81/month) + // uc_max_amount includes all elements; housing element monthly = 1200.81, annual = 14409.72 + // Full rent would give housing element of 2000*12 = 24000. Check it's below that. + assert!( + result.uc_max_amount < 2000.0 * 12.0 + 6000.0, // less than full rent + standard allowance + "UC max amount should be capped by LHA, not at full rent: {}", + result.uc_max_amount + ); + + // Without LHA (social housing tenure), full rent used + let hh_social = Household { + tenure_type: TenureType::RentFromCouncil, + ..hh.clone() + }; + let result_social = calculate_benunit(&bu, &[p], &pr, &hh_social, ¶ms, 0.0, 2025); + assert!( + result_social.uc_max_amount > result.uc_max_amount, + "Social renter should get higher UC housing element (no LHA cap) vs private renter above cap" + ); + } + + #[test] + fn lha_hb_capped_for_private_renter() { + // HB legacy: private renter with rent above LHA should be capped. + let params = Parameters::for_year(2025).unwrap(); + let mut p = Person::default(); p.age = 35.0; p.employment_income = 0.0; + + let bu = BenUnit { + id: 0, household_id: 0, person_ids: vec![0], + migration_seed: 0.99, on_legacy: true, + rent_monthly: 2500.0, would_claim_hb: true, + ..BenUnit::default() + }; + let hh_private = Household { + id: 0, benunit_ids: vec![0], person_ids: vec![0], + weight: 1.0, region: Region::London, + tenure_type: TenureType::RentPrivately, + rent: 30000.0, council_tax: 0.0, + ..Household::default() + }; + let hh_social = Household { tenure_type: TenureType::RentFromCouncil, ..hh_private.clone() }; + + let pr: Vec = vec![crate::variables::income_tax::calculate(&p, ¶ms, 0.0)]; + let hb_private = calculate_benunit(&bu, &[p.clone()], &pr, &hh_private, ¶ms, 0.0, 2025).housing_benefit; + let hb_social = calculate_benunit(&bu, &[p], &pr, &hh_social, ¶ms, 0.0, 2025).housing_benefit; + + assert!(hb_private > 0.0, "Private renter should still get some HB"); + assert!(hb_social > hb_private, "Social renter (no cap) should get more HB than private renter above cap"); + // HB for private renter at £2500/month rent in London should be capped at 1-bed LHA £1200.81/month + assert!(hb_private <= 1200.81 * 12.0 + 1.0, "HB should not exceed LHA cap for private renter"); + } } diff --git a/tests/parameter_impact.rs b/tests/parameter_impact.rs index 1c625e0..8142dbb 100644 --- a/tests/parameter_impact.rs +++ b/tests/parameter_impact.rs @@ -212,6 +212,11 @@ const SKIP_PARAMS: &[&str] = &[ // Not used in the microsimulation calculation — they contextualise fiscal costs. "obr_fiscal_headroom_bn", "obr_headroom_sensitivity", + // LHA private_rent_index: multiplicative reform scalar on all LHA cap rates. + // Nudging up from 1.0 loosens the cap — but in the baseline most FRS private renters + // are at or below their LHA rate, so a small upward nudge has no impact. + // Real impact is when the index is reduced (tighter cap) or in reform scenarios. + "lha.private_rent_index", ]; fn is_array_element(path: &str) -> bool { From 4fe875612d26e457bd9527744ad699e56d8ed054 Mon Sep 17 00:00:00 2001 From: "nikhil@policyengine.org" Date: Wed, 8 Apr 2026 15:34:09 +0100 Subject: [PATCH 3/3] chore: add changelog fragments for Wales manifesto and LHA cap --- changelog.d/added/lha-cap.md | 1 + changelog.d/added/wales-green-manifesto.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 changelog.d/added/lha-cap.md create mode 100644 changelog.d/added/wales-green-manifesto.md diff --git a/changelog.d/added/lha-cap.md b/changelog.d/added/lha-cap.md new file mode 100644 index 0000000..0cfa9a3 --- /dev/null +++ b/changelog.d/added/lha-cap.md @@ -0,0 +1 @@ +Implement Local Housing Allowance cap for private renters on HB and UC: bedroom entitlement logic (UC Regs 2013 Sch.4), region×category monthly rate table derived from VOA rent data uprated to 2024/25, and a `private_rent_index` scalar for modelling LHA unfreeze or rate changes. \ No newline at end of file diff --git a/changelog.d/added/wales-green-manifesto.md b/changelog.d/added/wales-green-manifesto.md new file mode 100644 index 0000000..336f11a --- /dev/null +++ b/changelog.d/added/wales-green-manifesto.md @@ -0,0 +1 @@ +Add Wales-specific modelling infrastructure (devolved council tax Band D rates, LTT, `is_in_wales` flag, universal FSM by nation) and score the first two Wales Green Manifesto 2026 policies: council tax abolition in Wales (~£3.7bn/yr) and extending universal free school meals to secondary pupils. \ No newline at end of file