diff --git a/changelog.d/fixed/state-pension-recorded-amount.md b/changelog.d/fixed/state-pension-recorded-amount.md new file mode 100644 index 0000000..6e67bc3 --- /dev/null +++ b/changelog.d/fixed/state-pension-recorded-amount.md @@ -0,0 +1 @@ +Fix `person_state_pension` to respect the recorded `state_pension` amount for the new-SP cohort (those below the basic-SP age cutoff). Previously the new-SP branch always returned the full `new_state_pension_weekly × 52`, ignoring any recorded amount and over-stating SP for partial-year and partial-record claimants. Now mirrors the existing old-SP scaling pattern: recorded amounts pass through (scaled by reform-ratio when the new-SP rate changes), with fallback to the parameter rate when no amount is recorded. Closes #59. Shrinks the parity-harness pensioner-couple state-pension diff from £946 → £0. diff --git a/src/engine/simulation.rs b/src/engine/simulation.rs index 32a71f6..06cb325 100644 --- a/src/engine/simulation.rs +++ b/src/engine/simulation.rs @@ -112,6 +112,8 @@ pub struct Simulation { pub parameters: Parameters, /// Baseline old basic SP weekly rate for scaling reported amounts under reforms. pub baseline_old_sp_weekly: f64, + /// Baseline new SP weekly rate for scaling reported amounts under reforms. + pub baseline_new_sp_weekly: f64, /// Fiscal year (e.g. 2025 for 2025/26) — used for new/basic SP cutoff. pub fiscal_year: u32, } @@ -125,25 +127,27 @@ impl Simulation { fiscal_year: u32, ) -> Self { let baseline_old_sp_weekly = parameters.state_pension.old_basic_pension_weekly; + let baseline_new_sp_weekly = parameters.state_pension.new_state_pension_weekly; Simulation { people, benunits, households, parameters, - baseline_old_sp_weekly, fiscal_year, + baseline_old_sp_weekly, baseline_new_sp_weekly, fiscal_year, } } - /// Create a simulation with explicit baseline old SP rate (for reform simulations - /// where the baseline rate differs from the reform parameters). + /// Create a simulation with explicit baseline SP rates (for reform simulations + /// where the baseline rates differ from the reform parameters). pub fn new_with_baseline_sp( people: Vec, benunits: Vec, households: Vec, parameters: Parameters, baseline_old_sp_weekly: f64, + baseline_new_sp_weekly: f64, fiscal_year: u32, ) -> Self { Simulation { people, benunits, households, parameters, - baseline_old_sp_weekly, fiscal_year, + baseline_old_sp_weekly, baseline_new_sp_weekly, fiscal_year, } } @@ -157,10 +161,11 @@ impl Simulation { // Phase 1a: Calculate each person's state pension under the current policy. // State pension is taxable income so must be computed before income tax. let baseline_old_sp = self.baseline_old_sp_weekly; + let baseline_new_sp = self.baseline_new_sp_weekly; let fiscal_year = self.fiscal_year; let person_sp: Vec = self.people.par_iter().map(|p| { variables::benefits::person_state_pension( - p, &self.parameters, baseline_old_sp, fiscal_year, + p, &self.parameters, baseline_old_sp, baseline_new_sp, fiscal_year, ) }).collect(); @@ -184,7 +189,7 @@ impl Simulation { let hh = &self.households[bu.household_id]; variables::benefits::calculate_benunit( bu, &self.people, &person_results, hh, &self.parameters, - baseline_old_sp, fiscal_year, + baseline_old_sp, baseline_new_sp, fiscal_year, ) }).collect(); benunit_results = br; @@ -708,6 +713,7 @@ mod tests { adjusted_people, benunits.clone(), households.clone(), policy_params.clone(), baseline_params.state_pension.old_basic_pension_weekly, + baseline_params.state_pension.new_state_pension_weekly, 2025, ); let dynamic_results = dynamic_sim.run(); diff --git a/src/main.rs b/src/main.rs index 3711e13..d6165a3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -561,13 +561,14 @@ fn main() -> anyhow::Result<()> { dataset.people.clone() }; - // Run policy simulation (pass baseline old SP rate so reported amounts scale correctly) + // Run policy simulation (pass baseline old + new SP rates so reported amounts scale correctly) let policy_sim = Simulation::new_with_baseline_sp( policy_people, dataset.benunits.clone(), dataset.households.clone(), policy_params.clone(), baseline_params.state_pension.old_basic_pension_weekly, + baseline_params.state_pension.new_state_pension_weekly, cli.year, ); let reformed = policy_sim.run(); diff --git a/src/variables/benefits.rs b/src/variables/benefits.rs index 8971331..9e5cc0b 100644 --- a/src/variables/benefits.rs +++ b/src/variables/benefits.rs @@ -15,12 +15,13 @@ pub fn calculate_benunit( household: &Household, params: &Parameters, baseline_old_sp_weekly: f64, + baseline_new_sp_weekly: f64, fiscal_year: u32, ) -> BenUnitResult { // Non-means-tested / universal benefits (available regardless of UC/legacy) let child_benefit = calculate_child_benefit(bu, people, person_results, params); let state_pension = calculate_state_pension( - bu, people, params, baseline_old_sp_weekly, fiscal_year, + bu, people, params, baseline_old_sp_weekly, baseline_new_sp_weekly, fiscal_year, ); // Carers Allowance: non-means-tested flat rate for informal carers. // Paid to individual, regardless of UC/legacy system. @@ -313,12 +314,17 @@ fn calculate_universal_credit( /// is: anyone aged > 66 + (Y - 2016) was already SP-age when new SP began, /// and is therefore on basic SP. Everyone else on SP is on new SP. /// Calculate reform-adjusted state pension for a single person. -/// New SP recipients get the full parameter rate; basic SP recipients get -/// their reported amount scaled by the reform ratio. +/// +/// For both the basic-SP cohort (age ≥ `basic_sp_min_age` for the fiscal year) +/// and the new-SP cohort (younger but ≥ SP age), a recorded amount on the +/// person is preserved and scaled by the reform's ratio over the baseline +/// rate. When no amount is recorded (e.g. synthetic households built via +/// `from_situation`), fall back to the full parameter rate × 52. pub fn person_state_pension( person: &Person, params: &Parameters, baseline_old_sp_weekly: f64, + baseline_new_sp_weekly: f64, fiscal_year: u32, ) -> f64 { if !person.is_sp_age() || !person.is_adult() { @@ -330,17 +336,27 @@ pub fn person_state_pension( if person.age >= basic_sp_min_age { // Basic SP: scale reported amount by reform ratio - let old_sp_scale = if baseline_old_sp_weekly > 0.0 { + let scale = if baseline_old_sp_weekly > 0.0 { sp.old_basic_pension_weekly / baseline_old_sp_weekly } else { 1.0 }; if person.state_pension > 0.0 { - person.state_pension * old_sp_scale + person.state_pension * scale } else { sp.old_basic_pension_weekly * 52.0 } } else { - // New SP: use full parameter rate directly - sp.new_state_pension_weekly * 52.0 + // New SP: same scaling pattern as basic SP. Previously this branch + // ignored `person.state_pension` and always returned the full + // parameter rate, over-stating SP for partial-year / partial-record + // claimants. + let scale = if baseline_new_sp_weekly > 0.0 { + sp.new_state_pension_weekly / baseline_new_sp_weekly + } else { 1.0 }; + if person.state_pension > 0.0 { + person.state_pension * scale + } else { + sp.new_state_pension_weekly * 52.0 + } } } @@ -349,10 +365,13 @@ fn calculate_state_pension( people: &[Person], params: &Parameters, baseline_old_sp_weekly: f64, + baseline_new_sp_weekly: f64, fiscal_year: u32, ) -> f64 { bu.person_ids.iter() - .map(|&pid| person_state_pension(&people[pid], params, baseline_old_sp_weekly, fiscal_year)) + .map(|&pid| person_state_pension( + &people[pid], params, baseline_old_sp_weekly, baseline_new_sp_weekly, fiscal_year, + )) .sum() } @@ -1117,7 +1136,7 @@ mod tests { let person_results: Vec = people.iter() .map(|p| crate::variables::income_tax::calculate(p, ¶ms, p.state_pension)) .collect(); - let result = calculate_benunit(&bu, &people, &person_results, &hh, ¶ms, params.state_pension.old_basic_pension_weekly, 2025); + let result = calculate_benunit(&bu, &people, &person_results, &hh, ¶ms, params.state_pension.old_basic_pension_weekly, params.state_pension.new_state_pension_weekly, 2025); let expected_cb = params.child_benefit.eldest_weekly * 52.0 + params.child_benefit.additional_weekly * 52.0; assert!((result.child_benefit - expected_cb).abs() < 1.0); @@ -1130,7 +1149,7 @@ mod tests { let person_results: Vec = people.iter() .map(|p| crate::variables::income_tax::calculate(p, ¶ms, p.state_pension)) .collect(); - let result = calculate_benunit(&bu, &people, &person_results, &hh, ¶ms, params.state_pension.old_basic_pension_weekly, 2025); + let result = calculate_benunit(&bu, &people, &person_results, &hh, ¶ms, params.state_pension.old_basic_pension_weekly, params.state_pension.new_state_pension_weekly, 2025); assert!(result.universal_credit > 0.0, "Low earner should receive UC"); } @@ -1142,14 +1161,14 @@ mod tests { let person_results: Vec = people.iter() .map(|p| crate::variables::income_tax::calculate(p, ¶ms, p.state_pension)) .collect(); - let result = calculate_benunit(&bu, &people, &person_results, &hh, ¶ms, params.state_pension.old_basic_pension_weekly, 2025); + let result = calculate_benunit(&bu, &people, &person_results, &hh, ¶ms, params.state_pension.old_basic_pension_weekly, params.state_pension.new_state_pension_weekly, 2025); assert!(result.uc_max_amount > 0.0); let (people2, bu2, hh2) = make_single_bu(10000.0, 1); let pr2: Vec = people2.iter() .map(|p| crate::variables::income_tax::calculate(p, ¶ms, p.state_pension)) .collect(); - let result2 = calculate_benunit(&bu2, &people2, &pr2, &hh2, ¶ms, params.state_pension.old_basic_pension_weekly, 2025); + let result2 = calculate_benunit(&bu2, &people2, &pr2, &hh2, ¶ms, params.state_pension.old_basic_pension_weekly, params.state_pension.new_state_pension_weekly, 2025); assert!(result.uc_max_amount > result2.uc_max_amount, "Disabled child should increase UC max amount"); } @@ -1163,7 +1182,7 @@ mod tests { let person_results: Vec = people.iter() .map(|p| crate::variables::income_tax::calculate(p, ¶ms, p.state_pension)) .collect(); - let result = calculate_benunit(&bu, &people, &person_results, &hh, ¶ms, params.state_pension.old_basic_pension_weekly, 2025); + let result = calculate_benunit(&bu, &people, &person_results, &hh, ¶ms, params.state_pension.old_basic_pension_weekly, params.state_pension.new_state_pension_weekly, 2025); let expected_min = (params.universal_credit.standard_allowance_single_over25 + params.universal_credit.lcwra_element + 800.0) * 12.0; @@ -1179,7 +1198,7 @@ mod tests { let person_results: Vec = people.iter() .map(|p| crate::variables::income_tax::calculate(p, ¶ms, p.state_pension)) .collect(); - let result = calculate_benunit(&bu, &people, &person_results, &hh, ¶ms, params.state_pension.old_basic_pension_weekly, 2025); + let result = calculate_benunit(&bu, &people, &person_results, &hh, ¶ms, params.state_pension.old_basic_pension_weekly, params.state_pension.new_state_pension_weekly, 2025); assert!(result.uc_income_reduction >= 5000.0, "£5000 unearned income should reduce UC by at least £5000, got {}", result.uc_income_reduction); } @@ -1206,7 +1225,7 @@ mod tests { let pr: Vec = people.iter() .map(|p| crate::variables::income_tax::calculate(p, ¶ms, p.state_pension)) .collect(); - let result = calculate_benunit(&bu, &people, &pr, &hh, ¶ms, params.state_pension.old_basic_pension_weekly, 2025); + let result = calculate_benunit(&bu, &people, &pr, &hh, ¶ms, params.state_pension.old_basic_pension_weekly, params.state_pension.new_state_pension_weekly, 2025); let mg_annual = params.pension_credit.standard_minimum_single * 52.0; // GC = mg - income assert!(result.pension_credit > 0.0, "Should receive pension credit"); @@ -1236,7 +1255,7 @@ mod tests { let pr: Vec = people.iter() .map(|p| crate::variables::income_tax::calculate(p, ¶ms, p.state_pension)) .collect(); - let result = calculate_benunit(&bu, &people, &pr, &hh, ¶ms, params.state_pension.old_basic_pension_weekly, 2025); + let result = calculate_benunit(&bu, &people, &pr, &hh, ¶ms, params.state_pension.old_basic_pension_weekly, params.state_pension.new_state_pension_weekly, 2025); // seed=0.85 > migration rate 0.70 → not yet migrated, still on HB assert!(result.housing_benefit > 0.0, "Low earner not yet migrated should get HB"); assert!(result.housing_benefit <= 7200.0, "HB should not exceed rent"); @@ -1268,7 +1287,7 @@ mod tests { let pr: Vec = people.iter() .map(|p| crate::variables::income_tax::calculate(p, ¶ms, p.state_pension)) .collect(); - let result = calculate_benunit(&bu, &people, &pr, &hh, ¶ms, params.state_pension.old_basic_pension_weekly, 2025); + let result = calculate_benunit(&bu, &people, &pr, &hh, ¶ms, params.state_pension.old_basic_pension_weekly, params.state_pension.new_state_pension_weekly, 2025); // seed=0.85 < migration rate 0.95 → migrated to UC assert!(result.universal_credit > 0.0, "Low-income lone parent migrated from tax credits should receive UC. UC={}", @@ -1284,7 +1303,7 @@ mod tests { let pr: Vec = people.iter() .map(|p| crate::variables::income_tax::calculate(p, ¶ms, p.state_pension)) .collect(); - let result = calculate_benunit(&bu, &people, &pr, &hh, ¶ms, params.state_pension.old_basic_pension_weekly, 2025); + let result = calculate_benunit(&bu, &people, &pr, &hh, ¶ms, params.state_pension.old_basic_pension_weekly, params.state_pension.new_state_pension_weekly, 2025); // With 4 children and £3000/month rent, total benefits should hit cap if let Some(bc) = ¶ms.benefit_cap { let cap = bc.non_single_london; @@ -1318,7 +1337,7 @@ mod tests { let pr: Vec = people.iter() .map(|p| crate::variables::income_tax::calculate(p, ¶ms, p.state_pension)) .collect(); - let result = calculate_benunit(&bu, &people, &pr, &hh, ¶ms, params.state_pension.old_basic_pension_weekly, 2025); + let result = calculate_benunit(&bu, &people, &pr, &hh, ¶ms, params.state_pension.old_basic_pension_weekly, params.state_pension.new_state_pension_weekly, 2025); if let Some(scp) = ¶ms.scottish_child_payment { let expected = scp.weekly_amount * 52.0; assert!((result.scottish_child_payment - expected).abs() < 1.0, @@ -1359,7 +1378,7 @@ mod parameter_impact_tests { let pr: Vec = people.iter() .map(|p| crate::variables::income_tax::calculate(p, params, p.state_pension)) .collect(); - calculate_benunit(bu, people, &pr, hh, params, params.state_pension.old_basic_pension_weekly, 2025) + calculate_benunit(bu, people, &pr, hh, params, params.state_pension.old_basic_pension_weekly, params.state_pension.new_state_pension_weekly, 2025) } // ── UC parameters ──────────────────────────────────────────────────────── @@ -1563,6 +1582,50 @@ mod parameter_impact_tests { // ── State Pension parameters ────────────────────────────────────────────── + #[test] + fn new_sp_uses_recorded_amount_when_present() { + // Regression test for #59: a person aged 70 (new-SP cohort) with + // recorded state_pension = £11,500 should produce £11,500, not the + // full new-SP weekly rate × 52. + let params = Parameters::for_year(2025).unwrap(); + let mut p = Person::default(); + p.age = 70.0; + p.state_pension = 11_500.0; + let baseline_old = params.state_pension.old_basic_pension_weekly; + let baseline_new = params.state_pension.new_state_pension_weekly; + let sp = person_state_pension(&p, ¶ms, baseline_old, baseline_new, 2025); + assert!((sp - 11_500.0).abs() < 0.01, + "expected 11500 (recorded), got {}", sp); + } + + #[test] + fn new_sp_falls_back_to_param_when_no_record() { + let params = Parameters::for_year(2025).unwrap(); + let mut p = Person::default(); + p.age = 70.0; + // state_pension = 0 (default) → use parameter rate × 52 + let baseline_old = params.state_pension.old_basic_pension_weekly; + let baseline_new = params.state_pension.new_state_pension_weekly; + let sp = person_state_pension(&p, ¶ms, baseline_old, baseline_new, 2025); + assert!((sp - params.state_pension.new_state_pension_weekly * 52.0).abs() < 0.01); + } + + #[test] + fn new_sp_recorded_amount_scales_under_reform() { + // Doubling the new-SP weekly rate should double the recorded SP for + // a new-SP cohort claimant. + let mut params = Parameters::for_year(2025).unwrap(); + let baseline_new = params.state_pension.new_state_pension_weekly; + params.state_pension.new_state_pension_weekly *= 2.0; + let mut p = Person::default(); + p.age = 70.0; + p.state_pension = 11_500.0; + let baseline_old = params.state_pension.old_basic_pension_weekly; + let sp = person_state_pension(&p, ¶ms, baseline_old, baseline_new, 2025); + assert!((sp - 23_000.0).abs() < 0.01, + "expected 23000 (11500 x 2.0 reform ratio), got {}", sp); + } + #[test] fn param_state_pension_new_weekly() { let (mut params, _, _, hh) = base_person_uc(); @@ -2077,7 +2140,7 @@ mod parameter_impact_tests { ..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); + let result = calculate_benunit(&bu, &[p.clone()], &pr, &hh, ¶ms, 0.0, 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 @@ -2093,7 +2156,7 @@ mod parameter_impact_tests { tenure_type: TenureType::RentFromCouncil, ..hh.clone() }; - let result_social = calculate_benunit(&bu, &[p], &pr, &hh_social, ¶ms, 0.0, 2025); + let result_social = calculate_benunit(&bu, &[p], &pr, &hh_social, ¶ms, 0.0, 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" @@ -2122,8 +2185,8 @@ mod parameter_impact_tests { 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; + let hb_private = calculate_benunit(&bu, &[p.clone()], &pr, &hh_private, ¶ms, 0.0, 0.0, 2025).housing_benefit; + let hb_social = calculate_benunit(&bu, &[p], &pr, &hh_social, ¶ms, 0.0, 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"); diff --git a/src/variables/labour_supply.rs b/src/variables/labour_supply.rs index 20e9f05..b2629a6 100644 --- a/src/variables/labour_supply.rs +++ b/src/variables/labour_supply.rs @@ -125,6 +125,7 @@ fn run_net_incomes( params: &Parameters, fiscal_year: u32, baseline_old_sp: f64, + baseline_new_sp: f64, ) -> Vec { let sim = Simulation::new_with_baseline_sp( people.to_vec(), @@ -132,6 +133,7 @@ fn run_net_incomes( households.to_vec(), params.clone(), baseline_old_sp, + baseline_new_sp, fiscal_year, ); sim.run().household_results.iter().map(|hr| hr.net_income).collect() @@ -170,6 +172,7 @@ fn batch_marginal_retention( params: &Parameters, fiscal_year: u32, baseline_old_sp: f64, + baseline_new_sp: f64, unperturbed_net: &[f64], slots: &[Option], max_slot: usize, @@ -188,7 +191,7 @@ fn batch_marginal_retention( let perturbed_net = run_net_incomes( &perturbed, benunits, households, - params, fiscal_year, baseline_old_sp, + params, fiscal_year, baseline_old_sp, baseline_new_sp, ); // Each perturbed household has exactly one bumped worker — attribute the @@ -225,6 +228,7 @@ pub fn apply_labour_supply_responses( } let baseline_old_sp = baseline_params.state_pension.old_basic_pension_weekly; + let baseline_new_sp = baseline_params.state_pension.new_state_pension_weekly; // Assign adult slots (O(n), no simulations) let slots = assign_adult_slots(people, households); @@ -237,18 +241,18 @@ pub fn apply_labour_supply_responses( // Unperturbed policy net incomes (the income-effect denominator) let unperturbed_policy_net = run_net_incomes( people, benunits, households, - policy_params, fiscal_year, baseline_old_sp, + policy_params, fiscal_year, baseline_old_sp, baseline_new_sp, ); // Batched marginal retention: 1 sim per slot per scenario let baseline_retention = batch_marginal_retention( people, benunits, households, - baseline_params, fiscal_year, baseline_old_sp, + baseline_params, fiscal_year, baseline_old_sp, baseline_new_sp, baseline_net, &slots, max_slot, ); let policy_retention = batch_marginal_retention( people, benunits, households, - policy_params, fiscal_year, baseline_old_sp, + policy_params, fiscal_year, baseline_old_sp, baseline_new_sp, &unperturbed_policy_net, &slots, max_slot, );