Skip to content
Merged
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [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.

### Added
- `npv_table` in `output_tables.py` (Issue #1131): builds a table of the net present value of the reform-minus-baseline change in flow variables (e.g. `Y`) over a horizon, evaluated at a list of discount rates. Values are un-stationarized by default so the NPV is taken over the actual (trend-inclusive) level path.

## [0.20.0] - 2026-08-13 12:00:00
Expand Down
48 changes: 48 additions & 0 deletions ogcore/pensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,45 @@ 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.

The multiplier scales the whole pension array, while SS_amount scales
only the entries from retirement on. These are equivalent only because
DB, NDC, and PS benefits are zero before retirement; a system that
pays benefits before retirement would need the SS_amount treatment.

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 +141,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 Down Expand Up @@ -1050,6 +1106,36 @@ def test_SS_solve_defined_benefits(tmp_path):
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])


@pytest.mark.local
def test_SS_solve_notional_defined_contribution(tmp_path):
"""
Expand Down