Skip to content

Keep branch period reads, dumps, and disk deletion consistent - #552

Open
MaxGhenis wants to merge 8 commits into
masterfrom
fix-uprating-branch-visibility
Open

MaxGhenis wants to merge 8 commits into
masterfrom
fix-uprating-branch-visibility

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #551; folds the contained-period disk deletion fix and tests from #565 (fixing #564). #565's branch is preserved and neither PR has been closed.

Problem and change

Unscoped known-period listings let values stored only on unreadable branches affect a simulation's fallback caching. Disk keys split on every underscore misread branch names such as no_salt, and prefix deletion also removed other branches. Dumps read default-branch values instead of the dumped branch's values. Disk deletion removed only an exact key, despite the holder's documented containment rule.

Holder.get_known_periods(branch_name) now deduplicates periods visible through master's existing ancestor lookup. Simulation fallback uses that scoped listing. Dumps save the visible value and its matching derived mark using the existing derived_periods.txt format. Disk keys split on the last underscore, whole-branch deletion matches the complete branch name, and period deletion removes contained periods while retaining other branches and derived marks for surviving values.

The branch includes a history-preserving merge of canonical master 5d68130be487f0b87d47fc75ef300f36ad46f7ca. It preserves master's input-only uprating/carry-over, input/derived bookkeeping, and #585's TemporaryStorageDirectory, ownership, clone/fork handling and _path_to_write architecture. The dumper conflict retains current derived marks; no parallel marks file is introduced.

Folded review findings

  • Simulation.set_input("rent", "month:2025-03-15", [500]) previously survived deletion on disk because its key lost the start day. Both backends now apply the existing memory restrictions before writing.
  • Restoring stray.npy or default_garbage.npy previously poisoned unrelated valid deletion. Restoration warns and skips filenames without a branch separator followed by a canonical period string.
  • The full Delete the periods within a deleted period from disk storage #565 review also identified put(..., periods.period(999)), whose filename cannot be reparsed. Both string-key backends now reject unparseable period strings before changing stored values. Supported twelve-month/one-year aliases remain accepted.

The original #565 commit bfa78cba8f229f9029dd17fe6609e8f91644c2a3 descends from #552's previous head 5fe8b61864a3a929d3394e1658a83de1f4ccf7be and is an ancestor of this head. Its examples and Hypothesis differential are retained and extended, and its changelog is folded into the single #552 fragment. Its draft status and branch remain untouched; merging the old #565 branch separately would duplicate this fold.

Invariants and coverage

  • For a non-neutralized holder, scoped known periods equal the stored periods its branch reads, without duplicates; input-period listings agree with the provenance of the visible value. Examples and a differential property compare these paths.
  • Unreadable branch values do not change fallback results, stored visible periods, or derived marks. The existing exhaustive simulation comparison is retained.
  • Dumped values and derived marks refer to the same visible value; branch dump/restore examples exercise ancestor fallbacks and branch overrides.
  • Disk and memory delete exactly the requested branch's contained intervals, retaining other keys and values. The Hypothesis test compares both backends with an independent tuple-key reference after each operation.
  • Invalid puts change neither existing values nor their derived marks. The differential now draws mid-month and early-year periods; explicit regressions retain twelve-month aliases.

Validation at 0432ced

Passed: Ruff 0.16.7 formatting and lint checks for all ten changed Python files, git diff --check, ancestry checks, and patch-bundle verification.

Fresh independent review of this exact complete source head: APPROVE, no unresolved P1/P2. The reviewer separately checked formatting, lint, ancestry, documentation and the complete master-to-head diff. Runtime verification remains blocked as below.

Focused pytest commands were attempted through ~/reviews/us-hub/scripts/heavy_run.sh; the wrapper exits 75 because this sandbox denies its ps calls. Pytest never starts. No fresh test counts, fail-before/pass-after execution, or downstream impact results are claimed. Historical counts from the previous PR description do not validate this head.

Required follow-up: run the four branch visibility/disk deletion test modules plus relevant holder, dump/restore, anchored-period, provenance, uprating/carry-over and #585 clone regressions through the hub wrapper. Full suites, country-template CLI, documentation build, Windows execution and microsimulations were not run in this bounded build. CI must validate the new head.

Downstream impact

Re-run pending at 0432ced3067eafa12f02e4b13b3cab1e5186c367. The hub's existing US/UK A/B and partner-impact gates remain for Max; this build performs no new microsimulation and edits no partner baseline tests.

Holds and composition

Core-semantics merge remains reserved for Max; no separate affirmative decision or decision ID was found. This PR is not declared ready. Independent current-head source review is complete; current green CI and the remaining hub gates are still required.

#560 and the other storage/restore stack changes remain separate work. Whichever branch lands later must preserve the current derived marks and #585 storage architecture. This repair does not adopt #560's invalidation semantics or a new dump format, or establish #576's separate restored-input registration contract.

axiom: n/a: core engine storage and branch lookup repair; no policy rule changes.

🤖 Generated with Claude Code

…g over

Holder.get_known_periods() lists the periods of every stored key, with the
branch name stripped, but Holder.get_array() reads only the requested branch,
its parent_branch ancestors and "default". Simulation._calculate took the
latest known period from the unscoped list, so a period stored only under an
unrelated branch read back as None: uprating raised TypeError and
auto-carry-over cached NaN.

- Holder._readable_branch_names() is the one definition of what a branch can
  read; get_array() and the new get_known_periods(branch_name) both use it.
- _calculate uses get_known_periods(self.branch_name).
- OnDiskStorage splits "<branch>_<period>" keys on the last "_": branch names
  like "no_salt" raised ValueError, "y_2019" listed the wrong period, and
  delete(None, "pre_tcja") also wiped "pre_tcja_ctc".
- dump_simulation saves the values the dumped branch reads instead of reading
  every period under "default" and saving None.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 2, 2026
…ut-only carry-over

Round-2 review findings:
- A default cached for a period with no earlier input became the base the
  uprating path uprated later periods from; the default is not cached for
  variables with uprating (as before).
- A value calculated inside a set_input helper was written to the input's
  branch and recorded as a user input, bypassing the guard that keeps
  inputs; derived writes now stay on the branch they were calculated on.
- dump_simulation reads each value's mark from the same branch and period
  as the value it dumps, so #552's branch dumps keep matching marks.
- Carry-over checks candidates latest first and stops at the first input,
  instead of resolving the storing branch for every known period.
- Storages drop the marks of deleted keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 2, 2026
… handlers

From the round-5 soundness review (on 2b31dcb); all were also on master:
- A branch stops reading macro-cache files as soon as an input is set on
  it, not only when the input drops something: a file for its name may come
  from another simulation's branch of the same name, read before any record
  exists.
- An input set during a calculation for its own period wins under any
  branch name the simulation reads (Holder.set_input stores under
  "default"), and also when the formula returns None and core would fall
  back to the default for want of an earlier period.
- A direct calculate_add sums again when a term changed an input while
  summing (an earlier term could be obsolete).
- A custom set_input handler that calculated and then raised: the branch
  still drops again (the inputs it stored stay). The drop after a handler
  now runs only if the handler calculated anything.
- calculate runs a calculation again until a run changes no input, at most
  ten times (was once), so a formula changing a second input on its rerun
  no longer leaves the period unknown to carry-over.
- Docs: the above, and that disk-backed branches whose names contain "_"
  cannot be dumped (OnDiskStorage key parsing, as on master; #552 fixes it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 2 commits October 2, 2026 07:44
The string form of a multi-unit or rolling-year period contains ':',
which a Windows file name cannot. On windows-latest, numpy.save raised
OSError for month:2025-01:3 and year:2024:2, and year:2025-03 read back
until restore() found no file for it. Disk storage has never kept these
periods on Windows; this PR does not change that, so the test skips them
there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OnDiskStorage.delete(period, branch_name) removed only the file keyed
exactly f"{branch_name}_{period}". InMemoryStorage.delete and the
Holder.delete_arrays docstring remove every period the given period
contains, so a disk-backed holder kept values the caller had deleted
(deleting "2025" left "2025-01" and "2025-02"). Parse each key with
_split_key and delete those whose branch is branch_name and whose period
the deleted period contains. Eternal storage still deletes its one
ETERNITY key.

Fixes #564.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 3, 2026
…562)

* Carry over only inputs, the latest at or before the requested period

Auto-carry-over took the latest-starting stored period of any kind and
returned the default if it started after the requested period. Any later
stored period therefore hid an earlier input: a later input (inputs for
2012 and 2014 gave the default for 2013), or a later period the simulation
had already calculated (2014 calculated first made 2013 the default). And
values the simulation calculated carried like inputs: a twelfth cached at a
month by calculate_divide (the policyengine-us monthly_age bug #557 fixes),
a value masked by defined_for, a default cached where defined_for was false
everywhere, or a formula result from before the formula's end. Results
depended on which periods were calculated first.

The rule now: a period takes the input stored for the latest-starting
period that starts no later than it (on a tie, the one ending last),
preferring the variable's own definition-period unit; with none, the
default. Values the simulation calculates are stored with derived=True:
the mark lives in the storage with the value (InMemoryStorage and
OnDiskStorage keep it per key, every put sets or clears it, it counts only
while the key is stored, and clones copy it), so deletions, direct writes
and #556's shared arrays cannot leave it stale. Holder.is_derived(period,
branch_name) reports the mark of the value get_array reads, so a value one
branch calculated never hides another branch's input. A derived value never
replaces an input the branch reads (calculate_add/calculate_divide used to
overwrite inputs stored in another unit), calculate_add caches only sums
over several sub-periods, and dump/restore keeps the marks. Storages gain
has(), which neither reads nor copies an array.

Supersedes #557's carry-over change: its 253 tests pass here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep uprating, input helpers, dumps and deletions consistent with input-only carry-over

Round-2 review findings:
- A default cached for a period with no earlier input became the base the
  uprating path uprated later periods from; the default is not cached for
  variables with uprating (as before).
- A value calculated inside a set_input helper was written to the input's
  branch and recorded as a user input, bypassing the guard that keeps
  inputs; derived writes now stay on the branch they were calculated on.
- dump_simulation reads each value's mark from the same branch and period
  as the value it dumps, so #552's branch dumps keep matching marks.
- Carry-over checks candidates latest first and stops at the first input,
  instead of resolving the storing branch for every known period.
- Storages drop the marks of deleted keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test that deleting a whole branch drops its derived marks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cache the default only where master cached a carried value

With no input at or before the period but a later period stored, master
returned the default without caching it; caching it changed what formulas
that test whether a value is stored see (policyengine-uk's maintenance
loan and current_education formulas check get_array(period.last_year)),
and gave the uprating path a calculated base. Return it uncached there, as
master did; cache it (derived) otherwise, as master cached the value it
carried. This replaces the uprating-only special case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read input periods in one pass over readable keys; clear marks with the caches

Round-3 review findings:
- A period stored only under a branch this one cannot read was taken for
  an input (own-unit preference ranked it first) and read back as NaN.
  Holder.get_input_periods(branch_name) now lists, in one pass over the
  stored keys, the periods whose readable value is an input, taking for
  each period the key get_array reads first; carry-over picks from those.
  This also replaces the per-period walk up the branch chain.
- apply_reform's cache wipe kept the storages' marks, and rebuilding a
  disk index brought a stale mark back onto a new input; the wipe now
  clears the marks and OnDiskStorage.restore starts without any.
- Storages pickled before the marks existed failed to clone, put or
  delete; __setstate__ now defaults them (and #556's shared-key set).
- The public carry-over rule now states the own-unit preference and that
  an input for the period itself is read back as stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test memory-over-disk precedence, in-memory mark clearing and index rebuilds

Closes three mutants the suite did not kill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Break equal-extent carry-over ties by unit, then period

Two inputs covering the same days in units other than the variable's
(month:2013-01:2 and day:2013-01-01:59) resolved to whichever was stored
first. Prefer the larger unit, then the period's string form, so the
choice depends only on the inputs (round-4 review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Record a storage's inputs, not its derived values, and share them with clones

Measured on a policyengine-us household (policyengine-us 2.18.3, one
household_net_income calculation, 6,185 storages, 30,925 holder clones
across 5 branches), master with #578 retains 20.94 MB per simulation.
With derived marks as a set per storage that holds a derived value, the
merge retained 24.35 MB: 1,514 sets in the simulation and a copy of its
source's marks in every holder clone. That is 2.6 times the 1.3 MB per
simulation that ran policyengine-us's CI out of memory (#577).

5,201 of the 5,204 stored values are derived, so record the inputs
instead: is_derived is "stored and not an input". The 3 storages holding
inputs have a set (648 bytes); the rest read _NO_INPUTS. A storage and its
clones share one frozenset of inputs until one of them changes its own.
Retained memory is now 21.25 MB per simulation, +0.31 MB on master: one
more 8-byte attribute slot in each of the 37,110 storage objects.

A pickle records that inputs were recorded; a state from before (no
marker) counts every stored value as an input, as it did then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Carry over an input that ends after the last representable date

The carry-over sort key evaluated period.stop for every candidate input.
A period that ends after 9999-12-31 (day:9999-12-30:3, or
day:2012-01-01:4000000) has no stop: computing it raises OverflowError.
Master carried such inputs ([10, 20]); this branch raised. Sort by
_end_order, which puts such a period after every period that has a stop,
so it still ends last. Found by the review of #582, which uses the same
key for uprating.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Order period ends by day number, so ends after 9999-12-31 compare too

6ddbaf9 sorted every period whose stop cannot be computed after those
that have one, but equal to each other, so of two inputs ending after
9999-12-31 the string form decided: day:9999-12-30:9 was carried over
day:9999-12-30:10, and 24 months over 800 days from the same day (delta
review, P2). Month and year periods past 9999 also raised ValueError, or
returned a stop no date can hold, rather than OverflowError.

_end_order is now the number of the day after the period's last day,
counted as date.toordinal counts, by integer arithmetic on the same
calendar. It equals stop + 1 day wherever stop has a value and orders
every other period by its true end.

Tests: the review's two cases in both set orders; a property that
_end_order agrees with Period.stop where it has a value and with numpy's
calendar everywhere (1,000 examples, sizes to 5,000,000); a longer period
from the same day ends later. Also asserts that a pickled state without
a set of shared keys gets none (a surviving mutant).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 5 commits October 7, 2026 16:56
Preserve input/derived provenance and #585 storage directory ownership. Resolve the dumper conflict using branch-scoped reads and the existing derived_periods.txt marks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reconcile #552 with master after the merge:

- Holder.get_known_periods(branch_name) uses master's _readable_branches
  (added by #562) instead of a second copy of the lookup, and lists each
  period once. get_array is master's again.
- _dump_holder reads the dumped simulation's branch from the holder, as
  #560 does, and keeps master's derived_periods.txt with each value's mark
  read on that branch.
- The _calculate comment says what scoping still changes now that
  uprating and carry-over read only inputs (#562/#563): a later period
  stored under an unreadable branch no longer leaves a default uncached.

Tests: the isolation grid now also compares what the branch then reads
(periods and derived marks); a period stored under several readable
branches is listed once; a restored branch dump calculates what the branch
calculates; and a Hypothesis differential checks get_known_periods against
get_array and get_input_periods against is_derived for random values on a
five-branch lineage, in memory and on disk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes the two findings of #565's review (REQUEST_CHANGES at bfa78cb):

1. An input for a month or year anchored mid-month (month:2025-03-15)
   was accepted on disk under the calendar month's key, since its string
   form drops the day; deleting its own period, which does not contain
   that calendar month, then left it. In-memory storage already rejected
   it. Both storages now run one check, storage_keys.check_storable, so
   whether an input is accepted no longer depends on which storage
   Holder._set picks by memory use; disk storage rejects branch names
   containing ':' too.
2. restore() read back every .npy file. A name that is not a key put
   writes (stray.npy, default_garbage.npy, default_2025-3.npy) made
   delete and get_known_periods raise, so a valid deletion left its
   value. restore now leaves such files out, with a warning naming them.

Tests: examples on both backends and through Simulation for each finding;
the memory/disk Hypothesis differential now draws mid-month periods and a
':' branch name and checks both storages accept or reject each put alike;
a new Hypothesis property restores a directory of random keys and non-key
names and checks restore reads back exactly the keys, warns about the
rest, and then deletes like memory.

Folds #565's changelog fragment into this PR's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cover the remaining #565 review witness, periods.period(999), on both string-key storage backends. Preserve supported twelve-month aliases and existing derived values after rejected writes. Extend the differential property across the early-year boundary and twelve-unit periods.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis MaxGhenis changed the title Read only periods the current branch can see when uprating or carrying over Keep branch period reads, dumps, and disk deletion consistent Oct 7, 2026
MaxGhenis added a commit that referenced this pull request Oct 10, 2026
…eplaces (#560)

* Drop what a branch input may have fed; add drop_computed_arrays

A branch starts with every value its parent holds, and set_input on a
branch used to keep everything calculated from the value it replaced, so a
value the parent calculated before branching answered for the branch
whatever its inputs said.

Every stored array now carries a sequence number from one process-wide
counter (store order), and storages record which values are inputs; both
are copied with clones. A simulation family shares a StoreHistory of the
first store of each variable and period (including values a holder does
not keep) and of the first uprated or carried-over value of each variable.
set_input on a branch drops the branch's non-input values stored at or
after the earliest of those for an overlapping period; when there is none,
nothing is dropped. Simulation.drop_computed_arrays() drops every non-input
value, for branches whose policy changes; _invalidate_all_caches now uses
the same input flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Start a new store history when subsample rebuilds the simulation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Record values served from the macro cache or a spiral's default

Neither is stored, but values calculated from them are, so set_input on a
branch must see them in the store history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move the Hypothesis property into its own module that skips without Hypothesis

The country-package smoke job installs no dev dependencies, so the
module-level hypothesis import failed its collection. The synthetic system
the examples and the property share moves to
tests/fixtures/branch_input_invalidation.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a store history per simulation; fix what two reviews found

Each simulation now keeps its own store history: a branch copies its
parent's, and a formula that calculates in another simulation takes in that
simulation's history when calculate returns (fast-cache hits included). A
drop prunes the records it made obsolete and records the kept inputs again.
An unrelated store in a sibling branch, or in the other arm of a
reform/baseline pair, no longer lowers a branch's threshold, so MTR-style and
baseline branches calculate shared values once.

Also from the soundness and code reviews:
- a branch whose input dropped values stops reading macro-cache files (they
  are keyed by branch and period, not inputs); drop_computed_arrays too;
- requires_computation_after counts a prerequisite requested before a drop;
- values a custom set_input handler calculates are not inputs;
- disk storage writes a new file per store, so a recalculation no longer
  changes what child branches or same-named branches read;
- a value written into storage without a number counts as a dependency;
- storages pickled before numbering still store (__setstate__);
- derivative keeps inputs set after the simulation was built;
- property test: an independent model of divided inputs, reused and
  forgotten branch names, a blacklist mode, derandomized CI examples with an
  environment soak; examples for each finding;
- docs and docstrings say what the code does, with the remaining limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep records while a formula runs; merge incrementally; survive pickling

From the second soundness review (in progress):
- A drop while a formula is still running in the simulation (its own
  set_input, or drop_computed_arrays) no longer prunes the history: the
  formula may hold values it read before the drop and store a result from
  them. Calculations in flight are counted per simulation; a clone starts
  at zero.
- Merging is incremental: each history keeps a journal of its changes since
  it was created or pruned, so repeated calls into a simulation whose history
  keeps growing no longer re-read every record (quadratic before).
- The merge map is keyed weakly, so temporary branches' entries go with
  them, and is left out of pickles (weak references do not pickle).
- Unpickling a storage or history advances the process's sequence counter
  past the numbers it carries; disk restore keeps each key's most recently
  written file (numbers restart in every process).
- Tests: drops inside a running formula, an uprated value handed back by a
  temporary branch (also in the property's scenarios), cross-process
  unpickling, restore order. Docs: the in-flight rule and the thread limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hand branch history to waiting ancestors; tokened disk files; gate on success

From the second soundness review:
- A branch calculated from a thread with no formula context (a worker a
  formula starts without copying its context) hands its store history to
  every ancestor with a calculation running, so the waiting formula's result
  keeps the dependency. Unrelated simulations are not touched.
- Disk files carry a per-process token as well as the sequence number:
  numbers restart in each process, so a later process could otherwise
  overwrite a file a restored view still maps.
- requires_computation_after counts a prerequisite only once its
  calculation succeeded.
- Docs and comments: the soundness argument states records for overlapping
  periods (summed and divided values), disk files stay until the directory is
  removed, the thread limit covers only unrelated simulations, and the
  history is copied into branches, not shared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Close the third review's gaps: in-flight results, failures, dumps, disk names

From the round-3 soundness review:
- A calculation still running when an input set on its simulation dropped
  values returns its result but does not cache it (an input epoch), so the
  next calculation uses the new input.
- A calculation that raises still hands its store history to the calling
  formula: whether it fails can depend on what it read.
- A merge records how far it read the source before reading, so a record
  added meanwhile (another thread) is read next time.
- dump_simulation records each variable's input periods (inputs.txt);
  restore_simulation restores inputs as inputs, then every other value under
  one later number, so a branch input drops what it may have fed. Older dumps
  restore every value as an input, as before.
- Disk restore reads the previous `<key>.<number>.npy` names, prefers this
  process's file on a timestamp tie, and a forked child gets its own token.
- StoreHistory pickles from before journals load with empty journals.
- Tests for each; docs updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test pickling with a live weak read position; tidy disk storage imports

The pickle test merged a history that died at once, so the weak map was
empty when pickled. Keep the source alive so the test covers the case the
__getstate__ exists for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fork the token test's child directly instead of through a process pool

A process pool forked from a multi-threaded process (the test process can
have threads by then) can deadlock; the child now only writes its token to
a pipe and exits. Still fails without the at-fork hook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Close the fourth review's gaps: refused results, dumps, handlers, macro reads

From the round-4 soundness review (on bff21a7):
- A result refused because an input changed while it was calculated left
  its period unknown, so later carry-over found nothing (0 instead of 3).
  calculate now runs such a calculation once more from the new inputs and
  keeps that result; a second change still returns without keeping.
- An input a formula sets for the very period it calculates is the result
  and is no longer overwritten by the formula's return value.
- A dump does not say what each value was calculated from, nor what was read
  without being kept, and a macro-cache value's sources were never
  calculated here: the store history now records the number from which
  values may depend on anything (restored values, the first macro-cache
  read), so an input for any variable on a branch drops them.
- A custom set_input handler that calculates between its own stores: the
  branch drops again, by the same rule, once the handler returns.
- dump_simulation on a branch dumps the values the branch reads (its own and
  inherited) instead of the default branch's, which could not be restored.
- calculate ends the calculation (in-flight count, tracer) even if handing
  its history back fails.
- The property test also dumps and restores simulations, including a chunk
  that calculates, dumps, branches the restored simulation and overrides an
  input; it catches a history without the new record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* DIAG: report traced parameter trees on Windows (to be reverted)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Give the disk-restore test's later process its own token, and age all earlier files

It faked a later process by restarting the counter only, so with the same
token its first store could reuse the writer's first file name; and it aged
only the writer's last file, so on a coarse file clock (Windows) the other
four could tie with the later write. Passes with every write given the same
timestamp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* DIAG: write the diagnostic through the terminal reporter (to be reverted)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* DIAG: tolerate half-built parameter nodes (to be reverted)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Revert the Windows diagnostics (3075a6c, a81adb4, 1d6221f)

They found the cause: test_disk_restore_reads_the_latest_file_of_each_key
failed on Windows' coarse file clock (fixed in acc5f58), and its rerun by
pytest-rerunfailures dropped the module's setup-state entry without running
its finalizers, so the module-scoped tax_benefit_system leaked into later
modules; test_branch_shared_arrays traced it, and test_parameters and
test_reforms then got a traced parameter tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Close the fifth review's gaps: macro reads, input wins, sums, failing handlers

From the round-5 soundness review (on 2b31dcb); all were also on master:
- A branch stops reading macro-cache files as soon as an input is set on
  it, not only when the input drops something: a file for its name may come
  from another simulation's branch of the same name, read before any record
  exists.
- An input set during a calculation for its own period wins under any
  branch name the simulation reads (Holder.set_input stores under
  "default"), and also when the formula returns None and core would fall
  back to the default for want of an earlier period.
- A direct calculate_add sums again when a term changed an input while
  summing (an earlier term could be obsolete).
- A custom set_input handler that calculated and then raised: the branch
  still drops again (the inputs it stored stay). The drop after a handler
  now runs only if the handler calculated anything.
- calculate runs a calculation again until a run changes no input, at most
  ten times (was once), so a formula changing a second input on its rerun
  no longer leaves the period unknown to carry-over.
- Docs: the above, and that disk-backed branches whose names contain "_"
  cannot be dumped (OnDiskStorage key parsing, as on master; #552 fixes it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Close the sixth review's gaps: input wins only if stored meanwhile, one rerun budget

From the round-6 soundness review (on 9f8d513):
- Direct calculate_divide/calculate_add could return an input stored
  before the calculation (they do not look in storage first), after any
  unrelated input change: 99 instead of 10, 100 instead of 12. An input
  now wins only if its sequence number is after the calculation began.
- Only the outermost calculation in a simulation (calculate or a direct
  calculate_add) runs again after an input change; inner ones run with it.
  The ten-rerun budget no longer multiplies through nesting (11/121/1,331
  leaf runs at depths 1/2/3, now 11 each).
- A result calculated across an input change is no longer written to the
  macro cache (only kept results are), so a branch of the same name
  elsewhere cannot read it (also on master).
- The post-handler drop counts _calculate, not only calculate, as
  calculating (a handler's private carry-over calculation was missed).
- Docs: the above, and the rerun cap's consequence for carry-over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Close the seventh review's gaps: refused results stay unkept across simulations

From the round-7 soundness review (on ed843df):
- A parent formula calling into a branch whose formula calls back into the
  parent and then changes the branch's input: the parent kept the branch's
  refused inner result (2 instead of 6; also through calculate_add and
  calculate_divide). Drops made while calculations run now also bump a
  process-wide count, and no result calculated across one, in any
  simulation, is kept. Which calculation runs again is still decided per
  simulation: the outermost one in the simulation whose input changed, so a
  temporary branch settles locally and nesting does not multiply reruns.
- A direct calculate_add counts as in flight while it sums, so its terms do
  not each get their own reruns (121 runs, now 11).
- Inputs are numbered when stored, even if the caller passes a number
  (a private _set with an earlier number lost its input to "input wins").
- Tests for each; docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Close the eighth review's regression: scope "not kept" to callers and family

From the round-8 soundness review (on 116b018): refusing to cache any
result calculated across an in-flight drop anywhere in the process let an
unrelated simulation's input change leave a period uncached, so a later
carry-over returned 0 instead of 6 (master, 9f8d513 and ed843df give 6).

A result calculated across an input change is now kept out of caches only
where it can matter:
- in the simulation whose input changed (its own epoch, as before);
- in any calculation that received a refused result, directly or through
  other calculations, in any simulation (a per-frame flag in
  _calculation_frames, passed to the caller when a frame returns);
- in calculations of the same family still running in this context, which
  may hold values read before the drop (their cache epoch is bumped).
Unrelated simulations and other threads keep caching. Reruns stay with the
outermost calculation in the simulation whose input changed; calculate_add
and calculate_divide run in frames too.

Docs: the scope, and that values a formula already read from a branch
before a formula there changed the branch's input stay read (its result is
returned but not kept). Tests isolate each part (taint across families,
the family bump, the unrelated family); new mutants M38/M39 are caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Close the ninth review's gaps: refuse by what each calculation read

From the round-9 soundness review (on 359c97f):
- A calculation that changed its simulation's input and then left before
  any cache decision (a carry-over default returned early, an exception)
  did not tell its caller, which kept an obsolete result (0 instead of 6).
- A formula that read another simulation (another family, a root clone, its
  own branch from a plain thread) and then calculated there something that
  changed that simulation's input kept its old result (2 on the second
  request instead of 6).
- The family bump refused a settled, correct result, so a later carry-over
  found no period (0 instead of 6; master gives 6).

One rule replaces the taint flag and the family bump: each calculation frame
records, for every other simulation it got a value from (directly or
through its callees), that simulation's _input_epoch when the value's
calculation began; it keeps its result only if neither its own simulation's
epoch nor any of those has changed. A callee hands its reads to its caller
however it ends (value, early default, error), its own simulation counted
at its start; a rerun that settles restarts the count, so a settled call
leaves its caller clean. Fast-cache hits from another simulation are reads
too.

Docs updated ("values already read stay read" now covers any simulation and
caught errors). Tests for each witness; mutants M39 (no reads) and M41 (a
read counted from return, not start) are caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Close the tenth review's gaps: thread reads, settled reads, idle changes

1. A value read in a thread started without the formula's context now
   counts as a read: with no calling frame, a calculation's reads go to
   every frame open in its simulation's ancestors (each simulation lists
   its open frames). Before, a parent that got its branch's value that way
   kept it after the branch's input changed.
2. The outermost calculation in a simulation now runs again while its
   frame is stale, not only while its own input epoch changed, so a
   formula that read a value before another simulation's input changed
   gets the current one and keeps it. Before, a correct settled result
   went unkept and an earlier read stayed in the returned value.
3. Every drop counts as an input change of the simulation, also when
   nothing is running there, so a formula that read a simulation and then
   set an input on it directly does not keep what it read.
4. Setting a branch input to the value the branch already reads there, as
   an input, drops nothing (not for a value calculated there, nor through
   a set_input helper). Without this, a formula that sets the same input
   on every run would rerun until the budget ran out.

The property test now also sets inputs again with the value read. Docs
describe the new rules, and the limit that a value kept in one simulation
stays when another's input changes after its calculation ended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove synthetic_simulation's temporary folders when done (US + core hub patch)

Each disk-backed synthetic simulation gets a folder inside one folder per
test process, removed when the simulation is collected or at exit, and its
pre-created disk storages leave removal to that, so a killed run leaves one
folder, not one per live simulation. Patch from the US + core hub
(policyengine-core#585 notes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Bring two docstrings in line with the merged rules

StoreHistory: uprating and carry-over read which periods hold inputs.
Simulation.set_input: drops count as input changes, stale calculations
anywhere run again, unchanged inputs drop nothing, and values kept
elsewhere stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep macro reads off on unchanged inputs; cut read tracking's per-call cost

Review of 789d7f0 (round 11, read-only):
- The unchanged-input short-circuit also skipped the branch's macro-cache
  switch-off, so a branch given the input it already read kept reading
  files another simulation's same-named branch may have written. Holder.
  set_input now turns macro reads off on every branch input.
- -0.0 no longer counts as the input 0.0 (1 / x tells them apart).
- The changelog states the short-circuit's conditions.

Cost: a single policyengine-us household (household_net_income, 2026)
took about 20% longer than on master, all in per-calculation
bookkeeping. Now:
- a calculation nested in another in the same simulation shares that
  one's frame (it began no later and notes at least the same reads, each
  at its first epoch, so sharing only keeps less); a frame opens only at
  the outermost calculation in a simulation or on entering another one;
- frames are a slotted class, their own context manager and registry key;
- counters are class attributes, read without getattr defaults.
The household now takes about 4-5% longer than on master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test a helper's input over a stored year, and a callback that changes its input

Two mutants of the round-10 mechanisms survived every test: an equal
input given to a set_input helper skipping the drop, and a call into
another simulation sharing its caller's frame. Each test fails under its
mutant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Stop macro reads in one place: Holder.set_input on every branch input

_drop_values_that_may_depend_on switched them off too, but it only runs
from Holder.set_input after that has; the mutation check could not tell
the second switch-off from none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep copied disk storage invalidation order independent

* Settle repeated branch input transitions without caching stale reads

* Keep fixed-point readers and saved branch snapshots distinct

* Retain registered branch paths across calculation retries

* Guard fixed-point writes to optional fast caches

* Keep independent calculations cached during stale branch retries

* Cover shared credit dependencies during stale retries

* Isolate early-year branch replay regression from fallback parsing

* Keep raw cache writes observable during guarded retries

* Keep branch invalidation changelog concise

* Clarify root simulation helper cache cleanup

* Forward store sequence numbers in restore property observer

* Reuse stable foreign reads within stale calculation attempts

* Capture branch registrations before memo-producing formulas run

* Guard attempt reuse with the branch name actually resolved

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant