Repository navigation
Conversation
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
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.
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 setsapplying
rounding'stypeandintervalto the increase. Withoutroundingit setsbase × index(t) / index(base.period). Explicit values always win: base mode adds values only after the last of them.base.periodnames the base in the index's own periods, since base mode reads the index there. For PolicyEngine US'sgov.irs.uprating, whose value atYmeasures calendar yearY − 1, the statute's 2024 base isperiod: 2025.base.valuedefaults to the parameter's value at that period; when the parameter has a value there, a statedbase.valuemust equal it, which catches a period one year off.Base mode validates its metadata. It raises a
ValueErrornaming the parameter for keys other thanparameter,baseandrounding(sostart_instant,at_defined_interval,parameter: selfand misspellings such asroudingfail instead of being ignored); a malformedbase,period,value,rounding.typeor 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
-.infcan use base mode withuprate_thresholds.Chained mode stays the default and doesn't change.
uprate_parametergains one early branch for metadata withbase; nothing else in the chained path moved, and a differential test pins it (below).Docs. A new "Uprating" section in
docs/usage/parameters.mdcovers both modes, the Rev. Proc. examples, how the modes differ and how base mode meets reforms. The metadata schema gainsUpratingBaseand documentsroundingandbaseonUpratingSchema; it had listed the rounding block under the keytype, 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), thenvalue(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: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 constructorreformbefore 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 bytest_a_reform_before_uprating_changes_projections_only_through_the_baseand a scratch run against chained mode):valueomitted, 2027valuestated, 2027base.valueoutThe 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_rangechecks that a2026-01-01.2100-12-31reform holds $30,000 through 2100 (chained mode does the same). PolicyEngine US applies reforms after uprating (policyengine_us/system.pyloads, uprates, then callsapply_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:value − baseis a whole number of rounding intervals.start_instant;parameter: self; a parameter uprated by another; sixat_defined_intervalcadences; and three rounding-boundary cases) gets exactly the values, or the error, that the implementation at 7c84d94 gives it.tests/fixtures/chained_uprating_reference.pyholds 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:no_snapfixed_snap_onlyinfinite_base_errorsround_whole_amountanchor_at_last_explicitproject_before_baseoverwrite_last_explicitceil_for_downwardsdownwardswithceilskip_stated_value_checkbase.valuematcheszero_floorchained_strict_to_inclusivechained_perturb_before_roundingchained_sends_base_branch_everythingroundingto base modeTwo 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
isinfguard inproject_from_basecame 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) anduvx 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.Follow-ups (not in this PR)
UnboundLocalErroron an int bare rounding (rounding: 50).round_uprated_valuehandles afloator adictonly, sointervalandrounding_fnare never bound. Base mode accepts ints. The differential test pins chained mode's current error, so a fix must updatetests/fixtures/chained_uprating_reference.pyand say why.downwardsto 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.floorcould follow if a statute needs it to bind.Parameter.updaterestores the old value at the range's end as an explicit value. A2026-01-01.2026-12-31reform 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.TaxBenefitSystem(reform=...)withparameters_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