Add opt-in period aging for US dollar targets#280
Merged
Conversation
Populace calibrates US 2024 weights against SOI TY2022/TY2023 dollar levels applied at the 2024 build period un-aged. Because calibration hits those source-year levels almost exactly, simulated current-year (2025+) income aggregates run ~6-10% under current-year projections (#212, #116: AGI $16.0T vs CBO TY2025 ~$17T+; income tax $2.15T vs FY2025 receipts ~$2.4T). This is a period-vintage artifact, not a support or weighting error. Add a compile-time aging pass that scales dollar-amount targets from their source period to the build period using growth ratios computed from CBO revenue-projection facts already in the Ledger consumer feed. No numeric factor is ever hardcoded: every factor is a ratio of two source-published CBO projected_amount facts, and each aged (or deliberately un-aged) target records its fact lineage. Factor policy: 1. Matching CBO series: a target whose measured concept maps to a CBO income-by-source row (AGI, wages, net capital gain, qualified dividends, net business income) is aged by that series' own projection ratio. 2. CBO AGI default: any other dollar amount uses the CBO AGI projection ratio. 3. Counts stay raw: return/claim/population counts are never aged. A target is aged only when both the build-year and source-year projection facts of the chosen series are present; otherwise it is left raw with aging_factor_source="unavailable" so the un-aged state is explicit rather than silent. Rows already period-aligned within-surface (uprating_factor present) are excluded to avoid double counting. Per-target diagnostics: basis (fact/projection), source_period, aged_to, aging_factor, aging_factor_source. The pass is opt-in via a new age_targets flag on compile_us_fiscal_target_registry (default False) and a --age-targets driver flag on tools/build_us_fiscal_refresh_release.py. With aging off the compiled surface is byte-identical to today, preserving build step-isolation until a build enables it. The eventual fact-vs-computed boundary is PolicyEngine/ledger#71: facts-only store, with PolicyEngine-computed aged levels living in Populace as a named, versioned aging implementation consuming growth-factor facts from Ledger. This module is that Populace-side implementation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
Author
|
Adversarial review (independent agent, full diff + empirical verification scripts): MERGE-SAFE. Attack angles checked and outcome:
Non-blocking polish filed as a follow-up issue: (a) strengthen the inertness test to compare against a frozen pre-PR registry hash (current test compares two off-paths); (b) record the denominator (source-year) fact id alongside the numerator for full ratio provenance; (c) apply the 1900–2100 sanity bound to the string branch of 83/83 tests pass in the aging test file; 798 pass across populace-build + populace-calibrate. |
This was referenced Jul 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Populace calibrates the US 2024 weights against SOI TY2022/TY2023 dollar levels applied at the 2024 build period un-aged. Because the calibration hits those source-year levels almost exactly, simulated current-year (2025+) income aggregates run systematically ~6-10% under current-year projections. This is a period-vintage artifact, not a support or weighting error.
Evidence from the #212 closing investigation (release
populace-us-2024-sparse-l0-refit-57k-71a0887-national-only-20260701):irs_soi.ty2022.historic_table_2.us.all.income_tax_liability_amountThe calibration is exact against the source-year levels, so the residual is the period of those levels. #116 raised this for the SOI EITC-by-AGI surface; the #212 comment generalized it to every SOI dollar target, to be implemented as Ledger target aging — the same declared consumer-side transform mechanism as the #205 geography-vintage crosswalk. This PR is the systematic results-level fix.
Closes the implementation gap flagged in #116 (generalized beyond EITC-by-AGI).
What this does
Adds a compile-time aging pass (
populace.build.us_runtime.target_aging.age_us_dollar_targets) that scales dollar-amount targets from their source period to the build period using growth ratios computed from CBO revenue-projection facts already present in the Ledger consumer feed (cbo.revenue_projection.tyYYYY.income_by_source.<series>.projected_amount, CBO Feb 2026 Revenue Projections).No numeric factor is ever hardcoded. Every factor is a ratio of two source-published CBO
projected_amountfacts, and every aged (or deliberately un-aged) target records the exact fact lineage it used.Factor policy (priority order)
projected_amount(series, build) / projected_amount(series, source). Mapped series: AGI, wages (wages_and_salaries), net capital gain, qualified dividends, net business income (Schedule C and partnership/S-corp both map here).measure_mode == "indicator_sum") are never aged — a growing nominal aggregate does not imply a growing return count.A target is aged only when both the build-year and source-year projection facts of the chosen series are present in the feed. If the source-aligned series is missing one period, it falls back to the AGI series; if no usable CBO pair exists, the target is left at its raw source-year value with
aging_factor_source="unavailable"so the un-aged state is explicit in diagnostics rather than silent (the #212 lesson: the failure was silent un-aged consumption). Rows already period-aligned within-surface by the existing SOI/EITC uprating passes (uprating_factorpresent) are excluded to avoid double counting.Per-target diagnostics fields
Every spec carries, after the pass:
basis—"projection"(aged) or"fact"(raw)source_period— the fact's source yearaged_to— the build periodaging_factor— the applied ratio (1when not aged)aging_factor_source— the CBO factsource_record_idused as the factor numerator, or a skip reason (not_dollar_amount,not_usd_unit,already_period_aligned,source_equals_build,unavailable)Opt-in / inert by default
The pass is gated behind:
age_targets: bool = Falseoncompile_us_fiscal_target_registry(...)--age-targetsontools/build_us_fiscal_refresh_release.py(default off)With aging off, the compiled target surface is byte-identical to today (registry content hash unchanged) — preserving build step-isolation. The change is inert until a build explicitly enables it.
Eventual schema home
The fact-vs-computed boundary belongs in PolicyEngine/ledger#71: Ledger stays a facts-only store (including source-published projections as facts), and PolicyEngine-computed aged levels live in Populace as a named, versioned aging implementation that consumes growth-factor facts from Ledger and emits its own lineage. This module is that Populace-side implementation; it references #116 (the concrete SOI case) and #212 (the generalization).
Files touched
packages/populace-build/src/populace/build/us_runtime/target_aging.py(new) — the aging pass, factor policy, CBO projection indexing, diagnostics.packages/populace-build/src/populace/build/us_runtime/fiscal_targets.py—age_targetsparam oncompile_us_fiscal_target_registry, applied as the final nominal transform after within-surface alignment.tools/build_us_fiscal_refresh_release.py—--age-targetsdriver flag, plumbed to the compile call.packages/populace-build/tests/test_us_fiscal_targets.py— factor-derivation tests (matching series, AGI fallback, series-year fallback, counts raw, unavailable, source==build, double-age guard) + inert-off byte parity + a compile-level test assertingbasis="projection"and the right factor.Expected results direction when enabled
Dollar-amount SOI/CBO targets sourced from TY2022/TY2023 are scaled up toward current-year (build-period) levels via CBO projection growth (~7-14% depending on series and source-year gap; e.g. AGI TY2023→TY2025 ≈ 1.14). Counts are unchanged. When a build calibrates against the aged surface, simulated current-year income and tax aggregates should rise ~6-10% toward CBO/receipts projections, closing the residual documented in #212 while benefits (already matching) stay put.
Tests
uv run pytest packages/populace-build packages/populace-calibrate→ 798 passed, 6 skipped (heavy frame suite excluded). New aging tests: 8 targeted cases, all green. Ruff clean.🤖 Generated with Claude Code