Skip to content

Make two formulas that read stored values independent of calculation order - #10224

Open
MaxGhenis wants to merge 3 commits into
mainfrom
fix-order-dependent-stored-reads
Open

MaxGhenis wants to merge 3 commits into
mainfrom
fix-order-dependent-stored-reads

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Two formulas read a stored value as if it had been entered. A stored value can also be one the simulation calculated, so their results depended on what had been calculated first. A code-reading sweep on a2f63c1235 reported both. Each was reproduced before any change, and only what reproduced is fixed.

Invariant

Order independence. For a simulation built from fixed inputs, the value of each changed variable for a period is the same whatever was calculated before it, and in whatever order. For foreign_earned_income_exclusion without Form 2555 leaf inputs, two more things hold:

  • Bit for bit, the value equals the latest earlier entered amount, uprated straight from its year by the per-capita AGI index.
  • It is held flat before the index starts.

1. foreign_earned_income_exclusion (no-leaf-input path)

Reproduced: float32 drift. The formula uprated from the latest earlier period in holder.get_known_periods(), which includes years the simulation calculated. So 2026 computed after 2025 was uprated from 2025's float32 result, not from the 2024 input.

from policyengine_us import Simulation

def feie():
    return Simulation(situation={
        "people": {"p": {"age": {2024: 40}}},
        "tax_units": {"t": {"members": ["p"],
                            "foreign_earned_income_exclusion": {2024: 10_000}}},
        "households": {"h": {"members": ["p"], "state_code": {2024: "MA"}}},
    })

fresh = feie().calculate("foreign_earned_income_exclusion", 2026)
s = feie()
s.calculate("foreign_earned_income_exclusion", 2025)
after = s.calculate("foreign_earned_income_exclusion", 2026)
2026 asked alone 2026 after 2025
main (a2f63c1235) 11090.326171875 11090.3271484375
this PR 11090.326171875 11090.326171875

The drift persists into later years: 2035 asked after 2025–2034 differed by up to 0.125 on a $1,000,003.30 input. Of six amounts, five differed bitwise for 2026.

Also reproduced: a crash with the same cause. An amount stored only under a branch the simulation cannot read (holder.set_input(period, value, "sibling")) was picked as the source, read back as None, and raised TypeError: unsupported operand type(s) for *: 'NoneType' and 'float'. It now takes the legacy default.

Did not reproduce: anything beyond rounding from Form 2555 leaf inputs. These cases matched bitwise before and after:

  • leaf inputs in 2025 only;
  • legacy 2024 plus leaves in 2025, with 2026 asked after 2024 or after 2025;
  • legacy 2024 plus leaves in 2027;
  • two legacy inputs (2022 and 2024).

has_section_911_leaf_inputs counts every leaf input at or before the period, across the whole simulation. So the no-leaf path never runs for a year after a leaf year, and a leaf-path result is never an uprating source. A batch that mixes leaf and legacy coverage across tax units is rejected when it is built (validate_section_911_batch_inputs).

Fix. The formula now uprates as Core does since policyengine-core#563 (released in 3.32.17; pe-us requires ≥ 3.32.27). It uprates from the latest earlier same-unit period in Holder.get_input_periods(branch_name), the periods whose stored value is an input, and breaks ties with Core's _latest_input_key. These behaviours are unchanged:

  • the legacy default: no earlier input → None, so Core's carry-over or default applies;
  • the per-capita index;
  • the held-flat boundaries.

2. medicaid_enrolled_for_ssi_state_supplement

From 2027, the formula returned the stored medicaid_enrolled whenever one existed, whether supplied or calculated. Otherwise it calculated medicaid_enrolled in a private branch, with medicaid_community_engagement_pass_through_eligible forced False, to avoid the SNAP → SSP → Medicaid recursion. A calculated medicaid_enrolled keeps that pass-through.

What reproduced. Each case below compares the value asked alone with the value after medicaid_enrolled (and after snap), using IN SAPN/RCAP and KS SSPP candidates and pass-through non-candidates.

  • Baseline, SNAP calculated by formula: no order dependence. Calculating medicaid_enrolled reaches this variable through pass-through → SNAP → in_ssp, before medicaid_enrolled is stored, so every order uses the branch.

  • Baseline, SNAP or the pass-through supplied as an input:

    • Non-candidates differ. The SNAP and TANF pass-through adults got False asked alone and True after medicaid_enrolled.
    • Candidates do not differ. Their values matched, and so did every consumer: SAPN, RCAP and SSPP eligibility and amounts.

    At baseline, SSI recipients in Indiana (section 1634) and Kansas (SSI criteria) are in the SSI_RECIPIENT category, which the community engagement requirement does not reach. RCAP's aged, blind or disabled candidates are exempt by age (SSI's aged threshold is 65; the requirement covers ages 19–64) or by blindness or disability.

  • A candidate differs under a reform. With Indiana's SSI-recipient Medicaid coverage turned off (gov.hhs.medicaid.eligibility.categories.ssi_recipient.is_covered.IN: false), a 40-year-old institutionalized SSI recipient with no disability finding falls into the ADULT group. With the pass-through supplied, that person's SAPN depended on order:

from policyengine_core.reforms import Reform
from policyengine_us import Simulation

reform = Reform.from_dict({
    "gov.hhs.medicaid.eligibility.categories.ssi_recipient.is_covered.IN":
        {"2000-01-01.2100-12-31": False}}, country_id="us")
situation = {
    "people": {"r": {
        "age": {2027: 40}, "receives_ssi": {2027: True},
        "ssi_lives_in_medical_treatment_facility": {2027: True},
        "ssi_medicaid_pays_majority_of_care": {2027: True},
        "medicaid_community_engagement_pass_through_eligible": {"2027-01": True}}},
    "households": {"h": {"members": ["r"], "state_code": {2027: "IN"}}},
}
first = Simulation(situation=situation, reform=reform)
fresh = first.calculate("in_ssp", "2027-01")
s = Simulation(situation=situation, tax_benefit_system=first.tax_benefit_system)
s.calculate("medicaid_enrolled", 2027)
after = s.calculate("in_ssp", "2027-01")
in_ssp, 2027-01 asked alone after medicaid_enrolled
main 0 22
this PR 0 0

The non-candidate case is the same shape without the reform: an Indiana adult, 40, with $5,000 of earnings and SNAP workfare, and snap supplied. medicaid_enrolled_for_ssi_state_supplement gave False asked alone and True after medicaid_enrolled on main, and False in both orders here.

Fix. medicaid_enrolled is read directly only when it is a supplied input for the period (period in holder.get_input_periods(branch_name)); otherwise the private branch is always used. The recursion guard is unchanged. Every value asked alone is unchanged: in each case the fix only removes the path that read a calculated value. Across these variants, all orders now agree:

  • base;
  • SNAP supplied;
  • pass-through supplied;
  • medicaid_enrolled supplied;
  • the reform with the pass-through supplied;
  • the reform with SNAP supplied.

Supplied enrollment is still honoured, including a set_input after construction (test_ssp_preserves_medicaid_inputs_set_after_initialization).

Tests

Hypothesis is used for both sites, with derandomize=True. Each property has its reproducing case as an explicit @hypothesis.example.

  • tests/test_foreign_earned_income_exclusion_leaves.py
    • Property over entered year (2015–2030), target year, 1–4 float32 amounts, and the years calculated first, in drawn order and sometimes after the target. It asserts:
      • the target is bit-for-bit equal to the value asked alone;
      • the target equals the amounts uprated straight from their year.
    • Example tests:
      • each year uprates from the latest earlier entered year, in either order;
      • a 2012 amount is held flat until the 2015 index start;
      • an amount held only by another branch gives the default, with no TypeError.
  • tests/core/test_ssi_state_supplement_medicaid_dependency.py
    • Property over state (IN/KS), which value on the cycle is supplied (none, snap, pass-through, medicaid_enrolled), and up to four variables calculated first. It asserts the SSP enrollment, eligibility and benefit match a fresh simulation.
    • Example tests:
      • SNAP and TANF pass-through adults drop the exclusion in either order;
      • an SSI recipient in the adult group gets the same SAPN in either order. The test sets is_ssi_recipient_for_medicaid: False, which has the same effect as the reform above, without building a reformed system (about 40 s).

On main (the base formulas with the new tests), 6 of the 13 new tests fail: both properties, the branch TypeError, both pass-through examples after medicaid_enrolled, and the SSI-recipient example after medicaid_enrolled. The order examples pass on main when nothing is calculated first. The latest-input and held-flat examples pass on main; they pin behaviour the fix must keep.

Local runs on this head, one file per process:

  • both changed test files pass: 31 and 22 tests;
  • 14 YAML files pass: IN SSP (medicaid_snap_cycle, in_ssp_sapn_eligible, in_ssp_rcap_eligible, integration, edge_cases), KS SSPP (medicaid_snap_cycle, integration, ks_sspp), snap_unearned_income_state_ssi_supplements, foreign_earned_income_exclusion/integration, section_911_excluded_income, MA foreign_earned_income, refundable_ctc_barred_by_section_911_exclusion, agi_plus_section_911_931_933_exclusions;
  • 12 Python files that touch section 911 pass (88 tests): test_ctc_foreign_earned_income_exclusion, test_ctc_foreign_earned_income_exclusion_property, test_section_911_batch_inputs, test_ma_foreign_earned_income_addback, test_modified_agi_add_backs, test_section_911_tax_stacking, test_form_4952_section_911_interaction, core/test_ald_determinism, contrib/congress/afa/test_afa_section_911, test_niit_section_911_magi, test_capital_gain_rate_group_netting, test_section_1h_regular_tax.

Test cost

These numbers are local macOS runs on a shared host under load 60–165, so wall time is noisy and user CPU is the steadier measure.

File (CI group) Tests User CPU Peak RSS CI on main, for the group
test_foreign_earned_income_exclusion_leaves.py (remaining) 25 → 31 18.4 s → 24.4 s 1.18 → 1.37 GB file 2.9 s; group 15:52 wall, 7.66 GB max RSS
core/test_ssi_state_supplement_medicaid_dependency.py (heavy-a) 15 → 22 117.6 s → 123.7 s 4.72 → 4.63 GB file 157 s; group 5:08 wall, 5.51 GB max RSS (at a2f63c1235)

The added cases build small situations only. The Medicaid file's peak comes from the existing all-states poverty test. No job, matrix entry or group assignment changes.

Impact

Microsimulation impact: pending. The US + core hub runs it.

What to expect, not yet measured:

  • Item 2. At most float32-level changes, and only where foreign_earned_income_exclusion is entered for a year before the one simulated and intermediate years were calculated first.
  • Item 1. Likely none. A search of microcosm's build code finds snap and medicaid_enrolled only as calibration target measures, not as supplied inputs. In the household runs with SNAP calculated by formula, every order already took the branch path.

Overlapping PRs. #10170 and #10164 edit this variable's documentation string, in a hunk separate from this change. #10161 and #10176 add Colorado enrollment helpers that always branch and never read a stored medicaid_enrolled.

axiom: n/a: calculation-order fix, no change to any rule's value

🤖 Generated with Claude Code

MaxGhenis and others added 3 commits October 14, 2026 06:16
…nputs

Without Form 2555 leaf inputs, foreign_earned_income_exclusion uprated from
the latest earlier period in holder.get_known_periods(), which includes years
the simulation had calculated. A 2026 value computed after 2025 was uprated
from the float32-rounded 2025 result instead of the 2024 input, so its bits
depended on calculation order (2024 input 10,000: 11090.326171875 fresh,
11090.3271484375 after 2025).

Mirror Core's uprating (policyengine-core#563, released in 3.32.17): take the
latest earlier same-unit period from Holder.get_input_periods for this branch,
breaking ties with Core's _latest_input_key. The legacy default, carry-over
and held-flat index behaviour are unchanged.

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

From 2027, medicaid_enrolled_for_ssi_state_supplement returned
simulation.get_array("medicaid_enrolled") whenever any value was stored,
calculated or supplied, and otherwise calculated enrollment in a private
branch with the SNAP/TANF community-engagement pass-through forced False.
A calculated medicaid_enrolled keeps the pass-through, so the result
depended on whether medicaid_enrolled had been calculated first. With SNAP
or the pass-through supplied (so the SNAP -> SSP cycle does not compute this
variable on the way), a pass-through adult got False fresh and True after
medicaid_enrolled. Under a reform that removes Indiana's SSI-recipient
Medicaid coverage, a working-age reported SSI recipient in a medical
facility falls into the adult group, and Indiana SAPN changed from $0 fresh
to $22 after medicaid_enrolled.

Read medicaid_enrolled directly only when it is a supplied input this branch
reads (Holder.get_input_periods); otherwise always use the private branch.
The recursion guard is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
foreign_earned_income_exclusion: a Hypothesis property over entered year,
target year, amounts and the years calculated first asserts the target is
bit-for-bit the same as asked alone and equals the entered amount uprated
straight from its year; the 2024 -> 2026-after-2025 repro is an explicit
@example. Example tests pin the latest-entered-year source, the held-flat
index before 2015, and the default when only an unreadable branch holds an
amount (previously a TypeError).

medicaid_enrolled_for_ssi_state_supplement: a Hypothesis property over state,
which value on the SNAP-SSP-Medicaid cycle is supplied, and the variables
calculated first asserts the SSP enrollment, eligibility and benefit match a
fresh simulation; the SNAP-supplied, medicaid_enrolled-first repro is an
explicit @example. Example tests cover SNAP and TANF pass-through adults and
an SSI recipient placed in the adult group, whose SAPN was $22 or $0
depending on order.

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

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