Skip to content

Store the UI start month as a dataset input instead of hashing person_id - #10447

Draft
MaxGhenis wants to merge 20 commits into
mainfrom
deterministic-ui-start-month
Draft

MaxGhenis wants to merge 20 commits into
mainfrom
deterministic-ui-start-month

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

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 merged upstream/main at 035a157, which touches none of this PR's files; r6 changes spm.py and two test files; r7 changes the guard only; r8 changes the guard and one comment in spm.py; r9 changes the guard, rewrites that comment, and adds a CI step to pr.yaml; r10 changes the guard only; r11 changes the guard only; r12 merges upstream/main at 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 the subsample export 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_compensation places each person's months of unemployment compensation in the year by hashing person_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/main at 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 like system.py's. The take-up flags are already stored inputs that the data build seeds.

What changes

  • New input 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_compensation compares 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 a ValueError names it. So a partial column raises, and so does a column of zeros. A value outside 0–12 raises everywhere.
    • Supplied means the simulation holds the column as an input on its own branch: 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.
    • A dataset reform's baseline also drops inputs the reform neutralizes, which is a core bug outside this PR: policyengine-core#632.
    • subsample keeps presence. Core's subsample before 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.py calls _get_exportable_input_periods with include_computed_variables=False, which the override never changed; no released core from 3.33.1 to 3.33.12 passes True itself (to_input_dataframe and to_input_dict pass the caller's flag, default False), 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's subsample, 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. SPMSimulationMixin overrides core's _get_exportable_input_periods, which subsample reaches through to_input_dataframe(include_computed_variables=True): each input in PRESENCE_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 only to_input_dataframe and to_input_dict exports with include_computed_variables=True, which nothing calls: pe-us's one caller passes False, and policyengine.py and Microcosm call neither. pe-us's test jobs resolve the latest core, where subsample never reaches the override, so the lint job, which installs the locked core, now also runs the start-month tests (pr.yaml).
  • Transitional: a data-built simulation whose dataset does not supply the column at all still hashes person_id exactly 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.
  • Household situations. A situation that supplies no start month uses a fixed 12-entry table indexed by the person's position in their household (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.
  • Intended changes for situations:
    • A situation that supplies person_id no 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. Supply unemployment_compensation_start_month to choose a month. The YAML cases that used person_id for this now supply the month and expect the same outputs.
    • Household positions 12 and later reuse the table from its start; the retired hash gave those positions other months, so households of 13 or more people can change. Through the adapter, the pinned national file (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).
  • policyengine.py's input-table adapter builds a Microsimulation subclass from situation={}, 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-527 at b4158177d). So pe-us sees it as a household situation: is_over_dataset is false and person_id keeps 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:
    • once a release stores the column, the adapter follows the stored months, as a Microsimulation of the same file does, and no longer gives its old household-position months. A child release must therefore be qualified through the adapter as well, with that change measured and accepted;
    • until the adapter declares that its populations come from a dataset, it accepts a partial column (situation semantics) and the part-2 missing-column error cannot reach it.
  • Guard: tests/code_health/test_no_engine_time_randomness.py parses every module in the package except tests, once per session.
    • How it reads names. A name resolves to everything it may refer to where it is used: the targets of every binding that may be the one read, in each scope Python looks in, so the scan errs toward reporting.
      • Bindings come from imports, assignment aliases (xp = np, also in tuples), default arguments, walrus targets, global and nonlocal, functions, lambdas and comprehensions.
      • A nonlocal name binds in the nearest enclosing function that makes it local. A global or nonlocal statement 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 a type alias's value (PEP 695, 3.12+) and a type parameter's default (PEP 696, 3.13+) are read like annotations, and a type statement 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 nested nonlocal of one names a cell only such nonlocals set, so 3.11's reading covers what either version can read.
      • No nested scope sees an enclosing class body's bindings or its global and nonlocal statements, 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 an if, loop, try, with, match or assert (which python -O drops), in a comprehension, after the first operand of and, or or 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 a del or the end of an except … as clause may come between. A class body that binds the name anywhere, an annotation alone or a del included, 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 its def has, so only the bindings before the def hide a builtin from its body, and any later deletion counts. A function's del of a name it declares global runs whenever the function is called, so after the def it 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 its def or class): Python makes those after evaluating it, and treating a walrus earlier in the statement the same way errs toward reporting.
      • Named imports and re-exports are followed across the package (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's model_api, is what that module binds under the name (api.np.random where api is core's model_api). A substituted name is expanded only past what it replaced, because a.b may bind b to a path through the submodule a.b, as policyengine_us.reforms.congress.mcdonald_rivet does.
      • A star import binds a package module's __all__ or, without one, its public names. __all__ is read from literal lists and tuples with =, +, +=, append, extend, insert and remove; 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__ whose fromlist (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 as importlib.import_module or __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 literal package or the module's __name__; __import__'s globals: 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 or type value 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, getattr with a literal name, sys.modules with a literal key, or what a literal dynamic import returns, from a name its imports or assignments bind, to a fixpoint (x = x.sub resolves; walruses and each branch of a conditional expression, and or or count), 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__ in system.py, two functions in data/economic_assumptions.py and one in tools/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 of model_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 than MAX_RESOLUTION_DEPTH (64) is recorded, and the package scan fails if any is, rather than passing with an unresolved name.
      • EXTERNAL_EXPORTS lists what numpy (random) and core's model_api (np, numpy, pd, random) bind, for star and named imports alike. np, numpy and pd are bound wherever nothing else binds them.
    • What it reports:
      • stochastic import: random, secrets or numpy.random, including importlib.import_module or __import__ with literal arguments, positional or keyword (package, fromlist and level too);
      • random source: a use that resolves into those modules, or to uuid.uuid1/uuid4 or os.urandom, including getattr(np, "random");
      • builtin hash: any use of the unshadowed hash, 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.mod and divmod of 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.
    • Allowlists. EXEMPT (reviewed non-draws) and RATCHET (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 exempt hashlib import 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 in system.py, the build fingerprint, and, from main, the stored dataset's content check in tools/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_CEILING only falls, and part 2 takes it to zero.
    • Limits. It is a ratchet over recognised patterns, not a proof that no draw exists. It does not follow 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, names match patterns capture, or methods as draw helpers.
  • CLAUDE.md states the rule next to the take-up note, with the guard's coverage stated as above.

Invariants

Invariant Where it is tested
Neutrality. On one dataset, storing the start month the build derives from person_id gives 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 hash
Neutrality across the transition. A dataset without the column (hash fallback) and the same dataset with it give identical results. same file
Determinism. Two simulations of one dataset agree. same file
Filtering. A dataset cut to a third of its households keeps each person's months of receipt. same file
One draw per person across years. The stored month is carried unchanged into later years. same file
One wrapping block. Every start month (1–12) with every number of months (0–12) gives receipt in exactly that many consecutive calendar months from the start month, wrapping into January. test_every_start_month_and_length_gives_one_wrapping_block (exhaustive over all 156 combinations; the expected block is built by counting forward)
Fail closed on partial data. A partly stored, all-zero, out-of-range or partly missing (NaN) column raises an error naming it, whether the dataset stores it, it is set after construction or it is set on a branch; a complete column set after construction is used. same file
Presence follows inputs, not values. A branch reads its parent's column and gives the parent's results; a branch with its own column of zeros raises while the parent is unaffected; a reform that neutralizes the column at construction gives the transitional hash's results; neutralizing a stored column on a live simulation raises, naming it. 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_raises
subsample keeps presence, on a branch too. After calculating receipt, subsample leaves 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)
Inputs carry forward, never back. A column set for a later year raises in an earlier one, naming the column, since core returns the default 0 there; a column set for the base year gives the stored results in a later year. test_start_month_applies_forward_from_the_year_it_is_set
The adapter path. Built as policyengine.py's input-table adapter builds it, a simulation keeps the retired household-position months without the column and gives a Microsimulation's months with it. test_population_built_simulation_*
No engine-time draws. The package draws nothing outside the reviewed exemptions and the ratchet, counted per scope, including reuse of an exempt import or of a ratcheted helper. tests/code_health/test_no_engine_time_randomness.py (97 positive and 38 negative detector cases, 20 cases of a global or nonlocal statement 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 its def, bindings in a class body, a decorated function or a lambda not yet complete, walrus targets in a nested function's annotations, nonlocal resolved to the function that binds the name, walrus targets in lambdas inside comprehensions, deletions and except cleanup, 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)
The build hashes the ids the engine sees. policyengine-core stores int variables as int32; the test dataset's ids, some near 2**31, reach the engine unchanged. test_engine_sees_the_dataset_person_ids
Situations follow position, not ids. Positions 0–3 start Jan, Feb, Nov and Sep, the same in every axis copy, restarting in each household, whatever person_id is supplied (increasing, reversed, tied). is_receiving_unemployment_compensation.yaml cases 14, 15, 17, 18 and 21–24

Plan

  1. This PR (output-neutral for every dataset and API path that exists today): the input, the stored-month read, the guard.
  2. Microcosm writes 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).
  3. policyengine.py pins those releases (URI, digest, certification) and makes its input-table adapter declare that its populations come from a dataset, qualifying the adapter's change above.
  4. pe-us part 2 pins DEFAULT_DATASET to the national release and deletes the hash, so that a data-built simulation without the column raises. It replaces test_dataset_without_the_column_gives_the_same_results with a test that such a dataset raises, empties RATCHET, 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 in tests/microsimulation/test_microsim.py stay: 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.

  • Guard (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 --noconftest on 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 new tools/social_security_preflight.py (its hashlib import, its _file_sha256 and the three calls that reach it), which r12 exempts like system.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.
  • Sol r2's reproductions (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.py and build_metadata.py with no helpers). Running the six scope cases with numpy's draw patched confirms each does draw under Python's own lookup.
  • Guard mutations at r2 (ebb4f3a), 15 killed of 15, each under run_capped.py --rss-gb 2 --timeout 600, each confirmed by the guard's own violation message. Ones whose pytest kill could have come from a broken package import were re-run against the guard alone. The mutations:
    • new hashlib use in system.py;
    • a second call of the ratcheted hash;
    • xp = np;
    • from numpy import *;
    • a comprehension shadowing np;
    • a class attribute named hash;
    • a cross-module import of the ratcheted helper;
    • a relative import of it;
    • a draw helper reached through model_api's star exports;
    • a draw helper in tools/general.py;
    • pd.util.hash_pandas_object;
    • getattr(np, "random");
    • x ^= x >> 30;
    • np.mod(person_id, 12);
    • a literal importlib.import_module of the offline module.
  • Differential (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.
  • Core versions. Every test job first runs 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 rebuilds subsample from 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.py and test_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.yaml 24 of 24 and snap_work_requirement_weeks_worked.yaml 29 of 29, through policyengine-core test in 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 what policyengine_core.model_api binds; model_api.py and commons/formulas.py are byte-identical at 3.32.27, 3.33.1, 3.33.2 and 3.33.12.
  • Runtime check at r15 (the same verifier on the r15 guard): 90 checks a version, none wrong: r14's 87 and r15's three table rows with entries unpacked two deep ({**{**{'x': 0}}}, [*[*['x']]] and globals of {**{**{'__package__': 'pkg.o'}}}) against real __import__.
  • Runtime check at r14 (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=[*['*']] and globals of {**{'__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__.
  • Runtime check at r13 (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 repeated globals key and a dict fromlist, each reaching the builtin in some import order), its four table rows against real __import__, and its three offline cases run as policyengine_us.tools.helper in a stand-in package (two load policyengine_us.tools.taxsim; the control raises, as Python looks for helper.taxsim).
  • Runtime check at r11. Every package layout r10 and r11 test (the star-load, around-code, reached-without-an-import and alias lists, and the computed-__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__ and import_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 that AttributeError, as code meeting it would.
  • Runtime check at r10. Every new case was run under Python 3.11.15, 3.12.14, 3.13.9 and 3.14.7 with the builtin, or numpy's draw, intercepted (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).
  • Review r7's differential fuzzer (3,000 and 8,000 generated programs, each run on Python 3.11 to 3.14 with the builtin intercepted): 39 programs that reach the builtin went unflagged at r8; at r9, r10, r11, r13, r14 and r15 none do (1,265 and 3,354 reach it on some version), with no guard errors, and r11's, r13's, r14's and r15's verdicts are byte-identical to r10's (evidence/pr-r11-fuzz/, evidence/pr-r13-fuzz/, evidence/pr-r14-fuzz/ and evidence/pr-r15-fuzz/ in the author's review folder). These programs are single modules, so they exercise name resolution, not the import graph.
  • Mutations at r15: running (mutate_r15.py, logging to mutate_r15.log in the author's review folder; the count goes here when it finishes), on core 3.33.2, each under run_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 3 subsample mutants with the override.
  • Mutations at r10, 112 killed of 112, each under 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.log and mutate_r10.log in the author's review folder):
    • 104 of 104 guard mutants, under --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 string fromlist naming 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;
    • 8 of 8 presence and subsample mutants, under --rss-gb 3.8, unchanged from r9, run on the locked core;
    • an app restart stopped the run after 96 mutants, all killed, with the 97th applied; the copy was restored and the run resumed from its log. The first 96 ran on a copy of the guard that differs from the committed file only in how ruff format wraps one test tuple (the syntax trees are equal); the rest ran on the committed file.
  • Mutations at r9, 96 killed of 96, each under 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.py and mutate_r9.log in the author's review folder):
    • 88 of 88 guard mutants, under --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, a del, an except name or an import making no name local; comprehension elements and conditions binding surely; parts that may not run binding surely; later and/or operands 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; an assert binding 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;
    • 8 of 8 presence and subsample mutants, under --rss-gb 3.8, unchanged from r8, run on the locked core;
    • one guard change has no mutant: a walrus in a comprehension's later iterable is a SyntaxError, so treating that part as one that may run binds nothing either way.
  • Mutations at r8, 73 killed of 73, each under 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.py and mutate_r8.log in the author's review folder):
    • 65 of 65 guard mutants, under --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;
    • 8 of 8 presence and subsample mutants, under --rss-gb 3.8, unchanged from r7;
    • a first r8 run (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.
  • Mutations at r7, 52 killed of 52, each under run_capped.py --timeout 900 after unmutated runs of both files passed, counted only where pytest reports failed tests (mutate_r7.py and mutate_r7.log in the author's review folder):
    • 44 of 44 guard mutants, under --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;
    • 8 of 8 presence and subsample mutants, under --rss-gb 3.8, unchanged from r6;
    • a first r7 run (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.
  • Mutations at r6, 39 killed of 39, each under run_capped.py --timeout 900 after unmutated runs of both files passed, counted only where pytest reports failed tests (mutate_r6.py and mutate_r6.log in the author's review folder):
    • 31 of 31 guard mutants, under --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;
    • 8 of 8 presence and subsample mutants, under --rss-gb 3.8 (the unmutated run peaks at 2.7 GB; a UK Budget hold caps runs at 4 GB): presence from the input_variables snapshot; presence from the default branch only; always supplied; never supplied; a supplied column of zeros accepted; subsample exporting calculated start-month values (r5's bug); subsample exporting only the default branch's start month; subsample dropping a supplied start month too.
  • Mutations at r5, 29 killed of 29, each under run_capped.py --timeout 900 after unmutated runs of both files passed, counted only where pytest reports failed tests (script and logs in the author's review folder):
    • 11 of 11 guard mutants carried from r4, under --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; global ignored; 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; nonlocal binding in the nearest function; nonlocal seeing no function locals. r4's named-import mutant went with the eager path it mutated;
    • 11 of 11 guard mutants for Sol r3's findings, under --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; except cleanup 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;
    • 7 of 7 presence and subsample mutants, under --rss-gb 6, since the unmutated run peaks at 2.7 GB: presence from the input_variables snapshot; presence from the default branch only; always supplied; never supplied; a supplied column of zeros accepted; subsample keeping calculated start-month defaults; subsample dropping a supplied column too.
  • Mutations at r4, 17 killed of 17, each under run_capped.py --timeout 600, after unmutated runs of both files passed:
    • 12 of 12 guard mutants, under --rss-gb 2: conditional bindings treated as sure; a use before a binding not looking further out; nested scopes seeing enclosing class bodies; global ignored; 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; nonlocal binding in the nearest function; nonlocal seeing no function locals;
    • 5 of 5 presence mutants, under --rss-gb 6, since the unmutated run peaks at 2.2 GB, above the 2 GB default: presence from the input_variables snapshot; presence from the default branch only; always supplied; never supplied; a supplied column of zeros accepted.
  • Formula mutations at r2, 5 killed of 5, under the same cap:
    • the presence check ignored, the stored month read without - 1, every simulation sent down the dataset branch, and a partial column accepted, each killed by the differential tests;
    • ranking person_id instead 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.
  • YAML (policyengine-core test on is_receiving_unemployment_compensation.yaml and snap_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 --check and ruff check (ruff 0.15.5, repo rules) are clean.

Review

GPT-6.1 Sol r1 at d9c3893 requested changes. Each finding and its resolution:

Finding Resolution
P1 The input-table adapter is a situation path, so a stored column changes its results. Neutral here, since no release stores the column. The change is documented, tested and made a part-2 prerequisite above. The provenance fix belongs to policyengine.py.
P2 The adapter accepts partial columns. Same: a part-2 prerequisite for policyengine.py.
P2 Ranking by person_id. Fixed: household position.
P2 Aliases, star imports, comprehensions, class-scope hash. Fixed.
P2 Reusing an exempt import or a ratcheted helper. Fixed: hash uses and draw-helper calls now count.
P2 Part-2 prerequisites. Expanded to the national and ACS releases, the policyengine.py pins, the migration recipe and the tests to keep.
P3 Dynamic imports. Fixed.
P3 Wording. Fixed.

GPT-6.1 Sol r2 at ebb4f3a requested changes (3 P2, 2 P3). Each finding and its resolution, at 3a58423:

Finding Resolution
P2 Presence came from the input_variables snapshot, so a column of zeros set after construction or on a branch fell back to the hash. Fixed: presence is the holder's input periods on the simulation's branch. Tested after construction, on branches, from a parent, and under a neutralizing reform.
P2 Named re-exports (from policyengine_us.model_api import np as lib) and re-exported helper chains were missed. Fixed: named imports are followed across the package.
P2 Scope: eager comprehension iterables, nested class bodies, same-line ordering, try/except imports, global. Fixed: may-refer-to resolution (above).
P3 Dynamic imports by keyword, fromlist or package. Fixed.
P3 Star exports: tuple aliases, built __all__, results depending on analysis order in cycles. Fixed.

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:

Finding Resolution
P2 (item 8) A nonlocal name went to the nearest enclosing function even when that function doesn't bind it, so lib.random.random() on a numpy imported two functions out was missed. Fixed: nonlocal resolves 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.
P2 (item 5) Presence is a list of input periods, not proof of coverage, and core doesn't carry an input backwards. pe-us's rule stays 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's supplied_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:

Finding Resolution
P2 Calculating receipt before subsample turns an absent column into a supplied column of zeros, which raises. Fixed: subsample drops 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.
P2 The function-local collector crosses a nested lambda scope, so a walrus in a lambda body was put in the enclosing function. Fixed: a lambda body is its own scope, apart from its defaults.
P2 Namespace attributes hide named re-exports and reuse of draw helpers. Fixed: module attributes resolve to what the module binds, for package modules and mapped external ones.
P2 Deleting a module or class shadow never brings the builtin back. Fixed: del and the end of an except … as clause count as deletions.
P3 The resolver assumes a function can't run before its enclosing scope finishes. Fixed: a function body reads the scopes around it as of its def, and every later deletion counts.
P3 Depth exhaustion silently loses sources and can make findings depend on order. Fixed: a cut lookup is recorded, and the package scan fails if any is.

r5's first mutation run left three mutants alive, each now fixed. from base import name resolved its binding twice, eagerly and through module attributes, so removing the eager path changed nothing; the import now binds base.name and 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:

Finding Resolution
P2 subsample on 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. Fixed: pe-us exports a presence-tested input only for its input periods on the branch or one it inherits from (above). r5's code fails the new inherited-default test; this head passes it and two more branch cases.
P2 A function's del of a global can run after a module binding that comes later in the source, so def clear(): global hash; del hash, then hash = str; clear(); hash("x"), reached the builtin unflagged. Fixed: such a deletion may come between any binding and any use after the def of 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.
P3 Code inside a decorated function, a class body or a lambda took the binding of the statement that creates it as sure, though it may run first (@eager def hash, class hash: answer = hash("x"), hash = (lambda value: hash(value))("x")). Fixed: code inside them takes none of that statement's bindings as sure. Tested with Sol's three examples; an undecorated recursive def hash is still not flagged.
P3 The PEP 709 comment covered generator expressions, which are not inlined. Fixed.

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:

Finding Resolution
P2 An annotation alone in a module or class body counted as a binding, so import numpy as lib, then class C: with lib: object and answer = lib.random.uniform(), reached numpy unflagged, as did hash: int and then answer = hash("x") at module level. Fixed: an annotation alone binds no value in a module or class body; in a function it still makes the name local. Tested with both examples, an annotation with a value, and a function-local annotation.
P3 A star import read only the final __all__, so a module importing pkg.api during an import cycle saw an earlier __all__ the scan ignored. Fixed: a star import may bind a name any earlier or removed __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 in hash = ( with hash("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:

Finding Resolution
P2 The annotation scope r7 added dropped an enclosing global. In outer, which binds hash = str, a class body declaring global hash with y: hash("x") reached the builtin unflagged, as did a function's parameter and return annotations under a global in the function around it. Fixed: a global or nonlocal statement 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's global missed the builtin too, at r5 as well. Tested with 14 cases that reach the builtin and 6 that read the outer binding.
P3 In an import cycle a star import could see its module before __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. Fixed: 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, so importing them starts nothing. Tested with Sol's two examples, the approach review's, cycles through another module, a package's __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.
P3 No test protected a statement's span past its first line: a mutant ending each span on its first line survived all 176 tests. Fixed: a later-line target, ( / 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 its def. A type parameter's bound or default and a type alias's value (PEP 695) are now read like annotations, and a type statement 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:

Finding Resolution
P2 A class body that binds a name reads it from its own namespace, then the module and builtins, never from a function around it (LOAD_NAME), but the guard fell through to the enclosing function and took its binding as sure. So in a reform factory that binds lib, a class that annotates lib: object read the module's numpy unflagged, as hash did under hash = str in the function. r6 missed it identically. Fixed: where a class body binds the name anywhere (an annotation alone or a del counts), 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 and type values in it. Tested with 18 cases, 14 that reach the builtin and 4 that read the function's binding, and the reform-factory example.
P3 A walrus in a comprehension or generator body was taken as a sure binding, though the body may run zero times, or only when iterated: [(hash := str) for _ in ()] and then hash('x') reached the builtin unflagged. Fixed: a walrus 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, or in an assert, which python -O drops. Tested with 12 cases, 8 that reach the builtin and 4 controls, and the assert case under both optimisation levels.
P3 The cycle check missed two routes by which a module starts the importer: a star import of a package loads the submodules its __all__ lists, and a literal dynamic import may be called through an alias. Fixed: a star import's edges include the submodules its target's __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 subsample never 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 only include_computed_variables=True exports, 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:

Finding Resolution
P3 (review r8, item 1; approach review r9, P2-2) A literal __import__('pkg.sub', fromlist=['*']) loads what from pkg.sub import * would, the submodules its __all__ names included, but only static star imports added those cycle edges. So when pkg.api made that call before binding hash = str, pkg.sub.user's star import of pkg.api read the builtin unflagged. Fixed: a literal __import__ whose fromlist holds * (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.
P2 (approach review r9, P2-2, second route) The packages around a module have run before it, so the import graph left them out, but the module may call their functions. With pkg defining load() as from . import user, and pkg.api calling load() before binding hash = str, pkg.user's star import of pkg.api read the builtin unflagged. Fixed: the packages around a module, and the modules they imported as they ran (with what their star imports load), count as run. The imports in their code that may run later (in functions, lambdas, generator expressions, annotations, type parameters and type values) 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.
P3 (review r8, item 2) Package search paths changed as the code runs (a pkg.sub.__path__ pointed elsewhere) or import hooks can run source under a name the graph doesn't give it. Documented: the guard's limits now name both. No module in pe-us outside its tests assigns __path__ or uses sys.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:

Finding Resolution
P2 A module that another module loaded is an attribute of its package, so code can reach it without importing it and call its functions, but the import graph had no edge for that. With pkg.main importing pkg.helper and then pkg.api, and pkg.api calling pkg.helper.go() (which imports pkg.user) before binding hash = str, pkg.user's star import of pkg.api read the builtin unflagged, in all 120 analysis orders; so did a PEP 562 __getattr__ on pkg.helper. Fixed: each name, attribute chain, getattr with a literal name, sys.modules read with a literal key ([] or get) 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 (so x = x.sub resolves), walruses and each branch of a conditional expression, and or or included; 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 reach pkg.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.
P3 A dotted __all__ entry (['branch.user']) loads pkg.sub.branch.user, as Python imports the package's name, a dot and the entry, but the guard compared only immediate children. Fixed: each entry loads every source module on its path below the package, so the packages on the way count, and a computed __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.
P3 A relative __import__ was resolved against the module's own package, whatever globals it was given. Fixed: it resolves as Python reads 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_module resolves against a literal package or the module's __name__; any other computed package reads as the module's own, and a missing or None one imports nothing. Tested with 18 rows (22 at r13) checked against real __import__ and import_module, and four package layouts (__package__, __name__, __name__ with __path__, and import_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() missed pkg.a.b), and that conditional expressions, and, or and 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 "subsample keeps 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):

Finding Resolution
P3 1 The scan's analyse() didn't pass the module's name to _dynamic_imports, as the import graph did, so in policyengine_us.tools.helper the offline-import check read import_module('..taxsim', __name__) as policyengine_us.taxsim and missed it. Fixed: analyse() passes it. Tested through analyse(): import_module('..taxsim', __name__) and __import__('taxsim', {'__name__': __name__}, None, [], 1) reach the offline module, and import_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.
P3 2 _globals_package dropped None values before building the mapping, so with {'__package__': 'pkg', '__package__': None, '__name__': 'pkg.other.mod'} it anchored at pkg, where Python keeps the last value of a repeated key, falls through to __name__ and imports from pkg.other. Fixed: the mapping is built first, the last of a repeated key winning, and only then does a None value 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__.
P3 3 A dict literal fromlist named nothing, though Python iterates its keys. Fixed: its keys are read. At r13 a ** 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.
P3 4 r12's start-month logs recorded the results but not which core each run imported; filenames alone don't establish it. r13 reruns the start-month tests and both YAML files on each core, each run printing the version and path of the core it imports (evidence/pr-r13-checks/cores/).
The r9 table above said 19 relative-import rows and five alias layouts. Corrected to 18 and seven (22 rows at r13, 28 at r14).
The mutation design left findings 1 to 3, and the pruning that bounds the reference fixpoint, unmutated. r13 adds 6 mutants: one each for findings 1 and 3, two for finding 2, one reading a dict fromlist by 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):

Finding Resolution
P3 1 A literal ** entry of a dict fromlist named nothing, though Python iterates the dict it builds: __import__('numpy', fromlist={**{'random': None}}) went unreported, and fromlist={**{'*': None}} added no star-load edges. Fixed rather than documented. A literal fromlist is 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. A globals dict 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.
P3 2 APPROACH r12's history still said r12's runs log the core each imports. Corrected in APPROACH r13: r12's start-month logs did not record the core; r13's reruns print it.
Noted: the YAML runs were not yet complete; both batches holding a reform case were killed at the 3.8 GB cap. Rerun with each reform case in its own batch: 24 of 24 and 29 of 29 on each of cores 3.33.1, 3.33.2 and 3.33.12, each run printing its core (Testing).
Noted: the r13 mutation run was incomplete, and it carried r11 results by name. Superseded: r14 ran every mutant on its own code, carrying none, until r15 added test rows; r15 carries r14's kills only after checking that the guard gained test rows alone (Testing).

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):

Finding Resolution
P3 The r14 cases unpacked one level only, so a reader that flattened one level would have passed them, and no mutant broke deeper recursion alone. r15 adds rows nested two deep: fromlist={**{**{'random': None}}} and fromlist=[*[*('random',)]] report the stochastic import, and three table rows ({**{**{'x': 0}}}, [*[*['x']]] and globals of {**{**{'__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.
Noted: the mutation run was still going. Its result is under Testing.

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

MaxGhenis and others added 4 commits October 25, 2026 13:57
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>
MaxGhenis and others added 7 commits October 25, 2026 17:01
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>
MaxGhenis and others added 9 commits October 26, 2026 10:37
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

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant