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/parameters/2025_26.yaml b/parameters/2025_26.yaml index f397da1..48ec239 100644 --- a/parameters/2025_26.yaml +++ b/parameters/2025_26.yaml @@ -263,6 +263,33 @@ wealth_tax: threshold: 10000000.0 rate: 0.01 +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 + + labour_supply: # OBR labour supply elasticities (Slutsky decomposition). # Source: OBR (2023) "Costing a cut in National Insurance contributions: diff --git a/src/engine/entities.rs b/src/engine/entities.rs index 5b5b020..753a64d 100644 --- a/src/engine/entities.rs +++ b/src/engine/entities.rs @@ -560,6 +560,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 f63eefd..aae4867 100644 --- a/src/parameters/mod.rs +++ b/src/parameters/mod.rs @@ -58,6 +58,11 @@ pub struct Parameters { /// Annual wealth tax (hypothetical — disabled by default). #[serde(default)] pub wealth_tax: 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 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. @@ -564,6 +569,68 @@ impl Default for LabourSupplyParams { } } +/// 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..8971331 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 }; @@ -168,6 +168,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 +235,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 +421,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 +512,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 +520,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; } @@ -1911,4 +2011,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 672e4ef..47cc4fd 100644 --- a/tests/parameter_impact.rs +++ b/tests/parameter_impact.rs @@ -202,6 +202,11 @@ 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", + // 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 {