Repository navigation
Conversation
is_receiving_unemployment_compensation placed each person's months of unemployment compensation by hashing person_id (splitmix64 mod 12) inside the formula. That is an engine-time draw, which policyengine-core#500 rules out and core#519's static check cannot see. - Add the unemployment_compensation_start_month input (Person, YEAR, 1-12, 0 = not supplied). The dataset build assigns and stores it. - The formula reads the stored month. A data-built simulation whose dataset stores it for some people but not others raises an error naming it. - Transitional: a dataset that does not store it at all (the current default Populace build) still hashes person_id, exactly as before, until the default dataset carries the column. - Household situations keep today's start months through a fixed table by household position. A supplied person_id no longer picks the month. - Add tests/code_health/test_no_engine_time_randomness.py: an AST scan of the package for draws, samples and entity hashes, with a ratchet that lists this transitional hash and must shrink to zero. - Add a differential test: on one dataset, storing the start month gives exactly the retired formula's results in every month and year. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine-time randomness guard now resolves names through each scope's own import bindings (so `from numpy import random as r; r.random()` is caught and a local variable called `random` is not), reports the unshadowed builtin hash() only, recognises pandas `.sample(...)` calls by their sampling arguments, and keys RATCHET and EXEMPT by (file, enclosing function or class, kind) with exact occurrence counts. An extra occurrence fails, and an entry that allows more than it matches is stale. A new check confirms the model never imports the offline TAXSIM tools that EXEMPT lists. policyengine-core stores int variables as int32, so the differential test's large ids now stay below 2**31, and a new test asserts the engine sees the dataset's person ids unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds an exhaustive check over the 156 combinations of start month (1-12) and months of receipt (0-12): receipt covers exactly that many calendar months, starting in the start month and wrapping into January. The expected block is built by counting months forward, independently of the formula's modular comparison. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Situation defaults follow household member position
(members_position), so supplied person_id values, reversed or tied,
no longer reorder them. New YAML cases cover reversed IDs, tied IDs,
reversed IDs across axis copies, and positions restarting in each
household.
A data-built simulation tells an absent column from a present one by
input_variables, not by an all-zero sentinel. A column that is stored,
or set after construction, must be complete for the year, so an
all-zero column now raises instead of falling back to the hash.
New differential tests: an all-zero column raises; a column set after
construction is used, and a partial one raises; a simulation built the
way policyengine.py's input-table adapter builds one keeps the retired
household-position months without the column and follows the dataset's
months with it.
The guard now:
- resolves assignment aliases and default arguments;
- follows star imports, of package modules (with __all__) and of numpy
and core's model_api;
- applies comprehension and class-body scoping, and lambdas;
- reports any use of the unshadowed builtin hash;
- counts each use of a hash library or pandas hashing ("hash use"),
each use of a top-level function that draws ("draw helper call",
a fixed point over the package with relative imports resolved), and
literal importlib/__import__ imports;
- catches augmented-assignment and numpy-function forms of xorshift
and id modulo, and getattr with a literal name.
It parses the package once per session. RATCHET adds the two calls
that reach the transitional hash, so RATCHET_CEILING is 7; EXEMPT adds
the two reviewed SHA-256 uses.
Docs, changelog and CLAUDE.md now describe the transitional fallback and
the guard's coverage accurately. The TRANSITIONAL comment names the
part-2 prerequisites: national and ACS local-area releases that store
the column, and policyengine.py's input-table adapter declaring that its
populations come from a dataset.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A data-built simulation decides whether it holds the start month from the holder's input periods on its own branch (get_input_periods), not from the input_variables snapshot. A column set after construction, or one a parent branch holds, counts as supplied, and a column of zeros supplied in any of these ways raises. A reform that neutralizes the column leaves it unsupplied, so the retired hash applies there. New tests: zeros set after construction raise; a branch with zeros raises while its parent keeps its months; a branch reads its parent's column; a neutralizing reform keeps the retired months. A dataset reform's baseline also drops inputs the reform neutralizes, filed as policyengine-core#632. The guard now resolves each name to everything it may refer to where it is used. Bindings in an if, loop, try, with or match, and uses that come before a binding, also reach the next scope out; nested scopes skip enclosing class bodies; global and nonlocal redirect bindings; eager comprehension iterables and same-line statements see the bindings made so far; a walrus in a comprehension binds outside it to the value read inside. Named imports and re-exports are followed across the package, star imports bind __all__ (literal lists and tuples, +, +=, append, extend, insert; anything else exports every name) or the public names, in any analysis order, cycles included. EXTERNAL_EXPORTS replaces STAR_EXPORTS and covers named imports from numpy and core's model_api. Literal dynamic imports read name, package, fromlist and level, positional or keyword. Every reproduction from the r2 review is now found, and the package scan is still clean. Docs and the changelog say a dataset that supplies the column must give every person a month. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The guard resolves a nonlocal name to the nearest enclosing function that makes it local, wherever in that function's body the binding sits, skipping class bodies. It computes each function's locals statically: parameters, names bound outside nested scopes, imports, except and match captures, and walrus targets in its comprehensions, less names a global or nonlocal statement sends elsewhere. Before, a nonlocal went to the nearest enclosing function even when that function doesn't bind the name, which hid a draw on a module imported two functions out. Named-import resolution no longer memoises a result cut short by an import cycle, so a module's findings no longer depend on the order the package is analysed in. A new test runs every analysis order. A new data test pins core's carry rule for the start month: an input applies to its own year and later years, never earlier ones, so a year before the first input reads the default 0 and raises naming the column. Mutation testing kills all 12 guard mutants and all 5 presence mutants. The unmutated runs pass (117 and 19 tests) at 1.0 GB and 2.2 GB peak resident memory. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… deletions - subsample drops calculated start-month defaults before core reloads every value the simulation holds as an input (policyengine-core#633), so a missing column stays missing and a stored one stays stored. - A walrus in a lambda body binds in the lambda; one in a lambda default binds where the comprehension's walrus would. Comprehension loop variables follow Python 3.11. - An attribute of a package module, or of a module EXTERNAL_EXPORTS maps, is what that module binds under the name, and a substituted name is expanded only past what it replaced. `from base import name` binds base.name and leaves the rest to that expansion. - del and the end of an except clause count as deletions, so a builtin can come back into view. - A function body reads the scopes around it as of its def, and every later deletion counts. - A lookup cut by the depth limit is recorded, and the package scan fails if any is. - Tests for each, including order independence where a kept partial result would hide a draw. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Answers GPT-6.1 Sol's review r4 of this PR and item 2 of its review r5 of APPROACH.md. - Subsample on a branch (r4 P2 1). Core's subsample exports every period a holder knows, inherited calculated ones included, and reloads them as inputs. r5 deleted only the simulation's own calculated values first, so a branch whose parent had calculated the default came back with a supplied column of zeros. pe-us now overrides `_get_exportable_input_periods`: a presence-tested input goes out only for the periods it is an input on this branch or one it inherits from. Tests cover a column absent, inherited and set on a branch. - A function's `del` of a global (r4 P2 2). It runs whenever the function is called, so after the def it may come between any binding and any use; the guard now treats it that way. - Unfinished bindings (r4 P3 3). A class body, a decorated function and a lambda may run before the statement creating them binds its names, so code inside them no longer takes those bindings as sure. - Annotations of a nested function (APPROACH r5 item 2) count toward the enclosing function's locals, as decorators and defaults already did. - The PEP 709 comment now says comprehensions other than generator expressions (r4 P3 4). - Tests: a stored start-month column with missing values raises naming it; neutralising a stored column on a live simulation raises. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…inty Answers GPT-6.1 Sol's PR review r5 and item 1 of its r6 review of the cross-country approach. Changes only the guard. - An annotation is read as code that may run any time after its statement starts, seeing a class body around it, with every later deletion counting. That covers both ways Python may evaluate it: with its statement (up to 3.13) and whenever the annotations are read (3.14 defers them, PEP 649). Covers function argument and return annotations, methods, and module and class AnnAssigns. - An annotation alone binds no value in a module or class body, so `hash: int` no longer hides the builtin (Sol r5 P2). In a function it still makes the name local. - A star import may bind a name an earlier or removed __all__ held, which an import cycle can see (Sol r5 P3), and is sure to bind only names the final __all__ holds and the module surely binds. A conditional or deleted binding, __all__.remove and a built __all__ now look further out. - A use no longer takes as sure a binding its own statement makes, so a multi-line assignment, default or class base reads the old binding. - A lambda no longer needs its own unfinished span: its body is part of its statement, which the use-statement rule covers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Answers GPT-6.1 Sol's PR review r6 and items 2-3 of its r7 review of the cross-country approach. Changes the guard and one comment in spm.py. - A global or nonlocal statement now applies in the code nested in its scope, as CPython passes declarations down: nested functions, lambdas, comprehensions and class bodies, and a class body's annotations, but not code nested in a class body. r7's annotation scope had dropped an enclosing global (Sol r6 P2); a nested body, lambda, comprehension or class body under a function's global missed it too. - A star import in an import cycle may see its module part-run, before it binds a name or sets __all__ (Sol r6 P3). Where the module, while it runs, may import the importing one, the star import may bind any public name the module binds and binds none surely. Cycles follow every import in a module (even in a function), literal dynamic imports and the packages an import starts; the packages around a module have started before it. No star import in pe-us sits in such a cycle. - A later-line assignment target is tested, so a statement span cut to its first line fails (Sol r6 P3). - Code Python runs when it is used is read as of then, with any later deletion counting: a generator expression's body, and (PEP 695, 3.12+) type-parameter bounds and defaults and type alias values, read like annotations. A type statement binds its alias's name, and a walrus in a generator expression binds around it. Each case is tested on the Python versions whose syntax it needs. - The docstring says the own-statement rule also reports a walrus earlier in the same statement, and a type parameter's own name is not taken as bound; both err toward reporting. - A private name that only an earlier __all__ lists is tested in a cycle; r8's cycle rule exports every public name, so the earlier-__all__ test no longer protected that path. - spm.py: the subsample override's comment says which cores it is for. Core before 3.33.1 exports calculated values from subsample (policyengine-core#633); from 3.33.1 subsample rebuilds from recorded inputs and doesn't call the override (policyengine-core#573). pe-us locks core 3.32.27 and policyengine.py bundles 3.33.3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Answers review r7 of this PR and items P2-2 to P2-4 and P3-7 of review r8 of the cross-country approach. Opus 5.5 ran both, since no Codex lane was free. Changes the guard, one comment in spm.py and pr.yaml. - A class body that binds a name anywhere (an annotation alone or a del counts) reads it from its namespace, then the module and builtins, never from a function around it. The guard fell through to the enclosing function and took its binding as sure, so a reform-factory class that annotates lib read the module's numpy unflagged. What the body evaluates (annotations, method defaults and annotations, type-parameter bounds and type values) now reads the same way. - A walrus that may not run binds nothing surely: in a comprehension's later iterables, conditions and element, after the first operand of and, or or a chained comparison, in a branch of a conditional expression, and in an assert, which python -O drops. - Import cycles also run through the submodules a star import of a package loads (those its __all__ may name, or all of them where __all__ is built otherwise) and through any call whose literal arguments name a module as a dynamic import would, under any name. No star import in pe-us sits in a cycle. - pr.yaml: the lint job, which installs the locked core, also runs the start-month tests. Every test job resolves the latest compatible core, where subsample never reaches spm.py's export override. - spm.py: the override's comment says that from 3.33.1 it changes only to_input_dataframe and to_input_dict with include_computed_variables=True. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A literal __import__ whose fromlist holds "*" (a list, tuple, set or string; positional or keyword; relative or under another name) loads what a static star import of that package would, so its submodules are cycle edges. The packages around a module have run before it, and so have the modules they imported as they ran, but the module's code may call their functions: imports in their functions, lambdas, generator expressions, annotations, type parameters and type values are cycle edges too. Imports are split by when they may run to tell the two apart. In pe-us, five lazy imports in four functions (system.py, data/ economic_assumptions.py, tools/per_capita_uprating.py) now put 156 of 6,829 star imports in a possible cycle; that only widens what they may bind, and the package scan's counts are unchanged. The module docstring names changed package search paths and import hooks among the limits. Answers Sol's PR review r8 (findings 1 and 2) and Sol's APPROACH review r9 P2-2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…obals Another module may have loaded a module, so code can reach it as an attribute of its package without importing it, and call its functions. Each name, attribute chain, getattr with a literal name, sys.modules read with a literal key, and literal dynamic import now names the paths its first name may: what the module's imports and assignments bind it to (to a fixpoint, so x = x.sub resolves), the submodules a star import of a package binds, and in a package's __init__ its own submodules. Each branch of a conditional expression, and or or counts, and so does a walrus. Only paths that may lead to a package module are kept. These paths are cycle edges, split by when they may run. A star import of a package loads each dotted __all__ entry with the packages on the way, and a computed __all__ may name any module below the package. A relative __import__ resolves against the package its globals give, as Python reads them: __package__, else __spec__, else __name__ and __path__. import_module resolves against a literal package or __name__. Anything computed reads as the module's own package, and a missing package imports nothing. In pe-us the package scan is clean, references add no cycle edge, and 156 of 6,829 star imports are still in a possible cycle. The docstring's limits now name bindings other than an import or a plain assignment. Answers Sol's PR review r9 (the P2 and both P3s). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main added tools/social_security_preflight.py (#10400), which checks a stored dataset's SHA-256 before a Social Security reform is scored, as system.py checks the default dataset's. Its hashlib import and _file_sha256 are exempt like system.py's, so the calls that reach _file_sha256 are no draw helper calls. The package scan of this head is clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main raised pe-us's core floor to 3.33.1 (#10400). From 3.33.1 core's subsample rebuilds from recorded inputs and never calls spm.py's _get_exportable_input_periods override (policyengine-core#573), so the override only changed to_input_dataframe and to_input_dict with include_computed_variables=True, which nothing calls. It goes, with PRESENCE_TESTED_INPUTS. section_911's call passes include_computed_variables=False and is unaffected. The lint job's start-month step ran the tests on the locked core because that core's subsample reached the override. The lock is now 3.33.2, so it no longer does; the PR test job's microsimulation group runs the same file on the latest core. The step goes. The subsample tests stay: a calculated default must still not come back as a supplied column. They pass, with the other start-month tests, on cores 3.33.1, 3.33.2 and 3.33.12. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y wins in globals; dict fromlists
Answers Sol PR review r10's three guard P3s:
- analyse() now passes the module's name to _dynamic_imports, as the
import graph already did, so import_module('..taxsim', __name__) in
policyengine_us.tools.helper names policyengine_us.tools.taxsim for the
offline-import check, not policyengine_us.taxsim.
- A dict literal given as __import__'s globals keeps the last value of a
repeated key, as the dict Python builds does, and only then reads a None
value as absent ({'__package__': 'pkg', '__package__': None, ...} falls
through to __name__).
- A dict literal fromlist is read by its keys, as Python iterates it.
Adds a test that reference paths stay within the known paths and the names
the rules read, which bounds the binding fixpoint.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Answers Sol PR review r11's guard P3: Python iterates an __import__
fromlist, so a literal * entry of a list, tuple or set and a literal **
entry of a dict give their own items in its place. The guard dropped them,
so __import__('numpy', fromlist={**{'random': None}}) went unreported, and
a fromlist of {**{'*': None}} loaded no star submodules in the import graph.
- _iterated() reads a literal fromlist as Python iterates it, recursing
through * and ** entries; computed parts give nothing, as before.
- _dict_entries() reads a dict literal's literal ** dicts in place, so a
globals dict built with one resolves its package as Python does. Only a
computed key or ** entry falls back to the module's own globals; a
constant key that is not a string no longer forces that fallback.
Tests: three stochastic-import rows, three star-load layouts and an empty
** control, and six relative-import table rows, each checked against real
__import__ on CPython 3.11 to 3.14.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Answers Sol PR review r12's P3: the r14 cases unpacked one level only, so
a reader that flattened one level would have passed them. Adds rows whose
unpacked entries nest: fromlist={**{**{'random': None}}} and
fromlist=[*[*('random',)]] report the stochastic import, and three
relative-import table rows ({**{**{'x': 0}}}, [*[*['x']]] and globals
{**{**{'__package__': 'pkg.o'}}}), checked against real __import__ on
CPython 3.11 to 3.14. No code or existing test changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
WIP draft. Part 1 of 2 in moving pe-us's engine-time draws into the data (Max ruled on 2026-10-25 that the rules engines must be deterministic). The cross-country approach is in
~/reviews/deterministic-engine/APPROACH.md; the UK plan for the same move is in the UK hub. Revised after GPT-6.1 Sol reviews r1 (REQUEST_CHANGES: 1 P1, 5 P2, 2 P3), r2 (3 P2, 2 P3), r3 (4 P2, 2 P3), r4 (2 P2, 2 P3), r5 (1 P2, 1 P3) and r6 (1 P2, 2 P3), review r7 (1 P2, 2 P3), review r8 (Sol; approved, with 2 P3), review r9 (Sol; 1 P2, 2 P3), review r10 (Sol; approved, with 4 P3), review r11 (Sol; approved, with 2 P3), review r12 (Sol; approved, with 1 P3), and the cross-country approach's reviews r6 and r7 (Sol; 1 P2 and 2 P2 on this guard), r8 (2 P2 on this guard, 1 on CI) and r9 (Sol; 1 P2 on this guard). Opus 5.5 ran review r7 and approach review r8 on a Subfleet lane, since no Codex lane was free. See "Review" below. r5 mergedupstream/mainat 035a157, which touches none of this PR's files; r6 changesspm.pyand two test files; r7 changes the guard only; r8 changes the guard and one comment inspm.py; r9 changes the guard, rewrites that comment, and adds a CI step topr.yaml; r10 changes the guard only; r11 changes the guard only; r12 mergesupstream/mainat c2785f6, which raises pe-us's core floor to 3.33.1 and its lock to 3.33.2 and adds a Social Security preflight, exempts that preflight's dataset checksum, and drops thesubsampleexport override and the lint step that ran the start-month tests on the locked core; r13 changes the guard only, answering review r10; r14 changes the guard only, answering review r11; r15 adds guard test rows only, answering review r12.Why
is_receiving_unemployment_compensationplaces each person's months of unemployment compensation in the year by hashingperson_id(splitmix64, modulo 12) inside the formula. That is a pseudo-random draw made while the simulation runs. policyengine-core#500 rules that out (a formula is a pure function of its inputs, and seeding does not exempt a draw). core#519's registration-time check catches numpy and stdlib randomness, but a hand-written integer hash passes it.An audit of
upstream/mainat dd7acdb found this to be the only engine-time draw in the package: formulas, reforms, simulation code, data loading, uprating and tools. The widened guard below, run over all 7,142 modules of this head (merged with main at c2785f6), finds no other; earlier heads gave the same over 7,020 modules (main at 035a157) and 7,045 (main at 9ee14cd). Main's new Social Security preflight adds a SHA-256 content check of a stored dataset, which the guard exempts likesystem.py's. The take-up flags are already stored inputs that the data build seeds.What changes
unemployment_compensation_start_month(Person, YEAR, int; 1 = January to 12 = December, 0 = not supplied). The dataset build assigns it to every person and stores it. It is not uprated, so a person keeps one start month in every year.is_receiving_unemployment_compensationcompares the month with the start month. In a data-built simulation (is_over_dataset), a supplied column must give every person a month from 1 to 12 for the year, or aValueErrornames it. So a partial column raises, and so does a column of zeros. A value outside 0–12 raises everywhere.holder.get_input_periods(branch_name)is non-empty. That follows the stored inputs, never the values, and covers a column the dataset stores, one set after construction, and one a parent branch holds. A reform that neutralizes the column when the simulation is built leaves it unsupplied, so the transitional hash applies there. Neutralizing it on a live simulation keeps its input record while core returns the default 0, which the formula refuses, naming the column. A float column with missing values raises too: core refuses NaN in an int input, naming it.subsamplekeeps presence. Core'ssubsamplebefore 3.33.1 exports every value the simulation holds, calculated ones included, and rebuilds from that as inputs (policyengine-core#633). From 3.33.1 it rebuilds from recorded inputs and doesn't call the override below (policyengine-core#573). r12 drops the override: main now requires core 3.33.1 and locks 3.33.2 (Add composable Social Security benefit levers #10400), so no core pe-us admits reaches it, and the lint step that ran the start-month tests on the locked core goes with it.tools/section_911.pycalls_get_exportable_input_periodswithinclude_computed_variables=False, which the override never changed; no released core from 3.33.1 to 3.33.12 passesTrueitself (to_input_dataframeandto_input_dictpass the caller's flag, defaultFalse), and neither pe-us nor the local clones of policyengine.py, policyengine-us-data and microcosm pass it. A caller that does gets back the mode core's docstring calls the historical unsafe behavior (to_input_dataframe): calculated values, the start month's default zeros included, come back as inputs; the formula then raises on the zeros, but calculated outputs exported with them can stand in for formulas on reload, so that mode does not fail closed (review r10). The subsample tests stay, as a check of core'ssubsample, and pass on 3.33.1, 3.33.2 and 3.33.12. What follows describes r6 to r11, when pe-us locked core 3.32.27; policyengine.py bundles 3.33.3. Calculating receipt before subsampling would otherwise turn a dataset without the column into one with a supplied column of zeros, which raises.SPMSimulationMixinoverrides core's_get_exportable_input_periods, whichsubsamplereaches throughto_input_dataframe(include_computed_variables=True): each input inPRESENCE_TESTED_INPUTS(only the start month) goes out only for the periods it is an input where the simulation reads it, on its own branch or one it inherits from (holder.get_input_periods(branch_name)). So a missing column stays missing and a stored one stays stored, on a branch too. r5 instead deleted the simulation's own calculated values before subsampling, which missed a calculated default a branch inherits from its parent. The core defect also makes a reform's rebuilt baseline read values calculated under the reform. core#573 fixed it in 3.33.1 (core#633's reproduction gives the right baseline on 3.33.3 and 3.33.12), so this override can go once pe-us requires core 3.33.1 or later. From 3.33.1 it changes onlyto_input_dataframeandto_input_dictexports withinclude_computed_variables=True, which nothing calls: pe-us's one caller passesFalse, and policyengine.py and Microcosm call neither. pe-us's test jobs resolve the latest core, wheresubsamplenever reaches the override, so the lint job, which installs the locked core, now also runs the start-month tests (pr.yaml).person_idexactly as before. Today that is every Populace US release: pe-us's default (populace-us-2024-spm-20260909) and policyengine.py's pinned bundle (-20260915, plus its ACS local-area release). Part 2 deletes that hash.members_position, the order the situation lists its people): Jan, Feb, Nov, Sep, Sep, Jan, May, May, May, Apr, Jun, Jun. These are the months the hash gave positions 0–11, so households of up to 12 people get the same results, in every axis copy. Members may supply their own start month; the rest keep the table.person_idno longer picks its start month from the id: it gets the same position months as one that does not. Reversed or tied ids change nothing. Supplyunemployment_compensation_start_monthto choose a month. The YAML cases that usedperson_idfor this now supply the month and expect the same outputs.populace-us-2024-spm-20260915, 166,321 people) has 21 households over 12 people. 29 people in them change start month, and every one has zero unemployment compensation and zero weeks, so no result changes (Sol r2's probe,adapter_large_household_probe.log).Microsimulationsubclass fromsituation={}, then declares the dataset's populations and sets every non-id column as an input (src/policyengine/tax_benefit_models/us/model.py:226-236, 497-527at b4158177d). So pe-us sees it as a household situation:is_over_datasetis false andperson_idkeeps its default. This PR leaves its results unchanged, because no release stores the column and the position table equals what the retired formula gave that path (household rank). Two consequences for part 2, now stated in the code's TRANSITIONAL comment and tested here:tests/code_health/test_no_engine_time_randomness.pyparses every module in the package except tests, once per session.xp = np, also in tuples), default arguments, walrus targets,globalandnonlocal, functions, lambdas and comprehensions.nonlocalname binds in the nearest enclosing function that makes it local. Aglobalornonlocalstatement in a function also applies in the code nested in it, unless that code binds the name itself, methods of a class nested in it included. One written in a class body applies to that body and to what the body evaluates (its annotations, its methods' annotations, defaults and decorators, its comprehensions' first iterables and its type-parameter bounds), not to code nested in the class body, such as a method's body. A nested function's decorators and defaults are evaluated in the function around it, so a walrus in either binds there. An annotation is read as code that may run any time after its statement starts, seeing a class body around it, with every later deletion counting. That covers both ways Python may evaluate it: with its statement, as up to 3.13 (a walrus in a function's annotation then binds in the function around it), and whenever the annotations are read, as 3.14 defers them (PEP 649). A generator expression's body runs as it is iterated, so it reads the scopes around it as of the expression, with every later deletion counting, and a walrus in it binds nothing surely; its first iterable runs at once. A type parameter's bound and atypealias's value (PEP 695, 3.12+) and a type parameter's default (PEP 696, 3.13+) are read like annotations, and atypestatement binds its alias's name. pe-us can't use that syntax while it supports 3.11, so this covers future code. An annotation alone (hash: int) binds no value in a module or class body; in a function it still makes the name local. A walrus in a comprehension binds in the function around it; one in a lambda body binds in the lambda. Comprehension loop variables follow Python 3.11: since 3.12 a nestednonlocalof one names a cell only suchnonlocals set, so 3.11's reading covers what either version can read.globalandnonlocalstatements, except what that body evaluates. A module or class body also looks further out when no binding is sure to have run before the use: the bindings are all in anif, loop,try,with,matchorassert(whichpython -Odrops), in a comprehension, after the first operand ofand,oror a chained comparison, or in a branch of a conditional expression, so a walrus there may not run; the use comes first (as in an eager comprehension iterable or a same-line statement); or adelor the end of anexcept … asclause may come between. A class body that binds the name anywhere, an annotation alone or adelincluded, then reads it from the module and builtins, never from a function around the class, as Python's class-body lookup does (LOAD_NAME), and so does what the body evaluates. A function may run as soon as itsdefhas, so only the bindings before thedefhide a builtin from its body, and any later deletion counts. A function'sdelof a name it declaresglobalruns whenever the function is called, so after thedefit may come between any binding and any use. A class body, a decorated function and a lambda may run before the statement that creates them has bound its names (a decorator may call the function, an assignment's value may call a lambda in it), so code inside them takes none of that statement's bindings as sure. Nor does a use take as sure a binding its own statement makes (a multi-line assignment, a target on a later line, or a default or base on a later line of itsdeforclass): Python makes those after evaluating it, and treating a walrus earlier in the statement the same way errs toward reporting.from policyengine_us.model_api import np as lib), as are chains of re-exported helpers. An attribute of a package module, or of numpy or core'smodel_api, is what that module binds under the name (api.np.randomwhereapiis core'smodel_api). A substituted name is expanded only past what it replaced, becausea.bmay bindbto a path through the submodulea.b, aspolicyengine_us.reforms.congress.mcdonald_rivetdoes.__all__or, without one, its public names.__all__is read from literal lists and tuples with=,+,+=,append,extend,insertandremove; any other__all__exports every name. A star import may not bind a name an earlier or removed__all__held (an import cycle can see that one), one the module binds only conditionally or deletes, or any name where__all__is built otherwise, so the scan also looks further out. In an import cycle, where the module, while it runs, may import the importing one (through any import in it or in a module it starts, even one in a function; the submodules a star import of a package, or a literal__import__whosefromlist(a list, tuple, set, dict or string, read as Python iterates it, a literal*or**entry's own items in its place) holds*, loads, those its__all__may name (a dotted name too, with the packages on the way, as Python imports the package's name, a dot and the entry), or every module below it where__all__is built otherwise; any call whose literal arguments name a module asimportlib.import_moduleor__import__would read them, under whatever name the call is made, a relative one against the package its arguments give as Python reads them (import_module's literalpackageor the module's__name__;__import__'sglobals: a dict literal's__package__, else__spec__, else__name__with__path__, its literal**dicts read in place and the last of a repeated key counting; anything computed (a key or**entry too) reads as the module's own package, and a call with nothing to resolve against imports nothing); a package an import starts; code the module may call in the packages around it, which have run before it with the modules they imported as they ran, so that an import in a function, lambda, generator expression, annotation, type parameter ortypevalue of theirs counts; or a module it reaches without importing it, as another module may have loaded it, as an attribute of its package: an attribute chain,getattrwith a literal name,sys.moduleswith a literal key, or what a literal dynamic import returns, from a name its imports or assignments bind, to a fixpoint (x = x.subresolves; walruses and each branch of a conditional expression,andororcount), a submodule a star import of a package binds, or in a package's__init__its own submodule), the star import may see it part-run: it may then bind any public name the module binds, and binds none surely. In pe-us, five lazy imports in four functions (Microsimulation.__init__insystem.py, two functions indata/economic_assumptions.pyand one intools/per_capita_uprating.py) put 156 of the 6,940 star imports of package modules at this head (6,829 before main was merged) in a possible cycle, 149 of them ofmodel_api; the modules reached without an import add none. That only widens what those imports may bind, and the package scan's counts are unchanged. Resolution does not depend on the order modules are analysed in, and cycles terminate. A lookup nested deeper thanMAX_RESOLUTION_DEPTH(64) is recorded, and the package scan fails if any is, rather than passing with an unresolved name.EXTERNAL_EXPORTSlists what numpy (random) and core'smodel_api(np,numpy,pd,random) bind, for star and named imports alike.np,numpyandpdare bound wherever nothing else binds them.stochastic import:random,secretsornumpy.random, includingimportlib.import_moduleor__import__with literal arguments, positional or keyword (package,fromlistandleveltoo);random source: a use that resolves into those modules, or touuid.uuid1/uuid4oros.urandom, includinggetattr(np, "random");builtin hash: any use of the unshadowedhash, passed or called;hash library: importing a hashing library;hash use: each use of a hashing library or of pandas' hashing;hash constant: hash-mixer constants (splitmix64, murmur3, FNV, xxHash, PCG);xorshift: operator,^=and numpy-function forms;sampling call;id modulo:%,%=,np.modanddivmodof an id;draw helper call: a use of a top-level package function whose body has a finding EXEMPT does not cover, or that uses such a function. This is a fixed point across modules, following relative imports and star imports.EXEMPT(reviewed non-draws) andRATCHET(draws still to move into the data) count exact occurrences per (file, enclosing function or class, kind). An extra occurrence fails, and so does an entry that allows more than it matches. So a new hash through an exempthashlibimport fails, and so does another call of the transitional hash. EXEMPT holds three SHA-256 checks, the import and its one use in each (the default dataset's content check insystem.py, the build fingerprint, and, from main, the stored dataset's content check intools/social_security_preflight.py, whose callers are then no draw helper calls), and the offline TAXSIM test generator. The offline-import check, which now also sees literal dynamic imports (a relative one resolved against the module's own name, as the import graph resolves it), confirms the model never imports that generator. RATCHET holds the transitional hash and the two calls that reach it (7 occurrences).RATCHET_CEILINGonly falls, and part 2 takes it to zero.eval/exec, imports by computed name, package search paths changed as the code runs (a__path__assignment) or import hooks, code outside the package, values reached through objects or containers (functions one module sets on another, or registers for a later call, included), names bound otherwise than by an import or an assignment to a plain name (unpacking,for,with, parameters), the values calls return, namesmatchpatterns capture, or methods as draw helpers.CLAUDE.mdstates the rule next to the take-up note, with the guard's coverage stated as above.Invariants
person_idgives exactly the retired formula's results, in all 12 months of the base year and of an extended later year.tests/microsimulation/data/test_unemployment_compensation_start_month.py, against a frozen copy of the retired hashtest_every_start_month_and_length_gives_one_wrapping_block(exhaustive over all 156 combinations; the expected block is built by counting forward)test_start_month_set_on_a_branch_is_supplied_there_only,test_branch_reads_its_parents_stored_column,test_reform_that_neutralizes_the_column_leaves_it_unsupplied,test_neutralizing_a_stored_column_on_a_live_simulation_raisessubsamplekeeps presence, on a branch too. After calculating receipt,subsampleleaves a dataset without the column without it (the transitional hash applies) and keeps a stored column stored. On a branch whose parent has calculated, an absent column stays absent, an inherited one is kept, and one set on the branch is kept, and the parent is unchanged.test_subsample_keeps_a_missing_start_month_missing,test_subsample_keeps_a_stored_start_month,test_subsampling_a_branch_keeps_*(3)test_start_month_applies_forward_from_the_year_it_is_settest_population_built_simulation_*tests/code_health/test_no_engine_time_randomness.py(97 positive and 38 negative detector cases, 20 cases of aglobalornonlocalstatement reaching nested code, 11 of code Python runs when it is used, 18 of a class body that binds a name reading it from the module and a reform-factory class, 13 of a walrus that may not run, 14 of star imports in cycles through the submodules a static or dynamic star import loads or an aliased dynamic import, 10 of code the packages around a module may run, 15 of imports split by when they may run, including a function deleting a global after itsdef, bindings in a class body, a decorated function or a lambda not yet complete, walrus targets in a nested function's annotations,nonlocalresolved to the function that binds the name, walrus targets in lambdas inside comprehensions, deletions andexceptcleanup, def-time reads and module attributes; named re-exports, helper chains, a package that rebinds its submodule's name, star exports through five__all__forms, private names, star imports in import cycles binding nothing surely, two import cycles giving the same findings in every analysis order; depth cuts recorded; helper fixed point, reuse, per-scope counting, stale entries, the ceiling, ten import forms for the offline check, and three it resolves against the module's own name; reference paths stay within the known paths)test_engine_sees_the_dataset_person_idsperson_idis supplied (increasing, reversed, tied).is_receiving_unemployment_compensation.yamlcases 14, 15, 17, 18 and 21–24Plan
person.unemployment_compensation_start_month = splitmix64(person_id) mod 12 + 1, hashing each file's own ids as the engine loads them (int32), in both the national release and the ACS local-area release, and in the from-scratch build. Microcosm's stored-input contract certifies a column only against a pe-us release that defines it, so this PR merges first. The column has to ride Microcosm's next certified release: the current parent stores retired inputs that the contract refuses, so a one-column child of it cannot certify. Microcosm's core pin (3.32.5) also has to move to one this engine accepts (≥3.32.27).DEFAULT_DATASETto the national release and deletes the hash, so that a data-built simulation without the column raises. It replacestest_dataset_without_the_column_gives_the_same_resultswith a test that such a dataset raises, emptiesRATCHET, and documents the migration recipe for custom and legacy datasets: store the column as above, from the ids the engine loads. The legacy-file tests intests/microsimulation/test_microsim.pystay: they exercise other workflows, and only the unemployment compensation, SNAP and Medicaid paths that read this variable would raise.Testing
Local, macOS. From r12, model tests run on core 3.33.2 from
uv.lock(main at c2785f6) and on 3.33.1 and 3.33.12 where stated; earlier rounds ran on 3.32.27, the lock then. CI skips drafts and runs when this leaves draft.pytest policyengine_us/tests/code_health/test_no_engine_time_randomness.py): 382 passed in 30.5 s at this head (r15), 1.14 GB peak resident memory, on Python 3.14.7 and core 3.33.2 with the host's load near 70 (364 at r13, 377 at r14). The guard alone (pytest --noconfteston a copy in an empty folder, without the six package tests) gives 376 passed on 3.14.7 and 3.13.9, 374 and 2 skipped on 3.12.14, and 363 and 13 skipped on 3.11.15, where cases that need newer syntax skip. Over the package's 7,142 modules it finds only the exempt and ratcheted sites, with no stale entries, no offline imports and no depth cuts; at r12 a direct package scan took 14.8 s (10.1 s CPU) with the host's load near 45, and at r14 10.0 s (7.5 s user CPU, 0.15 GB). Before main was merged, it found main's newtools/social_security_preflight.py(itshashlibimport, its_file_sha256and the three calls that reach it), which r12 exempts likesystem.py's content check. It puts 156 of 6,940 star imports of package modules in a possible cycle at r15, as at r12, r13 and r14.guard_scope_probe.py,guard_helper_probe_r2.py), rerun against this guard: each of the 12 scope cases, 5 dynamic-import forms, 7 star-export cases, the named model_api re-export and the named dynamic re-export is found. The walrus-in-comprehension false positive and two shadowing controls are not flagged. The helper assertions hold (checksum, reuse, chains, aliases, nested helpers, mutual recursion,system.pyandbuild_metadata.pywith no helpers). Running the six scope cases with numpy's draw patched confirms each does draw under Python's own lookup.run_capped.py --rss-gb 2 --timeout 600, each confirmed by the guard's own violation message. Ones whosepytestkill could have come from a broken package import were re-run against the guard alone. The mutations:hashlibuse insystem.py;xp = np;from numpy import *;np;hash;model_api's star exports;tools/general.py;pd.util.hash_pandas_object;getattr(np, "random");x ^= x >> 30;np.mod(person_id, 12);importlib.import_moduleof the offline module.pytest policyengine_us/tests/microsimulation/data/test_unemployment_compensation_start_month.py): 26 passed at r13 (r14 and r15 change only the guard, which these tests don't load) on each of core 3.33.1 (26.2 s), 3.33.2 (21.0 s) and 3.33.12 (19.3 s), each environment holding only that core, each run printing the version and path of the core it imports (evidence/pr-r13-checks/cores/); on 3.33.2 the mutation run's unmutated pass peaked at 2.5 GB resident memory. r12's runs passed 26 on each core too, but their logs did not record which core ran (review r10, finding 4), hence the rerun. On 3.33.2 with the export override, before r12 removed it, they passed too.uv lock --upgrade-package policyengine-core(77de576), so it tests the latest core the floor admits, 3.33.12 today; until r12 only the lint job installed the lock, 3.32.27. policyengine.py bundles 3.33.3 with pe-us 2.2.1 (pyproject.toml:43). From r12 main requires core 3.33.1 and locks 3.33.2 (Add composable Social Security benefit levers #10400), so every core pe-us admits rebuildssubsamplefrom recorded inputs; r12 drops the export override and the lint step that ran the start-month tests on the locked core, and pe-us's workflow tests (test_pr_runner_budget.py,test_make_test_matches_ci.py,test_pr_workflow_skips_drafts.py,.github/tests/test_release_build.pyandtest_release_lock.py) pass without it (61 passed; 2 opt-in registry probes skip). At r13 (r14 and r15 change only the guard) the start-month tests (above) and this PR's two YAML files pass on each of the three cores, each run printing the core it imports:is_receiving_unemployment_compensation.yaml24 of 24 andsnap_work_requirement_weeks_worked.yaml29 of 29, throughpolicyengine-core testin batches (the file split in order and checked equal to it), since its two reform cases need about 3.3 GB each (evidence/pr-r13-checks/cores/). Correction: r8 said these passed on 3.33.3 and 3.33.12. Those runs logged no core version, and on 27 Oct both environments were found holding 3.32.27 (the 3.33.3 one since 06:17 MST on 26 Oct, before the r8 runs began at 06:25; the 3.33.12 one was reinstalled before its install time was read), so that claim is withdrawn; r13's runs above print the version and path of the core each imports. The guard reads only pe-us's source and a table of whatpolicyengine_core.model_apibinds;model_api.pyandcommons/formulas.pyare byte-identical at 3.32.27, 3.33.1, 3.33.2 and 3.33.12.{**{**{'x': 0}}},[*[*['x']]]andglobalsof{**{**{'__package__': 'pkg.o'}}}) against real__import__.evidence/pr-r14-checks/verify_r14.py, r13's verifier, which reads its cases from the guard's AST): 87 checks a version on Python 3.11.15, 3.12.14, 3.13.9 and 3.14.7, none wrong: r13's 77, r14's three cycle layouts (fromlist={**{'*': None}},fromlist=[*['*']]andglobalsof{**{'__package__': 'pkg.other'}}, each reaching the builtin in some import order), its control (fromlist={**{}}, reaching it in no order and raising in none) and its six table rows against real__import__.evidence/pr-r13-checks/verify_r13.py, r11's verifier plus the offline check's cases): 77 checks a version on Python 3.11.15, 3.12.14, 3.13.9 and 3.14.7, none wrong: r11's 68, r13's two cycle layouts (a repeatedglobalskey and a dictfromlist, each reaching the builtin in some import order), its four table rows against real__import__, and its three offline cases run aspolicyengine_us.tools.helperin a stand-in package (two loadpolicyengine_us.tools.taxsim; the control raises, as Python looks forhelper.taxsim).__all__case, which the guard over-reports by design and no import order reaches), and each row of the relative-import table against real__import__andimport_module, was run under Python 3.11.15, 3.12.14, 3.13.9 and 3.14.7 with the builtin intercepted (verify_r11.py): 68 checks a version, none wrong. A layout that should reach the builtin does so in some import order that runs cleanly; a control reaches it in none and raises in none. One r11 control first raised in every order (a star import of a package whose__all__names a part-run submodule), so it now catches thatAttributeError, as code meeting it would.verify_r10.py, r9's cases and r10's): each flagged case reaches it and each control doesn't, with no exception, and a star-import case reaches it in some import order while a control reaches it in none (55 checks on 3.11, where 2 class cases need newer syntax, and 57 on the others; none wrong). r8's verifier, run against this guard, finds none wrong either (52 to 60 checks a version).evidence/pr-r11-fuzz/,evidence/pr-r13-fuzz/,evidence/pr-r14-fuzz/andevidence/pr-r15-fuzz/in the author's review folder). These programs are single modules, so they exercise name resolution, not the import graph.mutate_r15.py, logging tomutate_r15.login the author's review folder; the count goes here when it finishes), on core 3.33.2, each underrun_capped.py --timeout 900, one at a time at nice 10, behind a gate of load1 under 130, after unmutated runs of both files passed (382 and 26), counted only where pytest reports failed tests: 166 guard mutants under--rss-gb 2(r13's 155, six of them with snippets moved to the code r14 rewrote; 9 for review r11; 2 for review r12) and the 5 presence mutants. Before any runs, each snippet must occur exactly once. r14's run, on the same code, was stopped after 42 kills, none surviving, when r15 added test rows. Those kills carry, because the script first checks that r15's guard differs from r14's only by rows added to test parametrize lists (every other statement and every test body AST-identical) and that the formula and start-month files are byte-identical; the other 129 run at r15. r13's run, on its own code, was stopped after 88 kills, none surviving. r12 retired the 3subsamplemutants with the override.run_capped.py --timeout 900, one at a time at nice 10, after unmutated runs of both files passed, counted only where pytest reports failed tests (mutate_r10.py,mutate_r10-first.logandmutate_r10.login the author's review folder):--rss-gb 2: r9's 88 carried to the r10 code, and 16 for review r8 and approach review r9: a dynamic star import loading nothing; a stringfromlistnaming nothing; code the packages around a module may run starting nothing; only the packages around a module counted as run, not what they imported; what they imported counted as run without its star loads; only the nearest package around a module counted as run; later code's star imports loading nothing; imports the packages around ran with counted as starts; later code starting the packages around a module again; and functions, async functions, lambdas, generator expressions, annotations and type parameters, type aliases, and code inside later code each counted as running with the module;subsamplemutants, under--rss-gb 3.8, unchanged from r9, run on the locked core;ruff formatwraps one test tuple (the syntax trees are equal); the rest ran on the committed file.run_capped.py --timeout 900, one at a time at nice 10, after unmutated runs of both files passed, counted only where pytest reports failed tests (mutate_r9.pyandmutate_r9.login the author's review folder):--rss-gb 2: r8's 65 carried to the r9 code, and 23 for review r7 and approach review r8: a class body that binds a name looking in the functions around it, or in the scope around it; class bodies making no names local; an annotation alone, adel, anexceptname or an import making no name local; comprehension elements and conditions binding surely; parts that may not run binding surely; laterand/oroperands binding surely; the first operand binding conditionally; a chained comparison's third operand binding surely; its second binding conditionally; conditional-expression branches binding surely; its test binding conditionally; anassertbinding surely; star imports loading no submodules; star imports of a built__all__loading none; star imports loading only what a final__all__lists; star loads starting the packages around a module again; relative star bases resolved against no package; dynamic imports counting only under their own names;subsamplemutants, under--rss-gb 3.8, unchanged from r8, run on the locked core;SyntaxError, so treating that part as one that may run binds nothing either way.run_capped.py --timeout 900, one at a time at nice 10, after unmutated runs of both files passed, counted only where pytest reports failed tests (mutate_r8.pyandmutate_r8.login the author's review folder):--rss-gb 2: r7's 44 carried to the r8 code; 12 for Sol's r6 and approach r7 findings: declarations applying only in the use's own scope; a class body's declarations reaching code nested in the class; cyclic star imports binding surely; a cycle exporting no public name__all__lacks; a module's star import of itself not a cycle; cycles through direct imports only; imports in functions not counting; dynamic imports not counting; the packages an import starts not counting; the packages around a module counted as started by it; relative imports resolved against no package; statement spans ending on their first line; and 9 for code Python runs when it is used: generator expressions reading the scopes around them at once; generator expressions read as list comprehensions; a walrus in a generator expression binding in it; function type parameters read nowhere; class type parameters read nowhere; type-parameter defaults read nowhere; type alias values read with their statement; a type alias's type parameters read nowhere; a type alias binding no name;subsamplemutants, under--rss-gb 3.8, unchanged from r7;mutate_r8-first.log, stopped after 52) left two alive: ignoring an earlier__all__, and forgetting earlier exports. r8's cycle rule exports every public name a module binds, so the earlier-__all__test, which used a public name, no longer reached that path. A private name that only an earlier__all__lists now does (importing its module first draws on Python 3.11 to 3.14), and the rerun kills both.run_capped.py --timeout 900after unmutated runs of both files passed, counted only where pytest reports failed tests (mutate_r7.pyandmutate_r7.login the author's review folder):--rss-gb 2: 30 of r6's 31 carried to the r7 code (r6's lambda mutant became equivalent, below), and 14 for Sol's r5 and approach r6 findings and the cases found with them: annotations read only with their statement; deferred annotations not seeing a class body; module and class annotations read only with their statement; a walrus in an annotation binding in the annotation; an annotation alone binding in a module or class; an annotation alone binding nothing in a function; a use taking its own statement's bindings as sure; star imports ignoring an earlier__all__; earlier exports forgotten; star imports surely binding what they may bind; star imports ignoring a module's conditional or deleted binding; a built__all__binding surely; removals from__all__ignored; a removal of a computed name ignored;subsamplemutants, under--rss-gb 3.8, unchanged from r6;mutate_r7-first.log) left two mutants alive, both equivalent, and the code they mutated is gone. Reading an annotation only as deferred changed no outcome, since no binding its own statement makes is sure, so the deferred read covers the read with the statement and is now the only one. A lambda's unfinished span equalled the span of the statement its body is in, which every use now takes as unfinished, so lambdas no longer push one.run_capped.py --timeout 900after unmutated runs of both files passed, counted only where pytest reports failed tests (mutate_r6.pyandmutate_r6.login the author's review folder):--rss-gb 2: r5's 22 carried to the r6 code, and 9 for Sol's r4 and approach r5 findings: a function's global deletion counted at its body line; a later use ignoring a function's global deletion; a deferred use ignoring it; unfinished statements ignored; class bodies taking their own binding as sure; decorated functions taking their own binding as sure; lambdas taking their statement's bindings as sure; return annotations binding nowhere; argument annotations binding nowhere;subsamplemutants, under--rss-gb 3.8(the unmutated run peaks at 2.7 GB; a UK Budget hold caps runs at 4 GB): presence from theinput_variablessnapshot; presence from the default branch only; always supplied; never supplied; a supplied column of zeros accepted;subsampleexporting calculated start-month values (r5's bug);subsampleexporting only the default branch's start month;subsampledropping a supplied start month too.run_capped.py --timeout 900after unmutated runs of both files passed, counted only where pytest reports failed tests (script and logs in the author's review folder):--rss-gb 2, with their snippets moved to the r5 code: conditional bindings treated as sure; a use before a binding not looking further out; nested scopes seeing enclosing class bodies;globalignored; star imports ignoring__all__; dynamic-import keywords ignored; a walrus value evaluated outside its comprehension; results across a cycle cut memoised; function locals looking further out;nonlocalbinding in the nearest function;nonlocalseeing no function locals. r4's named-import mutant went with the eager path it mutated;--rss-gb 2: a walrus in a lambda body put in the function around the comprehension; a walrus in a lambda default bound nowhere; module attributes not followed; external module attributes not followed; a substituted value expanded again from its start (run on the rebind test alone, which fails it in 12 s; it also sends the package scan past the time cap); deletions ignored;exceptcleanup not a deletion; a global deletion kept local; functions reading the scopes around them as of their end; global names not skipping the functions between; depth cuts not recorded;subsamplemutants, under--rss-gb 6, since the unmutated run peaks at 2.7 GB: presence from theinput_variablessnapshot; presence from the default branch only; always supplied; never supplied; a supplied column of zeros accepted;subsamplekeeping calculated start-month defaults;subsampledropping a supplied column too.run_capped.py --timeout 600, after unmutated runs of both files passed:--rss-gb 2: conditional bindings treated as sure; a use before a binding not looking further out; nested scopes seeing enclosing class bodies;globalignored; named imports not followed into package modules; star imports ignoring__all__; dynamic-import keywords ignored; a walrus value evaluated outside its comprehension; results cut short by an import cycle memoised; function locals looking further out;nonlocalbinding in the nearest function;nonlocalseeing no function locals;--rss-gb 6, since the unmutated run peaks at 2.2 GB, above the 2 GB default: presence from theinput_variablessnapshot; presence from the default branch only; always supplied; never supplied; a supplied column of zeros accepted.- 1, every simulation sent down the dataset branch, and a partial column accepted, each killed by the differential tests;person_idinstead of household position, killed by a direct situation probe of the reversed-id case. The YAML runner needs more than the 2 GB cap, so its legs under the cap prove nothing and are not counted.policyengine-core testonis_receiving_unemployment_compensation.yamlandsnap_work_requirement_weeks_worked.yaml): at r13, 24 of 24 and 29 of 29 on each of cores 3.33.1, 3.33.2 and 3.33.12, each run printing the core it imports, the 29 in batches so each reform case runs under 3.8 GB (evidence/pr-r13-checks/cores/). 53 passed at r3 (3a58423) in 47.0 s, 4.0 GB peak resident memory. CI runs them when this leaves draft.ruff format --checkandruff check(ruff 0.15.5, repo rules) are clean.Review
GPT-6.1 Sol r1 at d9c3893 requested changes. Each finding and its resolution:
person_id.hash.GPT-6.1 Sol r2 at ebb4f3a requested changes (3 P2, 2 P3). Each finding and its resolution, at 3a58423:
input_variablessnapshot, so a column of zeros set after construction or on a branch fell back to the hash.from policyengine_us.model_api import np as lib) and re-exported helper chains were missed.try/exceptimports,global.fromlistorpackage.__all__, results depending on analysis order in cycles.Sol r2 also measured the large-household change through the adapter (see "Intended changes").
GPT-6.1 Sol's r3 review of the cross-country approach (
REVIEW-approach-sol-r3.md) found two points in this PR's code, each fixed at 8349f76:nonlocalname went to the nearest enclosing function even when that function doesn't bind it, solib.random.random()on a numpy imported two functions out was missed.nonlocalresolves to the nearest enclosing function that makes the name local, wherever in its body it binds it. Sol's example and a later-binding variant are flagged; a middle function that binds the name takes it.len(periods) > 0: a year before the first input reads core's default 0, which the formula already rejects, so the US check is complete. The new carry test pins this. The UK, whose defaults are legal values, needs a per-period rule (its plan'ssupplied_by).The mutation run also found one gap of its own: a result cut short by an import cycle was memoised, so a module's findings could depend on the order the package was analysed in. Fixed, with a test that runs every analysis order.
GPT-6.1 Sol r3 at 8349f76 requested changes (4 P2, 2 P3). Each finding and its resolution, at 24321c2:
subsampleturns an absent column into a supplied column of zeros, which raises.subsampledrops calculated start-month defaults first (above). Tested with the column absent and stored. Filed the core defect as policyengine-core#633, with a reproduction in which a reform's rebuilt baseline reads the reform's standard deduction.deland the end of anexcept … asclause count as deletions.def, and every later deletion counts.r5's first mutation run left three mutants alive, each now fixed.
from base import nameresolved its binding twice, eagerly and through module attributes, so removing the eager path changed nothing; the import now bindsbase.nameand leaves the rest to module-attribute expansion. Memoising across a cycle cut passed the old cycle test, since under r5's expansion a cut there drops only names already found; a second graph, where a kept partial result would hide numpy from a module reached only through it, fails that mutant. And the rebind test caps the depth at 5 (it needs 4), so an expansion that grows without end fails in seconds, with a recorded cut, instead of running past any time limit.GPT-6.1 Sol r4 at 4d83399 requested changes (2 P2, 2 P3). Each finding and its resolution, at this head:
subsampleon a branch: r5 deleted only the simulation's own calculated values, so a calculated default the branch inherits from its parent was exported and reloaded as a supplied column of zeros.delof a global can run after a module binding that comes later in the source, sodef clear(): global hash; del hash, thenhash = str; clear(); hash("x"), reached the builtin unflagged.defof the outermost function that contains it. Tested with Sol's example, its conditional variant and a nested one; a function deleting its own local still reaches no builtin.@eager def hash,class hash: answer = hash("x"),hash = (lambda value: hash(value))("x")).def hashis still not flagged.Sol's r5 review of the cross-country approach (
REVIEW-approach-sol-r5.md) also found that a walrus in a nested function's annotation binds in the function around it, which the guard missed; fixed, with argument and return cases.GPT-6.1 Sol r5 at af33289 requested changes (1 P2, 1 P3). Each finding and its resolution, at this head:
import numpy as lib, thenclass C:withlib: objectandanswer = lib.random.uniform(), reached numpy unflagged, as didhash: intand thenanswer = hash("x")at module level.__all__, so a module importingpkg.apiduring an import cycle saw an earlier__all__the scan ignored.__all__held, and is sure to bind only names the final one holds. Tested with Sol's example. Checking certainty found three more cases where a star import may not bind a name it names: one the module binds only conditionally, one it deletes, and any name where__all__is built otherwise. Each now looks further out, with tests.Sol's r6 review of the cross-country approach (
REVIEW-approach-sol-r6.md) found that the guard read annotations only with their statement, though Python 3.14 defers them (PEP 649), so a later deletion of a shadowing global, or a class body's names, decides what an annotation reads. Fixed: an annotation is read as code that may run any time after its statement starts, which covers both versions, with deferred cases for functions, methods, classes and modules. Checking it found one more gap: a use took as sure a binding its own multi-line statement makes, as inhash = (withhash("x")on the next line; fixed, with multi-line assignment, default and class-base tests. Each new case was also run under Python 3.13 and 3.14 with the builtin or numpy intercepted, and each flagged case reaches it under at least one of them while each control reaches neither.GPT-6.1 Sol r6 at f8fdd9e requested changes (1 P2, 2 P3), and Sol's r7 review of the cross-country approach (
REVIEW-approach-sol-r7.md) found the same two gaps in the guard (2 P2). Each finding and its resolution, at this head:global. Inouter, which bindshash = str, a class body declaringglobal hashwithy: hash("x")reached the builtin unflagged, as did a function's parameter and return annotations under aglobalin the function around it.globalornonlocalstatement now applies in the code nested in its scope, as CPython passes declarations down: in nested functions, lambdas, comprehensions and class bodies, and in a class body's annotations, but not in code nested in a class body. The approach review found the gap wider than annotations: a nested function body, lambda, comprehension or class body under a function'sglobalmissed the builtin too, at r5 as well. Tested with 14 cases that reach the builtin and 6 that read the outer binding.__all__existed, when every public name is exported (import numpy as lib,from . import user, then__all__ = []), or before a name in the final__all__was bound, which r7 took as sure.__init__, a function and a dynamic import, a module importing itself, and two controls with no cycle. No star import in pe-us sits in a cycle, so the package scan is unchanged.(/hash/) = (/hash("x")/), is tested, and the mutant is in the r8 campaign.While answering these, the author found that code Python runs when it is used, not where it is written, was read where it is written. A generator expression's body runs as it is iterated, so
hash = str,g = (hash(v) for v in "x"),del hash,list(g)calls the builtin, which r7 missed; it is now read as of the expression, like a function after itsdef. A type parameter's bound or default and atypealias's value (PEP 695) are now read like annotations, and atypestatement binds its alias's name. A walrus in a generator expression binds around it. pe-us supports Python 3.11 to 3.14 and so can't use PEP 695 syntax yet; those cases skip on the versions without it.Every new case was also run under Python 3.11.15, 3.12.14, 3.13.9 and 3.14.7 with the builtin intercepted: each flagged case reaches it on each version, and each control reaches it on none, in every import order tried (52 checks on 3.11, 59 on 3.12 and 60 on 3.13 and 3.14, each case on the versions whose syntax it needs). Sol also noted that the own-statement rule reports a walrus earlier in the same statement, which Python binds first; that errs toward reporting, and the docstring now says so, as it does for a type parameter's own name, which the guard doesn't take as bound.
Review r7 at 90eb918, which Opus 5.5 ran since no Codex lane was free, requested changes (1 P2, 2 P3), and the approach's review r8 (
REVIEW-approach-sol-r8.md, also Opus 5.5) found the same gaps (its P2-3 and P2-4). Each finding and its resolution, at this head:LOAD_NAME), but the guard fell through to the enclosing function and took its binding as sure. So in a reform factory that bindslib, a class that annotateslib: objectread the module's numpy unflagged, ashashdid underhash = strin the function. r6 missed it identically.delcounts), lookup goes from it to the module, skipping the functions around it, and so does what the body evaluates: its annotations, its methods' defaults and annotations, and type-parameter bounds andtypevalues in it. Tested with 18 cases, 14 that reach the builtin and 4 that read the function's binding, and the reform-factory example.[(hash := str) for _ in ()]and thenhash('x')reached the builtin unflagged.and,oror a chained comparison, in a branch of a conditional expression, or in anassert, whichpython -Odrops. Tested with 12 cases, 8 that reach the builtin and 4 controls, and theassertcase under both optimisation levels.__all__lists, and a literal dynamic import may be called through an alias.__all__may name, or all of them where__all__is built otherwise, and every call whose literal arguments name a module as a dynamic import would counts as importing it, under any name. Tested with 9 package layouts, 7 that reach the builtin and 2 controls, each analysed in every order. No star import in pe-us sits in a cycle, so the package scan is unchanged.The approach review r8 also found that pe-us's test jobs float to the latest core, where
subsamplenever reaches the export override, so their subsample tests can't catch a regression in it (P2-2). From r9 to r11 the lint job ran the start-month tests on the locked core. r12 drops that step: main's floor of 3.33.1 (#10400) means no core pe-us admits reaches the override, so r12 drops the override too. Which cores pe-us qualifies, and how, is for the US + core hub (APPROACH, Core versions). It also found the override's comment out of date (P3-7): from 3.33.1 the override changes onlyinclude_computed_variables=Trueexports, and the comment said so until r12 removed the override.Review r8 at a3316a1 (Sol) approved this PR with 2 P3, and the approach's review r9 (
REVIEW-approach-sol-r9.md, Sol) found the first of them again as its P2-2, with a second route. Each finding and its resolution, at this head:__import__('pkg.sub', fromlist=['*'])loads whatfrom pkg.sub import *would, the submodules its__all__names included, but only static star imports added those cycle edges. So whenpkg.apimade that call before bindinghash = str,pkg.sub.user's star import ofpkg.apiread the builtin unflagged.__import__whosefromlistholds*(a list, tuple, set or string; positional or keyword; relative, or under another name) loads what that static star import would. Tested with 5 package layouts, 4 that reach the builtin and 1 control (a package without__all__loads no submodule), each analysed in every order.pkgdefiningload()asfrom . import user, andpkg.apicallingload()before bindinghash = str,pkg.user's star import ofpkg.apiread the builtin unflagged.typevalues) count as edges, except imports of the module and the packages around it, which have already started. Imports are split by when they may run to tell the two apart. Tested with 10 package layouts, 8 that reach the builtin (through a helper function, a helper's helper, a nested package, a dynamic star load, a lambda, a generator expression, a method and a star-loaded helper module) and 2 controls, each in every order, and 15 cases of the split. This only adds edges: in pe-us, 156 star imports now sit in a possible cycle, so they bind nothing surely and the scan looks further out, with unchanged counts.pkg.sub.__path__pointed elsewhere) or import hooks can run source under a name the graph doesn't give it.__path__or usessys.modules.Each layout was also run under Python 3.11 to 3.14 with the builtin intercepted (
verify_r10.py), and each reaches it, or not, as its test expects.Review r9 at 692adeb (Sol) requested changes (1 P2, 2 P3). Each finding and its resolution, at this head:
pkg.mainimportingpkg.helperand thenpkg.api, andpkg.apicallingpkg.helper.go()(which importspkg.user) before bindinghash = str,pkg.user's star import ofpkg.apiread the builtin unflagged, in all 120 analysis orders; so did a PEP 562__getattr__onpkg.helper.getattrwith a literal name,sys.modulesread with a literal key ([]orget) and literal dynamic import (the module or, for__import__, its top package) names the paths its first name may: what the module's imports and assignments bind it to, to a fixpoint (sox = x.subresolves), walruses and each branch of a conditional expression,andororincluded; the name itself; the submodules a star import of a package binds; and in a package's__init__its own submodules. Only paths that may lead to a package module are kept, which bounds the fixpoint. These are cycle edges, split by when they may run. Tested with Sol's two reproductions and six more ways to reachpkg.helper(import pkg as p,getattr,sys.modules[...],sys.modules.get,__import__('pkg'), a star import), seven alias and function layouts (an assigned alias, one rebound through itself, a function in an imported module, a package's own bare name, two controls and a plain module's bare name as a third), each in every analysis order, and 19 cases of the split. In pe-us these paths add no edge.__all__entry (['branch.user']) loadspkg.sub.branch.user, as Python imports the package's name, a dot and the entry, but the guard compared only immediate children.__all__may load any module below the package, not only its children. Tested with dynamic and static star loads of a dotted entry (Python loads the module, then fails to bind the dotted name, which the test catches), an intermediate package that imports the user, a computed__all__with a dotted name, and a control naming a sibling. The r10 control that used a computed__all__now reports, by design, and is kept as that over-report's test.__import__was resolved against the module's own package, whateverglobalsit was given.globals: a dict literal's__package__, else__spec__(read as the module's own), else__name__, the package itself where__path__is a key, its parent otherwise, and nothing where none is given, as Python raises.globals()or a computed mapping reads as the module's own.import_moduleresolves against a literalpackageor the module's__name__; any other computed package reads as the module's own, and a missing orNoneone imports nothing. Tested with 18 rows (22 at r13) checked against real__import__andimport_module, and four package layouts (__package__,__name__,__name__with__path__, andimport_module('.user', __package__)) with a control that resolves elsewhere.While answering these, the author found the rebinding gap above (an alias's paths stopped at a recursion cut, so
x = pkg; x = x.a; x.b.f()missedpkg.a.b), and that conditional expressions,and,orand an inline walrus named nothing; both are fixed and tested. r12 then merged main, which raised the core floor to 3.33.1 (#10400): see "subsamplekeeps presence" and the Testing section's correction.Review r10 at e0f3c7b (Sol) approved this PR with 4 P3 and three corrections to the approach document (
REVIEW-pr10447-sol-r10.md). Each, and its resolution at this head (r13, 54da7d0):analyse()didn't pass the module's name to_dynamic_imports, as the import graph did, so inpolicyengine_us.tools.helperthe offline-import check readimport_module('..taxsim', __name__)aspolicyengine_us.taxsimand missed it.analyse()passes it. Tested throughanalyse():import_module('..taxsim', __name__)and__import__('taxsim', {'__name__': __name__}, None, [], 1)reach the offline module, andimport_module('.taxsim', __name__), which Python resolves below the module (not a package), is not flagged; each checked at runtime on 3.11 to 3.14._globals_packagedroppedNonevalues before building the mapping, so with{'__package__': 'pkg', '__package__': None, '__name__': 'pkg.other.mod'}it anchored atpkg, where Python keeps the last value of a repeated key, falls through to__name__and imports frompkg.other.Nonevalue read as absent. Tested with Sol's reproduction as a cycle layout, in every analysis order, and three table rows, each checked against real__import__.fromlistnamed nothing, though Python iterates its keys.**entry named nothing; r14 reads a literal one's keys (review r11, below). Tested with a cycle layout and a table row, both checked at runtime.evidence/pr-r13-checks/cores/).fromlistby value, and one removing the pruning, which a new test kills: reference paths stay within the known paths and the names the rules read.Its three corrections to APPROACH r11 (a presence list r12 removed, a mutation claim made while the run was going, and the core identity of r12's logs) are fixed in APPROACH r12.
Review r11 at 54da7d0 (Sol) approved this PR with 2 P3 (
REVIEW-pr10447-sol-r11.md). It found r10's four P3s, the count corrections and the mutation additions resolved, two of the three APPROACH corrections resolved and the third partly. Each finding, and its resolution at this head (r14, b488b09):**entry of a dictfromlistnamed nothing, though Python iterates the dict it builds:__import__('numpy', fromlist={**{'random': None}})went unreported, andfromlist={**{'*': None}}added no star-load edges.fromlistis read as Python iterates it, a literal*entry of a list, tuple or set and a literal**entry of a dict giving their own items in its place, at any depth; computed parts still give none. Aglobalsdict literal reads its literal**dicts in place too, so only a computed key or**entry falls back to the module's own globals, and a constant key that is not a string no longer forces that. Tested with three stochastic-import rows, three cycle layouts and an empty-**control, and six table rows, each layout and row checked against real__import__on 3.11 to 3.14; 9 new mutants.Review r12 at b488b09 (Sol) approved this PR with 1 P3 (
REVIEW-pr10447-sol-r12.md). It found r11's two P3s and the YAML note resolved, and the mutation note partly, as the run was still going. Its finding, and its resolution at this head (r15, f36d09d):fromlist={**{**{'random': None}}}andfromlist=[*[*('random',)]]report the stochastic import, and three table rows ({**{**{'x': 0}}},[*[*['x']]]andglobalsof{**{**{'__package__': 'pkg.o'}}}) are checked against real__import__on 3.11 to 3.14. Two new mutants unpack one level only, one in each helper. No code changes.Review r13 of this head is next.
axiom: n/a: engine and data plumbing; the SNAP exemption rule (7 CFR 273.7(b)(1)(v)) is unchanged.
🤖 Generated with Claude Code