Skip to content

Finish #515: per-record E19200 decomposition by published SOI 2.1 AGI-band components - #561

Merged
MaxGhenis merged 18 commits into
mainfrom
e19200-decomposition-515
Jul 27, 2026
Merged

Finish #515: per-record E19200 decomposition by published SOI 2.1 AGI-band components#561
MaxGhenis merged 18 commits into
mainfrom
e19200-decomposition-515

Conversation

@MaxGhenis

Copy link
Copy Markdown
Contributor

Why

Issue #515 identified an inconsistent calibration surface: the processed PUF
carried all-zero investment_interest_expense, so the broader
interest_deduction and mortgage-specific HMID controls compiled identical
carrier vectors even though their targets differ. This implements the
adjudicated repair.

Fixes #515

What changed

  • Package the TY2015 IRS SOI Table 2.1 rows 10–32 for total interest paid,
    home mortgage interest, deductible points, qualified mortgage-insurance
    premiums, and investment interest.

  • Select each record’s published share with source-year E00100 and split
    E19200 deterministically:

    mortgage_i = E19200_i × CH_band / CF_band
    non_mortgage_i = E19200_i − mortgage_i
    
  • Preserve the existing raw-E19200 $10M outlier screen and all
    mortgage-only lineage columns.

  • Populate investment_interest_expense with the conserving non-mortgage
    residual.

  • Align source-year AGI for the processed PUF’s seeded disclosure-record
    replacements, while keeping the Forbes-backed open tail in its known final
    SOI band.

  • Switch JCT HMID neutralization from interest_deduction to
    deductible_mortgage_interest.

  • Require nonconstant investment-interest signal at release, advance the
    primary QRF checkpoint to schema v4, update Build J source pins, add the
    changelog, and add decomposition/provenance regressions.

The official sources are the
TY2015 Table 2.1 workbook
and Publication 1304, printed pp. 100–101.
The ledger feed has no TY2015 band rows. Every packaged row matches the
official printed table; the repo’s historical SOI asset independently matches
all CF/CH cells. The published band totals are $304.461163B total,
$283.004467B mortgage, and $21.456696B conserving residual.

Allocation and real-input audit

The split is proportional within each half-open published AGI band, so each
record and each band preserve E19200 exactly. It is bit-deterministic and
order-invariant. On the pinned real inputs, 211,677 source-year AGIs align; the
existing outlier screen retains 208,611 donor rows and the weighted identity is
$418.251492B = $378.352506B mortgage + $39.898987B residual.

Checkpoint discipline

This repair runs in the base builder. Its staged resume identity hashes the
builder and all package sources, plus the new TY2015 PUF input’s full SHA; the
inner QRF checkpoint advances to schema v4. It therefore requires and produces
a new base H5. Release target-frame checkpoints and reform-vector caches are
keyed by that H5’s full SHA (and the changed target-registry/reform identities),
so no release materializer or cache-schema bump is warranted. Unlike #557, no
release-time staged transform changes behind an unchanged base hash.

Validation

  • uv run pytest packages/populace-build/tests/ -qrc 0
  • Ruff check on all 22 changed Python files — passed
  • Ruff format-check on all 22 changed Python files — passed
  • Independent implementation audit — clean

Key regressions:

  • test_ty2015_e19200_component_rows_are_complete_cited_and_conservative
  • test_e19200_donor_split_preserves_each_band_and_published_shares
  • test_e19200_donor_split_is_bit_deterministic_and_order_invariant
  • test_source_year_puf_agi_matches_pinned_flat_array_artifact
  • test_soi_filtered_targets_keep_mortgage_and_broad_interest_distinct
  • test_jct_mortgage_reform_neutralizes_only_the_mortgage_component
  • test_outer_stage_resume_rejects_changed_builder_code
  • test_puf_stage_distinguishes_runtime_prefix_from_artifact_lineage

MaxGhenis and others added 16 commits July 27, 2026 08:30
The interim national carve cannot support a per-AGI-band decomposition unless every carried component is independently traceable. Capture TY2015 SOI Table 2.1 rows 10-32, all five interest-paid amount columns, band boundaries, official URLs, and workbook/report coordinates as a packaged source asset. This preserves the published four-part identity and makes explicit that the modeled non-mortgage residual includes points and mortgage-insurance premiums as well as investment interest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A proportional split is only safe if it preserves every donor record and every published band while routing the full non-mortgage residual exactly once. Add RED tests for the complete source-row identity, the open-ended negative-AGI first band, nonzero residual mass, explicit AGI input, deterministic order-independent bits, and exact E19200 conservation. The tests currently fail at collection because the decomposition module is intentionally not implemented yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The national carve discarded the non-mortgage residual and left the broader-interest and HMID measures structurally identical. Load the row-cited TY2015 component table, choose a deterministic mortgage share from each donor record’s explicit tax-unit AGI, preserve raw E19200 as mortgage plus the modeled non-mortgage residual, and persist that residual through PolicyEngine’s investment_interest_expense leaf. Keep the grouped-raw $10M screen ahead of the split and apply one share to every mortgage-lineage leg.

The base builder now calculates aligned AGI from the pinned processed PUF and releases the transient engine simulation before QRF. Adding the new person target changes the production target-order digest, while schema v4 also invalidates custom-order standalone checkpoints whose manifests do not fingerprint donor construction.

Focused decomposition, support, base-builder, and QRF tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Once the E19200 residual reaches investment_interest_expense, zeroing the broader interest_deduction would erase both source-backed components and misstate the mortgage tax expenditure. Point the JCT mortgage reference at deductible_mortgage_interest and pin that the reform can no longer neutralize the broader deduction.

The focused JCT registry, reform-shape, and coverage tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY: Once E19200 carries a non-mortgage component, the SOI home-mortgage target must compile from the mortgage-specific engine variable rather than remain byte-identical to the broader interest-paid target.

Extend the target materializer fixture with both concepts and assert that their itemizer-filtered household vectors differ.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY: The published Table 2.1 decomposition replaces the prior source-unavailability adjudication, so leaving investment_interest_expense exempt from coverage or export gates would let the repaired component disappear silently.

Move ownership to the PUF tax-detail stage, remove the obsolete known gap, regenerate the 164-required/7-exclusion manifest, and require a nonconstant exported signal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY: Release notes must record the change from a national mortgage-only carve to a sourced per-band decomposition and the corresponding JCT concept correction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY: Keep the changed runtime exports and base-builder imports compliant with the repository Ruff gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY: The issue gate requires every changed Python file to pass the repository format check, including the new source-table loader and materializer fixtures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY: Country packages must enumerate every shipped source artifact, and the unrelated real-PUF tail test must supply an explicit band key now that nonzero E19200 fails closed without one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY: TY2015 Table 2.1 shares must follow the originating PUF E00100 bands, and the new restricted source input must invalidate outer-stage checkpoints when its bytes change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The published TY2015 component shares are only valid against TY2015 E00100. Recover that source value by direct RECID alignment and the archived seed-42 aggregate-row allocator, while anchoring the Forbes tail in its evidenced source bucket. Pin the restricted input identity and make stale zero-investment base artifacts ineligible for reuse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY: Source-year AGI reconstruction depends on the archived PUF having uprated the raw records before seed-42 disclosure-row expansion, including its Forbes-backed RECID 999999 tail. Correct the manifest's retired inverse ordering and disabled-tail claim, and pin both facts in the source-plan test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY: The archived artifact used uprate-before-disaggregate and a Forbes tail, but those are provenance facts about the consumed H5 rather than supported parameters of Populace's executable source prefix. Record and test the artifact lineage separately while preserving the runnable manifest operation contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY: The shipped donor note contradicted the pinned processed-PUF lineage by reversing uprating and disclosure-record replacement and denying the Forbes tail. Align the note with the source manifest and pin that agreement in the plan test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…losed on invalid AGI

Confirm round 1 (HOLD, two P2 edge contracts):

- The naive (total*share, total-mortgage) pair is algebraically but not
  bit-exactly conserving (the reviewer's E19200=1.53 top-band reproducer
  summed to 1.5300000000000002). Recomputing mortgage as the exact
  complement of the rounded residual restores bit-for-bit conservation —
  proven on the reproducer and a 20,000-record adversarial sweep of
  cents-valued totals across the AGI range, plus the pinned-PUF exactness
  the review measured on all 208,611 rows.
- The donor API's finiteness check was defeated upstream: _numeric_array
  coerces nonnumeric/NaN to 0.0, silently routing invalid AGI to the
  first band's share. AGI now converts strictly (errors=raise; NaN/inf
  reach and fail the explicit check), and the regression exercises the
  puf_tax_unit_donor_from_arrays seam the reviewer probed — not the split
  function, which always validated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Round 2 pushed for both P2 findings: (1) conservation is now bit-exact by reconciliation — mortgage is recomputed as the exact complement of the rounded residual; proven on your 1.53/top-band reproducer and a 20,000-record adversarial sweep (cents-valued totals, full AGI range), with the residual keeping its published-share definition to one rounding; (2) the donor API converts AGI strictly (errors=raise), so NaN/inf reach the explicit finiteness check and nonnumeric raises — regression exercises the puf_tax_unit_donor_from_arrays seam you probed rather than the split function. The P3 coverage note (stubbed engine in the acceptance fixture) stands as-is given your independent live-engine probe confirmed the 90/100/10 behavior. Decomposition + QRF-chain files green locally; full shard on CI.

…nservation comment

Confirm round 2 (HOLD, one P2 + two P3):

- errors=raise is parse-strict, not real-number-strict: datetime and
  timedelta arrays (including NaT) convert to finite epoch/sentinel
  integers, complex drops its imaginary part with only a warning, and
  booleans pass — the reviewer routed $100 into band 0 through a valid
  donor seam via pd.NaT. Non-real dtype kinds are now rejected up front
  (TypeError) before strict parsing; NaN/inf still fail the explicit
  finiteness check.
- The AGI regression could mask that hole: its fixture failed for
  unrelated reasons (wrong column names, underivable default outputs),
  so any raise looked like a pass. Rebuilt around a positive control —
  the same fixture succeeds with valid AGI and empty explicit outputs —
  plus cause-specific assertions per rejection class (NaN/inf ->
  'finite'; NaT/timedelta/complex/bool -> 'real-valued'; string ->
  parse error). The first NaT probe was itself invalid numpy
  (np.asarray([pd.NaT], dtype=datetime64) raises in the test's own
  argument expression); both the pandas-inferred and numpy-typed NaT
  forms are now exercised.
- The reconciliation comment claimed too little and too much:
  conservation is guaranteed, not empirical — with share in [0, 1] one
  of the two subtractions is exact by Sterbenz's lemma (case analysis
  verified: whichever of rounded mortgage or rounded residual lands in
  [total/2, total]), so the pair sums to total bit-for-bit for every
  accepted input, with -0.0 reconstructing as +0.0. And the mortgage
  deviation from total*share is bounded by the subtractions' roundings
  at the half-ULP-of-total scale — the reviewer measured 16-17 mortgage
  ULPs on the 1.53 reproducer, so 'one rounding' at mortgage scale was
  wrong and is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Round 3 pushed for the round-2 findings: (1) P2 — non-real dtypes are rejected up front by kind (TypeError) before strict parsing: datetime/timedelta (incl. your NaT-through-the-seam demonstration), complex, and boolean arrays no longer reach pd.to_numeric's finite-sentinel conversions; NaN/inf still fail the explicit finiteness check. (2) P3 regression masking — the AGI test is rebuilt around a positive control: the identical fixture succeeds with valid AGI and empty explicit outputs, so each rejection can only come from the AGI validation, with cause-specific match patterns per class ('finite' vs 'real-valued' vs parse error). Found and fixed en route: my first NaT probe was invalid numpy that raised in the test's own argument expression — both pd.Series([pd.NaT]) and np.asarray(['NaT'], dtype='datetime64[ns]') forms are now exercised. (3) P3 comment — replaced with the proven statement: conservation is exact by Sterbenz's lemma (one of the two subtractions is always exact since either rounded mortgage or rounded residual lands in [total/2, total]), −0.0 reconstructs as +0.0, and the share deviation is bounded at the half-ULP-of-total scale per subtraction (the 'one rounding' at mortgage scale claim is gone — you measured 16–17 mortgage ULPs). Decomposition + QRF-chain files green locally; full shard on CI.

…wording

Confirm round 3 (HOLD, one P2 + two advisories):

- Object-dtype payloads bypassed the round-3 kind gate: object-wrapped
  True parses to 1.0 and routes $100 to band 0, object-wrapped complex
  keeps its real component with only a warning, and categorical bools
  ride kind 'O' too. The object branch now screens element types via
  pd.api.types.infer_dtype, allowing only faithful float conversions
  (integer/floating/mixed-integer-float/decimal/string/empty — Decimal
  and numeric strings stay accepted per review; missing values still
  die at the finiteness check) and rejecting boolean/complex/
  categorical/mixed with the same real-valued TypeError the typed
  regressions pin. Verified live before writing: pd.Series promotes
  object-wrapped NaT/Timestamp to datetime64, so those hit the round-3
  kind gate; bool/complex/categorical were the residual routes.
- The bad-string regression now matches the strict parser's own message
  ('Unable to parse') instead of any ValueError.
- Conservation wording: 'bit-for-bit for every accepted total except
  signed zero' — the -0.0 -> +0.0 reconstruction is the one exception,
  stated as such.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Round 4 pushed: (1) P2 — the object branch now element-screens via pd.api.types.infer_dtype with a faithful-conversion allowlist (integer/floating/mixed-integer-float/decimal/string/empty): object-wrapped booleans, object-wrapped complex, and categoricals all raise the same real-valued TypeError the typed regressions pin, while Decimal and numeric strings stay accepted per your round-3 note, and missing values still die at the finiteness check. Verified live before writing: pd.Series promotes object-wrapped NaT/Timestamp to datetime64, so those were already caught by the kind gate — bool/complex/categorical were the full residual set. Regressions added for all three object routes plus positive controls for Decimal and numeric-string acceptance. (2) Bad-string assertion now pins the strict parser's own message ('Unable to parse'). (3) Comment says 'bit-for-bit for every accepted total except signed zero'. Decomposition + QRF-chain green locally; CI running.

@MaxGhenis
MaxGhenis merged commit 2ae4339 into main Jul 27, 2026
4 checks passed
MaxGhenis added a commit that referenced this pull request Jul 28, 2026
…ll Build P2 pool (#565)

* Registry-exclude VT under-$1 taxable interest: zero support on the full pool

The dense arm cannot express what the pool does not carry: on the Build
P2 dense frame (full 337,704-household pool, no selection) the registry
aggregates for both Vermont under-$1 taxable-interest rows are exactly
0.0 initial and final, with household weights strictly positive and the
signal column nonnegative — so zero aggregate proves zero carriers. This
is the ia/nd class (pool-level unexpressibility, both arms), not the
sparse-selection re-roll class the per-run registers handle; the Build P
sparse register's VT pair hands off to this standing entry.

Surfaced by dense-P2 attempt 2 (populace#507): the #561 donor-table
insertion re-rolled the QRF joint model and the VT cell lost its last
pool carriers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Round 2: repair wrapped release id; person-record phrasing; #507 receipt posted

The textwrap pass split the release id across a line break and the join
introduced an interior space (populace- us-2024-…) in both permanent
reasons — a registry reason must carry the exact id. The carrier count
now says what it counts (201,242 positive person records; 122,852
unique carrier households). The decisive dense attempt-2 receipt the PR
body cites is now actually on populace#507.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 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.

PUF donor home_mortgage_interest carries the E19200 total-interest concept (+7.6% vs the Table 2.1 mortgage-only leg)

1 participant