You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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):
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.
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.
Problem
ParameterScale._get_at_instantbuilds a scale by callingadd_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) andRateTaxScaleLike.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
masterat b613d6c):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_agialso 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 aValueErrorwhen 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_bracketas is. Combining tax scales depends on the summing:MarginalRateTaxScale.combine_bracketcallsself.add_bracket(self.thresholds[i], rate)on thresholds that already exist (marginal_rate_tax_scale.py:112-114).add_tax_scale(line 17) andcombine_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_instantrebuilds 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:
.infthresholds above.gov.states.la.tax.income.credits.school_readiness.rate, whose YAML lists brackets 5→1 and depends onadd_bracket's bisect insertion to sort them.Equal finite thresholds are the case that silently corrupts values, so this narrower check could land first.