Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
11b3fbb
Drop what a branch input may have fed; add drop_computed_arrays
MaxGhenis Oct 1, 2026
e8fe03e
Start a new store history when subsample rebuilds the simulation
MaxGhenis Oct 1, 2026
56f19b1
Record values served from the macro cache or a spiral's default
MaxGhenis Oct 1, 2026
3499695
Move the Hypothesis property into its own module that skips without H…
MaxGhenis Oct 1, 2026
f826d57
Keep a store history per simulation; fix what two reviews found
MaxGhenis Oct 1, 2026
5a18526
Keep records while a formula runs; merge incrementally; survive pickling
MaxGhenis Oct 2, 2026
4b70aac
Hand branch history to waiting ancestors; tokened disk files; gate on…
MaxGhenis Oct 2, 2026
d6974af
Merge master (#556 shared branch arrays) into branch-set-input-invali…
MaxGhenis Oct 2, 2026
32d5bc4
Close the third review's gaps: in-flight results, failures, dumps, di…
MaxGhenis Oct 2, 2026
bff21a7
Test pickling with a live weak read position; tidy disk storage imports
MaxGhenis Oct 2, 2026
9b42e03
Fork the token test's child directly instead of through a process pool
MaxGhenis Oct 2, 2026
2b31dcb
Close the fourth review's gaps: refused results, dumps, handlers, mac…
MaxGhenis Oct 2, 2026
3075a6c
DIAG: report traced parameter trees on Windows (to be reverted)
MaxGhenis Oct 2, 2026
acc5f58
Give the disk-restore test's later process its own token, and age all…
MaxGhenis Oct 2, 2026
a81adb4
DIAG: write the diagnostic through the terminal reporter (to be rever…
MaxGhenis Oct 2, 2026
1d6221f
DIAG: tolerate half-built parameter nodes (to be reverted)
MaxGhenis Oct 2, 2026
10b1d01
Revert the Windows diagnostics (3075a6cb, a81adb43, 1d6221f1)
MaxGhenis Oct 2, 2026
9f8d513
Close the fifth review's gaps: macro reads, input wins, sums, failing…
MaxGhenis Oct 2, 2026
ed843df
Close the sixth review's gaps: input wins only if stored meanwhile, o…
MaxGhenis Oct 2, 2026
116b018
Close the seventh review's gaps: refused results stay unkept across s…
MaxGhenis Oct 2, 2026
359c97f
Close the eighth review's regression: scope "not kept" to callers and…
MaxGhenis Oct 2, 2026
94f2130
Close the ninth review's gaps: refuse by what each calculation read
MaxGhenis Oct 2, 2026
1fd1bf5
Merge master into branch-set-input-invalidation
MaxGhenis Oct 6, 2026
23971e3
Close the tenth review's gaps: thread reads, settled reads, idle changes
MaxGhenis Oct 6, 2026
789d7f0
Remove synthetic_simulation's temporary folders when done (US + core …
MaxGhenis Oct 6, 2026
e586d0b
Bring two docstrings in line with the merged rules
MaxGhenis Oct 6, 2026
7084f56
Keep macro reads off on unchanged inputs; cut read tracking's per-cal…
MaxGhenis Oct 6, 2026
23c98a7
Test a helper's input over a stored year, and a callback that changes…
MaxGhenis Oct 6, 2026
78fb8d4
Stop macro reads in one place: Holder.set_input on every branch input
MaxGhenis Oct 6, 2026
efe2f4a
Merge master into branch-set-input-invalidation (#585)
MaxGhenis Oct 6, 2026
739ec84
Merge current master into branch-set-input-invalidation
MaxGhenis Oct 8, 2026
ef38b97
Merge master documentation deployment changes
MaxGhenis Oct 8, 2026
0e73dc8
Keep copied disk storage invalidation order independent
MaxGhenis Oct 8, 2026
9b9552d
Settle repeated branch input transitions without caching stale reads
MaxGhenis Oct 8, 2026
1a2576c
Keep fixed-point readers and saved branch snapshots distinct
MaxGhenis Oct 8, 2026
b78c0bf
Merge master release changes into branch invalidation repair
MaxGhenis Oct 8, 2026
542bb5b
Retain registered branch paths across calculation retries
MaxGhenis Oct 8, 2026
5f5e987
Guard fixed-point writes to optional fast caches
MaxGhenis Oct 9, 2026
09ae860
Keep independent calculations cached during stale branch retries
MaxGhenis Oct 9, 2026
3e14ed6
Cover shared credit dependencies during stale retries
MaxGhenis Oct 9, 2026
5247430
Merge master into branch input invalidation
MaxGhenis Oct 9, 2026
0853d64
Isolate early-year branch replay regression from fallback parsing
MaxGhenis Oct 9, 2026
77c29bf
Keep raw cache writes observable during guarded retries
MaxGhenis Oct 9, 2026
880ab0d
Keep branch invalidation changelog concise
MaxGhenis Oct 9, 2026
041dcda
Clarify root simulation helper cache cleanup
MaxGhenis Oct 9, 2026
73b90c0
Forward store sequence numbers in restore property observer
MaxGhenis Oct 9, 2026
bbb7b6f
Reuse stable foreign reads within stale calculation attempts
MaxGhenis Oct 9, 2026
26019ba
Capture branch registrations before memo-producing formulas run
MaxGhenis Oct 10, 2026
8c5a48a
Guard attempt reuse with the branch name actually resolved
MaxGhenis Oct 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/branch-set-input-invalidation.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Simulation.drop_computed_arrays() deletes every value a simulation holds except inputs, for branches whose policy changes after they are created.
1 change: 1 addition & 0 deletions changelog.d/branch-set-input-invalidation.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Branch input changes prevent caching stale calculations and rerun outermost `calculate` and `calculate_add` calls up to ten times, with exact repeated input transitions and results in other branches able to settle under the usual cache restrictions; unchanged direct inputs skip invalidation, branch input stores disable macro-cache reads, `apply_reform` preserves surviving supplied input tiers, branch input helpers share overlapping-cache cleanup with ordinary input helpers, and restored calculated values are invalidated conservatively.
1 change: 1 addition & 0 deletions changelog.d/branch-set-input-invalidation.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Branch input changes invalidate derived values while preserving supplied inputs, independent caches, guarded retry effects, fixed-point settlement, branch dumps and disk invalidation order, with read-only foreign calculations reused within each attempt only while their captured input and branch-registration dependencies remain current.
1 change: 1 addition & 0 deletions docs/_toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ parts:
- caption: Using PolicyEngine Core
chapters:
- file: usage/simulation
- file: usage/branches
- file: usage/country
- file: usage/cli
- file: usage/parameters
Expand Down
310 changes: 310 additions & 0 deletions docs/usage/branches.md

Large diffs are not rendered by default.

79 changes: 78 additions & 1 deletion policyengine_core/data_storage/in_memory_storage.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
from typing import Dict, FrozenSet, Set, Union
from typing import Dict, FrozenSet, List, Optional, Set, Tuple, Union

import numpy
from numpy.typing import ArrayLike

from policyengine_core import periods
from policyengine_core.data_storage.store_history import (
_period_from_storage_key,
advance_sequence_past,
next_sequence_number,
)
from policyengine_core.periods import Period


Expand Down Expand Up @@ -83,6 +88,10 @@ class InMemoryStorage:

def __init__(self, is_eternal: bool):
self._arrays = {}
# When each array was stored (see ``store_history``). A number
# describes the stored value, as its input mark does, so ``clone``
# copies the numbers with the arrays.
self._sequence_numbers: Dict[str, int] = {}
self.is_eternal = is_eternal

def __getstate__(self) -> dict:
Expand All @@ -100,7 +109,13 @@ def __setstate__(self, state: dict) -> None:
inputs = set(state.get("_arrays", {})).difference(derived)
if inputs:
state["_inputs"] = inputs
# Pickled before stores were numbered: no value has a number.
state.setdefault("_sequence_numbers", {})
self.__dict__.update(state)
# Numbers from the process that pickled this storage must stay below
# those of stores made after unpickling it.
if self._sequence_numbers:
advance_sequence_past(max(self._sequence_numbers.values()))

def clone(self, share_arrays: bool = False) -> "InMemoryStorage":
"""Copy this storage.
Expand Down Expand Up @@ -152,6 +167,7 @@ def clone(self, share_arrays: bool = False) -> "InMemoryStorage":
held = inputs.intersection(clone._arrays)
if held:
clone._inputs = frozenset(held)
clone._sequence_numbers = dict(self._sequence_numbers)
return clone

def get(self, period: Period, branch_name: str = "default") -> ArrayLike:
Expand Down Expand Up @@ -260,7 +276,14 @@ def put(
period: Period,
branch_name: str = "default",
derived: bool = False,
sequence_number: Optional[int] = None,
) -> None:
"""Store ``value`` for ``period`` on ``branch_name``.

``derived`` marks a value the simulation calculated rather than took
as input (see ``_inputs``), and ``sequence_number`` records when the
value was stored (a new number by default); see :meth:`drop_computed`.
"""
if self.is_eternal:
period = periods.period(periods.ETERNITY)
period = periods.period(period)
Expand All @@ -287,6 +310,58 @@ def put(
self._unmark_input(key)
else:
self._mark_input(key)
self._sequence_numbers[key] = (
next_sequence_number() if sequence_number is None else sequence_number
)

def drop_computed(self, *, since: Optional[int] = None) -> int:
"""Delete stored values that are not inputs, and return how many.

With ``since``, only values stored with that sequence number or a
later one are deleted (a value with no recorded number counts as
later). Inputs, which ``put`` received without ``derived``, are kept
whatever their number.
"""
inputs = self._inputs
numbers = self._sequence_numbers
dropped = [
key
for key in self._arrays
if key not in inputs and (since is None or numbers.get(key, since) >= since)
]
for key in dropped:
del self._arrays[key]
numbers.pop(key, None)
self._stop_sharing(key)
return len(dropped)

def inputs_since(self, since: Optional[int] = None) -> List[Tuple[Period, int]]:
"""The period and number of each input stored at ``since`` or later (or ever)."""
numbers = self._sequence_numbers
return [
(_period_from_storage_key(key.split(":", 1)[1]), numbers[key])
for key in self._inputs
if key in self._arrays
and key in numbers
and (since is None or numbers[key] >= since)
]

def mark_derived_except(self, keys: Set[str]) -> None:
"""Mark every stored value derived except those stored for ``keys``."""
for key in [key for key in self._inputs if key not in keys]:
self._unmark_input(key)

def has_unnumbered_values(self) -> bool:
"""Whether a value was stored without ``put`` (so without a number)."""
return any(key not in self._sequence_numbers for key in self._arrays)

def _forget_dropped_numbers(self) -> None:
"""Forget the numbers of keys ``_arrays`` no longer has."""
numbers = self._sequence_numbers
if not numbers.keys() <= self._arrays.keys():
self._sequence_numbers = {
key: number for key, number in numbers.items() if key in self._arrays
}

def delete(self, period: Period = None, branch_name: str = "default") -> None:
if period is None:
Expand All @@ -300,6 +375,7 @@ def delete(self, period: Period = None, branch_name: str = "default") -> None:
}
self._stop_sharing_dropped_keys()
self._unmark_dropped_keys()
self._forget_dropped_numbers()
return

if self.is_eternal:
Expand All @@ -319,6 +395,7 @@ def delete(self, period: Period = None, branch_name: str = "default") -> None:
}
self._stop_sharing_dropped_keys()
self._unmark_dropped_keys()
self._forget_dropped_numbers()

def get_known_periods(self) -> list:
# Split on the first colon only: an anchored period's string form
Expand Down
83 changes: 80 additions & 3 deletions policyengine_core/data_storage/on_disk_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@
import shutil
import tempfile
import weakref
from typing import Dict, List, Optional, Set, Tuple

import numpy
from numpy.typing import ArrayLike

from policyengine_core import periods
from policyengine_core.data_storage.store_history import (
_period_from_storage_key,
advance_sequence_past,
next_sequence_number,
)
from policyengine_core.data_storage.storage_directory import (
directory_containing,
path_key,
Expand Down Expand Up @@ -91,6 +97,8 @@ def _start(self, storage_dir: str, is_eternal: bool, parent_directory) -> None:
# File keys stored with ``put(..., derived=True)``; see
# ``InMemoryStorage``.
self._derived = set()
# When each file key was stored; see ``InMemoryStorage``.
self._sequence_numbers: Dict[str, int] = {}
# For each key, the file this storage last wrote for it, while it has
# not shared that file since: the only files of its family ``put``
# writes over (see ``_path_to_write``). Kept when the key is deleted,
Expand Down Expand Up @@ -204,7 +212,15 @@ def __setstate__(self, state: dict) -> None:
state["_storage_dir_owner"] = owner
if state.get("_parent_directory") is None:
state["_parent_directory"] = directory_containing(state["storage_dir"])
# Nor numbers: pickled before stores were numbered. Copies have their
# own numbers, like their file index: replacing or dropping a key
# through one must not change when the other's value was stored.
state["_sequence_numbers"] = dict(state.get("_sequence_numbers", {}))
self.__dict__.update(state)
# Numbers from the process that pickled this storage must stay below
# those of stores made after unpickling it.
if self._sequence_numbers:
advance_sequence_past(max(self._sequence_numbers.values()))

def clone(self) -> "OnDiskStorage":
"""Create a private metadata view over this storage directory.
Expand All @@ -225,6 +241,7 @@ def clone(self) -> "OnDiskStorage":
clone._files = self._files.copy()
clone._enums = self._enums.copy()
clone._derived = set(self._derived)
clone._sequence_numbers = dict(self._sequence_numbers)
# One set of read-back paths for the family, so a clone made before
# ``restore`` still writes over none of them after a fork.
clone._restored_paths = self._restored_paths
Expand Down Expand Up @@ -276,7 +293,13 @@ def put(
period: Period,
branch_name: str = "default",
derived: bool = False,
sequence_number: Optional[int] = None,
) -> None:
"""Store ``value`` for ``period`` on ``branch_name``.

``derived`` and ``sequence_number`` are as in
:meth:`InMemoryStorage.put`.
"""
if self.is_eternal:
period = periods.period(periods.ETERNITY)
period = periods.period(period)
Expand All @@ -296,6 +319,59 @@ def put(
self._derived.add(filename)
else:
self._derived.discard(filename)
self._sequence_numbers[filename] = (
next_sequence_number() if sequence_number is None else sequence_number
)

def drop_computed(self, *, since: Optional[int] = None) -> int:
"""Forget stored values that are not inputs, and return how many.

As :meth:`InMemoryStorage.drop_computed`. The files stay on disk
(clones may still read them) until the storage directory is removed;
writing a dropped key again reuses this storage's own file for it,
as after ``delete``.
"""
numbers = self._sequence_numbers
dropped = [
key
for key in self._files
if key in self._derived
and (since is None or numbers.get(key, since) >= since)
]
for key in dropped:
del self._files[key]
numbers.pop(key, None)
self._derived.difference_update(dropped)
return len(dropped)

def inputs_since(self, since: Optional[int] = None) -> List[Tuple[Period, int]]:
"""As :meth:`InMemoryStorage.inputs_since`."""
numbers = self._sequence_numbers
# Period strings contain no "_"; branch names may.
return [
(_period_from_storage_key(key.rsplit("_", 1)[1]), numbers[key])
for key in self._files
if key not in self._derived
and key in numbers
and (since is None or numbers[key] >= since)
]

def mark_derived_except(self, keys: Set[str]) -> None:
"""As :meth:`InMemoryStorage.mark_derived_except`."""
self._derived.update(key for key in self._files if key not in keys)

def has_unnumbered_values(self) -> bool:
"""As :meth:`InMemoryStorage.has_unnumbered_values`."""
return any(key not in self._sequence_numbers for key in self._files)

def _forget_dropped_keys(self) -> None:
"""Forget the derived marks and numbers of keys ``_files`` no longer has."""
self._derived.intersection_update(self._files)
numbers = self._sequence_numbers
if not numbers.keys() <= self._files.keys():
self._sequence_numbers = {
key: number for key, number in numbers.items() if key in self._files
}

def _path_to_write(self, filename: str) -> str:
"""The path ``put`` writes the value for key ``filename`` to.
Expand Down Expand Up @@ -348,7 +424,7 @@ def delete(self, period: Period = None, branch_name: str = "default") -> None:
for period_item, value in self._files.items()
if not period_item.startswith(branch_prefix)
}
self._derived.intersection_update(self._files)
self._forget_dropped_keys()
return

if self.is_eternal:
Expand All @@ -361,7 +437,7 @@ def delete(self, period: Period = None, branch_name: str = "default") -> None:
for period_item, value in self._files.items()
if not period_item == f"{branch_name}_{period}"
}
self._derived.intersection_update(self._files)
self._forget_dropped_keys()

def get_known_periods(self) -> list:
return list([periods.period(x.split("_")[1]) for x in self._files.keys()])
Expand Down Expand Up @@ -393,8 +469,9 @@ def restore(self) -> None:
are unchanged.
"""
self._files = files = {}
# Files read back from a directory carry no derived marks.
# Files read back from a directory carry no derived marks or numbers.
self._derived = set()
self._sequence_numbers = {}
# Restore self._files from content of storage_dir.
for filename in os.listdir(self.storage_dir):
if not filename.endswith(".npy"):
Expand Down
Loading
Loading