Skip to content

Guard federal-style bracket thresholds and NaN parameter values; fix the contrib MFS 33% bracket - #9934

Open
MaxGhenis wants to merge 3 commits into
mainfrom
mapped-threshold-and-nested-nan-guards
Open

MaxGhenis wants to merge 3 commits into
mainfrom
mapped-threshold-and-nested-nan-guards

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The 2026-10-06 invariants audit's verifier showed that two kinds of malformed parameters escaped the existing checks:

This PR extends test_parameter_scale_thresholds.py rather than adding a separate raw-file scan. The new checks walk the loaded parameter tree the module already builds, so they also see uprated values.

Guards added

  • test_mapped_bracket_thresholds_strictly_increase: for every rate schedule with a thresholds node of brackets 1..N beside a rates node (what tax_at_main_rates reads; today gov.irs.income.bracket, gov.irs.capital_gains, and the additional_tax_bracket and harris.capital_gains contrib schedules), each filing status's thresholds must strictly increase at every instant any of them changes, uprated years included. Repeated +inf (parked top brackets) is allowed, as in the scale guard. It asserts the two federal tables are found, so it cannot pass vacuously. Unit cases cover a repeat, a later inversion, a repeated -inf, a NaN threshold, parked +inf, and a finite value after +inf.
  • test_parameter_values_are_not_nan: no parameter value may hold NaN, as a scalar or at any depth of a list (core accepts only float, int, bool, None and list values). Unit cases cover [1.0, [2.0, nan]], a scalar NaN and [nan]; the test also asserts it visited parameters.

The guard's first catch: the contrib MFS bracket

The mapped-threshold guard fails on main. The additional_tax_bracket contrib reform's SEPARATE bracket-5 top from 2026 copies the JOINT values (541,550 in 2026, above bracket 6's 305,875; authored through 2035 and uprated after). #6235 fixed the same copy in gov.irs.income.bracket in 2025; this file kept it. Every other bracket and year in the file has SEPARATE == JOINT / 2, as 26 U.S.C. 1 sets for married filing separately. The values are now exactly #6235's: 270,775; 276,525; 282,100; 287,700; 293,375; 299,175; 305,100; 311,150; 317,325; 323,625. SINGLE bracket 5 also reads 541,550 in 2026, and that is correct: before TCJA the single and joint 33% bracket tops were equal.

With the copied threshold, tax_at_main_rates' clamp (#9084) emptied bracket 6 and started 39.6% at 541,550, so under the reform a separate filer's income from 270,775 to 305,875 was taxed at 33% instead of 35%, and from 305,875 to 541,550 at 33% instead of 39.6% (up to $16,256.55 less tax per filer in 2026, rising with the uprated thresholds to $19,429.55 in 2035).

New YAML cases (additional_tax_bracket_reform.yaml), 2026 separate filers under the reform, at 10/15/25/28/33/35/39.6% up to 12,150 / 49,300 / 99,500 / 151,625 / 270,775 / 305,875:

  • taxable income $300,000: $83,480.75 at the main rates (old data: $82,896.25);
  • taxable income $400,000: $122,810.50 (old data: $115,896.25).

test_negative_main_rates_inverted_brackets.py::test_shipped_additional_tax_bracket_reform_is_clamped relied on the shipped inversion to exercise the reform's clamp. It now recreates the old 541,550 top itself and also checks a separate filer inside the inverted band: $400,000 of taxable income must owe exactly $115,896.25, a figure that differs if the inversion fails to apply ($122,810.50) or the loop stops clamping.

Note for the merge: the repo squashes with the commits' messages, and the first commit's body understates the old error (it names only the 270,775–305,875 band). Please use this PR's description for the squash body.

Tests (fail before, pass after)

Fail-before and pass-after runs are queued behind the shared test lock; this section is updated when they finish.

Impact

The baseline is unchanged: only a contrib reform parameter changes, and the new tests add no model behaviour. With the additional_tax_bracket reform applied, 2026, default dataset, main vs this branch:

The reform microsimulation is queued behind the shared lock; this section is updated when it finishes.

2025 is unaffected by construction: only SEPARATE bracket-5 entries dated 2026-01-01 and later changed, and 2025 lookups read the 2025-01-01 entry.

axiom: n/a: test guards plus a contrib (hypothetical reform) parameter; no enacted-law provision changes.

Independent review: in progress (in-session Opus reviewers; Subfleet lanes are unavailable today).

🤖 Generated with Claude Code

MaxGhenis and others added 3 commits October 6, 2026 16:52
…racket

test_parameter_scale_thresholds only checked ParameterScale brackets, so
federal-style schedules (a `thresholds` node mapping bracket numbers to a
value per filing status, as gov.irs.income.bracket does) could repeat or
invert a threshold unnoticed, and nothing checked parameter values for
NaN, which YAML loads silently in scalars and inside list values.

The new mapped-threshold guard found one live inversion: the
additional_tax_bracket contrib reform's SEPARATE bracket-5 top for
2026-2035 copied the JOINT values (541,550 in 2026, above bracket 6's
305,875). #6235 fixed the same copy in gov.irs.income.bracket; the
contrib file kept it. The values are now half the joint thresholds, as
in every other bracket and year of the file and in #6235. Under the
reform, a separate filer's income from 270,775 to 305,875 was taxed at
33% instead of 35% in 2026.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_shipped_additional_tax_bracket_reform_is_clamped relied on the
shipped SEPARATE inversion, which the previous commit removed, so a
zero-income filer owed nothing with or without the clamp. The test now
recreates the old 541,550 bracket-5 top itself.

The mapped-threshold guard now requires a `rates` sibling, the node
tax_at_main_rates reads with `thresholds`, so a household-size table
named `thresholds` cannot be swept in. New unit cases show it flags a
repeated -inf and a NaN threshold, and the NaN test asserts it visited
parameters.

A second reform case covers the 39.6% band. The old data also taxed a
separate filer's income from 305,875 to 541,550 at 33% instead of 39.6%
(not only 270,775 to 305,875 at 33% instead of 35%, as the previous
commit said): taxable income of 400,000 owed 115,896.25, not 122,810.50.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round-2 review: a zero-income filer owes nothing under any loop that is
never negative per bracket, so the clamp test could not tell today's
unclamped form from the clamped one, nor notice if the recreated
inversion failed to apply. A separate filer with 400,000 of taxable
income now must owe exactly 115,896.25 under the recreated inversion:
122,810.50 if the inversion did not apply, other figures if the loop
did not clamp. (The previous commit's "owed nothing with or without the
clamp" held only for the old clip form.)

The table finder also skips an empty `thresholds` node, and the 2035
SEPARATE line loses a trailing space.

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

DTrim99 commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator

PR Review: Guard federal-style bracket thresholds and NaN parameter values; fix the contrib MFS 33% bracket

PR #9934 (author: MaxGhenis, head f0d858eb83, merge-base 6b80a8e447). 5 files (+263/−18), 3 commits.

Summary

The PR does three things:

  • Fixes a contrib parameter. In the additional_tax_bracket reform, the married-filing-separately bracket-5 (33%) top for 2026–2035 was a copy of the joint values. In 2026 that was 541,550, above bracket 6's 305,875. The PR sets it to half the joint values: 270,775 in 2026 up to 323,625 in 2035. These are the same values Fix filing status and married variables as well as separate income tax brackets #6235 put in gov.irs.income.bracket in July 2025.
  • Adds a mapped-threshold guard. test_mapped_bracket_thresholds_strictly_increase finds every thresholds node numbered 1..N that sits beside a rates node. For each filing status, it requires the thresholds to strictly increase at every instant, including uprated years. A repeated +inf is allowed.
  • Adds a NaN guard. test_parameter_values_are_not_nan rejects NaN in any parameter value, whether it is a scalar or nested in a list.

The substance is right:

  • The new values match the cited source. CBO's January 2025 Tax Parameters workbook, which the parameter already cites, has the same ten numbers in its married-filing-separately row.
  • Expectations reproduce. Every YAML and Python expectation and every dollar figure in the PR body checks out by hand.
  • The tests fail before and pass after. With the merge-base contrib values, the guard fails with the inversion and both new YAML cases fail.
  • The guards survive a merge with current main. I resolved the conflicts and ran the four affected test files against the merged tree; all pass.
  • The guards are cheap. They take 0.09 s and 0.37 s.
  • CI is green.

Two should-fix items remain:

  • The branch now conflicts with main in two files.
  • The PR body still has placeholder "queued" sections, and it asks for that body to be used as the squash commit message.

Critical (must fix)

None.

Should address

1. The branch conflicts with main in two files, so GitHub can't merge it.

  • Status. GitHub reports mergeable: false, mergeable_state: dirty. git merge-tree --write-tree PolicyEngine/main f0d858eb83 reports CONFLICT (content) in:

  • Both conflicts are additive. Keep main's hypothesis imports, and keep main's new YAML case with this PR's two cases after it. The rest of the test module merges cleanly: main's calendar-date tests land mid-file and the PR's guards at the end.

  • I checked that resolution. I ran it locally with policyengine-core 3.32.27, main's locked version:

    Test file Result
    test_parameter_scale_thresholds.py (main's hypothesis and calendar-date tests plus both new guards) 18 passed
    additional_tax_bracket_reform.yaml 12 passed
    test_negative_main_rates_inverted_brackets.py 7 passed
    test_additional_tax_bracket_unset_bracket.py 9 passed

    So main's 211 newer commits, 83 of which touch parameters, add no NaN value and no repeated or inverted mapped threshold.

  • Fix: rebase or merge main, resolving the two conflicts as above.

2. The PR body still has placeholder sections and asks to be used as the squash commit body.

  • Where: the PR body, last edited 2026-10-06 22:37 UTC.
    • Under "Tests (fail before, pass after)": "Fail-before and pass-after runs are queued behind the shared test lock; this section is updated when they finish."
    • Under "Impact": "The reform microsimulation is queued behind the shared lock; this section is updated when it finishes."
    • It also says "Independent review: in progress".
    • The body asks: "Please use this PR's description for the squash body." If that happens as written, the squash commit on main will say results are still pending.
  • The results the body promises. I reproduced them at the head, with only bracket.yaml swapped back to the merge-base version:
    • Guard, before: test_mapped_bracket_thresholds_strictly_increase fails with gov.contrib.additional_tax_bracket.bracket.thresholds.*.SEPARATE: bracket 6 threshold 305,875 does not exceed bracket 5 threshold 541,550 at 2026-01-01 and 74 later instants to 2100-01-01.
    • YAML, before: 2 of the 11 cases fail. The $300,000 case gives 82,896.25 against 83,480.75. The $400,000 case gives 115,896.25 against 122,810.50.
    • After: with the PR's values, all of them pass.
  • Fix:
    • Replace the Tests placeholder with these results.
    • Either run the reform microsimulation and report it, or delete the Impact placeholder. The baseline is unchanged, so the paragraph isn't needed.
    • Drop the "Independent review: in progress" line before merge.

Suggestions

  1. The same contrib file still has another stale copy: 2022 single 37% bracket.
    • Where: policyengine_us/parameters/gov/contrib/additional_tax_bracket/bracket.yaml:760. It reads 2022-01-01: 539_000 under bracket 6 SINGLE.
    • The source. Rev. Proc. 2021-45, Table 3 (PDF p. 7): "Over $215,950 but not over $539,900 ... $49,335.50 plus 35% of the excess over $215,950". CBO's January 2025 workbook also has 539,900.
    • The federal file is already fixed. Correct the 2022 single 37% bracket threshold to $539,900 #9594 (2026-09-25) corrected gov.irs.income.bracket to 539,900. This is the same contrib-copy drift as Fix filing status and married variables as well as separate income tax brackets #6235 and this PR.
    • The guard can't catch it, because the value doesn't invert anything.
    • Effect. In 2022 reform runs, a single filer above $539,000 pays up to (37% − 35%) × 900 = $18 too much.
    • Cross-check. I compared all 336 bracket-top cells (4 statuses × 6 brackets × 2022–2035) in the file against the CBO workbook. This is the only mismatch.
    • Fix: change it to 539_900 here or in a follow-up.
  2. Optional stronger invariants for the mapped tables.
    • SEPARATE as half of JOINT. The ordering check caught this copy only because it happened to invert. A SEPARATE bracket copied from JOINT that doesn't invert would pass. SEPARATE == JOINT / 2 holds in the contrib schedule:

      • at every explicit instant of all 8 brackets;
      • for bracket 5, at every uprated instant through 2100.

      Checking it per instant would catch that class of error. Confirm it on the loaded values before applying it to gov.irs.income.bracket: that file's SEPARATE brackets 4–5 carry explicit 2027–2035 values where JOINT is uprated.

    • An infinite top bracket. Where rates and thresholds have the same length (the income schedules, not capital_gains), the last threshold should be +inf. tax_at_main_rates never taxes income above a finite top.

  3. Optional later-year test. Only 2026 is tested. The 2027–2035 values match CBO exactly and are exact halves of JOINT, but one case would pin them, such as 2035 or an uprated 2036. In 2036, SEPARATE brackets 4–6 are 184,775 / 329,975 / 372,750.
  4. The module's summary line is now too narrow. test_parameter_scale_thresholds.py still opens with "Parameter scales must not repeat a threshold at any instant." It now also holds the mapped-threshold and NaN guards, and the calendar-date guard after merging main. A broader first line, or a rename, would make the guards easier to find.

Verified correct

  • The new MFS bracket-5 values (bracket.yaml:683-692):
    • CBO. CBO, Tax Parameters and Effective Marginal Tax Rates, January 2025 (already cited in the node's reference), sheet "1. Tax Parameters", "Married filing separately", "Sixth rate begins at taxable income of", 2026–2035: 270775, 276525, 282100, 287700, 293375, 299175, 305100, 311150, 317325, 323625. These equal the PR's values exactly.
    • Single and joint. The "Single filers" and "Married filing jointly" sixth-rate rows are both 541550 … 647250. So SINGLE bracket 5 at 541,550 is correct, as the PR body says.
    • The values equal Fix filing status and married variables as well as separate income tax brackets #6235's gov.irs.income.bracket values, digit for digit.
  • The pre-TCJA structure the projection follows. Rev. Proc. 2016-55 (tax year 2017):
    • Table 1 (PDF p. 7): joint "Over $233,350 but not over $416,700 ... 33%".
    • Table 4 (PDF p. 9): separate "Over $116,675 but not over $208,350 ... 33%", then "Over $208,350 ... not over $235,350 ... 35%".
    • The separate 33% top is half the joint one (208,350 = 416,700 / 2).
  • SEPARATE is half of JOINT in every bracket and year of the file at the head. This was checked on the explicit YAML values for all 8 brackets. After uprating, bracket 5 stays an exact half through 2100, for example 329,975 / 659,950 in 2036.
  • Hand checks: 2026 separate filer under the reform, brackets 12,150 / 49,300 / 99,500 / 151,625 / 270,775 / 305,875.
    • $300,000: 1,215 + 5,572.50 + 12,550 + 14,595 + 39,319.50 + 0.35 × 29,225 = 83,480.75. Old data gives 33,932.50 + 0.33 × 148,375 = 82,896.25.
    • $400,000: 83,480.75 + 0.35 × 5,875 + 0.396 × 94,125 = 122,810.50.
    • Recreated inversion, clamped: 33,932.50 + 0.33 × 248,375 = 115,896.25. An unclamped loop would give 33,932.50 + 81,963.75 + 0.396 × 94,125 = 153,169.75, so the new assertion separates all three cases.
    • PR-body maximum gaps:
      • 2026: 144,931.80 − 128,675.25 = 16,256.55.
      • 2035: 173,217.80 − 153,788.25 = 19,429.55.
  • The mapped-threshold guard:
    • Tables found. It finds exactly four:

      • gov.irs.income.bracket.thresholds
      • gov.irs.capital_gains.thresholds
      • gov.contrib.additional_tax_bracket.bracket.thresholds
      • gov.contrib.harris.capital_gains.thresholds

      That matches the PR body. The rates sibling requirement keeps out the WA senior property-tax thresholds directory. The guard asserts both federal tables are found, so it can't pass vacuously.

    • Instants. It walks 84–88 instants per table, through 2100, including uprated years. It skips brackets that haven't started yet (None) and only exempts a repeated +inf.

    • Probe on the merged tree. Re-injecting 541,550 for 2026 is flagged. A NaN injected into gov.irs.income.bracket.thresholds.3.SINGLE is flagged by both guards.

    • Robustness. Error messages are deterministic, with no randomness. The test takes 0.09 s.

  • The NaN guard:
    • Coverage. It reaches scale bracket leaves too: in core 3.32.27, ParameterScaleBracket.get_descendants yields the threshold, amount and rate parameters. It also catches np.float64 NaN, which is a float subclass.
    • Types. Core's ALLOWED_PARAM_TYPES is (float, int, bool, type(None), typing.List), so scalars and lists cover every legal value.
    • Runtime. It takes 0.37 s.
  • The clamp test (test_negative_main_rates_inverted_brackets.py:196-239):
    • The scenario. It now recreates the old 541,550 top itself, scoped to 2026-01-01.2026-12-31.
    • The new check. The $400,000 assertion is what makes it discriminating. The zero-income assertions alone pass under any non-negative loop, as commit 3 notes.
  • Callers:
    • The parameter. Only reforms/additional_tax_bracket reads gov.contrib.additional_tax_bracket.bracket.
    • Other tests. test_additional_tax_bracket_unset_bracket.py compares two loop forms on the shipped parameters and passes on the merged tree.
    • Baseline. It is untouched.
  • Changelog:
    • changelog.d/mapped-threshold-and-nested-nan-guards.fixed.md is top-level and typed fixed.
    • It is accurate: it names both mis-taxed bands, 270,775–541,550 at 33%.
  • PR-body claims I checked are accurate:

Axiom line assessment

Valid. The line reads axiom: n/a: test guards plus a contrib (hypothetical reform) parameter; no enacted-law provision changes.

CI status

All 35 checks pass (gh pr checks 9934).

  • The run. Run 37541471491 (pull_request, 2026-10-06 22:34 UTC, head f0d858eb83) tested the head merged into main as it stood that day. main has moved more than 200 commits since.
  • After merging current main. Should 1 covers the conflicts. Once they're resolved, the guards and the affected test files pass locally against current main (18 + 12 + 7 + 9).
  • Partner tests. The PR touches none, and the Household API Partners job passes.

Branch status

Verdict: REQUEST_CHANGES

@DTrim99 DTrim99 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ten new MFS values match the CBO Jan 2025 Table 1 'Sixth rate begins' row and are exact halves of JOINT. The guard catches injected inversions and NaN, all expectations check by hand, and the axiom n/a is valid. Two should items (see the comment above): (1) the branch conflicts with main in test_parameter_scale_thresholds.py and additional_tax_bracket_reform.yaml; both are additive, and a union resolution passes locally, so main's newer parameters violate neither guard; (2) the PR body still has the queued 'Tests' and 'Impact' placeholders and asks to be the squash message, so fill in the verified results. Also worth fixing: the stale 2022 SINGLE 539,000 in the same contrib file (should be 539,900).

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.

2 participants