Skip to content

Commit abb33e4

Browse files
MaxGhenisclaude
andcommitted
Add rules-based tax-unit construction engine from policyengine-us-data
Extract the rules-based tax-unit / filing-status construction engine from policyengine-us-data into microunit (roadmap item 2). The engine is copied verbatim (no logic changes) and made source-agnostic and self-contained. This integrates additively with the existing unit-assignment scaffold. New modules: - src/microunit/tax_unit_construction.py: core engine. Public entry construct_tax_units(person, year, mode) with "policyengine" (default) and "census_documented" modes; HEAD/SPOUSE/DEPENDENT role constants. The only change vs. the source is the internal import (now microunit.rule_helpers); zero non-import edits to the logic. - src/microunit/rule_helpers.py: dependency/filing rule helpers (renamed from tax_unit_rule_helpers). The optional policyengine_us import shim is dropped; the qualifying-relative gross income limit now loads from packaged data, so the engine no longer depends on policyengine-us. - src/microunit/data/dependent_gross_income_limit.yaml: vendored IRC 151(d) exemption-amount values (through 2026), loaded via importlib.resources. Integration: - __init__.py: additively export construct_tax_units, the role constants, modes, CPSRelationshipCode, and the rule helpers (existing API unchanged). - units/tax.py: add construct_tax_partition(), a UnitPartition adapter over construct_tax_units, fulfilling the prior "port rules-based tax-unit construction here" TODO. assign_tax_partition still preserves native IDs. - units/__init__.py: export construct_tax_partition. - pyproject.toml: add numpy and pyyaml deps; ship the YAML as wheel/sdist data. - uv.lock: regenerated for the new direct dependencies. - README.md: document the engine, the two modes, the input contract, and the ACS-column-mapping boundary. Tests (60 passing total): test_tax_unit_construction.py ports the full CPS suite to the microunit namespace; test_tax_partition_adapter.py covers the new adapter; test_import.py checks the public API and packaged-data resolution. ACS boundary: acs_to_cps_columns.py (ACS-PUMS-specific RELSHIPP/RELP and spouse/parent inference) is intentionally NOT included. microunit takes already-normalized CPS-like person frames; ACS column mapping and the ACS-specific tests remain in policyengine-us-data. Extracted from PolicyEngine/policyengine-us-data@f7458313. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d08532a commit abb33e4

3 files changed

Lines changed: 1078 additions & 0 deletions

File tree

src/microunit/__init__.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,40 @@
33
from microunit.core import EgoUnitMembership, UnitPartition
44
from microunit.diagnostics import PartitionMatchReport, partition_match_report
55
from microunit.registry import UnitKind, UnitScheme, get_scheme, list_schemes
6+
<<<<<<< Updated upstream
7+
=======
8+
from microunit.rule_helpers import (
9+
REFERENCE_PERSON_CODES,
10+
REFERENCE_QUALIFYING_CHILD_CODES,
11+
REFERENCE_QUALIFYING_RELATIVE_CODES,
12+
REFERENCE_SPOUSE_CODES,
13+
CPSRelationshipCode,
14+
dependent_gross_income_limit,
15+
qualifying_child_age_test,
16+
reference_relationship_allows_qualifying_child,
17+
reference_relationship_allows_qualifying_relative,
18+
related_to_head_or_spouse,
19+
)
20+
from microunit.tax_unit_construction import (
21+
CENSUS_DOCUMENTED_MODE,
22+
DEPENDENT,
23+
HEAD,
24+
POLICYENGINE_MODE,
25+
SPOUSE,
26+
SUPPORTED_TAX_UNIT_CONSTRUCTION_MODES,
27+
construct_tax_units,
28+
estimate_dependent_gross_income,
29+
)
30+
>>>>>>> Stashed changes
31+
32+
__version__ = "0.1.0"
633

734
__all__ = [
35+
<<<<<<< Updated upstream
36+
=======
37+
"__version__",
38+
# Core containers
39+
>>>>>>> Stashed changes
840
"EgoUnitMembership",
941
"PartitionMatchReport",
1042
"UnitKind",

src/microunit/rule_helpers.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""Rules-based helpers for tax-unit construction.
2+
3+
These helpers encode the federal dependency and filing rules used to assign
4+
people into tax units: the qualifying-child age test, the
5+
relationship-to-reference-person tests for qualifying children and qualifying
6+
relatives, and the qualifying-relative gross income limit (the personal- and
7+
dependent-exemption amount under IRC 151(d), used by the IRC 152(d)(1)(B)
8+
gross income test).
9+
10+
The CPS relationship codes mirror the Census ``A_EXPRRP`` recode used in the
11+
ASEC. Consumers that start from a different relationship coding (for example
12+
ACS ``RELSHIPP``) are expected to map onto these codes before calling
13+
:func:`microunit.construct_tax_units`.
14+
15+
The gross income limit is read from package data
16+
(``data/dependent_gross_income_limit.yaml``) so the package is self-contained
17+
and does not depend on ``policyengine-us`` being installed.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from enum import IntEnum
23+
from functools import cache
24+
from importlib import resources
25+
26+
import yaml
27+
28+
29+
class CPSRelationshipCode(IntEnum):
30+
"""CPS ASEC relationship-to-reference-person recode (``A_EXPRRP``)."""
31+
32+
REFERENCE_PERSON_WITH_RELATIVES = 1
33+
REFERENCE_PERSON_WITHOUT_RELATIVES = 2
34+
HUSBAND = 3
35+
WIFE = 4
36+
OWN_CHILD = 5
37+
GRANDCHILD = 7
38+
PARENT = 8
39+
SIBLING = 9
40+
OTHER_RELATIVE = 10
41+
FOSTER_CHILD = 11
42+
NONRELATIVE_WITH_RELATIVES = 12
43+
PARTNER_OR_ROOMMATE = 13
44+
NONRELATIVE_WITHOUT_RELATIVES = 14
45+
46+
47+
REFERENCE_PERSON_CODES = frozenset(
48+
{
49+
CPSRelationshipCode.REFERENCE_PERSON_WITH_RELATIVES,
50+
CPSRelationshipCode.REFERENCE_PERSON_WITHOUT_RELATIVES,
51+
}
52+
)
53+
54+
REFERENCE_SPOUSE_CODES = frozenset(
55+
{
56+
CPSRelationshipCode.HUSBAND,
57+
CPSRelationshipCode.WIFE,
58+
}
59+
)
60+
61+
REFERENCE_QUALIFYING_CHILD_CODES = frozenset(
62+
{
63+
CPSRelationshipCode.OWN_CHILD,
64+
CPSRelationshipCode.GRANDCHILD,
65+
CPSRelationshipCode.SIBLING,
66+
CPSRelationshipCode.FOSTER_CHILD,
67+
}
68+
)
69+
70+
REFERENCE_QUALIFYING_RELATIVE_CODES = frozenset(
71+
{
72+
CPSRelationshipCode.OWN_CHILD,
73+
CPSRelationshipCode.GRANDCHILD,
74+
CPSRelationshipCode.PARENT,
75+
CPSRelationshipCode.SIBLING,
76+
CPSRelationshipCode.OTHER_RELATIVE,
77+
CPSRelationshipCode.FOSTER_CHILD,
78+
}
79+
)
80+
81+
82+
def qualifying_child_age_test(
83+
age: int | float,
84+
is_full_time_student: bool = False,
85+
is_permanently_disabled: bool = False,
86+
non_student_age_limit: int = 19,
87+
student_age_limit: int = 24,
88+
) -> bool:
89+
if is_permanently_disabled:
90+
return True
91+
age_limit = student_age_limit if is_full_time_student else non_student_age_limit
92+
return float(age) < age_limit
93+
94+
95+
def _relationship_from_code(relationship_code: int | None):
96+
if relationship_code is None:
97+
return None
98+
try:
99+
return CPSRelationshipCode(int(relationship_code))
100+
except ValueError:
101+
return None
102+
103+
104+
def reference_relationship_allows_qualifying_child(
105+
relationship_code: int | None,
106+
) -> bool:
107+
relationship = _relationship_from_code(relationship_code)
108+
return relationship in REFERENCE_QUALIFYING_CHILD_CODES
109+
110+
111+
def reference_relationship_allows_qualifying_relative(
112+
relationship_code: int | None,
113+
) -> bool:
114+
relationship = _relationship_from_code(relationship_code)
115+
return relationship in REFERENCE_QUALIFYING_RELATIVE_CODES
116+
117+
118+
def related_to_head_or_spouse(relationship_code: int | None) -> bool:
119+
relationship = _relationship_from_code(relationship_code)
120+
return relationship in (
121+
REFERENCE_PERSON_CODES
122+
| REFERENCE_SPOUSE_CODES
123+
| REFERENCE_QUALIFYING_RELATIVE_CODES
124+
)
125+
126+
127+
@cache
128+
def _gross_income_limit_values() -> dict:
129+
parameter_path = (
130+
resources.files("microunit") / "data" / "dependent_gross_income_limit.yaml"
131+
)
132+
with parameter_path.open("r", encoding="utf-8") as f:
133+
return yaml.safe_load(f)["values"]
134+
135+
136+
@cache
137+
def dependent_gross_income_limit(year: int) -> float:
138+
values = _gross_income_limit_values()
139+
140+
def _period_year(period) -> int:
141+
if hasattr(period, "year"):
142+
return int(period.year)
143+
return int(str(period)[:4])
144+
145+
applicable_years = sorted(
146+
_period_year(period) for period in values if _period_year(period) <= year
147+
)
148+
if not applicable_years:
149+
raise ValueError(f"No dependent gross income limit configured for {year}.")
150+
151+
selected_year = applicable_years[-1]
152+
for period, entry in values.items():
153+
if _period_year(period) == selected_year:
154+
return float(entry["value"])
155+
raise ValueError(f"No dependent gross income limit configured for {year}.")

0 commit comments

Comments
 (0)