From a633d18d0fea6b071dc398c9395feda0056ddef7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 9 Nov 2026 13:16:36 -0500 Subject: [PATCH 1/2] Add a base mode to parameter uprating An `uprating.base` block ({period, value}) makes uprating compute each value after the parameter's last explicit one as the base amount plus the rounded increase the index implies since the base period: base + round(base * (index[t] / index[base] - 1)) Statutes such as 26 U.S.C. 63(c)(7)(B)(ii), 24(i)(3) and 213(d)(10)(B)(i) round the increase over a stated base, which chained mode (round the uprated amount, chained from the last explicit value) cannot express. Chained mode stays the default and is unchanged. Refs #403. Co-Authored-By: Claude Opus 5.5 --- changelog.d/base-year-uprating.added.md | 1 + .../operations/uprate_parameters.py | 347 +++++++++++++++++- 2 files changed, 346 insertions(+), 2 deletions(-) create mode 100644 changelog.d/base-year-uprating.added.md diff --git a/changelog.d/base-year-uprating.added.md b/changelog.d/base-year-uprating.added.md new file mode 100644 index 000000000..121e23cd9 --- /dev/null +++ b/changelog.d/base-year-uprating.added.md @@ -0,0 +1 @@ +Uprating metadata can name a statutory base: with `base: {period, value}`, uprating computes each projected value as the base amount plus the increase the index implies over the base period, rounding that increase rather than the uprated amount. diff --git a/policyengine_core/parameters/operations/uprate_parameters.py b/policyengine_core/parameters/operations/uprate_parameters.py index ee756cc0c..29789d927 100644 --- a/policyengine_core/parameters/operations/uprate_parameters.py +++ b/policyengine_core/parameters/operations/uprate_parameters.py @@ -1,3 +1,5 @@ +import math + from numpy import ceil, floor # rrule is purposely imported this way to allow for programmatic @@ -6,7 +8,7 @@ from dateutil.relativedelta import relativedelta from dateutil.parser import parse from datetime import datetime -from typing import Optional, Union +from typing import Callable, Optional, Union from policyengine_core.parameters.operations.get_parameter import get_parameter from policyengine_core.parameters.parameter import Parameter @@ -14,7 +16,34 @@ ParameterAtInstant, ) from policyengine_core.parameters.parameter_node import ParameterNode -from policyengine_core.periods import instant +from policyengine_core.periods import Instant, instant + +#: Keys a base-mode ``uprating`` block may hold. Base mode rejects the others +#: (``start_instant``, ``at_defined_interval`` and the ``self`` keys) rather +#: than ignore them, and a misspelt key, such as ``rouding``, would otherwise +#: drop the rounding rule without a word. +BASE_MODE_UPRATING_KEYS = frozenset({"parameter", "base", "rounding"}) + +#: Keys of an ``uprating.base`` block. +UPRATING_BASE_KEYS = frozenset({"period", "value"}) + +#: Base mode treats an increase within this many rounding intervals of a whole +#: number of intervals as that whole number before it rounds. Index values +#: written in decimal, such as 102.6, are inexact in binary floating point, so +#: an increase that decimal arithmetic puts exactly on a multiple of the +#: interval can come out a few units in the last place below it, where +#: rounding down would drop a full interval. On a $50 interval the tolerance +#: is $0.00000005. +INCREASE_ROUNDING_TOLERANCE = 1e-9 + +#: The rounding functions ``rounding.type`` names. ``nearest`` is Python's +#: ``round``, which rounds an exact half to the even multiple, as chained +#: mode's rounding does. +INCREASE_ROUNDING_FUNCTIONS: dict[str, Callable[[float], float]] = { + "nearest": round, + "upwards": ceil, + "downwards": floor, +} def uprate_parameters(root: ParameterNode) -> ParameterNode: @@ -264,11 +293,30 @@ def uprate_parameter( root: ParameterNode, parameter_paths: Optional[dict[int, str]] = None, ) -> None: + """Add projected values to a parameter according to its uprating metadata. + + Chained mode, the default, grows the parameter's last explicit value by the + index's change since that value's instant and rounds the uprated amount. + Base mode, chosen by an ``uprating.base`` block, computes each projected + value from a fixed base amount and rounds the increase over it; see + :func:`uprate_parameter_from_base`. + + Args: + parameter: The parameter to uprate. Its ``values_list`` gains the + projected values. + root: The root of the parameter tree, where the index is looked up. + parameter_paths: The parameter's path in a cloned subtree, which + scopes the index lookup; see :func:`get_parameter_paths`. + """ if parameter_paths is None: parameter_paths = {} # Pull the uprating definition dict meta = normalize_uprating_metadata(parameter.metadata["uprating"]) + if isinstance(meta, dict) and "base" in meta: + uprate_parameter_from_base(parameter, root, meta, parameter_paths) + return + # If param is "self", construct the uprating table if meta["parameter"] == "self": uprating_parameter = construct_uprater_self( @@ -376,6 +424,301 @@ def uprate_parameter( parameter.values_list.sort(key=lambda x: x.instant_str, reverse=True) +def uprate_parameter_from_base( + parameter: Parameter, + root: ParameterNode, + meta: dict, + parameter_paths: Optional[dict[int, str]] = None, +) -> None: + """Project a parameter from a fixed base amount (base mode). + + Statutes such as 26 U.S.C. 63(c)(7)(B)(ii) and 213(d)(10)(B) increase a + stated dollar amount by the change in a price index since a base year and + round the increase, not the increased amount. Base mode computes, for + every instant ``t`` at which the uprating index has a value after the + parameter's last explicit value (and no earlier than the base period):: + + base_value + round(base_value * (index(t) / index(base) - 1)) + + ``rounding`` takes the same ``type`` and ``interval`` as in chained mode + and applies them to the increase; without ``rounding`` the value is + ``base_value * index(t) / index(base)``. Explicit values always win: base + mode adds values only after the last of them. An index below its + base-period level gives a value below the base. + + The ``base`` block holds: + + * ``period`` (required): any period or instant the parameter system reads, + such as ``2025`` or ``1997-01-01``. Base mode reads the index there, so + it names the base in the index's own periods. Where an index's value at + period ``Y`` stands for calendar year ``Y - 1``, a statute's base year + ``Y - 1`` is ``period: Y``. + * ``value`` (optional): the base amount. It defaults to the parameter's + value at ``period``. When the parameter has a value there, a stated + ``value`` must equal it, which catches a ``period`` one year off. + + Args: + parameter: The parameter to uprate. Its ``values_list`` gains the + projected values. + root: The root of the parameter tree, where the index is looked up. + meta: The parameter's uprating metadata, holding ``parameter``, + ``base`` and optionally ``rounding``. It is read, never changed: + a scale's brackets can share one dict. + parameter_paths: The parameter's path in a cloned subtree, which + scopes the index lookup; see :func:`get_parameter_paths`. + + Raises: + ValueError: The metadata is malformed or combines ``base`` with + ``start_instant``, ``at_defined_interval`` or ``self``; the index + is undefined, zero or not finite where base mode reads it; the + base amount is missing, not a number, or differs from the + parameter's value at the base period; or the parameter has no + values or its last value is null. + """ + if parameter_paths is None: + parameter_paths = {} + base_instant, stated_value = parse_uprating_base(parameter, meta) + rounding = ( + parse_increase_rounding(parameter, meta["rounding"]) + if "rounding" in meta + else None + ) + uprating_parameter = get_uprating_parameter( + root, + parameter, + meta["parameter"], + parameter_paths, + ) + index_at_base = get_base_mode_index_value( + parameter, uprating_parameter, base_instant + ) + base_value = get_base_value(parameter, base_instant, stated_value) + + if not parameter.values_list: + raise base_mode_error(parameter, "it has no values to project after.") + latest = max(parameter.values_list, key=lambda value: value.instant_str) + if latest.value is None: + raise base_mode_error( + parameter, + f"its last value, at {latest.instant_str}, is null, which ends the " + "parameter; base mode does not project past an explicit end.", + ) + last_instant = instant(latest.instant_str) + + for entry in uprating_parameter.values_list[::-1]: + entry_instant = instant(entry.instant_str) + if entry_instant <= last_instant or entry_instant < base_instant: + continue + index_at_entry = get_base_mode_index_value( + parameter, uprating_parameter, entry_instant + ) + value = project_from_base( + base_value, + index_at_entry / index_at_base, + rounding, + ) + if not math.isfinite(value): + raise base_mode_error( + parameter, + f"its projected value at {entry.instant_str} is {value!r}.", + ) + parameter.values_list.append( + ParameterAtInstant(parameter.name, entry.instant_str, data=value) + ) + parameter.values_list.sort(key=lambda x: x.instant_str, reverse=True) + + +def base_mode_error(parameter: Parameter, reason: str) -> ValueError: + return ValueError(f"Failed to uprate {parameter.name} from its base: {reason}") + + +def is_real_number(value) -> bool: + """Whether ``value`` is a finite int or float other than a bool.""" + return ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(value) + ) + + +def parse_uprating_base( + parameter: Parameter, meta: dict +) -> tuple[Instant, Optional[float]]: + """Validate a base-mode ``uprating`` block and read its ``base``. + + Returns: + The base instant and the stated base ``value`` (``None`` when the + block leaves it to the parameter's value at the base period). + """ + unknown_keys = sorted(set(meta) - BASE_MODE_UPRATING_KEYS) + if unknown_keys: + raise base_mode_error( + parameter, + "base mode takes only `parameter`, `base` and `rounding`, not " + + ", ".join(f"`{key}`" for key in unknown_keys) + + ".", + ) + if "parameter" not in meta: + raise base_mode_error(parameter, "`parameter` names no index.") + if meta["parameter"] == "self": + raise base_mode_error( + parameter, + "`parameter: self` names no index for base mode to read.", + ) + base = meta["base"] + if not isinstance(base, dict): + raise base_mode_error( + parameter, + "`base` must be a mapping with a `period` and an optional `value`, " + f"not {base!r}.", + ) + unknown_base_keys = sorted(set(base) - UPRATING_BASE_KEYS) + if unknown_base_keys: + raise base_mode_error( + parameter, + "`base` takes only `period` and `value`, not " + + ", ".join(f"`{key}`" for key in unknown_base_keys) + + ".", + ) + if base.get("period") is None: + raise base_mode_error(parameter, "`base` has no `period`.") + period = base["period"] + base_instant = None + if not isinstance(period, bool): + try: + base_instant = instant(period) + except (AssertionError, TypeError, ValueError): + base_instant = None + if not isinstance(base_instant, Instant): + raise base_mode_error( + parameter, + f"`base.period` {period!r} is not a period or instant, such as " + "2025 or 2025-01-01.", + ) + stated_value = base.get("value") + if stated_value is not None and not is_real_number(stated_value): + raise base_mode_error( + parameter, + f"`base.value` {stated_value!r} is not a finite number.", + ) + return base_instant, stated_value + + +def parse_increase_rounding( + parameter: Parameter, rounding_config +) -> tuple[float, Callable[[float], float]]: + """Read a base-mode ``rounding`` block. + + It takes chained mode's forms: a mapping with a ``type`` (``nearest``, + ``upwards`` or ``downwards``) and a positive ``interval``, or a bare + interval, which rounds to the nearest multiple. + + Returns: + The interval and the rounding function. + """ + if isinstance(rounding_config, dict): + rounding_type = rounding_config.get("type") + if rounding_type not in INCREASE_ROUNDING_FUNCTIONS: + raise base_mode_error( + parameter, + f"`rounding.type` {rounding_type!r} is not one of " + + ", ".join(f"`{name}`" for name in INCREASE_ROUNDING_FUNCTIONS) + + ".", + ) + interval = rounding_config.get("interval") + else: + rounding_type = "nearest" + interval = rounding_config + if not is_real_number(interval) or interval <= 0: + raise base_mode_error( + parameter, + f"the rounding interval {interval!r} is not a positive number.", + ) + return interval, INCREASE_ROUNDING_FUNCTIONS[rounding_type] + + +def get_base_mode_index_value( + parameter: Parameter, uprating_parameter: Parameter, at: Instant +) -> float: + """Read the uprating index at ``at``, which must be finite and nonzero.""" + value = uprating_parameter(at) + if not is_real_number(value) or value == 0: + raise base_mode_error( + parameter, + f"the uprating index {uprating_parameter.name} is {value!r} at {at}; " + "base mode needs a finite, nonzero value there.", + ) + return value + + +def get_base_value( + parameter: Parameter, base_instant: Instant, stated_value: Optional[float] +) -> float: + """The base amount: the stated ``value``, else the parameter's at the base.""" + value_at_base = parameter(base_instant) + if stated_value is None: + if not is_real_number(value_at_base): + raise base_mode_error( + parameter, + f"it has no numeric value at the base period {base_instant} " + f"(it has {value_at_base!r}); state the base amount as " + "`base.value`.", + ) + return value_at_base + if value_at_base is not None and not ( + is_real_number(value_at_base) + and math.isclose(stated_value, value_at_base, rel_tol=1e-9, abs_tol=1e-9) + ): + raise base_mode_error( + parameter, + f"`base.value` is {stated_value!r}, but the parameter's value at the " + f"base period {base_instant} is {value_at_base!r}. `base.period` " + "names the period whose value is the base amount, in the uprating " + "index's periods; check it is not a year off.", + ) + return stated_value + + +def project_from_base( + base_value: float, + index_ratio: float, + rounding: Optional[tuple[float, Callable[[float], float]]], +) -> float: + """The base amount plus the (rounded) increase the index ratio implies. + + Args: + base_value: The base amount. + index_ratio: The index at the projected period over the index at the + base period. + rounding: The interval and rounding function for the increase, or + ``None`` for no rounding. + """ + uprated = base_value * index_ratio + if rounding is None or not math.isfinite(uprated): + return float(uprated) + interval, rounding_fn = rounding + return float( + base_value + round_increase(uprated - base_value, interval, rounding_fn) + ) + + +def round_increase( + increase: float, interval: float, rounding_fn: Callable[[float], float] +) -> float: + """Round an increase to a multiple of ``interval`` with ``rounding_fn``. + + An increase within :data:`INCREASE_ROUNDING_TOLERANCE` intervals of a + multiple counts as that multiple, so floating-point error in an increase + that is a whole number of intervals cannot move it to the next multiple + down (or up). + """ + intervals = increase / interval + whole_intervals = round(intervals) + if abs(intervals - whole_intervals) <= INCREASE_ROUNDING_TOLERANCE: + intervals = whole_intervals + return float(rounding_fn(intervals)) * interval + + def round_uprated_value(meta: dict, uprated_value: float) -> float: rounding_config = meta["rounding"] if isinstance(rounding_config, float): From ab9eb049dea34c17ddb09c81f93d701dc5d6407e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 9 Nov 2026 14:09:49 -0500 Subject: [PATCH 2/2] Test and document base-mode uprating; snap large multiples Tests: - Example tests reproduce Rev. Proc. 2026-35 sections 3.15 (standard deduction) and 3.29 (long-term care premium limits) and cover each rounding form, the base-period default, scales, cloned subtrees, reforms applied before uprating and every error path. - Hypothesis property tests check that the increase is a whole number of intervals, brackets the exact increase, rises with the index, matches chained mode without rounding, and leaves explicit values alone. - A differential test checks chained mode against a frozen copy of the 7c84d941b implementation (tests/fixtures/chained_uprating_reference.py). Fixes found by those tests: - The fixed 1e-9-interval snap missed large exact multiples: $572,903.25 raised by 2,032 / 186 is exactly 22,743,643 quarter-dollar intervals above its base, which floats put 4e-9 intervals short. The snap now widens to 16 machine epsilons of the amounts. - An infinite base, such as a scale's -inf lowest threshold, raised an error; it now stays infinite. Docs: a new "Uprating" section in docs/usage/parameters.md covers both modes, how they differ (with the Rev. Proc. examples) and how base mode meets reforms. UpratingSchema documents `rounding` and `base`, replacing a `type` key that uprating never read, and gains UpratingBase. Refs #403. Co-Authored-By: Claude Opus 5.5 --- docs/usage/parameters.md | 73 ++ .../data_structures/parameter_metadata.py | 33 +- .../operations/uprate_parameters.py | 75 +- .../operations/test_base_uprating.py | 704 ++++++++++++++++++ .../operations/test_base_uprating_property.py | 551 ++++++++++++++ tests/fixtures/chained_uprating_reference.py | 166 +++++ 6 files changed, 1583 insertions(+), 19 deletions(-) create mode 100644 tests/core/parameters/operations/test_base_uprating.py create mode 100644 tests/core/parameters/operations/test_base_uprating_property.py create mode 100644 tests/fixtures/chained_uprating_reference.py diff --git a/docs/usage/parameters.md b/docs/usage/parameters.md index f4758a22e..40c188ca3 100644 --- a/docs/usage/parameters.md +++ b/docs/usage/parameters.md @@ -67,6 +67,79 @@ parameter.metadata["reference"] = [*parameter.metadata["reference"], new_referen The same holds within one tree: a dated value written in dictionary form (`value:`, with or without `metadata:`) holds its parameter's metadata values, and `propagate_parameter_metadata` gives a node's descendants the node's values. +## Uprating + +A parameter's `uprating` metadata extends its value history with another parameter, the index, such as a price index. When a tax-benefit system loads its parameters, `uprate_parameters` adds a value at each instant where the index has a value after the parameter's last explicit value. Uprating never changes an explicit value, so a new explicit value always replaces a projection. + +### Chained mode + +By default, uprating scales the last explicit value, at instant `L`, by the index's change since then: + +```none +value(t) = value(L) × index(t) / index(L) +``` + +```yaml +metadata: + uprating: + parameter: gov.example.price_index + rounding: + type: downwards # or nearest, or upwards + interval: 50 +``` + +`rounding` rounds the uprated amount to a multiple of `interval`. A bare float, such as `rounding: 0.01`, rounds to the nearest multiple. Chained mode also takes `start_instant`, to project from an instant other than the last explicit value; `at_defined_interval`, to uprate on a fixed cadence; and `parameter: self`, to extend the parameter's own growth. + +### Base mode + +Some statutes index a fixed base amount and round the increase rather than the increased amount. 26 U.S.C. 63(c)(7)(B)(ii), for example, increases the $23,625 head-of-household standard deduction by the cost-of-living adjustment since 2024 and rounds that increase down to a multiple of $50. A `base` block opts a parameter into this rule: + +```yaml +metadata: + uprating: + parameter: gov.example.price_index + base: + period: 2025 + value: 23_625 + rounding: + type: downwards + interval: 50 +``` + +For each instant `t` where the index has a value after the last explicit value, and no earlier than `base.period`, base mode computes + +```none +value(t) = base + round(base × (index(t) / index(base.period) − 1)) +``` + +applying `rounding`'s type and interval to the increase. Without `rounding`, `value(t) = base × index(t) / index(base.period)`. + +- `base.period` names the base in the index's own periods, since base mode reads the index there. Where the index's value at period `Y` measures calendar year `Y − 1`, as in the example above, a statute's base year `Y − 1` is `period: Y`. +- `base.value` defaults to the parameter's own value at `base.period`. When the parameter has a value there, a stated `base.value` must equal it, which catches a period one year off. State it when the parameter has no value at the base period, such as a 1997 base for a parameter whose history starts later; elsewhere, leaving it out lets the base follow a reform (see below). +- Base mode takes only `parameter`, `base` and `rounding`. It raises an error for `start_instant`, `at_defined_interval`, `parameter: self` and any other key, rather than ignore them. +- Before rounding, base mode counts an increase within 10⁻⁹ intervals of a whole number of intervals as that whole number, so floating-point error cannot drop an exact multiple to the one below. A 40% rise on $1,400 is exactly $560, which floats compute as 559.9999999999998. For amounts large next to the interval, where float error is larger, the tolerance widens to about 3.6 × 10⁻¹⁵ of the amounts, a few units in their last place. +- An index below its base-period level gives a value below the base; base mode sets no floor. An infinite base, such as a scale's `-.inf` lowest threshold, stays infinite. + +### Base mode and reforms + +Base mode's projections depend on the explicit values only through the base amount, not on the values just before them. That matters where reforms apply before uprating, as in a `TaxBenefitSystem` that loads its parameters and receives a `reform`: it applies the reform, then uprates. Take the head-of-household example and a reform that sets $30,000 from some date on: + +- From 2026 on, the reform sets 2026 to $30,000, and the base is still $23,625, so 2027 follows the statute again. Chained mode would instead project 2027 from $30,000. +- From 2020 on, the reform also sets the base period's value. With `base.value` left out, $30,000 becomes the base and the projections index it. A stated `base.value` of $23,625 no longer matches, and uprating raises an error that says to leave it out. + +A reform that should hold its value in later years can give it a range covering them, such as `2026-01-01.2100-12-31`. Country packages that apply reforms after uprating, as PolicyEngine US does, see projected values as before: a reform replaces them from its start. + +### How the modes differ + +Without rounding, the modes agree up to floating-point error whenever the explicit values follow the index from the base: if `value(L) = base × index(L) / index(base)`, chained mode's `value(L) × index(t) / index(L)` equals base mode's `base × index(t) / index(base)`. + +With rounding, they round different quantities. Chained mode rounds the whole amount, so every value it projects is a multiple of the interval. Base mode rounds the increase, so its values are the base plus a multiple of the interval, and a base off the interval's grid stays off it. Rev. Proc. 2026-35 prints two 2027 amounts of this kind, which chained mode cannot produce: + +- Section 3.15 sets the head-of-household standard deduction at $24,925: the $23,625 base plus $1,300, a multiple of $50. $24,925 is not. +- Section 3.29 sets the long-term care premium limit for ages 41 to 50 at $945: the $375 base in 26 U.S.C. 213(d)(10)(A) plus $570, an increase that section 213(d)(10)(B)(i) rounds to the nearest $10. $945 is not a multiple of $10. + +Chained mode also carries rounding forward when it starts from a published, rounded value. Take a $2,000 base, an increase rounded down to $100, and an index 4.9% above its base level in year 1 and 10% above it in year 2. The year-1 amount is $2,000, since $98 rounds down to $0. Base mode gives $2,200 for year 2. Chained mode from the year-1 amount gives $2,000 × 1.10 / 1.049 = $2,097.24, which rounds down to $2,000. + ### Metadata for parameters ```{eval-rst} diff --git a/policyengine_core/data_structures/parameter_metadata.py b/policyengine_core/data_structures/parameter_metadata.py index 32c488b1f..abc6ff12e 100644 --- a/policyengine_core/data_structures/parameter_metadata.py +++ b/policyengine_core/data_structures/parameter_metadata.py @@ -23,6 +23,22 @@ class UpratingRoundingConfig(dict): """The interval to round to.""" +class UpratingBase(dict): + """ + The statutory base amount that base-mode uprating projects from. + """ + + period: Union[int, str] + """ + The period of the base amount, such as 2025 or 1997-01-01, in the uprating index's own periods. Base mode measures each increase from the index's value here. + """ + + value: float + """ + The base amount. Optional: it defaults to the parameter's value at ``period``, and when the parameter has a value there, a stated ``value`` must equal it. + """ + + class UpratingSchema(dict): """ A schema for uprating a parameter node. @@ -35,12 +51,17 @@ class UpratingSchema(dict): start_instant: int """ - The instant to add uprated values after. + The instant to add uprated values after. Chained mode only. """ - type: UpratingRoundingConfig + rounding: Union[UpratingRoundingConfig, float] """ - The rounding configuration to use when uprating. + The rounding to apply. Chained mode rounds the uprated amount; base mode rounds the increase over the base amount. A bare number rounds to the nearest multiple of it. + """ + + base: UpratingBase + """ + Opts in to base mode: each value after the last explicit one is the base amount plus the increase the index implies since the base period, with ``rounding`` applied to that increase. Base mode takes only ``parameter``, ``base`` and ``rounding``. """ @@ -68,9 +89,11 @@ class ParameterMetadata: uprating: Union[UpratingIndex, UpratingSchema] """The schema for uprating this parameter. - The process for uprating parameter $X$ based on parameter $Y$ is as follows: - + By default (chained mode), the process for uprating parameter $X$ based on parameter $Y$ is as follows: + 1. Look at $X$ and $Y$ at the same time, and move forward in history. 2. If $Y$ increases by $z$%, then increase $X$ by $z$%. + + With ``base`` (base mode), each value after the last explicit one is a fixed base amount plus the increase $Y$ implies since the base period. See "Uprating" in the parameters guide. """ diff --git a/policyengine_core/parameters/operations/uprate_parameters.py b/policyengine_core/parameters/operations/uprate_parameters.py index 29789d927..2e3d73453 100644 --- a/policyengine_core/parameters/operations/uprate_parameters.py +++ b/policyengine_core/parameters/operations/uprate_parameters.py @@ -1,4 +1,5 @@ import math +import sys from numpy import ceil, floor @@ -36,6 +37,15 @@ #: is $0.00000005. INCREASE_ROUNDING_TOLERANCE = 1e-9 +#: Float error in an increase grows with the amounts it comes from, so for +#: amounts large next to the interval the tolerance widens to this multiple +#: of their size (the uprated amount plus the base): sixteen machine epsilons, +#: about 3.6e-15, a few units in the last place. $572,903.25 raised by +#: 2,032 / 186 is exactly 22,743,643 quarter-dollar intervals above its base, +#: which floats put 4e-9 intervals short; the widened tolerance there is about +#: 1e-7 intervals. +INCREASE_ROUNDING_RELATIVE_TOLERANCE = 16 * sys.float_info.epsilon + #: The rounding functions ``rounding.type`` names. ``nearest`` is Python's #: ``round``, which rounds an exact half to the even multiple, as chained #: mode's rounding does. @@ -444,7 +454,8 @@ def uprate_parameter_from_base( and applies them to the increase; without ``rounding`` the value is ``base_value * index(t) / index(base)``. Explicit values always win: base mode adds values only after the last of them. An index below its - base-period level gives a value below the base. + base-period level gives a value below the base, and an infinite base + amount, such as a scale's ``-inf`` lowest threshold, stays infinite. The ``base`` block holds: @@ -517,7 +528,7 @@ def uprate_parameter_from_base( index_at_entry / index_at_base, rounding, ) - if not math.isfinite(value): + if not math.isfinite(value) and math.isfinite(base_value): raise base_mode_error( parameter, f"its projected value at {entry.instant_str} is {value!r}.", @@ -532,15 +543,20 @@ def base_mode_error(parameter: Parameter, reason: str) -> ValueError: return ValueError(f"Failed to uprate {parameter.name} from its base: {reason}") -def is_real_number(value) -> bool: - """Whether ``value`` is a finite int or float other than a bool.""" +def is_number(value) -> bool: + """Whether ``value`` is an int or float other than a bool or NaN.""" return ( isinstance(value, (int, float)) and not isinstance(value, bool) - and math.isfinite(value) + and not math.isnan(value) ) +def is_real_number(value) -> bool: + """Whether ``value`` is a finite int or float other than a bool.""" + return is_number(value) and math.isfinite(value) + + def parse_uprating_base( parameter: Parameter, meta: dict ) -> tuple[Instant, Optional[float]]: @@ -654,10 +670,14 @@ def get_base_mode_index_value( def get_base_value( parameter: Parameter, base_instant: Instant, stated_value: Optional[float] ) -> float: - """The base amount: the stated ``value``, else the parameter's at the base.""" + """The base amount: the stated ``value``, else the parameter's at the base. + + The parameter's own value may be infinite, as a scale's lowest threshold + often is; :func:`project_from_base` keeps an infinite base as it is. + """ value_at_base = parameter(base_instant) if stated_value is None: - if not is_real_number(value_at_base): + if not is_number(value_at_base): raise base_mode_error( parameter, f"it has no numeric value at the base period {base_instant} " @@ -674,7 +694,9 @@ def get_base_value( f"`base.value` is {stated_value!r}, but the parameter's value at the " f"base period {base_instant} is {value_at_base!r}. `base.period` " "names the period whose value is the base amount, in the uprating " - "index's periods; check it is not a year off.", + "index's periods; check it is not a year off. If a reform applied " + "before uprating changed the base-period value, omit `base.value` " + "so the base follows the reform.", ) return stated_value @@ -686,6 +708,10 @@ def project_from_base( ) -> float: """The base amount plus the (rounded) increase the index ratio implies. + An infinite base, such as a scale's ``-inf`` lowest threshold, gives an + infinite uprated amount, which returns before any rounding: it stays + infinite. + Args: base_value: The base amount. index_ratio: The index at the projected period over the index at the @@ -698,23 +724,44 @@ def project_from_base( return float(uprated) interval, rounding_fn = rounding return float( - base_value + round_increase(uprated - base_value, interval, rounding_fn) + base_value + + round_increase( + uprated - base_value, + interval, + rounding_fn, + magnitude=abs(uprated) + abs(base_value), + ) ) def round_increase( - increase: float, interval: float, rounding_fn: Callable[[float], float] + increase: float, + interval: float, + rounding_fn: Callable[[float], float], + magnitude: float = 0.0, ) -> float: """Round an increase to a multiple of ``interval`` with ``rounding_fn``. An increase within :data:`INCREASE_ROUNDING_TOLERANCE` intervals of a - multiple counts as that multiple, so floating-point error in an increase - that is a whole number of intervals cannot move it to the next multiple - down (or up). + multiple, or within :data:`INCREASE_ROUNDING_RELATIVE_TOLERANCE` times + ``magnitude`` where that is wider, counts as that multiple, so + floating-point error in an increase that is a whole number of intervals + cannot move it to the next multiple down (or up). + + Args: + increase: The increase over the base amount. + interval: The rounding interval. + rounding_fn: ``round``, ``ceil`` or ``floor``. + magnitude: The size of the amounts the increase comes from (the + uprated amount plus the base), which bounds its float error. """ intervals = increase / interval whole_intervals = round(intervals) - if abs(intervals - whole_intervals) <= INCREASE_ROUNDING_TOLERANCE: + tolerance = max( + INCREASE_ROUNDING_TOLERANCE, + INCREASE_ROUNDING_RELATIVE_TOLERANCE * magnitude / interval, + ) + if abs(intervals - whole_intervals) <= tolerance: intervals = whole_intervals return float(rounding_fn(intervals)) * interval diff --git a/tests/core/parameters/operations/test_base_uprating.py b/tests/core/parameters/operations/test_base_uprating.py new file mode 100644 index 000000000..1a5b3e79a --- /dev/null +++ b/tests/core/parameters/operations/test_base_uprating.py @@ -0,0 +1,704 @@ +"""Base-mode uprating (``metadata.uprating.base``): examples and errors. + +Base mode projects each period after a parameter's last explicit value as + + base + round(base * (index[t] / index[base] - 1)) + +rounding the increase over the base, where chained mode rounds the uprated +amount. The properties are in ``test_base_uprating_property.py``. + +The index values below are made up. The statutory examples use values inside +the range that Rev. Proc. 2026-35's 2027 amounts imply; they are not BLS data. +""" + +import pytest + +from policyengine_core.parameters import ParameterNode, uprate_parameters +from policyengine_core.periods import instant + + +def uprated_root(data: dict) -> ParameterNode: + return uprate_parameters(ParameterNode(data=data)) + + +def base_mode(base: dict, rounding=None, index: str = "index") -> dict: + meta = {"parameter": index, "base": base} + if rounding is not None: + meta["rounding"] = rounding + return {"uprating": meta} + + +DOWN_50 = {"type": "downwards", "interval": 50} + + +def test_standard_deduction_for_2027_matches_rev_proc_2026_35(): + # 26 U.S.C. 63(c)(7)(B)(ii) increases the $15,750 and $23,625 amounts by + # the cost-of-living adjustment with 2024 as the base year, rounding each + # increase down to a multiple of $50. Rev. Proc. 2026-35, section 3.15, + # prints $16,600, $24,925 and, for joint returns (twice the single + # amount, 63(c)(2)(A)), $33,200. Here the index at 2025 stands for the + # base year, and the ratio 1.0553 lies in the range those amounts imply. + index = {"2025-01-01": 100.0, "2026-01-01": 102.6, "2027-01-01": 105.53} + root = uprated_root( + { + "single": { + "values": {"2025-01-01": 15_750, "2026-01-01": 16_100}, + "metadata": base_mode({"period": 2025, "value": 15_750}, DOWN_50), + }, + "head_of_household": { + "values": {"2025-01-01": 23_625, "2026-01-01": 24_150}, + "metadata": base_mode({"period": 2025, "value": 23_625}, DOWN_50), + }, + # Twice the single amount: twice an increase rounded down to $50 + # is the doubled increase rounded down to $100. + "joint": { + "values": {"2025-01-01": 31_500, "2026-01-01": 32_200}, + "metadata": base_mode( + {"period": 2025, "value": 31_500}, + {"type": "downwards", "interval": 100}, + ), + }, + "head_of_household_chained": { + "values": {"2025-01-01": 23_625, "2026-01-01": 24_150}, + "metadata": {"uprating": {"parameter": "index", "rounding": DOWN_50}}, + }, + "index": {"values": index}, + } + ) + + assert root.single("2027-01-01") == 16_600 + assert root.head_of_household("2027-01-01") == 24_925 + assert root.joint("2027-01-01") == 33_200 + # Chained mode rounds the whole amount, so it lands on a multiple of $50 + # and cannot print $24,925. + chained = root.head_of_household_chained("2027-01-01") + assert chained % 50 == 0 + assert chained != 24_925 + + +def test_long_term_care_premium_limits_for_2027_match_rev_proc_2026_35(): + # 26 U.S.C. 213(d)(10)(B)(i) increases the 1997 amounts in (A) by the + # medical care cost adjustment and rounds each increase to the nearest + # $10. Rev. Proc. 2026-35, section 3.29, prints $500, $945, $1,890, + # $5,030 and $6,290. The parameter's history starts after 1997, so each + # bracket states its base amount. + bases = [200, 375, 750, 2_000, 2_500] + published_2026 = [500, 930, 1_860, 4_960, 6_200] + thresholds = [-float("inf"), 40, 50, 60, 70] + rounding = {"type": "nearest", "interval": 10} + brackets = [] + for threshold, base, value_2026 in zip(thresholds, bases, published_2026): + brackets.append( + { + "threshold": {"values": {"2013-01-01": threshold}}, + "amount": { + "values": {"2013-01-01": base * 2, "2026-01-01": value_2026}, + "metadata": base_mode({"period": 1997, "value": base}, rounding), + }, + } + ) + root = uprated_root( + { + "limit": {"brackets": brackets}, + "band_41_to_50_chained": { + "values": {"2026-01-01": 930}, + "metadata": {"uprating": {"parameter": "index", "rounding": rounding}}, + }, + "index": { + "values": { + "1997-01-01": 100.0, + "2026-01-01": 248.0, + "2027-01-01": 251.55, + } + }, + } + ) + + amounts = [bracket.amount("2027-01-01") for bracket in root.limit.brackets] + assert amounts == [500, 945, 1_890, 5_030, 6_290] + # Rounding the whole amount puts the $375 band on a multiple of $10. + assert root.band_41_to_50_chained("2027-01-01") == 940 + + +def test_explicit_values_win_and_projection_starts_after_the_last(): + root = uprated_root( + { + "amount": { + # 2027 departs from the formula, as a published value can. + "values": { + "2024-01-01": 1_000, + "2025-01-01": 1_050, + "2027-01-01": 1_234, + }, + "metadata": base_mode({"period": 2024}, DOWN_50), + }, + "index": { + "values": { + "2024-01-01": 100.0, + "2025-01-01": 110.0, + "2026-01-01": 120.0, + "2027-01-01": 130.0, + "2028-01-01": 140.0, + "2029-01-01": 150.0, + } + }, + } + ) + + assert root.amount("2024-01-01") == 1_000 + assert root.amount("2025-01-01") == 1_050 + # 2026 has no explicit value but precedes the last one: it keeps 2025's. + assert root.amount("2026-01-01") == 1_050 + assert root.amount("2027-01-01") == 1_234 + # From the base, not from 2027's value: 1,000 + 400 and 1,000 + 500. + assert root.amount("2028-01-01") == 1_400 + assert root.amount("2029-01-01") == 1_500 + assert [value.instant_str for value in root.amount.values_list] == [ + "2029-01-01", + "2028-01-01", + "2027-01-01", + "2025-01-01", + "2024-01-01", + ] + + +def test_base_value_defaults_to_the_parameters_value_at_the_base_period(): + root = uprated_root( + { + "amount": { + "values": {"2017-01-01": 900, "2018-01-01": 1_400}, + "metadata": base_mode( + {"period": 2018}, {"type": "downwards", "interval": 100} + ), + }, + "index": { + "values": { + "2018-01-01": 100.0, + "2019-01-01": 103.0, + "2024-01-01": 122.0, + } + }, + } + ) + + # 1,400 * 0.03 = 42 rounds down to 0; 1,400 * 0.22 = 308 to 300. + assert root.amount("2019-01-01") == 1_400 + assert root.amount("2024-01-01") == 1_700 + + +def test_a_stated_base_value_must_match_the_value_at_the_base_period(): + with pytest.raises(ValueError, match="a year off"): + uprated_root( + { + "amount": { + "values": {"2024-01-01": 21_900, "2025-01-01": 23_625}, + # One year early: the parameter's 2024 value is 21,900. + "metadata": base_mode({"period": 2024, "value": 23_625}), + }, + "index": {"values": {"2024-01-01": 100.0, "2026-01-01": 103.0}}, + } + ) + + +def test_a_stated_base_value_matching_the_parameter_is_accepted(): + root = uprated_root( + { + "amount": { + "values": {"2025-01-01": 23_625}, + "metadata": base_mode({"period": 2025, "value": 23_625.0}, DOWN_50), + }, + "index": {"values": {"2025-01-01": 100.0, "2026-01-01": 103.0}}, + } + ) + + # 23,625 * 0.03 = 708.75 rounds down to 700. + assert root.amount("2026-01-01") == 24_325 + + +@pytest.mark.parametrize( + "rounding, expected", + [ + ({"type": "downwards", "interval": 10}, 1_160), + ({"type": "upwards", "interval": 10}, 1_170), + ({"type": "nearest", "interval": 10}, 1_170), + # A bare interval rounds to the nearest multiple, as in chained mode. + (10, 1_170), + (10.0, 1_170), + ({"type": "downwards", "interval": 0.25}, 1_166.5), + ], +) +def test_rounding_applies_to_the_increase(rounding, expected): + # The increase is 1,000 * 0.1666 = 166.6. + root = uprated_root( + { + "amount": { + "values": {"2020-01-01": 1_000}, + "metadata": base_mode({"period": 2020}, rounding), + }, + "index": {"values": {"2020-01-01": 100.0, "2021-01-01": 116.66}}, + } + ) + + assert root.amount("2021-01-01") == pytest.approx(expected) + + +def test_without_rounding_base_mode_scales_the_base_by_the_index(): + root = uprated_root( + { + "amount": { + "values": {"2020-01-01": 1_000, "2021-01-01": 1_111}, + "metadata": base_mode({"period": 2020}), + }, + "index": { + "values": { + "2020-01-01": 100.0, + "2021-01-01": 110.0, + "2022-01-01": 125.0, + } + }, + } + ) + + assert root.amount("2022-01-01") == pytest.approx(1_250) + + +def test_an_increase_on_a_multiple_is_not_rounded_down_a_full_interval(): + # A 40% rise on $1,400 is $560 exactly, but 1,400 * (140 / 100) - 1,400 is + # 559.9999999999998 in binary floating point. Rounded down to $10 without + # the tolerance, it would lose $10. + root = uprated_root( + { + "amount": { + "values": {"2020-01-01": 1_400}, + "metadata": base_mode( + {"period": 2020}, {"type": "downwards", "interval": 10} + ), + }, + "index": {"values": {"2020-01-01": 100.0, "2021-01-01": 140.0}}, + } + ) + + assert root.amount("2021-01-01") == 1_960 + + +def test_a_large_increase_on_a_multiple_is_not_rounded_down_an_interval(): + # $572,903.25 * (2,032 / 186 - 1) is $5,685,910.75 exactly, 22,743,643 + # quarter-dollar intervals, but floats put it 4e-9 intervals short: past + # the fixed 1e-9 tolerance, within a few units in the last place of the + # amounts. + root = uprated_root( + { + "amount": { + "values": {"2020-01-01": 572_903.25}, + "metadata": base_mode( + {"period": 2020}, {"type": "downwards", "interval": 0.25} + ), + }, + "index": {"values": {"2020-01-01": 186.0, "2021-01-01": 2_032.0}}, + } + ) + + assert root.amount("2021-01-01") == 572_903.25 + 5_685_910.75 + + +def test_a_scale_level_base_reads_each_brackets_value_at_the_base_period(): + root = uprated_root( + { + "scale": { + "metadata": { + **base_mode({"period": 2025}, DOWN_50), + "uprate_thresholds": True, + }, + "brackets": [ + { + "threshold": {"values": {"2025-01-01": 0}}, + "rate": {"values": {"2025-01-01": 0.1}}, + }, + { + "threshold": {"values": {"2025-01-01": 11_925}}, + "rate": {"values": {"2025-01-01": 0.12}}, + }, + ], + }, + "index": {"values": {"2025-01-01": 100.0, "2026-01-01": 102.6}}, + } + ) + + thresholds = [bracket.threshold("2026-01-01") for bracket in root.scale.brackets] + # 11,925 * 0.026 = 310.05 rounds down to 300. + assert thresholds == [0, 12_225] + + +def test_an_infinite_base_stays_infinite(): + # A scale's lowest threshold is often -inf, and indexing keeps it there. + root = uprated_root( + { + "scale": { + "metadata": { + **base_mode({"period": 2025}, {"type": "nearest", "interval": 50}), + "uprate_thresholds": True, + }, + "brackets": [ + { + "threshold": {"values": {"2025-01-01": -float("inf")}}, + "rate": {"values": {"2025-01-01": 0.1}}, + }, + { + "threshold": {"values": {"2025-01-01": 11_925}}, + "rate": {"values": {"2025-01-01": 0.12}}, + }, + ], + }, + "index": {"values": {"2025-01-01": 100.0, "2026-01-01": 102.6}}, + } + ) + + thresholds = [bracket.threshold("2026-01-01") for bracket in root.scale.brackets] + # 11,925 * 0.026 = 310.05 rounds to 300. + assert thresholds == [-float("inf"), 12_225] + + +def test_base_mode_in_a_cloned_subtree_reads_that_subtrees_index(): + root = ParameterNode( + data={ + "gov": { + "amount": { + "values": {"2025-01-01": 1_000}, + "metadata": base_mode({"period": 2025}, DOWN_50, index="gov.index"), + }, + "index": {"values": {"2025-01-01": 100.0, "2026-01-01": 110.0}}, + }, + } + ) + root.add_child("baseline", root.gov.clone()) + root.gov.index.update(start="2026-01-01", value=120.0) + + uprated = uprate_parameters(root) + + assert uprated.gov.amount("2026-01-01") == 1_200 + assert uprated.baseline.amount("2026-01-01") == 1_100 + + +def test_no_values_are_projected_before_the_base_period(): + # The base period follows the last explicit value, so the base amount is + # the value in force there: 2020's. + root = uprated_root( + { + "amount": { + "values": {"2020-01-01": 500}, + "metadata": base_mode({"period": 2023}, DOWN_50), + }, + "index": { + "values": { + "2021-01-01": 90.0, + "2022-01-01": 95.0, + "2023-01-01": 100.0, + "2024-01-01": 110.0, + } + }, + } + ) + + assert [value.instant_str for value in root.amount.values_list] == [ + "2024-01-01", + "2023-01-01", + "2020-01-01", + ] + assert root.amount("2022-01-01") == 500 + assert root.amount("2023-01-01") == 500 + assert root.amount("2024-01-01") == 550 + + +def test_a_stated_base_after_the_last_explicit_value_must_match_it(): + # The parameter's value in force at 2023 is 2020's 500, so a stated + # 1,000 contradicts it: the statutory amount belongs in the values. + with pytest.raises(ValueError, match="value at the base period 2023-01-01 is 500"): + uprated_root( + { + "amount": { + "values": {"2020-01-01": 500}, + "metadata": base_mode({"period": 2023, "value": 1_000}), + }, + "index": {"values": {"2023-01-01": 100.0, "2024-01-01": 110.0}}, + } + ) + + +# Core's TaxBenefitSystem applies a constructor reform before it uprates: +# it loads the parameters, clones the baseline, applies the reform, then +# uprates. A reform's change reaches the parameter through Parameter.update. + + +def standard_deduction_after_reform( + reform_start: str, base: dict, reform_stop: str = None +) -> ParameterNode: + root = ParameterNode( + data={ + "amount": { + "values": {"2025-01-01": 23_625, "2026-01-01": 24_150}, + "metadata": base_mode(base, DOWN_50), + }, + "index": { + "values": { + "2025-01-01": 100.0, + "2026-01-01": 102.6, + "2027-01-01": 105.53, + } + }, + } + ) + # A reform entry: "from this date on" without a stop, and a range + # ("2026-01-01.2100-12-31" in Reform.from_dict) with one. + root.amount.update( + start=instant(reform_start), + stop=instant(reform_stop) if reform_stop else None, + value=30_000, + ) + return uprate_parameters(root) + + +def test_a_reform_before_uprating_changes_projections_only_through_the_base(): + def path(reform_start: str) -> list: + amount = standard_deduction_after_reform(reform_start, {"period": 2025}).amount + return [amount(f"{year}-01-01") for year in (2025, 2026, 2027)] + + # From 2026: the base is still 23,625, so 2027 follows the statute. + assert path("2026-01-01") == [23_625, 30_000, 24_925] + # From 2020: 30,000 is the value at the base period, so it is the base. + # 30,000 * 0.026 = 780 rounds down to 750, and 30,000 * 0.0553 = 1,659 + # to 1,650. + assert path("2020-01-01") == [30_000, 30_750, 31_650] + + +def test_a_bounded_reform_before_uprating_holds_its_value_over_its_range(): + # The docs' advice for a reform that should hold in later years. Its + # range ends after the index's last period, so base mode adds nothing; + # Parameter.update restores the prior value after the range. + amount = standard_deduction_after_reform( + "2026-01-01", {"period": 2025}, reform_stop="2100-12-31" + ).amount + years = (2025, 2026, 2027, 2100, 2101) + assert [amount(f"{year}-01-01") for year in years] == [ + 23_625, + 30_000, + 30_000, + 30_000, + 24_150, + ] + + +def test_a_reform_before_uprating_that_contradicts_a_stated_base_raises(): + with pytest.raises(ValueError, match="omit `base.value`"): + standard_deduction_after_reform("2020-01-01", {"period": 2025, "value": 23_625}) + + +def test_an_index_below_its_base_level_gives_a_value_below_the_base(): + root = uprated_root( + { + "amount": { + "values": {"2020-01-01": 1_000}, + "metadata": base_mode({"period": 2020}, DOWN_50), + }, + "index": {"values": {"2020-01-01": 100.0, "2021-01-01": 97.0}}, + } + ) + + # The increase, -30, rounds down to -50. + assert root.amount("2021-01-01") == 950 + + +def test_the_base_period_accepts_instant_strings(): + root = uprated_root( + { + "amount": { + "values": {"2020-01-01": 1_000}, + "metadata": base_mode({"period": "2020-01-01"}, DOWN_50), + }, + "index": {"values": {"2020-01-01": 100.0, "2021-01-01": 110.0}}, + } + ) + + assert root.amount("2021-01-01") == 1_100 + + +def error_case(meta: dict, values=None, index_values=None) -> dict: + return { + "amount": { + "values": values or {"2020-01-01": 1_000}, + "metadata": {"uprating": meta}, + }, + "index": {"values": index_values or {"2020-01-01": 100.0, "2021-01-01": 110.0}}, + } + + +@pytest.mark.parametrize( + "data, message", + [ + (error_case({"parameter": "index", "base": 2020}), "must be a mapping"), + (error_case({"parameter": "index", "base": None}), "must be a mapping"), + (error_case({"parameter": "index", "base": {}}), "has no `period`"), + ( + error_case({"parameter": "index", "base": {"year": 2020}}), + "not `year`", + ), + ( + error_case({"parameter": "index", "base": {"period": "next year"}}), + "not a period or instant", + ), + ( + error_case({"parameter": "index", "base": {"period": True}}), + "not a period or instant", + ), + ( + error_case( + {"parameter": "index", "base": {"period": 2020, "value": "1,000"}} + ), + "not a finite number", + ), + ( + error_case({"parameter": "index", "base": {"period": 2020, "value": True}}), + "not a finite number", + ), + ( + error_case({"parameter": "self", "base": {"period": 2020}}), + "names no index", + ), + ( + error_case( + { + "parameter": "index", + "base": {"period": 2020}, + "start_instant": "2020-01-01", + } + ), + "not `start_instant`", + ), + ( + error_case( + { + "parameter": "index", + "base": {"period": 2020}, + "at_defined_interval": { + "start": "2000-10-01", + "end": "2001-10-01", + "enactment": "2002-04-01", + }, + } + ), + "not `at_defined_interval`", + ), + ( + error_case( + { + "parameter": "index", + "base": {"period": 2020}, + "rouding": {"type": "downwards", "interval": 50}, + } + ), + "not `rouding`", + ), + ( + error_case( + { + "parameter": "index", + "base": {"period": 2020}, + "rounding": {"type": "down", "interval": 50}, + } + ), + "is not one of", + ), + ( + error_case( + { + "parameter": "index", + "base": {"period": 2020}, + "rounding": {"interval": 50}, + } + ), + "is not one of", + ), + ( + error_case( + { + "parameter": "index", + "base": {"period": 2020}, + "rounding": {"type": "downwards", "interval": 0}, + } + ), + "not a positive number", + ), + ( + error_case( + { + "parameter": "index", + "base": {"period": 2020}, + "rounding": {"type": "downwards", "interval": -50}, + } + ), + "not a positive number", + ), + ( + error_case( + {"parameter": "index", "base": {"period": 2020}, "rounding": True} + ), + "not a positive number", + ), + ( + error_case( + {"parameter": "index", "base": {"period": 2019}}, + index_values={"2019-01-01": 100.0, "2021-01-01": 110.0}, + ), + "has no numeric value at the base period", + ), + ( + error_case( + {"parameter": "index", "base": {"period": 2020}}, + values={"2020-01-01": float("nan")}, + ), + "has no numeric value at the base period", + ), + ( + error_case({"parameter": "index", "base": {"period": 2019, "value": 1}}), + "is None at 2019-01-01", + ), + ( + error_case( + {"parameter": "index", "base": {"period": 2020}}, + index_values={"2020-01-01": 0.0, "2021-01-01": 1.0}, + ), + "is 0.0 at 2020-01-01", + ), + ( + error_case( + {"parameter": "index", "base": {"period": 2020}}, + values={"2020-01-01": 1_000, "2021-01-01": None}, + index_values={"2020-01-01": 1.0, "2022-01-01": 1.1}, + ), + "is null", + ), + ], +) +def test_malformed_base_mode_metadata_raises(data, message): + with pytest.raises(ValueError, match=message): + uprated_root(data) + + +def test_errors_name_the_parameter(): + with pytest.raises(ValueError, match="Failed to uprate amount from its base"): + uprated_root(error_case({"parameter": "index", "base": {}})) + + +def test_a_parameter_with_no_values_and_a_stated_base_raises(): + from policyengine_core.parameters import Parameter + from policyengine_core.parameters.operations.uprate_parameters import ( + uprate_parameter_from_base, + ) + + root = ParameterNode(data={"index": {"values": {"2020-01-01": 1.0}}}) + parameter = Parameter("empty", data={}) + with pytest.raises(ValueError, match="no values to project after"): + uprate_parameter_from_base( + parameter, + root, + {"parameter": "index", "base": {"period": 2020, "value": 1}}, + ) diff --git a/tests/core/parameters/operations/test_base_uprating_property.py b/tests/core/parameters/operations/test_base_uprating_property.py new file mode 100644 index 000000000..3ae4d2fe7 --- /dev/null +++ b/tests/core/parameters/operations/test_base_uprating_property.py @@ -0,0 +1,551 @@ +"""Properties of base-mode uprating, and of chained mode beside it. + +Base mode (``metadata.uprating.base``) gives each index period ``t`` after a +parameter's last explicit value + + base + round(base * (index[t] / index[base] - 1)). + +Over the domain generated here: + +1. The increase over the base, ``value - base``, is a whole number of rounding + intervals. +2. Rounding down never puts the increase above the exact increase (the + rational number the float inputs define) and leaves it less than one + interval below it. Rounding up mirrors that, and rounding to the nearest + multiple leaves it within half an interval. An exact increase that is + already a whole number of intervals stays exactly that, under every + rule. "Never above" allows the tolerance base mode gives an increase near + a multiple (1e-9 intervals, or about 3.6e-15 of the amounts if more), plus + float error of order 1e-14 of the amounts. +3. With a positive base, a higher index at the projected period never gives + a lower value. +4. Without rounding, base mode and chained mode agree to 1e-12 when the + explicit values after the base follow the index exactly, and to the last + bit when the base is the last explicit value. +5. Explicit values win: base mode keeps every explicit value and adds values + only after the last of them, at the index's periods. +6. The values base mode adds depend on the explicit values only through the + base amount and the last explicit instant. +7. Every chained-mode parameter gets exactly the values, or the error, that + the implementation before base mode gives it + (``tests/fixtures/chained_uprating_reference.py``), including at rounding + boundaries, where a change to the arithmetic before rounding shows. + +The domain: the index and the parameter have values on January 1 of years +from 1995 to 2040. Index levels run from 1 to about 1,000 with yearly changes +from -10% to +15%, or are tenths from 100.0 to 3,000.0 drawn independently +each year, so the ratios include exact decimals such as 140 / 100. Amounts are +cents from $0.01 to $1m, rounding intervals from $0.01 to $1,000. +""" + +from __future__ import annotations + +import pytest + +# The smoke job installs Core without the dev extra but collects every module. +pytest.importorskip("hypothesis") + +import copy # noqa: E402 +from dataclasses import dataclass # noqa: E402 +from fractions import Fraction # noqa: E402 +from typing import Optional # noqa: E402 + +from hypothesis import HealthCheck, example, given, settings # noqa: E402 +from hypothesis import strategies as st # noqa: E402 + +from policyengine_core.parameters import ParameterNode, uprate_parameters # noqa: E402 +from policyengine_core.parameters.operations.uprate_parameters import ( # noqa: E402 + INCREASE_ROUNDING_RELATIVE_TOLERANCE, + INCREASE_ROUNDING_TOLERANCE, +) +from tests.fixtures.chained_uprating_reference import ( # noqa: E402 + reference_uprate_parameters, +) + +PROPERTY_SETTINGS = settings( + max_examples=300, + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) + +MONEY = st.integers(min_value=1, max_value=100_000_000).map(lambda cents: cents / 100) +INTERVALS = st.sampled_from([0.01, 0.25, 1, 5, 10, 25, 50, 100, 1_000]) +ROUNDING_TYPES = st.sampled_from(["nearest", "upwards", "downwards"]) + + +def year_instant(year: int) -> str: + return f"{year}-01-01" + + +@st.composite +def index_paths(draw, first_year: int, last_year: int) -> dict: + """Index values on January 1 of each year from first_year to last_year.""" + years = range(first_year, last_year + 1) + if draw(st.booleans()): + level = draw(st.floats(min_value=1.0, max_value=1_000.0)) + values = {} + for year in years: + values[year_instant(year)] = level + level *= draw(st.floats(min_value=0.9, max_value=1.15)) + return values + return {year_instant(year): draw(st.integers(1_000, 30_000)) / 10 for year in years} + + +@st.composite +def roundings(draw): + """A ``rounding`` block in one of the forms chained mode also reads.""" + interval = draw(INTERVALS) + form = draw(st.sampled_from(["mapping", "bare"])) + if form == "bare": + return float(interval) + return {"type": draw(ROUNDING_TYPES), "interval": interval} + + +def rounding_rule(rounding) -> tuple[str, float]: + if isinstance(rounding, dict): + return rounding["type"], rounding["interval"] + return "nearest", rounding + + +@dataclass +class BaseModeCase: + base_year: int + base_value: float + last_explicit_year: int + values: dict + index: dict + rounding: object + state_value: bool + + def meta(self) -> dict: + base = {"period": self.base_year} + if self.state_value: + base["value"] = self.base_value + meta = {"parameter": "index", "base": base} + if self.rounding is not None: + meta["rounding"] = self.rounding + return meta + + def uprate(self, values: Optional[dict] = None, index: Optional[dict] = None): + root = ParameterNode( + data={ + "amount": { + "values": dict(values if values is not None else self.values), + "metadata": {"uprating": self.meta()}, + }, + "index": {"values": dict(index if index is not None else self.index)}, + } + ) + return uprate_parameters(root).amount + + def projected(self, parameter) -> list: + last = year_instant(self.last_explicit_year) + return [ + (value.instant_str, value.value) + for value in reversed(parameter.values_list) + if value.instant_str > last + ] + + +@st.composite +def base_mode_cases(draw, rounded: Optional[bool] = None) -> BaseModeCase: + first_year = draw(st.integers(1995, 2010)) + last_year = draw(st.integers(first_year + 1, 2040)) + index = draw(index_paths(first_year, last_year)) + base_year = draw(st.integers(first_year, last_year - 1)) + last_explicit_year = draw(st.integers(base_year, last_year - 1)) + base_value = draw(MONEY) + values = {year_instant(base_year): base_value} + for year in draw(st.sets(st.integers(base_year - 5, base_year - 1), max_size=3)): + values[year_instant(year)] = draw(MONEY) + if last_explicit_year > base_year: + later_years = draw( + st.sets(st.integers(base_year + 1, last_explicit_year), max_size=4) + ) + for year in later_years | {last_explicit_year}: + values[year_instant(year)] = draw(MONEY) + if rounded is None: + rounding = draw(st.one_of(st.none(), roundings())) + elif rounded: + rounding = draw(roundings()) + else: + rounding = None + return BaseModeCase( + base_year=base_year, + base_value=base_value, + last_explicit_year=last_explicit_year, + values=values, + index=index, + rounding=rounding, + state_value=draw(st.booleans()), + ) + + +# A 40% rise on $1,400 is exactly $560, but 559.9999999999998 in floats. +FLOAT_NOISE_CASE = BaseModeCase( + base_year=2020, + base_value=1_400.0, + last_explicit_year=2020, + values={"2020-01-01": 1_400.0}, + index={"2020-01-01": 100.0, "2021-01-01": 140.0}, + rounding={"type": "downwards", "interval": 10}, + state_value=True, +) +# $5,685,910.75 exactly, 22,743,643 quarter-dollar intervals, but 4e-9 +# intervals short in floats: past the fixed tolerance, inside the relative one. +LARGE_MULTIPLE_CASE = BaseModeCase( + base_year=2020, + base_value=572_903.25, + last_explicit_year=2020, + values={"2020-01-01": 572_903.25}, + index={"2020-01-01": 186.0, "2021-01-01": 2_032.0}, + rounding={"type": "downwards", "interval": 0.25}, + state_value=False, +) +# Rev. Proc. 2026-35, section 3.15: heads of household, 2027. +HEAD_OF_HOUSEHOLD_CASE = BaseModeCase( + base_year=2025, + base_value=23_625.0, + last_explicit_year=2026, + values={"2025-01-01": 23_625.0, "2026-01-01": 24_150.0}, + index={"2025-01-01": 100.0, "2026-01-01": 102.6, "2027-01-01": 105.53}, + rounding={"type": "downwards", "interval": 50}, + state_value=True, +) + + +def tolerance(case: BaseModeCase, ratio: float, value: float, interval: float): + """Allowed error in an increase, in currency units, as an exact Fraction. + + Base mode's snap can move an increase by its tolerance: the larger of + INCREASE_ROUNDING_TOLERANCE intervals and INCREASE_ROUNDING_RELATIVE_TOLERANCE + times the uprated amount plus the base, which ``amounts`` bounds twice + over. Float arithmetic adds error of order 1e-16 of the amounts, which + 1e-14 covers. + """ + amounts = abs(case.base_value) * max(ratio, 1.0) + abs(value) + snap = max( + INCREASE_ROUNDING_TOLERANCE * interval, + INCREASE_ROUNDING_RELATIVE_TOLERANCE * 2 * amounts, + ) + return Fraction(snap + 1e-14 * amounts) + + +@PROPERTY_SETTINGS +@given(base_mode_cases(rounded=True)) +@example(FLOAT_NOISE_CASE) +@example(LARGE_MULTIPLE_CASE) +@example(HEAD_OF_HOUSEHOLD_CASE) +def test_the_increase_is_a_whole_number_of_intervals(case): + _, interval = rounding_rule(case.rounding) + projected = case.projected(case.uprate()) + assert projected + for _, value in projected: + intervals = (value - case.base_value) / interval + assert abs(intervals - round(intervals)) <= 1e-6 + + +@PROPERTY_SETTINGS +@given(base_mode_cases(rounded=True)) +@example(FLOAT_NOISE_CASE) +@example(LARGE_MULTIPLE_CASE) +@example(HEAD_OF_HOUSEHOLD_CASE) +def test_the_rounded_increase_brackets_the_exact_increase(case): + rounding_type, interval = rounding_rule(case.rounding) + base = Fraction(case.base_value) + index_at_base = Fraction(case.index[year_instant(case.base_year)]) + for instant_str, value in case.projected(case.uprate()): + index_at_t = Fraction(case.index[instant_str]) + exact = base * (index_at_t / index_at_base - 1) + rounded = Fraction(value) - base + slack = tolerance(case, float(index_at_t / index_at_base), value, interval) + if (exact / Fraction(interval)).denominator == 1: + # An exact whole number of intervals survives float error intact. + assert abs(rounded - exact) <= slack + if rounding_type == "downwards": + assert rounded <= exact + slack + assert exact - rounded < interval + slack + elif rounding_type == "upwards": + assert rounded >= exact - slack + assert rounded - exact < interval + slack + else: + assert abs(rounded - exact) <= Fraction(interval) / 2 + slack + + +@PROPERTY_SETTINGS +@given( + base_value=MONEY, + index_at_base=st.floats(min_value=1.0, max_value=1_000.0), + index_at_t=st.floats(min_value=1.0, max_value=3_000.0), + rise=st.floats(min_value=0.0, max_value=1_000.0), + rounding=st.one_of(st.none(), roundings()), +) +def test_a_higher_index_never_gives_a_lower_value( + base_value, index_at_base, index_at_t, rise, rounding +): + def value_at(index_value: float) -> float: + case = BaseModeCase( + base_year=2020, + base_value=base_value, + last_explicit_year=2020, + values={"2020-01-01": base_value}, + index={"2020-01-01": index_at_base, "2021-01-01": index_value}, + rounding=rounding, + state_value=False, + ) + return case.uprate()("2021-01-01") + + assert value_at(index_at_t + rise) >= value_at(index_at_t) + + +@PROPERTY_SETTINGS +@given(base_mode_cases(rounded=False), st.booleans()) +def test_without_rounding_base_and_chained_mode_agree(case, explicit_path): + base_index = case.index[year_instant(case.base_year)] + values = {year_instant(case.base_year): case.base_value} + if explicit_path: + # Explicit values after the base that follow the index exactly. + for instant_str, index_value in case.index.items(): + if ( + year_instant(case.base_year) + < instant_str + <= year_instant(case.last_explicit_year) + ): + values[instant_str] = case.base_value * (index_value / base_index) + last_explicit_year = case.last_explicit_year + else: + last_explicit_year = case.base_year + root = ParameterNode( + data={ + "base_mode": { + "values": dict(values), + "metadata": { + "uprating": { + "parameter": "index", + "base": {"period": case.base_year}, + } + }, + }, + "chained_mode": { + "values": dict(values), + "metadata": {"uprating": {"parameter": "index"}}, + }, + "index": {"values": dict(case.index)}, + } + ) + uprated = uprate_parameters(root) + + def pairs(parameter): + return [(value.instant_str, value.value) for value in parameter.values_list] + + base_pairs = pairs(uprated.base_mode) + chained_pairs = pairs(uprated.chained_mode) + assert [instant for instant, _ in base_pairs] == [ + instant for instant, _ in chained_pairs + ] + last = year_instant(last_explicit_year) + for (instant_str, base_value), (_, chained_value) in zip(base_pairs, chained_pairs): + if instant_str > last and last_explicit_year == case.base_year: + assert base_value == chained_value + else: + assert base_value == pytest.approx(chained_value, rel=1e-12) + + +@PROPERTY_SETTINGS +@given(base_mode_cases()) +@example(HEAD_OF_HOUSEHOLD_CASE) +def test_explicit_values_win(case): + parameter = case.uprate() + last = year_instant(case.last_explicit_year) + kept = { + value.instant_str: value.value + for value in parameter.values_list + if value.instant_str <= last + } + assert kept == case.values + projected_instants = [instant_str for instant_str, _ in case.projected(parameter)] + assert projected_instants == sorted( + instant_str for instant_str in case.index if instant_str > last + ) + # One value per instant, newest first. + instants = [value.instant_str for value in parameter.values_list] + assert instants == sorted(set(instants), reverse=True) + + +@PROPERTY_SETTINGS +@given(base_mode_cases(), st.data()) +def test_projected_values_depend_only_on_the_base_and_the_last_instant(case, data): + base_instant = year_instant(case.base_year) + other_values = { + instant_str: ( + value + if instant_str == base_instant + else data.draw(MONEY, label=instant_str) + ) + for instant_str, value in case.values.items() + } + assert case.projected(case.uprate(values=other_values)) == case.projected( + case.uprate() + ) + + +@dataclass +class ChainedModeCase: + data: dict + + +@st.composite +def chained_mode_cases(draw) -> ChainedModeCase: + first_year = draw(st.integers(1995, 2010)) + last_year = draw(st.integers(first_year + 1, 2040)) + index = draw(index_paths(first_year, last_year)) + years = draw( + st.sets(st.integers(first_year - 3, last_year + 2), min_size=1, max_size=6) + ) + values = {year_instant(year): draw(MONEY) for year in years} + form = draw( + st.sampled_from( + ["string", "mapping", "rounded", "bare", "int", "start", "self"] + ) + ) + if form == "string": + meta = "index" + elif form == "mapping": + meta = {"parameter": "index"} + elif form == "rounded": + meta = {"parameter": "index", "rounding": draw(roundings())} + elif form == "bare": + meta = {"parameter": "index", "rounding": float(draw(INTERVALS))} + elif form == "int": + # Chained mode fails on an int interval; both implementations must. + meta = {"parameter": "index", "rounding": 50} + elif form == "start": + meta = { + "parameter": "index", + "start_instant": year_instant(draw(st.integers(first_year, last_year))), + } + if draw(st.booleans()): + meta["rounding"] = draw(roundings()) + else: + meta = {"parameter": "self"} + if draw(st.booleans()): + meta["number"] = draw(st.integers(1, 5)) + data = { + "amount": {"values": values, "metadata": {"uprating": meta}}, + "index": {"values": index}, + } + if draw(st.booleans()): + # A parameter uprated by the first, so dependency order matters. + data["derived"] = { + "values": {year_instant(min(years)): draw(MONEY)}, + "metadata": {"uprating": "amount"}, + } + return ChainedModeCase(data=data) + + +def chained_case(value: float, index: tuple, rounding: dict) -> ChainedModeCase: + return ChainedModeCase( + data={ + "amount": { + "values": {"2020-01-01": value}, + "metadata": {"uprating": {"parameter": "index", "rounding": rounding}}, + }, + "index": {"values": {"2020-01-01": index[0], "2021-01-01": index[1]}}, + } + ) + + +# Rounding boundaries, where random cases rarely land and where any change to +# chained mode's arithmetic before rounding shows. +# 1,400 * (140 / 100) is 1,959.9999999999998, which rounds down to 1,950. +CHAINED_JUST_BELOW_A_MULTIPLE = chained_case( + 1_400.0, (100.0, 140.0), {"type": "downwards", "interval": 10} +) +# 100 * (110 / 100) is 110.00000000000001, which rounds up to 120. +CHAINED_JUST_ABOVE_A_MULTIPLE = chained_case( + 100.0, (100.0, 110.0), {"type": "upwards", "interval": 10} +) +# 125 at an unchanged index is a tie between 100 and 150; nearest takes the +# even multiple, 100. +CHAINED_EXACT_TIE = chained_case( + 125.0, (100.0, 100.0), {"type": "nearest", "interval": 50} +) + + +def uprating_outcome(data: dict, uprate) -> object: + """The values each parameter ends up with, or the error uprating raises.""" + try: + root = uprate(ParameterNode(data=copy.deepcopy(data))) + except Exception as error: # noqa: BLE001 - the error is the outcome + return ("error", type(error), str(error)) + return { + name: [ + (value.instant_str, type(value.value), repr(value.value)) + for value in getattr(root, name).values_list + ] + for name in data + } + + +@PROPERTY_SETTINGS +@given(chained_mode_cases()) +@example(CHAINED_JUST_BELOW_A_MULTIPLE) +@example(CHAINED_JUST_ABOVE_A_MULTIPLE) +@example(CHAINED_EXACT_TIE) +def test_chained_mode_matches_the_implementation_before_base_mode(case): + assert uprating_outcome(case.data, uprate_parameters) == uprating_outcome( + case.data, reference_uprate_parameters + ) + + +CADENCE_UPRATER = { + "2015-10-01": 2, + "2015-12-01": 1, + "2016-10-01": 4, + "2016-12-01": 1, + "2017-10-01": 6, + "2017-12-01": 1, + "2018-10-01": 3, + "2019-10-01": 16, + "2020-10-01": 32, +} + + +@pytest.mark.parametrize( + "cadence", + [ + {"enactment": "2002-04-01", "start": "2000-10-01", "end": "2001-10-01"}, + {"enactment": "2003-04-01", "start": "2000-10-01", "end": "2002-10-01"}, + {"enactment": "2003-04-01", "start": "2000-10-01", "end": "2001-10-01"}, + { + "enactment": "2002-04-01", + "start": "2000-10-01", + "end": "2001-10-01", + "effective": "2020-04-01", + }, + { + "enactment": "2002-04-01", + "start": "2000-10-01", + "end": "2001-10-01", + "interval": "month", + }, + {"enactment": "2002-04-01", "start": "2000-10-01"}, + ], +) +@pytest.mark.parametrize("rounding", [None, {"type": "downwards", "interval": 1}]) +def test_cadence_uprating_matches_the_implementation_before_base_mode( + cadence, rounding +): + meta = {"parameter": "uprater", "at_defined_interval": cadence} + if rounding is not None: + meta["rounding"] = rounding + data = { + "amount": { + "values": {"2015-04-01": 1, "2016-04-01": 2, "2017-04-01": 4}, + "metadata": {"uprating": meta}, + }, + "uprater": {"values": CADENCE_UPRATER}, + } + assert uprating_outcome(data, uprate_parameters) == uprating_outcome( + data, reference_uprate_parameters + ) diff --git a/tests/fixtures/chained_uprating_reference.py b/tests/fixtures/chained_uprating_reference.py new file mode 100644 index 000000000..c00c73551 --- /dev/null +++ b/tests/fixtures/chained_uprating_reference.py @@ -0,0 +1,166 @@ +"""Chained-mode uprating as it was before base mode, for differential tests. + +``reference_uprate_parameter`` is ``uprate_parameter`` from +``policyengine_core/parameters/operations/uprate_parameters.py`` at +PolicyEngine/policyengine-core@7c84d941b, the last commit before base mode, +copied verbatim except for its name. Base mode added an early branch to that +function and left the helpers it calls unchanged, so the copy imports them. + +``test_base_uprating_property.py`` checks that ``uprate_parameters`` gives +every chained-mode parameter exactly what this copy gives it. A change that +means to alter chained mode should update this copy with the reason. +""" + +from datetime import datetime +from typing import Optional + +from policyengine_core.parameters.parameter import Parameter +from policyengine_core.parameters.parameter_at_instant import ParameterAtInstant +from policyengine_core.parameters.parameter_node import ParameterNode +from policyengine_core.parameters.operations.uprate_parameters import ( + construct_cadence_options, + construct_uprater_self, + find_cadence_first, + find_cadence_last, + get_parameter_paths, + get_uprating_parameter, + normalize_uprating_metadata, + round_uprated_value, + sort_parameters_by_uprating_dependencies, + uprate_by_cadence, +) +from policyengine_core.periods import instant + + +def reference_uprate_parameters(root: ParameterNode) -> ParameterNode: + """``uprate_parameters`` at 7c84d941b, calling the copy below.""" + parameters = [ + parameter + for parameter in root.get_descendants() + if isinstance(parameter, Parameter) + ] + parameter_paths = get_parameter_paths(root) + + for parameter in sort_parameters_by_uprating_dependencies( + parameters, + parameter_paths, + ): + reference_uprate_parameter(parameter, root, parameter_paths) + return root + + +def reference_uprate_parameter( + parameter: Parameter, + root: ParameterNode, + parameter_paths: Optional[dict[int, str]] = None, +) -> None: + if parameter_paths is None: + parameter_paths = {} + # Pull the uprating definition dict + meta = normalize_uprating_metadata(parameter.metadata["uprating"]) + + # If param is "self", construct the uprating table + if meta["parameter"] == "self": + uprating_parameter = construct_uprater_self( + parameter, + meta, + ) + # Otherwise, pull uprating table from YAML + else: + uprating_parameter = get_uprating_parameter( + root, + parameter, + meta["parameter"], + parameter_paths, + ) + + # If uprating with a set candence, ensure that all + # required values are present + cadence_meta = meta.get("at_defined_interval") + cadence_options = {} + if cadence_meta: + cadence_options_test = [ + cadence_meta.get("start"), + cadence_meta.get("end"), + cadence_meta.get("enactment"), + ] + + # Ensure that all options are properly defined + if not all(cadence_options_test): + raise SyntaxError( + f"Failed to uprate {parameter.name} using cadence; start, end, and enactment must all be provided" + ) + + # Construct cadence options object + cadence_options = construct_cadence_options(cadence_meta, parameter) + + # Ensure that end comes after start and enactment comes after end + if cadence_options["end"] <= cadence_options["start"]: + raise ValueError( + f"Failed to uprate {parameter.name} using {uprating_parameter.name}: end must come after start" + ) + if cadence_options["enactment"] <= cadence_options["end"]: + raise ValueError( + f"Failed to uprate {parameter.name} using {uprating_parameter.name}: enactment must come after end" + ) + + # Determine the first date from which to start uprating - + # this should be the first application date (month, day) + # following the last defined param value (not including the + # final value) + uprating_first_date: datetime = find_cadence_first(parameter, cadence_options) + uprating_last_date: datetime = find_cadence_last( + uprating_parameter, cadence_options + ) + + # Uprate data + uprated_data = uprate_by_cadence( + parameter, + uprating_parameter, + cadence_options, + uprating_first_date, + uprating_last_date, + meta, + ) + + # Append uprated data to parameter values list + parameter.values_list.extend(uprated_data) + + else: + # Start from the latest value + if "start_instant" in meta: + last_instant = instant(meta["start_instant"]) + else: + last_instant = instant(parameter.values_list[0].instant_str) + + # Pre-compute values that don't change in the loop + value_at_start = parameter(last_instant) + uprater_at_start = uprating_parameter(last_instant) + + if uprater_at_start is None: + raise ValueError( + f"Failed to uprate using {uprating_parameter.name} at {last_instant} for {parameter.name} because the uprating parameter is not defined at {last_instant}." + ) + + has_rounding = "rounding" in meta + + # For each defined instant in the uprating parameter + for entry in uprating_parameter.values_list[::-1]: + entry_instant = instant(entry.instant_str) + # If the uprater instant is defined after the last parameter instant + if entry_instant > last_instant: + # Apply the uprater and add to the parameter + uprater_at_entry = uprating_parameter(entry_instant) + uprater_change = uprater_at_entry / uprater_at_start + uprated_value = value_at_start * uprater_change + if has_rounding: + uprated_value = round_uprated_value(meta, uprated_value) + parameter.values_list.append( + ParameterAtInstant( + parameter.name, + entry.instant_str, + data=uprated_value, + ) + ) + # Whether using cadence or not, sort the parameter values_list + parameter.values_list.sort(key=lambda x: x.instant_str, reverse=True)