Skip to content

Derive Philadelphia taxable wages and record the work-location inputs as unavailable - #1462

Draft
MaxGhenis wants to merge 8 commits into
mainfrom
us-local-work-location-inputs
Draft

MaxGhenis wants to merge 8 commits into
mainfrom
us-local-work-location-inputs

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #1453 (us-local-jurisdiction-flags, head 51022c0de). The base is main so CI tests the combined state; until #1453 merges, the diff also shows its commits. This PR's own change is 51022c0de..HEAD. Merge #1453 first.

Problem

#1453 derives pa_philadelphia_wage_tax_resident from household geography, but the flag alone moves no tax. In policyengine-us 2.2.1, pa_philadelphia_wage_tax sums pa_philadelphia_wage_tax_taxable_wages times the rate and returns 0 when no person in the tax unit has taxable wages. No microcosm line writes that input, so every built dataset still computes zero Philadelphia wage tax.

The engine ABI lock listed five related person inputs as declared_absent_engine_input. Each has no formula and no uprating in policyengine-us 2.2.1 and 2.60.4:

Input Engine consumer Needs
pa_philadelphia_wage_tax_taxable_wages pa_philadelphia_wage_tax compensation subject to the Wage Tax
pa_philadelphia_wage_tax_reduced_rate_eligible pa_philadelphia_wage_tax whether the person gets the 1.5% income-based rate
ny_yonkers_nonresident_earnings ny_yonkers_income_tax a nonresident's earnings for work in Yonkers
de_wilmington_nonresident_earnings de_wilmington_earned_income_tax a nonresident's earnings for work in Wilmington
co_denver_employee_occupational_privilege_tax_months co_denver_employee_occupational_privilege_tax months with at least $500 earned for work in Denver

Decisions

A new SHA-pinned registry, us_runtime/us_local_tax_inputs.json (e5592932…), records each decision, its reason and its sources. local_jurisdictions.py refuses the registry unless its bytes match the pin.

pa_philadelphia_wage_tax_taxable_wages: derived for residents. A resident's value is max(employment_income_before_lsr, 0); everyone else gets 0.

  • The Philadelphia Income Tax Regulations (§§201, 205) tax a resident "regardless of the place where their services are performed". So a resident's base does not depend on where they work, and the residence flag from Derive US local-jurisdiction flags (Denver, Yonkers, Wilmington, Philadelphia) from household geography #1453 decides who is taxed.
  • The base is gross compensation. Revenue Department guidance taxes employee 401(k), 403(b) and 457 deferrals and Section 125 premiums, HSA and FSA contributions. employment_income_before_lsr is the engine's gross wage input; irs_employment_income subtracts pre-tax contributions, so the gross input is the closer match.
  • Self-employment income falls under the Net Profits Tax, not the Wage Tax, so it stays out.
  • A negative wage is no compensation, so it gives 0.

The nonresident component: unavailable. A nonresident owes tax on pay for days worked in Philadelphia. ACS PUMS POWPUMA 42/03200 is exactly Philadelphia County, so a place-of-work source exists in principle. But:

  • microcosm reads no ACS place-of-work field;
  • ACS records one reference-week location for the main job only; and
  • ACS codes people who worked from home to their home POWPUMA, while Philadelphia taxes a nonresident's elective home-work days.

Nonresidents therefore hold 0, the engine default, and the registry, receipts and manifest record the component as unavailable. Wiring POWSP/POWPUMA is a separate change (follow-up below).

pa_philadelphia_wage_tax_reduced_rate_eligible: unavailable (refund_claim). Employers withhold the full rate. A worker approved for PA Schedule SP tax forgiveness gets 1.5% only by filing a refund petition after the year ends. The City reports 1,421 refunds for tax year 2020; the City Council estimated up to 60,000 eligible households. No survey records the petition, and eligibility alone would overstate the relief many times over. The input stays unwritten, so the engine applies the standard rates.

The three work-location inputs: unavailable (work_location). No defensible source exists at microcosm's geography:

  • Yonkers: the smallest person-level place-of-work unit, POWPUMA 36/03100, is all of Westchester County.
  • Wilmington: POWPUMA 10/00100 is all of New Castle County.
  • Denver: POWPUMA 08/00490 spans nine counties. LODES counts April 1 jobs in three monthly earnings bands, the lowest $1,250 or less, so neither the month count nor the $500 test can be read from it.
  • Place-level commuting flows (CTPP, LODES origin-destination) give no person's earnings.

The registry pins the ACS PUMS 2022 and 2023 code lists (the PUMA-to-POWPUMA and county-to-POWPUMA sheets), the 2020 PUMA criteria, the ACS subject definitions and the LODES 8.4 technical documentation by SHA-256.

Change

local_jurisdictions.py gains the registry loader, us_resident_wage_tax_values, apply_us_local_tax_inputs, with_us_local_tax_inputs and the gate check us_local_tax_input_failures. Every line now writes the input once it has both the residence flag and final wages:

  • Block ladder (tools/build_us_puf_support_base.py, monolithic and staged): right after with_household_us_geography_ladder. The assignment summary records the receipt as local_tax_inputs, and the block_ladder_assignment stage boundary names the new operator.
  • Stacked pool: a new derive operator, with_us_local_tax_inputs. The PUMA ladder draws residence at assembly, before the wage transfers, so the input belongs in the derive stage. It runs after the Schedule D completion and before the QBI reconciliation, whose live-output check binds the person-column inventory. The derive receipt carries the receipt.
  • Pooled ACS line (with_optional_acs_spine, legacy build_optional_acs_multispine): after the flags, on every row.
    • ACS people under 15 carry the PUMS blank wage. With null_wages="propagate", a resident's input stays null wherever the wage is null.
    • The release then fills both with the engine default through the reviewed-null register.
    • pa_philadelphia_wage_tax_taxable_wages is not a declared dataset source input, so the register accepts it.
  • Existing artifacts: rederive_us_local_jurisdiction_flags, which Derive US local-jurisdiction flags (Denver, Yonkers, Wilmington, Philadelphia) from household geography #1453 calls from export_us_l0_refit_h5 and the fiscal refresh release builder, now derives the inputs after the flags. It refuses a carried input that disagrees with the artifact's own flags and wages.
  • Annual static aging: the engine has no uprating series for these inputs, so aging would carry base-year taxable wages beside grown wages. Each projected year now re-derives them from that year's wages, and the base is checked first.

Every unavailable input stays unwritten, and any stale column is removed. The receipt records each input's status and reason, the resident counts and, given weights, the weighted resident wage base.

Engine ABI lock. tools/repin_us_pool_engine_contracts.py re-derived the pool manifest:

  • Projection rows:
    • the taxable wages become derived_resident_wage_tax_input (available derived, fallback nonresident_work_location_unavailable_engine_default);
    • the three work-location inputs become unavailable_work_location_input;
    • the reduced-rate flag becomes unavailable_refund_claim_input;
    • declared absent drops 756 → 751.
  • Five derive reads for with_us_local_tax_inputs: the residence flag, the transferred wage, person_household_id, household_id and the household weight. Derive rows go 34 → 39 and total rows 1,059 → 1,064.
  • Manifest digest 6e70bc74… → 92cad0dc…. The stacked checkpoint identity binds it, so resume refuses a pre-change checkpoint.

Spec pins. The chain in tools/repin_us_spec_engine_contracts.py (legacy-only, bundle, full, coverage, full) re-derived the bundle (US spec_sha256 c69a4313…). spec_engine_coverage.py reports 42,347 of 42,347 configuration fields and 41 of 41 inventory checks. --check passes on all three spec tools and on the pool tool.

Invariants

These hold for every input; Hypothesis tests in tests/engine_free/us/test_us_local_tax_inputs.py check them.

  1. Rule. A resident's taxable wages equal max(wages, 0); every other person's equal 0. So 0 <= taxable <= max(wages, 0).
  2. Monotone. Raising any wages never lowers anyone's taxable wages, and a nonresident's stay 0 whatever they earn.
  3. Determinism and order invariance. Derivation repeats exactly and commutes with any permutation of person rows.
  4. Idempotence and no mutation. Applying the step twice equals applying it once. The caller's tables never change.
  5. Never guessed.
    • An unavailable input is never written, and a stale column is removed.
    • Without the residence flag the taxable wages are not written.
    • A missing column, a null or non-boolean flag, an infinite wage or (unless the line propagates nulls) a resident's null wage raises instead of writing 0.
  6. Receipt accounting. The weighted taxable wages equal the person-weighted sum of the written column, with each person weighted by their household, and the counts match the rows.
  7. Gate. us_local_tax_input_failures passes exactly the column the rule writes, or its absence. It fails any other value, a present unavailable input and, with require_derivable, an absent input whose flag is present.
  8. Round trip. Re-deriving a stripped block-line artifact restores the block line's tables exactly; re-deriving again changes nothing.

The derive-stage contract test checks one more, for the stacked pool: no operator after with_us_local_tax_inputs rewrites the wages it read, so the written value matches the released wages.

Mutation check (Hypothesis DB cleared per mutant), each killed by the new tests:

  • dropping the max(·, 0);
  • giving nonresidents a share of their wages;
  • keeping stale unavailable columns;
  • removing the gate's disagreement check;
  • misaligning person weights with households;
  • accepting a resident's null wage in refuse mode;
  • dropping require_derivable.

Tests

  • engine_free/us/test_us_local_tax_inputs.py (new):
    • the registry pin, an oracle of the five decisions written out independently of the file, source-document pins, and 17 malformed-registry refusals;
    • the properties above;
    • null and infinite handling;
    • gate cases;
    • the frame helper, re-derivation refusals and the aging re-derivation.
  • engine_scenario/us/test_us_local_tax_inputs_engine.py (new): households carry only employment_income_before_lsr, are placed on fixture blocks by with_household_us_geography_ladder and built with with_us_local_tax_inputs, then run through PolicyEngineUSEngine.materialize.
    • The Philadelphia resident owes wages × the resident rate.
    • A resident with a negative wage and the Delaware County neighbour owe 0.
    • Stripping the derived input returns the tax to 0 while the residence flag stays true.
    • Re-deriving a stripped artifact restores the tax.
  • engine_free/us/test_us_base_pool.py: the pooled line re-derives a stale carried value on every row, and a resident ACS child's blank wage leaves a null input. Seeds 0 and 2 place the ACS household in Philadelphia, the others in Delaware County.
  • engine_free/us/test_us_acs_multispine.py: the pooled receipt records the input as unavailable without a ladder and derived (propagate mode) with one.
  • engine_free/us/test_us_local_jurisdictions.py: the re-derivation round trip covers the taxable wages.
  • engine_free/us/test_us_multispine_pool.py, engine_contract/us/test_us_multispine_pool.py, engine_contract/us/test_spec_engine_engine_abi.py:
    • new manifest counts, rows and digest;
    • the derive stage writes the taxable wages for residents (a negative resident wage gives 0), receipts them, and leaves the wages it read unchanged through the QBI reconciliation;
    • the derive entrypoint maps its kernels in POOL_DERIVE_OPERATOR_ORDER, and the structure check knows the local-tax receipt wrapper;
    • a pre-clone pool reaches no derive kernel.
  • engine_free/us/test_us_l0_refit_export.py: the fixture frame gains employment_income_before_lsr, which every built base carries. A new case exports a base that predates the input with one household moved onto a Philadelphia block: its people's wages become their taxable wages, the receipt marks the input derived with the weighted wage base, and no work-location input appears.
  • engine_free/us/test_us_multispine_pool_tool.py: the legacy publication golden's manifest identity moves with the remaining-stage manifest. pool_h5 and agreement stay unchanged.
  • microcosm-graph H3 parity fixture: tools/graph_us_post_transfer_fixture.py regenerated us_post_transfer.json. Only the derive node's remaining_stage_manifest_sha256 changed: Derive US local-jurisdiction flags (Denver, Yonkers, Wilmington, Philadelphia) from household geography #1453 regenerated it to its own digest, 6e70bc74…, and this PR moves it to 92cad0dc…. expected.csv is unchanged, and the tool's own graph-parity check passes.

Not covered

  • The ladder gates (us_geography_ladder_gate, us_puma_ladder_gate) take only the household table, so they do not check this person input. Each line's derivation raises on anything it cannot decide, and re-derivation and aging run the gate check.
  • The value stays fixed once written. A labor-supply response moves the engine's employment_income but not this input.
  • Philadelphia excludes nonqualified stock-option income (W-2 code V) from the base; microcosm cannot separate it from wages.

Follow-ups

Running Route A builds pin their own commit and are untouched.

axiom: n/a: microcosm input provisioning; no country-model rule changes.

🤖 Generated with Claude Code

MaxGhenis and others added 8 commits November 2, 2026 08:02
policyengine-us declares in_denver, in_yonkers, in_wilmington and
pa_philadelphia_wage_tax_resident as inputs with no formula. The registry maps
each to its 2020 county (Denver 08031, Philadelphia 42101) or place (Yonkers
36/84000, Wilmington 10/77580), checked against the 2020 Gazetteer, P.L.
94-171 P1_001N and block ladders 7ba39b95 and 6840b990.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The block-ladder frame wrapper derives all four flags from the block's county
and place. The PUMA ladder and the pooled ACS line derive the county-rule
flags (in_denver, pa_philadelphia_wage_tax_resident) from the final county
and remove the place-rule flags (in_yonkers, in_wilmington), which no place
rung can decide there. Summaries record each flag as derived or unavailable,
and both ladder gates fail a present flag that disagrees with its rule.

The pool manifest moves the county-rule flags to
geography_derived_jurisdiction_input (assembled) and the place-rule flags to
unavailable_place_jurisdiction_input, and relabels county_fips and
congressional_district_geoid, which the stacked PUMA operator writes, as
preserved_stacked_engine_input. tools/repin_us_pool_engine_contracts.py
re-derived the provision counts, the manifest digest and the engine ABI lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-derived with the repo tools after the derivation change:
repin_us_spec_engine_contracts.py (--legacy-only, then full),
generate_us_bundle_from_constants.py (US spec_sha256 0b5c29e4...) and
spec_engine_coverage.py (42305/42305 fields, 41/41 checks); --check passes
on all three and on repin_us_pool_engine_contracts.py.

Tests: Hypothesis properties for the iff rule, block-population
conservation, row-order invariance and person broadcast, plus registry pin,
gate, PUMA and pooled-line cases (engine_free); an engine contract that each
flag is an input-only boolean its consumers read; and an engine scenario in
which Yonkers, Wilmington, Denver and Philadelphia residents placed by the
block ladder get their local tax or benefit and their neighbours do not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, record the no-ladder pool receipt

Answers the round-1 review of b7b3e21:

- rederive_us_local_jurisdiction_flags reads the rungs an existing
  artifact's own geography supports (county when county_fips is on every
  row; place only when place_fips and a 15-digit block_geoid are too),
  refuses carried household or person flags that disagree, and writes
  the flags. export_us_l0_refit_h5 and the fiscal refresh release
  builder call it on the loaded base and record the receipt.
- Both ladder gates fail an absent flag whose rule the line's own rung
  decides (block: county and place; PUMA: county), so a missing column
  can no longer reach the engine as an all-False default.
- build_optional_acs_multispine records the pool's flag receipt in
  provenance on both the ladder and no-ladder paths.

Also classifies local_jurisdictions.py for the spine-blindness test,
repins the engine ABI digest, and reruns the US spec repin chain
(seed-kernel attestations hash geography_ladder.py and puma_ladder.py).

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

tools/graph_us_post_transfer_fixture.py rewrote the pinned graph's
remaining_stage_manifest_sha256 (d4fb9de9 -> 6e70bc74) after this PR
moved the pool manifest. Sources and expected.csv are byte-identical;
the tool's own graph-parity check passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… as unavailable

pa_philadelphia_wage_tax returns 0 unless pa_philadelphia_wage_tax_taxable_wages
is nonzero, and no line wrote it. A new SHA-pinned registry,
us_local_tax_inputs.json, decides the five related person inputs:

- pa_philadelphia_wage_tax_taxable_wages: max(employment_income_before_lsr, 0)
  for residents (the regulations tax a resident's compensation wherever
  earned, deferrals and Section 125 reductions included), 0 for everyone
  else. The nonresident component is unavailable: microcosm reads no ACS
  place-of-work field, and ACS codes elective home workers to their home.
- pa_philadelphia_wage_tax_reduced_rate_eligible: unavailable (refund_claim);
  the 1.5% rate comes only by petition after the year ends.
- ny_yonkers_nonresident_earnings, de_wilmington_nonresident_earnings,
  co_denver_employee_occupational_privilege_tax_months: unavailable
  (work_location); the smallest place-of-work units are whole counties.

Unavailable inputs are never written, and stale columns are removed. The block
ladder, the stacked pool (a derive operator before the QBI reconciliation),
the pooled ACS line (propagating PUMS blank wages), re-derivation of existing
artifacts and annual static aging all write the taxable wages once residence
and wages are final.

Repins the pool manifest (derive 34 -> 39, digest 92cad0dc), the spec chain
(US spec_sha256 c69a4313, 42347/42347 fields) and the legacy publication
golden's manifest identity. Adds Hypothesis properties, gate and refusal
cases, a pooled-line propagate test and an engine scenario in which a
Philadelphia resident built through the block line owes wages times the
resident rate.

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

tools/graph_us_post_transfer_fixture.py rewrote us_post_transfer.json; only
the derive node's remaining_stage_manifest_sha256 changed (6e70bc74, the
manifest with the flag change, to 92cad0dc, which adds the local-tax derive
operator). expected.csv is unchanged and the tool's graph-parity check
passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The derive entrypoint's operators mapping listed with_us_local_tax_inputs
after the QBI reconciliation. It now lists the kernels in
POOL_DERIVE_OPERATOR_ORDER, as the structure check requires, and the check
knows the receipt wrapper. The L0 refit-export and derive-stage fixtures
gain employment_income_before_lsr, which every built frame carries. Two
tests show a Philadelphia resident's wages becoming the taxable wages, one
through the L0 refit export of a base that predates the input and one
through the pool derive stage, where no later operator rewrites the wages
the base read and a resident's negative wage gives 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis force-pushed the us-local-work-location-inputs branch from a805259 to 5a7135f Compare November 2, 2026 17:54
@MaxGhenis
MaxGhenis changed the base branch from us-local-jurisdiction-flags to main November 2, 2026 17:56
@MaxGhenis MaxGhenis closed this Nov 2, 2026
@MaxGhenis MaxGhenis reopened this Nov 2, 2026

This branch has not been deployed

No deployments
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