Skip to content

Reject duplicate finite thresholds within a single parameter scale #550

Description

@MaxGhenis

Problem

ParameterScale._get_at_instant builds a scale by calling add_bracket(threshold, value) once per bracket (parameter_scale.py:129-176). When the threshold is already present, AmountTaxScaleLike.add_bracket (amount_tax_scale_like.py:37-49) and RateTaxScaleLike.add_bracket (rate_tax_scale_like.py:45-57) add the new value to the existing row. So if one YAML scale gives two brackets the same threshold, they silently collapse into a single row carrying their sum.

Minimal reproduction (policyengine-core 3.30.2, whose copies of these files are identical to master at b613d6c):

import numpy as np
from policyengine_core.parameters import ParameterScale

scale = ParameterScale(
    "example",
    {
        "metadata": {"type": "single_amount"},
        "brackets": [
            {"threshold": {"2024-01-01": 0}, "amount": {"2024-01-01": 0}},
            {"threshold": {"2024-01-01": 24_200}, "amount": {"2024-01-01": 92}},
            {"threshold": {"2024-01-01": 24_200}, "amount": {"2024-01-01": 104}},
            {"threshold": {"2024-01-01": 24_300}, "amount": {"2024-01-01": 116}},
        ],
    },
    "example.yaml",
)
at_instant = scale("2024-01-01")
print(list(zip(at_instant.thresholds, at_instant.amounts)))
# [(0, 0), (24200, 196), (24300, 116)]
print(at_instant.calc(np.array([24_150, 24_250, 24_350]), right=True))
# [  0 196 116]

Downstream impact

policyengine-us shipped this in its 2024 Arkansas low-income tax table for head of household and surviving spouse filers with two or more dependents. The $92 row was keyed at $24,200, the $104 row's threshold, so AGI of $24,201-$24,300 got $196 and AGI of $24,177-$24,200 got $0. Two later PRs edited that line without noticing. calibration.gov.irs.soi.agi.total_agi also repeated its top bracket, doubling the $10M-or-more bucket. PolicyEngine/policyengine-us#9599 fixes the data and adds a repo-level test. That test scans all 867 scales, and none relies on the summing.

Proposal

In ParameterScale._get_at_instant, for all four scale types, raise a ValueError when two brackets have the same finite threshold at the instant. The message should name the scale, the instant, the threshold, and the bracket indices.

Leave add_bracket as is. Combining tax scales depends on the summing: MarginalRateTaxScale.combine_bracket calls self.add_bracket(self.thresholds[i], rate) on thresholds that already exist (marginal_rate_tax_scale.py:112-114). add_tax_scale (line 17) and combine_tax_scales (helpers.py:38) reach it through that call.

Design notes

  • Exclude infinite thresholds. In policyengine-us, 1,549 scale-instants have two or more brackets at .inf. Tables whose length changes over time park their unused brackets there, and nothing reaches them.

  • Reforms. A reform that sets one threshold equal to another would start raising instead of silently summing. For a marginal rate scale the sum adds two rates, which is also wrong, so an error seems better than the current behavior. The alternative is to treat the earlier bracket as zero-width and keep the later value; that needs a decision here.

  • Cost. _get_at_instant rebuilds the scale on each call, and only the instant-to-string conversion is cached (at_instant_like.py). The check is O(brackets) per call, the same order as the loop it would sit beside.

  • Relation to Assert that scale parameter bracket thresholds increase monotonically #374. Assert that scale parameter bracket thresholds increase monotonically #374 proposes asserting that thresholds increase monotonically. Two data points from the policyengine-us scan:

    • A strict monotonic assert would currently fail on the duplicate .inf thresholds above.
    • It would also fail on gov.states.la.tax.income.credits.school_readiness.rate, whose YAML lists brackets 5→1 and depends on add_bracket's bisect insertion to sort them.

    Equal finite thresholds are the case that silently corrupts values, so this narrower check could land first.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions