Skip to content

Add a base mode to parameter uprating - #654

Draft
MaxGhenis wants to merge 2 commits into
masterfrom
feat/base-year-uprating
Draft

MaxGhenis wants to merge 2 commits into
masterfrom
feat/base-year-uprating

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #403, with one design change: base mode rounds the increase over the statutory base, not the uprated amount.

What changes

  • metadata.uprating.base: {period, value} opts a parameter into base mode. For each instant where the index has a value after the parameter's last explicit value, and no earlier than the base period, base mode sets

    value(t) = base + round(base × (index(t) / index(base.period) − 1))
    

    applying rounding's type and interval to the increase. Without rounding it sets base × index(t) / index(base.period). Explicit values always win: base mode adds values only after the last of them.

  • base.period names the base in the index's own periods, since base mode reads the index there. For PolicyEngine US's gov.irs.uprating, whose value at Y measures calendar year Y − 1, the statute's 2024 base is period: 2025. base.value defaults to the parameter's value at that period; when the parameter has a value there, a stated base.value must equal it, which catches a period one year off.

  • Base mode validates its metadata. It raises a ValueError naming the parameter for keys other than parameter, base and rounding (so start_instant, at_defined_interval, parameter: self and misspellings such as rouding fail instead of being ignored); a malformed base, period, value, rounding.type or interval; an index that is missing, zero or not finite where base mode reads it; a missing, NaN or non-numeric base amount; a parameter with no values or a null last value; and a non-finite projection from a finite base.

  • Float error on an exact multiple can't cost an interval. Before rounding, an increase within 1e-9 intervals of a whole number of intervals counts as that number: a 40% rise on $1,400 is exactly $560, which floats compute as 559.9999999999998, and rounding that down to $10 would give $550. For amounts large next to the interval the tolerance widens to 16 machine epsilons (about 3.6e-15) of the amounts, since float error grows with them: $572,903.25 raised by 2,032 / 186 is exactly 22,743,643 quarter-dollar intervals above its base, which floats put 4e-9 intervals short.

  • An infinite base stays infinite, so a scale whose lowest threshold is -.inf can use base mode with uprate_thresholds.

  • Chained mode stays the default and doesn't change. uprate_parameter gains one early branch for metadata with base; nothing else in the chained path moved, and a differential test pins it (below).

  • Docs. A new "Uprating" section in docs/usage/parameters.md covers both modes, the Rev. Proc. examples, how the modes differ and how base mode meets reforms. The metadata schema gains UpratingBase and documents rounding and base on UpratingSchema; it had listed the rounding block under the key type, which uprating never reads.

Correcting #403's "Mathematical Equivalence" note

#403 says incremental (chained) and base-year uprating are mathematically equivalent. They agree only without rounding: if value(L) = base × index(L) / index(base), then value(L) × index(t) / index(L) = base × index(t) / index(base). With rounding they round different quantities. Chained mode rounds the whole amount, so every value it projects is a multiple of the interval; base mode rounds the increase, so a base off the interval's grid stays off it. Rev. Proc. 2026-35 prints two 2027 amounts that chained rounding cannot produce:

  • §3.15, head of household: $24,925. 26 U.S.C. 63(c)(7)(B)(ii) increases the $23,625 amount by the cost-of-living adjustment with 2024 as the base year and rounds the increase down to a multiple of $50: $23,625 + $1,300 (26 × $50). $24,925 is not a multiple of $50. On an index path that reproduces the 2027 figures, chained mode from 2026's $24,150 gives $24,800.
  • §3.29, long-term care premiums, ages 41 to 50: $945. 26 U.S.C. 213(d)(10)(B)(i) increases the $375 amount in (A) and rounds the increase to the nearest multiple of $10: $375 + $570. $945 is not a multiple of $10; chained mode gives $940 on the test's index path.

Chained mode also carries rounding forward when it starts from a published, rounded value. With a $2,000 base, increases rounded down to $100, and an index 4.9% and then 10% above its base level, base mode gives $2,000 and then $2,200; chained mode from year 1's $2,000 gives $2,097.24, which rounds down to $2,000.

Reforms

TaxBenefitSystem.__init__ applies a constructor reform before it uprates (tax_benefit_system.py, load → baseline clone → reform → propagate → interpolate → uprate). Base mode's projections depend on the explicit values only through the base amount, so there (verified by test_a_reform_before_uprating_changes_projections_only_through_the_base and a scratch run against chained mode):

Reform before uprating Chained, 2027 Base, value omitted, 2027 Base, value stated, 2027
none 24,800 24,925 24,925
$30,000 from 2026 on 30,850 24,925 (statute resumes) 24,925
$30,000 from 2020 on error (this index starts in 2025) 31,650 (new base) error: leave base.value out

The docs describe this and suggest a bounded range for a reform that should hold; test_a_bounded_reform_before_uprating_holds_its_value_over_its_range checks that a 2026-01-01.2100-12-31 reform holds $30,000 through 2100 (chained mode does the same). PolicyEngine US applies reforms after uprating (policyengine_us/system.py loads, uprates, then calls apply_reform_set), so its reforms replace projected values from their start, in both modes, as before.

Invariants

Property tests (tests/core/parameters/operations/test_base_uprating_property.py, Hypothesis, 300 examples each in CI; 4,000 each in a local run, all passing) cover index paths from 1995 to 2040 (geometric paths with yearly changes from −10% to +15%, and independent tenths from 100.0 to 3,000.0), amounts from $0.01 to $1m and intervals from $0.01 to $1,000:

  1. value − base is a whole number of rounding intervals.
  2. Rounding down never puts the increase above the exact increase (the rational number the float inputs define) and leaves it less than one interval below; rounding up mirrors that; nearest stays within half an interval. An exact increase that is already a whole number of intervals stays exactly that under every rule.
  3. With a positive base, a higher index never gives a lower value.
  4. Without rounding, base and chained mode agree: to the last bit when the base is the last explicit value, and to 1e-12 when the explicit values after the base follow the index.
  5. Explicit values win: every explicit value survives, and base mode adds one value per index instant after the last of them.
  6. The projected values depend on the explicit values only through the base amount and the last explicit instant.
  7. Differential: every chained-mode parameter (string and mapping forms; rounding as a mapping, a bare float or an int; start_instant; parameter: self; a parameter uprated by another; six at_defined_interval cadences; and three rounding-boundary cases) gets exactly the values, or the error, that the implementation at 7c84d94 gives it. tests/fixtures/chained_uprating_reference.py holds that implementation verbatim.

Example tests (test_base_uprating.py) reproduce §3.15 (single $16,600, head of household $24,925, joint $33,200 with a $100 interval, since twice an increase rounded down to $50 is the doubled increase rounded down to $100) and §3.29 (all five brackets, $500, $945, $1,890, $5,030 and $6,290, as a scale with per-bracket bases), and cover each rounding form, the base-period default, the year-off check, scales, an infinite threshold, cloned subtrees, projections before the base period, reforms before uprating and every error path.

Mutation testing

Thirteen hand-written mutants of uprate_parameters.py, each run against the two new test files at ab9eb04. Every mutant fails at least one test:

Mutant Change Tests failed
no_snap never snap an increase to a whole number of intervals 3
fixed_snap_only drop the tolerance that grows with the amounts 2
infinite_base_errors raise on an infinite projection even from an infinite base 1
round_whole_amount round the uprated amount instead of the increase 8
anchor_at_last_explicit project from the last explicit value, as chained mode does 9
project_before_base add values at instants before the base period 1
overwrite_last_explicit replace the value at the last explicit instant 3
ceil_for_downwards round downwards with ceil 9
skip_stated_value_check skip the check that a stated base.value matches 3
zero_floor never let a projection fall below the base 3
chained_strict_to_inclusive chained mode also replaces its last explicit value 2
chained_perturb_before_rounding chained mode scales the amount by 1 + 1e-12 before rounding 1
chained_sends_base_branch_everything send chained metadata with rounding to base mode 9

Two earlier survivors led to changes in this PR: the differential test gained three rounding-boundary examples after a perturbation before chained rounding survived, and a dead isinf guard in project_from_base came out after a mutant of it survived (the infinite-base mutant above now targets the live check).

Checks run

  • uvx ruff format --check . (423 files) and uvx ruff check .: clean.
  • uv run pytest tests, the whole suite, serially: 3,725 passed, 4 skipped, 3 xfailed. The one test added after that run started (test_a_bounded_reform_before_uprating_holds_its_value_over_its_range) passes on its own and in CI.
  • policyengine-core test policyengine_core/country_template/tests -c policyengine_core.country_template: 39 passed.
  • jb build docs: builds. Its 26 warnings all predate this PR; none comes from the new "Uprating" section or the schema.
  • The property tests at 4,000 examples each (CI runs 300): 19 passed in 6m47s.
  • CI: all 19 checks pass, including the smoke jobs that install PolicyEngine US 2.42.13 and the other country packages on this Core.
  • PolicyEngine US on this branch: loading policyengine-us#10683's head with base-mode metadata on the standard deduction and both CTC amounts gives each statute's formula in every projected year; chained-mode parameters keep their values. The follow-up PR there carries the details.

Follow-ups (not in this PR)

  1. Chained mode raises UnboundLocalError on an int bare rounding (rounding: 50). round_uprated_value handles a float or a dict only, so interval and rounding_fn are never bound. Base mode accepts ints. The differential test pins chained mode's current error, so a fix must update tests/fixtures/chained_uprating_reference.py and say why.
  2. Chained mode's rounding has no float-error snap. 1,400 × (140 / 100) is 1,959.9999999999998 in floats, which downwards to 10 rounds to 1,950, not 1,960. Base mode snaps an increase within a tolerance of a whole number of intervals; the same helper could serve chained mode, as a deliberate change to its outputs.
  3. Base mode sets no floor. An index below its base-period level gives a value below the base. 26 U.S.C. 1(f)(3)'s "percentage (if any)" floors the cost-of-living adjustment at zero; projections where the index only rises never reach it. An opt-in floor could follow if a statute needs it to bind.
  4. Pre-existing, both modes: a bounded reform applied before uprating freezes the period after its range at the pre-uprating value. Parameter.update restores the old value at the range's end as an explicit value. A 2026-01-01.2026-12-31 reform of a parameter last set in 2026 makes 2027 that 2026 value (24,150) instead of the projection (24,800 chained, 24,925 base), and chained mode then projects from it.
  5. Decide reform semantics before a country that applies reforms before uprating adopts base mode. This affects core's TaxBenefitSystem(reform=...) with parameters_dir. Base mode's projections depend only on the base, so a reform that starts after the base period hands back to the statutory path after its last explicit value; chained mode projects from the reform's value. PolicyEngine US applies reforms after uprating, so it is unaffected.

axiom: n/a: projection infrastructure (no policy encoded; PolicyEngine US adopts base mode in a follow-up PR)

🤖 Generated with Claude Code

MaxGhenis and others added 2 commits November 9, 2026 13:16
An `uprating.base` block ({period, value}) makes uprating compute each
value after the parameter's last explicit one as the base amount plus
the rounded increase the index implies since the base period:

    base + round(base * (index[t] / index[base] - 1))

Statutes such as 26 U.S.C. 63(c)(7)(B)(ii), 24(i)(3) and 213(d)(10)(B)(i)
round the increase over a stated base, which chained mode (round the
uprated amount, chained from the last explicit value) cannot express.
Chained mode stays the default and is unchanged.

Refs #403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tests:
- Example tests reproduce Rev. Proc. 2026-35 sections 3.15 (standard
  deduction) and 3.29 (long-term care premium limits) and cover each
  rounding form, the base-period default, scales, cloned subtrees,
  reforms applied before uprating and every error path.
- Hypothesis property tests check that the increase is a whole number of
  intervals, brackets the exact increase, rises with the index, matches
  chained mode without rounding, and leaves explicit values alone.
- A differential test checks chained mode against a frozen copy of the
  7c84d94 implementation (tests/fixtures/chained_uprating_reference.py).

Fixes found by those tests:
- The fixed 1e-9-interval snap missed large exact multiples:
  $572,903.25 raised by 2,032 / 186 is exactly 22,743,643 quarter-dollar
  intervals above its base, which floats put 4e-9 intervals short. The
  snap now widens to 16 machine epsilons of the amounts.
- An infinite base, such as a scale's -inf lowest threshold, raised an
  error; it now stays infinite.

Docs: a new "Uprating" section in docs/usage/parameters.md covers both
modes, how they differ (with the Rev. Proc. examples) and how base mode
meets reforms. UpratingSchema documents `rounding` and `base`, replacing
a `type` key that uprating never read, and gains UpratingBase.

Refs #403.

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

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.

Add base year support for parameter uprating

1 participant