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..79b8637788e --- /dev/null +++ b/changelog.d/trustees-wage-base-cap.fixed.md @@ -0,0 +1 @@ +Keep the Social Security wage base on each Trustees assumption's path when a reform changes only the payroll cap. 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_2025.py b/policyengine_us/reforms/ssa/trustees_2025.py index 9cb2a8d775c..d90f4d1b6db 100644 --- a/policyengine_us/reforms/ssa/trustees_2025.py +++ b/policyengine_us/reforms/ssa/trustees_2025.py @@ -201,6 +201,34 @@ def apply_trustees_2025_economic_assumptions( last_projected_year=2035, end_year=end_year, ) - # 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) + # 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, + ) + # 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=wage_base, + ) diff --git a/policyengine_us/reforms/ssa/trustees_2026.py b/policyengine_us/reforms/ssa/trustees_2026.py index 00daf7a69f8..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,7 +482,26 @@ 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) + # 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=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 new file mode 100644 index 00000000000..a25e047e598 --- /dev/null +++ b/policyengine_us/tests/core/test_trustees_2025_economic_assumptions.py @@ -0,0 +1,327 @@ +"""Under the 2025 Trustees economic assumptions, a reform that changes only +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 extends the wage base from the +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 cached tree'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 + + +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): + 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. No cap is drawn often.""" + baseline, _ = _run(False) + return st.integers(2027, END_YEAR).flatmap( + lambda year: st.tuples( + st.just(year), + st.one_of( + st.just(math.inf), + 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] 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..138df9217a6 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 @@ -21,10 +23,11 @@ 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 +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,140 @@ 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 + + +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 + 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 +933,58 @@ 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. + + 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.one_of( + st.just(math.inf), + st.floats( + min_value=_statutory_path(CAP)[year], + allow_nan=False, + allow_infinity=True, + ), + ), + ) + ) + + +@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 + 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 +1035,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):