diff --git a/changelog.d/scale-threshold-uprating.added.md b/changelog.d/scale-threshold-uprating.added.md new file mode 100644 index 00000000..8fdfdd84 --- /dev/null +++ b/changelog.d/scale-threshold-uprating.added.md @@ -0,0 +1 @@ +Support scale threshold uprating via bracket-level `metadata.uprating` (value side), bracket per-component `metadata.threshold.uprating`/`metadata.amount.uprating`, bracket `metadata.uprate_thresholds`, and scale-level `metadata.threshold_uprating`, without changing the working per-leaf `threshold.metadata.uprating` form. diff --git a/policyengine_core/parameters/parameter_scale.py b/policyengine_core/parameters/parameter_scale.py index de471ea2..9b92a75b 100644 --- a/policyengine_core/parameters/parameter_scale.py +++ b/policyengine_core/parameters/parameter_scale.py @@ -94,6 +94,7 @@ def propagate_uprating(self) -> None: bracket.propagate_uprating( self.metadata.get("uprating"), threshold=self.metadata.get("uprate_thresholds", False), + threshold_uprating=self.metadata.get("threshold_uprating"), ) def get_descendants(self) -> Iterable: diff --git a/policyengine_core/parameters/parameter_scale_bracket.py b/policyengine_core/parameters/parameter_scale_bracket.py index e4b31bcc..8b941746 100644 --- a/policyengine_core/parameters/parameter_scale_bracket.py +++ b/policyengine_core/parameters/parameter_scale_bracket.py @@ -1,4 +1,4 @@ -from typing import Iterable +from typing import Iterable, Optional from policyengine_core.parameters import Parameter, ParameterNode from policyengine_core.parameters.config import COMMON_KEYS @@ -17,6 +17,12 @@ class ParameterScaleBracket(ParameterNode): # flagged by the loader (issue #505). _allowed_keys = frozenset(_component_keys).union(COMMON_KEYS) + # The component that carries the bracket's threshold, versus the components + # that carry its "value" (amount/rate/base). A bare ``uprating`` applies to + # the value side; the threshold is opted in separately (issue #390). + _threshold_key = "threshold" + _value_keys = ("amount", "rate", "average_rate", "base") + @staticmethod def allowed_unit_keys(): return [key + "_unit" for key in ParameterScaleBracket._component_keys] @@ -26,11 +32,75 @@ def get_descendants(self) -> Iterable[Parameter]: if key in self.children: yield self.children[key] - def propagate_uprating(self, uprating: str, threshold: bool = False) -> None: + def propagate_uprating( + self, + uprating: str, + threshold: bool = False, + threshold_uprating: Optional[str] = None, + ) -> None: + """Route uprating metadata down onto the bracket's component leaves. + + Uprating is applied by reading each leaf ``Parameter``'s + ``metadata["uprating"]``. Scale parameters carry their uprating intent on + the scale, the bracket, or the individual component, so this method + resolves all of those into per-leaf ``uprating`` entries. See issue #390. + + Resolution order for a component (highest priority first): + + 1. the component leaf's own ``metadata.uprating`` (per-leaf form, e.g. + ``brackets[i].threshold.metadata.uprating``); + 2. a bracket-level per-component override + (``brackets[i].metadata.threshold.uprating``); + 3. a bracket-level default: ``brackets[i].metadata.uprating`` for the + value side, and for the threshold either + ``brackets[i].metadata.threshold_uprating`` or the bare bracket + ``uprating`` when ``brackets[i].metadata.uprate_thresholds`` is set; + 4. the scale-level default passed in by :class:`ParameterScale` + (``uprating`` for the value side, gated by ``uprate_thresholds`` for + the threshold; ``threshold_uprating`` for the threshold). + + Args: + uprating: Scale-level default uprating for the value side (may be + ``None``). + threshold: Whether the scale-level ``uprating`` should also apply to + thresholds (the scale's ``uprate_thresholds`` flag). + threshold_uprating: Scale-level uprating dedicated to thresholds (may + be ``None``). + """ + bracket_meta = self.metadata + + # Bracket-level defaults, falling back to the scale-level ones. + bracket_value_uprating = bracket_meta.get("uprating", uprating) + bracket_threshold_uprating = bracket_meta.get( + "threshold_uprating", threshold_uprating + ) + bracket_uprate_thresholds = bracket_meta.get("uprate_thresholds", threshold) + for key in self._component_keys: - if key in self.children: - if key == "threshold" and not threshold: - continue - self.children[key].metadata["uprating"] = uprating or self.children[ - key - ].metadata.get("uprating") + if key not in self.children: + continue + child = self.children[key] + + # (2) bracket-level per-component override, e.g. metadata.threshold. + component_override = None + component_meta = bracket_meta.get(key) + if isinstance(component_meta, dict): + component_override = component_meta.get("uprating") + + if key == self._threshold_key: + # Threshold: dedicated index, else bare bracket uprating when the + # bracket/scale opts thresholds in. + default = bracket_threshold_uprating + if default is None and bracket_uprate_thresholds: + default = bracket_value_uprating + else: + # Value side (amount/rate/base/average_rate). + default = bracket_value_uprating + + resolved = ( + child.metadata.get("uprating") # (1) per-leaf wins + or component_override # (2) + or default # (3)/(4) + ) + if resolved is not None: + child.metadata["uprating"] = resolved diff --git a/tests/core/parameters/operations/test_scale_threshold_uprating.py b/tests/core/parameters/operations/test_scale_threshold_uprating.py new file mode 100644 index 00000000..bc62e4d5 --- /dev/null +++ b/tests/core/parameters/operations/test_scale_threshold_uprating.py @@ -0,0 +1,360 @@ +"""Regression tests for issue #390: scale parameter threshold uprating. + +Covers the placements that were silently dead on core before this change, plus +the placements that already worked (locked in so they never regress): + +Working before (locked): +- per-leaf ``brackets[i].threshold.metadata.uprating`` +- per-leaf ``brackets[i].amount.metadata.uprating`` +- scale ``metadata.uprating`` + ``uprate_thresholds: true`` + +Fixed here: +- bracket-level ``brackets[i].metadata.uprating`` (value-side: amount/rate/...) +- bracket-level ``brackets[i].metadata.threshold.uprating`` (sub-key form) +- bracket-level ``brackets[i].metadata.amount.uprating`` (sub-key form) +- bracket-level ``metadata.uprating`` + ``metadata.uprate_thresholds: true`` +- scale ``metadata.threshold_uprating`` (distinct index for all thresholds) +""" + +import pytest + +from policyengine_core.parameters import ( + ParameterNode, + ParameterScale, + uprate_parameters, +) + + +def _index(): + # A simple index growing 100 -> 110 (a 10% step in 2026). + return {"values": {"2025-01-01": 100, "2026-01-01": 110}} + + +# --------------------------------------------------------------------------- +# Working forms locked as regressions (per issue #390 evidence comments). +# --------------------------------------------------------------------------- + + +def test_per_leaf_threshold_metadata_uprating_still_works(): + root = ParameterNode( + data={ + "index": _index(), + "scale": { + "metadata": {"type": "marginal_rate"}, + "brackets": [ + { + "threshold": { + "values": {"2025-01-01": 3000}, + "metadata": {"uprating": "index"}, + }, + "rate": {"values": {"2025-01-01": 0.1}}, + }, + ], + }, + } + ) + uprate_parameters(root) + assert root.scale.brackets[0].threshold("2026-01-01") == pytest.approx(3300) + + +def test_per_leaf_amount_metadata_uprating_still_works(): + root = ParameterNode( + data={ + "index": _index(), + "scale": { + "metadata": {"type": "single_amount"}, + "brackets": [ + { + "threshold": {"values": {"2025-01-01": 0}}, + "amount": { + "values": {"2025-01-01": 4000}, + "metadata": {"uprating": "index"}, + }, + }, + ], + }, + } + ) + uprate_parameters(root) + assert root.scale.brackets[0].amount("2026-01-01") == pytest.approx(4400) + + +def test_scale_uprating_with_uprate_thresholds_still_works(): + root = ParameterNode( + data={ + "index": _index(), + "scale": { + "metadata": { + "type": "marginal_rate", + "uprating": "index", + "uprate_thresholds": True, + }, + "brackets": [ + { + "threshold": {"values": {"2025-01-01": 5000}}, + "rate": {"values": {"2025-01-01": 0.1}}, + }, + ], + }, + } + ) + uprate_parameters(root) + assert root.scale.brackets[0].threshold("2026-01-01") == pytest.approx(5500) + + +# --------------------------------------------------------------------------- +# Broken forms this change fixes. +# --------------------------------------------------------------------------- + + +def test_bracket_level_uprating_applies_to_amount(): + # brackets[i].metadata.uprating -> value side (amount), like scale-level bare. + root = ParameterNode( + data={ + "index": _index(), + "scale": { + "metadata": {"type": "single_amount"}, + "brackets": [ + { + "threshold": {"values": {"2025-01-01": 0}}, + "amount": {"values": {"2025-01-01": 4000}}, + "metadata": {"uprating": "index"}, + }, + ], + }, + } + ) + uprate_parameters(root) + assert root.scale.brackets[0].amount("2026-01-01") == pytest.approx(4400) + + +def test_bracket_level_uprating_does_not_touch_threshold_by_default(): + # Bare bracket uprating is value-side only; the threshold stays frozen unless + # explicitly requested (mirrors scale-level semantics). + root = ParameterNode( + data={ + "index": _index(), + "scale": { + "metadata": {"type": "single_amount"}, + "brackets": [ + { + "threshold": {"values": {"2025-01-01": 2000}}, + "amount": {"values": {"2025-01-01": 4000}}, + "metadata": {"uprating": "index"}, + }, + ], + }, + } + ) + uprate_parameters(root) + assert root.scale.brackets[0].threshold("2026-01-01") == pytest.approx(2000) + + +def test_bracket_level_threshold_subkey_uprating_applies_to_threshold(): + # brackets[i].metadata.threshold.uprating -> threshold leaf (issue body form). + root = ParameterNode( + data={ + "index": _index(), + "scale": { + "metadata": {"type": "marginal_rate"}, + "brackets": [ + { + "threshold": {"values": {"2025-01-01": 1000}}, + "rate": {"values": {"2025-01-01": 0.1}}, + "metadata": {"threshold": {"uprating": "index"}}, + }, + ], + }, + } + ) + uprate_parameters(root) + assert root.scale.brackets[0].threshold("2026-01-01") == pytest.approx(1100) + + +def test_bracket_level_amount_subkey_uprating_applies_to_amount(): + # brackets[i].metadata.amount.uprating -> amount leaf (symmetric sub-key form). + root = ParameterNode( + data={ + "index": _index(), + "scale": { + "metadata": {"type": "single_amount"}, + "brackets": [ + { + "threshold": {"values": {"2025-01-01": 0}}, + "amount": {"values": {"2025-01-01": 4000}}, + "metadata": {"amount": {"uprating": "index"}}, + }, + ], + }, + } + ) + uprate_parameters(root) + assert root.scale.brackets[0].amount("2026-01-01") == pytest.approx(4400) + + +def test_bracket_level_uprate_thresholds_flag_applies_to_threshold(): + # brackets[i].metadata.uprating + metadata.uprate_thresholds -> threshold too. + root = ParameterNode( + data={ + "index": _index(), + "scale": { + "metadata": {"type": "marginal_rate"}, + "brackets": [ + { + "threshold": {"values": {"2025-01-01": 1000}}, + "rate": {"values": {"2025-01-01": 0.1}}, + "metadata": { + "uprating": "index", + "uprate_thresholds": True, + }, + }, + ], + }, + } + ) + uprate_parameters(root) + assert root.scale.brackets[0].threshold("2026-01-01") == pytest.approx(1100) + + +def test_scale_level_threshold_uprating_applies_to_all_thresholds(): + # scale metadata.threshold_uprating -> distinct index for every threshold. + root = ParameterNode( + data={ + "index": _index(), + "scale": { + "metadata": { + "type": "marginal_rate", + "threshold_uprating": "index", + }, + "brackets": [ + { + "threshold": {"values": {"2025-01-01": 2000}}, + "rate": {"values": {"2025-01-01": 0.1}}, + }, + { + "threshold": {"values": {"2025-01-01": 6000}}, + "rate": {"values": {"2025-01-01": 0.2}}, + }, + ], + }, + } + ) + uprate_parameters(root) + assert root.scale.brackets[0].threshold("2026-01-01") == pytest.approx(2200) + assert root.scale.brackets[1].threshold("2026-01-01") == pytest.approx(6600) + + +def test_scale_threshold_uprating_leaves_amounts_frozen(): + # threshold_uprating must not touch the value side. + root = ParameterNode( + data={ + "index": _index(), + "scale": { + "metadata": { + "type": "single_amount", + "threshold_uprating": "index", + }, + "brackets": [ + { + "threshold": {"values": {"2025-01-01": 2000}}, + "amount": {"values": {"2025-01-01": 4000}}, + }, + ], + }, + } + ) + uprate_parameters(root) + assert root.scale.brackets[0].threshold("2026-01-01") == pytest.approx(2200) + assert root.scale.brackets[0].amount("2026-01-01") == pytest.approx(4000) + + +def test_scale_threshold_uprating_and_amount_uprating_combine(): + # Distinct indices for thresholds vs amounts on the same scale. + root = ParameterNode( + data={ + "index_low": {"values": {"2025-01-01": 100, "2026-01-01": 110}}, + "index_high": {"values": {"2025-01-01": 100, "2026-01-01": 120}}, + "scale": { + "metadata": { + "type": "single_amount", + "uprating": "index_high", + "threshold_uprating": "index_low", + }, + "brackets": [ + { + "threshold": {"values": {"2025-01-01": 2000}}, + "amount": {"values": {"2025-01-01": 4000}}, + }, + ], + }, + } + ) + uprate_parameters(root) + assert root.scale.brackets[0].threshold("2026-01-01") == pytest.approx(2200) + assert root.scale.brackets[0].amount("2026-01-01") == pytest.approx(4800) + + +def test_per_leaf_threshold_overrides_scale_threshold_uprating(): + # Individual threshold metadata should win over the scale-level default. + root = ParameterNode( + data={ + "index_low": {"values": {"2025-01-01": 100, "2026-01-01": 110}}, + "index_high": {"values": {"2025-01-01": 100, "2026-01-01": 120}}, + "scale": { + "metadata": { + "type": "marginal_rate", + "threshold_uprating": "index_low", + }, + "brackets": [ + { + "threshold": { + "values": {"2025-01-01": 2000}, + "metadata": {"uprating": "index_high"}, + }, + "rate": {"values": {"2025-01-01": 0.1}}, + }, + { + "threshold": {"values": {"2025-01-01": 6000}}, + "rate": {"values": {"2025-01-01": 0.2}}, + }, + ], + }, + } + ) + uprate_parameters(root) + # Bracket 0 uses its own index_high (20% growth). + assert root.scale.brackets[0].threshold("2026-01-01") == pytest.approx(2400) + # Bracket 1 falls back to the scale threshold_uprating index_low (10%). + assert root.scale.brackets[1].threshold("2026-01-01") == pytest.approx(6600) + + +def test_scale_get_descendants_reaches_all_bracket_leaves(): + # Lock in that uprate_parameters can see every bracket leaf (get_descendants + # coverage), independent of the propagation path. + from policyengine_core.parameters import Parameter + + scale = ParameterScale( + "scale", + data={ + "metadata": {"type": "marginal_rate"}, + "brackets": [ + { + "threshold": {"values": {"2025-01-01": 0}}, + "rate": {"values": {"2025-01-01": 0.1}}, + }, + { + "threshold": {"values": {"2025-01-01": 1000}}, + "rate": {"values": {"2025-01-01": 0.2}}, + }, + ], + }, + file_path=None, + ) + leaves = [d for d in scale.get_descendants() if isinstance(d, Parameter)] + names = {leaf.name for leaf in leaves} + assert names == { + "scale[0].threshold", + "scale[0].rate", + "scale[1].threshold", + "scale[1].rate", + }