Skip to content

Exclude input dependents from tax unit head and spouse inference - #9630

Merged
MaxGhenis merged 3 commits into
mainfrom
fix-head-excludes-input-dependents
Oct 6, 2026
Merged

MaxGhenis merged 3 commits into
mainfrom
fix-head-excludes-input-dependents

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

is_tax_unit_head picks the oldest non-child in the tax unit, and is_tax_unit_spouse picks the next oldest adult. Neither reads an input is_tax_unit_dependent. So a person the caller explicitly marks as a dependent can become the filer or the filer's spouse:

  • An elderly parent marked as a dependent becomes the head. The real head becomes the spouse, and the real spouse ends up as neither.
  • An adult child marked as a dependent becomes a single parent's spouse, and the return files JOINT.

This PR makes head and spouse inference skip people input as tax-unit dependents. The fallback: if a tax unit has no adult who isn't an input dependent, every adult stays a candidate, exactly as before. Without dependent inputs, the rule is unchanged.

Changed expectations (CI found these at 9b2c416)

Two existing tests had an 18-year-old input dependent whom main inferred as the filer's spouse, so the unit filed jointly. With this fix the parent files alone as head of household:

  • ma/dta/tcap/tafdc/integration.yaml Case 2: the dependent care deduction is no longer halved for a joint filer. Countable earned income is 7,600 × 50% − 528 = 3,272 for the year, times 6/12 after the full disregard, so 1,636 (was 1,768). TAFDC is 6,439 (was 6,307).
  • contrib/congress/afa/afa_other_dependent_credit.yaml: the head-of-household higher threshold is 300,000, not the joint 400,000. The "without limited phase-out" case now uses 290,000 of wages to keep its intent. The "fully phased out" case's phase-out is ceil(150,000 / 1,000) × 50 = 7,500.

Reproduction

Period 2015, AR. People: head 40, spouse 40, parent 70 with is_tax_unit_dependent: true. All three are in one tax unit, with explicit marital units and no role inputs.

is_tax_unit_head is_tax_unit_spouse age_head filing_status
main (ca1805c) [F, F, T] [T, F, F] 70 JOINT
this PR [T, F, F] [F, T, F] 40 JOINT

Households shaped exactly like the web app's payloads (2026, TX, primary earner $50,000), federal income_tax:

Household main this PR
Single 40 + "dependent" aged 19 dependent is spouse, JOINT, $1,280 HEAD_OF_HOUSEHOLD, $2,248
Married 40/40 + "dependent" aged 70 70-year-old is head, $515 couple is head/spouse, $1,280

Who this reaches

Findings below come from code read on each repo's origin/main for this PR.

  • Web app (policyengine-app-v2 @ 0c41dbf8). Household.addChild sets is_tax_unit_dependent: {year: true} on every US dependent (app/src/models/Household.ts:1105-1117). No runtime code sets is_tax_unit_head or is_tax_unit_spouse. Age is an editable basic input for every person, and HouseholdValidation.ts only checks that it is present. The v1 API (policyengine-api @ aac95d3b) adds None placeholders for formula variables, so the roles stay computed, and then builds Simulation(situation=household) (policyengine_api/country.py:542). Result: any app user who gives a "dependent" an age of 18 or over hits this bug. Default households (children aged 10) are unaffected.
  • policyengine.py (6.1.1 @ 4dc59596). calculate_household passes caller dicts through verbatim and never adds role variables (src/policyengine/tax_benefit_models/us/household.py:99-131). Callers who flag an adult dependent are affected. This includes the docstring pattern (head flagged, dependent flagged, spouse not flagged), where an older dependent becomes the spouse. It pins policyengine-us 2.2.1, which has the same formulas.
  • policyengine-household-api (@ cbc588a0). Passes situations through without touching roles (libs/household-api/policyengine_household_api/country.py:885-911).
  • Partner contract fixtures (tests/policy/baseline/partners/). Unchanged. Across 143 files and 620 cases, 26 adults are input as dependents. Every case containing one also inputs is_tax_unit_head and is_tax_unit_spouse, so neither formula runs for them.
  • Microsimulation. Unchanged with DEFAULT_DATASET. The pinned populace_us_2024.h5 (sha256 6496cc43…, matching DEFAULT_DATASET_SHA256) has no is_tax_unit_* column in any table. Its person table carries tax_unit_role_input instead, and that is not a policyengine-us variable on main. With no stored dependent input, the helper returns the same ~is_child mask as before.

Prior reports: #9132 worked around this in the childless EITC age test ("an older adult dependent is itself classified as the unit 'head'"). #9612 lists it as a pre-existing issue.

Design

is_tax_unit_dependent's formula is ~is_tax_unit_head & ~is_tax_unit_spouse. So head and spouse can't call person("is_tax_unit_dependent", period): when the flag isn't input, that call raises CycleError (head → dependent → head).

The new helper head_or_spouse_candidates (_head_or_spouse_candidates.py) reads the stored value with person.simulation.get_array("is_tax_unit_dependent", period). That core call is branch-aware and never runs a formula (policyengine_core/simulations/simulation.py:1233-1241).

  • No stored value: candidates are ~is_child, as before.
  • Stored input: candidates are adults not flagged. A unit with no such adult keeps all its adults.
  • Stored value that the formula computed: this can only exist after head and spouse were computed for the period. It flags exactly the people who are neither, so excluding them leaves the ranking unchanged. test_computed_dependents_do_not_change_roles covers this.
  • Scope is the exact period. Core never carries a formula variable's input into other periods, so a 2015 dependent input doesn't affect 2016 inference.

Invariants

These hold for every input and are checked in tests/core/test_tax_unit_role_inference.py. That file enumerates every tax unit of up to three people drawn from ages {10, 18, 40, 70} × dependent input {true, false}: 584 units, including age ties and the age-18 boundary.

  1. Reference agreement. Head and spouse equal a pure-Python reference of the rule: rank candidates by age, oldest first, with ties going to the earlier person. Candidates are adults not input as dependents, or all adults if there are none.
  2. Differential vs. the previous rule. With no dependent inputs, or with only false inputs, roles are identical to the previous oldest-adult rule.
  3. Structure.
    • Exactly one head when the unit has an adult, and none otherwise.
    • At most one spouse.
    • Head and spouse are disjoint, and both are adults.
    • An input dependent is never head or spouse while the unit has an adult who isn't one.
  4. Inputs are kept. is_tax_unit_dependent returns its input unchanged.
  5. No cycle, order-independent. Computing is_tax_unit_dependent before head and spouse works, and gives the same roles when head and spouse are recomputed.
  6. Branches. A dependent input set only in a branch is honoured in that branch, and not in the parent.

The invariant test fails on main in checks 1, 3 and 6, and 5 of the 9 new YAML cases fail on main. The rest are controls that pass on both.

Tests

  • New YAML cases in household/demographic/tax_unit/is_tax_unit_head.yaml and is_tax_unit_spouse.yaml:
    • Elderly dependent parent with a couple (the 2015 AR reproduction).
    • Single filer with a dependent parent (HEAD_OF_HOUSEHOLD).
    • Single parent with a dependent 20-year-old student (no spouse, HEAD_OF_HOUSEHOLD).
    • Head input plus an elderly dependent (the policyengine.py docstring pattern).
    • Dependents of several ages.
    • Controls: no dependent inputs, explicit false inputs, a lone adult input as a dependent, and every adult input as a dependent.
  • Local runs: pending (household and IRS shards, tests/core); results to follow in a comment.

Overlap with #9617

#9617 (dataset-supplied tax_unit_role_input) edits the same lines. The two compose: its age fallback (oldest_adult / next_oldest_adult) should rank head_or_spouse_candidates(person, period) instead of ~is_child. Whichever PR merges second takes that one-line change per formula.

axiom: n/a: household role inference (which input person is the filer), not a legal provision

🤖 Generated with Claude Code

is_tax_unit_head picked the oldest non-child and is_tax_unit_spouse the
next oldest adult, ignoring an input is_tax_unit_dependent. A dependent
elderly parent therefore became the head (and the real head the spouse),
and an adult child input as a dependent became a single parent's spouse,
turning the return JOINT. The web app flags every dependent it adds and
never sends head or spouse, so this reached app users who set a
dependent's age to 18 or over.

Head and spouse now rank only adults not input as dependents, unless a
tax unit has no such adult, in which case every adult stays a candidate
as before. is_tax_unit_dependent's formula depends on head and spouse,
so the inputs are read with simulation.get_array, which never runs a
formula. Without dependent inputs the rule is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (909176a) to head (191a8ae).
⚠️ Report is 51 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff            @@
##              main     #9630   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            4         3    -1     
  Lines           76        36   -40     
  Branches         2         1    -1     
=========================================
- Hits            76        36   -40     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

MaxGhenis and others added 2 commits October 5, 2026 22:35
… spouse

CI on 9b2c416 found both. In each, an 18-year-old input dependent was
inferred as the filer's spouse on main, so the unit filed jointly. With
input dependents excluded from head and spouse inference, the parent files
alone as head of household:

- MA TAFDC integration Case 2: the dependent care deduction is no longer
  halved for a joint filer, so 7600 * 0.5 - 528 = 3272 and countable
  earned income is 3272 * 6/12 = 1636 (was 1768); TAFDC is 6439 (was
  6307). The old comment's arithmetic (1900 - 528 = 1768) did not add up.
- AFA other dependent credit: the head-of-household higher threshold is
  300,000, not the joint 400,000. The "without limited phase-out" case
  lowers the head's wages to 290,000 to keep its intent; the "fully
  phased out" case's phase-out is ceil(150,000 / 1,000) * 50 = 7,500.

Both files pass locally (3 and 12 cases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis merged commit 3bed4d0 into main Oct 6, 2026
35 checks passed
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Merged by the US + core hub on its merge gates. Audit:

  • Head: 17dbd8cd6fd968aefd933d3a46c3c96db883d7d4.
  • Independent review (Opus 5.5, files in ~/reviews/us-hub/reviews-gen/us-9630/):
    • r1b approved 9b2c41605b, the PR plus a main merge, with no P1 or P2.
    • Methodology: r1b flagged a methodology choice without naming one. An independent adjudication (adjudication.md) ruled MAX_CALL: no. The legal touchpoints (IRC §152(b)(1), §152(d)(2)(H) and §152(b)(2)) have one defensible reading each, and the rest are prior-behavior conventions.
    • CI fix and r2: CI at 9b2c41605b failed two existing tests. Each had an 18-year-old input dependent whom main inferred as the filer's spouse, so the unit filed jointly. The hub's commit 17dbd8cd6f changes expectations only, in two YAML files:
      • MA TAFDC countable earned income is 1,636 (was 1,768), and TAFDC is 6,439.
      • The AFA head-of-household threshold of 300,000 applies. The phase-out is 7,500, and case 1's wages are lowered to 290,000 to keep its intent.
    • r2 approved that delta. It re-derived each number from 106 CMR 704.281 and 704.275 and from the AFA bill (p. 5, "$300,000, in any other case").
  • CI: every check passed at this head. The full suite runs all roughly 150 test files with adult input dependents.
  • Impact: the default microsimulation has no stored is_tax_unit_dependent, so its output is unchanged. Households that input adult dependents now get the correct head and spouse.
  • Overlap: Honor dataset-supplied tax-unit roles #9617 edits the same two formulas. If it merges after this, it should rank head_or_spouse_candidates(...) in place of ~is_child.

@MaxGhenis
MaxGhenis deleted the fix-head-excludes-input-dependents branch October 6, 2026 06:07
MaxGhenis added a commit that referenced this pull request Oct 6, 2026
Conflicts:
- pyproject.toml, uv.lock: main already added hypothesis>=6.100.0 to the
  dev group, this branch's only new dependency, so both files are main's;
  `uv lock` leaves uv.lock unchanged.
- is_tax_unit_head.py, is_tax_unit_spouse.py: #9630 narrowed the
  age-ordered fallback to head_or_spouse_candidates (skipping input
  dependents); this branch wraps that fallback in
  where(tax_unit_roles_supplied, <supplied role>, <fallback>). Kept both:
  supplied roles still decide when every member has one, and otherwise the
  fallback uses main's candidates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 6, 2026
Brings in #9808, #9751, #9752, #9788 and #9630. No conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 6, 2026
Brings in #9841 (28 percent rate gain and unrecaptured section 1250 gain
netted against losses), #9801, #9790, #9779, #9768, #9741 and #9630. Only
test_section_911_tax_stacking.py changed on both sides, in different
functions, and git merged it without conflict. #9841 passes Schedule D
lines 18 and 19 entered already netted through unchanged, and this
branch's capital_gains_tax, reforms and tests read the section 911
amounts and Schedule D inputs that #9841 keeps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 6, 2026
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 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 6, 2026
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 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 8, 2026
* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* Guarantee each abolition property case holds its target status

Independent review of d8b02c1 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* docs: clarify filing status input overrides

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant