Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions notes/data/snap_pathways_20260930.csv.meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"alt_conventions_r25.py": "0f77fdd44e758581f7e563b0f7b70c4d4359a833b08a4ac45a614c380938775e",
"latest_alt_r02_ira_219g.py": "3e71bab1db867988544e6f87f6dda4dc7c719d51320fbbb7558cd574a0374f6e",
"latest_alt_salt_refund_no_prior_benefit.py": "d8a314093466caf5fe0a226f1060acba2e4364c4b87befbd80639ebf41cc4a2c",
"latest_alt_snap_mortgage_residence.py": "00cd13886a8c3d03c4e84944d35f7e8a1448af26838e8bc0761b914b887c70cd",
"latest_alt_snap_mortgage_residence.py": "5e701f8fa1b31bfeed882a0bc5160faa4ee1d2b5a67974f9d9dc60e6f6638053",
"latest_alt_unlisted_hours_40.py": "5e6538a903112243ad85051ccba26b6133bdfbd5b9e5ba3dfa81ba690c061b32",
"latest_c_ca_hold_2025.py": "f19a47b9583032a0602a31e52f995eee824a427a76e87a9d4257d6682a016fa2",
"latest_c_id_hold_2025.py": "39fb99292691600cd273a1c6ef3c90847453e21e4d7b872603bcaac71727f8a5",
Expand Down Expand Up @@ -484,6 +484,6 @@
}
}
},
"generated_at_utc": "2026-10-05T12:54:30.665476+00:00",
"generated_at_utc": "2026-10-09T12:23:57.014680+00:00",
"script": "scripts/snap_pathways_20260930.py"
}
60 changes: 50 additions & 10 deletions policybench/scenarios.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,35 @@
# also the prompt's rule for unlisted numbers (0).
PE_INPUT_ALIASES = {"hours_worked_last_week": "weekly_hours_worked_before_lsr"}

# Manifest inputs policyengine-us has renamed. The manifests keep the name the
# prompts were rendered from; the situation passes the value under the engine's
# name. policyengine-us 1.728.0 renamed partnership_se_income. Every reference
# build since then has applied the rename outside this builder: v1.1 in a
# patched manifest, later builds in the RENAME map of
# reference_audit/*/scripts/sweep.py.
PE_INPUT_RENAMES = {
"partnership_se_income": "partnership_self_employment_net_earnings",
}

# Manifest inputs the situation leaves out, by entity. policyengine-us #9605
# deletes the tax-unit first_home_mortgage_interest and
# second_home_mortgage_interest inputs. Every household that lists them also
# lists the same interest as the person-level home_mortgage_interest (on the
# two that list a second home, the person value is the sum of both loans).
# From 1.782.1, home_mortgage_interest_tax_unit falls back to the person-level
# input when the tax-unit inputs are absent (from 1.804.1, #9276, it prefers
# it), so leaving them out moves no reference on those engines. 1.620.0 through
# 1.782.0 take the interest only from the tax-unit inputs (on 1.755.4 five
# outputs move without them), so references built on those engines reproduce
# from a commit before this change. The prompts still list the inputs. The
# per-loan balances and origination years stay, since the acquisition-debt cap
# reads them.
PE_DROPPED_INPUTS = {
"tax_unit": frozenset(
{"first_home_mortgage_interest", "second_home_mortgage_interest"}
),
}

EXCLUDED_INPUT_PREFIXES = (
"takes_up_",
"would_",
Expand Down Expand Up @@ -470,6 +499,22 @@ def num_children(self) -> int:
def _yearize(self, value: Any) -> dict[str, Any]:
return {str(self.year): value}

def _pe_inputs(self, entity: str, inputs: dict[str, Any]) -> dict[str, Any]:
"""An entity's manifest inputs, yearized, under the engine's names."""
dropped = PE_DROPPED_INPUTS.get(entity, frozenset())
data: dict[str, Any] = {}
for key, value in inputs.items():
if key in dropped:
continue
name = PE_INPUT_RENAMES.get(key, key)
if name in data:
raise ValueError(
f"Scenario {self.id} sets {name} twice on one {entity}: "
f"directly and through the input it renames."
)
data[name] = self._yearize(value)
return data

def to_pe_household(self) -> dict:
"""Convert to PolicyEngine-US household JSON format."""
if self.country != "us":
Expand All @@ -483,8 +528,7 @@ def to_pe_household(self) -> dict:
"age": self._yearize(person.age),
"employment_income": self._yearize(person.employment_income),
}
for key, value in person.inputs.items():
person_data[key] = self._yearize(value)
person_data.update(self._pe_inputs("person", person.inputs))
for source, target in PE_INPUT_ALIASES.items():
if source in person.inputs and target not in person.inputs:
person_data[target] = self._yearize(person.inputs[source])
Expand All @@ -498,8 +542,7 @@ def to_pe_household(self) -> dict:
"age": self._yearize(person.age),
"employment_income": self._yearize(person.employment_income),
}
for key, value in person.inputs.items():
person_data[key] = self._yearize(value)
person_data.update(self._pe_inputs("person", person.inputs))
for source, target in PE_INPUT_ALIASES.items():
if source in person.inputs and target not in person.inputs:
person_data[target] = self._yearize(person.inputs[source])
Expand All @@ -513,23 +556,20 @@ def to_pe_household(self) -> dict:
tax_unit_data = {
"members": all_names,
}
for key, value in self.tax_unit_inputs.items():
tax_unit_data[key] = self._yearize(value)
tax_unit_data.update(self._pe_inputs("tax_unit", self.tax_unit_inputs))
for key, value in DEFAULT_TAKEUP_INPUTS["tax_unit"].items():
tax_unit_data.setdefault(key, self._yearize(value))

spm_unit_data = {"members": all_names}
for key, value in self.spm_unit_inputs.items():
spm_unit_data[key] = self._yearize(value)
spm_unit_data.update(self._pe_inputs("spm_unit", self.spm_unit_inputs))
for key, value in DEFAULT_TAKEUP_INPUTS["spm_unit"].items():
spm_unit_data.setdefault(key, self._yearize(value))

household_data = {
"members": all_names,
"state_code": self._yearize(self.state),
}
for key, value in self.household_inputs.items():
household_data[key] = self._yearize(value)
household_data.update(self._pe_inputs("household", self.household_inputs))
for key, value in DEFAULT_TAKEUP_INPUTS["household"].items():
household_data.setdefault(key, self._yearize(value))

Expand Down
2 changes: 2 additions & 0 deletions reference_audit/2026-09-22/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ This directory records how the September 22 references and exclusion record were
- this package's builder, `package_audit.py`.

They run against the local results tree and a policyengine-us 1.755.4 environment; their paths are the ones used on 2026-09-22 and 2026-09-23.

They reproduce these records only with policybench at a commit before 2026-10-09 (origin/main 9ce4ade8 or earlier). On 2026-10-09 policybench's situation builder (`Scenario.to_pe_household`) stopped passing the tax-unit `first_home_mortgage_interest` and `second_home_mortgage_interest` inputs, which policyengine-us #9605 deletes. policyengine-us 1.755.4 takes mortgage interest only from those inputs, so with the current builder the unmodified harness moves five of the 1,984 references on 1.755.4: scenario_078 federal and state income tax, and scenario_081, scenario_088 and scenario_120 federal income tax.
- `verification/` holds the independent reports:
- `v1`–`v4`: GPT-6 Astra lanes re-derived each first-pass fix from primary sources and reran its sweep.
- `v5a`, `v5b`: sourced every projected parameter behind a moved output.
Expand Down
45 changes: 23 additions & 22 deletions reference_audit/2026-09-22/fixes/r15_snap_mortgage_interest_v2.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
"""r15 v2: alternate SNAP shelter reading, with tax-unit-local input fallback.
"""r15 v2: alternate SNAP shelter reading of listed mortgage interest.

Classification: unlisted_input, not a proved engine-law defect. The benchmark
prompts list mortgage interest but do not explicitly state that the encumbered
home is the SNAP household's occupied shelter. This module implements the
alternative reading that first-home interest (or person interest where the
tax unit has no populated interest structure) belongs to the occupied home.
alternative reading that the listed home mortgage interest (the person-level
home_mortgage_interest input) belongs to the occupied home.
It does not establish that reading as the unique interpretation of the prompt.

Revised 2026-10-09. The module first read the tax-unit
first_home_mortgage_interest and second_home_mortgage_interest inputs and, for
a tax unit that listed them, counted first-home interest only. policyengine-us
#9605 deletes those inputs, so it now reads only home_mortgage_interest, which
every benchmark household that lists the tax-unit inputs also lists, with the
same total. The two readings differ only where a second home is listed:
scenario_046 and scenario_120, whose person-level total includes the second
loan's 1,358.97 and 768.28. On policyengine-us 1.755.4 the sweep of all 1,984
outputs is byte-identical under both readings and equal to this module's
recorded sweep (triage/sweep/out/r15_snap_mortgage_interest_v2.csv, sha256
63c9120f...),
with situations built as before policybench stopped passing the tax-unit
inputs. 1.755.4 takes the interest amount only from those inputs, so the
current builder cannot reproduce that sweep on 1.755.4.

7 CFR 273.9(d)(6)(ii)(A) allows continuing mortgage charges, including interest,
for occupied shelter. Paragraph (D) also permits certain temporarily vacant
homes; first/second-home labels alone do not resolve those occupancy facts.
Expand All @@ -18,19 +33,17 @@
As in v1, the listed interest is a lower bound of total payments; unlisted
principal is not invented. The SNAP-only increment is max(interest minus
mortgage_payments, 0), preventing double counting of the reported payment.
The first-versus-person fallback now occurs per tax unit before SPM-unit
aggregation. The remainder of the original SNAP shelter formula is unchanged.
No statutory dollar parameter is introduced. All 100 bundle scenarios have
one tax unit, so this correction should leave the benchmark sweep unchanged.
The remainder of the original SNAP shelter formula is unchanged.
No statutory dollar parameter is introduced.
"""

from policyengine_core.reforms import Reform
from policyengine_us.model_api import *

FIX_ID = "r15_snap_mortgage_interest_v2"
DESCRIPTION = (
"Alternative occupied-home reading of listed mortgage interest for SNAP; "
"choose first-home versus person inputs per tax unit before aggregation."
"Alternative occupied-home reading of listed mortgage interest for SNAP, "
"from the person-level home_mortgage_interest input."
)


Expand All @@ -45,19 +58,7 @@ class snap_mortgage_interest_shelter_cost(Variable):
)

def formula(spm_unit, period, parameters):
person = spm_unit.members
head = person("is_tax_unit_head", period)
first = person.tax_unit("first_home_mortgage_interest", period)
second = person.tax_unit("second_home_mortgage_interest", period)
# Choose the input representation separately for each tax unit.
# A populated structure for one tax unit must not suppress another
# tax unit's person-only mortgage interest within the same SPM unit.
interest_by_person = where(
first + second > 0,
head * first,
person("home_mortgage_interest", period),
)
occupied_home_interest = spm_unit.sum(interest_by_person)
occupied_home_interest = add(spm_unit, period, ["home_mortgage_interest"])
mortgage_payments = spm_unit("mortgage_payments", period)
return max_(occupied_home_interest - mortgage_payments, 0)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,16 @@
"Continuing charges for the shelter occupied by the household, ... including mortgage
payments ... and interest on such payments"). Unlisted principal is not invented; only
max(interest - mortgage_payments, 0) is added, so a reported payment is never counted
twice. First-home versus person interest is chosen per tax unit, as in r15 v2.
twice. The interest is the person-level home_mortgage_interest input, as in r15 v2.

Revised 2026-10-09, with r15 v2. The module first counted first-home interest for a tax
unit that listed the tax-unit first/second home interest inputs; policyengine-us #9605
deletes them, and every benchmark household that lists them lists the same total as
home_mortgage_interest. The readings differ only where a second home is listed
(scenario_046 and scenario_120), and no output moves: on 2.15.17 the revised module
reproduces this sweep's recorded CSV byte for byte (verification/rerun_sweeps.json,
csv_sha256 3dea0be8..., from the scenario builder and board of 2026-09-28), as does the
original, and the two give byte-identical sweeps with today's builder too.

Port to 2.15.17. 2.15.17's snap_excess_shelter_expense_deduction differs from 1.755.4 in
two places (diff of the installed files): actual housing costs are multiplied by
Expand Down Expand Up @@ -61,16 +70,7 @@ class snap_mortgage_interest_shelter_cost(Variable):
)

def formula(spm_unit, period, parameters):
person = spm_unit.members
head = person("is_tax_unit_head", period)
first = person.tax_unit("first_home_mortgage_interest", period)
second = person.tax_unit("second_home_mortgage_interest", period)
interest_by_person = where(
first + second > 0,
head * first,
person("home_mortgage_interest", period),
)
occupied_home_interest = spm_unit.sum(interest_by_person)
occupied_home_interest = add(spm_unit, period, ["home_mortgage_interest"])
mortgage_payments = spm_unit("mortgage_payments", period)
return max_(occupied_home_interest - mortgage_payments, 0)

Expand Down
2 changes: 1 addition & 1 deletion reference_audit/2026-09-28/verification/rerun_sweeps.json
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@
"september_22_root_cause": "r15_snap_mortgage_interest",
"baseline": "latest_conventions",
"csv_sha256": "3dea0be87a30b52d8e693fe8bf45fd2339979ec2f340ac342ee7f416eb7114ac",
"module_sha256": "00cd13886a8c3d03c4e84944d35f7e8a1448af26838e8bc0761b914b887c70cd",
"module_sha256": "5e701f8fa1b31bfeed882a0bc5160faa4ee1d2b5a67974f9d9dc60e6f6638053",
"outputs": 1984,
"moves": [
{
Expand Down
10 changes: 9 additions & 1 deletion scripts/snap_pathways.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
"""Recompute SNAP eligibility pathways for the frozen 100-household snapshot."""
"""Recompute SNAP eligibility pathways for the frozen 100-household snapshot.

The committed output (notes/data/snap_pathways_20260901.csv) was built on
policyengine-us 1.723.0. Reproduce it with policybench at a commit before
2026-10-09. Since then the situation builder passes partnership_se_income as
partnership_self_employment_net_earnings, which policyengine-us 1.728.0 added,
and leaves out the tax-unit mortgage interest inputs; engines before 1.782.1
have no person-level fallback for them.
"""

from __future__ import annotations

Expand Down
Loading
Loading