Skip to content

Share nested metadata values between parameter clones - #631

Merged
MaxGhenis merged 7 commits into
masterfrom
share-parameter-metadata-on-clone
Oct 31, 2026
Merged

MaxGhenis merged 7 commits into
masterfrom
share-parameter-metadata-on-clone

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Relates to #621 and #618 (does not change the cache design either discusses).

Problem

Parameter.clone, ParameterAtInstant.clone, ParameterNode.clone and ParameterScale.clone each called copy.deepcopy(self.metadata). policyengine-us's tree has 22,796 nodes, 959 scales with 8,209 brackets, 124,946 parameters and 1,338,663 dated values, so one clone() of it ran about 1.5 million metadata deep copies. TaxBenefitSystem.clone() and Reform.__init__ each clone the tree, so every reform build pays this, as do policyengine-us's behavioural-response measurements and Simulation.clone(), which reach TaxBenefitSystem.clone().

Change

  • Clones share nested metadata values. Each clone gets its own metadata dict, made with copy.copy, holding the same values as the source's. Setting, updating, popping or replacing a key changes only the object that receives it. A nested value (a reference list, an uprating dict) stays one object for both until an edit replaces its key. copy.copy keeps the old behaviour for None metadata (stays None) and for dict subclasses (keep their type). Variable.clone already copied metadata this way (dict(self.metadata)).
  • interpolate_parameters records its work on the parameter. It wrote metadata["interpolation"]["completed"] = True in place, into a dict that clones now share. TaxBenefitSystem.__init__ clones the tree into parameters.baseline before it interpolates the whole root, so that write would have skipped the second copy reached. Completion belongs to a parameter's values, while its clones and every parameter under a propagating node share the interpolation dict, and propagating again replaces each parameter's reference to it. So Parameter.interpolated, a class default of False that the function sets on the instance and clones copy, now holds completion, and interpolate_parameters writes no metadata. The class default also covers subclasses that skip Parameter.__init__, such as policyengine-uk's LegacyNIParameter, and trees pickled by an older Core. Three effects, none of which moves a model value, because no PolicyEngine country model sets interpolation on any parameter (see the audit):
    • A master bug fixed. propagate_parameter_metadata gives each descendant the node's own interpolation dict, so the marker on it skipped every child after the first. On master, a node with propagate_metadata_to_children: true and interpolation: {interval: year} over a (100 → 140) and b (200 → 280) gives a(2016-06-01) == 110 but b(2016-06-01) == 200; it now gives 220.
    • Core stops writing completed. interpolate_parameters still honours a true completed already in the metadata, so a tree pickled by an older Core doesn't interpolate twice. In every repository searched, only that function reads interpolation, and no YAML sets it.
    • Replacing interpolation no longer restarts a parameter. On master a new dict interpolated the parameter again. Now that also takes parameter.interpolated = False, and the new dict must not carry a true completed, as the function's docstring says.
  • Docs. docs/usage/parameters.md states the rule: replace a key to change a nested value. Within one tree this already held: a dated value written in dictionary form holds its parameter's metadata values (a scalar entry has none), and propagate_parameter_metadata gives descendants the node's values.

Invariants

  1. For any chain of clones (of the source or of another clone) and any edits along the way, every tree reads as a tree built fresh from the same data with the same edit history: no edit reaches a tree it was not made on. The edits are top-level metadata edits ([k] =, update, setdefault, pop, del, clear, replacing nested values), Parameter.update, assigning or removing a dated value, propagate_parameter_metadata and interpolate_parameters.
  2. Apart from the identity of nested metadata values, a clone behaves as master's deep-copying clone does (differential).
  3. Intended violation: a nested metadata value edited in place, rather than replaced, shows through every tree that shares it. This is the documented contract, pinned by test_a_clone_shares_nested_metadata_values_with_its_source.
  4. After TaxBenefitSystem.__init__'s sequence (propagate, interpolate, propagate), another interpolate_parameters changes nothing: each parameter interpolates once, whichever object gave it interpolation.

Audit of metadata writers

Searched Core, policyengine-us, -uk (plus PolicyEngine/policyengine-uk#2756's diff), -canada, -il, -ng, policyengine.py, policyengine-api, -api-v2 and -household-api for every write to parameter, value, node or scale metadata, and read each line that reads one for locals that alias a nested value. First pass on 2026-10-24, repeated on each repository's default branch on 2026-10-30.

  • Core. Only interpolate_parameters edited a nested value after a clone can exist; it now writes no metadata. propagate_parameter_metadata (update, top-level) also runs after the baseline clone and needs no change. ParameterScale.propagate_units and ParameterScaleBracket.propagate_uprating write top-level keys, in ParameterScale.__init__ and, for propagate_units, also from policyengine-api's country.py. normalize_uprating_metadata returns a dict argument unchanged, and the uprating code that receives it only reads it. homogenize_parameter_node reads the breakdown list and recurses on a slice.
  • policyengine-us. One writer, top-level: system.py clones the OASDI cap into gov.contrib.ssa.owners_draw.current_law_cap, then calls update and pop("uprating", None) on the clone's metadata. Its spm.py tree sharing sets node attributes and caches, not metadata.
  • policyengine-uk. All top-level: setdefault/pop/update in dated_reforms.py, parameters.py, create_threshold_indexation.py, reindex_benefit_cap.py; ni_parameter_scales.py swaps metadata["uprating"] for a new dict and restores it; create_savings_credit_threshold.py sets uprating on a clone. Derive economic assumption index horizon from the growth series policyengine-uk#2756 adds one read.
  • policyengine.py and the API repos read parameter metadata only.
  • No country model sets interpolation: no YAML in the five country repositories has that key, and no Python in them names it. So the interpolation change has nothing to act on, although policyengine-us has 880 YAML files with propagate_metadata_to_children: true and policyengine-uk 65. Digests confirm it. Each of policyengine-us (under two hash seeds) and policyengine-uk, built on master and on this branch, gives the same SHA-256 over every object's type, name, metadata, dated values and child links, walked by real child keys and bracket positions, in the tree as built, in policyengine-uk's baseline and in a clone(): 156,910 objects, 124,946 parameters and 1,338,663 values in policyengine-us; 6,480 objects, 4,626 parameters and 73,659 values in policyengine-uk. A second digest over what get_descendants yields, which includes policyengine-uk's NI discovery views, also matches. A moved value, an edited nested metadata entry and a swap of two children each change the digest.

The two clone-then-edit writers above (policyengine-us's cap, policyengine-uk's savings credit threshold) are why each clone still gets its own dict: sharing the dict outright carries those edits back to the source parameter. With that mutant, policyengine-us's real OASDI cap loses uprating and takes the label "Current-law OASDI cap preserved for owners' draw"; on this branch it keeps both.

Benchmark

TaxBenefitSystem.clone() on policyengine-us main (bdd08b8d69), CPython 3.14.7, macOS arm64, Core master (33dc9bb) against this branch. One venv ran both sides, with PYTHONPATH choosing the Core, so no other package differs. Four rounds in ABBA order, fresh processes, five timings per measurement per round: 20 samples a side, medians below. Times are CPU time (time.process_time) rather than wall time, because the host stayed busy (load average 46 to 106 on 18 cores when each process started); only the full-collection row uses wall time. In each of the three clone timings, every master sample exceeds every branch sample.

master this PR change
system.parameters.clone(), GC off 1.96 s 0.38 s −80%
system.parameters.clone(), GC on 5.40 s 2.10 s −61%
system.clone() (policyengine-us's clone_spm_system, which calls TaxBenefitSystem.clone), GC on 5.56 s 2.27 s −59%
RSS kept by one parameters.clone() 722 MB 632 MB −90 MB
GC-tracked objects added by one parameters.clone() 6.69 M 6.15 M −0.54 M
RSS kept by three system.clone()s 1.37–1.38 GB 1.10–1.20 GB −0.18 to −0.27 GB
Peak RSS, system plus three clones 3.89–3.90 GB 3.52–3.61 GB −0.28 to −0.37 GB
Full gc.collect() with three clones alive 1.96–2.19 s 1.96–2.13 s no measurable change

Of the 0.54 M fewer tracked objects per tree copy, 0.39 M are dicts and 0.15 M lists. The tree copy takes 2.10 s with the collector on and 0.38 s with it off, so collection passes during the copy account for most of the time that remains.

The table comes from f99fd71bf. Later commits add one class attribute and otherwise change only interpolate_parameters, which no country model's parameters reach. One more interleaved round, at 4db4c7cae (one docstring from this head), agrees: 2.37 → 0.39 s with the collector off, 5.31 → 1.88 s with it on, system.clone() 5.59 → 2.26 s, 722 → 632 MB kept per tree copy, 1.37 → 1.20 GB kept by three clones, peak 3.90 → 3.61 GB, and 6.69 M → 6.14 M tracked objects.

Build time does not change. CountryTaxBenefitSystem(), one venv, four ABBA rounds of three builds per process, mean CPU seconds by build order: collector off, master 6.70, 6.77, 6.76 and this PR 6.85, 6.65, 6.56; collector on, master 8.88, 10.30, 9.83 and this PR 9.42, 9.08, 10.22. The collector-on differences change sign from one build to the next and the collector-off means sit within 0.2 s of each other, which points to where collection passes land rather than to the code, though the script records no collection events.

policyengine-uk. The US + core hub's interleaved probe, on the tree PolicyEngine/policyengine-uk#2756 builds (91,410 processed entries), three CPU-time runs per side at this PR's 449d2d4, which has the same clone code as this head: processed tree copy 309–314 ms → 183–189 ms, new system 542–551 ms → 366–372 ms, memory per kept system 50.7–50.9 MB → 48.0–48.1 MB.

Script and raw results: bench_clone.py (run one variant per process; installed measures the code as installed), below.

bench_clone.py
"""Time and memory of cloning policyengine-us's tax-benefit system.

Run one variant per process:

    python bench_clone.py <variant> <out.json>

``installed`` measures the clone methods as installed. The other variants
monkeypatch the four metadata-copying clone methods first, so designs can be
compared on the same code:

- ``deepcopy``: ``copy.deepcopy(self.metadata)`` everywhere (master).
- ``dict``: ``dict(self.metadata)`` everywhere.
- ``dict_shared_values``: ``dict`` on parameters, nodes and scales; values
  share their source's metadata dict.
"""

import collections
import copy
import gc
import json
import os
import resource
import statistics
import sys
import time

import psutil

VARIANT = sys.argv[1]
OUT = sys.argv[2]
REPEATS = int(os.environ.get("BENCH_REPEATS", "5"))

from policyengine_core import commons
from policyengine_core.parameters import (
    Parameter,
    ParameterAtInstant,
    ParameterNode,
    ParameterScale,
)


def _patch(copy_metadata, copy_value_metadata):
    def parameter_at_instant_clone(self):
        clone = commons.empty_clone(self)
        clone.__dict__ = self.__dict__.copy()
        clone.metadata = copy_value_metadata(self.metadata)
        return clone

    def parameter_clone(self):
        clone = commons.empty_clone(self)
        clone.__dict__ = self.__dict__.copy()
        clone.metadata = copy_metadata(self.metadata)
        clone.values_list = [value.clone() for value in self.values_list]
        return clone

    def node_clone(self):
        clone = commons.empty_clone(self)
        clone.__dict__ = self.__dict__.copy()
        clone.metadata = copy_metadata(self.metadata)
        clone.children = {key: child.clone() for key, child in self.children.items()}
        for child_key, child in clone.children.items():
            setattr(clone, child_key, child)
            child.parent = clone
        clone._at_instant_cache = {}
        return clone

    def scale_clone(self):
        clone = commons.empty_clone(self)
        clone.__dict__ = self.__dict__.copy()
        clone.brackets = [bracket.clone() for bracket in self.brackets]
        for bracket in clone.brackets:
            bracket.parent = clone
        clone.metadata = copy_metadata(self.metadata)
        return clone

    ParameterAtInstant.clone = parameter_at_instant_clone
    Parameter.clone = parameter_clone
    ParameterNode.clone = node_clone
    ParameterScale.clone = scale_clone


if VARIANT == "deepcopy":
    _patch(copy.deepcopy, copy.deepcopy)
elif VARIANT == "dict":
    _patch(dict, dict)
elif VARIANT == "dict_shared_values":
    _patch(dict, lambda metadata: metadata)
elif VARIANT != "installed":
    raise SystemExit(f"unknown variant {VARIANT}")

from policyengine_us import CountryTaxBenefitSystem

process = psutil.Process()


def rss():
    return process.memory_info().rss


def tree_counts(root):
    nodes = parameters = values = scales = 0
    for item in root.get_descendants():
        if isinstance(item, Parameter):
            parameters += 1
            values += len(item.values_list)
        elif isinstance(item, ParameterScale):
            scales += 1
        else:
            nodes += 1
    return dict(nodes=nodes, parameters=parameters, values=values, scales=scales)


def timed(fn, repeats, gc_enabled):
    """CPU seconds, which a loaded host disturbs far less than wall time."""
    times = []
    for _ in range(repeats):
        gc.collect()
        if not gc_enabled:
            gc.disable()
        start = time.process_time()
        result = fn()
        times.append(time.process_time() - start)
        gc.enable()
        del result
    return times


start = time.process_time()
system = CountryTaxBenefitSystem()
build_seconds = time.process_time() - start
gc.collect()
result = dict(
    variant=VARIANT,
    python=sys.version.split()[0],
    load_average=os.getloadavg(),
    build_cpu_seconds=build_seconds,
    tree=tree_counts(system.parameters),
)


def full_collection_seconds():
    start = time.perf_counter()
    gc.collect()
    return time.perf_counter() - start


# Memory first, in a process that has made no clone yet, so the allocator
# has no freed clone to reuse and RSS growth is what a clone keeps.
# Count types first: the list ``get_objects`` returns is freed before the
# RSS reading, so it counts towards neither side of the difference.
types_before = collections.Counter(type(o).__name__ for o in gc.get_objects())
result["full_collection_seconds_system_only"] = full_collection_seconds()
result["rss_after_build"] = rss()

tree_copy = system.parameters.clone()
gc.collect()
result["parameters_clone_retained_rss_bytes"] = rss() - result["rss_after_build"]
types_after = collections.Counter(type(o).__name__ for o in gc.get_objects())
added = types_after - types_before
result["parameters_clone_gc_tracked_objects"] = sum(added.values())
result["parameters_clone_gc_tracked_objects_by_type"] = dict(added.most_common(8))
del types_before, types_after, tree_copy
gc.collect()

# Three retained system clones: what a behavioural-response measurement and
# its baseline arm keep alive at once.
objects = len(gc.get_objects())
rss_before = rss()
clones = [system.clone() for _ in range(3)]
gc.collect()
result["three_system_clones_retained_rss_bytes"] = rss() - rss_before
result["three_system_clones_gc_tracked_objects"] = len(gc.get_objects()) - objects
result["full_collection_seconds_three_clones"] = full_collection_seconds()
# macOS reports ru_maxrss in bytes, Linux in kilobytes.
maxrss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
result["peak_rss_bytes_system_and_three_clones"] = (
    maxrss if sys.platform == "darwin" else maxrss * 1024
)
del clones
gc.collect()

# CPU time, with the garbage collector on (what callers pay) and off (the
# copy alone).
result["parameters_clone_cpu_seconds_gc_off"] = timed(
    system.parameters.clone, REPEATS, gc_enabled=False
)
result["parameters_clone_cpu_seconds_gc_on"] = timed(
    system.parameters.clone, REPEATS, gc_enabled=True
)
result["system_clone_cpu_seconds_gc_on"] = timed(system.clone, REPEATS, gc_enabled=True)

with open(OUT, "w") as f:
    json.dump(result, f, indent=2)
print(json.dumps(result, indent=2))

policyengine-us test time

policyengine_us/tests/core/test_behavioral_response_measurement_copies.py (16 tests), which these copies dominate. User plus system CPU and peak RSS from /usr/bin/time -l, one venv, policyengine-us bdd08b8d69, two rounds per cell with the order of the sides swapped between rounds. The ranges span the two rounds; the host stayed busy, and master's first default run (862 s) sits well above its second, so read them as ranges rather than a percentage.

master this PR
Default collector thresholds 633–862 s, 6.4 GB 563–614 s, 6.3 GB
First threshold 100,000 (the setting in PolicyEngine/policyengine-us#10440) 550–624 s, 7.5 GB 393–453 s, 6.4 GB

Tests

  • tests/core/parameters/test_parameter_metadata_clone.py (110 cases): each metadata edit (set, replace nested, update, setdefault, pop, del, clear) on each kind of object (node, parameter, dated value, scale, bracket, bracket component), on either side of a clone; nested values shared; None and dict-subclass metadata copied as copy.copy copies them; a tree holding its own clone interpolates both (the TaxBenefitSystem.__init__ shape), including a real TaxBenefitSystem with a parameters_dir; on a real system whose node propagates interpolation, a second interpolate_parameters changes nothing; every child a node propagates interpolation to interpolates; interpolating writes no metadata; clearing the flag interpolates a parameter again; a completed key already in the metadata stops interpolation; a parameter that skipped __init__ interpolates; interpolating or propagating one side leaves the other alone; a clone or deepcopy of an interpolated tree stays interpolated; TaxBenefitSystem.clone() and a Reform that edits metadata leave their source alone; cloning the country template's tree calls copy.deepcopy zero times.
  • tests/core/parameters/test_parameter_metadata_clone_property.py (Hypothesis, 300 examples each, derandomized): invariant 1 over clone chains; invariant 2 against deep_clone, a copy of master's clone methods; the two-tree case against the untouched side's own snapshot; invariant 4 over clone chains; and the values interpolate_parameters gives against interpolate_in_place, a copy of master's function, on parameters that each have their own interpolation dict. The shared fixture has a node that propagates interpolation, and the generated edits can set interpolation on any object.
  • On master's code, reading the new flag with getattr, 14 of the 115 tests fail, all on behaviour this PR changes: 12 example tests on sharing, zero deep copies and how interpolation records its work, and the two properties that compare a tree with a fresh one. Those two fail for one reason, minimal case a single interpolate: on master a fresh tree's dated value shows "completed": True and a clone's does not, because the clone deep-copied that value's interpolation apart from its parameter's. With that key left out of the snapshot, all 5 properties pass on master's code. The other 98 example tests and 3 properties pass unchanged.
  • Mutation check at this head: each of twelve mutants fails at least one example test, and ten of them also fail at least one property. The three tree properties all fail on each clone method sharing its metadata dict outright, a parameter sharing its values_list, dated values not cloned, and master's in-place interpolation. Invariant 4's property fails on an unrecorded interpolation and on this PR's first version, which replaced the parameter's dict. A clone that forgets interpolated fails the chain and differential properties. A flag assigned only in __init__ and a completed key ignored each fail the one example test that pins them.

Checks run

At this head, 21a8c5f9a, on master 33dc9bb36:

  • Core suite: 2,826 passed, 3 xfailed, and 4 skipped (three country smoke tests that don't run in this venv, and one test that needs pyarrow, which this venv lacks; CI's suite passes too). Country template suite: 39 passed.
  • Core's landing gate, 31 policyengine-us test files on cbe322b51c: 782 passed.
  • policyengine-uk 7ed52038a, the 36 test files that reach its metadata writers: 2,487 passed and 61 skipped on master and on this branch, with no test changing status between them. A coverage run of its NI tests (128 passed on this branch) shows they execute ni_parameter_scales.py's two metadata writes.
  • UK writer probe: each of five writer scenarios (benefit cap reindex, two dated-reform setdefaults, the NI view with its uprating references, the savings credit threshold projection), run through a statutory cache, a processed cache and a fresh system, changes no tree it was not made on, and gives the same income and the same reformed tree on master and on this branch. Two controls show the probe can see a leak: a top-level edit leaks only under the shared-dict mutant, and a nested in-place edit leaks on this branch as invariant 3 says it does.

The digests and the last benchmark round ran at 4db4c7cae, one docstring from this head.

Follow-ups (not in this PR)

  • policyengine-uk's NI discovery views can't hold the flag. NICompatibilityNode.get_descendants yields a fresh view on each walk, so a flag set on a view goes with it, and a second walk would interpolate the target again. On master the marker reached the target through the shared dict. This takes two things policyengine-uk doesn't do: setting interpolation and calling interpolate_parameters on that tree.
  • Reform.modify_parameters still calls copy.deepcopy on the whole baseline tree. Reform.__init__ already clones it, so that call pays the deep copy this PR removes elsewhere.
  • Share dated values' metadata dicts outright once Core freezes trees (Define how Core's parameter caches are owned, invalidated, copied and shared #621, design 1). Nothing writes a ParameterAtInstant's metadata after construction in any repository searched, but nothing enforces it, so this PR still copies each dict. Measured on policyengine-us (one round, same script, dict_shared_values variant), sharing them would keep 545 MB per tree copy instead of 632 MB, add 4.8 M tracked objects instead of 6.1 M, and copy the tree in 1.88 s instead of 2.10 s with the collector on.
  • policyengine-uk's NICompatibilityNode.clone overrides ParameterNode.clone and still deep-copies its own metadata.

axiom: n/a: infra (Core parameter-tree copying; no policy change)

🤖 Generated with Claude Code

MaxGhenis and others added 4 commits October 24, 2026 07:33
Parameter, ParameterAtInstant, ParameterNode and ParameterScale clones
deep-copied their metadata. On policyengine-us one tree clone makes
1,257,541 ParameterAtInstant clones, and those deep copies were most of
the cost of TaxBenefitSystem.clone().

Each clone now gets its own metadata dict (copy.copy, so None and dict
subclasses behave as before) holding the source's values. Top-level
edits stay on their side; a nested value must be replaced, not edited
in place. interpolate_parameters was the only writer that edited a
nested value after a clone exists (TaxBenefitSystem.__init__ clones the
tree into parameters.baseline before interpolating), so it now replaces
the interpolation dict it marks complete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
propagate_parameter_metadata hands each descendant the node's own
interpolation dict, so marking it complete in place skipped every child
after the first. Replacing the dict fixes that too; no PolicyEngine
country model uses propagated interpolation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replacing the parameter's `interpolation` dict left the propagating node's
dict unmarked, so `TaxBenefitSystem.__init__`'s final
`propagate_parameter_metadata` overwrote the parameter's marked copy and a
second `interpolate_parameters` added every entry again.

Completion belongs to a parameter's values, while the `interpolation` dict
is shared by its clones and by every parameter under a propagating node. So
`Parameter.interpolated` now holds it, and `interpolate_parameters` writes
no metadata. Add the constructor sequence as a test and as a property, an
`interpolation` node to the shared fixture, and a differential against the
in-place version's values. Correct the docs on which dated values hold
their parameter's metadata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 3 commits October 30, 2026 21:34
An `__init__` assignment left no attribute on policyengine-uk's
`LegacyNIParameter` and other subclasses that skip `Parameter.__init__`, or
on a tree pickled before the attribute existed, so interpolating one raised
AttributeError. A class default reads the same everywhere and adds nothing
to a parameter that never interpolates.

Document how to interpolate a parameter again after changing its
`interpolation`, which on master took only a new dict, and let the generated
edits set a monthly interval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A tree pickled by an older Core carries `completed`, and without this read a
second `interpolate_parameters` on it added every entry again. Core still
writes no metadata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 31, 2026 03:42
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Landing audit at 21a8c5f9aecd82b730e510216f63df2dc134a1cf, on master 33dc9bb36.

Reviews. GPT-6.1 Sol reviewed five rounds, each in its own worktree:

Head Verdict Outcome
f99fd71bf REQUEST_CHANGES Found the propagated interpolation regression and a docs claim. Both fixed in 6f5e46b87.
6f5e46b87 APPROVE Ran policyengine-uk's writers on both Cores: reindexing, resets, dated-reform markers, savings credit generation, removed aliases, NI restoration. Source, sibling and cached trees stayed unchanged, and positive controls detected leaks.
a65e6dd69 APPROVE Flagged the lost completion state in old pickles, fixed in 4db4c7cae.
4db4c7cae APPROVE Flagged reset wording, fixed in 21a8c5f9a.
21a8c5f9a APPROVE The commit changes only a docstring, and the documented reset works on a master pickle.

Core gate (core_gate.sh 631, us-tests-core.txt): core#631 pr=21a8c5f9a tested=21a8c5f9a (head-contains-master) us=cbe322b51c, 31 files, 782 passed in 25:17.

This head. Core suite 2,826 passed, 3 xfailed, 4 skipped. Country template 39 passed. CI 19 of 19, MERGEABLE, CLEAN.

policyengine-uk 7ed52038a at this head. The 36 test files that reach its metadata writers: 2,487 passed and 61 skipped, and no test changes status from master. Writer probe: 0 leaks in all five scenarios through all three caches, and both controls behave as invariant 3 says. NI tests: 128 passed, and coverage shows ni_parameter_scales.py's two metadata writes ran. UK main has since moved 19 commits and adds no metadata writer, and #2756 adds one read.

Judgment. No public claim, money, legal, people or outside send. Model outputs stay the same: digests of policyengine-us and policyengine-uk match master at 4db4c7cae, one docstring from this head.

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