From c667c1f751171e4c18d57d71ae1117b2d098bb14 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 11 Jun 2026 20:01:21 +0200 Subject: [PATCH] Exempt inherited computation modes from the exclusive-mode check The exclusive computation-mode check rejected any variable whose modes mix after reform inheritance: update_variable keeps baseline formulas (by design, for periods before the update), so a reform redeclaring a formula variable with adds/subtracts always tripped the check even though the runtime resolves precedence (formula wins). Check only the modes a class declares itself; runtime uprating assignments still count as explicit. Co-Authored-By: Claude Fable 5 --- ...xempt-inherited-computation-modes.fixed.md | 1 + policyengine_core/variables/variable.py | 36 ++++++++++++++++++- tests/core/test_reforms.py | 36 +++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 changelog.d/exempt-inherited-computation-modes.fixed.md diff --git a/changelog.d/exempt-inherited-computation-modes.fixed.md b/changelog.d/exempt-inherited-computation-modes.fixed.md new file mode 100644 index 00000000..6be15ddf --- /dev/null +++ b/changelog.d/exempt-inherited-computation-modes.fixed.md @@ -0,0 +1 @@ +Exempt attributes inherited from a baseline variable (via reform `update_variable`) from the exclusive computation-mode check. The check now applies only to modes a variable class declares itself, so reforms may redeclare a formula variable with `adds`/`subtracts` while inherited baseline formulas keep their runtime precedence. Runtime `uprating` assignments still count as explicit declarations. diff --git a/policyengine_core/variables/variable.py b/policyengine_core/variables/variable.py index 97944ad8..7f55c3ea 100644 --- a/policyengine_core/variables/variable.py +++ b/policyengine_core/variables/variable.py @@ -145,6 +145,12 @@ def __init__(self, baseline_variable=None): for name, value in self.__class__.__dict__.items() if not name.startswith("__") } + # Attributes the class declares itself, as opposed to attributes + # inherited from a baseline variable when a reform updates it. + # Computation-mode checks only apply to explicit declarations. + self._explicit_attribute_names = frozenset( + name for name, value in attr.items() if value is not None + ) # Allow inheritance for some properties INHERITED_ALLOWED_PROPERTIES = ( @@ -338,12 +344,19 @@ def uprating(self): @uprating.setter def uprating(self, value): old_value = getattr(self, "_uprating", None) + old_explicit = getattr(self, "_explicit_attribute_names", frozenset()) self._uprating = value + # During __init__ (before formulas exist) the assignment may carry a + # value inherited from a baseline variable; only runtime assignments + # count as explicit declarations. if hasattr(self, "formulas"): + if value is not None: + self._explicit_attribute_names = old_explicit | {"uprating"} try: self.check_computation_modes() except ValueError: self._uprating = old_value + self._explicit_attribute_names = old_explicit raise def get_computation_modes(self): @@ -356,8 +369,29 @@ def get_computation_modes(self): computation_modes.append("uprating") return computation_modes + def get_explicit_computation_modes(self): + """Computation modes the class declares itself. + + Excludes attributes inherited from a baseline variable when a + reform updates it: a reform may, for example, redeclare a formula + variable with ``adds``, and the inherited baseline formulas keep + their runtime precedence rather than constituting a mixed-mode + authoring error. + """ + explicit = getattr(self, "_explicit_attribute_names", None) + if explicit is None: + return self.get_computation_modes() + computation_modes = [] + if any(name.startswith(config.FORMULA_NAME_PREFIX) for name in explicit): + computation_modes.append("formula") + if "adds" in explicit or "subtracts" in explicit: + computation_modes.append("adds/subtracts") + if "uprating" in explicit: + computation_modes.append("uprating") + return computation_modes + def check_computation_modes(self): - computation_modes = self.get_computation_modes() + computation_modes = self.get_explicit_computation_modes() if len(computation_modes) > 1: raise ValueError( f'Variable "{self.name}" mixes computation modes: ' diff --git a/tests/core/test_reforms.py b/tests/core/test_reforms.py index a6075ed9..5cd930d8 100644 --- a/tests/core/test_reforms.py +++ b/tests/core/test_reforms.py @@ -203,6 +203,42 @@ def apply(self): assert disposable_income2 > 100 +def test_update_variable_from_formula_to_adds(tax_benefit_system): + """Updating a formula variable with an adds declaration is not a + mixed-mode authoring error: the baseline formulas are inherited and + keep their runtime precedence.""" + + class disposable_income(Variable): + adds = ["salary"] + + class reform(Reform): + def apply(self): + self.update_variable(disposable_income) + + reformed = reform(tax_benefit_system) + updated = reformed.get_variable("disposable_income") + + assert updated.adds == ["salary"] + # Baseline formulas are kept for periods before the update. + assert len(updated.formulas) > 0 + assert updated.get_explicit_computation_modes() == ["adds/subtracts"] + + +def test_update_variable_with_explicit_mixed_modes_raises(tax_benefit_system): + class disposable_income(Variable): + adds = ["salary"] + + def formula(person, period): + return person.empty_array() + + class reform(Reform): + def apply(self): + self.update_variable(disposable_income) + + with pytest.raises(ValueError, match="mixes computation modes"): + reform(tax_benefit_system) + + def test_replace_variable(tax_benefit_system): class disposable_income(Variable): definition_period = MONTH