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)