Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions ogcore/pensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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: "
Expand Down
86 changes: 86 additions & 0 deletions tests/test_pensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand All @@ -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])