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/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 diff --git a/parameters/2025_26.yaml b/parameters/2025_26.yaml index f397da1..e5e74fe 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,41 @@ 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). +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 + 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, @@ -560,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/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..5607057 100644 --- a/src/parameters/mod.rs +++ b/src/parameters/mod.rs @@ -58,6 +58,31 @@ 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, + /// 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). + /// 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 +91,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 +411,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 +602,120 @@ 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 + +/// 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 127649d..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 }; @@ -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, } } @@ -168,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) @@ -234,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 @@ -412,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%)) @@ -421,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 { @@ -428,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; } @@ -867,6 +970,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], @@ -1911,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/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..8142dbb 100644 --- a/tests/parameter_impact.rs +++ b/tests/parameter_impact.rs @@ -202,6 +202,21 @@ 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", + // 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 {