From 5aca8ea3b7fc2f69ccae865bbec51881ddfbda70 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 09:20:29 -0400 Subject: [PATCH] Stop passing the mortgage interest inputs policyengine-us #9605 deletes Scenario.to_pe_household now leaves out the tax-unit first_home_mortgage_interest and second_home_mortgage_interest inputs (PE_DROPPED_INPUTS). It also passes partnership_se_income as partnership_self_employment_net_earnings (PE_INPUT_RENAMES), which the reference builds had applied outside the builder. Prompts and scenario manifests are unchanged. On policyengine-us 2.15.17, on main 2.37.1 and on main with #9605, every one of the 1,984 references is byte-identical. The two audit modules #9605 names now read person-level home_mortgage_interest. Both reproduce their recorded sweeps byte for byte. The records pinning latest_alt_snap_mortgage_residence.py are regenerated by their own generators: rerun_sweeps.json changes only the module hash, and snap_pathways_20260930.csv regenerates byte-identical. tests/test_engine_situation.py parses every committed manifest's situations against the pinned engine, so the next removed input fails in CI. It also checks that the two inputs are absent, and adds property and differential tests. Co-Authored-By: Claude Opus 5.5 --- .../data/snap_pathways_20260930.csv.meta.json | 4 +- policybench/scenarios.py | 60 +++- reference_audit/2026-09-22/README.md | 2 + .../fixes/r15_snap_mortgage_interest_v2.py | 45 +-- .../latest_alt_snap_mortgage_residence.py | 22 +- .../2026-09-28/verification/rerun_sweeps.json | 2 +- scripts/snap_pathways.py | 10 +- tests/test_engine_situation.py | 327 ++++++++++++++++++ 8 files changed, 425 insertions(+), 47 deletions(-) create mode 100644 tests/test_engine_situation.py diff --git a/notes/data/snap_pathways_20260930.csv.meta.json b/notes/data/snap_pathways_20260930.csv.meta.json index 81d96d10..daa84e6f 100644 --- a/notes/data/snap_pathways_20260930.csv.meta.json +++ b/notes/data/snap_pathways_20260930.csv.meta.json @@ -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", @@ -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" } diff --git a/policybench/scenarios.py b/policybench/scenarios.py index aaeeec30..a665c698 100644 --- a/policybench/scenarios.py +++ b/policybench/scenarios.py @@ -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_", @@ -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": @@ -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]) @@ -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]) @@ -513,14 +556,12 @@ 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)) @@ -528,8 +569,7 @@ def to_pe_household(self) -> dict: "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)) diff --git a/reference_audit/2026-09-22/README.md b/reference_audit/2026-09-22/README.md index c48c2814..1fd1f036 100644 --- a/reference_audit/2026-09-22/README.md +++ b/reference_audit/2026-09-22/README.md @@ -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. diff --git a/reference_audit/2026-09-22/fixes/r15_snap_mortgage_interest_v2.py b/reference_audit/2026-09-22/fixes/r15_snap_mortgage_interest_v2.py index 6d657a75..777eb5e8 100644 --- a/reference_audit/2026-09-22/fixes/r15_snap_mortgage_interest_v2.py +++ b/reference_audit/2026-09-22/fixes/r15_snap_mortgage_interest_v2.py @@ -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. @@ -18,10 +33,8 @@ 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 @@ -29,8 +42,8 @@ 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." ) @@ -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) diff --git a/reference_audit/2026-09-28/fixes/latest_alt_snap_mortgage_residence.py b/reference_audit/2026-09-28/fixes/latest_alt_snap_mortgage_residence.py index 6ae64248..42b1158d 100644 --- a/reference_audit/2026-09-28/fixes/latest_alt_snap_mortgage_residence.py +++ b/reference_audit/2026-09-28/fixes/latest_alt_snap_mortgage_residence.py @@ -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 @@ -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) diff --git a/reference_audit/2026-09-28/verification/rerun_sweeps.json b/reference_audit/2026-09-28/verification/rerun_sweeps.json index 062979aa..51476d3b 100644 --- a/reference_audit/2026-09-28/verification/rerun_sweeps.json +++ b/reference_audit/2026-09-28/verification/rerun_sweeps.json @@ -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": [ { diff --git a/scripts/snap_pathways.py b/scripts/snap_pathways.py index 1fb1ac17..03a4ab65 100644 --- a/scripts/snap_pathways.py +++ b/scripts/snap_pathways.py @@ -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 diff --git a/tests/test_engine_situation.py b/tests/test_engine_situation.py new file mode 100644 index 00000000..07593f6d --- /dev/null +++ b/tests/test_engine_situation.py @@ -0,0 +1,327 @@ +"""The situations PolicyBench sends policyengine-us. + +A situation that sets a variable the engine does not define fails with +SituationParsingError (unknown variable). policyengine-us removes and renames +inputs (#9605 deletes the tax-unit first/second home mortgage interest inputs), +so these tests parse every committed scenario manifest's situations against +the pinned engine: the next removal fails here, not at the next engine bump's +reference build. + +Invariants of ``Scenario.to_pe_household`` (property tests below): +- every manifest input is either dropped for its entity (PE_DROPPED_INPUTS) or + passed once, under its engine name (PE_INPUT_RENAMES), with its value; +- no dropped input reaches the engine; +- building the situation leaves the scenario, and so its prompt, unchanged. +""" + +import copy +import importlib.util +import json +from functools import lru_cache +from pathlib import Path + +import pandas as pd +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from policybench.ground_truth import _build_us_vectorized_situation +from policybench.prompts import describe_household +from policybench.scenarios import ( + PE_DROPPED_INPUTS, + PE_INPUT_RENAMES, + Person, + Scenario, + scenario_from_dict, + scenario_to_dict, +) + +ROOT = Path(__file__).resolve().parents[1] +MANIFESTS = sorted( + { + *ROOT.glob("paper/snapshot/*/us_scenarios.csv"), + *ROOT.glob("paper/snapshot/*/runs/*/scenarios.csv"), + } +) +BENCHMARK_MANIFEST = ROOT / "paper/snapshot/20260501/us_scenarios.csv" +MORTGAGE_INTEREST = ("first_home_mortgage_interest", "second_home_mortgage_interest") +ENTITY_OF_GROUP = { + "people": "person", + "marital_units": "marital_unit", + "tax_units": "tax_unit", + "spm_units": "spm_unit", + "families": "family", + "households": "household", +} +# The audit harness the 2026-09-22 and 2026-09-29 reference builds used +# (reference_audit/2026-09-22/scripts/regen_references.py and +# 2026-09-28/scripts/build_references_latest.py import its build_situation). +AUDIT_SWEEP = ROOT / "reference_audit/2026-09-28/scripts/sweep.py" + + +@lru_cache(maxsize=None) +def _manifest_scenarios(path: Path) -> tuple[Scenario, ...]: + frame = pd.read_csv(path) + scenarios = [scenario_from_dict(json.loads(s)) for s in frame["scenario_json"]] + return tuple(s for s in scenarios if s.country == "us") + + +def _pinned_variables(): + # The system policyengine_us.Simulation parses situations against. + from policyengine_us.system import system + + return system.variables + + +def _carriers() -> list[Scenario]: + return [ + scenario + for scenario in _manifest_scenarios(BENCHMARK_MANIFEST) + if any(key in scenario.tax_unit_inputs for key in MORTGAGE_INTEREST) + ] + + +def test_the_committed_manifests_include_the_benchmark(): + # A glob that matched nothing would pass every check below vacuously. + assert BENCHMARK_MANIFEST in MANIFESTS + assert len(_manifest_scenarios(BENCHMARK_MANIFEST)) == 100 + + +@pytest.mark.parametrize("manifest", MANIFESTS, ids=lambda p: str(p.relative_to(ROOT))) +def test_every_situation_input_is_a_variable_of_the_pinned_engine(manifest): + variables = _pinned_variables() + problems = [] + for scenario in _manifest_scenarios(manifest): + situation = scenario.to_pe_household() + for group, entities in situation.items(): + for entity in entities.values(): + for name in entity: + if name == "members": + continue + variable = variables.get(name) + if variable is None: + problems.append(f"{scenario.id} {group}: {name} is unknown") + elif variable.entity.key != ENTITY_OF_GROUP[group]: + problems.append( + f"{scenario.id} {group}: {name} belongs to " + f"{variable.entity.key}" + ) + assert not problems, ( + "Situations set inputs the pinned policyengine-us does not define. Map a " + "renamed input in PE_INPUT_RENAMES or drop a removed one in " + "PE_DROPPED_INPUTS (policybench/scenarios.py):\n" + "\n".join(problems) + ) + + +@pytest.mark.parametrize("manifest", MANIFESTS, ids=lambda p: str(p.relative_to(ROOT))) +def test_the_pinned_engine_parses_every_situation(manifest): + """The reference build's own path: one vectorized situation, parsed.""" + from policyengine_us import Simulation + + situation, _ = _build_us_vectorized_situation(list(_manifest_scenarios(manifest))) + Simulation(situation=situation) + + +def test_structured_mortgage_interest_never_reaches_the_engine(): + carriers = _carriers() + # The benchmark's 11 households that list them; CT 120 and OK 046 list a + # second home too. + assert sorted(s.id for s in carriers) == [ + "scenario_040", + "scenario_046", + "scenario_049", + "scenario_056", + "scenario_078", + "scenario_081", + "scenario_088", + "scenario_092", + "scenario_110", + "scenario_118", + "scenario_120", + ] + for manifest in MANIFESTS: + for scenario in _manifest_scenarios(manifest): + tax_unit = scenario.to_pe_household()["tax_units"]["tax_unit"] + assert not set(MORTGAGE_INTEREST) & set(tax_unit), scenario.id + for scenario in carriers: + situation = scenario.to_pe_household() + # The premise that makes dropping them inert: the engine's preferred + # person-level input carries the same total. + person_total = sum( + person["home_mortgage_interest"]["2026"] + for person in situation["people"].values() + if "home_mortgage_interest" in person + ) + listed = sum(scenario.tax_unit_inputs.get(k, 0.0) for k in MORTGAGE_INTEREST) + assert person_total == pytest.approx(listed, abs=1e-6), scenario.id + # The per-loan balances stay: the acquisition-debt cap reads them. + tax_unit = situation["tax_units"]["tax_unit"] + for key, value in scenario.tax_unit_inputs.items(): + if key.endswith("_mortgage_balance"): + assert tax_unit[key] == {"2026": value}, (scenario.id, key) + + +def test_the_prompt_still_lists_the_dropped_inputs(): + """The manifests, and so the frozen prompts, keep them.""" + scenario = next(s for s in _carriers() if s.id == "scenario_120") + before = scenario_to_dict(scenario) + prompt = describe_household(scenario) + scenario.to_pe_household() + assert scenario_to_dict(scenario) == before + assert describe_household(scenario) == prompt + assert "- first home mortgage interest: $2,976" in prompt + assert "- second home mortgage interest: $768" in prompt + assert "- home mortgage interest: $3,744" in prompt + + +def test_a_renamed_input_reaches_the_engine_under_its_new_name(): + scenario = next( + s for s in _manifest_scenarios(BENCHMARK_MANIFEST) if s.id == "scenario_064" + ) + head = scenario.to_pe_household()["people"]["head"] + assert "partnership_se_income" not in head + assert head["partnership_self_employment_net_earnings"] == {"2026": 14_000.0} + # The prompt keeps the manifest's name and label. + assert "- self-employment partnership income: $14,000" in describe_household( + scenario + ) + + +def test_an_input_set_directly_and_through_a_rename_is_refused(): + scenario = Scenario( + id="collision", + state="TX", + filing_status="single", + adults=[ + Person( + name="head", + age=40, + employment_income=0.0, + inputs={ + "partnership_se_income": 1.0, + "partnership_self_employment_net_earnings": 2.0, + }, + ) + ], + ) + with pytest.raises(ValueError, match="partnership_self_employment_net_earnings"): + scenario.to_pe_household() + + +def _audit_build_situation(): + spec = importlib.util.spec_from_file_location("audit_sweep", AUDIT_SWEEP) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.build_situation + + +def test_the_audit_harness_builds_the_same_situations(): + """The audit harness's build_situation (to_pe_household plus its own RENAME) + and to_pe_household agree on every committed household: PE_INPUT_RENAMES + covers the harness's RENAME, so audit sweeps on current engines build the + package's situations. (It does not compare with the situations the + 1.755.4-era references were built from, which passed the dropped inputs.)""" + build_situation = _audit_build_situation() + for manifest in MANIFESTS: + for scenario in _manifest_scenarios(manifest): + assert build_situation(scenario) == scenario.to_pe_household(), ( + manifest, + scenario.id, + ) + + +@pytest.mark.slow +def test_dropping_the_structured_interest_moves_no_reference(monkeypatch): + """On the pinned engine every output of the 11 households is the same with + the tax-unit interest inputs passed and without them.""" + import policybench.scenarios as scenarios_module + from policybench.ground_truth import calculate_ground_truth + + meta = json.loads( + ( + ROOT / "paper/snapshot/20260501/runs/" + "us_full_run_20260612_policyengine_4_16_1_populace/" + "reference_outputs.csv.meta.json" + ).read_text() + ) + carriers = _carriers() + dropped = calculate_ground_truth(carriers, programs=meta["programs"], year=2026) + monkeypatch.setattr(scenarios_module, "PE_DROPPED_INPUTS", {}) + assert ( + "first_home_mortgage_interest" + in (carriers[0].to_pe_household()["tax_units"]["tax_unit"]) + ) + passed = calculate_ground_truth(carriers, programs=meta["programs"], year=2026) + pd.testing.assert_frame_equal(dropped, passed, check_exact=True) + + +# Property tests over arbitrary inputs: the dropped and renamed names mixed with +# ordinary ones, on every entity. +_DROPPED_NAMES = sorted({n for names in PE_DROPPED_INPUTS.values() for n in names}) +_NAMES = st.sampled_from( + [ + *_DROPPED_NAMES, + *PE_INPUT_RENAMES, + *PE_INPUT_RENAMES.values(), + "home_mortgage_interest", + "first_home_mortgage_balance", + "real_estate_taxes", + "rent", + ] +) +_VALUES = st.one_of( + st.floats(allow_nan=False, allow_infinity=False, width=32), st.booleans() +) +_INPUTS = st.dictionaries(_NAMES, _VALUES, max_size=6).filter( + # A manifest never lists an input under both names; that case is refused. + lambda inputs: ( + not any( + old in inputs and new in inputs for old, new in PE_INPUT_RENAMES.items() + ) + ) +) + + +def _engine_inputs(entity: str, inputs: dict) -> dict: + dropped = PE_DROPPED_INPUTS.get(entity, frozenset()) + return { + PE_INPUT_RENAMES.get(k, k): {"2026": v} + for k, v in inputs.items() + if k not in dropped + } + + +@settings(max_examples=200, deadline=None) +@given(person=_INPUTS, tax_unit=_INPUTS, spm_unit=_INPUTS, household=_INPUTS) +def test_every_input_is_dropped_or_passed_once_under_its_engine_name( + person, tax_unit, spm_unit, household +): + scenario = Scenario( + id="property", + state="TX", + filing_status="single", + adults=[Person(name="head", age=40, employment_income=0.0, inputs=person)], + tax_unit_inputs=tax_unit, + spm_unit_inputs=spm_unit, + household_inputs=household, + year=2026, + ) + before = copy.deepcopy(scenario_to_dict(scenario)) + situation = scenario.to_pe_household() + assert scenario_to_dict(scenario) == before + assert scenario.to_pe_household() == situation + + entities = { + "person": (situation["people"]["head"], person), + "tax_unit": (situation["tax_units"]["tax_unit"], tax_unit), + "spm_unit": (situation["spm_units"]["spm_unit"], spm_unit), + "household": (situation["households"]["household"], household), + } + for entity, (data, inputs) in entities.items(): + expected = _engine_inputs(entity, inputs) + for name, value in expected.items(): + assert data[name] == value, (entity, name) + for name in PE_DROPPED_INPUTS.get(entity, ()): + assert name not in data, (entity, name) + for old in PE_INPUT_RENAMES: + assert old not in data, (entity, old)