diff --git a/changelog.d/453.fixed.md b/changelog.d/453.fixed.md new file mode 100644 index 00000000..786059d0 --- /dev/null +++ b/changelog.d/453.fixed.md @@ -0,0 +1 @@ +Open-ended parametric reforms (`end_date=None`, including all flat-dict reforms) now apply from their start date onward in microsimulation runs instead of only within the start year. Open-ended values are serialised with explicit period stops, each clipped at the next-later value of the same parameter. diff --git a/src/policyengine/utils/parametric_reforms.py b/src/policyengine/utils/parametric_reforms.py index 025df22e..58d2c1f2 100644 --- a/src/policyengine/utils/parametric_reforms.py +++ b/src/policyengine/utils/parametric_reforms.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable +from datetime import timedelta from typing import TYPE_CHECKING, Optional, Union from policyengine_core.periods import period @@ -11,6 +12,22 @@ from policyengine.core.dynamic import Dynamic from policyengine.core.policy import Policy +# policyengine-core's Reform.from_dict applies a bare "YYYY-MM-DD" period +# key for a single instant only, so open-ended values (end_date=None) need +# an explicit far-future stop to hold beyond their start year (#453). +OPEN_ENDED_REFORM_STOP = "2100-12-31" + + +def _open_ended_stop_str(pv: ParameterValue, siblings: list[ParameterValue]) -> str: + """Stop date for an open-ended value: the day before the next-later + value of the same parameter starts, or far-future if none follows.""" + later_starts = [ + other.start_date for other in siblings if other.start_date > pv.start_date + ] + if later_starts: + return (min(later_starts) - timedelta(days=1)).strftime("%Y-%m-%d") + return OPEN_ENDED_REFORM_STOP + def reform_dict_from_parameter_values( parameter_values: Optional[list[ParameterValue]], @@ -21,6 +38,10 @@ def reform_dict_from_parameter_values( This format is accepted by policyengine_us.Microsimulation(reform=...) and policyengine_uk.Microsimulation(reform=...) at construction time. + Every period key uses the "start.stop" range format. Values with + end_date=None run from their start date until the next-later value of + the same parameter takes over, or until OPEN_ENDED_REFORM_STOP. + Args: parameter_values: List of ParameterValue objects to convert. @@ -28,26 +49,27 @@ def reform_dict_from_parameter_values( A dict mapping parameter names to period-value dicts, e.g.: { "gov.irs.deductions.standard.amount.SINGLE": { - "2024-01-01": 29200 + "2024-01-01.2100-12-31": 29200 } } """ if not parameter_values: return None - reform_dict = {} + by_parameter: dict[str, list[ParameterValue]] = {} for pv in parameter_values: - param_name = pv.parameter.name - if param_name not in reform_dict: - reform_dict[param_name] = {} - - # Format the period string - period_str = pv.start_date.strftime("%Y-%m-%d") - if pv.end_date: - # Use period range format: "start.end" - period_str = f"{period_str}.{pv.end_date.strftime('%Y-%m-%d')}" - - reform_dict[param_name][period_str] = pv.value + by_parameter.setdefault(pv.parameter.name, []).append(pv) + + reform_dict: dict[str, dict] = {} + for param_name, values in by_parameter.items(): + periods = reform_dict.setdefault(param_name, {}) + for pv in values: + start_str = pv.start_date.strftime("%Y-%m-%d") + if pv.end_date: + stop_str = pv.end_date.strftime("%Y-%m-%d") + else: + stop_str = _open_ended_stop_str(pv, values) + periods[f"{start_str}.{stop_str}"] = pv.value return reform_dict diff --git a/tests/test_entity_utils.py b/tests/test_entity_utils.py index cc51c6b1..17a1fc5a 100644 --- a/tests/test_entity_utils.py +++ b/tests/test_entity_utils.py @@ -324,7 +324,7 @@ def test__given_parameter_values__then_returns_reform_dict(self): result = build_reform_dict(obj) - assert result == {"gov.test.param": {"2024-01-01": 1000}} + assert result == {"gov.test.param": {"2024-01-01.2100-12-31": 1000}} class TestMergeReformDicts: diff --git a/tests/test_parametric_reforms.py b/tests/test_parametric_reforms.py index 418f2fce..3c752226 100644 --- a/tests/test_parametric_reforms.py +++ b/tests/test_parametric_reforms.py @@ -66,8 +66,8 @@ def test__given_single_parameter_value__then_returns_dict_with_one_entry( # Then assert result is not None assert MOCK_PARAM_SINGLE.name in result - assert "2024-01-01" in result[MOCK_PARAM_SINGLE.name] - assert result[MOCK_PARAM_SINGLE.name]["2024-01-01"] == 29200 + assert "2024-01-01.2100-12-31" in result[MOCK_PARAM_SINGLE.name] + assert result[MOCK_PARAM_SINGLE.name]["2024-01-01.2100-12-31"] == 29200 def test__given_parameter_value_with_end_date__then_uses_period_range_format( self, @@ -93,9 +93,9 @@ def test__given_parameter_value_with_end_date__then_uses_period_range_format( def test__given_multiple_periods_same_parameter__then_includes_all_periods( self, ): - """Given: Multiple parameter values for same parameter (different periods) + """Given: Multiple open-ended values for same parameter (different periods) When: Calling reform_dict_from_parameter_values - Then: Returns dict with all periods for that parameter + Then: Each value runs until the next-later value starts """ # Given param_values = MULTI_PERIOD_PARAM_VALUES @@ -108,8 +108,8 @@ def test__given_multiple_periods_same_parameter__then_includes_all_periods( param_name = MOCK_PARAM_SINGLE.name assert param_name in result assert len(result[param_name]) == 2 - assert result[param_name]["2024-01-01"] == 29200 - assert result[param_name]["2025-01-01"] == 30000 + assert result[param_name]["2024-01-01.2024-12-31"] == 29200 + assert result[param_name]["2025-01-01.2100-12-31"] == 30000 def test__given_multiple_different_parameters__then_includes_all_parameters( self, @@ -130,9 +130,9 @@ def test__given_multiple_different_parameters__then_includes_all_parameters( assert MOCK_PARAM_SINGLE.name in result assert MOCK_PARAM_JOINT.name in result assert MOCK_PARAM_TAX_RATE.name in result - assert result[MOCK_PARAM_SINGLE.name]["2024-01-01"] == 29200 - assert result[MOCK_PARAM_JOINT.name]["2024-01-01"] == 58400 - assert result[MOCK_PARAM_TAX_RATE.name]["2024-01-01"] == 0.10 + assert result[MOCK_PARAM_SINGLE.name]["2024-01-01.2100-12-31"] == 29200 + assert result[MOCK_PARAM_JOINT.name]["2024-01-01.2100-12-31"] == 58400 + assert result[MOCK_PARAM_TAX_RATE.name]["2024-01-01.2100-12-31"] == 0.10 def test__given_parameter_value__then_preserves_value_type(self): """Given: Parameter values with different types (int, float) @@ -151,8 +151,80 @@ def test__given_parameter_value__then_preserves_value_type(self): result = reform_dict_from_parameter_values([pv_float]) # Then - assert result["gov.test.rate"]["2024-01-01"] == 0.15 - assert isinstance(result["gov.test.rate"]["2024-01-01"], float) + assert result["gov.test.rate"]["2024-01-01.2100-12-31"] == 0.15 + assert isinstance(result["gov.test.rate"]["2024-01-01.2100-12-31"], float) + + def test__given_open_ended_value__then_period_stops_far_future(self): + """Given: Parameter value with no end date (open-ended) + When: Calling reform_dict_from_parameter_values + Then: Emits an explicit far-future stop instead of a bare start + date, which policyengine-core Reform.from_dict would apply for a + single instant only (issue #453) + """ + # Given + pv = SINGLE_PARAM_VALUE + + # When + result = reform_dict_from_parameter_values([pv]) + + # Then + (period_key,) = result[MOCK_PARAM_SINGLE.name] + assert period_key == "2024-01-01.2100-12-31" + + def test__given_open_ended_values_in_reverse_order__then_clips_at_next_start( + self, + ): + """Given: Two open-ended values for one parameter, listed latest-first + When: Calling reform_dict_from_parameter_values + Then: The earlier value stops the day before the later one starts, + regardless of list order + """ + # Given + param = create_mock_parameter("gov.test.step") + later = create_parameter_value( + parameter=param, value=2_000, start_date=date(2028, 1, 1) + ) + earlier = create_parameter_value( + parameter=param, value=1_000, start_date=date(2026, 1, 1) + ) + + # When + result = reform_dict_from_parameter_values([later, earlier]) + + # Then + assert result["gov.test.step"] == { + "2028-01-01.2100-12-31": 2_000, + "2026-01-01.2027-12-31": 1_000, + } + + def test__given_open_ended_and_explicit_end_values__then_open_ended_clips_at_next_start( + self, + ): + """Given: An open-ended value followed by a later value with an explicit end + When: Calling reform_dict_from_parameter_values + Then: The open-ended value stops the day before the later value + starts; the explicit end is preserved verbatim + """ + # Given + param = create_mock_parameter("gov.test.mixed") + open_ended = create_parameter_value( + parameter=param, value=100, start_date=date(2024, 1, 1) + ) + bounded = create_parameter_value( + parameter=param, + value=200, + start_date=date(2026, 1, 1), + end_date=date(2026, 12, 31), + ) + + # When + result = reform_dict_from_parameter_values([open_ended, bounded]) + + # Then + assert result["gov.test.mixed"] == { + "2024-01-01.2025-12-31": 100, + "2026-01-01.2026-12-31": 200, + } class TestSimulationModifierFromParameterValues: diff --git a/uv.lock b/uv.lock index 5cf26e02..dd39bb1b 100644 --- a/uv.lock +++ b/uv.lock @@ -2820,7 +2820,7 @@ wheels = [ [[package]] name = "policyengine" -version = "4.18.7" +version = "4.18.10" source = { editable = "." } dependencies = [ { name = "diskcache" },