From 275defa7551231834c2a54cb450b3acbf3c75a46 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 21:50:55 -0400 Subject: [PATCH 01/15] Drop the years Core grew for every input after a later input After a calculation, Core fills each year an input variable holds no input for by growing or carrying an earlier input, and caches the result in the variable's holder. drop_calculated_values, which runs before the next calculation after a set_input on the default branch, kept every year of an input variable, so an input set for an earlier year never reached a year already grown. It now keeps only the years Core records as supplied (Holder._drop_computed reads _user_input_keys) for every input variable, as drop_calculated_totals did for the stored household totals alone, and reset_calculations does the same. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2774.fixed.md | 1 + docs/book/usage/scenarios.md | 9 ++++ policyengine_uk/simulation.py | 7 ++-- policyengine_uk/utils/measurement_branches.py | 42 +++++++++++-------- 4 files changed, 39 insertions(+), 20 deletions(-) create mode 100644 changelog.d/2774.fixed.md diff --git a/changelog.d/2774.fixed.md b/changelog.d/2774.fixed.md new file mode 100644 index 0000000000..c3cbb1487c --- /dev/null +++ b/changelog.d/2774.fixed.md @@ -0,0 +1 @@ +Give an input set for an earlier year after a calculation to every later year the simulation had grown or carried over from an earlier input on the default branch, as a named branch and a new simulation do. diff --git a/docs/book/usage/scenarios.md b/docs/book/usage/scenarios.md index a8665e07d8..e925e5a087 100644 --- a/docs/book/usage/scenarios.md +++ b/docs/book/usage/scenarios.md @@ -193,6 +193,15 @@ response values. Inputs stored only on another clone or branch do not become the parent's inputs. Formula and framework writes during a calculation keep their existing selective invalidation. +The cleanup, like `reset_calculations`, keeps only the years the data, a +modifier or a caller supplied for each input variable. The model calculates +the other years again: those it grew from an earlier input by the variable's +uprating index, carried over from one, or (for a stored household total) +grew from an earlier total. So an input set for an earlier year after a +calculation reaches the later years the simulation had already filled, as on +a named branch and in a new simulation given the same inputs. A later year +that the data or `set_input` supplied keeps its value. + Direct edits of the parameter tree require a separate array-cache boundary. After calling a parameter's `update` method on a built simulation, call `simulation.drop_computed_arrays()` before reading its results. This public diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index 7e87c950d0..fcdb93deaa 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -85,8 +85,8 @@ from policyengine_uk.utils.data_source import built_from_data from policyengine_uk.utils.input_dataframe_export import input_dataframe from policyengine_uk.utils.measurement_branches import ( - drop_calculated_totals, drop_calculated_values, + drop_calculated_years, retained_input_variables, ) from policyengine_uk.utils.supplied_inputs import ( @@ -1119,8 +1119,9 @@ def reset_calculations(self): for variable in self.tax_benefit_system.variables: if variable not in retained: self.delete_arrays(variable) - elif variable in STORED_HOUSEHOLD_TOTALS: - drop_calculated_totals(self, variable) + drop_calculated_years( + self, retained.intersection(self.tax_benefit_system.variables) + ) def get_known_variables(self): variables = [] diff --git a/policyengine_uk/utils/measurement_branches.py b/policyengine_uk/utils/measurement_branches.py index e752895be4..969fea98cf 100644 --- a/policyengine_uk/utils/measurement_branches.py +++ b/policyengine_uk/utils/measurement_branches.py @@ -42,21 +42,30 @@ def retained_input_variables(simulation) -> set: return retained -def drop_calculated_totals(simulation, name: str) -> None: - """Delete the values of a stored household total that the simulation +def drop_calculated_years(simulation, names) -> None: + """Delete the values of the input variables ``names`` that the simulation calculated, keeping those the data or a caller supplied. - A total in STORED_HOUSEHOLD_TOTALS that the data store counts as an input - by name, but the simulation calculates it for the years the data do not - hold, from the stored years and the parts. Core's own record of inputs - (``_user_input_keys``, checked by ``Holder._stores_user_input``) decides - which values go, as it does for ``drop_computed_arrays``. + The simulation counts an input variable as an input by name, but Core + fills each year it holds no input for: it grows the latest earlier input + by the variable's ``uprating`` index, or carries the latest input over, + and caches the result in the variable's holder (``put_in_cache`` with + ``derived=True``). The simulation itself grows a stored household total + (``Simulation._stored_household_total``). Such a year depends on which + years hold inputs, so an input set later for an earlier year changes it. + Core's record of inputs (``_user_input_keys``, checked by + ``Holder._stores_user_input``) holds every value the data or a caller + stored through ``set_input``, whatever the year, and decides which values + go, as it does for ``drop_computed_arrays``. Core then calculates the + other years again from the inputs. """ - simulation.get_holder(name)._drop_computed() + names = set(names) + for name in names: + simulation.get_holder(name)._drop_computed() cache = getattr(simulation, "_fast_cache", None) - if cache: + if cache and names: simulation._fast_cache = { - key: value for key, value in cache.items() if key[0] != name + key: value for key, value in cache.items() if key[0] not in names } @@ -65,12 +74,10 @@ def drop_calculated_values(simulation) -> None: Inputs are variables without a formula, variables the simulation was built from, and variables set since through set_input, as a scenario's - simulation modifier does to formula variables. A stored household total - keeps only the years supplied for it (see drop_calculated_totals). It - visits only the variables the simulation holds values for. + simulation modifier does to formula variables. An input variable keeps + only the years supplied for it (see drop_calculated_years). It visits only + the variables the simulation holds values for. """ - from policyengine_uk.data.economic_assumptions import STORED_HOUSEHOLD_TOTALS - supplied = retained_input_variables(simulation) variables = simulation.tax_benefit_system.variables held = { @@ -78,15 +85,16 @@ def drop_calculated_values(simulation) -> None: for population in simulation.populations.values() for name in getattr(population, "_holders", {}) } + inputs = set() for name in held: variable = variables.get(name) if variable is None: continue if name in supplied or variable.is_input_variable(): - if name in STORED_HOUSEHOLD_TOTALS: - drop_calculated_totals(simulation, name) + inputs.add(name) continue simulation.delete_arrays(name) + drop_calculated_years(simulation, inputs) def held_behavioural_responses(person, period) -> dict: From 313b60dc8117459eae8dcacd00123a8598383e5c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 21:53:37 -0400 Subject: [PATCH 02/15] Test that a later input reaches years grown on every path The issue's rows, its cash_isa total, a data year after the input year, a year a modifier or a caller set for later, a reform before data load, and a branch left alone; and a differential property across the default branch, a named branch and a new simulation for a grown, an uprating-map and a flat-carried input. The stored-totals property now sets balances on the default branch too. Co-Authored-By: Claude Opus 5.5 --- .../test_later_inputs_reach_grown_years.py | 344 ++++++++++++++++++ .../tests/test_stored_household_totals.py | 39 +- 2 files changed, 365 insertions(+), 18 deletions(-) create mode 100644 policyengine_uk/tests/test_later_inputs_reach_grown_years.py diff --git a/policyengine_uk/tests/test_later_inputs_reach_grown_years.py b/policyengine_uk/tests/test_later_inputs_reach_grown_years.py new file mode 100644 index 0000000000..bf52c513d2 --- /dev/null +++ b/policyengine_uk/tests/test_later_inputs_reach_grown_years.py @@ -0,0 +1,344 @@ +"""An input set for an earlier year reaches a year already grown (#2774). + +For a year an input variable holds no input for, Core grows the latest earlier +input by the variable's ``uprating`` index, or carries the latest input over, +and caches the result in the variable's holder. Simulation grows a stored +household total the same way. An input set afterwards for a year in between +changes what that year should be. On a named branch Core drops the values that +may depend on the input. On the default branch Simulation drops what it +calculated before its next calculation (``drop_calculated_values``), and keeps +only the years Core records as supplied, through the data or ``set_input``, of +each input variable. + +Invariants, for any amounts and any order of inputs and calculations: + +- the default branch, a named branch and a new simulation given the same inputs + give the same value for every input variable in every year; +- every year the data, a caller or a scenario's modifier supplied keeps the + value supplied, whether it comes before or after a year set later; +- a branch keeps the values it held when it was created when its parent + changes. +""" + +import numpy as np +import pandas as pd +import pytest +from hypothesis import example, given, settings +from hypothesis import strategies as st +from policyengine_core.periods import period +from policyengine_core.simulations.simulation import _uprating_index_value + +from policyengine_uk import Simulation +from policyengine_uk.data.dataset_schema import UKSingleYearDataset +from policyengine_uk.data.economic_assumptions import PROJECTION_END_YEAR +from policyengine_uk.scenarios import no_economic_assumptions +from policyengine_uk.utils.scenario import Scenario + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +DATA_YEAR = 2025 +LAST_YEAR = 2040 +YEARS = range(DATA_YEAR, LAST_YEAR + 1) +# A person balance grown by GDP per capita, a person income the uprating map +# grows by the private pension index, and a person input with no index, which +# Core carries over flat. +VARIABLES = ("cash_isa_balance", "private_pension_income", "car_list_price") +PROPERTY_SETTINGS = settings( + max_examples=6, + deadline=None, + derandomize=True, + database=None, +) + + +def _frame(**columns): + """One person, with a ``name__year`` column for each value given.""" + return pd.DataFrame( + { + f"{name}__{DATA_YEAR}": [value] + for name, value in ( + ("person_id", 1), + ("person_benunit_id", 1), + ("person_household_id", 1), + ) + } + | {name: [value] for name, value in columns.items()} + ) + + +def _growth(simulation, name, year, base): + """The factor Core grows ``name`` by from ``base`` to ``year``.""" + parameters = simulation.tax_benefit_system.parameters + index = parameters.get_child(simulation.tax_benefit_system.variables[name].uprating) + return _uprating_index_value(index, period(year).start) / _uprating_index_value( + index, period(base).start + ) + + +# Each row of the issue: the variables, then each one's value in 2035 grown +# from £3,000 in 2025, and grown from £10,000 in 2030. savings_interest_income +# depends on the ISA balances, so it has a simulation of its own. +ISSUE_ROWS = { + "balances": { + "cash_isa_balance": (4_197.81, 11_773.88), + "savings": (4_197.81, 11_773.88), + }, + "savings interest": {"savings_interest_income": (4_288.88, 11_773.92)}, +} + + +@pytest.mark.parametrize("row", ISSUE_ROWS) +def test_an_input_set_for_an_earlier_year_reaches_a_year_already_grown(row): + # The issue's reproduction: the data supply £3,000 for 2025. Each path + # grows it to 2035, then gets £10,000 for 2030, and grows that instead, + # as a new simulation given both inputs does. A branch made before the + # input keeps the value its parent held then. + expected = ISSUE_ROWS[row] + simulation = Simulation( + dataset=_frame(**{f"{name}__{DATA_YEAR}": 3_000.0 for name in expected}) + ) + named = simulation.get_branch("edited") + untouched = simulation.get_branch("untouched") + for name, (grown, _) in expected.items(): + for path in (simulation, named, untouched): + assert path.calculate(name, 2035)[0] == pytest.approx(grown, abs=0.005) + assert path.calculate(name, 2035)[0] == pytest.approx( + 3_000 * _growth(simulation, name, 2035, DATA_YEAR), rel=1e-6 + ) + for name in expected: + for path in (simulation, named): + path.set_input(name, 2030, np.array([10_000.0])) + new = Simulation( + dataset=_frame( + **{f"{name}__{DATA_YEAR}": 3_000.0 for name in expected}, + **{f"{name}__2030": 10_000.0 for name in expected}, + ) + ) + for name, (grown, regrown) in expected.items(): + values = [path.calculate(name, 2035)[0] for path in (simulation, named, new)] + assert values == [values[-1]] * 3, name + assert values[-1] == pytest.approx(regrown, abs=0.005) + assert values[-1] == pytest.approx( + 10_000 * _growth(simulation, name, 2035, 2030), rel=1e-6 + ) + assert untouched.calculate(name, 2035)[0] == pytest.approx(grown, abs=0.005) + for path in (simulation, named): + assert path.calculate(name, DATA_YEAR)[0] == 3_000 + assert path.calculate(name, 2030)[0] == 10_000 + + +def _two_adults_with_a_stored_cash_isa(): + return UKSingleYearDataset( + person=pd.DataFrame( + { + "person_id": [1, 2], + "person_benunit_id": [1, 1], + "person_household_id": [1, 1], + "age": [30, 28], + } + ), + benunit=pd.DataFrame({"benunit_id": [1]}), + household=pd.DataFrame( + { + "household_id": [1], + "household_weight": [1.0], + "region": ["LONDON"], + "tenure_type": ["RENT_PRIVATELY"], + "cash_isa": [3_000.0], + } + ), + fiscal_year=DATA_YEAR, + ) + + +def test_a_balance_set_for_an_earlier_year_reaches_a_total_already_summed(): + # The issue's total: the data store cash_isa through PROJECTION_END_YEAR. + # A balance of £100 for 2034, newer than the stored total, gives cash_isa + # in 2040 as £117.74. A balance of £200 then set for 2035 gives £227.91 + # on the default branch, as on a named branch and in a simulation given + # both balances before calculating anything. + assert PROJECTION_END_YEAR < 2034 + simulation = Simulation(dataset=_two_adults_with_a_stored_cash_isa()) + named = simulation.get_branch("edited") + new = Simulation(dataset=_two_adults_with_a_stored_cash_isa()) + for path in (simulation, named, new): + path.set_input("cash_isa_balance", 2034, np.array([100.0, 0.0])) + for path in (simulation, named): + assert path.calculate("cash_isa", 2040)[0] == pytest.approx(117.74, abs=0.005) + path.set_input("cash_isa_balance", 2035, np.array([200.0, 0.0])) + new.set_input("cash_isa_balance", 2035, np.array([200.0, 0.0])) + values = [path.calculate("cash_isa", 2040)[0] for path in (simulation, named, new)] + assert values == [values[-1]] * 3 + assert values[-1] == pytest.approx(227.91, abs=0.005) + assert values[-1] == pytest.approx( + 200 * _growth(simulation, "cash_isa_balance", 2040, 2035), rel=1e-6 + ) + for path in (simulation, named): + assert path.calculate("cash_isa", DATA_YEAR)[0] == 3_000 + + +def test_years_the_data_supply_after_a_later_input_keep_their_values(): + # The frame supplies 2025 and 2033. After 2030 and 2035 are grown, an + # input for 2028 replaces what 2030 grew from, but not 2033, nor 2035, + # which grows from 2033. + data = { + f"cash_isa_balance__{DATA_YEAR}": 3_000.0, + "cash_isa_balance__2033": 5_000.0, + } + simulation = Simulation(dataset=_frame(**data)) + named = simulation.get_branch("edited") + for path in (simulation, named): + for year in (2030, 2035): + path.calculate("cash_isa_balance", year) + path.set_input("cash_isa_balance", 2028, np.array([10_000.0])) + new = Simulation(dataset=_frame(**data, cash_isa_balance__2028=10_000.0)) + for year in YEARS: + values = [ + path.calculate("cash_isa_balance", year)[0] + for path in (simulation, named, new) + ] + assert values == [values[-1]] * 3, year + for path in (simulation, named): + assert path.calculate("cash_isa_balance", 2033)[0] == 5_000 + assert path.calculate("cash_isa_balance", 2035)[0] == pytest.approx( + 5_000 * _growth(simulation, "cash_isa_balance", 2035, 2033), rel=1e-6 + ) + assert path.calculate("cash_isa_balance", 2030)[0] == pytest.approx( + 10_000 * _growth(simulation, "cash_isa_balance", 2030, 2028), rel=1e-6 + ) + + +def test_years_a_dataset_projects_under_a_reform_before_data_load_stay(): + # no_economic_assumptions applies before data load, so the dataset holds + # cash_isa flat at £3,000 in each year it projects, through + # PROJECTION_END_YEAR. A balance set after a calculation for the first of + # those years replaces none of the others. + simulation = Simulation( + scenario=no_economic_assumptions, + dataset=_two_adults_with_a_stored_cash_isa(), + ) + later = PROJECTION_END_YEAR + 3 + assert simulation.calculate("cash_isa", later)[0] == 3_000 + simulation.set_input("cash_isa", DATA_YEAR + 1, np.array([10_000.0])) + assert simulation.calculate("cash_isa", DATA_YEAR + 1)[0] == 10_000 + for year in range(DATA_YEAR + 2, PROJECTION_END_YEAR + 1): + assert simulation.calculate("cash_isa", year)[0] == 3_000, year + assert simulation.calculate("cash_isa", later)[0] == 3_000 + + +def _sets_a_later_balance(simulation): + simulation.set_input("cash_isa_balance", 2038, np.array([7_000.0])) + + +@pytest.mark.parametrize("route", ["modifier", "simulation", "holder"]) +def test_a_year_set_for_later_than_a_later_input_keeps_its_value(route): + # A scenario's modifier, which runs once the data are built, or a caller, + # through the simulation or straight through the holder, sets £7,000 for + # 2038 before anything is calculated. After 2040 is grown from it, an + # input for 2030 replaces only the years before 2038. + data = _frame(**{f"cash_isa_balance__{DATA_YEAR}": 3_000.0}) + if route == "modifier": + simulation = Simulation( + scenario=Scenario(simulation_modifier=_sets_a_later_balance), dataset=data + ) + else: + simulation = Simulation(dataset=data) + if route == "simulation": + _sets_a_later_balance(simulation) + else: + simulation.get_holder("cash_isa_balance").set_input( + period(2038), np.array([7_000.0]) + ) + grown = 7_000 * _growth(simulation, "cash_isa_balance", 2040, 2038) + assert simulation.calculate("cash_isa_balance", 2040)[0] == pytest.approx( + grown, rel=1e-6 + ) + assert simulation.calculate("cash_isa_balance", 2035)[0] == pytest.approx( + 3_000 * _growth(simulation, "cash_isa_balance", 2035, DATA_YEAR), rel=1e-6 + ) + simulation.set_input("cash_isa_balance", 2030, np.array([10_000.0])) + assert simulation.calculate("cash_isa_balance", 2035)[0] == pytest.approx( + 10_000 * _growth(simulation, "cash_isa_balance", 2035, 2030), rel=1e-6 + ) + assert simulation.calculate("cash_isa_balance", 2038)[0] == 7_000 + assert simulation.calculate("cash_isa_balance", 2040)[0] == pytest.approx( + grown, rel=1e-6 + ) + + +AMOUNTS = st.integers(min_value=0, max_value=5_000_000).map(lambda pence: pence / 100) +LATER_YEARS = st.integers(min_value=DATA_YEAR + 1, max_value=LAST_YEAR) +STEPS = st.lists( + st.one_of( + st.tuples( + st.just("calculate"), + st.sampled_from(VARIABLES), + st.integers(min_value=DATA_YEAR, max_value=LAST_YEAR), + ), + st.tuples(st.just("set"), st.sampled_from(VARIABLES), LATER_YEARS, AMOUNTS), + ), + min_size=1, + max_size=8, +) +# What the data supply: each variable in 2025, and maybe in one later year. +DATA = st.fixed_dictionaries( + { + name: st.tuples(AMOUNTS, st.none() | st.tuples(LATER_YEARS, AMOUNTS)) + for name in VARIABLES + } +) + + +@PROPERTY_SETTINGS +@given(data=DATA, steps=STEPS) +@example( + data={name: (3_000.0, None) for name in VARIABLES}, + steps=[("calculate", name, 2035) for name in VARIABLES] + + [("set", name, 2030, 10_000.0) for name in VARIABLES], +) +@example( + data={name: (3_000.0, (2033, 5_000.0)) for name in VARIABLES}, + steps=[ + ("calculate", "private_pension_income", 2040), + ("set", "private_pension_income", 2028, 10_000.0), + ("calculate", "car_list_price", 2030), + ("set", "car_list_price", 2027, 20_000.0), + ("calculate", "cash_isa_balance", 2036), + ("set", "cash_isa_balance", 2034, 100.0), + ("set", "cash_isa_balance", 2035, 200.0), + ], +) +def test_every_path_gives_what_a_new_simulation_gives(data, steps): + # The same inputs and calculations, in the same order, on the default + # branch and on a named branch, made before either: each calculation + # gives the same value on both, and afterwards every input variable in + # every year equals what a new simulation built from all the inputs + # gives. Every supplied year keeps its value. + columns = {} + for name, (amount, later) in data.items(): + columns[f"{name}__{DATA_YEAR}"] = amount + if later is not None: + columns[f"{name}__{later[0]}"] = later[1] + simulation = Simulation(dataset=_frame(**columns)) + named = simulation.get_branch("edited") + for step in steps: + if step[0] == "calculate": + _, name, year = step + default_value = simulation.calculate(name, year)[0] + assert default_value == named.calculate(name, year)[0], (step, steps) + else: + _, name, year, amount = step + for path in (simulation, named): + path.set_input(name, year, np.array([amount])) + columns[f"{name}__{year}"] = amount + new = Simulation(dataset=_frame(**columns)) + for name in VARIABLES: + for year in YEARS: + values = [ + path.calculate(name, year)[0] for path in (simulation, named, new) + ] + assert values == [values[-1]] * 3, (name, year, steps) + for column, amount in columns.items(): + name, year = column.rsplit("__", 1) + for path in (simulation, named): + assert path.calculate(name, int(year))[0] == np.float32(amount), column diff --git a/policyengine_uk/tests/test_stored_household_totals.py b/policyengine_uk/tests/test_stored_household_totals.py index 468ff7c971..2a96a6d44e 100644 --- a/policyengine_uk/tests/test_stored_household_totals.py +++ b/policyengine_uk/tests/test_stored_household_totals.py @@ -18,9 +18,9 @@ - with person balances stored instead, the total equals their sum every year; - the two forms give the same totals and means-test capital when they hold the same amounts; -- after totals set for any years, and balances set on a named branch, between - calculations, a total keeps every value the data or a caller supplied, and - equals what a fresh calculation gives in other years; +- after totals and balances set for any years, on the default or a named + branch, between calculations, a total keeps every value the data or a caller + supplied, and equals what a fresh calculation gives in other years; - a grown total grows as Core grows an input with its parts' index; - a reform that changes the sum or a part gets Core's sum, and a reform that declares the same variables again changes nothing. @@ -1069,18 +1069,11 @@ def test_stored_totals_and_person_balances_agree_in_every_year(households): st.tuples(st.just("cash_isa"), INPUT_YEARS, AMOUNTS), ) BALANCE_STEP = st.tuples(st.just("cash_isa_balance"), INPUT_YEARS, AMOUNTS) -# Core keeps a value it has uprated for a person balance on the default -# branch when a balance for an earlier year changes there, so a total that -# sums the balances would read it. Balances change only on a named branch, -# where Core drops what depends on them. -RUNS = st.one_of( - st.tuples( - st.just(False), st.lists(st.one_of(*TOTAL_STEPS), min_size=1, max_size=8) - ), - st.tuples( - st.just(True), - st.lists(st.one_of(*TOTAL_STEPS, BALANCE_STEP), min_size=1, max_size=8), - ), +# A balance set on either branch replaces what Core grew from an earlier one, +# so a total that sums the balances reads the new one (#2774). +RUNS = st.tuples( + st.booleans(), + st.lists(st.one_of(*TOTAL_STEPS, BALANCE_STEP), min_size=1, max_size=8), ) @@ -1108,10 +1101,20 @@ def test_stored_totals_and_person_balances_agree_in_every_year(households): ], ) ) +@example( + run=( + False, + [ + ("cash_isa_balance", 2034, 100.0), + ("calculate", 2040), + ("cash_isa_balance", 2035, 200.0), + ], + ) +) def test_a_total_after_any_inputs_equals_a_fresh_calculation(run): - # Totals set for any year, and on a named branch balances too, between - # calculations of any year: each calculated total equals what the same - # simulation gives with every calculated value dropped, and every + # Totals and balances set for any year, on the default or a named branch, + # between calculations of any year: each calculated total equals what the + # same simulation gives with every calculated value dropped, and every # supplied total keeps its value. on_branch, steps = run simulation = Simulation( From 952ae5feeb0d9aff3cb35f3325a4ec6e0e00365a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 21:56:44 -0400 Subject: [PATCH 03/15] Test that a reset keeps supplied years and drops grown ones Co-Authored-By: Claude Opus 5.5 --- .../test_later_inputs_reach_grown_years.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/policyengine_uk/tests/test_later_inputs_reach_grown_years.py b/policyengine_uk/tests/test_later_inputs_reach_grown_years.py index bf52c513d2..2cc83fab7f 100644 --- a/policyengine_uk/tests/test_later_inputs_reach_grown_years.py +++ b/policyengine_uk/tests/test_later_inputs_reach_grown_years.py @@ -266,6 +266,25 @@ def test_a_year_set_for_later_than_a_later_input_keeps_its_value(route): ) +def test_a_reset_drops_grown_years_and_keeps_supplied_ones(): + # reset_calculations keeps what the drop before a calculation keeps: the + # years the data or a caller supplied, here 2025 and 2038, and not the + # years grown from them. + simulation = Simulation( + dataset=_frame(**{f"cash_isa_balance__{DATA_YEAR}": 3_000.0}) + ) + simulation.set_input("cash_isa_balance", 2038, np.array([7_000.0])) + holder = simulation.get_holder("cash_isa_balance") + for year in (2035, 2040): + simulation.calculate("cash_isa_balance", year) + assert holder.get_array(period(year)) is not None + simulation.reset_calculations() + for year in (2035, 2040): + assert holder.get_array(period(year)) is None + assert holder.get_array(period(DATA_YEAR))[0] == 3_000 + assert holder.get_array(period(2038))[0] == 7_000 + + AMOUNTS = st.integers(min_value=0, max_value=5_000_000).map(lambda pence: pence / 100) LATER_YEARS = st.integers(min_value=DATA_YEAR + 1, max_value=LAST_YEAR) STEPS = st.lists( From 9998e32381c1694813ebdac3afa8c340f78eed78 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 22:08:52 -0400 Subject: [PATCH 04/15] Test that a supplied formula variable is recalculated for other years Co-Authored-By: Claude Opus 5.5 --- .../test_later_inputs_reach_grown_years.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/policyengine_uk/tests/test_later_inputs_reach_grown_years.py b/policyengine_uk/tests/test_later_inputs_reach_grown_years.py index 2cc83fab7f..4751eb2775 100644 --- a/policyengine_uk/tests/test_later_inputs_reach_grown_years.py +++ b/policyengine_uk/tests/test_later_inputs_reach_grown_years.py @@ -31,6 +31,7 @@ from policyengine_uk import Simulation from policyengine_uk.data.dataset_schema import UKSingleYearDataset from policyengine_uk.data.economic_assumptions import PROJECTION_END_YEAR +from policyengine_uk.model_api import YEAR, Person, Variable from policyengine_uk.scenarios import no_economic_assumptions from policyengine_uk.utils.scenario import Scenario @@ -266,6 +267,37 @@ def test_a_year_set_for_later_than_a_later_input_keeps_its_value(route): ) +class twice_car_list_price(Variable): + value_type = float + entity = Person + label = "Twice the car list price" + definition_period = YEAR + + def formula(person, period, parameters): + return 2 * person("car_list_price", period) + + +def test_a_formula_variable_supplied_for_one_year_is_recalculated_for_others(): + # A caller supplies a formula variable for 2025 only, so the simulation + # keeps it as an input variable. Its formula gives 2030 from the list + # price carried from 2025; a list price then set for 2030 changes it on + # every path, and the supplied year keeps its value. + data = {f"car_list_price__{DATA_YEAR}": 3_000.0} + simulation = Simulation(dataset=_frame(**data)) + new = Simulation(dataset=_frame(**data, car_list_price__2030=5_000.0)) + for path in (simulation, new): + path.tax_benefit_system.add_variable(twice_car_list_price) + named = simulation.get_branch("edited") + for path in (simulation, named, new): + path.set_input("twice_car_list_price", DATA_YEAR, np.array([1.0])) + for path in (simulation, named): + assert path.calculate("twice_car_list_price", 2030)[0] == 6_000 + path.set_input("car_list_price", 2030, np.array([5_000.0])) + for path in (simulation, named, new): + assert path.calculate("twice_car_list_price", 2030)[0] == 10_000 + assert path.calculate("twice_car_list_price", DATA_YEAR)[0] == 1 + + def test_a_reset_drops_grown_years_and_keeps_supplied_ones(): # reset_calculations keeps what the drop before a calculation keeps: the # years the data or a caller supplied, here 2025 and 2038, and not the From e3462b79aca23deaba42414e45127456c61b032a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 22:17:55 -0400 Subject: [PATCH 05/15] Give the test dataset the household columns its projection reads Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_later_inputs_reach_grown_years.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/policyengine_uk/tests/test_later_inputs_reach_grown_years.py b/policyengine_uk/tests/test_later_inputs_reach_grown_years.py index 4751eb2775..1b737a7c0f 100644 --- a/policyengine_uk/tests/test_later_inputs_reach_grown_years.py +++ b/policyengine_uk/tests/test_later_inputs_reach_grown_years.py @@ -145,6 +145,9 @@ def _two_adults_with_a_stored_cash_isa(): "household_weight": [1.0], "region": ["LONDON"], "tenure_type": ["RENT_PRIVATELY"], + "council_tax": [1_500.0], + "rent": [12_000.0], + "savings": [1_000.0], "cash_isa": [3_000.0], } ), From 6434140e96533e2fe68b9519a54df6a7d092b7ed Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 22:19:33 -0400 Subject: [PATCH 06/15] Run the later-input tests on a copy of the simulation too A copy keeps Core's cache of results by variable and period, which the drop must clear as well. Co-Authored-By: Claude Opus 5.5 --- .../test_later_inputs_reach_grown_years.py | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/policyengine_uk/tests/test_later_inputs_reach_grown_years.py b/policyengine_uk/tests/test_later_inputs_reach_grown_years.py index 1b737a7c0f..cf519367c1 100644 --- a/policyengine_uk/tests/test_later_inputs_reach_grown_years.py +++ b/policyengine_uk/tests/test_later_inputs_reach_grown_years.py @@ -92,22 +92,26 @@ def _growth(simulation, name, year, base): def test_an_input_set_for_an_earlier_year_reaches_a_year_already_grown(row): # The issue's reproduction: the data supply £3,000 for 2025. Each path # grows it to 2035, then gets £10,000 for 2030, and grows that instead, - # as a new simulation given both inputs does. A branch made before the - # input keeps the value its parent held then. + # as a new simulation given both inputs does. A copy of the simulation is + # a default branch too, one that also keeps Core's cache of results by + # variable and period. A branch made before the input keeps the value its + # parent held then. expected = ISSUE_ROWS[row] simulation = Simulation( dataset=_frame(**{f"{name}__{DATA_YEAR}": 3_000.0 for name in expected}) ) + copy = simulation.clone() named = simulation.get_branch("edited") untouched = simulation.get_branch("untouched") + paths = (simulation, copy, named) for name, (grown, _) in expected.items(): - for path in (simulation, named, untouched): + for path in (*paths, untouched): assert path.calculate(name, 2035)[0] == pytest.approx(grown, abs=0.005) assert path.calculate(name, 2035)[0] == pytest.approx( 3_000 * _growth(simulation, name, 2035, DATA_YEAR), rel=1e-6 ) for name in expected: - for path in (simulation, named): + for path in paths: path.set_input(name, 2030, np.array([10_000.0])) new = Simulation( dataset=_frame( @@ -116,14 +120,14 @@ def test_an_input_set_for_an_earlier_year_reaches_a_year_already_grown(row): ) ) for name, (grown, regrown) in expected.items(): - values = [path.calculate(name, 2035)[0] for path in (simulation, named, new)] - assert values == [values[-1]] * 3, name + values = [path.calculate(name, 2035)[0] for path in (*paths, new)] + assert values == [values[-1]] * 4, name assert values[-1] == pytest.approx(regrown, abs=0.005) assert values[-1] == pytest.approx( 10_000 * _growth(simulation, name, 2035, 2030), rel=1e-6 ) assert untouched.calculate(name, 2035)[0] == pytest.approx(grown, abs=0.005) - for path in (simulation, named): + for path in paths: assert path.calculate(name, DATA_YEAR)[0] == 3_000 assert path.calculate(name, 2030)[0] == 10_000 @@ -364,35 +368,34 @@ def test_a_reset_drops_grown_years_and_keeps_supplied_ones(): ) def test_every_path_gives_what_a_new_simulation_gives(data, steps): # The same inputs and calculations, in the same order, on the default - # branch and on a named branch, made before either: each calculation - # gives the same value on both, and afterwards every input variable in - # every year equals what a new simulation built from all the inputs - # gives. Every supplied year keeps its value. + # branch, on a copy of it (which keeps Core's cache of results by variable + # and period) and on a named branch, each made before any step: each + # calculation gives the same value on all three, and afterwards every + # input variable in every year equals what a new simulation built from + # all the inputs gives. Every supplied year keeps its value. columns = {} for name, (amount, later) in data.items(): columns[f"{name}__{DATA_YEAR}"] = amount if later is not None: columns[f"{name}__{later[0]}"] = later[1] simulation = Simulation(dataset=_frame(**columns)) - named = simulation.get_branch("edited") + paths = (simulation, simulation.clone(), simulation.get_branch("edited")) for step in steps: if step[0] == "calculate": _, name, year = step - default_value = simulation.calculate(name, year)[0] - assert default_value == named.calculate(name, year)[0], (step, steps) + values = [path.calculate(name, year)[0] for path in paths] + assert values == [values[0]] * 3, (step, steps) else: _, name, year, amount = step - for path in (simulation, named): + for path in paths: path.set_input(name, year, np.array([amount])) columns[f"{name}__{year}"] = amount new = Simulation(dataset=_frame(**columns)) for name in VARIABLES: for year in YEARS: - values = [ - path.calculate(name, year)[0] for path in (simulation, named, new) - ] - assert values == [values[-1]] * 3, (name, year, steps) + values = [path.calculate(name, year)[0] for path in (*paths, new)] + assert values == [values[-1]] * 4, (name, year, steps) for column, amount in columns.items(): name, year = column.rsplit("__", 1) - for path in (simulation, named): + for path in paths: assert path.calculate(name, int(year))[0] == np.float32(amount), column From f6e1217e41cae6fe3056d98f6560d8fbd73ff5ba Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 22:29:34 -0400 Subject: [PATCH 07/15] Read a grown balance through a sum before reading it directly Check cash_isa, which sums cash_isa_balance, on every path before reading the balance itself, in the issue-row test and in the property. The next commit reads the inputs through a formula instead, and says why. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_later_inputs_reach_grown_years.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_later_inputs_reach_grown_years.py b/policyengine_uk/tests/test_later_inputs_reach_grown_years.py index cf519367c1..7b96539f38 100644 --- a/policyengine_uk/tests/test_later_inputs_reach_grown_years.py +++ b/policyengine_uk/tests/test_later_inputs_reach_grown_years.py @@ -86,6 +86,11 @@ def _growth(simulation, name, year, base): }, "savings interest": {"savings_interest_income": (4_288.88, 11_773.92)}, } +# A household variable that sums a person balance. A call from outside the +# simulation reads a calculated balance from its holder, but the sum's formula +# reads it through Core's cache of results by variable and period, which a +# copy of a simulation keeps. +SUMS = {"cash_isa_balance": "cash_isa"} @pytest.mark.parametrize("row", ISSUE_ROWS) @@ -120,6 +125,9 @@ def test_an_input_set_for_an_earlier_year_reaches_a_year_already_grown(row): ) ) for name, (grown, regrown) in expected.items(): + if name in SUMS: + sums = [path.calculate(SUMS[name], 2035)[0] for path in (*paths, new)] + assert sums == [sums[-1]] * 4, SUMS[name] values = [path.calculate(name, 2035)[0] for path in (*paths, new)] assert values == [values[-1]] * 4, name assert values[-1] == pytest.approx(regrown, abs=0.005) @@ -391,7 +399,7 @@ def test_every_path_gives_what_a_new_simulation_gives(data, steps): path.set_input(name, year, np.array([amount])) columns[f"{name}__{year}"] = amount new = Simulation(dataset=_frame(**columns)) - for name in VARIABLES: + for name in (*SUMS.values(), *VARIABLES): for year in YEARS: values = [path.calculate(name, year)[0] for path in (*paths, new)] assert values == [values[-1]] * 4, (name, year, steps) From 19f5ac7524fc4523324a235fb109287bbb4fbf21 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 22:49:38 -0400 Subject: [PATCH 08/15] Read the inputs through a formula, which reads Core's result cache Core sums a variable's adds with map_to, and a call from outside the simulation decodes enums; both skip Core's cache of results by variable and period. A formula that calls person(name, period) reads it, so the formula test and the property read the inputs through one. Co-Authored-By: Claude Opus 5.5 --- .../test_later_inputs_reach_grown_years.py | 55 +++++++++++-------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/policyengine_uk/tests/test_later_inputs_reach_grown_years.py b/policyengine_uk/tests/test_later_inputs_reach_grown_years.py index 7b96539f38..f5caf92b53 100644 --- a/policyengine_uk/tests/test_later_inputs_reach_grown_years.py +++ b/policyengine_uk/tests/test_later_inputs_reach_grown_years.py @@ -86,21 +86,15 @@ def _growth(simulation, name, year, base): }, "savings interest": {"savings_interest_income": (4_288.88, 11_773.92)}, } -# A household variable that sums a person balance. A call from outside the -# simulation reads a calculated balance from its holder, but the sum's formula -# reads it through Core's cache of results by variable and period, which a -# copy of a simulation keeps. -SUMS = {"cash_isa_balance": "cash_isa"} @pytest.mark.parametrize("row", ISSUE_ROWS) def test_an_input_set_for_an_earlier_year_reaches_a_year_already_grown(row): # The issue's reproduction: the data supply £3,000 for 2025. Each path # grows it to 2035, then gets £10,000 for 2030, and grows that instead, - # as a new simulation given both inputs does. A copy of the simulation is - # a default branch too, one that also keeps Core's cache of results by - # variable and period. A branch made before the input keeps the value its - # parent held then. + # as a new simulation given both inputs does. A copy of the simulation, + # made as its baseline is, is a default branch too. A branch made before + # the input keeps the value its parent held then. expected = ISSUE_ROWS[row] simulation = Simulation( dataset=_frame(**{f"{name}__{DATA_YEAR}": 3_000.0 for name in expected}) @@ -125,9 +119,6 @@ def test_an_input_set_for_an_earlier_year_reaches_a_year_already_grown(row): ) ) for name, (grown, regrown) in expected.items(): - if name in SUMS: - sums = [path.calculate(SUMS[name], 2035)[0] for path in (*paths, new)] - assert sums == [sums[-1]] * 4, SUMS[name] values = [path.calculate(name, 2035)[0] for path in (*paths, new)] assert values == [values[-1]] * 4, name assert values[-1] == pytest.approx(regrown, abs=0.005) @@ -295,21 +286,27 @@ def formula(person, period, parameters): def test_a_formula_variable_supplied_for_one_year_is_recalculated_for_others(): # A caller supplies a formula variable for 2025 only, so the simulation # keeps it as an input variable. Its formula gives 2030 from the list - # price carried from 2025; a list price then set for 2030 changes it on - # every path, and the supplied year keeps its value. + # price carried from 2025. A list price then set for 2028 changes both on + # every path, and the supplied year keeps its value. The formula reads the + # list price as formulas do, through Core's cache of results by variable + # and period, which a call from outside the simulation does not read + # (nor does Core's sum of a variable's adds, which maps the result to + # the variable's entity), so the drop clears that cache too. data = {f"car_list_price__{DATA_YEAR}": 3_000.0} simulation = Simulation(dataset=_frame(**data)) - new = Simulation(dataset=_frame(**data, car_list_price__2030=5_000.0)) + new = Simulation(dataset=_frame(**data, car_list_price__2028=5_000.0)) for path in (simulation, new): path.tax_benefit_system.add_variable(twice_car_list_price) named = simulation.get_branch("edited") for path in (simulation, named, new): path.set_input("twice_car_list_price", DATA_YEAR, np.array([1.0])) for path in (simulation, named): + assert path.calculate("car_list_price", 2030)[0] == 3_000 assert path.calculate("twice_car_list_price", 2030)[0] == 6_000 - path.set_input("car_list_price", 2030, np.array([5_000.0])) + path.set_input("car_list_price", 2028, np.array([5_000.0])) for path in (simulation, named, new): assert path.calculate("twice_car_list_price", 2030)[0] == 10_000 + assert path.calculate("car_list_price", 2030)[0] == 5_000 assert path.calculate("twice_car_list_price", DATA_YEAR)[0] == 1 @@ -332,6 +329,16 @@ def test_a_reset_drops_grown_years_and_keeps_supplied_ones(): assert holder.get_array(period(2038))[0] == 7_000 +class sum_of_the_inputs(Variable): + value_type = float + entity = Person + label = "The inputs the property test sets, summed by a formula" + definition_period = YEAR + + def formula(person, period, parameters): + return sum(person(name, period) for name in VARIABLES) + + AMOUNTS = st.integers(min_value=0, max_value=5_000_000).map(lambda pence: pence / 100) LATER_YEARS = st.integers(min_value=DATA_YEAR + 1, max_value=LAST_YEAR) STEPS = st.lists( @@ -376,17 +383,19 @@ def test_a_reset_drops_grown_years_and_keeps_supplied_ones(): ) def test_every_path_gives_what_a_new_simulation_gives(data, steps): # The same inputs and calculations, in the same order, on the default - # branch, on a copy of it (which keeps Core's cache of results by variable - # and period) and on a named branch, each made before any step: each - # calculation gives the same value on all three, and afterwards every - # input variable in every year equals what a new simulation built from - # all the inputs gives. Every supplied year keeps its value. + # branch, on a copy of it and on a named branch, each made before any + # step: each calculation gives the same value on all three. Afterwards + # every input variable in every year, and a formula that reads them all + # through Core's cache of results by variable and period, equal what a + # new simulation built from all the inputs gives. Every supplied year + # keeps its value. columns = {} for name, (amount, later) in data.items(): columns[f"{name}__{DATA_YEAR}"] = amount if later is not None: columns[f"{name}__{later[0]}"] = later[1] simulation = Simulation(dataset=_frame(**columns)) + simulation.tax_benefit_system.add_variable(sum_of_the_inputs) paths = (simulation, simulation.clone(), simulation.get_branch("edited")) for step in steps: if step[0] == "calculate": @@ -399,7 +408,9 @@ def test_every_path_gives_what_a_new_simulation_gives(data, steps): path.set_input(name, year, np.array([amount])) columns[f"{name}__{year}"] = amount new = Simulation(dataset=_frame(**columns)) - for name in (*SUMS.values(), *VARIABLES): + new.tax_benefit_system.add_variable(sum_of_the_inputs) + # The formula first, while the inputs it reads are only cached. + for name in ("sum_of_the_inputs", *VARIABLES): for year in YEARS: values = [path.calculate(name, year)[0] for path in (*paths, new)] assert values == [values[-1]] * 4, (name, year, steps) From 2e16755fa7f30d34a4e7cb7635c773906a88969b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 00:07:03 -0400 Subject: [PATCH 09/15] Name the formula years and the result cache the drop covers Co-Authored-By: Claude Opus 5.5 --- docs/book/usage/scenarios.md | 10 +++---- policyengine_uk/utils/measurement_branches.py | 30 ++++++++++--------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/docs/book/usage/scenarios.md b/docs/book/usage/scenarios.md index e925e5a087..c780f53288 100644 --- a/docs/book/usage/scenarios.md +++ b/docs/book/usage/scenarios.md @@ -196,11 +196,11 @@ their existing selective invalidation. The cleanup, like `reset_calculations`, keeps only the years the data, a modifier or a caller supplied for each input variable. The model calculates the other years again: those it grew from an earlier input by the variable's -uprating index, carried over from one, or (for a stored household total) -grew from an earlier total. So an input set for an earlier year after a -calculation reaches the later years the simulation had already filled, as on -a named branch and in a new simulation given the same inputs. A later year -that the data or `set_input` supplied keeps its value. +uprating index, carried over from one, gave by formula, or (for a stored +household total) grew from an earlier total. So an input set for an earlier +year after a calculation reaches the later years the simulation had already +filled, as on a named branch and in a new simulation given the same inputs. +A later year that the data or `set_input` supplied keeps its value. Direct edits of the parameter tree require a separate array-cache boundary. After calling a parameter's `update` method on a built simulation, call diff --git a/policyengine_uk/utils/measurement_branches.py b/policyengine_uk/utils/measurement_branches.py index 969fea98cf..e6cc86ee1c 100644 --- a/policyengine_uk/utils/measurement_branches.py +++ b/policyengine_uk/utils/measurement_branches.py @@ -7,7 +7,7 @@ behavioural response neutralised and those inputs set. """ -from typing import Dict +from typing import Dict, Iterable import numpy as np @@ -42,22 +42,24 @@ def retained_input_variables(simulation) -> set: return retained -def drop_calculated_years(simulation, names) -> None: +def drop_calculated_years(simulation, names: Iterable[str]) -> None: """Delete the values of the input variables ``names`` that the simulation calculated, keeping those the data or a caller supplied. - The simulation counts an input variable as an input by name, but Core - fills each year it holds no input for: it grows the latest earlier input - by the variable's ``uprating`` index, or carries the latest input over, - and caches the result in the variable's holder (``put_in_cache`` with - ``derived=True``). The simulation itself grows a stored household total - (``Simulation._stored_household_total``). Such a year depends on which - years hold inputs, so an input set later for an earlier year changes it. - Core's record of inputs (``_user_input_keys``, checked by - ``Holder._stores_user_input``) holds every value the data or a caller - stored through ``set_input``, whatever the year, and decides which values - go, as it does for ``drop_computed_arrays``. Core then calculates the - other years again from the inputs. + The simulation counts an input variable as an input by name, but it also + holds years of it that it calculated. For a year with no input, Core grows + the latest earlier input by the variable's ``uprating`` index, or carries + the latest input over, and caches the result in the variable's holder + (``put_in_cache`` with ``derived=True``). The simulation grows a stored + household total itself (``Simulation._stored_household_total``), and a + formula gives the other years of a formula variable supplied for some. + Such a year depends on other years' inputs, so an input set later for an + earlier year changes it. Core's record of inputs (``_user_input_keys``, + checked by ``Holder._stores_user_input``) holds every value the data or a + caller stored through ``set_input``, whatever the year, and decides which + values go, as it does for ``drop_computed_arrays``. Core then calculates + the other years again. Formulas read Core's cache of results by variable + and period, so its entries for ``names`` go too. """ names = set(names) for name in names: From 948c79fcdcb5f84440e0250c64169115fa4e9134 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 01:12:48 -0400 Subject: [PATCH 10/15] Say what Core does with a balance stored in another unit Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/simulation.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index fcdb93deaa..3e2043e803 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -816,9 +816,8 @@ def _stored_household_total(self, variable, period): native = self.tax_benefit_system.variables[part].definition_period if part_source.start > source.start or part_source.unit != native: # Person balances stored for a later year than the total are - # the newer observation, and Core carries a balance stored in - # another unit flat rather than uprating it, so their sum - # stands. + # the newer observation, and Core doesn't uprate a balance + # stored in another unit, so their sum stands. return None try: index_name = stored_total_uprating(self.tax_benefit_system, variable.name) From fc80288af7b7cd360ede4e744718a824ed11f269 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 05:29:16 -0500 Subject: [PATCH 11/15] Check grown years against the growth index, not pinned figures The issue rows and the summed total now compare each grown year with the input times the variable's growth from the parameters, so a new forecast or a change to the growth series moves the expected value with it. The paths still have to agree exactly. A new test covers a reset after a later input, when a caller supplied a formula variable for one year. Co-Authored-By: Claude Opus 5.5 --- .../test_later_inputs_reach_grown_years.py | 81 +++++++++++-------- 1 file changed, 49 insertions(+), 32 deletions(-) diff --git a/policyengine_uk/tests/test_later_inputs_reach_grown_years.py b/policyengine_uk/tests/test_later_inputs_reach_grown_years.py index f5caf92b53..7882bbd368 100644 --- a/policyengine_uk/tests/test_later_inputs_reach_grown_years.py +++ b/policyengine_uk/tests/test_later_inputs_reach_grown_years.py @@ -13,11 +13,12 @@ Invariants, for any amounts and any order of inputs and calculations: - the default branch, a named branch and a new simulation given the same inputs - give the same value for every input variable in every year; + give the same value for each of the three input variables the property sets, + and for a formula that reads them, in every year from 2025 to 2040; - every year the data, a caller or a scenario's modifier supplied keeps the value supplied, whether it comes before or after a year set later; -- a branch keeps the values it held when it was created when its parent - changes. +- a branch that a caller takes before its parent changes keeps the values it + held then. """ import numpy as np @@ -76,15 +77,12 @@ def _growth(simulation, name, year, base): ) -# Each row of the issue: the variables, then each one's value in 2035 grown -# from £3,000 in 2025, and grown from £10,000 in 2030. savings_interest_income -# depends on the ISA balances, so it has a simulation of its own. +# Each row of the issue: the variables the test grows to 2035 from £3,000 in +# 2025, and then from £10,000 in 2030. savings_interest_income depends on the +# ISA balances, so it has a simulation of its own. ISSUE_ROWS = { - "balances": { - "cash_isa_balance": (4_197.81, 11_773.88), - "savings": (4_197.81, 11_773.88), - }, - "savings interest": {"savings_interest_income": (4_288.88, 11_773.92)}, + "balances": ("cash_isa_balance", "savings"), + "savings interest": ("savings_interest_income",), } @@ -95,37 +93,36 @@ def test_an_input_set_for_an_earlier_year_reaches_a_year_already_grown(row): # as a new simulation given both inputs does. A copy of the simulation, # made as its baseline is, is a default branch too. A branch made before # the input keeps the value its parent held then. - expected = ISSUE_ROWS[row] + names = ISSUE_ROWS[row] simulation = Simulation( - dataset=_frame(**{f"{name}__{DATA_YEAR}": 3_000.0 for name in expected}) + dataset=_frame(**{f"{name}__{DATA_YEAR}": 3_000.0 for name in names}) ) copy = simulation.clone() named = simulation.get_branch("edited") untouched = simulation.get_branch("untouched") paths = (simulation, copy, named) - for name, (grown, _) in expected.items(): + grown = {name: 3_000 * _growth(simulation, name, 2035, DATA_YEAR) for name in names} + for name in names: for path in (*paths, untouched): - assert path.calculate(name, 2035)[0] == pytest.approx(grown, abs=0.005) - assert path.calculate(name, 2035)[0] == pytest.approx( - 3_000 * _growth(simulation, name, 2035, DATA_YEAR), rel=1e-6 - ) - for name in expected: + assert path.calculate(name, 2035)[0] == pytest.approx(grown[name], rel=1e-6) + for name in names: for path in paths: path.set_input(name, 2030, np.array([10_000.0])) new = Simulation( dataset=_frame( - **{f"{name}__{DATA_YEAR}": 3_000.0 for name in expected}, - **{f"{name}__2030": 10_000.0 for name in expected}, + **{f"{name}__{DATA_YEAR}": 3_000.0 for name in names}, + **{f"{name}__2030": 10_000.0 for name in names}, ) ) - for name, (grown, regrown) in expected.items(): + for name in names: values = [path.calculate(name, 2035)[0] for path in (*paths, new)] assert values == [values[-1]] * 4, name - assert values[-1] == pytest.approx(regrown, abs=0.005) assert values[-1] == pytest.approx( 10_000 * _growth(simulation, name, 2035, 2030), rel=1e-6 ) - assert untouched.calculate(name, 2035)[0] == pytest.approx(grown, abs=0.005) + assert untouched.calculate(name, 2035)[0] == pytest.approx( + grown[name], rel=1e-6 + ) for path in paths: assert path.calculate(name, DATA_YEAR)[0] == 3_000 assert path.calculate(name, 2030)[0] == 10_000 @@ -161,9 +158,9 @@ def _two_adults_with_a_stored_cash_isa(): def test_a_balance_set_for_an_earlier_year_reaches_a_total_already_summed(): # The issue's total: the data store cash_isa through PROJECTION_END_YEAR. # A balance of £100 for 2034, newer than the stored total, gives cash_isa - # in 2040 as £117.74. A balance of £200 then set for 2035 gives £227.91 - # on the default branch, as on a named branch and in a simulation given - # both balances before calculating anything. + # in 2040 grown from it. A balance of £200 then set for 2035 gives + # cash_isa grown from that on the default branch, as on a named branch + # and in a simulation given both balances before calculating anything. assert PROJECTION_END_YEAR < 2034 simulation = Simulation(dataset=_two_adults_with_a_stored_cash_isa()) named = simulation.get_branch("edited") @@ -171,12 +168,13 @@ def test_a_balance_set_for_an_earlier_year_reaches_a_total_already_summed(): for path in (simulation, named, new): path.set_input("cash_isa_balance", 2034, np.array([100.0, 0.0])) for path in (simulation, named): - assert path.calculate("cash_isa", 2040)[0] == pytest.approx(117.74, abs=0.005) + assert path.calculate("cash_isa", 2040)[0] == pytest.approx( + 100 * _growth(simulation, "cash_isa_balance", 2040, 2034), rel=1e-6 + ) path.set_input("cash_isa_balance", 2035, np.array([200.0, 0.0])) new.set_input("cash_isa_balance", 2035, np.array([200.0, 0.0])) values = [path.calculate("cash_isa", 2040)[0] for path in (simulation, named, new)] assert values == [values[-1]] * 3 - assert values[-1] == pytest.approx(227.91, abs=0.005) assert values[-1] == pytest.approx( 200 * _growth(simulation, "cash_isa_balance", 2040, 2035), rel=1e-6 ) @@ -241,8 +239,8 @@ def _sets_a_later_balance(simulation): def test_a_year_set_for_later_than_a_later_input_keeps_its_value(route): # A scenario's modifier, which runs once the data are built, or a caller, # through the simulation or straight through the holder, sets £7,000 for - # 2038 before anything is calculated. After 2040 is grown from it, an - # input for 2030 replaces only the years before 2038. + # 2038 before the simulation calculates anything. After Core grows 2040 + # from it, an input for 2030 replaces only the years before 2038. data = _frame(**{f"cash_isa_balance__{DATA_YEAR}": 3_000.0}) if route == "modifier": simulation = Simulation( @@ -329,6 +327,24 @@ def test_a_reset_drops_grown_years_and_keeps_supplied_ones(): assert holder.get_array(period(2038))[0] == 7_000 +def test_a_reset_after_a_later_input_recalculates_a_supplied_formula_variable(): + # A caller supplies the formula variable for 2025, and its formula gives + # 2030 from the list price carried from 2025. After a list price set for + # 2028, reset_calculations keeps the supplied year and drops the year the + # formula gave, so 2030 follows the new list price, as a new simulation + # given both list prices does. The reset clears the drop the input queued + # for the next calculation, so it has to drop that year itself. + simulation = Simulation(dataset=_frame(**{f"car_list_price__{DATA_YEAR}": 3_000.0})) + simulation.tax_benefit_system.add_variable(twice_car_list_price) + simulation.set_input("twice_car_list_price", DATA_YEAR, np.array([1.0])) + assert simulation.calculate("twice_car_list_price", 2030)[0] == 6_000 + simulation.set_input("car_list_price", 2028, np.array([5_000.0])) + simulation.reset_calculations() + assert simulation.calculate("twice_car_list_price", 2030)[0] == 10_000 + assert simulation.calculate("car_list_price", 2030)[0] == 5_000 + assert simulation.calculate("twice_car_list_price", DATA_YEAR)[0] == 1 + + class sum_of_the_inputs(Variable): value_type = float entity = Person @@ -409,7 +425,8 @@ def test_every_path_gives_what_a_new_simulation_gives(data, steps): columns[f"{name}__{year}"] = amount new = Simulation(dataset=_frame(**columns)) new.tax_benefit_system.add_variable(sum_of_the_inputs) - # The formula first, while the inputs it reads are only cached. + # Read the formula before the inputs, so it reads them through Core's + # result cache. for name in ("sum_of_the_inputs", *VARIABLES): for year in YEARS: values = [path.calculate(name, year)[0] for path in (*paths, new)] From e6221fd3fc72786835239a78b343601d2e3bb987 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 05:38:19 -0500 Subject: [PATCH 12/15] Name who acts in the drop's docstrings, docs and changelog entry Co-Authored-By: Claude Opus 5.5 --- changelog.d/2774.fixed.md | 2 +- docs/book/usage/scenarios.md | 18 +++++++------ policyengine_uk/simulation.py | 2 +- policyengine_uk/utils/measurement_branches.py | 26 ++++++++++--------- 4 files changed, 26 insertions(+), 22 deletions(-) diff --git a/changelog.d/2774.fixed.md b/changelog.d/2774.fixed.md index c3cbb1487c..03a179927d 100644 --- a/changelog.d/2774.fixed.md +++ b/changelog.d/2774.fixed.md @@ -1 +1 @@ -Give an input set for an earlier year after a calculation to every later year the simulation had grown or carried over from an earlier input on the default branch, as a named branch and a new simulation do. +When a caller sets an input for an earlier year after a calculation, recalculate the later years the default branch had grown, carried over or calculated from earlier inputs, as a named branch and a new simulation do. diff --git a/docs/book/usage/scenarios.md b/docs/book/usage/scenarios.md index c780f53288..35d2dc3dc0 100644 --- a/docs/book/usage/scenarios.md +++ b/docs/book/usage/scenarios.md @@ -193,14 +193,16 @@ response values. Inputs stored only on another clone or branch do not become the parent's inputs. Formula and framework writes during a calculation keep their existing selective invalidation. -The cleanup, like `reset_calculations`, keeps only the years the data, a -modifier or a caller supplied for each input variable. The model calculates -the other years again: those it grew from an earlier input by the variable's -uprating index, carried over from one, gave by formula, or (for a stored -household total) grew from an earlier total. So an input set for an earlier -year after a calculation reaches the later years the simulation had already -filled, as on a named branch and in a new simulation given the same inputs. -A later year that the data or `set_input` supplied keeps its value. +In that cleanup, as in `reset_calculations`, the simulation keeps only the +years of each input variable that the data, a modifier or a caller supplied. +It calculates the other years again, whether it grew them from an earlier +input by the variable's uprating index, carried an earlier input into them, +ran the variable's formula for them or, for a stored household total, grew +them from an earlier total. So when a caller sets an input for an earlier year +after a calculation, the later years the simulation had already filled follow +it, as they do on a named branch and in a new simulation given the same +inputs. The simulation keeps a later year that the data or `set_input` +supplied. Direct edits of the parameter tree require a separate array-cache boundary. After calling a parameter's `update` method on a built simulation, call diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index 3e2043e803..e000f0f9a3 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -815,7 +815,7 @@ def _stored_household_total(self, variable, period): continue native = self.tax_benefit_system.variables[part].definition_period if part_source.start > source.start or part_source.unit != native: - # Person balances stored for a later year than the total are + # Person balances stored for a later year than the total give # the newer observation, and Core doesn't uprate a balance # stored in another unit, so their sum stands. return None diff --git a/policyengine_uk/utils/measurement_branches.py b/policyengine_uk/utils/measurement_branches.py index e6cc86ee1c..27b5997c6d 100644 --- a/policyengine_uk/utils/measurement_branches.py +++ b/policyengine_uk/utils/measurement_branches.py @@ -51,15 +51,16 @@ def drop_calculated_years(simulation, names: Iterable[str]) -> None: the latest earlier input by the variable's ``uprating`` index, or carries the latest input over, and caches the result in the variable's holder (``put_in_cache`` with ``derived=True``). The simulation grows a stored - household total itself (``Simulation._stored_household_total``), and a - formula gives the other years of a formula variable supplied for some. - Such a year depends on other years' inputs, so an input set later for an - earlier year changes it. Core's record of inputs (``_user_input_keys``, - checked by ``Holder._stores_user_input``) holds every value the data or a - caller stored through ``set_input``, whatever the year, and decides which - values go, as it does for ``drop_computed_arrays``. Core then calculates - the other years again. Formulas read Core's cache of results by variable - and period, so its entries for ``names`` go too. + household total itself (``Simulation._stored_household_total``). Where the + data or a caller supplied a formula variable for some years, Core runs its + formula for the others. Such a year depends on other years' inputs, so an + input a caller sets later for an earlier year changes it. Core keeps the + values its record of inputs (``_user_input_keys``, checked by + ``Holder._stores_user_input``) names: every value the data or a caller + stored through ``set_input``, whatever the year, as + ``drop_computed_arrays`` does. Core then calculates the other years again. + Formulas read Core's cache of results by variable and period, so this + function drops its entries for ``names`` too. """ names = set(names) for name in names: @@ -76,9 +77,10 @@ def drop_calculated_values(simulation) -> None: Inputs are variables without a formula, variables the simulation was built from, and variables set since through set_input, as a scenario's - simulation modifier does to formula variables. An input variable keeps - only the years supplied for it (see drop_calculated_years). It visits only - the variables the simulation holds values for. + simulation modifier does to formula variables. It keeps only the years of + each input variable that the data or a caller supplied (see + drop_calculated_years). It visits only the variables the simulation holds + values for. """ supplied = retained_input_variables(simulation) variables = simulation.tax_benefit_system.variables From 5b858c17871c4685763634afd9d93bfdfcbb6dc7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 06:21:42 -0500 Subject: [PATCH 13/15] Name who acts in the later-input tests' comments Comments and the module docstring only; the test code is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../test_later_inputs_reach_grown_years.py | 93 ++++++++++--------- 1 file changed, 49 insertions(+), 44 deletions(-) diff --git a/policyengine_uk/tests/test_later_inputs_reach_grown_years.py b/policyengine_uk/tests/test_later_inputs_reach_grown_years.py index 7882bbd368..8de22e11c2 100644 --- a/policyengine_uk/tests/test_later_inputs_reach_grown_years.py +++ b/policyengine_uk/tests/test_later_inputs_reach_grown_years.py @@ -1,10 +1,10 @@ -"""An input set for an earlier year reaches a year already grown (#2774). +"""An input a caller sets for an earlier year reaches a year Core grew (#2774). For a year an input variable holds no input for, Core grows the latest earlier input by the variable's ``uprating`` index, or carries the latest input over, and caches the result in the variable's holder. Simulation grows a stored -household total the same way. An input set afterwards for a year in between -changes what that year should be. On a named branch Core drops the values that +household total the same way. An input a caller sets afterwards for a year in +between changes what that year should be. On a named branch Core drops the values that may depend on the input. On the default branch Simulation drops what it calculated before its next calculation (``drop_calculated_values``), and keeps only the years Core records as supplied, through the data or ``set_input``, of @@ -15,8 +15,9 @@ - the default branch, a named branch and a new simulation given the same inputs give the same value for each of the three input variables the property sets, and for a formula that reads them, in every year from 2025 to 2040; -- every year the data, a caller or a scenario's modifier supplied keeps the - value supplied, whether it comes before or after a year set later; +- the simulation keeps the value of every year the data, a caller or a + scenario's modifier supplied, whether that year comes before or after a year + a caller sets later; - a branch that a caller takes before its parent changes keeps the values it held then. """ @@ -41,9 +42,9 @@ DATA_YEAR = 2025 LAST_YEAR = 2040 YEARS = range(DATA_YEAR, LAST_YEAR + 1) -# A person balance grown by GDP per capita, a person income the uprating map -# grows by the private pension index, and a person input with no index, which -# Core carries over flat. +# A person balance that Core grows by GDP per capita, a person income the +# uprating map grows by the private pension index, and a person input with no +# index, which Core carries over flat. VARIABLES = ("cash_isa_balance", "private_pension_income", "car_list_price") PROPERTY_SETTINGS = settings( max_examples=6, @@ -77,9 +78,9 @@ def _growth(simulation, name, year, base): ) -# Each row of the issue: the variables the test grows to 2035 from £3,000 in -# 2025, and then from £10,000 in 2030. savings_interest_income depends on the -# ISA balances, so it has a simulation of its own. +# The issue's rows hold the variables Core grows to 2035 from £3,000 in 2025, +# and then from £10,000 in 2030. savings_interest_income depends on the ISA +# balances, so it has a simulation of its own. ISSUE_ROWS = { "balances": ("cash_isa_balance", "savings"), "savings interest": ("savings_interest_income",), @@ -88,11 +89,12 @@ def _growth(simulation, name, year, base): @pytest.mark.parametrize("row", ISSUE_ROWS) def test_an_input_set_for_an_earlier_year_reaches_a_year_already_grown(row): - # The issue's reproduction: the data supply £3,000 for 2025. Each path - # grows it to 2035, then gets £10,000 for 2030, and grows that instead, - # as a new simulation given both inputs does. A copy of the simulation, - # made as its baseline is, is a default branch too. A branch made before - # the input keeps the value its parent held then. + # As in the issue, the data supply £3,000 for 2025. Core grows it to 2035 + # on each path. Each path then gets £10,000 for 2030, and Core grows that + # instead, as it does in a new simulation that gets both inputs. A copy, + # which Simulation.__init__ also makes for a baseline, works as a default + # branch too. A branch that the test takes before the input keeps the + # value its parent held then. names = ISSUE_ROWS[row] simulation = Simulation( dataset=_frame(**{f"{name}__{DATA_YEAR}": 3_000.0 for name in names}) @@ -156,11 +158,12 @@ def _two_adults_with_a_stored_cash_isa(): def test_a_balance_set_for_an_earlier_year_reaches_a_total_already_summed(): - # The issue's total: the data store cash_isa through PROJECTION_END_YEAR. - # A balance of £100 for 2034, newer than the stored total, gives cash_isa - # in 2040 grown from it. A balance of £200 then set for 2035 gives - # cash_isa grown from that on the default branch, as on a named branch - # and in a simulation given both balances before calculating anything. + # As in the issue, the data store cash_isa through PROJECTION_END_YEAR. A + # balance of £100 for 2034, newer than the stored total, gives cash_isa in + # 2040 grown from it. A balance of £200 that the test then sets for 2035 + # gives cash_isa grown from that on the default branch, as on a named + # branch and in a new simulation that gets both balances before it + # calculates anything. assert PROJECTION_END_YEAR < 2034 simulation = Simulation(dataset=_two_adults_with_a_stored_cash_isa()) named = simulation.get_branch("edited") @@ -183,7 +186,7 @@ def test_a_balance_set_for_an_earlier_year_reaches_a_total_already_summed(): def test_years_the_data_supply_after_a_later_input_keep_their_values(): - # The frame supplies 2025 and 2033. After 2030 and 2035 are grown, an + # The frame supplies 2025 and 2033. After Core grows 2030 and 2035, an # input for 2028 replaces what 2030 grew from, but not 2033, nor 2035, # which grows from 2033. data = { @@ -216,8 +219,8 @@ def test_years_the_data_supply_after_a_later_input_keep_their_values(): def test_years_a_dataset_projects_under_a_reform_before_data_load_stay(): # no_economic_assumptions applies before data load, so the dataset holds # cash_isa flat at £3,000 in each year it projects, through - # PROJECTION_END_YEAR. A balance set after a calculation for the first of - # those years replaces none of the others. + # PROJECTION_END_YEAR. A balance the test sets after a calculation for the + # first of those years replaces none of the others. simulation = Simulation( scenario=no_economic_assumptions, dataset=_two_adults_with_a_stored_cash_isa(), @@ -284,12 +287,13 @@ def formula(person, period, parameters): def test_a_formula_variable_supplied_for_one_year_is_recalculated_for_others(): # A caller supplies a formula variable for 2025 only, so the simulation # keeps it as an input variable. Its formula gives 2030 from the list - # price carried from 2025. A list price then set for 2028 changes both on - # every path, and the supplied year keeps its value. The formula reads the - # list price as formulas do, through Core's cache of results by variable - # and period, which a call from outside the simulation does not read - # (nor does Core's sum of a variable's adds, which maps the result to - # the variable's entity), so the drop clears that cache too. + # price carried from 2025. A list price the caller then sets for 2028 + # changes both on every path, and the simulation keeps the supplied year. + # The formula reads the list price as formulas do, through Core's cache of + # results by variable and period. A call from outside the simulation + # doesn't read that cache, and nor does Core's sum of a variable's adds, + # which maps the result to the variable's entity. So the drop clears that + # cache too. data = {f"car_list_price__{DATA_YEAR}": 3_000.0} simulation = Simulation(dataset=_frame(**data)) new = Simulation(dataset=_frame(**data, car_list_price__2028=5_000.0)) @@ -311,7 +315,7 @@ def test_a_formula_variable_supplied_for_one_year_is_recalculated_for_others(): def test_a_reset_drops_grown_years_and_keeps_supplied_ones(): # reset_calculations keeps what the drop before a calculation keeps: the # years the data or a caller supplied, here 2025 and 2038, and not the - # years grown from them. + # years Core grew from them. simulation = Simulation( dataset=_frame(**{f"cash_isa_balance__{DATA_YEAR}": 3_000.0}) ) @@ -329,11 +333,12 @@ def test_a_reset_drops_grown_years_and_keeps_supplied_ones(): def test_a_reset_after_a_later_input_recalculates_a_supplied_formula_variable(): # A caller supplies the formula variable for 2025, and its formula gives - # 2030 from the list price carried from 2025. After a list price set for - # 2028, reset_calculations keeps the supplied year and drops the year the - # formula gave, so 2030 follows the new list price, as a new simulation - # given both list prices does. The reset clears the drop the input queued - # for the next calculation, so it has to drop that year itself. + # 2030 from the list price carried from 2025. After the caller sets a list + # price for 2028, reset_calculations keeps the supplied year and drops the + # year the formula gave, so 2030 follows the new list price, as it does in + # a new simulation that gets both list prices. The reset clears the drop + # the input queued for the next calculation, so it has to drop that year + # itself. simulation = Simulation(dataset=_frame(**{f"car_list_price__{DATA_YEAR}": 3_000.0})) simulation.tax_benefit_system.add_variable(twice_car_list_price) simulation.set_input("twice_car_list_price", DATA_YEAR, np.array([1.0])) @@ -369,7 +374,7 @@ def formula(person, period, parameters): min_size=1, max_size=8, ) -# What the data supply: each variable in 2025, and maybe in one later year. +# The data supply each variable in 2025, and maybe in one later year. DATA = st.fixed_dictionaries( { name: st.tuples(AMOUNTS, st.none() | st.tuples(LATER_YEARS, AMOUNTS)) @@ -398,13 +403,13 @@ def formula(person, period, parameters): ], ) def test_every_path_gives_what_a_new_simulation_gives(data, steps): - # The same inputs and calculations, in the same order, on the default - # branch, on a copy of it and on a named branch, each made before any - # step: each calculation gives the same value on all three. Afterwards - # every input variable in every year, and a formula that reads them all - # through Core's cache of results by variable and period, equal what a - # new simulation built from all the inputs gives. Every supplied year - # keeps its value. + # The test runs the same inputs and calculations, in the same order, on the + # default branch, on a copy of it and on a named branch, which it takes + # before any step, and each calculation gives the same value on all three. + # Afterwards each input variable in every year, and a formula that reads + # them all through Core's cache of results by variable and period, equal + # what a new simulation that gets all the inputs gives. The simulation + # keeps the value of every supplied year. columns = {} for name, (amount, later) in data.items(): columns[f"{name}__{DATA_YEAR}"] = amount From 0cb929ec55a261e0125c17a19ad2cb824cfb8244 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 08:30:46 -0500 Subject: [PATCH 14/15] Name who sets the inputs in the stored-total and later-input comments Comments, docstrings and one docs sentence only; the test code is unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/book/usage/scenarios.md | 2 +- .../test_later_inputs_reach_grown_years.py | 26 ++++++++++--------- .../tests/test_stored_household_totals.py | 18 ++++++------- 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/docs/book/usage/scenarios.md b/docs/book/usage/scenarios.md index 35d2dc3dc0..896def5520 100644 --- a/docs/book/usage/scenarios.md +++ b/docs/book/usage/scenarios.md @@ -200,7 +200,7 @@ input by the variable's uprating index, carried an earlier input into them, ran the variable's formula for them or, for a stored household total, grew them from an earlier total. So when a caller sets an input for an earlier year after a calculation, the later years the simulation had already filled follow -it, as they do on a named branch and in a new simulation given the same +it, as they do on a named branch and in a new simulation that gets the same inputs. The simulation keeps a later year that the data or `set_input` supplied. diff --git a/policyengine_uk/tests/test_later_inputs_reach_grown_years.py b/policyengine_uk/tests/test_later_inputs_reach_grown_years.py index 8de22e11c2..4bb5726023 100644 --- a/policyengine_uk/tests/test_later_inputs_reach_grown_years.py +++ b/policyengine_uk/tests/test_later_inputs_reach_grown_years.py @@ -4,17 +4,18 @@ input by the variable's ``uprating`` index, or carries the latest input over, and caches the result in the variable's holder. Simulation grows a stored household total the same way. An input a caller sets afterwards for a year in -between changes what that year should be. On a named branch Core drops the values that -may depend on the input. On the default branch Simulation drops what it -calculated before its next calculation (``drop_calculated_values``), and keeps -only the years Core records as supplied, through the data or ``set_input``, of -each input variable. +between changes what that year should be. On a named branch Core drops the +values that may depend on the input. On the default branch Simulation drops +what it calculated before its next calculation (``drop_calculated_values``), +and keeps only the years Core records as supplied, through the data or +``set_input``, of each input variable. Invariants, for any amounts and any order of inputs and calculations: -- the default branch, a named branch and a new simulation given the same inputs - give the same value for each of the three input variables the property sets, - and for a formula that reads them, in every year from 2025 to 2040; +- the default branch, a named branch and a new simulation that gets the same + inputs give the same value for each of the three input variables the + property sets, and for a formula that reads them, in every year from 2025 to + 2040; - the simulation keeps the value of every year the data, a caller or a scenario's modifier supplied, whether that year comes before or after a year a caller sets later; @@ -240,10 +241,11 @@ def _sets_a_later_balance(simulation): @pytest.mark.parametrize("route", ["modifier", "simulation", "holder"]) def test_a_year_set_for_later_than_a_later_input_keeps_its_value(route): - # A scenario's modifier, which runs once the data are built, or a caller, - # through the simulation or straight through the holder, sets £7,000 for - # 2038 before the simulation calculates anything. After Core grows 2040 - # from it, an input for 2030 replaces only the years before 2038. + # A scenario's modifier, which runs once Simulation has built the data, or + # a caller, through the simulation or straight through the holder, sets + # £7,000 for 2038 before the simulation calculates anything. After Core + # grows 2040 from it, an input for 2030 replaces only the years before + # 2038. data = _frame(**{f"cash_isa_balance__{DATA_YEAR}": 3_000.0}) if route == "modifier": simulation = Simulation( diff --git a/policyengine_uk/tests/test_stored_household_totals.py b/policyengine_uk/tests/test_stored_household_totals.py index 2a96a6d44e..1088176bda 100644 --- a/policyengine_uk/tests/test_stored_household_totals.py +++ b/policyengine_uk/tests/test_stored_household_totals.py @@ -18,9 +18,9 @@ - with person balances stored instead, the total equals their sum every year; - the two forms give the same totals and means-test capital when they hold the same amounts; -- after totals and balances set for any years, on the default or a named - branch, between calculations, a total keeps every value the data or a caller - supplied, and equals what a fresh calculation gives in other years; +- after a caller sets totals and balances for any years, on the default or a + named branch, between calculations, a total keeps every value the data or a + caller supplied, and equals what a fresh calculation gives in other years; - a grown total grows as Core grows an input with its parts' index; - a reform that changes the sum or a part gets Core's sum, and a reform that declares the same variables again changes nothing. @@ -1069,8 +1069,8 @@ def test_stored_totals_and_person_balances_agree_in_every_year(households): st.tuples(st.just("cash_isa"), INPUT_YEARS, AMOUNTS), ) BALANCE_STEP = st.tuples(st.just("cash_isa_balance"), INPUT_YEARS, AMOUNTS) -# A balance set on either branch replaces what Core grew from an earlier one, -# so a total that sums the balances reads the new one (#2774). +# A balance a caller sets on either branch replaces what Core grew from an +# earlier one, so a total that sums the balances reads the new one (#2774). RUNS = st.tuples( st.booleans(), st.lists(st.one_of(*TOTAL_STEPS, BALANCE_STEP), min_size=1, max_size=8), @@ -1112,10 +1112,10 @@ def test_stored_totals_and_person_balances_agree_in_every_year(households): ) ) def test_a_total_after_any_inputs_equals_a_fresh_calculation(run): - # Totals and balances set for any year, on the default or a named branch, - # between calculations of any year: each calculated total equals what the - # same simulation gives with every calculated value dropped, and every - # supplied total keeps its value. + # A caller sets totals and balances for any year, on the default or a + # named branch, between calculations of any year. Each calculated total + # equals what the same simulation gives once it drops every calculated + # value, and the simulation keeps the value of every supplied total. on_branch, steps = run simulation = Simulation( dataset=_dataset([{"cash_isa": 3_000.0}], household_columns=["cash_isa"]) From 206621a9ca8b10c3af1505bb5974e0fc076bc394 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 09:28:44 -0500 Subject: [PATCH 15/15] Clarify the later-input test comments and name Simulation.set_input Co-Authored-By: Claude Opus 5.5 --- docs/book/usage/scenarios.md | 4 +-- .../test_later_inputs_reach_grown_years.py | 32 +++++++++---------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/docs/book/usage/scenarios.md b/docs/book/usage/scenarios.md index 896def5520..0ee975cc18 100644 --- a/docs/book/usage/scenarios.md +++ b/docs/book/usage/scenarios.md @@ -186,8 +186,8 @@ calculations retain them too. To undo an override, call variable's formula or default runs again. A reset alone no longer undoes an input supplied after construction. -Every top-level `set_input` on a built simulation queues dependent-cache cleanup -before its next outermost read. This includes simulations with no modifier: a +Every top-level `Simulation.set_input` on a built simulation queues +dependent-cache cleanup before its next outermost read. This includes simulations with no modifier: a caller can read Child Benefit, add an amount and then obtain fresh income and response values. Inputs stored only on another clone or branch do not become the parent's inputs. Formula and framework writes during a calculation keep diff --git a/policyengine_uk/tests/test_later_inputs_reach_grown_years.py b/policyengine_uk/tests/test_later_inputs_reach_grown_years.py index 4bb5726023..279306f93f 100644 --- a/policyengine_uk/tests/test_later_inputs_reach_grown_years.py +++ b/policyengine_uk/tests/test_later_inputs_reach_grown_years.py @@ -163,8 +163,8 @@ def test_a_balance_set_for_an_earlier_year_reaches_a_total_already_summed(): # balance of £100 for 2034, newer than the stored total, gives cash_isa in # 2040 grown from it. A balance of £200 that the test then sets for 2035 # gives cash_isa grown from that on the default branch, as on a named - # branch and in a new simulation that gets both balances before it - # calculates anything. + # branch and in a new simulation that gets both balances before the test + # calculates the total. assert PROJECTION_END_YEAR < 2034 simulation = Simulation(dataset=_two_adults_with_a_stored_cash_isa()) named = simulation.get_branch("edited") @@ -219,9 +219,9 @@ def test_years_the_data_supply_after_a_later_input_keep_their_values(): def test_years_a_dataset_projects_under_a_reform_before_data_load_stay(): # no_economic_assumptions applies before data load, so the dataset holds - # cash_isa flat at £3,000 in each year it projects, through - # PROJECTION_END_YEAR. A balance the test sets after a calculation for the - # first of those years replaces none of the others. + # cash_isa flat at £3,000 through PROJECTION_END_YEAR. The test calculates + # a year beyond that projection, then overrides the first projected year. + # The other projected years keep their stored totals. simulation = Simulation( scenario=no_economic_assumptions, dataset=_two_adults_with_a_stored_cash_isa(), @@ -241,11 +241,10 @@ def _sets_a_later_balance(simulation): @pytest.mark.parametrize("route", ["modifier", "simulation", "holder"]) def test_a_year_set_for_later_than_a_later_input_keeps_its_value(route): - # A scenario's modifier, which runs once Simulation has built the data, or - # a caller, through the simulation or straight through the holder, sets - # £7,000 for 2038 before the simulation calculates anything. After Core - # grows 2040 from it, an input for 2030 replaces only the years before - # 2038. + # A modifier or a caller supplies £7,000 for 2038, through the simulation + # or its holder, before the test reads the balance for 2040. Core grows + # 2040 from that input. A later input for 2030 changes the earlier grown + # years while the supplied 2038 value and the growth from it stay fixed. data = _frame(**{f"cash_isa_balance__{DATA_YEAR}": 3_000.0}) if route == "modifier": simulation = Simulation( @@ -334,13 +333,12 @@ def test_a_reset_drops_grown_years_and_keeps_supplied_ones(): def test_a_reset_after_a_later_input_recalculates_a_supplied_formula_variable(): - # A caller supplies the formula variable for 2025, and its formula gives - # 2030 from the list price carried from 2025. After the caller sets a list - # price for 2028, reset_calculations keeps the supplied year and drops the - # year the formula gave, so 2030 follows the new list price, as it does in - # a new simulation that gets both list prices. The reset clears the drop - # the input queued for the next calculation, so it has to drop that year - # itself. + # The caller supplies twice_car_list_price for 2025, and its formula + # calculates 2030 from the list price carried from 2025. The caller then + # changes the list price for 2028 and resets calculations. The reset + # keeps the supplied 2025 value and drops the calculated 2030 value, so + # the formula reads the new list price. The reset also clears the pending + # drop, so it must invalidate the formula's cached value itself. simulation = Simulation(dataset=_frame(**{f"car_list_price__{DATA_YEAR}": 3_000.0})) simulation.tax_benefit_system.add_variable(twice_car_list_price) simulation.set_input("twice_car_list_price", DATA_YEAR, np.array([1.0]))