From 39ebd5481bc30df0380f6c840b527e9906f8c7be Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 07:13:05 +0000 Subject: [PATCH 1/6] Keep the wage base off a cap-only reform under the 2026 Trustees assumption A built system applies its reform set twice, so the assumption's second pass runs after a policy's first. extend_social_security_wage_indexed_parameters set the wage base from the live payroll cap, so a reform that changed only the cap also moved the benefit wage base, crediting newly taxed earnings toward benefits. Read the wage base from the owners' draw copy of the cap, which the assumption already extends from its own values; the default build's call passes no cap and is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6Grnvh6oFdh5mUvYjF --- changelog.d/trustees-wage-base-cap.fixed.md | 1 + .../parameters/uprating_extensions.py | 16 +- policyengine_us/reforms/ssa/trustees_2026.py | 10 +- ...test_trustees_2026_economic_assumptions.py | 158 +++++++++++++++++- 4 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 changelog.d/trustees-wage-base-cap.fixed.md diff --git a/changelog.d/trustees-wage-base-cap.fixed.md b/changelog.d/trustees-wage-base-cap.fixed.md new file mode 100644 index 00000000000..4be30222299 --- /dev/null +++ b/changelog.d/trustees-wage-base-cap.fixed.md @@ -0,0 +1 @@ +Keep the Social Security wage base on the Trustees assumption's path when a reform changes only the payroll cap under the 2026 or 2025 Trustees economic assumptions. diff --git a/policyengine_us/parameters/uprating_extensions.py b/policyengine_us/parameters/uprating_extensions.py index 84246e0a9c5..3e3950b835e 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -820,14 +820,24 @@ def extend_social_security_payroll_cap( def extend_social_security_wage_indexed_parameters( parameters: ParameterNode, end_year: int, + *, + cap: Optional[Parameter] = None, ) -> None: - """Extend SSA wage-indexed benefit parameters with statutory lag and rounding.""" + """Extend SSA wage-indexed benefit parameters with statutory lag and rounding. + + The wage base for each year from 2027 is set to the contribution and + benefit base. `cap` defaults to `gov.irs.payroll.social_security.cap`. + Pass another parameter that holds the base, such as a copy of it, to read + the wage base from that parameter instead, so a reform that changes only + the payroll cap doesn't move the wage base when this runs after it. + """ ssa = parameters.gov.ssa social_security = ssa.social_security nawi = ssa.nawi wage_base = social_security.wage_base - payroll_cap = parameters.gov.irs.payroll.social_security.cap + if cap is None: + cap = parameters.gov.irs.payroll.social_security.cap qc_threshold = social_security.quarters_of_coverage_threshold earnings_test = social_security.earnings_test pia = social_security.pia.formula_factors @@ -839,7 +849,7 @@ def extend_social_security_wage_indexed_parameters( wage_base.update( period=period, - value=payroll_cap(f"{year}-01-01"), + value=cap(f"{year}-01-01"), ) qc_threshold.update( diff --git a/policyengine_us/reforms/ssa/trustees_2026.py b/policyengine_us/reforms/ssa/trustees_2026.py index 00daf7a69f8..737ff61ca4d 100644 --- a/policyengine_us/reforms/ssa/trustees_2026.py +++ b/policyengine_us/reforms/ssa/trustees_2026.py @@ -480,7 +480,15 @@ def apply_trustees_2026_economic_assumptions( end_year=end_year, cap=parameters.gov.contrib.ssa.owners_draw.current_law_cap, ) - extend_social_security_wage_indexed_parameters(parameters, end_year=end_year) + # Read the wage base from that copy, not the live cap, for the same + # reason: a reform that changes only the payroll cap must not move the + # benefit wage base when the reform set runs again. A reform that means + # to move it sets the wage base itself. + extend_social_security_wage_indexed_parameters( + parameters, + end_year=end_year, + cap=parameters.gov.contrib.ssa.owners_draw.current_law_cap, + ) apply_trustees_2026_path_readers(parameters, end_year=end_year) diff --git a/policyengine_us/tests/core/test_trustees_2026_economic_assumptions.py b/policyengine_us/tests/core/test_trustees_2026_economic_assumptions.py index ea85fa8bf61..048953be953 100644 --- a/policyengine_us/tests/core/test_trustees_2026_economic_assumptions.py +++ b/policyengine_us/tests/core/test_trustees_2026_economic_assumptions.py @@ -7,8 +7,10 @@ module reuses. Cost: the module builds one reformed system and copies the default tree once, -both cached. The two composition tests, last, free them and each build one -more system, keeping only the values they compare. +both cached. It builds one more system, the assumption plus a cap-only policy, +and keeps only the values its tests compare. The two composition tests, last, +free them and each build one more system, keeping only the values they +compare. """ import hashlib @@ -25,6 +27,7 @@ from hypothesis import strategies as st from policyengine_core.parameters import Parameter, ParameterNode from policyengine_core.parameters.operations.get_parameter import get_parameter +from policyengine_core.reforms import Reform from policyengine_us import CountryTaxBenefitSystem, Simulation from policyengine_us.reforms.ssa import ( @@ -159,7 +162,7 @@ def _snapshot(tree): def _release_cached_systems(): """Free the systems this module builds before later modules run.""" yield - for cached in (_reformed_system, _applied_once, _end_to_end): + for cached in (_reformed_system, _applied_once, _end_to_end, _cap_policy_run): cached.cache_clear() @@ -399,6 +402,113 @@ def policy(parameters): assert _value(tree, CAP, year) == policy_cap, year +@pytest.mark.parametrize("order", ["assumption_first", "policy_first"]) +@pytest.mark.parametrize("policy_cap", CAP_POLICIES.values(), ids=CAP_POLICIES) +def test_wage_base_survives_both_passes_of_a_2027_cap_policy(policy_cap, order): + """A policy that sets only the payroll cap from 2027 leaves the benefit + wage base on the assumption's path after both passes, in either order.""" + + def policy(parameters): + get_parameter(parameters, CAP).update(start="2027-01-01", value=policy_cap) + + reform_set = (apply_trustees_2026_economic_assumptions, policy) + if order == "policy_first": + reform_set = reform_set[::-1] + tree = _applied_once() + with _on_default_values(tree): + for apply in reform_set * 2: + apply(tree) + for year in range(2027, LAST_TABLE_YEAR + 1): + assert _value(tree, CAP, year) == policy_cap, year + assert _value(tree, WAGE_BASE, year) == _statutory_path(CAP)[year], year + + +def test_a_policy_after_the_assumption_keeps_the_wage_base_it_sets(): + """A policy that means to credit the newly taxed earnings sets the wage + base itself. After the assumption in the tuple, it applies last in both + passes, so its wage base stands.""" + + def policy(parameters): + for path in (CAP, WAGE_BASE): + get_parameter(parameters, path).update(start="2027-01-01", value=math.inf) + + tree = _applied_once() + with _on_default_values(tree): + for apply in (apply_trustees_2026_economic_assumptions, policy) * 2: + apply(tree) + for year in range(2027, LAST_TABLE_YEAR + 1): + assert _value(tree, WAGE_BASE, year) == math.inf, year + assert _entries(tree, WAGE_BASE, "2026-12-31") == _entries( + system.parameters, WAGE_BASE, "2026-12-31" + ) + + +def _cap_policy(value, start_year=2027): + def modify_parameters(parameters): + get_parameter(parameters, CAP).update(start=f"{start_year}-01-01", value=value) + return parameters + + class reform(Reform): + def apply(self): + self.modify_parameters(modify_parameters) + + return reform + + +def _leaves(tree): + return { + parameter.name: _entries(tree, parameter.name) + for parameter in tree.get_descendants() + if isinstance(parameter, Parameter) + } + + +HOUSEHOLD_YEAR = 2027 +HOUSEHOLD_WAGES = 500_000 + + +@lru_cache(maxsize=1) +def _cap_policy_run(): + """Build the assumption plus a policy that removes the payroll cap from + 2027, and keep only every parameter's values and one worker's covered + earnings, so the system itself is freed.""" + built = CountryTaxBenefitSystem( + reform=(trustees_2026_economic_assumptions_reform, _cap_policy(math.inf)) + ) + situation = { + "people": { + "worker": { + "age": {HOUSEHOLD_YEAR: 40}, + "employment_income": {HOUSEHOLD_YEAR: HOUSEHOLD_WAGES}, + } + } + } + simulation = Simulation(situation=situation, tax_benefit_system=built) + covered = simulation.calculate("ss_covered_earnings_this_year", HOUSEHOLD_YEAR) + return _leaves(built.parameters), float(covered[0]) + + +def test_a_cap_only_policy_moves_no_other_parameter(): + """In a built system, a policy that changes only the payroll cap leaves + every other parameter equal to the assumption baseline's, entry for + entry. The replayed passes stand for this build.""" + with_policy, _ = _cap_policy_run() + baseline = _leaves(_reformed()) + assert with_policy.keys() == baseline.keys() + differ = sorted(name for name in baseline if with_policy[name] != baseline[name]) + assert differ == [CAP] + for year in range(2027, LAST_TABLE_YEAR + 1): + assert _value(_reformed(), WAGE_BASE, year) == _statutory_path(CAP)[year] + + +def test_covered_earnings_stop_at_the_wage_base_under_a_cap_only_policy(): + """A worker earning $500,000 in 2027, with the payroll cap removed from + 2027 under the assumption, is credited only up to the assumption's 2027 + wage base.""" + _, covered = _cap_policy_run() + assert covered == _statutory_path(CAP)[HOUSEHOLD_YEAR] == 192_300 + + @pytest.mark.parametrize("end_year", [2028, 2030, 2036]) def test_a_short_end_year_keeps_the_cbo_income_totals(end_year): """The SOI helper holds each total at its end_year value from end_year @@ -796,6 +906,46 @@ def test_applying_twice_equals_applying_once(path, year): assert _value(_reformed(), path, year) == _value(_applied_once(), path, year) +def _cap_policies(): + """A start year from 2027 to 2100 and a cap from the assumption's cap in + that year up to no cap.""" + return st.integers(2027, LAST_TABLE_YEAR).flatmap( + lambda year: st.tuples( + st.just(year), + st.floats( + min_value=_statutory_path(CAP)[year], + allow_nan=False, + allow_infinity=True, + ), + ) + ) + + +@PROPERTIES +@given(_cap_policies(), st.booleans()) +def test_any_cap_only_policy_leaves_the_wage_base_and_the_copy(policy, first): + """Replay both passes of the assumption and any cap-only policy, in + either order: the wage base and the owners' draw copy keep the values + the assumption alone gives them.""" + start_year, policy_cap = policy + + def apply_policy(parameters): + get_parameter(parameters, CAP).update( + start=f"{start_year}-01-01", value=policy_cap + ) + + reform_set = (apply_trustees_2026_economic_assumptions, apply_policy) + if first: + reform_set = reform_set[::-1] + tree = _applied_once() + expected = {path: _entries(tree, path) for path in (WAGE_BASE, OWNERS_DRAW_CAP)} + with _on_default_values(tree): + for apply in reform_set * 2: + apply(tree) + for path, entries in expected.items(): + assert _entries(tree, path) == entries, path + + def test_real_wage_growth_matches_the_trustees_long_run(): tree = _reformed() wages = _value(tree, NAWI, 2100) / _value(tree, NAWI, 2050) @@ -846,7 +996,7 @@ def test_core_thresholds_follow_the_trustees_2026_nawi(order): Last in the module: it frees the cached trees before building its own. """ - for cached in (_reformed_system, _applied_once, _end_to_end): + for cached in (_reformed_system, _applied_once, _end_to_end, _cap_policy_run): cached.cache_clear() nawi, thresholds = _core_thresholds_under(COMPOSITIONS[order]) for year in range(2026, 2101): From f9b6bb203525cebd165f0323d86272cca24e16de Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 07:17:38 +0000 Subject: [PATCH 2/6] Keep the wage base off a cap-only reform under the 2025 Trustees assumption apply_trustees_2025_economic_assumptions had the same two steps as the 2026 function: extend the live cap, then set the wage base from it. In a built system its second pass therefore copied a cap-only reform's cap into the wage base. Extend the owners' draw copy of the cap on the same rule from its own values and read the wage base from it. With no other reform the copy equals the live cap in every year, so the wage base doesn't change; the copy itself now follows the 2025 Trustees path from 2036 instead of the default build's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6Grnvh6oFdh5mUvYjF --- policyengine_us/reforms/ssa/trustees_2025.py | 20 +- ...test_trustees_2025_economic_assumptions.py | 299 ++++++++++++++++++ 2 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 policyengine_us/tests/core/test_trustees_2025_economic_assumptions.py diff --git a/policyengine_us/reforms/ssa/trustees_2025.py b/policyengine_us/reforms/ssa/trustees_2025.py index 9cb2a8d775c..218363390eb 100644 --- a/policyengine_us/reforms/ssa/trustees_2025.py +++ b/policyengine_us/reforms/ssa/trustees_2025.py @@ -201,6 +201,22 @@ def apply_trustees_2025_economic_assumptions( last_projected_year=2035, end_year=end_year, ) + # Extend the owners' draw copy on the same rule from its own values, not + # from the live cap, so a policy reform's cap can't become the copy's + # no-decrease floor when the reform set runs again. + current_law_cap = parameters.gov.contrib.ssa.owners_draw.current_law_cap + extend_social_security_payroll_cap( + parameters, + last_projected_year=2035, + end_year=end_year, + cap=current_law_cap, + ) # The wage base and the other wage-indexed amounts follow the cap and the - # NAWI, so re-derive them from the Trustees path as well (#10452). - extend_social_security_wage_indexed_parameters(parameters, end_year=end_year) + # NAWI, so re-derive them from the Trustees path as well (#10452). Read + # the wage base from the copy, so a reform that changes only the payroll + # cap doesn't move it. A reform that means to move it sets it itself. + extend_social_security_wage_indexed_parameters( + parameters, + end_year=end_year, + cap=current_law_cap, + ) diff --git a/policyengine_us/tests/core/test_trustees_2025_economic_assumptions.py b/policyengine_us/tests/core/test_trustees_2025_economic_assumptions.py new file mode 100644 index 00000000000..6698f2fa627 --- /dev/null +++ b/policyengine_us/tests/core/test_trustees_2025_economic_assumptions.py @@ -0,0 +1,299 @@ +"""Under the 2025 Trustees economic assumptions, a reform that changes only +the payroll cap leaves the benefit wage base, and every other parameter, on +the assumption's path. + +A built system applies its reform set twice, so the assumption's second pass +runs after a policy's first. The assumption reads the wage base from the +owners' draw copy of the cap, which it extends from its own values. + +Cost: the module builds two systems, the assumption alone and the assumption +plus a cap-only policy, keeping only the values its tests compare, and copies +the default tree once, cached. Replays restore the copy's values after each +case. +""" + +import math +from contextlib import contextmanager +from functools import lru_cache + +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +from policyengine_core.parameters import Parameter +from policyengine_core.parameters.operations.get_parameter import get_parameter +from policyengine_core.reforms import Reform + +from policyengine_us import CountryTaxBenefitSystem, Simulation +from policyengine_us.reforms.ssa import apply_trustees_2025_economic_assumptions +from policyengine_us.system import system + +CAP = "gov.irs.payroll.social_security.cap" +OWNERS_DRAW_CAP = "gov.contrib.ssa.owners_draw.current_law_cap" +WAGE_BASE = "gov.ssa.social_security.wage_base" +END_YEAR = 2100 +HOUSEHOLD_YEAR = 2027 +HOUSEHOLD_WAGES = 500_000 + +# The 2025 Trustees cap path from 2036, which the wage base follows; the +# default build's path, which the owners' draw copy held before it followed +# the assumption. +TRUSTEES_2025_CAP = {2036: 261_900, 2050: 430_500, 2100: 2_454_900} +DEFAULT_CAP = {2036: 260_400, 2050: 406_500, 2100: 1_995_300} + + +def _value(tree, path, year): + return get_parameter(tree, path)(f"{year}-01-01") + + +def _entries(tree, path, through=None): + return [ + (entry.instant_str, entry.value) + for entry in get_parameter(tree, path).values_list + if through is None or entry.instant_str <= through + ] + + +def _leaves(tree): + return { + parameter.name: _entries(tree, parameter.name) + for parameter in tree.get_descendants() + if isinstance(parameter, Parameter) + } + + +def _value_of(values, path, year): + """The value in force on 1 January of ``year`` from a list of entries.""" + instant = f"{year}-01-01" + for start, value in values[path]: + if start <= instant: + return value + raise AssertionError((path, year)) + + +def _assumption(): + def modify_parameters(parameters): + apply_trustees_2025_economic_assumptions(parameters) + return parameters + + class reform(Reform): + def apply(self): + self.modify_parameters(modify_parameters) + + return reform + + +def _cap_policy(value): + def modify_parameters(parameters): + get_parameter(parameters, CAP).update(start="2027-01-01", value=value) + return parameters + + class reform(Reform): + def apply(self): + self.modify_parameters(modify_parameters) + + return reform + + +def _covered_earnings(tax_benefit_system): + situation = { + "people": { + "worker": { + "age": {HOUSEHOLD_YEAR: 40}, + "employment_income": {HOUSEHOLD_YEAR: HOUSEHOLD_WAGES}, + } + } + } + simulation = Simulation(situation=situation, tax_benefit_system=tax_benefit_system) + covered = simulation.calculate("ss_covered_earnings_this_year", HOUSEHOLD_YEAR) + return float(covered[0]) + + +@lru_cache(maxsize=None) +def _run(with_cap_policy): + """Build the assumption, alone or with a policy that removes the payroll + cap from 2027, and keep only every parameter's values and one worker's + covered earnings, so the system itself is freed.""" + reform = _assumption() + if with_cap_policy: + reform = (reform, _cap_policy(math.inf)) + built = CountryTaxBenefitSystem(reform=reform) + return _leaves(built.parameters), _covered_earnings(built) + + +@lru_cache(maxsize=1) +def _applied_once(): + tree = system.parameters.clone() + apply_trustees_2025_economic_assumptions(tree) + return tree + + +@pytest.fixture(scope="module", autouse=True) +def _release_cached_systems(): + """Free what this module builds before later modules run.""" + yield + for cached in (_run, _paired_leaves, _applied_once): + cached.cache_clear() + + +@lru_cache(maxsize=1) +def _paired_leaves(): + """Each parameter of the cached copy with its default-tree twin.""" + copy_leaves = [ + p for p in _applied_once().get_descendants() if isinstance(p, Parameter) + ] + default_leaves = [ + p for p in system.parameters.get_descendants() if isinstance(p, Parameter) + ] + pairs = list(zip(copy_leaves, default_leaves, strict=True)) + assert all(own.name == default.name for own, default in pairs) + return pairs + + +@contextmanager +def _on_default_values(): + """Put every parameter of the cached copy on the default tree's values, + then give the copy its own values back. Core's ``update`` replaces a + parameter's value list and never edits the entries in it, so the copy can + share the default tree's entries meanwhile.""" + pairs = _paired_leaves() + own = [parameter.values_list for parameter, _ in pairs] + + def put(lists): + for (parameter, _), values in zip(pairs, lists): + parameter.values_list = list(values) + parameter.parent.clear_parent_cache() + + put([default.values_list for _, default in pairs]) + try: + yield + finally: + put(own) + + +def _replay(reform_set): + """Apply a reform set twice to a copy of the default tree, as a built + system does, and return the values the case compares.""" + tree = _applied_once() + with _on_default_values(): + for apply in reform_set * 2: + apply(tree) + return { + path: _entries(tree, path) for path in (CAP, OWNERS_DRAW_CAP, WAGE_BASE) + } + + +def test_wage_base_and_copy_equal_the_cap_under_the_assumption_alone(): + values, _ = _run(False) + for year in range(2027, END_YEAR + 1): + assert _value_of(values, WAGE_BASE, year) == _value_of(values, CAP, year) + for year in range(1994, END_YEAR + 1): + assert _value_of(values, OWNERS_DRAW_CAP, year) == _value_of( + values, CAP, year + ), year + + +def test_the_copy_moves_to_the_trustees_path_and_the_wage_base_stays(): + """The copy now follows the assumption from 2036 on; the wage base and + the cap keep the values they had.""" + values, _ = _run(False) + for year, pin in TRUSTEES_2025_CAP.items(): + assert _value_of(values, CAP, year) == pin + assert _value_of(values, WAGE_BASE, year) == pin + assert _value_of(values, OWNERS_DRAW_CAP, year) == pin + assert _value(system.parameters, OWNERS_DRAW_CAP, year) == DEFAULT_CAP[year] + + +def test_a_cap_only_policy_moves_no_other_parameter(): + """In a built system, a policy that removes the payroll cap from 2027 + leaves every other parameter equal to the assumption baseline's, entry + for entry. The replayed passes stand for this build.""" + baseline, _ = _run(False) + with_policy, _ = _run(True) + assert with_policy.keys() == baseline.keys() + differ = sorted(name for name in baseline if with_policy[name] != baseline[name]) + assert differ == [CAP] + for year in range(2027, END_YEAR + 1): + assert _value_of(with_policy, CAP, year) == math.inf + + +def test_covered_earnings_stop_at_the_wage_base_under_a_cap_only_policy(): + _, baseline = _run(False) + _, with_policy = _run(True) + assert with_policy == baseline == _value(system.parameters, WAGE_BASE, 2027) + assert with_policy == 192_300 + + +CAP_POLICIES = {"cap_removed": math.inf, "cap_of_10_million": 10_000_000} + + +@pytest.mark.parametrize("order", ["assumption_first", "policy_first"]) +@pytest.mark.parametrize("policy_cap", CAP_POLICIES.values(), ids=CAP_POLICIES) +def test_wage_base_survives_both_passes_of_a_2027_cap_policy(policy_cap, order): + def policy(parameters): + get_parameter(parameters, CAP).update(start="2027-01-01", value=policy_cap) + + reform_set = (apply_trustees_2025_economic_assumptions, policy) + if order == "policy_first": + reform_set = reform_set[::-1] + replayed = _replay(reform_set) + baseline, _ = _run(False) + assert replayed[WAGE_BASE] == baseline[WAGE_BASE] + assert replayed[OWNERS_DRAW_CAP] == baseline[OWNERS_DRAW_CAP] + for year in range(2027, END_YEAR + 1): + assert _value_of(replayed, CAP, year) == policy_cap, year + + +def test_a_policy_after_the_assumption_keeps_the_wage_base_it_sets(): + def policy(parameters): + for path in (CAP, WAGE_BASE): + get_parameter(parameters, path).update(start="2027-01-01", value=math.inf) + + replayed = _replay((apply_trustees_2025_economic_assumptions, policy)) + for year in range(2027, END_YEAR + 1): + assert _value_of(replayed, WAGE_BASE, year) == math.inf, year + assert [entry for entry in replayed[WAGE_BASE] if entry[0] < "2027"] == _entries( + system.parameters, WAGE_BASE, "2026-12-31" + ) + + +PROPERTIES = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) + + +def _cap_policies(): + """A start year from 2027 to 2100 and a cap from the assumption's cap in + that year up to no cap.""" + baseline, _ = _run(False) + return st.integers(2027, END_YEAR).flatmap( + lambda year: st.tuples( + st.just(year), + st.floats( + min_value=_value_of(baseline, CAP, year), + allow_nan=False, + allow_infinity=True, + ), + ) + ) + + +@PROPERTIES +@given(st.data(), st.booleans()) +def test_any_cap_only_policy_leaves_the_wage_base_and_the_copy(data, policy_first): + start_year, policy_cap = data.draw(_cap_policies()) + + def policy(parameters): + get_parameter(parameters, CAP).update( + start=f"{start_year}-01-01", value=policy_cap + ) + + reform_set = (apply_trustees_2025_economic_assumptions, policy) + if policy_first: + reform_set = reform_set[::-1] + replayed = _replay(reform_set) + baseline, _ = _run(False) + assert replayed[WAGE_BASE] == baseline[WAGE_BASE] + assert replayed[OWNERS_DRAW_CAP] == baseline[OWNERS_DRAW_CAP] From c2bc80920d70564ef756d4ed6867b3f788ce3743 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 07:40:15 +0000 Subject: [PATCH 3/6] Draw a 2027 start and no cap often in the cap-policy properties The 2026 assumption's second pass re-extends the cap from 2028 on the no-decrease rule, overwriting a policy cap that starts in 2028 or later, so only a policy from 2027 reaches the wage base. With uniform draws the 2026 property passed with the wage-base fix removed; explicit 2027/no-cap examples and weighted draws make it fail then. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6Grnvh6oFdh5mUvYjF --- ...test_trustees_2025_economic_assumptions.py | 13 ++++++---- ...test_trustees_2026_economic_assumptions.py | 26 ++++++++++++++----- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/policyengine_us/tests/core/test_trustees_2025_economic_assumptions.py b/policyengine_us/tests/core/test_trustees_2025_economic_assumptions.py index 6698f2fa627..306c062bcda 100644 --- a/policyengine_us/tests/core/test_trustees_2025_economic_assumptions.py +++ b/policyengine_us/tests/core/test_trustees_2025_economic_assumptions.py @@ -266,15 +266,18 @@ def policy(parameters): def _cap_policies(): """A start year from 2027 to 2100 and a cap from the assumption's cap in - that year up to no cap.""" + that year up to no cap. No cap is drawn often.""" baseline, _ = _run(False) return st.integers(2027, END_YEAR).flatmap( lambda year: st.tuples( st.just(year), - st.floats( - min_value=_value_of(baseline, CAP, year), - allow_nan=False, - allow_infinity=True, + st.one_of( + st.just(math.inf), + st.floats( + min_value=_value_of(baseline, CAP, year), + allow_nan=False, + allow_infinity=True, + ), ), ) ) diff --git a/policyengine_us/tests/core/test_trustees_2026_economic_assumptions.py b/policyengine_us/tests/core/test_trustees_2026_economic_assumptions.py index 048953be953..2aac2d1020f 100644 --- a/policyengine_us/tests/core/test_trustees_2026_economic_assumptions.py +++ b/policyengine_us/tests/core/test_trustees_2026_economic_assumptions.py @@ -23,7 +23,7 @@ import pytest import yaml -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_core.parameters import Parameter, ParameterNode from policyengine_core.parameters.operations.get_parameter import get_parameter @@ -908,14 +908,24 @@ def test_applying_twice_equals_applying_once(path, year): def _cap_policies(): """A start year from 2027 to 2100 and a cap from the assumption's cap in - that year up to no cap.""" - return st.integers(2027, LAST_TABLE_YEAR).flatmap( + that year up to no cap. + + The assumption re-extends the cap from 2028 on the no-decrease rule, so + its second pass overwrites a policy cap that starts in 2028 or later. A + policy from 2027 is the one that reaches the wage base through the + second pass, so 2027 and no cap are drawn often. + """ + years = st.one_of(st.just(2027), st.integers(2027, LAST_TABLE_YEAR)) + return years.flatmap( lambda year: st.tuples( st.just(year), - st.floats( - min_value=_statutory_path(CAP)[year], - allow_nan=False, - allow_infinity=True, + st.one_of( + st.just(math.inf), + st.floats( + min_value=_statutory_path(CAP)[year], + allow_nan=False, + allow_infinity=True, + ), ), ) ) @@ -923,6 +933,8 @@ def _cap_policies(): @PROPERTIES @given(_cap_policies(), st.booleans()) +@example(policy=(2027, math.inf), first=False) +@example(policy=(2027, math.inf), first=True) def test_any_cap_only_policy_leaves_the_wage_base_and_the_copy(policy, first): """Replay both passes of the assumption and any cap-only policy, in either order: the wage base and the owners' draw copy keep the values From 85d4a5fa3b8f28ead0bbd045562f75bd156f6ea8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 3 Nov 2026 02:59:43 -0500 Subject: [PATCH 4/6] Shorten the wage-base changelog entry Co-Authored-By: Claude Opus 5.5 --- changelog.d/trustees-wage-base-cap.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/trustees-wage-base-cap.fixed.md b/changelog.d/trustees-wage-base-cap.fixed.md index 4be30222299..79b8637788e 100644 --- a/changelog.d/trustees-wage-base-cap.fixed.md +++ b/changelog.d/trustees-wage-base-cap.fixed.md @@ -1 +1 @@ -Keep the Social Security wage base on the Trustees assumption's path when a reform changes only the payroll cap under the 2026 or 2025 Trustees economic assumptions. +Keep the Social Security wage base on each Trustees assumption's path when a reform changes only the payroll cap. From 185b636a32f2eddb6715c82175f9951f96809b72 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 09:20:12 +0000 Subject: [PATCH 5/6] Extend the wage base from its own values under both Trustees assumptions Reading the wage base from the owners' draw copy kept a cap-only reform out of it, but the copy is itself a reformable parameter: a reform that set only the copy then moved the benefit wage base, which main never did. Each assumption now extends the wage base on the statutory rule from the wage base's own values (through 2027 under the 2026 assumption, through 2035 under the 2025 one), and the wage-indexed call reads the wage base from itself, so it reads no other parameter a reform can change. The copy keeps its own extension. Adds a test to each module: a reform that sets only the copy to $10 million from 2027 leaves the wage base equal to the assumption baseline's, entry for entry, in either order. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WR8LHedB89QC7jBYtzQWqi --- policyengine_us/reforms/ssa/trustees_2025.py | 22 ++++++++++--- policyengine_us/reforms/ssa/trustees_2026.py | 27 +++++++++++---- ...test_trustees_2025_economic_assumptions.py | 33 ++++++++++++++++--- ...test_trustees_2026_economic_assumptions.py | 27 +++++++++++++++ 4 files changed, 93 insertions(+), 16 deletions(-) diff --git a/policyengine_us/reforms/ssa/trustees_2025.py b/policyengine_us/reforms/ssa/trustees_2025.py index 218363390eb..d90f4d1b6db 100644 --- a/policyengine_us/reforms/ssa/trustees_2025.py +++ b/policyengine_us/reforms/ssa/trustees_2025.py @@ -211,12 +211,24 @@ def apply_trustees_2025_economic_assumptions( end_year=end_year, cap=current_law_cap, ) - # The wage base and the other wage-indexed amounts follow the cap and the - # NAWI, so re-derive them from the Trustees path as well (#10452). Read - # the wage base from the copy, so a reform that changes only the payroll - # cap doesn't move it. A reform that means to move it sets it itself. + # Extend the benefit wage base on the same rule from its own values + # through 2035. The rule reads the wage base's 1994 base, its value for + # the year before and the NAWI, but neither the live cap nor the copy, so + # a reform that changes only one of those doesn't move the wage base when + # the reform set runs again. A reform that means to move it sets the wage + # base itself. + wage_base = parameters.gov.ssa.social_security.wage_base + extend_social_security_payroll_cap( + parameters, + last_projected_year=2035, + end_year=end_year, + cap=wage_base, + ) + # The other wage-indexed amounts follow the NAWI, so re-derive them from + # the Trustees path as well (#10452). This reads the wage base from + # itself, so it rewrites the wage base with its own values. extend_social_security_wage_indexed_parameters( parameters, end_year=end_year, - cap=current_law_cap, + cap=wage_base, ) diff --git a/policyengine_us/reforms/ssa/trustees_2026.py b/policyengine_us/reforms/ssa/trustees_2026.py index 737ff61ca4d..3e679b77b4d 100644 --- a/policyengine_us/reforms/ssa/trustees_2026.py +++ b/policyengine_us/reforms/ssa/trustees_2026.py @@ -39,8 +39,10 @@ - the base's owners' draw copy, ``gov.contrib.ssa.owners_draw.current_law_cap``, on the same rule from the copy's own values through 2027, so a policy reform that changes the live base doesn't reach it; -- the wage base, quarter-of-coverage threshold, earnings-test exempt amounts, - PIA bend points and SGA amounts, from 2027; +- the wage base on the same rule from its own values through 2027, so a + policy reform that changes the live base or the copy doesn't reach it; +- the quarter-of-coverage threshold, earnings-test exempt amounts, PIA bend + points and SGA amounts, from 2027; - every parameter whose ``uprating`` metadata names either path. Core's ``uprate_parameters`` writes their values when the system builds, so a later change to a path doesn't reach them. The module derives each again as core @@ -480,14 +482,25 @@ def apply_trustees_2026_economic_assumptions( end_year=end_year, cap=parameters.gov.contrib.ssa.owners_draw.current_law_cap, ) - # Read the wage base from that copy, not the live cap, for the same - # reason: a reform that changes only the payroll cap must not move the - # benefit wage base when the reform set runs again. A reform that means - # to move it sets the wage base itself. + # Extend the benefit wage base on the same rule from its own values + # through 2027. The rule reads the wage base's 1994 base, its value for + # the year before and the NAWI, but neither the live cap nor the copy, so + # a reform that changes only one of those doesn't move the wage base when + # the reform set runs again. A reform that means to move it sets the wage + # base itself. + wage_base = parameters.gov.ssa.social_security.wage_base + extend_social_security_payroll_cap( + parameters, + last_projected_year=TRUSTEES_2026_LAST_DETERMINED_CAP_YEAR, + end_year=end_year, + cap=wage_base, + ) + # Reads the wage base from itself, so this rewrites the wage base with its + # own values and extends the other wage-indexed amounts on the NAWI. extend_social_security_wage_indexed_parameters( parameters, end_year=end_year, - cap=parameters.gov.contrib.ssa.owners_draw.current_law_cap, + cap=wage_base, ) apply_trustees_2026_path_readers(parameters, end_year=end_year) diff --git a/policyengine_us/tests/core/test_trustees_2025_economic_assumptions.py b/policyengine_us/tests/core/test_trustees_2025_economic_assumptions.py index 306c062bcda..b0f8febf239 100644 --- a/policyengine_us/tests/core/test_trustees_2025_economic_assumptions.py +++ b/policyengine_us/tests/core/test_trustees_2025_economic_assumptions.py @@ -1,10 +1,11 @@ """Under the 2025 Trustees economic assumptions, a reform that changes only -the payroll cap leaves the benefit wage base, and every other parameter, on -the assumption's path. +the payroll cap, or only its owners' draw copy, leaves the benefit wage base +on the assumption's path. A built system applies its reform set twice, so the assumption's second pass -runs after a policy's first. The assumption reads the wage base from the -owners' draw copy of the cap, which it extends from its own values. +runs after a policy's first. The assumption extends the wage base from the +wage base's own values and the copy from the copy's own values, so the wage +base reads neither the cap nor the copy. Cost: the module builds two systems, the assumption alone and the assumption plus a cap-only policy, keeping only the values its tests compare, and copies @@ -243,6 +244,30 @@ def policy(parameters): assert _value_of(replayed, CAP, year) == policy_cap, year +COPY_POLICY_CAP = 10_000_000 + + +@pytest.mark.parametrize("order", ["assumption_first", "policy_first"]) +def test_wage_base_survives_both_passes_of_a_2027_owners_draw_copy_policy(order): + """A policy that sets only the owners' draw copy from 2027 leaves the + benefit wage base equal to the assumption baseline's, entry for entry, + after both passes, in either order.""" + + def policy(parameters): + get_parameter(parameters, OWNERS_DRAW_CAP).update( + start="2027-01-01", value=COPY_POLICY_CAP + ) + + reform_set = (apply_trustees_2025_economic_assumptions, policy) + if order == "policy_first": + reform_set = reform_set[::-1] + replayed = _replay(reform_set) + baseline, _ = _run(False) + assert replayed[WAGE_BASE] == baseline[WAGE_BASE] + for year in range(2027, END_YEAR + 1): + assert _value_of(replayed, OWNERS_DRAW_CAP, year) == COPY_POLICY_CAP, year + + def test_a_policy_after_the_assumption_keeps_the_wage_base_it_sets(): def policy(parameters): for path in (CAP, WAGE_BASE): diff --git a/policyengine_us/tests/core/test_trustees_2026_economic_assumptions.py b/policyengine_us/tests/core/test_trustees_2026_economic_assumptions.py index 2aac2d1020f..138df9217a6 100644 --- a/policyengine_us/tests/core/test_trustees_2026_economic_assumptions.py +++ b/policyengine_us/tests/core/test_trustees_2026_economic_assumptions.py @@ -423,6 +423,33 @@ def policy(parameters): assert _value(tree, WAGE_BASE, year) == _statutory_path(CAP)[year], year +COPY_POLICY_CAP = 10_000_000 + + +@pytest.mark.parametrize("order", ["assumption_first", "policy_first"]) +def test_wage_base_survives_both_passes_of_a_2027_owners_draw_copy_policy(order): + """A policy that sets only the owners' draw copy from 2027 leaves the + benefit wage base equal to the assumption baseline's, entry for entry, + after both passes, in either order.""" + + def policy(parameters): + get_parameter(parameters, OWNERS_DRAW_CAP).update( + start="2027-01-01", value=COPY_POLICY_CAP + ) + + reform_set = (apply_trustees_2026_economic_assumptions, policy) + if order == "policy_first": + reform_set = reform_set[::-1] + baseline = _entries(_reformed(), WAGE_BASE) + tree = _applied_once() + with _on_default_values(tree): + for apply in reform_set * 2: + apply(tree) + assert _entries(tree, WAGE_BASE) == baseline + for year in range(2027, LAST_TABLE_YEAR + 1): + assert _value(tree, OWNERS_DRAW_CAP, year) == COPY_POLICY_CAP, year + + def test_a_policy_after_the_assumption_keeps_the_wage_base_it_sets(): """A policy that means to credit the newly taxed earnings sets the wage base itself. After the assumption in the tuple, it applies last in both From b9d28d254a9ba761d1af190a6a421ff755147107 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 10:02:23 +0000 Subject: [PATCH 6/6] Name the owners' draw copy and the cached tree apart in the 2025 test docstring The module docstring used "the copy" both for the owners' draw copy of the cap and for the module's cached copy of the default tree; say which is which. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WR8LHedB89QC7jBYtzQWqi --- .../tests/core/test_trustees_2025_economic_assumptions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/policyengine_us/tests/core/test_trustees_2025_economic_assumptions.py b/policyengine_us/tests/core/test_trustees_2025_economic_assumptions.py index b0f8febf239..a25e047e598 100644 --- a/policyengine_us/tests/core/test_trustees_2025_economic_assumptions.py +++ b/policyengine_us/tests/core/test_trustees_2025_economic_assumptions.py @@ -4,13 +4,13 @@ A built system applies its reform set twice, so the assumption's second pass runs after a policy's first. The assumption extends the wage base from the -wage base's own values and the copy from the copy's own values, so the wage -base reads neither the cap nor the copy. +wage base's own values, and the owners' draw copy from the draw copy's own +values, so the wage base reads neither the payroll cap nor the draw copy. Cost: the module builds two systems, the assumption alone and the assumption plus a cap-only policy, keeping only the values its tests compare, and copies -the default tree once, cached. Replays restore the copy's values after each -case. +the default tree once, cached. Replays restore the cached tree's values after +each case. """ import math