From fa603ef489fe76df0cc2bb33d5cfbaf695decb9e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 25 Sep 2026 04:00:33 -0400 Subject: [PATCH 1/5] Backdate parameters before applying reforms CountryTaxBenefitSystem.__init__ backdated every parameter to 2015 (copying its earliest value back) after applying the reform set. For a parameter first dated after 2015, a reform starting on or before that first date was copied back to 2015, and a reform ending before it left the years in between undefined, which dropped brackets from scales and could raise ParameterNotFoundError. Backdate the baseline tree right after the parameter pipeline instead, before the reform is applied. Reforms still apply after uprating and interpolation (#9075), and structural-reform detection still reads reformed values, now on a backdated tree, so a start_instant before 2024 no longer raises ParameterNotFoundError. Co-Authored-By: Claude Opus 5.5 --- .../fix-backdate-before-reforms.fixed.md | 1 + policyengine_us/system.py | 13 +- .../parameters/test_reform_backdating.py | 228 ++++++++++++++++++ 3 files changed, 238 insertions(+), 4 deletions(-) create mode 100644 changelog.d/fix-backdate-before-reforms.fixed.md create mode 100644 policyengine_us/tests/policy/baseline/parameters/test_reform_backdating.py diff --git a/changelog.d/fix-backdate-before-reforms.fixed.md b/changelog.d/fix-backdate-before-reforms.fixed.md new file mode 100644 index 00000000000..aa6e41314b1 --- /dev/null +++ b/changelog.d/fix-backdate-before-reforms.fixed.md @@ -0,0 +1 @@ +Reforms to a parameter first dated after 2015 no longer change years outside the reform's period. Parameters are now backdated to 2015 before the reform is applied, so a reform starting on or before a parameter's first dated value is no longer copied back to 2015, and a reform ending before that value no longer leaves the years in between undefined (which dropped brackets from scales and could raise ParameterNotFoundError). CountryTaxBenefitSystem and Simulation(reform=...) also now build with a start_instant before 2024, which raised ParameterNotFoundError because structural-reform detection read switches first dated after the start. diff --git a/policyengine_us/system.py b/policyengine_us/system.py index 47f8f45556e..fef1d87948b 100644 --- a/policyengine_us/system.py +++ b/policyengine_us/system.py @@ -122,6 +122,15 @@ def __init__( self.parameters = propagate_parameter_metadata(self.parameters) add_default_uprating(self) + # Backdated before any reform is applied. Backdating copies each + # parameter's earliest value back to 2015, so on a parameter first + # dated after 2015 it would copy a reform value that starts on that + # first date back to 2015, and a reform bounded to a year before + # that date would leave the years between the two undefined. + self.parameters = backdate_parameters( + self.parameters, first_instant="2015-01-01" + ) + if reform: # Applied after the parameter processing pipeline so that values # a reform inserts at future dates cannot act as defined values @@ -139,10 +148,6 @@ def __init__( reform = () reform = (reform, structural_reform) - self.parameters = backdate_parameters( - self.parameters, first_instant="2015-01-01" - ) - for parameter in self.parameters.get_descendants(): parameter.modified = False diff --git a/policyengine_us/tests/policy/baseline/parameters/test_reform_backdating.py b/policyengine_us/tests/policy/baseline/parameters/test_reform_backdating.py new file mode 100644 index 00000000000..1fafd19ea9a --- /dev/null +++ b/policyengine_us/tests/policy/baseline/parameters/test_reform_backdating.py @@ -0,0 +1,228 @@ +"""Reforms must not change years outside their own period. + +``CountryTaxBenefitSystem`` backdates every parameter to 2015 by copying its +earliest value back. It used to do so after applying the reform, so for a +parameter first dated after 2015: + +1. a reform starting on the first dated value was copied back to 2015; and +2. a reform bounded to a year before the first dated value left the years + between the two undefined (``None``), so a scale lost that bracket. + +Structural-reform detection also read those parameters before they were +backdated, so a ``start_instant`` before 2024 raised ``ParameterNotFoundError``. + +Backdating now runs before the reform is applied, on the baseline values only. +""" + +import pytest +import yaml +from policyengine_core.reforms import Reform + +from policyengine_us import Simulation +from policyengine_us.system import COUNTRY_DIR, CountryTaxBenefitSystem, system + +PARAMETERS = COUNTRY_DIR / "parameters" + +SCALE = "calibration.gov.irs.soi.agi.number_of_returns" +SCALE_FILE = "calibration/gov/irs/soi/agi/number_of_returns.yaml" +# Reform on the first dated value, which then must not reach back to 2015. +FIRST_DATE_BRACKET = 0 +FIRST_DATE_YEAR = 2021 +FIRST_DATE_VALUE = 123 +# Reform bounded to a year before the first dated value. +EARLY_BRACKET = 2 +EARLY_YEAR = 2019 +EARLY_VALUE = 789 + +# App-style reform ("start.2100-12-31") on a policy parameter whose first +# dated value is its start date. +RANGE_PARAMETER = "gov.irs.deductions.tip_income.cap" +RANGE_FILE = "gov/irs/deductions/tip_income/cap.yaml" +RANGE_START_YEAR = 2025 +RANGE_VALUE = 50_000 + +# A scalar policy parameter reformed for one year before its first value. +EARLY_SCALAR = "gov.irs.deductions.auto_loan_interest.cap" +EARLY_SCALAR_FILE = "gov/irs/deductions/auto_loan_interest/cap.yaml" +EARLY_SCALAR_YEAR = 2023 +EARLY_SCALAR_VALUE = 5_000 + +# A structural reform switch first dated after 2015, turned on from a +# ``start_instant`` before its first dated value. +SWITCH = "gov.contrib.ctc.linear_phase_out.in_effect" +SWITCH_FILE = "gov/contrib/ctc/linear_phase_out/in_effect.yaml" +SWITCH_START_YEAR = 2019 +SWITCH_VARIABLE = "ctc_phase_out" +SWITCH_REFORM_FILE = "ctc_linear_phase_out.py" + +YEARS = range(2015, 2031) + + +def first_dated_year(values): + return min(date.year for date in values) + + +def scale_amount(parameter_tree, bracket, year): + return parameter_tree.get_child(f"{SCALE}[{bracket}].amount")(f"{year}-01-01") + + +@pytest.fixture(scope="module") +def reformed(): + reform = Reform.from_dict( + { + f"{SCALE}[{FIRST_DATE_BRACKET}].amount": { + f"year:{FIRST_DATE_YEAR}:1": FIRST_DATE_VALUE + }, + f"{SCALE}[{EARLY_BRACKET}].amount": {f"year:{EARLY_YEAR}:1": EARLY_VALUE}, + RANGE_PARAMETER: {f"{RANGE_START_YEAR}-01-01.2100-12-31": RANGE_VALUE}, + EARLY_SCALAR: {f"year:{EARLY_SCALAR_YEAR}:1": EARLY_SCALAR_VALUE}, + }, + country_id="us", + ) + return CountryTaxBenefitSystem(reform=(reform,)).parameters + + +def test_reformed_parameters_are_first_dated_after_2015(): + # Guard: the tests below only discriminate while each parameter's first + # dated value falls after 2015, so backdating has something to fill. + brackets = yaml.safe_load((PARAMETERS / SCALE_FILE).read_text())["brackets"] + assert first_dated_year(brackets[FIRST_DATE_BRACKET]["amount"]["values"]) == ( + FIRST_DATE_YEAR + ) + assert first_dated_year(brackets[EARLY_BRACKET]["amount"]["values"]) > ( + EARLY_YEAR + 1 + ) + range_values = yaml.safe_load((PARAMETERS / RANGE_FILE).read_text())["values"] + assert first_dated_year(range_values) == RANGE_START_YEAR + early_values = yaml.safe_load((PARAMETERS / EARLY_SCALAR_FILE).read_text())[ + "values" + ] + assert first_dated_year(early_values) > EARLY_SCALAR_YEAR + 1 + switch_values = yaml.safe_load((PARAMETERS / SWITCH_FILE).read_text())["values"] + assert first_dated_year(switch_values) > SWITCH_START_YEAR + + +def test_reform_on_first_dated_value_is_not_backdated(reformed): + for year in YEARS: + expected = ( + FIRST_DATE_VALUE + if year == FIRST_DATE_YEAR + else scale_amount(system.parameters, FIRST_DATE_BRACKET, year) + ) + assert scale_amount(reformed, FIRST_DATE_BRACKET, year) == expected + + +def test_reform_before_first_dated_value_leaves_no_gap(reformed): + for year in YEARS: + expected = ( + EARLY_VALUE + if year == EARLY_YEAR + else scale_amount(system.parameters, EARLY_BRACKET, year) + ) + assert expected is not None + assert scale_amount(reformed, EARLY_BRACKET, year) == expected + + +def test_reformed_scale_keeps_every_bracket(reformed): + baseline_scale = system.parameters.get_child(SCALE) + reformed_scale = reformed.get_child(SCALE) + for year in YEARS: + instant = f"{year}-01-01" + assert len(reformed_scale(instant).thresholds) == len( + baseline_scale(instant).thresholds + ) + + +def test_range_reform_from_first_dated_value_leaves_earlier_years(reformed): + baseline = system.parameters.get_child(RANGE_PARAMETER) + parameter = reformed.get_child(RANGE_PARAMETER) + for year in YEARS: + expected = ( + RANGE_VALUE if year >= RANGE_START_YEAR else baseline(f"{year}-01-01") + ) + assert parameter(f"{year}-01-01") == expected + + +def test_scalar_reform_before_first_dated_value_leaves_no_gap(reformed): + baseline = system.parameters.get_child(EARLY_SCALAR) + parameter = reformed.get_child(EARLY_SCALAR) + for year in YEARS: + expected = ( + EARLY_SCALAR_VALUE + if year == EARLY_SCALAR_YEAR + else baseline(f"{year}-01-01") + ) + assert expected is not None + assert parameter(f"{year}-01-01") == expected + + +def test_only_reformed_parameters_are_marked_modified(reformed): + # The macro-impact cache reads ``modified`` as "differs from current law". + # Backdating edits every late-dated parameter, and those edits must not + # leave the flag set on parameters the reform never touched. + for name in ( + f"{SCALE}[{FIRST_DATE_BRACKET}].amount", + f"{SCALE}[{EARLY_BRACKET}].amount", + RANGE_PARAMETER, + EARLY_SCALAR, + ): + assert reformed.get_child(name).modified + for name in ( + f"{SCALE}[1].amount", + "gov.irs.deductions.tip_income.phase_out.rate", + ): + assert not reformed.get_child(name).modified + + +def test_simulation_reform_leaves_earlier_years(): + # ``Simulation(reform=...)`` builds its system with the reform, the path + # policyengine.py and the web app's API take. + years = (RANGE_START_YEAR - 1, RANGE_START_YEAR) + situation = { + "people": { + "worker": { + "age": {year: 30 for year in years}, + "employment_income": {year: 80_000 for year in years}, + "tip_income": {year: 45_000 for year in years}, + "tip_income_deduction_occupation_requirement_met": { + year: True for year in years + }, + } + }, + "tax_units": {"tax_unit": {"members": ["worker"]}}, + "households": { + "household": { + "members": ["worker"], + "state_code": {year: "TX" for year in years}, + } + }, + } + baseline = Simulation(situation=situation) + reformed = Simulation( + situation=situation, + reform={RANGE_PARAMETER: {f"{RANGE_START_YEAR}-01-01.2100-12-31": RANGE_VALUE}}, + ) + before = RANGE_START_YEAR - 1 + assert ( + reformed.calculate("tip_income_deduction", before)[0] + == (baseline.calculate("tip_income_deduction", before)[0]) + ) + # Tips fall under the reformed cap in the reform's own year. + assert reformed.calculate("tip_income_deduction", RANGE_START_YEAR)[0] == 45_000 + + +def test_structural_reform_detected_at_start_instant_before_2024(): + # Detection reads each switch at ``start_instant``. It used to read them + # before backdating, so a switch first dated after the start was missing. + reformed_system = CountryTaxBenefitSystem( + reform={SWITCH: {f"{SWITCH_START_YEAR}-01-01.2100-12-31": True}}, + start_instant=f"{SWITCH_START_YEAR}-01-01", + ) + switch = reformed_system.parameters.get_child(SWITCH) + assert switch(f"{SWITCH_START_YEAR}-01-01") + assert not switch(f"{SWITCH_START_YEAR - 1}-01-01") + formulas = reformed_system.variables[SWITCH_VARIABLE].formulas.values() + assert all( + formula.__code__.co_filename.endswith(SWITCH_REFORM_FILE) + for formula in formulas + ) From a7fd4ad17172cbd66c1412274925b912cd7ef63d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 25 Sep 2026 13:43:44 -0400 Subject: [PATCH 2/5] Backdate reform-added parameters and test the invariants exhaustively Independent review found that a reform adding a parameter (guarded, because __init__ applies a reform twice) was backdated on main but not after moving backdating ahead of the reform, so a formula reading it before its first date raised ParameterNotFoundError. Backdate again after the first reform application: every baseline parameter already has a value dated by 2015 and Parameter.update never removes earlier values, so this pass reaches only parameters the reform added. Tests now check two invariants over every backdated numeric and boolean parameter outside gov.contrib (46,615): a reform changes a parameter only inside its own period, and building the system with a reform matches applying the same reform to the finished system. Both fail on main for 45,937 parameters. The targeted tests compare whole series, cover an app-style reform starting before a breakdown parameter's first date, a formula reading the year after a bounded reform, the Simulation path with start_instant, and parametrized guards on each test parameter's first dated value. Co-Authored-By: Claude Opus 5.5 --- .../fix-backdate-before-reforms.fixed.md | 2 +- policyengine_us/system.py | 10 +- .../parameters/test_reform_backdating.py | 415 ++++++++++++++---- 3 files changed, 334 insertions(+), 93 deletions(-) diff --git a/changelog.d/fix-backdate-before-reforms.fixed.md b/changelog.d/fix-backdate-before-reforms.fixed.md index aa6e41314b1..1d80f4e1341 100644 --- a/changelog.d/fix-backdate-before-reforms.fixed.md +++ b/changelog.d/fix-backdate-before-reforms.fixed.md @@ -1 +1 @@ -Reforms to a parameter first dated after 2015 no longer change years outside the reform's period. Parameters are now backdated to 2015 before the reform is applied, so a reform starting on or before a parameter's first dated value is no longer copied back to 2015, and a reform ending before that value no longer leaves the years in between undefined (which dropped brackets from scales and could raise ParameterNotFoundError). CountryTaxBenefitSystem and Simulation(reform=...) also now build with a start_instant before 2024, which raised ParameterNotFoundError because structural-reform detection read switches first dated after the start. +Reforms to a parameter first dated after 2015 no longer change years outside the reform's period. Parameters are now backdated to 2015 before the reform is applied (and parameters a reform adds are backdated afterwards, as before), so a reform starting on or before a parameter's first dated value is no longer copied back to 2015, and a reform ending before that value no longer leaves the years in between undefined, which dropped brackets from scales and made formulas reading the parameter raise ParameterNotFoundError. CountryTaxBenefitSystem and Simulation(reform=...) also now build with any start_instant from 2015 through 2023; they raised ParameterNotFoundError because structural-reform detection read contrib parameters first dated after the start before they were backdated. diff --git a/policyengine_us/system.py b/policyengine_us/system.py index fef1d87948b..d256ece7292 100644 --- a/policyengine_us/system.py +++ b/policyengine_us/system.py @@ -124,8 +124,8 @@ def __init__( # Backdated before any reform is applied. Backdating copies each # parameter's earliest value back to 2015, so on a parameter first - # dated after 2015 it would copy a reform value that starts on that - # first date back to 2015, and a reform bounded to a year before + # dated after 2015 it would copy a reform value that starts on or + # before that first date back to 2015, and a reform ending before # that date would leave the years between the two undefined. self.parameters = backdate_parameters( self.parameters, first_instant="2015-01-01" @@ -140,6 +140,12 @@ def __init__( # structural-reform detection, which reads reformed parameter # values. self.apply_reform_set(reform) + # Backdates parameters the reform added. The rest already have a + # value dated by 2015, and ``Parameter.update`` never removes + # values dated before the period it sets, so they keep it. + self.parameters = backdate_parameters( + self.parameters, first_instant="2015-01-01" + ) structural_reform = create_structural_reforms_from_parameters( self.parameters, start_instant diff --git a/policyengine_us/tests/policy/baseline/parameters/test_reform_backdating.py b/policyengine_us/tests/policy/baseline/parameters/test_reform_backdating.py index 1fafd19ea9a..a67c5a81ecf 100644 --- a/policyengine_us/tests/policy/baseline/parameters/test_reform_backdating.py +++ b/policyengine_us/tests/policy/baseline/parameters/test_reform_backdating.py @@ -4,18 +4,28 @@ earliest value back. It used to do so after applying the reform, so for a parameter first dated after 2015: -1. a reform starting on the first dated value was copied back to 2015; and -2. a reform bounded to a year before the first dated value left the years - between the two undefined (``None``), so a scale lost that bracket. - -Structural-reform detection also read those parameters before they were -backdated, so a ``start_instant`` before 2024 raised ``ParameterNotFoundError``. - -Backdating now runs before the reform is applied, on the baseline values only. +1. a reform starting on or before the first dated value was copied back to + 2015; and +2. a reform bounded to a year before the first dated value was copied back to + 2015 and left the years between the two undefined (``None``), so a scale + lost that bracket and a formula reading the parameter raised + ``ParameterNotFoundError``. + +Structural-reform detection also read contrib parameters before backdating, +so any ``start_instant`` from 2015 through 2023 raised +``ParameterNotFoundError`` while the system was built. + +Backdating now runs before the reform is applied, on the baseline values, and +again afterwards for any parameter the reform added. """ +import datetime +import math +from pathlib import Path + import pytest import yaml +from policyengine_core.parameters import Parameter from policyengine_core.reforms import Reform from policyengine_us import Simulation @@ -41,6 +51,12 @@ RANGE_START_YEAR = 2025 RANGE_VALUE = 50_000 +# App-style reform starting before a breakdown parameter's first dated value. +BEFORE_PARAMETER = "gov.irs.deductions.overtime_income.cap.SINGLE" +BEFORE_FILE = "gov/irs/deductions/overtime_income/cap.yaml" +BEFORE_START_YEAR = 2023 +BEFORE_VALUE = 20_000 + # A scalar policy parameter reformed for one year before its first value. EARLY_SCALAR = "gov.irs.deductions.auto_loan_interest.cap" EARLY_SCALAR_FILE = "gov/irs/deductions/auto_loan_interest/cap.yaml" @@ -55,15 +71,52 @@ SWITCH_VARIABLE = "ctc_phase_out" SWITCH_REFORM_FILE = "ctc_linear_phase_out.py" +# A parameter a reform adds, first dated after 2015. It has no baseline, so +# its first dated value is backdated to 2015 like any other parameter's. +ADDED_NAME = "reform_backdating_added" +ADDED_PARAMETER = f"gov.contrib.{ADDED_NAME}" +ADDED_YEAR = 2025 +ADDED_VALUE = 7 + YEARS = range(2015, 2031) +TIPS = 45_000 +AUTO_LOAN_INTEREST = 8_000 + -def first_dated_year(values): - return min(date.year for date in values) +def first_dated_year(file, *keys): + # BaseLoader keeps dates as strings, so a "0000-01-01" key parses too. + node = yaml.load((PARAMETERS / file).read_text(), Loader=yaml.BaseLoader) + for key in keys: + node = node[key] + return min(int(date[:4]) for date in node) -def scale_amount(parameter_tree, bracket, year): - return parameter_tree.get_child(f"{SCALE}[{bracket}].amount")(f"{year}-01-01") +def series(parameter): + return {year: parameter(f"{year}-01-01") for year in YEARS} + + +def formula_files(tax_benefit_system): + variable = tax_benefit_system.variables[SWITCH_VARIABLE] + return { + Path(formula.__code__.co_filename).name + for formula in variable.formulas.values() + } + + +def add_parameter(parameters): + # ``__init__`` applies a reform twice, so an added child needs a guard. + if ADDED_NAME not in parameters.gov.contrib.children: + parameters.gov.contrib.add_child( + ADDED_NAME, + Parameter(ADDED_PARAMETER, data={f"{ADDED_YEAR}-01-01": ADDED_VALUE}), + ) + return parameters + + +class add_parameter_reform(Reform): + def apply(self): + self.modify_parameters(add_parameter) @pytest.fixture(scope="module") @@ -75,95 +128,131 @@ def reformed(): }, f"{SCALE}[{EARLY_BRACKET}].amount": {f"year:{EARLY_YEAR}:1": EARLY_VALUE}, RANGE_PARAMETER: {f"{RANGE_START_YEAR}-01-01.2100-12-31": RANGE_VALUE}, + BEFORE_PARAMETER: {f"{BEFORE_START_YEAR}-01-01.2100-12-31": BEFORE_VALUE}, EARLY_SCALAR: {f"year:{EARLY_SCALAR_YEAR}:1": EARLY_SCALAR_VALUE}, }, country_id="us", ) - return CountryTaxBenefitSystem(reform=(reform,)).parameters - - -def test_reformed_parameters_are_first_dated_after_2015(): + return CountryTaxBenefitSystem(reform=(reform, add_parameter_reform)).parameters + + +GUARDS = [ + pytest.param( + SCALE_FILE, + ("brackets", FIRST_DATE_BRACKET, "amount", "values"), + lambda year: year == FIRST_DATE_YEAR, + id=f"{SCALE}[{FIRST_DATE_BRACKET}].amount", + ), + pytest.param( + SCALE_FILE, + ("brackets", EARLY_BRACKET, "amount", "values"), + lambda year: year > EARLY_YEAR + 1, + id=f"{SCALE}[{EARLY_BRACKET}].amount", + ), + pytest.param( + RANGE_FILE, + ("values",), + lambda year: year == RANGE_START_YEAR, + id=RANGE_PARAMETER, + ), + pytest.param( + BEFORE_FILE, + ("SINGLE",), + lambda year: year > BEFORE_START_YEAR, + id=BEFORE_PARAMETER, + ), + pytest.param( + EARLY_SCALAR_FILE, + ("values",), + lambda year: year > EARLY_SCALAR_YEAR + 1, + id=EARLY_SCALAR, + ), + pytest.param( + SWITCH_FILE, + ("values",), + lambda year: year > SWITCH_START_YEAR, + id=SWITCH, + ), +] + + +@pytest.mark.parametrize("file, keys, check", GUARDS) +def test_reformed_parameters_are_first_dated_after_2015(file, keys, check): # Guard: the tests below only discriminate while each parameter's first - # dated value falls after 2015, so backdating has something to fill. - brackets = yaml.safe_load((PARAMETERS / SCALE_FILE).read_text())["brackets"] - assert first_dated_year(brackets[FIRST_DATE_BRACKET]["amount"]["values"]) == ( - FIRST_DATE_YEAR - ) - assert first_dated_year(brackets[EARLY_BRACKET]["amount"]["values"]) > ( - EARLY_YEAR + 1 + # dated value falls where the constants above assume. + year = first_dated_year(file, *keys) + assert check(year), ( + f"{file} is now first dated {year}; choose another parameter first " + "dated after 2015 or these regression tests stop discriminating." ) - range_values = yaml.safe_load((PARAMETERS / RANGE_FILE).read_text())["values"] - assert first_dated_year(range_values) == RANGE_START_YEAR - early_values = yaml.safe_load((PARAMETERS / EARLY_SCALAR_FILE).read_text())[ - "values" - ] - assert first_dated_year(early_values) > EARLY_SCALAR_YEAR + 1 - switch_values = yaml.safe_load((PARAMETERS / SWITCH_FILE).read_text())["values"] - assert first_dated_year(switch_values) > SWITCH_START_YEAR def test_reform_on_first_dated_value_is_not_backdated(reformed): - for year in YEARS: - expected = ( - FIRST_DATE_VALUE - if year == FIRST_DATE_YEAR - else scale_amount(system.parameters, FIRST_DATE_BRACKET, year) - ) - assert scale_amount(reformed, FIRST_DATE_BRACKET, year) == expected + name = f"{SCALE}[{FIRST_DATE_BRACKET}].amount" + baseline = series(system.parameters.get_child(name)) + assert series(reformed.get_child(name)) == { + **baseline, + FIRST_DATE_YEAR: FIRST_DATE_VALUE, + } def test_reform_before_first_dated_value_leaves_no_gap(reformed): - for year in YEARS: - expected = ( - EARLY_VALUE - if year == EARLY_YEAR - else scale_amount(system.parameters, EARLY_BRACKET, year) - ) - assert expected is not None - assert scale_amount(reformed, EARLY_BRACKET, year) == expected + name = f"{SCALE}[{EARLY_BRACKET}].amount" + baseline = series(system.parameters.get_child(name)) + assert None not in baseline.values() + assert series(reformed.get_child(name)) == {**baseline, EARLY_YEAR: EARLY_VALUE} def test_reformed_scale_keeps_every_bracket(reformed): baseline_scale = system.parameters.get_child(SCALE) reformed_scale = reformed.get_child(SCALE) - for year in YEARS: - instant = f"{year}-01-01" - assert len(reformed_scale(instant).thresholds) == len( - baseline_scale(instant).thresholds - ) + counts = {year: len(reformed_scale(f"{year}-01-01").thresholds) for year in YEARS} + assert counts == { + year: len(baseline_scale(f"{year}-01-01").thresholds) for year in YEARS + } def test_range_reform_from_first_dated_value_leaves_earlier_years(reformed): - baseline = system.parameters.get_child(RANGE_PARAMETER) - parameter = reformed.get_child(RANGE_PARAMETER) - for year in YEARS: - expected = ( - RANGE_VALUE if year >= RANGE_START_YEAR else baseline(f"{year}-01-01") - ) - assert parameter(f"{year}-01-01") == expected + baseline = series(system.parameters.get_child(RANGE_PARAMETER)) + assert series(reformed.get_child(RANGE_PARAMETER)) == { + year: RANGE_VALUE if year >= RANGE_START_YEAR else value + for year, value in baseline.items() + } + + +def test_range_reform_before_first_dated_value_leaves_earlier_years(reformed): + baseline = series(system.parameters.get_child(BEFORE_PARAMETER)) + assert series(reformed.get_child(BEFORE_PARAMETER)) == { + year: BEFORE_VALUE if year >= BEFORE_START_YEAR else value + for year, value in baseline.items() + } def test_scalar_reform_before_first_dated_value_leaves_no_gap(reformed): - baseline = system.parameters.get_child(EARLY_SCALAR) - parameter = reformed.get_child(EARLY_SCALAR) - for year in YEARS: - expected = ( - EARLY_SCALAR_VALUE - if year == EARLY_SCALAR_YEAR - else baseline(f"{year}-01-01") - ) - assert expected is not None - assert parameter(f"{year}-01-01") == expected + baseline = series(system.parameters.get_child(EARLY_SCALAR)) + assert None not in baseline.values() + assert series(reformed.get_child(EARLY_SCALAR)) == { + **baseline, + EARLY_SCALAR_YEAR: EARLY_SCALAR_VALUE, + } + + +def test_parameter_added_by_reform_is_backdated(reformed): + assert series(reformed.get_child(ADDED_PARAMETER)) == { + year: ADDED_VALUE for year in YEARS + } def test_only_reformed_parameters_are_marked_modified(reformed): # The macro-impact cache reads ``modified`` as "differs from current law". - # Backdating edits every late-dated parameter, and those edits must not - # leave the flag set on parameters the reform never touched. + # ``Parameter.update`` marks every late-dated parameter that backdating + # fills, so the reset in ``__init__`` must stay after backdating. This + # guards the new order; the old order passes it too. for name in ( f"{SCALE}[{FIRST_DATE_BRACKET}].amount", f"{SCALE}[{EARLY_BRACKET}].amount", RANGE_PARAMETER, + BEFORE_PARAMETER, EARLY_SCALAR, ): assert reformed.get_child(name).modified @@ -175,15 +264,15 @@ def test_only_reformed_parameters_are_marked_modified(reformed): def test_simulation_reform_leaves_earlier_years(): - # ``Simulation(reform=...)`` builds its system with the reform, the path - # policyengine.py and the web app's API take. - years = (RANGE_START_YEAR - 1, RANGE_START_YEAR) + # ``Simulation(reform=...)`` builds ``CountryTaxBenefitSystem(reform=...)`` + # (``spm.py`` ``_prepare_spm_system``); core then applies the reform again. + years = range(EARLY_SCALAR_YEAR - 1, RANGE_START_YEAR + 1) situation = { "people": { "worker": { "age": {year: 30 for year in years}, "employment_income": {year: 80_000 for year in years}, - "tip_income": {year: 45_000 for year in years}, + "tip_income": {year: TIPS for year in years}, "tip_income_deduction_occupation_requirement_met": { year: True for year in years }, @@ -194,35 +283,181 @@ def test_simulation_reform_leaves_earlier_years(): "household": { "members": ["worker"], "state_code": {year: "TX" for year in years}, + "qualified_passenger_vehicle_loan_interest": { + year: AUTO_LOAN_INTEREST for year in years + }, } }, } baseline = Simulation(situation=situation) reformed = Simulation( situation=situation, - reform={RANGE_PARAMETER: {f"{RANGE_START_YEAR}-01-01.2100-12-31": RANGE_VALUE}}, + reform={ + RANGE_PARAMETER: {f"{RANGE_START_YEAR}-01-01.2100-12-31": RANGE_VALUE}, + EARLY_SCALAR: {f"year:{EARLY_SCALAR_YEAR}:1": EARLY_SCALAR_VALUE}, + }, ) + + # The simulation's own tree, independent of how any formula gates by year. + cap = reformed.tax_benefit_system.parameters.get_child(RANGE_PARAMETER) + baseline_cap = system.parameters.get_child(RANGE_PARAMETER) before = RANGE_START_YEAR - 1 - assert ( - reformed.calculate("tip_income_deduction", before)[0] - == (baseline.calculate("tip_income_deduction", before)[0]) - ) - # Tips fall under the reformed cap in the reform's own year. - assert reformed.calculate("tip_income_deduction", RANGE_START_YEAR)[0] == 45_000 + assert cap(f"{before}-01-01") == baseline_cap(f"{before}-01-01") + + def tip_deduction(simulation, year): + return simulation.calculate("tip_income_deduction", year)[0] + + # Guard: a leaked cap only shows while the baseline computes the + # deduction before the cap's first dated year, below the reformed cap. + assert 0 < tip_deduction(baseline, before) < min(TIPS, RANGE_VALUE) + assert tip_deduction(reformed, before) == tip_deduction(baseline, before) + assert tip_deduction(reformed, RANGE_START_YEAR) == min(TIPS, RANGE_VALUE) + + def auto_loan_deduction(simulation, year): + return simulation.calculate("auto_loan_interest_deduction", year)[0] + + # The year after the bounded reform used to be undefined, so this formula + # raised ParameterNotFoundError; the year before it received the reform. + for year in (EARLY_SCALAR_YEAR - 1, EARLY_SCALAR_YEAR + 1): + assert auto_loan_deduction(baseline, year) == AUTO_LOAN_INTEREST + assert auto_loan_deduction(reformed, year) == AUTO_LOAN_INTEREST + assert auto_loan_deduction(reformed, EARLY_SCALAR_YEAR) == EARLY_SCALAR_VALUE def test_structural_reform_detected_at_start_instant_before_2024(): - # Detection reads each switch at ``start_instant``. It used to read them - # before backdating, so a switch first dated after the start was missing. - reformed_system = CountryTaxBenefitSystem( + # Detection reads the structural switches from ``start_instant``. The old + # order read them before backdating, so building at a start before some + # switch's first dated value raised ParameterNotFoundError, whatever the + # reform. ``Simulation`` builds the system with ``start_instant`` and then + # runs detection again. + simulation = Simulation( + situation={ + "people": {"parent": {"age": 40}}, + "tax_units": {"tax_unit": {"members": ["parent"]}}, + "households": {"household": {"members": ["parent"]}}, + }, reform={SWITCH: {f"{SWITCH_START_YEAR}-01-01.2100-12-31": True}}, start_instant=f"{SWITCH_START_YEAR}-01-01", ) - switch = reformed_system.parameters.get_child(SWITCH) + switch = simulation.tax_benefit_system.parameters.get_child(SWITCH) assert switch(f"{SWITCH_START_YEAR}-01-01") assert not switch(f"{SWITCH_START_YEAR - 1}-01-01") - formulas = reformed_system.variables[SWITCH_VARIABLE].formulas.values() - assert all( - formula.__code__.co_filename.endswith(SWITCH_REFORM_FILE) - for formula in formulas - ) + # Guard: the formula check only discriminates while the baseline formula + # lives in another file. + baseline_files = formula_files(system) + assert baseline_files and SWITCH_REFORM_FILE not in baseline_files + assert formula_files(simulation.tax_benefit_system) == {SWITCH_REFORM_FILE} + + +BACKDATED_FROM = "2015-01-01" +CHECKED_YEARS = range(2015, 2036) + + +def shifted(instant, years=0, days=0): + date = datetime.date.fromisoformat(instant) + date = date.replace(year=date.year + years) + datetime.timedelta(days=days) + return date.isoformat() + + +def backdated_first_date(parameter): + # Backdating inserts a copy of the first dated value at 2015-01-01. + values = parameter.values_list + if ( + len(values) > 1 + and values[-1].instant_str == BACKDATED_FROM + and values[-2].value == values[-1].value + ): + return values[-2].instant_str + return None + + +def perturbed(value): + if isinstance(value, bool): + return not value + if isinstance(value, (int, float)) and math.isfinite(value): + return type(value)(value * 1.5 + 7) + return None + + +@pytest.fixture(scope="module") +def every_backdated_parameter(): + """Reform every backdated numeric or boolean parameter at once. + + Parameters cycle through three windows: the year from the first dated + value, the year before it, and from a year before it to 2100 (the app's + format). ``gov.contrib`` is left out because its switches trigger + structural reforms. + """ + windows = {} + for parameter in sorted( + system.parameters.get_descendants(), key=lambda node: node.name + ): + if not isinstance(parameter, Parameter): + continue + if parameter.name.startswith("gov.contrib."): + continue + first = backdated_first_date(parameter) + value = perturbed(parameter(first)) if first else None + if value is None: + continue + shape = len(windows) % 3 + if shape == 0: + start, stop = first, shifted(first, years=1, days=-1) + elif shape == 1: + start, stop = shifted(first, years=-1), shifted(first, days=-1) + else: + start, stop = shifted(first, years=-1), "2100-12-31" + windows[parameter.name] = (start, stop, value) + reform = { + name: {f"{start}.{stop}": value} + for name, (start, stop, value) in windows.items() + } + constructed = CountryTaxBenefitSystem(reform=reform).parameters + # A dict reform applied to the finished system, as the YAML test runner + # and the household API apply one. + applied_afterwards = Reform.from_dict(reform, country_id="us")(system).parameters + return windows, constructed, applied_afterwards + + +def checked_instants(start, stop): + instants = {f"{year}-{day}" for year in CHECKED_YEARS for day in ("01-01", "07-01")} + instants |= {start, stop, shifted(start, days=-1), shifted(stop, days=1)} + return sorted(t for t in instants if BACKDATED_FROM <= t <= "2100-12-31") + + +def test_every_backdated_parameter_changes_only_in_its_reform_period( + every_backdated_parameter, +): + # Invariant: from 2015 on, a reformed parameter equals the reform value + # inside the reform's period and its baseline value everywhere else. + windows, constructed, _ = every_backdated_parameter + assert len(windows) > 1_000 + violations = [] + for name, (start, stop, value) in windows.items(): + baseline = system.parameters.get_child(name) + reformed = constructed.get_child(name) + for instant in checked_instants(start, stop): + expected = value if start <= instant <= stop else baseline(instant) + if reformed(instant) != expected: + violations.append((name, instant, reformed(instant), expected)) + break + assert not violations, f"{len(violations)} parameters, e.g. {violations[:5]}" + + +def test_reform_at_construction_matches_reform_applied_afterwards( + every_backdated_parameter, +): + # Differential: building the system with a reform must match applying the + # same reform to the finished, already backdated system. + windows, constructed, applied_afterwards = every_backdated_parameter + violations = [] + for name, (start, stop, _) in windows.items(): + at_construction = constructed.get_child(name) + afterwards = applied_afterwards.get_child(name) + for instant in checked_instants(start, stop): + if at_construction(instant) != afterwards(instant): + violations.append( + (name, instant, at_construction(instant), afterwards(instant)) + ) + break + assert not violations, f"{len(violations)} parameters, e.g. {violations[:5]}" From bb010100ff405b31ed23ae5ea557a3f912adb72b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 23:57:47 -0400 Subject: [PATCH 3/5] Harden the backdating tests and check invariant 3 over the whole tree Review round 2 (findings 2, 4 and 5): - test_reform_at_construction_matches_afterwards_for_every_parameter compares every parameter, not only the ones the reform sets, between building with the reform and applying it afterwards. The derived _per_capita series are skipped: construction rebuilds them from the reformed totals and Reform.from_dict does not. - The selection-size guard moves into the shared fixture, so neither exhaustive test can pass on an empty selection. - shifted() moves Feb 29 to Feb 28 in a non-leap year instead of raising; test_shifted_moves_february_29_to_february_28 covers it. - Locals no longer shadow the module's reformed fixture. - The first backdated year comes from FIRST_MODELED_YEAR (#9626). Co-Authored-By: Claude Opus 5.5 --- .../parameters/test_reform_backdating.py | 99 ++++++++++++++++--- 1 file changed, 84 insertions(+), 15 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/parameters/test_reform_backdating.py b/policyengine_us/tests/policy/baseline/parameters/test_reform_backdating.py index a67c5a81ecf..eecd0835d0a 100644 --- a/policyengine_us/tests/policy/baseline/parameters/test_reform_backdating.py +++ b/policyengine_us/tests/policy/baseline/parameters/test_reform_backdating.py @@ -30,6 +30,8 @@ from policyengine_us import Simulation from policyengine_us.system import COUNTRY_DIR, CountryTaxBenefitSystem, system +from policyengine_us.tools.parameters import FIRST_MODELED_YEAR +from policyengine_us.tools.per_capita_uprating import DERIVED_FROM PARAMETERS = COUNTRY_DIR / "parameters" @@ -78,7 +80,7 @@ ADDED_YEAR = 2025 ADDED_VALUE = 7 -YEARS = range(2015, 2031) +YEARS = range(FIRST_MODELED_YEAR, 2031) TIPS = 45_000 AUTO_LOAN_INTEREST = 8_000 @@ -290,7 +292,7 @@ def test_simulation_reform_leaves_earlier_years(): }, } baseline = Simulation(situation=situation) - reformed = Simulation( + reformed_simulation = Simulation( situation=situation, reform={ RANGE_PARAMETER: {f"{RANGE_START_YEAR}-01-01.2100-12-31": RANGE_VALUE}, @@ -299,7 +301,7 @@ def test_simulation_reform_leaves_earlier_years(): ) # The simulation's own tree, independent of how any formula gates by year. - cap = reformed.tax_benefit_system.parameters.get_child(RANGE_PARAMETER) + cap = reformed_simulation.tax_benefit_system.parameters.get_child(RANGE_PARAMETER) baseline_cap = system.parameters.get_child(RANGE_PARAMETER) before = RANGE_START_YEAR - 1 assert cap(f"{before}-01-01") == baseline_cap(f"{before}-01-01") @@ -310,8 +312,10 @@ def tip_deduction(simulation, year): # Guard: a leaked cap only shows while the baseline computes the # deduction before the cap's first dated year, below the reformed cap. assert 0 < tip_deduction(baseline, before) < min(TIPS, RANGE_VALUE) - assert tip_deduction(reformed, before) == tip_deduction(baseline, before) - assert tip_deduction(reformed, RANGE_START_YEAR) == min(TIPS, RANGE_VALUE) + assert tip_deduction(reformed_simulation, before) == tip_deduction(baseline, before) + assert tip_deduction(reformed_simulation, RANGE_START_YEAR) == min( + TIPS, RANGE_VALUE + ) def auto_loan_deduction(simulation, year): return simulation.calculate("auto_loan_interest_deduction", year)[0] @@ -320,8 +324,11 @@ def auto_loan_deduction(simulation, year): # raised ParameterNotFoundError; the year before it received the reform. for year in (EARLY_SCALAR_YEAR - 1, EARLY_SCALAR_YEAR + 1): assert auto_loan_deduction(baseline, year) == AUTO_LOAN_INTEREST - assert auto_loan_deduction(reformed, year) == AUTO_LOAN_INTEREST - assert auto_loan_deduction(reformed, EARLY_SCALAR_YEAR) == EARLY_SCALAR_VALUE + assert auto_loan_deduction(reformed_simulation, year) == AUTO_LOAN_INTEREST + assert ( + auto_loan_deduction(reformed_simulation, EARLY_SCALAR_YEAR) + == EARLY_SCALAR_VALUE + ) def test_structural_reform_detected_at_start_instant_before_2024(): @@ -349,14 +356,25 @@ def test_structural_reform_detected_at_start_instant_before_2024(): assert formula_files(simulation.tax_benefit_system) == {SWITCH_REFORM_FILE} -BACKDATED_FROM = "2015-01-01" -CHECKED_YEARS = range(2015, 2036) +BACKDATED_FROM = f"{FIRST_MODELED_YEAR}-01-01" +CHECKED_YEARS = range(FIRST_MODELED_YEAR, 2036) def shifted(instant, years=0, days=0): date = datetime.date.fromisoformat(instant) - date = date.replace(year=date.year + years) + datetime.timedelta(days=days) - return date.isoformat() + try: + date = date.replace(year=date.year + years) + except ValueError: # Feb 29 in a non-leap year + date = date.replace(year=date.year + years, day=28) + return (date + datetime.timedelta(days=days)).isoformat() + + +def test_shifted_moves_february_29_to_february_28(): + # A parameter first dated on Feb 29 must not crash the window builder. + assert shifted("2024-02-29", years=1) == "2025-02-28" + assert shifted("2024-02-29", years=-1, days=-1) == "2023-02-27" + assert shifted("2024-02-29", years=4) == "2028-02-29" + assert shifted("2025-01-01", days=-1) == "2024-12-31" def backdated_first_date(parameter): @@ -408,6 +426,9 @@ def every_backdated_parameter(): else: start, stop = shifted(first, years=-1), "2100-12-31" windows[parameter.name] = (start, stop, value) + # Guard: the tests using this fixture would pass vacuously on an empty + # selection. + assert len(windows) > 1_000 reform = { name: {f"{start}.{stop}": value} for name, (start, stop, value) in windows.items() @@ -431,15 +452,16 @@ def test_every_backdated_parameter_changes_only_in_its_reform_period( # Invariant: from 2015 on, a reformed parameter equals the reform value # inside the reform's period and its baseline value everywhere else. windows, constructed, _ = every_backdated_parameter - assert len(windows) > 1_000 violations = [] for name, (start, stop, value) in windows.items(): baseline = system.parameters.get_child(name) - reformed = constructed.get_child(name) + reformed_parameter = constructed.get_child(name) for instant in checked_instants(start, stop): expected = value if start <= instant <= stop else baseline(instant) - if reformed(instant) != expected: - violations.append((name, instant, reformed(instant), expected)) + if reformed_parameter(instant) != expected: + violations.append( + (name, instant, reformed_parameter(instant), expected) + ) break assert not violations, f"{len(violations)} parameters, e.g. {violations[:5]}" @@ -461,3 +483,50 @@ def test_reform_at_construction_matches_reform_applied_afterwards( ) break assert not violations, f"{len(violations)} parameters, e.g. {violations[:5]}" + + +def same_value(a, b): + # NaN never equals itself, so two NaN values count as the same. + return a == b or ( + isinstance(a, float) + and isinstance(b, float) + and math.isnan(a) + and math.isnan(b) + ) + + +def same_values_list(left, right): + return [(v.instant_str, v.value) for v in left.values_list] == [ + (v.instant_str, v.value) for v in right.values_list + ] + + +def test_reform_at_construction_matches_afterwards_for_every_parameter( + every_backdated_parameter, +): + # The differential above, over the whole tree rather than only the + # parameters the reform sets. The derived ``_per_capita`` + # series are the expected exception: construction rebuilds them from the + # reformed totals (``add_per_capita_uprating``), while + # ``Reform.from_dict`` sets values through ``Parameter.update`` alone. + _, constructed, applied_afterwards = every_backdated_parameter + instants = [f"{year}-{day}" for year in CHECKED_YEARS for day in ("01-01", "07-01")] + checked = 0 + violations = [] + for parameter in constructed.get_descendants(): + if not isinstance(parameter, Parameter): + continue + if parameter.metadata.get(DERIVED_FROM): + continue + checked += 1 + afterwards = applied_afterwards.get_child(parameter.name) + if same_values_list(parameter, afterwards): + continue + for instant in instants: + if not same_value(parameter(instant), afterwards(instant)): + violations.append( + (parameter.name, instant, parameter(instant), afterwards(instant)) + ) + break + assert checked > 10_000 + assert not violations, f"{len(violations)} parameters, e.g. {violations[:5]}" From 743b6dfd3d7fc36b5764aff14f6b9452f06832f2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 03:10:40 -0400 Subject: [PATCH 4/5] Say which parameters the exhaustive backdating tests select backdated_first_date picks a parameter whose 2015-01-01 value repeats its next value. That is every backdated parameter, and also any parameter whose YAML dates 2015-01-01 itself with the same value as its next date: 178 of the 42,489 selected at a50e1e3a7c, mostly state sales tax table cells. The invariants hold for those too, so the selection stays; the comments now say so. The module docstring says the second pass reaches parameters a reform added or replaced (review round 2, finding 3). Co-Authored-By: Claude Opus 5.5 --- .../baseline/parameters/test_reform_backdating.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/parameters/test_reform_backdating.py b/policyengine_us/tests/policy/baseline/parameters/test_reform_backdating.py index eecd0835d0a..42dee090530 100644 --- a/policyengine_us/tests/policy/baseline/parameters/test_reform_backdating.py +++ b/policyengine_us/tests/policy/baseline/parameters/test_reform_backdating.py @@ -16,7 +16,7 @@ ``ParameterNotFoundError`` while the system was built. Backdating now runs before the reform is applied, on the baseline values, and -again afterwards for any parameter the reform added. +again afterwards for any parameter the reform added or replaced. """ import datetime @@ -378,7 +378,10 @@ def test_shifted_moves_february_29_to_february_28(): def backdated_first_date(parameter): - # Backdating inserts a copy of the first dated value at 2015-01-01. + # Backdating inserts a copy of the first dated value at 2015-01-01. A + # parameter whose YAML dates 2015-01-01 itself, with the same value as its + # next date, looks the same and is selected too; the invariants hold for + # it as well. values = parameter.values_list if ( len(values) > 1 @@ -401,7 +404,8 @@ def perturbed(value): def every_backdated_parameter(): """Reform every backdated numeric or boolean parameter at once. - Parameters cycle through three windows: the year from the first dated + The selection also takes the few parameters that only look backdated (see + ``backdated_first_date``). Parameters cycle through three windows: the year from the first dated value, the year before it, and from a year before it to 2100 (the app's format). ``gov.contrib`` is left out because its switches trigger structural reforms. From 81e427cf852fc417ece06e30e37a84529b330f1c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 04:14:44 -0400 Subject: [PATCH 5/5] Say the second backdating pass also reaches replaced parameters The changelog fragment now says parameters a reform adds or replaces are backdated afterwards, as the second-pass comment in system.py does (review round 2, finding 3). Correction to 743b6dfd3d's message: 176 of the 42,489 selected parameters only look backdated (139 state sales tax table cells, 36 Arkansas low-income tax table entries and the SSA PIA threshold), not 178. Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-backdate-before-reforms.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/fix-backdate-before-reforms.fixed.md b/changelog.d/fix-backdate-before-reforms.fixed.md index 1d80f4e1341..b3201fddd3e 100644 --- a/changelog.d/fix-backdate-before-reforms.fixed.md +++ b/changelog.d/fix-backdate-before-reforms.fixed.md @@ -1 +1 @@ -Reforms to a parameter first dated after 2015 no longer change years outside the reform's period. Parameters are now backdated to 2015 before the reform is applied (and parameters a reform adds are backdated afterwards, as before), so a reform starting on or before a parameter's first dated value is no longer copied back to 2015, and a reform ending before that value no longer leaves the years in between undefined, which dropped brackets from scales and made formulas reading the parameter raise ParameterNotFoundError. CountryTaxBenefitSystem and Simulation(reform=...) also now build with any start_instant from 2015 through 2023; they raised ParameterNotFoundError because structural-reform detection read contrib parameters first dated after the start before they were backdated. +Reforms to a parameter first dated after 2015 no longer change years outside the reform's period. Parameters are now backdated to 2015 before the reform is applied (and parameters a reform adds or replaces are backdated afterwards, as before), so a reform starting on or before a parameter's first dated value is no longer copied back to 2015, and a reform ending before that value no longer leaves the years in between undefined, which dropped brackets from scales and made formulas reading the parameter raise ParameterNotFoundError. CountryTaxBenefitSystem and Simulation(reform=...) also now build with any start_instant from 2015 through 2023; they raised ParameterNotFoundError because structural-reform detection read contrib parameters first dated after the start before they were backdated.