From 064c3c93723c4ba26c37f0fed89858f2f8a370fe Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 11 Sep 2026 22:41:33 -0400 Subject: [PATCH] Make above-the-line deduction sums deterministic Person-level adjusted gross income and the student-loan-interest MAGI built their list of tax-unit deductions from a set difference, so the float32 accumulation order followed the process hash seed and results could differ across runs at the last digits. Sort the unique deduction names instead. This is the fix the accepted canonical candidate wheel carried (the acceptance matrix's seed-invariance rests on it) but which never reached the merged branch; the published 2.0.0 sums in hash-seed order. The regression tests cover both formulas, with and without deductions, in both orders, and a subprocess check across two hash seeds. Co-Authored-By: Claude Fable 5.1 --- changelog.d/ald-deterministic-order.fixed.md | 1 + .../tests/core/test_ald_determinism.py | 183 ++++++++++++++++++ .../student_loan_interest_ald_magi.py | 2 +- .../adjusted_gross_income_person.py | 2 +- 4 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 changelog.d/ald-deterministic-order.fixed.md create mode 100644 policyengine_us/tests/core/test_ald_determinism.py diff --git a/changelog.d/ald-deterministic-order.fixed.md b/changelog.d/ald-deterministic-order.fixed.md new file mode 100644 index 00000000000..4cc1e13d3da --- /dev/null +++ b/changelog.d/ald-deterministic-order.fixed.md @@ -0,0 +1 @@ +Use a stable deduction order for person-level adjusted gross income and student loan interest modified adjusted gross income, preventing process-dependent floating-point results while preserving deduction membership and exclusions. diff --git a/policyengine_us/tests/core/test_ald_determinism.py b/policyengine_us/tests/core/test_ald_determinism.py new file mode 100644 index 00000000000..3ef4ff22cca --- /dev/null +++ b/policyengine_us/tests/core/test_ald_determinism.py @@ -0,0 +1,183 @@ +"""Above-the-line deductions must have stable float32 accumulation order.""" + +import importlib +import json +import os +from pathlib import Path +import subprocess +import sys +from types import SimpleNamespace + +import numpy as np +import pytest + + +AGI_MODULE = ( + "policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income" +) +FORMULAS = { + "adjusted_gross_income_person": f"{AGI_MODULE}.adjusted_gross_income_person", + "student_loan_interest_ald_magi": ( + f"{AGI_MODULE}.above_the_line_deductions.student_loan_interest." + "student_loan_interest_ald_magi" + ), +} +PERSON_ALDS = [ + "self_employment_tax_ald", + "self_employed_health_insurance_ald", + "self_employed_pension_contribution_ald", +] + + +@pytest.mark.parametrize("variable_name", FORMULAS) +@pytest.mark.parametrize("reverse", [False, True]) +@pytest.mark.parametrize("empty", [False, True]) +def test_tax_unit_deductions_are_ordered_unique_and_exclude_person_alds( + monkeypatch, variable_name, reverse, empty +): + module = importlib.import_module(FORMULAS[variable_name]) + deductions = ( + [] + if empty + else [ + "loss_ald", + "educator_expense", + "alimony_expense_ald", + "loss_ald", + *PERSON_ALDS, + "student_loan_interest_ald", + "puerto_rico_income", + "educator_expense", + ] + ) + if reverse: + deductions.reverse() + magi = SimpleNamespace( + person_alds=PERSON_ALDS, + excluded_alds=["student_loan_interest_ald", "puerto_rico_income"], + excluded_gross_income_sources=[], + ) + parameters = SimpleNamespace( + gov=SimpleNamespace( + irs=SimpleNamespace( + gross_income=SimpleNamespace(sources=[]), + ald=SimpleNamespace( + deductions=deductions, + student_loan_interest=SimpleNamespace(magi=magi), + ), + ) + ) + ) + + class Person: + tax_unit = object() + + def __call__(self, name, period): + if name == "is_tax_unit_dependent": + return np.array([False]) + assert name == "irs_gross_income" + return np.array([0], dtype=np.float32) + + class ReachedTaxUnitSum(Exception): + pass + + captured = [] + person = Person() + + def capture_add(entity, period, variables): + if entity is person.tax_unit: + captured.extend(variables) + raise ReachedTaxUnitSum + assert variables == [f"{name}_person" for name in PERSON_ALDS] + return np.array([0], dtype=np.float32) + + monkeypatch.setattr(module, "add", capture_add) + with pytest.raises(ReachedTaxUnitSum): + getattr(module, variable_name).formula(person, 2026, lambda period: parameters) + + if empty: + expected = [] + elif variable_name == "student_loan_interest_ald_magi": + expected = ["alimony_expense_ald", "educator_expense", "loss_ald"] + else: + expected = [ + "alimony_expense_ald", + "educator_expense", + "loss_ald", + "puerto_rico_income", + "student_loan_interest_ald", + ] + assert captured == expected + + +def _household_results(): + """Run real formulas at a float32 rounding boundary, with no dataset.""" + from policyengine_us import Simulation + + simulation = Simulation( + situation={ + "people": {"person": {"age": {2026: 40}}}, + "households": { + "household": { + "members": ["person"], + "state_code": {2026: "MT"}, + "county_fips": {2026: "30031"}, + } + }, + } + ) + irs = simulation.tax_benefit_system.parameters("2026-01-01").gov.irs + # Explicit inputs isolate summation from deduction eligibility and phaseouts. + for name in irs.gross_income.sources: + simulation.set_input(name, 2026, [0]) + for name in irs.ald.deductions: + simulation.set_input(name, 2026, [0]) + for name in PERSON_ALDS: + simulation.set_input(f"{name}_person", 2026, [0]) + inputs = { + "irs_employment_income": 2**25, + "irs_gross_income": 2**25, + "loss_ald": 2**24, + "alimony_expense_ald": 1, + "health_savings_account_ald": 1, + "student_loan_interest_ald_eligible": True, + "is_tax_unit_dependent": False, + "is_tax_unit_head": True, + "is_tax_unit_spouse": False, + "filing_status": "SINGLE", + } + for name, value in inputs.items(): + simulation.set_input(name, 2026, [value]) + results = {} + for name in FORMULAS: + values = simulation.calculate(name, 2026) + results[name] = { + "values": values.tolist(), + "dtype": str(values.dtype), + "bytes": values.tobytes().hex(), + } + return results + + +def test_household_float32_deductions_are_identical_across_hash_seeds(): + results = {} + for seed in (0, 20, 32, 34, 42, 123): + completed = subprocess.run( + [sys.executable, str(Path(__file__).resolve()), "--household-probe"], + env={**os.environ, "PYTHONHASHSEED": str(seed)}, + check=True, + capture_output=True, + text=True, + timeout=180, + ) + results[seed] = json.loads(completed.stdout) + assert all(result == results[0] for result in results.values()), json.dumps(results) + # Both $1 deductions precede the large deduction in the canonical order; + # their sum and the resulting income are exactly representable in float32. + for result in results[0].values(): + assert result["dtype"] == "float32" + assert result["values"] == [16_777_214.0] + + +if __name__ == "__main__" and sys.argv[1:] == ["--household-probe"]: + print(json.dumps(_household_results())) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py index e2e593934bf..d4d6df1142e 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py @@ -30,7 +30,7 @@ def formula(person, period, parameters): person_ald_vars = [f"{ald}_person" for ald in person_alds] ald_sum_person = add(person, period, person_ald_vars) all_alds = p_irs.ald.deductions - other_alds = list( + other_alds = sorted( set(all_alds) - set(person_alds) - set(p_irs.ald.student_loan_interest.magi.excluded_alds) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.py index 6516850b07b..1ca12f01e0a 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.py @@ -21,7 +21,7 @@ def formula(person, period, parameters): ald_sum_person = add(person, period, person_ald_vars) # split other alds evenly between head and spouse all_alds = parameters(period).gov.irs.ald.deductions - other_alds = list(set(all_alds) - set(PERSON_ALDS)) + other_alds = sorted(set(all_alds) - set(PERSON_ALDS)) ald_sum_taxunit = add(person.tax_unit, period, other_alds) is_head = person("is_tax_unit_head", period) is_spouse = person("is_tax_unit_spouse", period)