From 666eaf3a913f406c0b390551bce11d356852f336 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 24 Sep 2026 08:27:33 -0400 Subject: [PATCH 1/9] Honor dataset-supplied tax-unit roles and filing status Populace's tax-unit constructor (microunit) writes each person's role (tax_unit_role_input: HEAD, SPOUSE, DEPENDENT) and each unit's filing status (filing_status_input) into the certified build, but no variable read them, so the loader dropped both and the role variables fell back to age ordering. Add the two input variables under the producer's exact column and member names, with an UNSPECIFIED default, and make is_tax_unit_head, is_tax_unit_spouse and filing_status defer to them when supplied. Malformed supplied values raise instead of falling back. Co-Authored-By: Claude Opus 5.5 --- changelog.d/source-tax-unit-roles.added.md | 1 + docs/usage/microsimulation.md | 25 +++ .../remove_head_of_household.py | 3 + .../tests/core/test_tax_unit_role_input.py | 177 ++++++++++++++++++ .../tests/microsimulation/test_microsim.py | 51 +++++ .../tax_unit/filing_status_input.yaml | 123 ++++++++++++ .../tax_unit/tax_unit_role_input.yaml | 142 ++++++++++++++ .../remove_head_of_household.yaml | 19 ++ .../demographic/tax_unit/filing_status.py | 22 ++- .../tax_unit/filing_status_input.py | 20 ++ .../demographic/tax_unit/is_tax_unit_head.py | 10 +- .../tax_unit/is_tax_unit_spouse.py | 12 +- .../tax_unit/tax_unit_role_input.py | 18 ++ .../tax_unit/tax_unit_roles_supplied.py | 32 ++++ 14 files changed, 646 insertions(+), 9 deletions(-) create mode 100644 changelog.d/source-tax-unit-roles.added.md create mode 100644 policyengine_us/tests/core/test_tax_unit_role_input.py create mode 100644 policyengine_us/tests/policy/baseline/household/demographic/tax_unit/filing_status_input.yaml create mode 100644 policyengine_us/tests/policy/baseline/household/demographic/tax_unit/tax_unit_role_input.yaml create mode 100644 policyengine_us/variables/household/demographic/tax_unit/filing_status_input.py create mode 100644 policyengine_us/variables/household/demographic/tax_unit/tax_unit_role_input.py create mode 100644 policyengine_us/variables/household/demographic/tax_unit/tax_unit_roles_supplied.py diff --git a/changelog.d/source-tax-unit-roles.added.md b/changelog.d/source-tax-unit-roles.added.md new file mode 100644 index 00000000000..90d44650eec --- /dev/null +++ b/changelog.d/source-tax-unit-roles.added.md @@ -0,0 +1 @@ +Tax-unit roles and filing statuses supplied by a dataset (tax_unit_role_input, filing_status_input) now determine is_tax_unit_head, is_tax_unit_spouse, is_tax_unit_dependent and filing_status, with age ordering and the filing rules as the fallback. On the certified Populace default build this replaces age-ordered roles with the build's own, so adult students and other adult dependents are no longer paired with a parent as joint filers, couples are headed by the build's head, and lone minors head their own returns; federal and state tax, credit and filing-status totals over that build change accordingly. diff --git a/docs/usage/microsimulation.md b/docs/usage/microsimulation.md index d1559310db7..8ce6295ee1c 100644 --- a/docs/usage/microsimulation.md +++ b/docs/usage/microsimulation.md @@ -109,6 +109,31 @@ source-backed `is_spm_independent_minor_role` values, and it must not store formula-owned SPM outputs such as `spm_unit_spm_threshold`. Any observed Census measurement is retained under a separate report-only name. +A population file can also supply the tax units its constructor built, not just +their membership: + +- `tax_unit_role_input` (person): `HEAD`, `SPOUSE` or `DEPENDENT`. +- `filing_status_input` (tax unit): `SINGLE`, `JOINT`, `SEPARATE`, + `HEAD_OF_HOUSEHOLD` or `SURVIVING_SPOUSE`. + +The certified Populace build supplies both. When every member of a tax unit has +a supplied role, `is_tax_unit_head`, `is_tax_unit_spouse` and +`is_tax_unit_dependent` follow it, and a supplied filing status becomes +`filing_status`. A unit without supplied roles falls back to age ordering: the +oldest adult is the head and the next-oldest adult the spouse, which pairs an +adult student with a parent as joint filers and leaves a minor living without a +parent with no head. A unit without a supplied status has its status derived +from its members and the filing rules. + +Supplied values fail closed rather than falling back: roles supplied for only +some members of a unit, a unit without exactly one `HEAD` or with more than one +`SPOUSE`, and a `JOINT` status in a unit without a spouse (or a non-`JOINT` +status in a unit with one) all raise a `ValueError`. A supplied status is a fact +about the record, so parameter reforms to the head of household and surviving +spouse rules, such as the dependent age limits, do not re-derive it. A +structural reform that removes a status must neutralize `filing_status_input`, +as `remove_head_of_household` does. + The legacy files under `hf://policyengine/policyengine-us-data/` predate that contract, so SPM measurements are not available over them: diff --git a/policyengine_us/reforms/congress/romney/family_security_act/remove_head_of_household.py b/policyengine_us/reforms/congress/romney/family_security_act/remove_head_of_household.py index 23927edabd6..5c9ef62b180 100644 --- a/policyengine_us/reforms/congress/romney/family_security_act/remove_head_of_household.py +++ b/policyengine_us/reforms/congress/romney/family_security_act/remove_head_of_household.py @@ -6,6 +6,9 @@ def create_remove_head_of_household() -> Reform: class reform(Reform): def apply(self): self.neutralize_variable("head_of_household_eligible") + # A supplied filing status would otherwise keep head of household + # units filing as such, so re-derive every status from the rules. + self.neutralize_variable("filing_status_input") return reform diff --git a/policyengine_us/tests/core/test_tax_unit_role_input.py b/policyengine_us/tests/core/test_tax_unit_role_input.py new file mode 100644 index 00000000000..e4df28f5fb3 --- /dev/null +++ b/policyengine_us/tests/core/test_tax_unit_role_input.py @@ -0,0 +1,177 @@ +"""Tax-unit roles and filing statuses supplied by a population dataset. + +The Populace US build's tax-unit constructor (microunit, called from +microcosm-frame) assigns every person a role - HEAD, SPOUSE or DEPENDENT - and +every tax unit a filing status, and writes them to the person column +``tax_unit_role_input`` and the tax-unit column ``filing_status_input``. The +role variables and ``filing_status`` use them when supplied and fall back to +age ordering and the filing rules when not. + +The YAML tests cover household situations. These cover what the YAML runner +cannot: the error contract, the column contract with the producer, and the +dataset loading path, where the columns arrive as strings and must hold in +every year the loader extends the data to. +""" + +import numpy as np +import pandas as pd +import pytest + +from policyengine_us import Microsimulation, Simulation +from policyengine_us.data.dataset_schema import USSingleYearDataset +from policyengine_us.system import system +from policyengine_us.variables.household.demographic.tax_unit.filing_status import ( + FilingStatus, +) + +YEAR = 2024 + + +def _situation(people: dict, filing_status_input: str | None = None) -> dict: + tax_unit = {"members": list(people)} + if filing_status_input is not None: + tax_unit["filing_status_input"] = {YEAR: filing_status_input} + return { + "people": { + name: { + "age": {YEAR: age}, + **({"tax_unit_role_input": {YEAR: role}} if role else {}), + } + for name, (age, role) in people.items() + }, + "tax_units": {"tax_unit": tax_unit}, + "households": {"household": {"members": list(people)}}, + } + + +def test_supplied_inputs_carry_the_producers_column_names_and_values(): + """The variables must match the columns the Populace build writes. + + The loader sets a dataset column only when a variable has its exact name, + and encodes a string column by Enum member name, so a renamed variable or + member would silently drop the build's roles again. + """ + role = system.variables["tax_unit_role_input"] + status = system.variables["filing_status_input"] + assert role.entity.key == "person" + assert status.entity.key == "tax_unit" + for variable in (role, status): + assert not variable.formulas # An input, carried to every later year. + assert variable.default_value.name == "UNSPECIFIED" + assert {"HEAD", "SPOUSE", "DEPENDENT"} <= set(role.possible_values.__members__) + # Every status the rules can derive can also be supplied. + assert set(status.possible_values.__members__) == { + "UNSPECIFIED", + *FilingStatus.__members__, + } + + +@pytest.mark.parametrize( + "people,message", + [ + ({"head": (50, "HEAD"), "other": (20, None)}, "some members"), + ({"head": (50, "HEAD"), "other": (20, "HEAD")}, "exactly one HEAD"), + ({"only": (50, "DEPENDENT")}, "exactly one HEAD"), + ( + {"head": (50, "HEAD"), "a": (45, "SPOUSE"), "b": (44, "SPOUSE")}, + "at most one", + ), + ], + ids=["partly supplied", "two heads", "no head", "two spouses"], +) +def test_malformed_supplied_roles_raise(people, message): + simulation = Simulation(situation=_situation(people)) + with pytest.raises(ValueError, match=message): + simulation.calculate("is_tax_unit_head", YEAR) + + +@pytest.mark.parametrize( + "people,status", + [ + ({"head": (50, "HEAD")}, "JOINT"), + ({"head": (50, "HEAD"), "spouse": (48, "SPOUSE")}, "SINGLE"), + ({"older": (50, None), "younger": (48, None)}, "HEAD_OF_HOUSEHOLD"), + ], + ids=["joint without a spouse", "spouse but not joint", "age-rule spouse"], +) +def test_supplied_status_must_agree_with_the_units_spouse(people, status): + simulation = Simulation(situation=_situation(people, status)) + with pytest.raises(ValueError, match="filing_status_input disagrees"): + simulation.calculate("filing_status", YEAR) + + +def _dataset(with_supplied_columns: bool) -> USSingleYearDataset: + """Three tax units in one household, with Populace-style string columns. + + 1. A couple headed by a 30-year-old reference person with a 60-year-old + spouse, and their 20-year-old full-time student. + 2. A 16-year-old living without a parent. + 3. A 50-year-old parent and their 21-year-old full-time student. + """ + ages = [30, 60, 20, 16, 50, 21] + tax_unit_of = [1, 1, 1, 2, 3, 3] + person = pd.DataFrame( + { + "person_id": np.arange(1, 7), + "person_household_id": np.ones(6, dtype=int), + "person_tax_unit_id": tax_unit_of, + "person_spm_unit_id": np.ones(6, dtype=int), + "person_family_id": np.ones(6, dtype=int), + "person_marital_unit_id": np.arange(1, 7), + "age": np.asarray(ages, dtype=float), + "is_full_time_student": [False, False, True, False, False, True], + } + ) + tax_unit = pd.DataFrame({"tax_unit_id": [1, 2, 3]}) + if with_supplied_columns: + person["tax_unit_role_input"] = pd.array( + ["HEAD", "SPOUSE", "DEPENDENT", "HEAD", "HEAD", "DEPENDENT"], dtype="str" + ) + tax_unit["filing_status_input"] = pd.array( + ["JOINT", "SINGLE", "HEAD_OF_HOUSEHOLD"], dtype="str" + ) + return USSingleYearDataset( + person=person, + household=pd.DataFrame( + {"household_id": [1], "state_fips": [48], "household_weight": [1.0]} + ), + tax_unit=tax_unit, + spm_unit=pd.DataFrame({"spm_unit_id": [1]}), + family=pd.DataFrame({"family_id": [1]}), + marital_unit=pd.DataFrame({"marital_unit_id": np.arange(1, 7)}), + time_period=YEAR, + ) + + +def _roles_and_statuses(simulation, year): + return ( + np.asarray(simulation.calculate("is_tax_unit_head", year)).tolist(), + np.asarray(simulation.calculate("is_tax_unit_spouse", year)).tolist(), + np.asarray(simulation.calculate("is_tax_unit_dependent", year)).tolist(), + np.asarray(simulation.calculate("filing_status", year)).tolist(), + ) + + +@pytest.mark.parametrize("year", [YEAR, 2026]) +def test_dataset_roles_and_statuses_hold_in_every_extended_year(year): + simulation = Microsimulation(dataset=_dataset(True), dataset_end_year=2026) + heads, spouses, dependents, statuses = _roles_and_statuses(simulation, year) + assert heads == [True, False, False, True, True, False] + assert spouses == [False, True, False, False, False, False] + assert dependents == [False, False, True, False, False, True] + assert statuses == ["JOINT", "SINGLE", "HEAD_OF_HOUSEHOLD"] + + +def test_dataset_without_supplied_columns_keeps_age_ordering(): + """The same people without the columns: the oldest adult heads each unit. + + The 60-year-old heads the couple, the 16-year-old heads nothing, and the + student becomes the parent's spouse, so both multi-person units file + jointly - the behaviour the supplied roles exist to correct. + """ + simulation = Microsimulation(dataset=_dataset(False), dataset_end_year=YEAR) + heads, spouses, dependents, statuses = _roles_and_statuses(simulation, YEAR) + assert heads == [False, True, False, False, True, False] + assert spouses == [True, False, False, False, False, True] + assert dependents == [False, False, True, True, False, False] + assert statuses == ["JOINT", "HEAD_OF_HOUSEHOLD", "JOINT"] diff --git a/policyengine_us/tests/microsimulation/test_microsim.py b/policyengine_us/tests/microsimulation/test_microsim.py index 7c7470a2198..a313e89c308 100644 --- a/policyengine_us/tests/microsimulation/test_microsim.py +++ b/policyengine_us/tests/microsimulation/test_microsim.py @@ -237,3 +237,54 @@ def test_legacy_enhanced_cps_allocates_no_housing_share_to_an_unclassified_unit( "SPM units hold an allocated housing share while classifying no " "measurement adult." ) + + +def test_default_dataset_uses_its_supplied_tax_unit_roles(): + """Every role and filing status on the certified default build is its own. + + `populace_us_2024` ships its tax-unit constructor's roles + (`tax_unit_role_input`) and filing statuses (`filing_status_input`). Without + them, age ordering made the oldest adult the head and the next-oldest the + spouse, pairing adult students with their parents as joint filers, heading + couples by the older partner rather than the reference person, and leaving + lone minors with no head. This checks the whole population, person by + person and unit by unit, in the data year and in an extended year, since + the loader carries the columns forward rather than the formulas. + """ + import numpy as np + import pandas as pd + + from policyengine_us import Microsimulation + from policyengine_us.system import DEFAULT_DATASET, _resolve_dataset_path + + with pd.HDFStore(_resolve_dataset_path(DEFAULT_DATASET), mode="r") as store: + person = store["person"][["person_id", "tax_unit_role_input"]] + tax_unit = store["tax_unit"][["tax_unit_id", "filing_status_input"]] + roles = person.set_index("person_id")["tax_unit_role_input"].astype(str) + statuses = tax_unit.set_index("tax_unit_id")["filing_status_input"].astype(str) + + simulation = Microsimulation(dataset_end_year=2026) + for year in (2024, 2026): + person_id = np.asarray(simulation.calculate("person_id", year)) + role = roles.reindex(person_id).to_numpy() + for name, variable in ( + ("HEAD", "is_tax_unit_head"), + ("SPOUSE", "is_tax_unit_spouse"), + ("DEPENDENT", "is_tax_unit_dependent"), + ): + modelled = np.asarray(simulation.calculate(variable, year)).astype(bool) + mismatched = int((modelled != (role == name)).sum()) + assert mismatched == 0, ( + f"populace_us_2024 (certified default build), {year}: " + f"{variable} disagrees with tax_unit_role_input == {name} for " + f"{mismatched} of {role.size} people." + ) + tax_unit_id = np.asarray(simulation.calculate("tax_unit_id", year)) + status = statuses.reindex(tax_unit_id).to_numpy() + modelled = np.asarray(simulation.calculate("filing_status", year)).astype(str) + mismatched = int((modelled != status).sum()) + assert mismatched == 0, ( + f"populace_us_2024 (certified default build), {year}: " + f"filing_status disagrees with filing_status_input for " + f"{mismatched} of {status.size} tax units." + ) diff --git a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/filing_status_input.yaml b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/filing_status_input.yaml new file mode 100644 index 00000000000..cc65e314845 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/filing_status_input.yaml @@ -0,0 +1,123 @@ +- name: A supplied status is used even where the rules would give another. + # The rules give a parent of a 10-year-old head of household status. + period: 2024 + input: + people: + parent: + age: 50 + tax_unit_role_input: HEAD + child: + age: 10 + tax_unit_role_input: DEPENDENT + tax_units: + tax_unit: + members: [parent, child] + filing_status_input: SINGLE + households: + household: + members: [parent, child] + output: + filing_status: SINGLE + head_of_household_eligible: true + +- name: A supplied surviving spouse status is used. + period: 2024 + input: + people: + parent: + age: 66 + tax_unit_role_input: HEAD + child: + age: 10 + tax_unit_role_input: DEPENDENT + tax_units: + tax_unit: + members: [parent, child] + filing_status_input: SURVIVING_SPOUSE + households: + household: + members: [parent, child] + output: + filing_status: SURVIVING_SPOUSE + +- name: A supplied separate status is used. + period: 2024 + input: + people: + taxpayer: + age: 40 + tax_unit_role_input: HEAD + tax_units: + tax_unit: + members: [taxpayer] + filing_status_input: SEPARATE + households: + household: + members: [taxpayer] + output: + filing_status: SEPARATE + +- name: A supplied joint status is used for a supplied couple. + period: 2024 + input: + people: + head: + age: 30 + tax_unit_role_input: HEAD + spouse: + age: 60 + tax_unit_role_input: SPOUSE + tax_units: + tax_unit: + members: [head, spouse] + filing_status_input: JOINT + households: + household: + members: [head, spouse] + output: + filing_status: JOINT + +- name: Without a supplied status the rules decide. + # A parent of a 10-year-old qualifying child files as head of household + # (26 USC 2(b)(1)(A)(i), 152(c)(3)(A)(i)). + period: 2024 + input: + people: + parent: + age: 50 + child: + age: 10 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + output: + filing_status: HEAD_OF_HOUSEHOLD + +- name: Each tax unit uses its own supplied status, or the rules without one. + period: 2024 + input: + people: + supplied_parent: + age: 50 + tax_unit_role_input: HEAD + supplied_child: + age: 10 + tax_unit_role_input: DEPENDENT + unsupplied_parent: + age: 50 + unsupplied_child: + age: 10 + tax_units: + supplied_unit: + members: [supplied_parent, supplied_child] + filing_status_input: SINGLE + unsupplied_unit: + members: [unsupplied_parent, unsupplied_child] + households: + household: + members: [supplied_parent, supplied_child, unsupplied_parent, unsupplied_child] + output: + filing_status: [SINGLE, HEAD_OF_HOUSEHOLD] diff --git a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/tax_unit_role_input.yaml b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/tax_unit_role_input.yaml new file mode 100644 index 00000000000..a10c9e1b19d --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/tax_unit_role_input.yaml @@ -0,0 +1,142 @@ +- name: Supplied roles keep an adult student a dependent rather than a spouse. + # By age ordering the 20-year-old would be the parent's spouse and the unit + # would file jointly. As a dependent full-time student under 24 they are the + # parent's qualifying child (26 USC 152(c)(3)(A)(ii)), so the parent files as + # head of household (26 USC 2(b)(1)(A)(i)). + period: 2024 + input: + people: + parent: + age: 50 + tax_unit_role_input: HEAD + student: + age: 20 + is_full_time_student: true + tax_unit_role_input: DEPENDENT + tax_units: + tax_unit: + members: [parent, student] + households: + household: + members: [parent, student] + output: + is_tax_unit_head: [true, false] + is_tax_unit_spouse: [false, false] + is_tax_unit_dependent: [false, true] + filing_status: HEAD_OF_HOUSEHOLD + +- name: A supplied head can be younger than the spouse. + period: 2024 + input: + people: + reference_person: + age: 30 + tax_unit_role_input: HEAD + partner: + age: 60 + tax_unit_role_input: SPOUSE + tax_units: + tax_unit: + members: [reference_person, partner] + households: + household: + members: [reference_person, partner] + output: + is_tax_unit_head: [true, false] + is_tax_unit_spouse: [false, true] + age_head: 30 + age_spouse: 60 + filing_status: JOINT + +- name: A minor supplied as head heads their own return. + # By age ordering a lone 16-year-old is nobody's dependent yet heads nothing. + period: 2024 + input: + people: + minor: + age: 16 + tax_unit_role_input: HEAD + tax_units: + tax_unit: + members: [minor] + households: + household: + members: [minor] + output: + is_tax_unit_head: true + is_tax_unit_dependent: false + filing_status: SINGLE + +- name: A separated dependent does not unpair a supplied couple. + period: 2024 + input: + people: + head: + age: 50 + tax_unit_role_input: HEAD + spouse: + age: 48 + tax_unit_role_input: SPOUSE + adult_child: + age: 25 + is_separated: true + tax_unit_role_input: DEPENDENT + tax_units: + tax_unit: + members: [head, spouse, adult_child] + households: + household: + members: [head, spouse, adult_child] + output: + is_tax_unit_spouse: [false, true, false] + is_tax_unit_dependent: [false, false, true] + filing_status: JOINT + +- name: Each tax unit uses its own supplied roles, or age ordering without them. + period: 2024 + input: + people: + supplied_head: + age: 30 + tax_unit_role_input: HEAD + supplied_spouse: + age: 60 + tax_unit_role_input: SPOUSE + unsupplied_younger: + age: 30 + unsupplied_older: + age: 60 + tax_units: + supplied_unit: + members: [supplied_head, supplied_spouse] + unsupplied_unit: + members: [unsupplied_younger, unsupplied_older] + households: + household: + members: [supplied_head, supplied_spouse, unsupplied_younger, unsupplied_older] + output: + is_tax_unit_head: [true, false, false, true] + is_tax_unit_spouse: [false, true, true, false] + tax_unit_roles_supplied: [true, false] + +- name: Without supplied roles the oldest adult is the head. + period: 2024 + input: + people: + younger: + age: 30 + older: + age: 60 + child: + age: 5 + tax_units: + tax_unit: + members: [younger, older, child] + households: + household: + members: [younger, older, child] + output: + tax_unit_roles_supplied: false + is_tax_unit_head: [false, true, false] + is_tax_unit_spouse: [true, false, false] + is_tax_unit_dependent: [false, false, true] diff --git a/policyengine_us/tests/policy/contrib/congress/romney/family_security_act/remove_head_of_household.yaml b/policyengine_us/tests/policy/contrib/congress/romney/family_security_act/remove_head_of_household.yaml index eaa48bc257c..e8bf1c086ef 100644 --- a/policyengine_us/tests/policy/contrib/congress/romney/family_security_act/remove_head_of_household.yaml +++ b/policyengine_us/tests/policy/contrib/congress/romney/family_security_act/remove_head_of_household.yaml @@ -27,3 +27,22 @@ members: [head] output: filing_status: SINGLE + +- name: Remove head of household filing status for a supplied head of household status + period: 2023 + reforms: policyengine_us.reforms.congress.romney.family_security_act.remove_head_of_household.remove_head_of_household + input: + gov.contrib.congress.romney.family_security_act.remove_head_of_household : true + people: + head: + age: 40 + tax_unit_role_input: HEAD + child: + age: 10 + tax_unit_role_input: DEPENDENT + tax_units: + tax_unit: + members: [head, child] + filing_status_input: HEAD_OF_HOUSEHOLD + output: + filing_status: SINGLE diff --git a/policyengine_us/variables/household/demographic/tax_unit/filing_status.py b/policyengine_us/variables/household/demographic/tax_unit/filing_status.py index a9c454cda69..b6fcf2766c0 100644 --- a/policyengine_us/variables/household/demographic/tax_unit/filing_status.py +++ b/policyengine_us/variables/household/demographic/tax_unit/filing_status.py @@ -23,9 +23,10 @@ def formula(tax_unit, period, parameters): # to the tax unit head or spouse, not a dependent or other member. is_head_or_spouse = person("is_tax_unit_head_or_spouse", period) is_separated = tax_unit.any(is_head_or_spouse & person("is_separated", period)) - return select( + married = tax_unit("tax_unit_married", period) + derived = select( [ - tax_unit("tax_unit_married", period), + married, tax_unit("surviving_spouse_eligible", period), tax_unit("head_of_household_eligible", period), is_separated, @@ -38,3 +39,20 @@ def formula(tax_unit, period, parameters): ], default=FilingStatus.SINGLE, ) + # A dataset's tax-unit constructor can supply the status instead. + supplied = tax_unit("filing_status_input", period) + statuses = supplied.possible_values + is_supplied = supplied != statuses.UNSPECIFIED + disagrees = is_supplied & ((supplied == statuses.JOINT) != married) + if disagrees.any(): + raise ValueError( + f"filing_status_input disagrees with the members of " + f"{disagrees.sum()} tax units: a supplied JOINT status needs a " + "spouse in the unit, and a unit with a spouse must be supplied " + "JOINT." + ) + return select( + [supplied == statuses[status.name] for status in FilingStatus], + list(FilingStatus), + default=derived, + ) diff --git a/policyengine_us/variables/household/demographic/tax_unit/filing_status_input.py b/policyengine_us/variables/household/demographic/tax_unit/filing_status_input.py new file mode 100644 index 00000000000..c694a6a29bd --- /dev/null +++ b/policyengine_us/variables/household/demographic/tax_unit/filing_status_input.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * + + +class FilingStatusInput(Enum): + UNSPECIFIED = "Unspecified" + SINGLE = "Single" + JOINT = "Joint" + SEPARATE = "Separate" + HEAD_OF_HOUSEHOLD = "Head of household" + SURVIVING_SPOUSE = "Surviving spouse" + + +class filing_status_input(Variable): + value_type = Enum + entity = TaxUnit + possible_values = FilingStatusInput + default_value = FilingStatusInput.UNSPECIFIED + definition_period = YEAR + label = "Supplied filing status" + documentation = "The tax unit's filing status as assigned by the tax-unit constructor that built a dataset, such as the Populace US build. When supplied, filing_status uses it; a supplied JOINT status requires a spouse in the unit, and a unit with a spouse must be supplied JOINT. UNSPECIFIED (the default) means the status was not supplied, and filing_status is derived from the unit's members and the head of household and surviving spouse rules. A supplied status is a fact about the record, so parameter reforms to those rules, such as the dependent age limits, do not re-derive it; a structural reform that removes a status must neutralize this variable, as remove_head_of_household does." diff --git a/policyengine_us/variables/household/demographic/tax_unit/is_tax_unit_head.py b/policyengine_us/variables/household/demographic/tax_unit/is_tax_unit_head.py index 9860297e311..ddabb328a75 100644 --- a/policyengine_us/variables/household/demographic/tax_unit/is_tax_unit_head.py +++ b/policyengine_us/variables/household/demographic/tax_unit/is_tax_unit_head.py @@ -8,8 +8,12 @@ class is_tax_unit_head(Variable): definition_period = YEAR def formula(person, period, parameters): - # Only adults can be heads. + tax_unit = person.tax_unit + # A dataset's tax-unit constructor can supply every member's role. + role = person("tax_unit_role_input", period) + supplied = tax_unit("tax_unit_roles_supplied", period) + # Otherwise only adults can be heads, and the oldest is the head. eligible = ~person("is_child", period) age = person("age", period) - tax_unit = person.tax_unit - return person.get_rank(tax_unit, -age, eligible) == 0 + oldest_adult = person.get_rank(tax_unit, -age, eligible) == 0 + return where(supplied, role == role.possible_values.HEAD, oldest_adult) diff --git a/policyengine_us/variables/household/demographic/tax_unit/is_tax_unit_spouse.py b/policyengine_us/variables/household/demographic/tax_unit/is_tax_unit_spouse.py index 6810f6d7a8b..f90eb3e5099 100644 --- a/policyengine_us/variables/household/demographic/tax_unit/is_tax_unit_spouse.py +++ b/policyengine_us/variables/household/demographic/tax_unit/is_tax_unit_spouse.py @@ -8,11 +8,15 @@ class is_tax_unit_spouse(Variable): definition_period = YEAR def formula(person, period, parameters): - # Only non-head adults can be spouses. - is_separated = person.tax_unit.any(person("is_separated", period)) + tax_unit = person.tax_unit + # A dataset's tax-unit constructor can supply every member's role. + role = person("tax_unit_role_input", period) + supplied = tax_unit("tax_unit_roles_supplied", period) + # Otherwise only non-head adults can be spouses. + is_separated = tax_unit.any(person("is_separated", period)) adult = ~person("is_child", period) head = person("is_tax_unit_head", period) eligible = adult & ~head & ~is_separated - tax_unit = person.tax_unit age = person("age", period) - return person.get_rank(tax_unit, -age, eligible) == 0 + next_oldest_adult = person.get_rank(tax_unit, -age, eligible) == 0 + return where(supplied, role == role.possible_values.SPOUSE, next_oldest_adult) diff --git a/policyengine_us/variables/household/demographic/tax_unit/tax_unit_role_input.py b/policyengine_us/variables/household/demographic/tax_unit/tax_unit_role_input.py new file mode 100644 index 00000000000..473b6214d20 --- /dev/null +++ b/policyengine_us/variables/household/demographic/tax_unit/tax_unit_role_input.py @@ -0,0 +1,18 @@ +from policyengine_us.model_api import * + + +class TaxUnitRole(Enum): + UNSPECIFIED = "Unspecified" + HEAD = "Head" + SPOUSE = "Spouse" + DEPENDENT = "Dependent" + + +class tax_unit_role_input(Variable): + value_type = Enum + entity = Person + possible_values = TaxUnitRole + default_value = TaxUnitRole.UNSPECIFIED + definition_period = YEAR + label = "Supplied tax unit role" + documentation = "The person's role in their tax unit - head, spouse or dependent - as assigned by the tax-unit constructor that built a dataset, such as the Populace US build. When every member of a tax unit has a supplied role, is_tax_unit_head and is_tax_unit_spouse use it, so the constructor's head, its spouse pairing and its dependency claims all hold: a younger reference person can head a couple, an adult student stays a dependent, and a minor living without a parent can head a return. UNSPECIFIED (the default) means the role was not supplied, and the role variables fall back to age ordering, where the oldest adult is the head and the next-oldest adult the spouse." diff --git a/policyengine_us/variables/household/demographic/tax_unit/tax_unit_roles_supplied.py b/policyengine_us/variables/household/demographic/tax_unit/tax_unit_roles_supplied.py new file mode 100644 index 00000000000..23f1de3d4b5 --- /dev/null +++ b/policyengine_us/variables/household/demographic/tax_unit/tax_unit_roles_supplied.py @@ -0,0 +1,32 @@ +from policyengine_us.model_api import * + + +class tax_unit_roles_supplied(Variable): + value_type = bool + entity = TaxUnit + definition_period = YEAR + label = "Tax unit roles are supplied" + documentation = "Whether every member of the tax unit has a supplied tax_unit_role_input, in which case is_tax_unit_head and is_tax_unit_spouse use the supplied roles instead of age ordering. A unit whose roles are supplied for only some members, or whose supplied roles do not name exactly one head and at most one spouse, is an error rather than a silent fallback." + + def formula(tax_unit, period, parameters): + role = tax_unit.members("tax_unit_role_input", period) + roles = role.possible_values + supplied = role != roles.UNSPECIFIED + all_supplied = tax_unit.all(supplied) + partly_supplied = tax_unit.any(supplied) & ~all_supplied + if partly_supplied.any(): + raise ValueError( + f"tax_unit_role_input is supplied for some members of " + f"{partly_supplied.sum()} tax units but not for others. " + "Supply a role for every member of a tax unit, or for none." + ) + heads = tax_unit.sum(role == roles.HEAD) + spouses = tax_unit.sum(role == roles.SPOUSE) + malformed = all_supplied & ((heads != 1) | (spouses > 1)) + if malformed.any(): + raise ValueError( + f"tax_unit_role_input gives {malformed.sum()} tax units " + "something other than exactly one HEAD and at most one " + "SPOUSE." + ) + return all_supplied From 96dc205e5636e30d8b4f1166ffbbc43a0d534616 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 25 Sep 2026 07:34:04 -0400 Subject: [PATCH 2/9] Let a reform switch off only the status whose rule it removes Review found that neutralizing filing_status_input in remove_head_of_household re-derived every supplied status, moving 158 non-head-of-household units on the certified build, and that the gov.abolitions parameters for head_of_household_eligible and surviving_spouse_eligible did nothing to supplied statuses. filing_status now re-derives a supplied HEAD_OF_HOUSEHOLD or SURVIVING_SPOUSE only when that rule is neutralized or abolished, using core's own switch-off test, and the reform is back to its original single neutralization. Also: check a supplied JOINT status against the supplied roles so abolishing a role variable cannot raise; keep an explicit head out of the supplied spouse role; document precedence, the fallback's separation gate, and that supplied adult dependents' income counts toward no return. Co-Authored-By: Claude Opus 5.5 --- changelog.d/source-tax-unit-roles.added.md | 2 +- docs/usage/microsimulation.md | 69 ++++++++++++------- .../remove_head_of_household.py | 3 - .../tests/core/test_tax_unit_role_input.py | 62 +++++++++++++++++ .../tax_unit/filing_status_input.yaml | 4 +- .../tax_unit/tax_unit_role_input.yaml | 3 +- .../remove_head_of_household.yaml | 47 +++++++++++++ .../demographic/tax_unit/filing_status.py | 38 +++++++++- .../tax_unit/filing_status_input.py | 2 +- .../tax_unit/is_tax_unit_spouse.py | 5 +- 10 files changed, 199 insertions(+), 36 deletions(-) diff --git a/changelog.d/source-tax-unit-roles.added.md b/changelog.d/source-tax-unit-roles.added.md index 90d44650eec..2bcb8aa760c 100644 --- a/changelog.d/source-tax-unit-roles.added.md +++ b/changelog.d/source-tax-unit-roles.added.md @@ -1 +1 @@ -Tax-unit roles and filing statuses supplied by a dataset (tax_unit_role_input, filing_status_input) now determine is_tax_unit_head, is_tax_unit_spouse, is_tax_unit_dependent and filing_status, with age ordering and the filing rules as the fallback. On the certified Populace default build this replaces age-ordered roles with the build's own, so adult students and other adult dependents are no longer paired with a parent as joint filers, couples are headed by the build's head, and lone minors head their own returns; federal and state tax, credit and filing-status totals over that build change accordingly. +Tax-unit roles and filing statuses supplied by a dataset (tax_unit_role_input, filing_status_input) now determine is_tax_unit_head, is_tax_unit_spouse, is_tax_unit_dependent and filing_status, with age ordering and the filing rules as the fallback. On the certified Populace default build this replaces age-ordered roles with the build's own: adult students and other adult dependents are no longer paired with a parent as joint filers, couples are headed by the build's head, and lone minors head their own returns. In 2024, joint returns fall by 3.07 million and federal income tax by $0.94 billion net. Because the model computes no return for a tax-unit dependent, the income of adults the build makes dependents no longer counts toward any return, which lowers adjusted gross income by $35.9 billion. diff --git a/docs/usage/microsimulation.md b/docs/usage/microsimulation.md index 8ce6295ee1c..3fb3b9bf47e 100644 --- a/docs/usage/microsimulation.md +++ b/docs/usage/microsimulation.md @@ -109,31 +109,6 @@ source-backed `is_spm_independent_minor_role` values, and it must not store formula-owned SPM outputs such as `spm_unit_spm_threshold`. Any observed Census measurement is retained under a separate report-only name. -A population file can also supply the tax units its constructor built, not just -their membership: - -- `tax_unit_role_input` (person): `HEAD`, `SPOUSE` or `DEPENDENT`. -- `filing_status_input` (tax unit): `SINGLE`, `JOINT`, `SEPARATE`, - `HEAD_OF_HOUSEHOLD` or `SURVIVING_SPOUSE`. - -The certified Populace build supplies both. When every member of a tax unit has -a supplied role, `is_tax_unit_head`, `is_tax_unit_spouse` and -`is_tax_unit_dependent` follow it, and a supplied filing status becomes -`filing_status`. A unit without supplied roles falls back to age ordering: the -oldest adult is the head and the next-oldest adult the spouse, which pairs an -adult student with a parent as joint filers and leaves a minor living without a -parent with no head. A unit without a supplied status has its status derived -from its members and the filing rules. - -Supplied values fail closed rather than falling back: roles supplied for only -some members of a unit, a unit without exactly one `HEAD` or with more than one -`SPOUSE`, and a `JOINT` status in a unit without a spouse (or a non-`JOINT` -status in a unit with one) all raise a `ValueError`. A supplied status is a fact -about the record, so parameter reforms to the head of household and surviving -spouse rules, such as the dependent age limits, do not re-derive it. A -structural reform that removes a status must neutralize `filing_status_input`, -as `remove_head_of_household` does. - The legacy files under `hf://policyengine/policyengine-us-data/` predate that contract, so SPM measurements are not available over them: @@ -166,6 +141,50 @@ sim = Microsimulation(dataset=single_year_dataset, dataset_end_year=2024) paths. It does not apply to an existing `USMultiYearDataset` or to the legacy variable-centric HDF5 format. +### Tax-unit roles and filing status + +A population file can supply the tax units its constructor built, not just +their membership: + +- `tax_unit_role_input` (person): `HEAD`, `SPOUSE` or `DEPENDENT`. +- `filing_status_input` (tax unit): `SINGLE`, `JOINT`, `SEPARATE`, + `HEAD_OF_HOUSEHOLD` or `SURVIVING_SPOUSE`. + +The certified Populace build supplies both. When every member of a tax unit has +a supplied role, `is_tax_unit_head` and `is_tax_unit_spouse` follow it and +`is_tax_unit_dependent` covers everyone else; a supplied filing status becomes +`filing_status`. Explicitly set `is_tax_unit_head`, `is_tax_unit_spouse`, +`is_tax_unit_dependent` or `filing_status` inputs still take precedence. + +Without supplied roles, a unit falls back to age ordering: the oldest adult is +the head, and the next-oldest adult the spouse unless any member of the unit is +separated. That pairs an adult student with a parent as joint filers and leaves +a minor living without a parent with no head. Without a supplied status, the +status is derived from the unit's members and the filing rules. + +Supplied values fail closed rather than falling back. Each of these raises a +`ValueError`: + +- roles supplied for only some members of a unit; +- a unit without exactly one `HEAD`, or with more than one `SPOUSE`; +- a `JOINT` status in a unit without a spouse, or a non-`JOINT` status in a + unit with one. + +A supplied status is a fact about the record, so parameter reforms to the head +of household and surviving spouse rules, such as the dependent age limits, do +not re-derive it. A reform that switches off one of those rules, by neutralizing +`head_of_household_eligible` or `surviving_spouse_eligible` (as +`remove_head_of_household` does) or through its `gov.abolitions` parameter, +re-derives the units supplied with that status and leaves every other supplied +status in place. + +Two consequences follow from honoring supplied roles. A dependent's own income +counts toward no return, because `irs_gross_income` excludes tax-unit dependents +and the model computes no separate return for them, so an adult dependent's +earnings leave the income tax base. And a minor can head a return, which +programs that identify minor parents as a tax-unit head or spouse under 18 will +treat as a minor parent. + ### Filtering by geography The microdata includes geographic identifiers that can be used for state-level analysis: diff --git a/policyengine_us/reforms/congress/romney/family_security_act/remove_head_of_household.py b/policyengine_us/reforms/congress/romney/family_security_act/remove_head_of_household.py index 5c9ef62b180..23927edabd6 100644 --- a/policyengine_us/reforms/congress/romney/family_security_act/remove_head_of_household.py +++ b/policyengine_us/reforms/congress/romney/family_security_act/remove_head_of_household.py @@ -6,9 +6,6 @@ def create_remove_head_of_household() -> Reform: class reform(Reform): def apply(self): self.neutralize_variable("head_of_household_eligible") - # A supplied filing status would otherwise keep head of household - # units filing as such, so re-derive every status from the rules. - self.neutralize_variable("filing_status_input") return reform diff --git a/policyengine_us/tests/core/test_tax_unit_role_input.py b/policyengine_us/tests/core/test_tax_unit_role_input.py index e4df28f5fb3..e4aa0f4c201 100644 --- a/policyengine_us/tests/core/test_tax_unit_role_input.py +++ b/policyengine_us/tests/core/test_tax_unit_role_input.py @@ -100,6 +100,68 @@ def test_supplied_status_must_agree_with_the_units_spouse(people, status): simulation.calculate("filing_status", YEAR) +def _abolish(variable: str) -> dict: + return {f"gov.abolitions.{variable}": {"2000-01-01.2100-12-31": True}} + + +@pytest.mark.parametrize( + "status,abolished,expected", + [ + ("HEAD_OF_HOUSEHOLD", "head_of_household_eligible", "SINGLE"), + ("SINGLE", "head_of_household_eligible", "SINGLE"), + ("SURVIVING_SPOUSE", "surviving_spouse_eligible", "HEAD_OF_HOUSEHOLD"), + ("HEAD_OF_HOUSEHOLD", "surviving_spouse_eligible", "HEAD_OF_HOUSEHOLD"), + ], + ids=[ + "abolished HOH re-derives a supplied HOH", + "abolished HOH keeps a supplied SINGLE", + "abolished SS re-derives a supplied SS", + "abolished SS keeps a supplied HOH", + ], +) +def test_abolishing_a_status_rule_re_derives_only_that_status( + status, abolished, expected +): + """A parent of a 10-year-old, supplied with a status the rules may not give. + + Abolishing an eligibility rule removes that status, so a unit supplied with + it is re-derived from the remaining rules; a unit supplied with another + status keeps it, as the rules would not otherwise differ from the input. + """ + people = {"parent": (40, "HEAD"), "child": (10, "DEPENDENT")} + simulation = Simulation( + situation=_situation(people, status), reform=_abolish(abolished) + ) + assert simulation.calculate("filing_status", YEAR).decode_to_str().tolist() == [ + expected + ] + + +@pytest.mark.parametrize( + "abolished", ["tax_unit_married", "is_tax_unit_spouse", "tax_unit_roles_supplied"] +) +def test_abolishing_a_role_variable_does_not_fail_the_status_check(abolished): + """The JOINT check reads the supplied roles, not the abolishable variables.""" + people = {"head": (40, "HEAD"), "spouse": (40, "SPOUSE")} + simulation = Simulation( + situation=_situation(people, "JOINT"), reform=_abolish(abolished) + ) + assert simulation.calculate("filing_status", YEAR).decode_to_str().tolist() == [ + "JOINT" + ] + + +def test_an_explicit_head_input_is_never_also_the_spouse(): + """Explicit role flags win over supplied roles without doubling a person.""" + situation = _situation({"a": (50, "HEAD"), "b": (48, "SPOUSE")}) + situation["people"]["b"]["is_tax_unit_head"] = {YEAR: True} + simulation = Simulation(situation=situation) + head = simulation.calculate("is_tax_unit_head", YEAR) + spouse = simulation.calculate("is_tax_unit_spouse", YEAR) + assert head.tolist() == [False, True] + assert not (head & spouse).any() + + def _dataset(with_supplied_columns: bool) -> USSingleYearDataset: """Three tax units in one household, with Populace-style string columns. diff --git a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/filing_status_input.yaml b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/filing_status_input.yaml index cc65e314845..a0360bcccaa 100644 --- a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/filing_status_input.yaml +++ b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/filing_status_input.yaml @@ -57,7 +57,9 @@ output: filing_status: SEPARATE -- name: A supplied joint status is used for a supplied couple. +- name: A supplied joint status is accepted for a supplied couple. + # The rules also give JOINT here; this checks the status is accepted, not + # that it overrides the rules, which cannot differ for a couple. period: 2024 input: people: diff --git a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/tax_unit_role_input.yaml b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/tax_unit_role_input.yaml index a10c9e1b19d..4a11aa37d1b 100644 --- a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/tax_unit_role_input.yaml +++ b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/tax_unit_role_input.yaml @@ -49,7 +49,8 @@ filing_status: JOINT - name: A minor supplied as head heads their own return. - # By age ordering a lone 16-year-old is nobody's dependent yet heads nothing. + # By age ordering a lone 16-year-old heads nothing: they count as the unit's + # dependent and it files as head of household with them as the qualifying child. period: 2024 input: people: diff --git a/policyengine_us/tests/policy/contrib/congress/romney/family_security_act/remove_head_of_household.yaml b/policyengine_us/tests/policy/contrib/congress/romney/family_security_act/remove_head_of_household.yaml index e8bf1c086ef..7e0f6245911 100644 --- a/policyengine_us/tests/policy/contrib/congress/romney/family_security_act/remove_head_of_household.yaml +++ b/policyengine_us/tests/policy/contrib/congress/romney/family_security_act/remove_head_of_household.yaml @@ -46,3 +46,50 @@ filing_status_input: HEAD_OF_HOUSEHOLD output: filing_status: SINGLE + +- name: Removing head of household status leaves a supplied single status alone + # The rules would make this widowed parent of an unrelated 10-year-old a + # surviving spouse; the repeal must not re-derive a status it does not remove. + period: 2023 + reforms: policyengine_us.reforms.congress.romney.family_security_act.remove_head_of_household.remove_head_of_household + input: + gov.contrib.congress.romney.family_security_act.remove_head_of_household : true + people: + head: + age: 66 + is_surviving_spouse: true + tax_unit_role_input: HEAD + child: + age: 10 + is_related_to_head_or_spouse: false + tax_unit_role_input: DEPENDENT + tax_units: + tax_unit: + members: [head, child] + filing_status_input: SINGLE + output: + filing_status: SINGLE + +- name: Removing head of household status leaves a supplied surviving spouse status alone + # The rules would make this unit single: the 30-year-old dependent is neither + # a qualifying child nor, with $20,000 of income, a qualifying relative. + period: 2023 + reforms: policyengine_us.reforms.congress.romney.family_security_act.remove_head_of_household.remove_head_of_household + input: + gov.contrib.congress.romney.family_security_act.remove_head_of_household : true + people: + head: + age: 60 + is_surviving_spouse: true + employment_income: 60_000 + tax_unit_role_input: HEAD + dependent: + age: 30 + employment_income: 20_000 + tax_unit_role_input: DEPENDENT + tax_units: + tax_unit: + members: [head, dependent] + filing_status_input: SURVIVING_SPOUSE + output: + filing_status: SURVIVING_SPOUSE diff --git a/policyengine_us/variables/household/demographic/tax_unit/filing_status.py b/policyengine_us/variables/household/demographic/tax_unit/filing_status.py index b6fcf2766c0..b39afd35cd4 100644 --- a/policyengine_us/variables/household/demographic/tax_unit/filing_status.py +++ b/policyengine_us/variables/household/demographic/tax_unit/filing_status.py @@ -9,6 +9,21 @@ class FilingStatus(Enum): SURVIVING_SPOUSE = "Surviving spouse" +# The eligibility rule behind each status that a reform can switch off. +STATUS_RULES = { + FilingStatus.HEAD_OF_HOUSEHOLD: "head_of_household_eligible", + FilingStatus.SURVIVING_SPOUSE: "surviving_spouse_eligible", +} + + +def _rule_switched_off(tax_unit, period, parameters, variable_name): + # The same test policyengine-core applies before calculating a variable: + # a structural reform neutralized it, or its abolition parameter is set. + variable = tax_unit.simulation.tax_benefit_system.variables[variable_name] + abolished = parameters(period).gov.abolitions[variable_name] + return variable.is_neutralized or bool(abolished) + + class filing_status(Variable): value_type = Enum entity = TaxUnit @@ -43,7 +58,14 @@ def formula(tax_unit, period, parameters): supplied = tax_unit("filing_status_input", period) statuses = supplied.possible_values is_supplied = supplied != statuses.UNSPECIFIED - disagrees = is_supplied & ((supplied == statuses.JOINT) != married) + # Check a supplied status against the supplied roles themselves where + # the unit has them, so no reform to the role variables can turn this + # data check into an error. + role = person("tax_unit_role_input", period) + roles = role.possible_values + roles_supplied = tax_unit.all(role != roles.UNSPECIFIED) + has_spouse = where(roles_supplied, tax_unit.any(role == roles.SPOUSE), married) + disagrees = is_supplied & ((supplied == statuses.JOINT) != has_spouse) if disagrees.any(): raise ValueError( f"filing_status_input disagrees with the members of " @@ -51,8 +73,18 @@ def formula(tax_unit, period, parameters): "spouse in the unit, and a unit with a spouse must be supplied " "JOINT." ) + # A reform that switches off a status's rule re-derives the units + # supplied with that status; every other supplied status stands. + honored = [ + status + for status in FilingStatus + if status not in STATUS_RULES + or not _rule_switched_off( + tax_unit, period, parameters, STATUS_RULES[status] + ) + ] return select( - [supplied == statuses[status.name] for status in FilingStatus], - list(FilingStatus), + [supplied == statuses[status.name] for status in honored], + honored, default=derived, ) diff --git a/policyengine_us/variables/household/demographic/tax_unit/filing_status_input.py b/policyengine_us/variables/household/demographic/tax_unit/filing_status_input.py index c694a6a29bd..e29ee7c2f3d 100644 --- a/policyengine_us/variables/household/demographic/tax_unit/filing_status_input.py +++ b/policyengine_us/variables/household/demographic/tax_unit/filing_status_input.py @@ -17,4 +17,4 @@ class filing_status_input(Variable): default_value = FilingStatusInput.UNSPECIFIED definition_period = YEAR label = "Supplied filing status" - documentation = "The tax unit's filing status as assigned by the tax-unit constructor that built a dataset, such as the Populace US build. When supplied, filing_status uses it; a supplied JOINT status requires a spouse in the unit, and a unit with a spouse must be supplied JOINT. UNSPECIFIED (the default) means the status was not supplied, and filing_status is derived from the unit's members and the head of household and surviving spouse rules. A supplied status is a fact about the record, so parameter reforms to those rules, such as the dependent age limits, do not re-derive it; a structural reform that removes a status must neutralize this variable, as remove_head_of_household does." + documentation = "The tax unit's filing status as assigned by the tax-unit constructor that built a dataset, such as the Populace US build. When supplied, filing_status uses it; a supplied JOINT status requires a spouse in the unit, and a unit with a spouse must be supplied JOINT. UNSPECIFIED (the default) means the status was not supplied, and filing_status is derived from the unit's members and the head of household and surviving spouse rules. A supplied status is a fact about the record, so parameter reforms to those rules, such as the dependent age limits, do not re-derive it. A reform that switches off the head of household or surviving spouse rule, by neutralizing head_of_household_eligible or surviving_spouse_eligible or through their gov.abolitions parameters, re-derives the units supplied with that status and leaves every other supplied status in place." diff --git a/policyengine_us/variables/household/demographic/tax_unit/is_tax_unit_spouse.py b/policyengine_us/variables/household/demographic/tax_unit/is_tax_unit_spouse.py index f90eb3e5099..b1ad0c4f1f2 100644 --- a/policyengine_us/variables/household/demographic/tax_unit/is_tax_unit_spouse.py +++ b/policyengine_us/variables/household/demographic/tax_unit/is_tax_unit_spouse.py @@ -19,4 +19,7 @@ def formula(person, period, parameters): eligible = adult & ~head & ~is_separated age = person("age", period) next_oldest_adult = person.get_rank(tax_unit, -age, eligible) == 0 - return where(supplied, role == role.possible_values.SPOUSE, next_oldest_adult) + # An explicit is_tax_unit_head input still wins over a supplied role, + # so keep the head out of the spouse role either way. + supplied_spouse = (role == role.possible_values.SPOUSE) & ~head + return where(supplied, supplied_spouse, next_oldest_adult) From d8b02c1ef0e03a9200e8f22bb333d101477787ac Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 16:48:28 -0400 Subject: [PATCH 3/9] Test the role and filing-status invariants with Hypothesis State seven properties of supplied tax-unit roles and filing statuses (partition, supplied roles hold, age-ordering fallback, supplied statuses hold, equivalence with the explicit-pin workaround, switch-off locality, error contract) and check each over generated households. Adds hypothesis to the dev extra; uv.lock regenerated with CI's uv 0.12.13 so it changes only by that package. Co-Authored-By: Claude Opus 5.5 --- .../test_tax_unit_role_input_properties.py | 248 ++++++++++++++++++ pyproject.toml | 1 + uv.lock | 90 +++++++ 3 files changed, 339 insertions(+) create mode 100644 policyengine_us/tests/core/test_tax_unit_role_input_properties.py diff --git a/policyengine_us/tests/core/test_tax_unit_role_input_properties.py b/policyengine_us/tests/core/test_tax_unit_role_input_properties.py new file mode 100644 index 00000000000..235884217b5 --- /dev/null +++ b/policyengine_us/tests/core/test_tax_unit_role_input_properties.py @@ -0,0 +1,248 @@ +"""Properties of supplied tax-unit roles and filing statuses over generated units. + +Invariants, each checked for every generated household: + +1. Partition: every person is exactly one of head, spouse and dependent. +2. Supplied roles hold: in a unit whose roles are supplied, the role variables + equal the supplied roles, so the unit has exactly one head and at most one + spouse. +3. Fallback: in a unit without supplied roles, the roles equal an independent + implementation of age ordering (the oldest adult heads the unit, earliest + member on ties; the next-oldest adult is the spouse unless any member is + separated). +4. Supplied statuses hold: a supplied filing status is the filing status, and a + supplied unit files jointly exactly when it has a spouse. +5. Workaround equivalence (differential): supplying roles and statuses through + the input variables gives the same filing status and income tax as setting + is_tax_unit_head, is_tax_unit_spouse, is_tax_unit_dependent and + filing_status directly, the policyengine-taxsim workaround. The explicit pin + must cover every unit: a situation that sets filing_status for some tax + units gives the rest the default (SINGLE) rather than the formula. +6. Switch-off locality: abolishing one status's eligibility rule re-derives the + units supplied with that status and no others. +7. Error contract: supplied roles raise exactly when malformed - supplied for + only some members, or not exactly one HEAD and at most one SPOUSE. +""" + +from functools import cache + +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +import numpy as np +import pytest + +from policyengine_core.reforms import Reform + +from policyengine_us import CountryTaxBenefitSystem, Simulation + +YEAR = 2024 +STATUSES = ["SINGLE", "SEPARATE", "HEAD_OF_HOUSEHOLD", "SURVIVING_SPOUSE"] +SETTINGS = dict( + derandomize=True, + deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@st.composite +def tax_units(draw, supplied=None, every_status=False): + size = draw(st.integers(1, 4)) + ages = draw(st.lists(st.integers(0, 90), min_size=size, max_size=size)) + separated = draw( + st.lists( + st.sampled_from([False, False, False, True]), + min_size=size, + max_size=size, + ) + ) + earnings = draw( + st.lists(st.sampled_from([0, 15_000, 60_000]), min_size=size, max_size=size) + ) + with_roles = draw(st.booleans()) if supplied is None else supplied + roles = None + status = None + if with_roles: + head = draw(st.integers(0, size - 1)) + others = [i for i in range(size) if i != head] + spouse = draw(st.one_of(st.none(), st.sampled_from(others))) if others else None + roles = ["DEPENDENT"] * size + roles[head] = "HEAD" + if spouse is not None: + roles[spouse] = "SPOUSE" + if every_status or draw(st.booleans()): + status = "JOINT" if spouse is not None else draw(st.sampled_from(STATUSES)) + return dict( + ages=ages, + separated=separated, + earnings=earnings, + roles=roles, + status=status, + ) + + +def _situation(units, pin_explicitly=False): + people, tax_unit_groups = {}, {} + for u, unit in enumerate(units): + members = [] + for i, age in enumerate(unit["ages"]): + name = f"u{u}p{i}" + person = { + "age": {YEAR: age}, + "is_separated": {YEAR: unit["separated"][i]}, + "employment_income": {YEAR: unit["earnings"][i]}, + } + role = unit["roles"][i] if unit["roles"] else None + if role and pin_explicitly: + person["is_tax_unit_head"] = {YEAR: role == "HEAD"} + person["is_tax_unit_spouse"] = {YEAR: role == "SPOUSE"} + person["is_tax_unit_dependent"] = {YEAR: role == "DEPENDENT"} + elif role: + person["tax_unit_role_input"] = {YEAR: role} + people[name] = person + members.append(name) + group = {"members": members} + if unit["status"]: + key = "filing_status" if pin_explicitly else "filing_status_input" + group[key] = {YEAR: unit["status"]} + tax_unit_groups[f"u{u}"] = group + return { + "people": people, + "tax_units": tax_unit_groups, + "households": { + "household": {"members": list(people), "state_name": {YEAR: "TX"}} + }, + } + + +def _age_rule(ages, separated): + # Mirrors the fallback as it stands, including its unit-wide separation + # gate: any separated member blocks every spouse. That gate is a known + # defect (#9620); update this reference when it is fixed. + adults = [i for i, age in enumerate(ages) if age >= 18] + head = min(adults, key=lambda i: (-ages[i], i)) if adults else None + candidates = [] if any(separated) else [i for i in adults if i != head] + spouse = min(candidates, key=lambda i: (-ages[i], i)) if candidates else None + return head, spouse + + +def _by_unit(units, values): + out, start = [], 0 + for unit in units: + size = len(unit["ages"]) + out.append(list(values[start : start + size])) + start += size + return out + + +@cache +def _abolished(rule): + """A system with one eligibility rule abolished, built once per rule.""" + reform = Reform.from_dict( + {f"gov.abolitions.{rule}": {"2000-01-01.2100-12-31": True}}, + country_id="us", + ) + return CountryTaxBenefitSystem(reform=reform) + + +@settings(max_examples=40, **SETTINGS) +@given(st.lists(tax_units(), min_size=1, max_size=4)) +def test_roles_and_statuses_follow_supplied_values_and_otherwise_rules(units): + simulation = Simulation(situation=_situation(units)) + head = simulation.calculate("is_tax_unit_head", YEAR).astype(bool) + spouse = simulation.calculate("is_tax_unit_spouse", YEAR).astype(bool) + dependent = simulation.calculate("is_tax_unit_dependent", YEAR).astype(bool) + status = simulation.calculate("filing_status", YEAR).decode_to_str().tolist() + # 1. Partition. + assert np.all(head.astype(int) + spouse.astype(int) + dependent.astype(int) == 1) + for unit, h, s, unit_status in zip( + units, _by_unit(units, head), _by_unit(units, spouse), status + ): + if unit["roles"]: + # 2. Supplied roles hold. + assert h == [role == "HEAD" for role in unit["roles"]] + assert s == [role == "SPOUSE" for role in unit["roles"]] + # 4. Supplied units file jointly exactly when they have a spouse. + assert (unit_status == "JOINT") == any(s) + else: + # 3. Fallback equals the independent age-ordering rule. + expected_head, expected_spouse = _age_rule(unit["ages"], unit["separated"]) + assert h == [i == expected_head for i in range(len(h))] + assert s == [i == expected_spouse for i in range(len(s))] + if unit["status"]: + # 4. A supplied status is the status. + assert unit_status == unit["status"] + + +@settings(max_examples=15, **SETTINGS) +@given(st.lists(tax_units(supplied=True, every_status=True), min_size=1, max_size=3)) +def test_supplied_inputs_match_the_explicit_pin(units): + # 5. Differential: input variables and a complete explicit pin agree. + supplied = Simulation(situation=_situation(units)) + pinned = Simulation(situation=_situation(units, pin_explicitly=True)) + assert ( + supplied.calculate("filing_status", YEAR).decode_to_str().tolist() + == pinned.calculate("filing_status", YEAR).decode_to_str().tolist() + ) + np.testing.assert_allclose( + supplied.calculate("income_tax", YEAR), + pinned.calculate("income_tax", YEAR), + atol=0.005, + ) + + +@pytest.mark.parametrize( + "rule,status", + [ + ("head_of_household_eligible", "HEAD_OF_HOUSEHOLD"), + ("surviving_spouse_eligible", "SURVIVING_SPOUSE"), + ], +) +@settings(max_examples=15, **SETTINGS) +@given(units=st.lists(tax_units(supplied=True), min_size=1, max_size=3)) +def test_abolishing_a_rule_moves_only_units_supplied_with_its_status( + rule, status, units +): + simulation = Simulation( + situation=_situation(units), tax_benefit_system=_abolished(rule) + ) + results = simulation.calculate("filing_status", YEAR).decode_to_str().tolist() + for unit, result in zip(units, results): + # 6. Switch-off locality. + if unit["status"] == status: + assert result != status + elif unit["status"]: + assert result == unit["status"] + + +@settings(max_examples=40, **SETTINGS) +@given( + st.lists( + st.sampled_from([None, "HEAD", "SPOUSE", "DEPENDENT"]), + min_size=1, + max_size=4, + ) +) +def test_supplied_roles_raise_exactly_when_malformed(roles): + people = { + f"p{i}": { + "age": {YEAR: 40}, + **({"tax_unit_role_input": {YEAR: r}} if r else {}), + } + for i, r in enumerate(roles) + } + situation = { + "people": people, + "tax_units": {"unit": {"members": list(people)}}, + "households": {"household": {"members": list(people)}}, + } + supplied = [r is not None for r in roles] + malformed = (any(supplied) and not all(supplied)) or ( + all(supplied) and (roles.count("HEAD") != 1 or roles.count("SPOUSE") > 1) + ) + # 7. Error contract. + simulation = Simulation(situation=situation) + if malformed: + with pytest.raises(ValueError, match="tax_unit_role_input"): + simulation.calculate("is_tax_unit_head", YEAR) + else: + simulation.calculate("is_tax_unit_head", YEAR) diff --git a/pyproject.toml b/pyproject.toml index ada6b6fa3ca..a901020f375 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ dev = [ "setuptools>=80.9.0", "build>=1.2.2.post1", "towncrier>=24.8.0", + "hypothesis>=6.100", ] [tool.hatch.build.targets.wheel] diff --git a/uv.lock b/uv.lock index b84171deda0..89b08d8937b 100644 --- a/uv.lock +++ b/uv.lock @@ -766,6 +766,94 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/07/3d0c34c345043c6a398a5882e196b2220dc5861adfa18322448b90908f26/huggingface_hub-1.3.4-py3-none-any.whl", hash = "sha256:a0c526e76eb316e96a91e8a1a7a93cf66b0dd210be1a17bd5fc5ae53cba76bfd", size = 536611, upload-time = "2026-01-26T14:05:08.549Z" }, ] +[[package]] +name = "hypothesis" +version = "6.168.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/b7/13118bbc45d6d8b9d04e2de779e2a4ff23145ea39efa692b994fb874ca72/hypothesis-6.168.3.tar.gz", hash = "sha256:a43388f9067678fef6e13bdff325b6cfa6961a590498bb37f7ff31589c83bc75", size = 511022, upload-time = "2026-09-28T05:20:58.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/cd/746a2fc1e5e5ba34f07ea6ee1ad07525a3dde5ff4836db3a7980af49a891/hypothesis-6.168.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d20972ca134e652e9928ecd200966a8a2857adc2812c1d74b12a872e5cd503eb", size = 791536, upload-time = "2026-09-28T05:18:57.162Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0f/7a158e377b69556c8e12c25c8fd811d0103e54c24a12dab1f3b9819f202e/hypothesis-6.168.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:eafbec09d3e87d13d8242411d1f5868b5e879f1bd6d95e233e9ff80c527bea1c", size = 787313, upload-time = "2026-09-28T05:20:18.122Z" }, + { url = "https://files.pythonhosted.org/packages/45/c6/7df9104ee359e8fcd77791781c47e70794964ca69673665e0de2a6590f4c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73c5627497968cc62d140e9fde1ea21e12cac7b64dc419fea8286cd34ff1ad4e", size = 1124036, upload-time = "2026-09-28T05:20:04.372Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/8a61715404a73b9a82cb1f976803428d603cdea976a9c30266c72ad6a7ce/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dc56e6dc6eeb0aeb08ac463f847279bde2336c4786faf7ee0af4b93cd031a7", size = 1147875, upload-time = "2026-09-28T05:19:15.495Z" }, + { url = "https://files.pythonhosted.org/packages/e4/3f/2b16e95cc3b9a069afa23830a012aefe377ce4a457a227e5a5026eb827ff/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:753bb501f8560d2e321ed3b62596b4496c56f15668b0a0669231ccc3e6c4e80d", size = 1149489, upload-time = "2026-09-28T05:19:18.886Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e7/d7cf6dc068bb02732b2a6e38a35a0dcb7f2137608198fc79d740f69d197c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71ab606c472449cb872ef2a7acaec679ee4f651b7f0a477a04ebc85d8933ef8c", size = 1191992, upload-time = "2026-09-28T05:19:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/5f/26/f1e5b25dec15998e8375d722825ec3dae1075cbe7cb1f74221b2312a2e33/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26928956c54748e4dfa587333741ab750246123b93484955646ff316cca27eef", size = 1169911, upload-time = "2026-09-28T05:19:27.996Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/901234a49147e6bf8a5b9e54b775706a223c2b52befea1106ea2aaf15d48/hypothesis-6.168.3-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:0bdfc53c041b61c854fa3761735736b991bc2bddafee45a361cfe4d43027c1ef", size = 1129406, upload-time = "2026-09-28T05:18:42.632Z" }, + { url = "https://files.pythonhosted.org/packages/55/c1/5fc9ff91ec9fba6bb527833386c66812524b5cc75fe7d4a4f9122ee1c420/hypothesis-6.168.3-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:650528e2b1e2a45e95c2df624d4b9364b8ac5a027ba84e1eea2bc5398dd01dcd", size = 1160399, upload-time = "2026-09-28T05:20:23.911Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d2/49ad1ef5c55547c8abfd0fc571b3493806dda530fd5cb3d8263dbad86a72/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:209dc54cdb1b4d6d7020ad8d09e44c49756361b7183adacea4d6f5705085b595", size = 1299906, upload-time = "2026-09-28T05:18:58.417Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5f/f98a26094c4f462c4215807bed0f9a6b5508b27a3c329bcba1bc8adc7ac7/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:af8ca98cd11dc7f9427bb90483abcd9688d4df2e8e63893a30a2423b027ebb11", size = 1425538, upload-time = "2026-09-28T05:20:49.074Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b8/c5b4406e3a6591e51a42f85469351d1d824e0a54b84167aec9b2353759fe/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:f8122cfdd0bc0ba843063effa22ac310bdebdb3a1319bf1e63f14baa119c72ab", size = 1377084, upload-time = "2026-09-28T05:20:06.618Z" }, + { url = "https://files.pythonhosted.org/packages/e0/01/0d84a8ea469d024c15f602799c0b8410fbe03d99fdb0370449b33c3c916f/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4bedbb379eab34f792af7ee9a05aae04e9c08bbb52e0f90f5a2110d4fe4b2fbd", size = 1281162, upload-time = "2026-09-28T05:19:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/22/b5/ea6435038de1a795f005cb603531e8ccc053febc50180f3ab55583c9c60e/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:5a953115b9f5c95133ab2d04efffeec96e5658c3207923dca7285f7db3e6bbef", size = 1300433, upload-time = "2026-09-28T05:18:55.634Z" }, + { url = "https://files.pythonhosted.org/packages/6c/78/fa6c77d9f64b6d592e69e582d83cbce7d7e56897faa4762885a2123b0166/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6173558e676ad25ed1e20507fa4024a0d816dd90f715f77006e5a28f19109026", size = 1336273, upload-time = "2026-09-28T05:19:08.273Z" }, + { url = "https://files.pythonhosted.org/packages/f1/56/4acd0d778bab818c9ea336fcba768bf43bc70db7f84cc373579298df4641/hypothesis-6.168.3-cp310-abi3-win32.whl", hash = "sha256:dc66390fb12d80585aa9222bf538ce8b7aa22cf5d118250647355a1c9e8f62f4", size = 678211, upload-time = "2026-09-28T05:19:23.355Z" }, + { url = "https://files.pythonhosted.org/packages/b0/db/0a02b146ad1c30f9716362f2e1a379dfd6b0f7be03ac1e22c9da24c9e2a9/hypothesis-6.168.3-cp310-abi3-win_amd64.whl", hash = "sha256:92325b276360fe86c5bf71a568c0d53a6d140b0de36dcf029f9164a17803bb24", size = 684906, upload-time = "2026-09-28T05:18:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c0/958deaf726848f96f52250740bf39f13f476b068e22f73e2b358eaa07532/hypothesis-6.168.3-cp310-abi3-win_arm64.whl", hash = "sha256:3cf6f1eeaf41cd8d60cf1f88fde905ca1dd77c906929a507d6ac7f66f2ccba2a", size = 683292, upload-time = "2026-09-28T05:19:57.404Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ab/443208c2988a24eaafbfbd6791b897ad9a8a1dd7f870d5b455d7d15a28ad/hypothesis-6.168.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9029775dc2e25e3e8e596c4306926f0079158353631c7c52c285bbc5c8073c2b", size = 792239, upload-time = "2026-09-28T05:19:59.144Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7b/619c1a76c52de2d3cfd68a2f9876c09464d63097db5bf86a28b62dc37bb1/hypothesis-6.168.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e1d91530cefdb9e0b6467c203eb509c61a11d70480a37db59cb53a2f9913102", size = 788113, upload-time = "2026-09-28T05:19:17.145Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/33841e8833105f850214a486d83740ba52041892ca94e687a05829c0ce83/hypothesis-6.168.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0df00cbe8133aa220308d11fa28e3add996cf5102a39b279c1d711015fd9114b", size = 1124241, upload-time = "2026-09-28T05:18:37.589Z" }, + { url = "https://files.pythonhosted.org/packages/4b/43/c84a7900feced6a420e824ab61d316e4f59a6887d991bc143ecc294a09ed/hypothesis-6.168.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7f75a2e23288ee82ee965a952473d9c5447c2cbc1afc94be09d2402201774e", size = 1170406, upload-time = "2026-09-28T05:19:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/47/5d/6e2a32b9c3020af7a06b00fe446a2c5155ae76ac468c0b68d7188e524002/hypothesis-6.168.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc2b1c37631f2231dd0d077225aa5908fc2bd34a4c3faf475053b0bc2eb24c95", size = 1300339, upload-time = "2026-09-28T05:20:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f9/78/19f3c9eb9a5c215b9fbde57be18ef7c1c78afa509b460d4e8f86026d741a/hypothesis-6.168.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:73960a58f6efc8cbc57d8ad0647955f7c5b25f3d562e3eaa57afa1c69894f7aa", size = 1336484, upload-time = "2026-09-28T05:19:02.841Z" }, + { url = "https://files.pythonhosted.org/packages/88/bb/d46c4519e989e022667705d8a56e83a09ad4ea50eae07ca4bb0f4708dd23/hypothesis-6.168.3-cp311-cp311-win_amd64.whl", hash = "sha256:3f1122223759acc0fe5301c1ae505d762ee61af91db7b191a791ccdbcd965462", size = 684646, upload-time = "2026-09-28T05:20:00.842Z" }, + { url = "https://files.pythonhosted.org/packages/7a/56/44a17266bcf1add5fba7c3df5c3920396a5e924d917fb0534a6f9e2b9b9a/hypothesis-6.168.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bff12e036b67abc5ded682f724f4d7afaff5223190270a0763695f218c079068", size = 793313, upload-time = "2026-09-28T05:20:22.045Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/1a25b11e9133cb54c02ee6cf99a61296ad461aabc7aae3399e1d0ff1f095/hypothesis-6.168.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c73c6188056e6dc110260e39d141c69c3d02f23e3ba1d8610c7a8b6f2510ec96", size = 784855, upload-time = "2026-09-28T05:19:34.496Z" }, + { url = "https://files.pythonhosted.org/packages/8b/da/728d3210f0b0089337f37206a100d31ef546d1f99839e709f0a9ca443f8e/hypothesis-6.168.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb1063cb794765097b8c0add598bd34a904f44dc15db81275dd1f0ed1ac567ed", size = 1123043, upload-time = "2026-09-28T05:19:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/53/86/bfb9ec344a8b3ef2fa6f59bb3d30c5b3a7d86d594107670539c34ee53d1b/hypothesis-6.168.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b2d47f5d9060b049039bef0b267e88480f6468f95ee394e514e84a81ccdc5f5", size = 1169121, upload-time = "2026-09-28T05:20:40.512Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e0/5a76dbafbcaf8fff075f09377cddefa17289ef05dbf51878dde88385b966/hypothesis-6.168.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8367f623a98cbf90f33fd2a43b100671b152b5975bc343a578290945f52f7018", size = 1298749, upload-time = "2026-09-28T05:19:32.883Z" }, + { url = "https://files.pythonhosted.org/packages/28/15/0e65af96b353f2c4bdc283c53ed0980e5ac5009afe97ef3b23c77b364664/hypothesis-6.168.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd81241f4cd76fb18d481e87b9688b30a0cb5c30841730513323ba1c7aba1837", size = 1335271, upload-time = "2026-09-28T05:20:54.005Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c5/37eab2155664aa544df321f6d3940b89c6702d79a72fdc98d7b5a2286f8c/hypothesis-6.168.3-cp312-cp312-win_amd64.whl", hash = "sha256:377438de53afb94347d9845b7d90db5905c6d2a8d612108e9938e0f80503bb90", size = 682211, upload-time = "2026-09-28T05:18:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a2/6787da846d929e52fc3344d803299c45782dbeac287528af08380e984bf8/hypothesis-6.168.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b230a850de63334c16654a34a2d547e0179d36b9071d4439b3e7237f6d077e7", size = 793254, upload-time = "2026-09-28T05:19:36.294Z" }, + { url = "https://files.pythonhosted.org/packages/89/ba/2893f5ca42501f4d562cba3229c8994c3a3e5fac66ef60c1e0e0b5220a5d/hypothesis-6.168.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d63b0226cd3e0d8bdd97c3384b22a21934ed4d53246c1c93575dada616672499", size = 784699, upload-time = "2026-09-28T05:18:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/b7/aa/7d7349daf75b71f6f35876f8de115e974c5c04d600e86df1b2779b0883ab/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0369f5df055f96e117ab12e5f249668ff144731ab5280bc7a205fdf81f990b89", size = 1123016, upload-time = "2026-09-28T05:20:36.372Z" }, + { url = "https://files.pythonhosted.org/packages/00/f0/7774e1ea072708ea46cb25c4aeee5f9978b6c271764809069e8a6789858e/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f076bcd0f77fdcdb7797826c879099573d03a02e228ebe649ea917081b962ac0", size = 1168989, upload-time = "2026-09-28T05:20:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7d/113992abed9efbd7944e3a496a382da58152dce126fddf6419ff31a0a57d/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b823ba1fcec8da730f29316d010b06d3f7e0c3828dcf91020e24c55e7d24652a", size = 1298626, upload-time = "2026-09-28T05:19:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/d5/65/3659fa5e733027e5b37a27486e57f4053535e40853d23501c422bb275043/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dd2849c269d674e4618590f3b48d443bd4c06b5aef3d8d869086c2e6d213d248", size = 1335184, upload-time = "2026-09-28T05:20:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/b4/6c/35aab2221b5ea65125340f236e1a765a9e9f3b28d89a389ea0033fed29f7/hypothesis-6.168.3-cp313-cp313-win_amd64.whl", hash = "sha256:3ef7d26f5789e691401d5f87eafed9bd2763f0dbe47af6d6b66012a509404766", size = 682173, upload-time = "2026-09-28T05:19:12.762Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/27b0cb56ff5d2bc92458bf6fcebdb1f0dc01f57f043a178e9f9d74498169/hypothesis-6.168.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e2d4c68729a13df9af4998d2652cfb5d541c5609b88c306880a5dfb284938ec2", size = 793287, upload-time = "2026-09-28T05:20:38.522Z" }, + { url = "https://files.pythonhosted.org/packages/e8/bd/5342f95c3bc36586777ef8cb8eba87ac7acf0184210327fb0e6d8654e295/hypothesis-6.168.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b345f818083ec99966a43ca4f7b38feb62c6920ce28572bc2bde4948a8b7eaba", size = 784857, upload-time = "2026-09-28T05:20:42.609Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5f/fc774241518a0588d362680a3084275058aab86d6bc02a2c61e1073142d0/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0608c610fc002978fc5de0f471770d8817e9455cb8649a47983c9f8bccfc1897", size = 1123330, upload-time = "2026-09-28T05:19:29.735Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/131f16775a3dca5f4fe27f0d6ad9a9851600098a54a872d163f6ebf0b664/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dcf6448b1ecc37f2b23f2d1b3ddfc9ff6b6910614c15a642dc82f419b96037b", size = 1169178, upload-time = "2026-09-28T05:19:53.436Z" }, + { url = "https://files.pythonhosted.org/packages/06/61/c9f5bff8b73321c9fd12f2d69666c6e4861b97b60f5f24fdd6ac293007fd/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f9f094041dcce5119ebd7bab71062743ab02b6e654d056b370beda78c19e2", size = 1299127, upload-time = "2026-09-28T05:20:14.486Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6d/d4618f8ab12dd76c4d58ea052252759aa2bf0c4e71f4b502ba5d577e0b9f/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d479985fe73af97badfb72dc6d20c6a353e486a36f6d065f600026e0cf954a86", size = 1335390, upload-time = "2026-09-28T05:20:10.326Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/727161e17cc297df1aeea945de05c532033b66751625bec192bc5b5ff707/hypothesis-6.168.3-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:785e2c45f8c08e274b4bf1ccae97f1a4e09407e9a68e80790cfab1d17a4a45fa", size = 624291, upload-time = "2026-09-28T05:18:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/77/23/2f9b506392a0b8712440bcb781abc5713be9ffac1303b6d10f3669050882/hypothesis-6.168.3-cp314-cp314-win_amd64.whl", hash = "sha256:320920b1e3dae8611eee8a03d063cf2187446f2a17c38cfb8a7fc1466f71eee2", size = 682064, upload-time = "2026-09-28T05:20:16.336Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/efabfd95eb2b69c0c9fa1e1c83c8290aa2715d46baeb5a7764faf09c133a/hypothesis-6.168.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:35380baa981108a7f60c4eab71e46acd8d8f58440520346a1a6aba06dca7e074", size = 791875, upload-time = "2026-09-28T05:19:37.789Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fc/2a0ada1623a9a33048bbf9f00a7c92513398c6b8865cba5efb854262ce30/hypothesis-6.168.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f0aaaed00438fa6673d856aaee12a4a8afbde82a9f3c5ff487cacfb064239afe", size = 783412, upload-time = "2026-09-28T05:18:48.266Z" }, + { url = "https://files.pythonhosted.org/packages/41/31/73c615d37eeb12da0209555612c32ced628d860267c23cfd2691c6207aea/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27e6df1576bf838e7f484d4ae5cba92114497ca30afb917056bf1ea33e95675", size = 1121662, upload-time = "2026-09-28T05:19:26.225Z" }, + { url = "https://files.pythonhosted.org/packages/92/54/1893344a3b8bbf83fa7c9bb7e96f6836580213854f46671cb749f7b4706c/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3bc85014577982ec6d2e266edc5cd6e7a7d3674c648791ca983c67a52f89a0c4", size = 1167761, upload-time = "2026-09-28T05:18:49.85Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/a61d82968febd25a0f08ffbbfabfaeead66f68fc5fa73f3c9ece1c36fc8f/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ccfc29505aa1cdcc254cb9cd701fd0811fef5480addd97d4127a00df023410f7", size = 1297309, upload-time = "2026-09-28T05:19:51.711Z" }, + { url = "https://files.pythonhosted.org/packages/bc/01/fe8e4cf6d39d02f4efadaa351427fa13722086cfc54a2da3579741c5177a/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32d0699566aaa93f9e97a44705d7164386f1e91de78d277f53bada35e78bcc96", size = 1334260, upload-time = "2026-09-28T05:19:00.121Z" }, + { url = "https://files.pythonhosted.org/packages/39/6a/09177ebc62f4778f94379ecade6a51299d3d8c4f4b75ba636bf3a0202364/hypothesis-6.168.3-cp314-cp314t-win_amd64.whl", hash = "sha256:28d88fa174ecbd4ecbd7bb290f06d0db084a3971c3f511ae2830b65b5e25500f", size = 681992, upload-time = "2026-09-28T05:19:48.043Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f6/890bf33d608cd63348d3146a3ca83362fbf09e589c5e5230a00c403070f4/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:61f5782d807b1e6aa5c9beef1054cb2037e7d1add48a64972cd6ee447778781f", size = 791231, upload-time = "2026-09-28T05:18:43.998Z" }, + { url = "https://files.pythonhosted.org/packages/03/dd/fc36b204f8aa7437c676576d9437f0422de46aa8a729e6cd5bbb0224e759/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:7aacf3cf40c7ce8f9e4924d5b57bc0b068beafdfed2347bcf160a28b4cfbba7b", size = 783171, upload-time = "2026-09-28T05:19:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d2/3cd087d577db67c404991d7723e64d7cd75570cdc82a0f7329aed6f2086c/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01768a03a30dc54df7fe457c0b34016c84d598fa00d5965ededab93ba4eb3408", size = 1121152, upload-time = "2026-09-28T05:18:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/42/6f/a05a68cc66e7a22701c50bf94fb9ddeb36f4f5d0de3cc130066709eda621/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1a8a4ffc6c6e6e577f2bfbcfebf7cffbb310283ba2532c729570a0a752cebaaa", size = 1144069, upload-time = "2026-09-28T05:20:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f4/fdd7a093fb2fb3a0ce24256c92ad5bf67936f4669bd06d5ceae4298150ec/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b987d73eba95183a7e59cca6d1925c588aa4307922d9852cc2b4d282a2ae4128", size = 1146692, upload-time = "2026-09-28T05:20:25.83Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/6dbd4377e935505ae4fc8ee4c7b18c69994c4bbaca015447c470218bdbee/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4569c39bd97d9573e946429ed676f3b55a7c8ed80920addd67d384a47d59b38", size = 1189480, upload-time = "2026-09-28T05:20:46.734Z" }, + { url = "https://files.pythonhosted.org/packages/ba/bf/ff288b496b690000d2686dcfa7f67855e4c5a6dddb464ed8e4880be83bb2/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6042b8707a4b25bbbfe20b110258fb7549a68951e5525608a8ce06091039e9fc", size = 1167109, upload-time = "2026-09-28T05:19:24.758Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d1/3fee2bc29fc747fabbb27e174515592e390809ca465d3bbbabbcfa3235a8/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:f413629de94d38a7a2ad259697a6752526143cb49c2e2d7bdba19381c80693f6", size = 1126823, upload-time = "2026-09-28T05:18:52.636Z" }, + { url = "https://files.pythonhosted.org/packages/58/6a/585294392fa6a6d9719446a042a777ba98ecca265d9e48cbedc10c88df0f/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f071737e4e775bebba07e319eb645a880d1e0186b4d24bad23255e849d86d483", size = 1155773, upload-time = "2026-09-28T05:20:51.268Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4d/fa283ff79996debf1ff08593e01f8b773eb8211b19b3d6d5d491317a65bf/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:f59a3912858d0609c26054aa1c474847c797937f5e0e1c77f0d3f08032e50f09", size = 1296671, upload-time = "2026-09-28T05:18:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/42/05/98f9c2f628da5afabb6c3e8df5d2d299d2d50bf306b14a6e26d3954c4951/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:f09c05a23a8025dd5cad07c2ed299a46778e72d49ff0dd5bf353bcc0f7dc1580", size = 1422001, upload-time = "2026-09-28T05:19:04.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c9/f2109ade29a7ec284d55bca328d784743e8b13e321576fd34ee7e65f99af/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:44ace770bda3a0301739fc5a413c764de790c739df1f1a7218dd049f8594d9f5", size = 1374182, upload-time = "2026-09-28T05:20:27.764Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6d/e05d5f72441564a3bebc71fa155deafd0fd3b015d6014ec8a00edf6e42bb/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:03b131043608f94a2578a2a896a1a72092acb7079815eef5b8513b08447e0e62", size = 1278402, upload-time = "2026-09-28T05:20:56.332Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/6988a7f1f69c5c530687dce03a32c157e2d878e3a2e32af9269ce016b78a/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:e9784aca26eddfe99b03a0292320db742cc8a74200ef864fdce949f526f973cd", size = 1297770, upload-time = "2026-09-28T05:19:11.387Z" }, + { url = "https://files.pythonhosted.org/packages/a2/28/5bb82b60b836bd2329e1fe01ad94efc2b14dfa806f8e50ed14b795d78770/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:2b52ac363096232bebc2add117e9178d91f4248f4cdb919fd1026b5f86a4bb16", size = 1333977, upload-time = "2026-09-28T05:20:02.483Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7d/d419841b8f65481ea1a50c4ba36f2670d48b8c7a51e4e8586089564cf7f6/hypothesis-6.168.3-cp315-abi3.abi3t-win32.whl", hash = "sha256:d28e3a6b511a74ce37df5274b51f36c2b274fea7365e00b16f0c81e22acd5957", size = 675394, upload-time = "2026-09-28T05:19:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/1de6a2ad100e44817f2e9a8e8ba3eeaf4769621f4f87f2d4966c4971fcc4/hypothesis-6.168.3-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:7b9638789548361a57d984f56619ac694a914c328181d911271618409261ff4a", size = 681699, upload-time = "2026-09-28T05:19:09.957Z" }, + { url = "https://files.pythonhosted.org/packages/c0/79/be3370fd02734d6b1d950183ae9224580d19fa8d356b95348d60f1e3eeb7/hypothesis-6.168.3-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:65d78e4357ec48ed2c67825f06740ee3599be4cfe770a6092bed07108d679ac5", size = 679849, upload-time = "2026-09-28T05:19:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0e/27960db1d45a94497e1681fb9d7b9140b44da4e655ba20f0d82f95d10230/hypothesis-6.168.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:101531b4ccf7fa12965a6b295d10b64c2f8a8fab61e1f9b18c55335d8fb02575", size = 793135, upload-time = "2026-09-28T05:19:14.056Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cb/be08379286c1925fcd651b92ed835f470a83b859b5c484b3431da9d851f5/hypothesis-6.168.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:41e0120814de5c3a6b58d8cb27cb11b8873ab33fba130cd09855a27f5a12a1ca", size = 788995, upload-time = "2026-09-28T05:20:34.164Z" }, + { url = "https://files.pythonhosted.org/packages/38/7b/6d885d139b2ed76e2f4908cf10eae56acc73bf05cbef286357c4559c9a04/hypothesis-6.168.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d120009d145697909f2f0c532f2d568558ddd6990707f8cba6576bfb9770696", size = 1124974, upload-time = "2026-09-28T05:19:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/05/cc/b302ae809b48254830cae57801d0af0b66783df515b5dd56b9f21c8dfa86/hypothesis-6.168.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:818b3d09bba60ef90463e47a4bc87275dba1e8aeb184d3102888440ca7c7837d", size = 1171967, upload-time = "2026-09-28T05:20:12.295Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ab/51031080a6600f5b07adf5f9dfa117345c2adda07a5c24bda523e70bd1e9/hypothesis-6.168.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9e3c36eba80e089ecfa28ec08049723223cb41d3cfe4d9988169cf98440e402b", size = 685641, upload-time = "2026-09-28T05:19:44.609Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -1746,6 +1834,7 @@ dev = [ { name = "build" }, { name = "coverage" }, { name = "furo" }, + { name = "hypothesis" }, { name = "jupyter-book" }, { name = "ruff" }, { name = "setuptools" }, @@ -1757,6 +1846,7 @@ requires-dist = [ { name = "build", marker = "extra == 'dev'", specifier = ">=1.2.2.post1" }, { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.9.2" }, { name = "furo", marker = "extra == 'dev'", specifier = ">=2024.8.6" }, + { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.100" }, { name = "jupyter-book", marker = "extra == 'dev'", specifier = ">=1.0.4.post1" }, { name = "microdf-python", specifier = ">=1.0.0" }, { name = "pandas", specifier = ">=3.0" }, From c0060170e51e1fe638ce19695c2d8824aeb8a6ba Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 00:23:44 -0400 Subject: [PATCH 4/9] Guarantee each abolition property case holds its target status Independent review of d8b02c1ef0 found the switch-off-locality property could generate no unit supplied with the abolished status, so it never exercised the re-derivation it claims to check. Each example now holds one forced target unit beside other supplied units. Also narrow the docs and changelog: a dependent's income is left out of the claiming return's gross income, but the deductible share of a dependent's self-employment tax still lowers the filer's AGI through self_employment_tax_ald. Co-Authored-By: Claude Opus 5.5 --- changelog.d/source-tax-unit-roles.added.md | 2 +- docs/usage/microsimulation.md | 15 ++++++---- .../test_tax_unit_role_input_properties.py | 30 +++++++++++++++---- 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/changelog.d/source-tax-unit-roles.added.md b/changelog.d/source-tax-unit-roles.added.md index 2bcb8aa760c..bd894e185c3 100644 --- a/changelog.d/source-tax-unit-roles.added.md +++ b/changelog.d/source-tax-unit-roles.added.md @@ -1 +1 @@ -Tax-unit roles and filing statuses supplied by a dataset (tax_unit_role_input, filing_status_input) now determine is_tax_unit_head, is_tax_unit_spouse, is_tax_unit_dependent and filing_status, with age ordering and the filing rules as the fallback. On the certified Populace default build this replaces age-ordered roles with the build's own: adult students and other adult dependents are no longer paired with a parent as joint filers, couples are headed by the build's head, and lone minors head their own returns. In 2024, joint returns fall by 3.07 million and federal income tax by $0.94 billion net. Because the model computes no return for a tax-unit dependent, the income of adults the build makes dependents no longer counts toward any return, which lowers adjusted gross income by $35.9 billion. +Tax-unit roles and filing statuses supplied by a dataset (tax_unit_role_input, filing_status_input) now determine is_tax_unit_head, is_tax_unit_spouse, is_tax_unit_dependent and filing_status, with age ordering and the filing rules as the fallback. On the certified Populace default build this replaces age-ordered roles with the build's own: adult students and other adult dependents are no longer paired with a parent as joint filers, couples are headed by the build's head, and lone minors head their own returns. In 2024, joint returns fall by 3.07 million and federal income tax by $0.94 billion net. Because a tax-unit dependent's income is left out of the claiming return's gross income and the model computes no separate return for dependents, the income of adults the build makes dependents leaves the income tax base, which lowers adjusted gross income by $35.9 billion. diff --git a/docs/usage/microsimulation.md b/docs/usage/microsimulation.md index 3fb3b9bf47e..06f4eb9b08e 100644 --- a/docs/usage/microsimulation.md +++ b/docs/usage/microsimulation.md @@ -178,12 +178,15 @@ not re-derive it. A reform that switches off one of those rules, by neutralizing re-derives the units supplied with that status and leaves every other supplied status in place. -Two consequences follow from honoring supplied roles. A dependent's own income -counts toward no return, because `irs_gross_income` excludes tax-unit dependents -and the model computes no separate return for them, so an adult dependent's -earnings leave the income tax base. And a minor can head a return, which -programs that identify minor parents as a tax-unit head or spouse under 18 will -treat as a minor parent. +Two consequences follow from honoring supplied roles. First, a dependent's +income is left out of the return they are claimed on, because +`irs_gross_income` excludes tax-unit dependents, and the model computes no +separate return for them, so an adult dependent's earnings leave the income tax +base. The exception is self-employment: the deductible share of a dependent's +self-employment tax still lowers the filer's adjusted gross income through +`self_employment_tax_ald`. Second, a minor can head a return, which programs +that identify minor parents as a tax-unit head or spouse under 18 will treat as +a minor parent. ### Filtering by geography diff --git a/policyengine_us/tests/core/test_tax_unit_role_input_properties.py b/policyengine_us/tests/core/test_tax_unit_role_input_properties.py index 235884217b5..c8e1aada1d4 100644 --- a/policyengine_us/tests/core/test_tax_unit_role_input_properties.py +++ b/policyengine_us/tests/core/test_tax_unit_role_input_properties.py @@ -45,7 +45,8 @@ @st.composite -def tax_units(draw, supplied=None, every_status=False): +def tax_units(draw, supplied=None, every_status=False, status=None): + """One tax unit; ``status`` forces a supplied unit with that non-joint status.""" size = draw(st.integers(1, 4)) ages = draw(st.lists(st.integers(0, 90), min_size=size, max_size=size)) separated = draw( @@ -58,18 +59,25 @@ def tax_units(draw, supplied=None, every_status=False): earnings = draw( st.lists(st.sampled_from([0, 15_000, 60_000]), min_size=size, max_size=size) ) - with_roles = draw(st.booleans()) if supplied is None else supplied + forced = status + with_roles = forced is not None or ( + draw(st.booleans()) if supplied is None else supplied + ) roles = None status = None if with_roles: head = draw(st.integers(0, size - 1)) others = [i for i in range(size) if i != head] - spouse = draw(st.one_of(st.none(), st.sampled_from(others))) if others else None + spouse = None + if others and forced is None: + spouse = draw(st.one_of(st.none(), st.sampled_from(others))) roles = ["DEPENDENT"] * size roles[head] = "HEAD" if spouse is not None: roles[spouse] = "SPOUSE" - if every_status or draw(st.booleans()): + if forced is not None: + status = forced + elif every_status or draw(st.booleans()): status = "JOINT" if spouse is not None else draw(st.sampled_from(STATUSES)) return dict( ages=ages, @@ -198,10 +206,20 @@ def test_supplied_inputs_match_the_explicit_pin(units): ], ) @settings(max_examples=15, **SETTINGS) -@given(units=st.lists(tax_units(supplied=True), min_size=1, max_size=3)) +@given(data=st.data()) def test_abolishing_a_rule_moves_only_units_supplied_with_its_status( - rule, status, units + rule, status, data ): + # Every example holds at least one unit supplied with the target status, + # beside other supplied units. + target = data.draw(tax_units(status=status), label="target") + others = data.draw( + st.lists(tax_units(supplied=True, every_status=True), max_size=2), + label="others", + ) + position = data.draw(st.integers(0, len(others)), label="position") + units = others[:position] + [target] + others[position:] + assert sum(unit["status"] == status for unit in units) >= 1 simulation = Simulation( situation=_situation(units), tax_benefit_system=_abolished(rule) ) From 0781d5f1210b99b8f627c28f0108b0f9df77933d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 08:19:02 -0400 Subject: [PATCH 5/9] Honor supplied tax-unit roles only; compute filing status Filing status is a policy calculation, so the model computes it from the household's facts, as on main. Supplied data may set who is head, spouse or dependent, but not the filing status. - filing_status.py returns to main's formula; no supplied status is read. - Remove filing_status_input and its YAML tests. The Populace build's filing_status_input column now matches no variable, so the loader skips it. - Revert the romney remove_head_of_household cases, which tested only how the repeal re-derived a supplied status. - Keep tax_unit_role_input, tax_unit_roles_supplied and the head/spouse formulas composed with #9630's inference. - Unit tests: drop the supplied-status tests; add a dataset test showing a filing_status_input column that disagrees with the rules is not read, in the data year and an extended year. - Property tests: roles only. Filing status is joint exactly when there is a spouse; the role input matches pinning the role flags (neither side pins filing_status); a new property composes supplied roles with input dependents, against an oracle that skips them as #9630 does. - Microsim test: check the build's roles only. - Docs: roles only, the fallback as it stands after #9630, and dependents' above-the-line deductions after #9801. Co-Authored-By: Claude Opus 5.5 --- docs/usage/microsimulation.md | 68 +++---- .../tests/core/test_tax_unit_role_input.py | 146 +++++--------- .../test_tax_unit_role_input_properties.py | 186 ++++++++---------- .../tests/microsimulation/test_microsim.py | 28 +-- .../tax_unit/filing_status_input.yaml | 125 ------------ .../tax_unit/tax_unit_role_input.yaml | 34 ++++ .../remove_head_of_household.yaml | 66 ------- .../demographic/tax_unit/filing_status.py | 54 +---- .../tax_unit/filing_status_input.py | 20 -- .../tax_unit/tax_unit_role_input.py | 2 +- 10 files changed, 202 insertions(+), 527 deletions(-) delete mode 100644 policyengine_us/tests/policy/baseline/household/demographic/tax_unit/filing_status_input.yaml delete mode 100644 policyengine_us/variables/household/demographic/tax_unit/filing_status_input.py diff --git a/docs/usage/microsimulation.md b/docs/usage/microsimulation.md index 06f4eb9b08e..38512609fdd 100644 --- a/docs/usage/microsimulation.md +++ b/docs/usage/microsimulation.md @@ -141,52 +141,44 @@ sim = Microsimulation(dataset=single_year_dataset, dataset_end_year=2024) paths. It does not apply to an existing `USMultiYearDataset` or to the legacy variable-centric HDF5 format. -### Tax-unit roles and filing status +### Tax-unit roles A population file can supply the tax units its constructor built, not just -their membership: - -- `tax_unit_role_input` (person): `HEAD`, `SPOUSE` or `DEPENDENT`. -- `filing_status_input` (tax unit): `SINGLE`, `JOINT`, `SEPARATE`, - `HEAD_OF_HOUSEHOLD` or `SURVIVING_SPOUSE`. - -The certified Populace build supplies both. When every member of a tax unit has -a supplied role, `is_tax_unit_head` and `is_tax_unit_spouse` follow it and -`is_tax_unit_dependent` covers everyone else; a supplied filing status becomes -`filing_status`. Explicitly set `is_tax_unit_head`, `is_tax_unit_spouse`, -`is_tax_unit_dependent` or `filing_status` inputs still take precedence. - -Without supplied roles, a unit falls back to age ordering: the oldest adult is -the head, and the next-oldest adult the spouse unless any member of the unit is -separated. That pairs an adult student with a parent as joint filers and leaves -a minor living without a parent with no head. Without a supplied status, the -status is derived from the unit's members and the filing rules. - -Supplied values fail closed rather than falling back. Each of these raises a +their membership, through `tax_unit_role_input` (person): `HEAD`, `SPOUSE` or +`DEPENDENT`. The certified Populace build supplies it. When every member of a +tax unit has a supplied role, `is_tax_unit_head` and `is_tax_unit_spouse` +follow it and `is_tax_unit_dependent` covers everyone else. Explicitly set +`is_tax_unit_head`, `is_tax_unit_spouse` or `is_tax_unit_dependent` inputs +still take precedence. + +Without supplied roles, a unit falls back to age ordering. The oldest adult is +the head, skipping adults input as tax-unit dependents unless every adult in +the unit is one. The next-oldest such adult is the spouse, unless any member of +the unit is separated. Without dependent inputs, that pairs an adult student +with a parent as joint filers and leaves a minor living without a parent with +no head. + +Filing status is not supplied. It is a policy calculation, so `filing_status` +is always computed from the unit's members, including any supplied roles, and +the filing rules, and reforms to those rules apply to every unit. The Populace +build also carries its constructor's filing status in a `filing_status_input` +column. No variable has that name, so the column is not loaded. + +Supplied roles fail closed rather than falling back. Each of these raises a `ValueError`: - roles supplied for only some members of a unit; -- a unit without exactly one `HEAD`, or with more than one `SPOUSE`; -- a `JOINT` status in a unit without a spouse, or a non-`JOINT` status in a - unit with one. - -A supplied status is a fact about the record, so parameter reforms to the head -of household and surviving spouse rules, such as the dependent age limits, do -not re-derive it. A reform that switches off one of those rules, by neutralizing -`head_of_household_eligible` or `surviving_spouse_eligible` (as -`remove_head_of_household` does) or through its `gov.abolitions` parameter, -re-derives the units supplied with that status and leaves every other supplied -status in place. +- a unit without exactly one `HEAD`, or with more than one `SPOUSE`. Two consequences follow from honoring supplied roles. First, a dependent's income is left out of the return they are claimed on, because -`irs_gross_income` excludes tax-unit dependents, and the model computes no -separate return for them, so an adult dependent's earnings leave the income tax -base. The exception is self-employment: the deductible share of a dependent's -self-employment tax still lowers the filer's adjusted gross income through -`self_employment_tax_ald`. Second, a minor can head a return, which programs -that identify minor parents as a tax-unit head or spouse under 18 will treat as -a minor parent. +`irs_gross_income` excludes tax-unit dependents, and so are their +above-the-line deductions. The model does not yet compute a dependent's own +return ([#9618](https://github.com/PolicyEngine/policyengine-us/issues/9618)), +so an adult dependent's earnings leave the income tax base. Second, a minor can +head a return, which programs that identify minor parents as a tax-unit head or +spouse under 18 will treat as a minor parent +([#9619](https://github.com/PolicyEngine/policyengine-us/issues/9619)). ### Filtering by geography diff --git a/policyengine_us/tests/core/test_tax_unit_role_input.py b/policyengine_us/tests/core/test_tax_unit_role_input.py index e4aa0f4c201..45c59c222d1 100644 --- a/policyengine_us/tests/core/test_tax_unit_role_input.py +++ b/policyengine_us/tests/core/test_tax_unit_role_input.py @@ -1,11 +1,14 @@ -"""Tax-unit roles and filing statuses supplied by a population dataset. +"""Tax-unit roles supplied by a population dataset. The Populace US build's tax-unit constructor (microunit, called from microcosm-frame) assigns every person a role - HEAD, SPOUSE or DEPENDENT - and -every tax unit a filing status, and writes them to the person column -``tax_unit_role_input`` and the tax-unit column ``filing_status_input``. The -role variables and ``filing_status`` use them when supplied and fall back to -age ordering and the filing rules when not. +writes it to the person column ``tax_unit_role_input``. The role variables use +it when supplied and fall back to age ordering when not. + +The build also writes its constructor's filing status to a tax-unit column, +``filing_status_input``. Filing status is a policy calculation, so the model +does not read that column: ``filing_status`` is computed from the unit's +members, including the supplied roles, and the filing rules. The YAML tests cover household situations. These cover what the YAML runner cannot: the error contract, the column contract with the producer, and the @@ -20,17 +23,11 @@ from policyengine_us import Microsimulation, Simulation from policyengine_us.data.dataset_schema import USSingleYearDataset from policyengine_us.system import system -from policyengine_us.variables.household.demographic.tax_unit.filing_status import ( - FilingStatus, -) YEAR = 2024 -def _situation(people: dict, filing_status_input: str | None = None) -> dict: - tax_unit = {"members": list(people)} - if filing_status_input is not None: - tax_unit["filing_status_input"] = {YEAR: filing_status_input} +def _situation(people: dict) -> dict: return { "people": { name: { @@ -39,31 +36,26 @@ def _situation(people: dict, filing_status_input: str | None = None) -> dict: } for name, (age, role) in people.items() }, - "tax_units": {"tax_unit": tax_unit}, + "tax_units": {"tax_unit": {"members": list(people)}}, "households": {"household": {"members": list(people)}}, } -def test_supplied_inputs_carry_the_producers_column_names_and_values(): - """The variables must match the columns the Populace build writes. +def test_supplied_input_carries_the_producers_column_name_and_values(): + """The role variable must match the column the Populace build writes. The loader sets a dataset column only when a variable has its exact name, and encodes a string column by Enum member name, so a renamed variable or - member would silently drop the build's roles again. + member would silently drop the build's roles again. The build's filing + status column must keep no variable of its name, so the model computes + filing status rather than loading it. """ role = system.variables["tax_unit_role_input"] - status = system.variables["filing_status_input"] assert role.entity.key == "person" - assert status.entity.key == "tax_unit" - for variable in (role, status): - assert not variable.formulas # An input, carried to every later year. - assert variable.default_value.name == "UNSPECIFIED" + assert not role.formulas # An input, carried to every later year. + assert role.default_value.name == "UNSPECIFIED" assert {"HEAD", "SPOUSE", "DEPENDENT"} <= set(role.possible_values.__members__) - # Every status the rules can derive can also be supplied. - assert set(status.possible_values.__members__) == { - "UNSPECIFIED", - *FilingStatus.__members__, - } + assert "filing_status_input" not in system.variables @pytest.mark.parametrize( @@ -85,72 +77,6 @@ def test_malformed_supplied_roles_raise(people, message): simulation.calculate("is_tax_unit_head", YEAR) -@pytest.mark.parametrize( - "people,status", - [ - ({"head": (50, "HEAD")}, "JOINT"), - ({"head": (50, "HEAD"), "spouse": (48, "SPOUSE")}, "SINGLE"), - ({"older": (50, None), "younger": (48, None)}, "HEAD_OF_HOUSEHOLD"), - ], - ids=["joint without a spouse", "spouse but not joint", "age-rule spouse"], -) -def test_supplied_status_must_agree_with_the_units_spouse(people, status): - simulation = Simulation(situation=_situation(people, status)) - with pytest.raises(ValueError, match="filing_status_input disagrees"): - simulation.calculate("filing_status", YEAR) - - -def _abolish(variable: str) -> dict: - return {f"gov.abolitions.{variable}": {"2000-01-01.2100-12-31": True}} - - -@pytest.mark.parametrize( - "status,abolished,expected", - [ - ("HEAD_OF_HOUSEHOLD", "head_of_household_eligible", "SINGLE"), - ("SINGLE", "head_of_household_eligible", "SINGLE"), - ("SURVIVING_SPOUSE", "surviving_spouse_eligible", "HEAD_OF_HOUSEHOLD"), - ("HEAD_OF_HOUSEHOLD", "surviving_spouse_eligible", "HEAD_OF_HOUSEHOLD"), - ], - ids=[ - "abolished HOH re-derives a supplied HOH", - "abolished HOH keeps a supplied SINGLE", - "abolished SS re-derives a supplied SS", - "abolished SS keeps a supplied HOH", - ], -) -def test_abolishing_a_status_rule_re_derives_only_that_status( - status, abolished, expected -): - """A parent of a 10-year-old, supplied with a status the rules may not give. - - Abolishing an eligibility rule removes that status, so a unit supplied with - it is re-derived from the remaining rules; a unit supplied with another - status keeps it, as the rules would not otherwise differ from the input. - """ - people = {"parent": (40, "HEAD"), "child": (10, "DEPENDENT")} - simulation = Simulation( - situation=_situation(people, status), reform=_abolish(abolished) - ) - assert simulation.calculate("filing_status", YEAR).decode_to_str().tolist() == [ - expected - ] - - -@pytest.mark.parametrize( - "abolished", ["tax_unit_married", "is_tax_unit_spouse", "tax_unit_roles_supplied"] -) -def test_abolishing_a_role_variable_does_not_fail_the_status_check(abolished): - """The JOINT check reads the supplied roles, not the abolishable variables.""" - people = {"head": (40, "HEAD"), "spouse": (40, "SPOUSE")} - simulation = Simulation( - situation=_situation(people, "JOINT"), reform=_abolish(abolished) - ) - assert simulation.calculate("filing_status", YEAR).decode_to_str().tolist() == [ - "JOINT" - ] - - def test_an_explicit_head_input_is_never_also_the_spouse(): """Explicit role flags win over supplied roles without doubling a person.""" situation = _situation({"a": (50, "HEAD"), "b": (48, "SPOUSE")}) @@ -162,7 +88,9 @@ def test_an_explicit_head_input_is_never_also_the_spouse(): assert not (head & spouse).any() -def _dataset(with_supplied_columns: bool) -> USSingleYearDataset: +def _dataset( + with_supplied_roles: bool, filing_status_column: list | None = None +) -> USSingleYearDataset: """Three tax units in one household, with Populace-style string columns. 1. A couple headed by a 30-year-old reference person with a 60-year-old @@ -185,13 +113,12 @@ def _dataset(with_supplied_columns: bool) -> USSingleYearDataset: } ) tax_unit = pd.DataFrame({"tax_unit_id": [1, 2, 3]}) - if with_supplied_columns: + if with_supplied_roles: person["tax_unit_role_input"] = pd.array( ["HEAD", "SPOUSE", "DEPENDENT", "HEAD", "HEAD", "DEPENDENT"], dtype="str" ) - tax_unit["filing_status_input"] = pd.array( - ["JOINT", "SINGLE", "HEAD_OF_HOUSEHOLD"], dtype="str" - ) + if filing_status_column is not None: + tax_unit["filing_status_input"] = pd.array(filing_status_column, dtype="str") return USSingleYearDataset( person=person, household=pd.DataFrame( @@ -214,14 +141,35 @@ def _roles_and_statuses(simulation, year): ) +# The statuses the filing rules give the supplied roles: the couple files +# jointly, the lone 16-year-old is single, and the parent of a full-time +# student under 24 (a qualifying child, 26 USC 152(c)(3)(A)(ii)) is a head of +# household (26 USC 2(b)(1)(A)(i)). +COMPUTED_STATUSES = ["JOINT", "SINGLE", "HEAD_OF_HOUSEHOLD"] + + @pytest.mark.parametrize("year", [YEAR, 2026]) -def test_dataset_roles_and_statuses_hold_in_every_extended_year(year): +def test_dataset_roles_hold_in_every_extended_year(year): simulation = Microsimulation(dataset=_dataset(True), dataset_end_year=2026) heads, spouses, dependents, statuses = _roles_and_statuses(simulation, year) assert heads == [True, False, False, True, True, False] assert spouses == [False, True, False, False, False, False] assert dependents == [False, False, True, False, False, True] - assert statuses == ["JOINT", "SINGLE", "HEAD_OF_HOUSEHOLD"] + assert statuses == COMPUTED_STATUSES + + +@pytest.mark.parametrize("year", [YEAR, 2026]) +def test_a_supplied_filing_status_column_is_not_read(year): + """Filing status is computed from the roles, whatever the build supplies. + + The column below disagrees with the filing rules for two units: it makes + the lone 16-year-old a surviving spouse and the parent of a full-time + student single. Neither holds; the rules decide. + """ + dataset = _dataset(True, ["JOINT", "SURVIVING_SPOUSE", "SINGLE"]) + simulation = Microsimulation(dataset=dataset, dataset_end_year=2026) + _, _, _, statuses = _roles_and_statuses(simulation, year) + assert statuses == COMPUTED_STATUSES def test_dataset_without_supplied_columns_keeps_age_ordering(): diff --git a/policyengine_us/tests/core/test_tax_unit_role_input_properties.py b/policyengine_us/tests/core/test_tax_unit_role_input_properties.py index c8e1aada1d4..b27dfa95508 100644 --- a/policyengine_us/tests/core/test_tax_unit_role_input_properties.py +++ b/policyengine_us/tests/core/test_tax_unit_role_input_properties.py @@ -1,4 +1,4 @@ -"""Properties of supplied tax-unit roles and filing statuses over generated units. +"""Properties of supplied tax-unit roles over generated units. Invariants, each checked for every generated household: @@ -7,36 +7,29 @@ equal the supplied roles, so the unit has exactly one head and at most one spouse. 3. Fallback: in a unit without supplied roles, the roles equal an independent - implementation of age ordering (the oldest adult heads the unit, earliest - member on ties; the next-oldest adult is the spouse unless any member is - separated). -4. Supplied statuses hold: a supplied filing status is the filing status, and a - supplied unit files jointly exactly when it has a spouse. -5. Workaround equivalence (differential): supplying roles and statuses through - the input variables gives the same filing status and income tax as setting - is_tax_unit_head, is_tax_unit_spouse, is_tax_unit_dependent and - filing_status directly, the policyengine-taxsim workaround. The explicit pin - must cover every unit: a situation that sets filing_status for some tax - units gives the rest the default (SINGLE) rather than the formula. -6. Switch-off locality: abolishing one status's eligibility rule re-derives the - units supplied with that status and no others. -7. Error contract: supplied roles raise exactly when malformed - supplied for + implementation of age ordering. The oldest adult heads the unit, earliest + member on ties, skipping adults input as tax unit dependents unless every + adult is one; the next such adult is the spouse unless any member of the + unit is separated. +4. Filing status is computed: no supplied status is read, so a unit files + jointly exactly when it has a spouse, supplied or inferred. +5. Workaround equivalence (differential): supplying roles through + tax_unit_role_input gives the same filing status and income tax as setting + is_tax_unit_head, is_tax_unit_spouse and is_tax_unit_dependent directly, + the policyengine-taxsim workaround. Neither side sets filing_status, so both + compute it from the same roles. +6. Error contract: supplied roles raise exactly when malformed - supplied for only some members, or not exactly one HEAD and at most one SPOUSE. """ -from functools import cache - from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st import numpy as np import pytest -from policyengine_core.reforms import Reform - -from policyengine_us import CountryTaxBenefitSystem, Simulation +from policyengine_us import Simulation YEAR = 2024 -STATUSES = ["SINGLE", "SEPARATE", "HEAD_OF_HOUSEHOLD", "SURVIVING_SPOUSE"] SETTINGS = dict( derandomize=True, deadline=None, @@ -45,8 +38,13 @@ @st.composite -def tax_units(draw, supplied=None, every_status=False, status=None): - """One tax unit; ``status`` forces a supplied unit with that non-joint status.""" +def tax_units(draw, supplied=None, dependent_inputs=False): + """One tax unit, with supplied roles when ``supplied`` (drawn if None). + + With ``dependent_inputs``, a unit without supplied roles also flags some + members as input tax unit dependents, and a supplied unit flags exactly + its supplied dependents. + """ size = draw(st.integers(1, 4)) ages = draw(st.lists(st.integers(0, 90), min_size=size, max_size=size)) separated = draw( @@ -59,32 +57,32 @@ def tax_units(draw, supplied=None, every_status=False, status=None): earnings = draw( st.lists(st.sampled_from([0, 15_000, 60_000]), min_size=size, max_size=size) ) - forced = status - with_roles = forced is not None or ( - draw(st.booleans()) if supplied is None else supplied - ) + with_roles = draw(st.booleans()) if supplied is None else supplied roles = None - status = None if with_roles: head = draw(st.integers(0, size - 1)) others = [i for i in range(size) if i != head] spouse = None - if others and forced is None: + if others: spouse = draw(st.one_of(st.none(), st.sampled_from(others))) roles = ["DEPENDENT"] * size roles[head] = "HEAD" if spouse is not None: roles[spouse] = "SPOUSE" - if forced is not None: - status = forced - elif every_status or draw(st.booleans()): - status = "JOINT" if spouse is not None else draw(st.sampled_from(STATUSES)) + dependent_input = None + if dependent_inputs: + if roles: + dependent_input = [role == "DEPENDENT" for role in roles] + else: + dependent_input = draw( + st.lists(st.booleans(), min_size=size, max_size=size) + ) return dict( ages=ages, separated=separated, earnings=earnings, roles=roles, - status=status, + dependent_input=dependent_input, ) @@ -106,13 +104,11 @@ def _situation(units, pin_explicitly=False): person["is_tax_unit_dependent"] = {YEAR: role == "DEPENDENT"} elif role: person["tax_unit_role_input"] = {YEAR: role} + if unit["dependent_input"] is not None: + person["is_tax_unit_dependent"] = {YEAR: unit["dependent_input"][i]} people[name] = person members.append(name) - group = {"members": members} - if unit["status"]: - key = "filing_status" if pin_explicitly else "filing_status_input" - group[key] = {YEAR: unit["status"]} - tax_unit_groups[f"u{u}"] = group + tax_unit_groups[f"u{u}"] = {"members": members} return { "people": people, "tax_units": tax_unit_groups, @@ -122,11 +118,16 @@ def _situation(units, pin_explicitly=False): } -def _age_rule(ages, separated): - # Mirrors the fallback as it stands, including its unit-wide separation - # gate: any separated member blocks every spouse. That gate is a known - # defect (#9620); update this reference when it is fixed. +def _age_rule(ages, separated, dependent_input=None): + # Mirrors the fallback as it stands. Adults input as tax unit dependents + # are skipped unless every adult is one (#9630). Any separated member + # blocks every spouse; that unit-wide gate is a known defect (#9620), so + # update this reference when it is fixed. adults = [i for i, age in enumerate(ages) if age >= 18] + if dependent_input is not None: + non_dependents = [i for i in adults if not dependent_input[i]] + if non_dependents: + adults = non_dependents head = min(adults, key=lambda i: (-ages[i], i)) if adults else None candidates = [] if any(separated) else [i for i in adults if i != head] spouse = min(candidates, key=lambda i: (-ages[i], i)) if candidates else None @@ -142,19 +143,24 @@ def _by_unit(units, values): return out -@cache -def _abolished(rule): - """A system with one eligibility rule abolished, built once per rule.""" - reform = Reform.from_dict( - {f"gov.abolitions.{rule}": {"2000-01-01.2100-12-31": True}}, - country_id="us", - ) - return CountryTaxBenefitSystem(reform=reform) +def _check_roles(units, head, spouse): + for unit, h, s in zip(units, _by_unit(units, head), _by_unit(units, spouse)): + if unit["roles"]: + # 2. Supplied roles hold. + assert h == [role == "HEAD" for role in unit["roles"]] + assert s == [role == "SPOUSE" for role in unit["roles"]] + else: + # 3. Fallback equals the independent age-ordering rule. + expected_head, expected_spouse = _age_rule( + unit["ages"], unit["separated"], unit["dependent_input"] + ) + assert h == [i == expected_head for i in range(len(h))] + assert s == [i == expected_spouse for i in range(len(s))] @settings(max_examples=40, **SETTINGS) @given(st.lists(tax_units(), min_size=1, max_size=4)) -def test_roles_and_statuses_follow_supplied_values_and_otherwise_rules(units): +def test_roles_follow_supplied_values_and_otherwise_age_ordering(units): simulation = Simulation(situation=_situation(units)) head = simulation.calculate("is_tax_unit_head", YEAR).astype(bool) spouse = simulation.calculate("is_tax_unit_spouse", YEAR).astype(bool) @@ -162,29 +168,29 @@ def test_roles_and_statuses_follow_supplied_values_and_otherwise_rules(units): status = simulation.calculate("filing_status", YEAR).decode_to_str().tolist() # 1. Partition. assert np.all(head.astype(int) + spouse.astype(int) + dependent.astype(int) == 1) - for unit, h, s, unit_status in zip( - units, _by_unit(units, head), _by_unit(units, spouse), status - ): - if unit["roles"]: - # 2. Supplied roles hold. - assert h == [role == "HEAD" for role in unit["roles"]] - assert s == [role == "SPOUSE" for role in unit["roles"]] - # 4. Supplied units file jointly exactly when they have a spouse. - assert (unit_status == "JOINT") == any(s) - else: - # 3. Fallback equals the independent age-ordering rule. - expected_head, expected_spouse = _age_rule(unit["ages"], unit["separated"]) - assert h == [i == expected_head for i in range(len(h))] - assert s == [i == expected_spouse for i in range(len(s))] - if unit["status"]: - # 4. A supplied status is the status. - assert unit_status == unit["status"] + _check_roles(units, head, spouse) + for s, unit_status in zip(_by_unit(units, spouse), status): + # 4. Filing status is computed: joint exactly when there is a spouse. + assert (unit_status == "JOINT") == any(s) + + +@settings(max_examples=40, **SETTINGS) +@given(st.lists(tax_units(dependent_inputs=True), min_size=1, max_size=4)) +def test_supplied_roles_compose_with_input_dependents(units): + # Every person carries an is_tax_unit_dependent input, as a situation that + # sets it for anyone must. Supplied units flag exactly their supplied + # dependents; the others skip their flagged adults when inferring roles. + simulation = Simulation(situation=_situation(units)) + head = simulation.calculate("is_tax_unit_head", YEAR).astype(bool) + spouse = simulation.calculate("is_tax_unit_spouse", YEAR).astype(bool) + assert not np.any(head & spouse) + _check_roles(units, head, spouse) @settings(max_examples=15, **SETTINGS) -@given(st.lists(tax_units(supplied=True, every_status=True), min_size=1, max_size=3)) -def test_supplied_inputs_match_the_explicit_pin(units): - # 5. Differential: input variables and a complete explicit pin agree. +@given(st.lists(tax_units(supplied=True), min_size=1, max_size=3)) +def test_supplied_roles_match_the_explicit_pin(units): + # 5. Differential: the role input and a complete explicit pin agree. supplied = Simulation(situation=_situation(units)) pinned = Simulation(situation=_situation(units, pin_explicitly=True)) assert ( @@ -198,40 +204,6 @@ def test_supplied_inputs_match_the_explicit_pin(units): ) -@pytest.mark.parametrize( - "rule,status", - [ - ("head_of_household_eligible", "HEAD_OF_HOUSEHOLD"), - ("surviving_spouse_eligible", "SURVIVING_SPOUSE"), - ], -) -@settings(max_examples=15, **SETTINGS) -@given(data=st.data()) -def test_abolishing_a_rule_moves_only_units_supplied_with_its_status( - rule, status, data -): - # Every example holds at least one unit supplied with the target status, - # beside other supplied units. - target = data.draw(tax_units(status=status), label="target") - others = data.draw( - st.lists(tax_units(supplied=True, every_status=True), max_size=2), - label="others", - ) - position = data.draw(st.integers(0, len(others)), label="position") - units = others[:position] + [target] + others[position:] - assert sum(unit["status"] == status for unit in units) >= 1 - simulation = Simulation( - situation=_situation(units), tax_benefit_system=_abolished(rule) - ) - results = simulation.calculate("filing_status", YEAR).decode_to_str().tolist() - for unit, result in zip(units, results): - # 6. Switch-off locality. - if unit["status"] == status: - assert result != status - elif unit["status"]: - assert result == unit["status"] - - @settings(max_examples=40, **SETTINGS) @given( st.lists( @@ -257,7 +229,7 @@ def test_supplied_roles_raise_exactly_when_malformed(roles): malformed = (any(supplied) and not all(supplied)) or ( all(supplied) and (roles.count("HEAD") != 1 or roles.count("SPOUSE") > 1) ) - # 7. Error contract. + # 6. Error contract. simulation = Simulation(situation=situation) if malformed: with pytest.raises(ValueError, match="tax_unit_role_input"): diff --git a/policyengine_us/tests/microsimulation/test_microsim.py b/policyengine_us/tests/microsimulation/test_microsim.py index a313e89c308..a7ecdfd7eb2 100644 --- a/policyengine_us/tests/microsimulation/test_microsim.py +++ b/policyengine_us/tests/microsimulation/test_microsim.py @@ -240,16 +240,17 @@ def test_legacy_enhanced_cps_allocates_no_housing_share_to_an_unclassified_unit( def test_default_dataset_uses_its_supplied_tax_unit_roles(): - """Every role and filing status on the certified default build is its own. + """Every tax-unit role on the certified default build is its own. `populace_us_2024` ships its tax-unit constructor's roles - (`tax_unit_role_input`) and filing statuses (`filing_status_input`). Without - them, age ordering made the oldest adult the head and the next-oldest the - spouse, pairing adult students with their parents as joint filers, heading - couples by the older partner rather than the reference person, and leaving - lone minors with no head. This checks the whole population, person by - person and unit by unit, in the data year and in an extended year, since - the loader carries the columns forward rather than the formulas. + (`tax_unit_role_input`). Without them, age ordering made the oldest adult + the head and the next-oldest the spouse, pairing adult students with their + parents as joint filers, heading couples by the older partner rather than + the reference person, and leaving lone minors with no head. This checks the + whole population, person by person, in the data year and in an extended + year, since the loader carries the column forward rather than the formulas. + Filing status is not checked against the build: the model computes it from + these roles and the filing rules. """ import numpy as np import pandas as pd @@ -259,9 +260,7 @@ def test_default_dataset_uses_its_supplied_tax_unit_roles(): with pd.HDFStore(_resolve_dataset_path(DEFAULT_DATASET), mode="r") as store: person = store["person"][["person_id", "tax_unit_role_input"]] - tax_unit = store["tax_unit"][["tax_unit_id", "filing_status_input"]] roles = person.set_index("person_id")["tax_unit_role_input"].astype(str) - statuses = tax_unit.set_index("tax_unit_id")["filing_status_input"].astype(str) simulation = Microsimulation(dataset_end_year=2026) for year in (2024, 2026): @@ -279,12 +278,3 @@ def test_default_dataset_uses_its_supplied_tax_unit_roles(): f"{variable} disagrees with tax_unit_role_input == {name} for " f"{mismatched} of {role.size} people." ) - tax_unit_id = np.asarray(simulation.calculate("tax_unit_id", year)) - status = statuses.reindex(tax_unit_id).to_numpy() - modelled = np.asarray(simulation.calculate("filing_status", year)).astype(str) - mismatched = int((modelled != status).sum()) - assert mismatched == 0, ( - f"populace_us_2024 (certified default build), {year}: " - f"filing_status disagrees with filing_status_input for " - f"{mismatched} of {status.size} tax units." - ) diff --git a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/filing_status_input.yaml b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/filing_status_input.yaml deleted file mode 100644 index a0360bcccaa..00000000000 --- a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/filing_status_input.yaml +++ /dev/null @@ -1,125 +0,0 @@ -- name: A supplied status is used even where the rules would give another. - # The rules give a parent of a 10-year-old head of household status. - period: 2024 - input: - people: - parent: - age: 50 - tax_unit_role_input: HEAD - child: - age: 10 - tax_unit_role_input: DEPENDENT - tax_units: - tax_unit: - members: [parent, child] - filing_status_input: SINGLE - households: - household: - members: [parent, child] - output: - filing_status: SINGLE - head_of_household_eligible: true - -- name: A supplied surviving spouse status is used. - period: 2024 - input: - people: - parent: - age: 66 - tax_unit_role_input: HEAD - child: - age: 10 - tax_unit_role_input: DEPENDENT - tax_units: - tax_unit: - members: [parent, child] - filing_status_input: SURVIVING_SPOUSE - households: - household: - members: [parent, child] - output: - filing_status: SURVIVING_SPOUSE - -- name: A supplied separate status is used. - period: 2024 - input: - people: - taxpayer: - age: 40 - tax_unit_role_input: HEAD - tax_units: - tax_unit: - members: [taxpayer] - filing_status_input: SEPARATE - households: - household: - members: [taxpayer] - output: - filing_status: SEPARATE - -- name: A supplied joint status is accepted for a supplied couple. - # The rules also give JOINT here; this checks the status is accepted, not - # that it overrides the rules, which cannot differ for a couple. - period: 2024 - input: - people: - head: - age: 30 - tax_unit_role_input: HEAD - spouse: - age: 60 - tax_unit_role_input: SPOUSE - tax_units: - tax_unit: - members: [head, spouse] - filing_status_input: JOINT - households: - household: - members: [head, spouse] - output: - filing_status: JOINT - -- name: Without a supplied status the rules decide. - # A parent of a 10-year-old qualifying child files as head of household - # (26 USC 2(b)(1)(A)(i), 152(c)(3)(A)(i)). - period: 2024 - input: - people: - parent: - age: 50 - child: - age: 10 - tax_units: - tax_unit: - members: [parent, child] - households: - household: - members: [parent, child] - output: - filing_status: HEAD_OF_HOUSEHOLD - -- name: Each tax unit uses its own supplied status, or the rules without one. - period: 2024 - input: - people: - supplied_parent: - age: 50 - tax_unit_role_input: HEAD - supplied_child: - age: 10 - tax_unit_role_input: DEPENDENT - unsupplied_parent: - age: 50 - unsupplied_child: - age: 10 - tax_units: - supplied_unit: - members: [supplied_parent, supplied_child] - filing_status_input: SINGLE - unsupplied_unit: - members: [unsupplied_parent, unsupplied_child] - households: - household: - members: [supplied_parent, supplied_child, unsupplied_parent, unsupplied_child] - output: - filing_status: [SINGLE, HEAD_OF_HOUSEHOLD] diff --git a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/tax_unit_role_input.yaml b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/tax_unit_role_input.yaml index 4a11aa37d1b..079ab48e48f 100644 --- a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/tax_unit_role_input.yaml +++ b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/tax_unit_role_input.yaml @@ -141,3 +141,37 @@ is_tax_unit_head: [false, true, false] is_tax_unit_spouse: [true, false, false] is_tax_unit_dependent: [false, false, true] + +- name: Without supplied roles, inference skips an input dependent; supplied roles stand beside it. + # The unsupplied unit's 70-year-old is input as a tax unit dependent, so the + # 40-year-old heads it and nobody is the spouse. Inputs set for one person + # cover everyone, so every person carries is_tax_unit_dependent. + period: 2024 + input: + people: + supplied_head: + age: 30 + tax_unit_role_input: HEAD + is_tax_unit_dependent: false + supplied_spouse: + age: 60 + tax_unit_role_input: SPOUSE + is_tax_unit_dependent: false + adult: + age: 40 + is_tax_unit_dependent: false + dependent_parent: + age: 70 + is_tax_unit_dependent: true + tax_units: + supplied_unit: + members: [supplied_head, supplied_spouse] + unsupplied_unit: + members: [adult, dependent_parent] + households: + household: + members: [supplied_head, supplied_spouse, adult, dependent_parent] + output: + tax_unit_roles_supplied: [true, false] + is_tax_unit_head: [true, false, true, false] + is_tax_unit_spouse: [false, true, false, false] diff --git a/policyengine_us/tests/policy/contrib/congress/romney/family_security_act/remove_head_of_household.yaml b/policyengine_us/tests/policy/contrib/congress/romney/family_security_act/remove_head_of_household.yaml index 7e0f6245911..eaa48bc257c 100644 --- a/policyengine_us/tests/policy/contrib/congress/romney/family_security_act/remove_head_of_household.yaml +++ b/policyengine_us/tests/policy/contrib/congress/romney/family_security_act/remove_head_of_household.yaml @@ -27,69 +27,3 @@ members: [head] output: filing_status: SINGLE - -- name: Remove head of household filing status for a supplied head of household status - period: 2023 - reforms: policyengine_us.reforms.congress.romney.family_security_act.remove_head_of_household.remove_head_of_household - input: - gov.contrib.congress.romney.family_security_act.remove_head_of_household : true - people: - head: - age: 40 - tax_unit_role_input: HEAD - child: - age: 10 - tax_unit_role_input: DEPENDENT - tax_units: - tax_unit: - members: [head, child] - filing_status_input: HEAD_OF_HOUSEHOLD - output: - filing_status: SINGLE - -- name: Removing head of household status leaves a supplied single status alone - # The rules would make this widowed parent of an unrelated 10-year-old a - # surviving spouse; the repeal must not re-derive a status it does not remove. - period: 2023 - reforms: policyengine_us.reforms.congress.romney.family_security_act.remove_head_of_household.remove_head_of_household - input: - gov.contrib.congress.romney.family_security_act.remove_head_of_household : true - people: - head: - age: 66 - is_surviving_spouse: true - tax_unit_role_input: HEAD - child: - age: 10 - is_related_to_head_or_spouse: false - tax_unit_role_input: DEPENDENT - tax_units: - tax_unit: - members: [head, child] - filing_status_input: SINGLE - output: - filing_status: SINGLE - -- name: Removing head of household status leaves a supplied surviving spouse status alone - # The rules would make this unit single: the 30-year-old dependent is neither - # a qualifying child nor, with $20,000 of income, a qualifying relative. - period: 2023 - reforms: policyengine_us.reforms.congress.romney.family_security_act.remove_head_of_household.remove_head_of_household - input: - gov.contrib.congress.romney.family_security_act.remove_head_of_household : true - people: - head: - age: 60 - is_surviving_spouse: true - employment_income: 60_000 - tax_unit_role_input: HEAD - dependent: - age: 30 - employment_income: 20_000 - tax_unit_role_input: DEPENDENT - tax_units: - tax_unit: - members: [head, dependent] - filing_status_input: SURVIVING_SPOUSE - output: - filing_status: SURVIVING_SPOUSE diff --git a/policyengine_us/variables/household/demographic/tax_unit/filing_status.py b/policyengine_us/variables/household/demographic/tax_unit/filing_status.py index b39afd35cd4..a9c454cda69 100644 --- a/policyengine_us/variables/household/demographic/tax_unit/filing_status.py +++ b/policyengine_us/variables/household/demographic/tax_unit/filing_status.py @@ -9,21 +9,6 @@ class FilingStatus(Enum): SURVIVING_SPOUSE = "Surviving spouse" -# The eligibility rule behind each status that a reform can switch off. -STATUS_RULES = { - FilingStatus.HEAD_OF_HOUSEHOLD: "head_of_household_eligible", - FilingStatus.SURVIVING_SPOUSE: "surviving_spouse_eligible", -} - - -def _rule_switched_off(tax_unit, period, parameters, variable_name): - # The same test policyengine-core applies before calculating a variable: - # a structural reform neutralized it, or its abolition parameter is set. - variable = tax_unit.simulation.tax_benefit_system.variables[variable_name] - abolished = parameters(period).gov.abolitions[variable_name] - return variable.is_neutralized or bool(abolished) - - class filing_status(Variable): value_type = Enum entity = TaxUnit @@ -38,10 +23,9 @@ def formula(tax_unit, period, parameters): # to the tax unit head or spouse, not a dependent or other member. is_head_or_spouse = person("is_tax_unit_head_or_spouse", period) is_separated = tax_unit.any(is_head_or_spouse & person("is_separated", period)) - married = tax_unit("tax_unit_married", period) - derived = select( + return select( [ - married, + tax_unit("tax_unit_married", period), tax_unit("surviving_spouse_eligible", period), tax_unit("head_of_household_eligible", period), is_separated, @@ -54,37 +38,3 @@ def formula(tax_unit, period, parameters): ], default=FilingStatus.SINGLE, ) - # A dataset's tax-unit constructor can supply the status instead. - supplied = tax_unit("filing_status_input", period) - statuses = supplied.possible_values - is_supplied = supplied != statuses.UNSPECIFIED - # Check a supplied status against the supplied roles themselves where - # the unit has them, so no reform to the role variables can turn this - # data check into an error. - role = person("tax_unit_role_input", period) - roles = role.possible_values - roles_supplied = tax_unit.all(role != roles.UNSPECIFIED) - has_spouse = where(roles_supplied, tax_unit.any(role == roles.SPOUSE), married) - disagrees = is_supplied & ((supplied == statuses.JOINT) != has_spouse) - if disagrees.any(): - raise ValueError( - f"filing_status_input disagrees with the members of " - f"{disagrees.sum()} tax units: a supplied JOINT status needs a " - "spouse in the unit, and a unit with a spouse must be supplied " - "JOINT." - ) - # A reform that switches off a status's rule re-derives the units - # supplied with that status; every other supplied status stands. - honored = [ - status - for status in FilingStatus - if status not in STATUS_RULES - or not _rule_switched_off( - tax_unit, period, parameters, STATUS_RULES[status] - ) - ] - return select( - [supplied == statuses[status.name] for status in honored], - honored, - default=derived, - ) diff --git a/policyengine_us/variables/household/demographic/tax_unit/filing_status_input.py b/policyengine_us/variables/household/demographic/tax_unit/filing_status_input.py deleted file mode 100644 index e29ee7c2f3d..00000000000 --- a/policyengine_us/variables/household/demographic/tax_unit/filing_status_input.py +++ /dev/null @@ -1,20 +0,0 @@ -from policyengine_us.model_api import * - - -class FilingStatusInput(Enum): - UNSPECIFIED = "Unspecified" - SINGLE = "Single" - JOINT = "Joint" - SEPARATE = "Separate" - HEAD_OF_HOUSEHOLD = "Head of household" - SURVIVING_SPOUSE = "Surviving spouse" - - -class filing_status_input(Variable): - value_type = Enum - entity = TaxUnit - possible_values = FilingStatusInput - default_value = FilingStatusInput.UNSPECIFIED - definition_period = YEAR - label = "Supplied filing status" - documentation = "The tax unit's filing status as assigned by the tax-unit constructor that built a dataset, such as the Populace US build. When supplied, filing_status uses it; a supplied JOINT status requires a spouse in the unit, and a unit with a spouse must be supplied JOINT. UNSPECIFIED (the default) means the status was not supplied, and filing_status is derived from the unit's members and the head of household and surviving spouse rules. A supplied status is a fact about the record, so parameter reforms to those rules, such as the dependent age limits, do not re-derive it. A reform that switches off the head of household or surviving spouse rule, by neutralizing head_of_household_eligible or surviving_spouse_eligible or through their gov.abolitions parameters, re-derives the units supplied with that status and leaves every other supplied status in place." diff --git a/policyengine_us/variables/household/demographic/tax_unit/tax_unit_role_input.py b/policyengine_us/variables/household/demographic/tax_unit/tax_unit_role_input.py index 473b6214d20..8b342b60ecd 100644 --- a/policyengine_us/variables/household/demographic/tax_unit/tax_unit_role_input.py +++ b/policyengine_us/variables/household/demographic/tax_unit/tax_unit_role_input.py @@ -15,4 +15,4 @@ class tax_unit_role_input(Variable): default_value = TaxUnitRole.UNSPECIFIED definition_period = YEAR label = "Supplied tax unit role" - documentation = "The person's role in their tax unit - head, spouse or dependent - as assigned by the tax-unit constructor that built a dataset, such as the Populace US build. When every member of a tax unit has a supplied role, is_tax_unit_head and is_tax_unit_spouse use it, so the constructor's head, its spouse pairing and its dependency claims all hold: a younger reference person can head a couple, an adult student stays a dependent, and a minor living without a parent can head a return. UNSPECIFIED (the default) means the role was not supplied, and the role variables fall back to age ordering, where the oldest adult is the head and the next-oldest adult the spouse." + documentation = "The person's role in their tax unit - head, spouse or dependent - as assigned by the tax-unit constructor that built a dataset, such as the Populace US build. When every member of a tax unit has a supplied role, is_tax_unit_head and is_tax_unit_spouse use it, so the constructor's head, its spouse pairing and its dependency claims all hold: a younger reference person can head a couple, an adult student stays a dependent, and a minor living without a parent can head a return. The roles only say who is head, spouse or dependent; filing_status is still computed from the unit's members and the filing rules. UNSPECIFIED (the default) means the role was not supplied, and the role variables fall back to age ordering: the oldest adult not input as a tax unit dependent is the head, and the next-oldest such adult is the spouse unless any member of the unit is separated." From 20695df37e98afa0666212c16c106a028e0caedf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 12:45:03 -0400 Subject: [PATCH 6/9] Describe the role fallback with #9630's all-dependent case The age-ordered fallback skips adults input as tax unit dependents unless every adult in the unit is one, in which case every adult stays a candidate. Say so in tax_unit_role_input's documentation. Co-Authored-By: Claude Opus 5.5 --- .../household/demographic/tax_unit/tax_unit_role_input.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_us/variables/household/demographic/tax_unit/tax_unit_role_input.py b/policyengine_us/variables/household/demographic/tax_unit/tax_unit_role_input.py index 8b342b60ecd..74003fcca48 100644 --- a/policyengine_us/variables/household/demographic/tax_unit/tax_unit_role_input.py +++ b/policyengine_us/variables/household/demographic/tax_unit/tax_unit_role_input.py @@ -15,4 +15,4 @@ class tax_unit_role_input(Variable): default_value = TaxUnitRole.UNSPECIFIED definition_period = YEAR label = "Supplied tax unit role" - documentation = "The person's role in their tax unit - head, spouse or dependent - as assigned by the tax-unit constructor that built a dataset, such as the Populace US build. When every member of a tax unit has a supplied role, is_tax_unit_head and is_tax_unit_spouse use it, so the constructor's head, its spouse pairing and its dependency claims all hold: a younger reference person can head a couple, an adult student stays a dependent, and a minor living without a parent can head a return. The roles only say who is head, spouse or dependent; filing_status is still computed from the unit's members and the filing rules. UNSPECIFIED (the default) means the role was not supplied, and the role variables fall back to age ordering: the oldest adult not input as a tax unit dependent is the head, and the next-oldest such adult is the spouse unless any member of the unit is separated." + documentation = "The person's role in their tax unit - head, spouse or dependent - as assigned by the tax-unit constructor that built a dataset, such as the Populace US build. When every member of a tax unit has a supplied role, is_tax_unit_head and is_tax_unit_spouse use it, so the constructor's head, its spouse pairing and its dependency claims all hold: a younger reference person can head a couple, an adult student stays a dependent, and a minor living without a parent can head a return. The roles only say who is head, spouse or dependent; filing_status is still computed from the unit's members and the filing rules. UNSPECIFIED (the default) means the role was not supplied, and the role variables fall back to age ordering: the oldest adult is the head, skipping adults input as tax unit dependents unless every adult in the unit is one, and the next-oldest such adult is the spouse unless any member of the unit is separated." From 9d233a2ef4f9f843e1dfa0cf0de177ab928c065a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 15:46:14 -0400 Subject: [PATCH 7/9] Rewrite the changelog fragment for roles only Per Max's ruling d318, the dataset supplies tax-unit roles but not filing status, so the fragment no longer says filing_status_input sets filing_status. It drops the 2024 aggregates measured with supplied filing status, which no longer describe this change. Co-Authored-By: Claude Opus 5.5 --- changelog.d/source-tax-unit-roles.added.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/source-tax-unit-roles.added.md b/changelog.d/source-tax-unit-roles.added.md index bd894e185c3..e2764fe6d0c 100644 --- a/changelog.d/source-tax-unit-roles.added.md +++ b/changelog.d/source-tax-unit-roles.added.md @@ -1 +1 @@ -Tax-unit roles and filing statuses supplied by a dataset (tax_unit_role_input, filing_status_input) now determine is_tax_unit_head, is_tax_unit_spouse, is_tax_unit_dependent and filing_status, with age ordering and the filing rules as the fallback. On the certified Populace default build this replaces age-ordered roles with the build's own: adult students and other adult dependents are no longer paired with a parent as joint filers, couples are headed by the build's head, and lone minors head their own returns. In 2024, joint returns fall by 3.07 million and federal income tax by $0.94 billion net. Because a tax-unit dependent's income is left out of the claiming return's gross income and the model computes no separate return for dependents, the income of adults the build makes dependents leaves the income tax base, which lowers adjusted gross income by $35.9 billion. +Tax-unit roles supplied by a dataset (tax_unit_role_input: HEAD, SPOUSE or DEPENDENT) now determine is_tax_unit_head, is_tax_unit_spouse and is_tax_unit_dependent in every tax unit whose members all have one, with age ordering as the fallback. Filing status is not supplied: filing_status is still computed from the resulting roles and the filing rules, and a dataset's filing_status_input column is not read. On the certified Populace default build this replaces age-ordered roles with the build's own: adult students and other adult dependents are no longer made a parent's spouse, couples are headed by the build's head, and lone minors head their own returns. Because irs_gross_income excludes tax-unit dependents and the model does not yet compute a dependent's own return (#9618), the income of adults the build makes dependents leaves the income tax base. From f0cb844fe3e859941b47574ebbf204a603146576 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 18:05:20 -0400 Subject: [PATCH 8/9] Let a tax unit with no members fall back instead of raising The loader declares every tax unit in a dataset's tax_unit_id column, members or not, and all() is True over no members. So tax_unit_roles_supplied counted a memberless unit as supplied with no HEAD and raised, where main gives the unit no head. It now needs at least one supplied member. The certified default build has no memberless units, so its results do not change. Co-Authored-By: Claude Opus 5.5 --- .../tests/core/test_tax_unit_role_input.py | 26 +++++++++++++++++-- .../tax_unit/tax_unit_roles_supplied.py | 6 +++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/policyengine_us/tests/core/test_tax_unit_role_input.py b/policyengine_us/tests/core/test_tax_unit_role_input.py index 45c59c222d1..a15b26ab69e 100644 --- a/policyengine_us/tests/core/test_tax_unit_role_input.py +++ b/policyengine_us/tests/core/test_tax_unit_role_input.py @@ -89,7 +89,9 @@ def test_an_explicit_head_input_is_never_also_the_spouse(): def _dataset( - with_supplied_roles: bool, filing_status_column: list | None = None + with_supplied_roles: bool, + filing_status_column: list | None = None, + empty_tax_units: int = 0, ) -> USSingleYearDataset: """Three tax units in one household, with Populace-style string columns. @@ -97,6 +99,8 @@ def _dataset( spouse, and their 20-year-old full-time student. 2. A 16-year-old living without a parent. 3. A 50-year-old parent and their 21-year-old full-time student. + + ``empty_tax_units`` appends tax-unit rows that no person belongs to. """ ages = [30, 60, 20, 16, 50, 21] tax_unit_of = [1, 1, 1, 2, 3, 3] @@ -112,7 +116,7 @@ def _dataset( "is_full_time_student": [False, False, True, False, False, True], } ) - tax_unit = pd.DataFrame({"tax_unit_id": [1, 2, 3]}) + tax_unit = pd.DataFrame({"tax_unit_id": np.arange(1, 4 + empty_tax_units)}) if with_supplied_roles: person["tax_unit_role_input"] = pd.array( ["HEAD", "SPOUSE", "DEPENDENT", "HEAD", "HEAD", "DEPENDENT"], dtype="str" @@ -172,6 +176,24 @@ def test_a_supplied_filing_status_column_is_not_read(year): assert statuses == COMPUTED_STATUSES +def test_a_tax_unit_without_members_falls_back_rather_than_raising(): + """A tax-unit row that no person belongs to supplies no roles. + + The loader declares every tax unit in the dataset's id column, members or + not, and ``all`` over no members is True. Such a unit must fall back to + age ordering, which gives it no head, as before supplied roles existed, + rather than count as supplied with no HEAD and raise. + """ + dataset = _dataset(True, empty_tax_units=1) + simulation = Microsimulation(dataset=dataset, dataset_end_year=YEAR) + supplied = simulation.calculate("tax_unit_roles_supplied", YEAR, use_weights=False) + assert np.asarray(supplied).tolist() == [True, True, True, False] + heads = simulation.calculate("is_tax_unit_head", YEAR, use_weights=False) + spouses = simulation.calculate("is_tax_unit_spouse", YEAR, use_weights=False) + assert np.asarray(heads).tolist() == [True, False, False, True, True, False] + assert np.asarray(spouses).tolist() == [False, True, False, False, False, False] + + def test_dataset_without_supplied_columns_keeps_age_ordering(): """The same people without the columns: the oldest adult heads each unit. diff --git a/policyengine_us/variables/household/demographic/tax_unit/tax_unit_roles_supplied.py b/policyengine_us/variables/household/demographic/tax_unit/tax_unit_roles_supplied.py index 23f1de3d4b5..36b8f797490 100644 --- a/policyengine_us/variables/household/demographic/tax_unit/tax_unit_roles_supplied.py +++ b/policyengine_us/variables/household/demographic/tax_unit/tax_unit_roles_supplied.py @@ -12,8 +12,10 @@ def formula(tax_unit, period, parameters): role = tax_unit.members("tax_unit_role_input", period) roles = role.possible_values supplied = role != roles.UNSPECIFIED - all_supplied = tax_unit.all(supplied) - partly_supplied = tax_unit.any(supplied) & ~all_supplied + any_supplied = tax_unit.any(supplied) + # all() is True over no members, so a unit with none supplies no roles. + all_supplied = tax_unit.all(supplied) & any_supplied + partly_supplied = any_supplied & ~all_supplied if partly_supplied.any(): raise ValueError( f"tax_unit_role_input is supplied for some members of " From 7df3acc1e25354f0a4539d0c563220691223729d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 06:45:03 -0400 Subject: [PATCH 9/9] docs: clarify filing status input overrides --- docs/usage/microsimulation.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/usage/microsimulation.md b/docs/usage/microsimulation.md index 38512609fdd..041ff825887 100644 --- a/docs/usage/microsimulation.md +++ b/docs/usage/microsimulation.md @@ -158,11 +158,12 @@ the unit is separated. Without dependent inputs, that pairs an adult student with a parent as joint filers and leaves a minor living without a parent with no head. -Filing status is not supplied. It is a policy calculation, so `filing_status` -is always computed from the unit's members, including any supplied roles, and -the filing rules, and reforms to those rules apply to every unit. The Populace -build also carries its constructor's filing status in a `filing_status_input` -column. No variable has that name, so the column is not loaded. +The Populace build carries its constructor's filing status in a +`filing_status_input` column. No variable has that name, so the column is +ignored. By default, `filing_status` is computed from the unit's members, +including any supplied roles, and the filing rules. Reforms to those rules +apply when the formula computes the status. A direct `filing_status` input +supplied by a caller still overrides the formula. Supplied roles fail closed rather than falling back. Each of these raises a `ValueError`: