From ed31a6dc9d597d21eaa5b1a6f6106d0ea24b6be4 Mon Sep 17 00:00:00 2001 From: marcelolafleur Date: Tue, 11 Aug 2026 16:16:13 -0400 Subject: [PATCH] Apply replacement_rate_adjust to the pension systems that ignore it --- CHANGELOG.md | 13 +++++++ ogcore/pensions.py | 43 +++++++++++++++++++++ tests/test_pensions.py | 86 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e7eb0a39..7e8473ade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Bug Fixes + +- Fixes Issue [#1200](https://github.com/PSLmodels/OG-Core/issues/1200): + `replacement_rate_adjust` was read only inside `SS_amount`, so it applied + to the US-Style Social Security system and was silently ignored under + Defined Benefits, Notional Defined Contribution, and Points System. The + adjustment is now applied to those three systems in `pension_amount`, + via a `replacement_rate_adjustment` helper that mirrors the indexing + `SS_amount` already uses, including the per-cohort `t + tt` offset along + the time path. `SS_amount` is unchanged, so US-Style results cannot move. + ## [0.19.1] - 2026-08-10 12:00:00 ### Added diff --git a/ogcore/pensions.py b/ogcore/pensions.py index af504f9b4..e06e5303a 100644 --- a/ogcore/pensions.py +++ b/ogcore/pensions.py @@ -74,6 +74,40 @@ def replacement_rate_vals(nssmat, wss, factor_ss, j, p): return theta +def replacement_rate_adjustment(pension, t, j, method, p): + """ + Return the replacement rate adjustment, shaped to broadcast against + pension. + + US-Style Social Security applies this itself inside SS_amount. The + other three systems do not, so pension_amount applies it for them. + Along the time path the multiplier varies with each cohort's own year, + so it is indexed at t + tt for row tt. + + Args: + pension (Numpy array): pension amount for each household + t (int): model period + j (int): index of lifetime income group, None if all groups + method (str): 'SS', 'TPI', or 'TPI_scalar' + p (OG-Core Specifications object): model parameters + + Returns: + adjustment (array_like): multiplier on the replacement rate + + """ + adjust = p.replacement_rate_adjust + if method == "SS": + return adjust[-1, :] if j is None else adjust[-1, j] + if method == "TPI_scalar": + return adjust[0, j] + if pension.ndim == 1: + return adjust[t, j] + length = pension.shape[0] + if pension.ndim == 2: + return adjust[t : t + length, j].reshape(length, 1) + return adjust[t : t + length, :].reshape(length, 1, p.J) + + def pension_amount(r, w, n, Y, theta, t, j, shift, method, e, factor, p): """ Calculate public pension benefit amounts for each household. @@ -102,10 +136,19 @@ def pension_amount(r, w, n, Y, theta, t, j, shift, method, e, factor, p): pension = SS_amount(w, n, theta, t, j, shift, method, e, p) elif p.pension_system == "Defined Benefits": pension = DB_amount(w, e, n, j, p) + pension = pension * replacement_rate_adjustment( + pension, t, j, method, p + ) elif p.pension_system == "Notional Defined Contribution": pension = NDC_amount(w, e, n, r, Y, j, p) + pension = pension * replacement_rate_adjustment( + pension, t, j, method, p + ) elif p.pension_system == "Points System": pension = PS_amount(w, e, n, j, factor, p) + pension = pension * replacement_rate_adjustment( + pension, t, j, method, p + ) else: raise ValueError( "pension_system must be one of the following: " diff --git a/tests/test_pensions.py b/tests/test_pensions.py index 8b407da52..df0a3edef 100644 --- a/tests/test_pensions.py +++ b/tests/test_pensions.py @@ -1022,6 +1022,62 @@ def test_pension_amount_with_real_specifications(system, updates): assert np.all(pension[S_ret:] > 0.0) +@pytest.mark.parametrize( + "system,updates", + [ + ("US-Style Social Security", {}), + ("Defined Benefits", {"alpha_db": 0.01, "yr_contrib": 40}), + ( + "Points System", + {"vpoint": 0.4, "points_growth_rate": "LR GDP"}, + ), + ( + "Notional Defined Contribution", + { + "tau_p": 0.1, + "ndc_growth_rate": "LR GDP", + "dir_growth_rate": "r", + }, + ), + ], + ids=["SS", "DB", "PS", "NDC"], +) +def test_replacement_rate_adjust_applies_to_every_system(system, updates): + """ + replacement_rate_adjust must scale benefits under every pension system. + + It was previously read only inside SS_amount, so setting it under any + system other than US-Style Social Security was silently ignored. + """ + + def pension_for(adjust): + p = Specifications() + p.update_specifications(dict(updates, pension_system=system)) + adjust_arr = np.asarray(p.replacement_rate_adjust).copy() + adjust_arr[:, :] = adjust + p.replacement_rate_adjust = adjust_arr + j = 0 + return pensions.pension_amount( + 0.05, + 1.2, + 0.4 * np.ones(p.S), + 1.0, + 0.1, + None, + j, + False, + "SS", + p.e[-1, :, j], + 100000.0, + p, + ) + + full = pension_for(1.0) + halved = pension_for(0.5) + assert np.any(full > 0.0), "test would be vacuous with a zero benefit" + assert np.allclose(halved, 0.5 * full) + + @pytest.mark.local def test_SS_solve_defined_benefits(tmp_path): """ @@ -1048,3 +1104,33 @@ def test_SS_solve_defined_benefits(tmp_path): Y = np.asarray(ss["Y"]).sum() assert ss["agg_pension_outlays"] > 0.0 assert 0.0 < ss["agg_pension_outlays"] / Y < 1.0 + + +def test_replacement_rate_adjustment_indexes_by_cohort_year(): + """ + On the time path the adjustment must vary with each cohort's own year. + + A single adjust[t] applied to the whole path would pass a constant- + adjustment test and silently flatten a glide, so this checks the + per-row offset directly. + """ + p = Specifications() + j, t, length = 0, 3, 5 + adjust = np.ones((p.T + p.S, p.J)) + adjust[:, j] = np.arange(p.T + p.S) + 1.0 + p.replacement_rate_adjust = adjust + expected = np.arange(t, t + length) + 1.0 + + two_d = pensions.replacement_rate_adjustment( + np.ones((length, p.S)), t, j, "TPI", p + ) + assert np.allclose(two_d.flatten(), expected) + + three_d = pensions.replacement_rate_adjustment( + np.ones((length, p.S, p.J)), t, None, "TPI", p + ) + assert np.allclose(three_d[:, 0, j], expected) + + # the steady state takes the terminal value + ss = pensions.replacement_rate_adjustment(np.ones(p.S), None, j, "SS", p) + assert np.isclose(ss, adjust[-1, j])