Skip to content

Tax bases at or above the last LinearAverageRateTaxScale threshold - #596

Open
MaxGhenis wants to merge 2 commits into
masterfrom
fix-linear-average-rate-top-bracket
Open

MaxGhenis wants to merge 2 commits into
masterfrom
fix-linear-average-rate-top-bracket

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Problem and caller behavior

For a supported LinearAverageRateTaxScale with multiple brackets, finite rates, and a finite final threshold, finite bases at and above that threshold previously returned zero. This PR returns base * final_rate there. For example:

s = taxscales.LinearAverageRateTaxScale()
s.add_bracket(0, 0)
s.add_bracket(100, 0.1)
s.calc(numpy.array([50, 100, 150]))
# current master 78a6c503: [2.5, 0, 0]
# this PR:               [2.5, 10, 15]
# By hand: 50 * (50 / 100 * 0.1) = 2.5; 100 * 0.1 = 10; 150 * 0.1 = 15.

The old bracket selection only includes intervals between consecutive thresholds. The added final-rate term handles the missing upper tail. The production implementation remains exactly as adopted at d7321e598b5df6f3fa1ed5dcaef050d13f475780; this review round changes tests and this description.

Finite bases below the final threshold retain the existing interpolation. Multiple-bracket scales still return zero below the first threshold; one-bracket scales still apply their rate to every base. Scales ending with an infinite threshold retain their finite outputs. Conversion algorithms, accepted input and return types, nonfinite-base behavior, and empty-scale behavior remain unchanged. With a first threshold of zero and at least two brackets, conversion agrees with average-scale tax at thresholds and above the final one, up to rounding; within interpolation intervals the average-scale tax is quadratic and the converted marginal-scale tax follows linear secants.

ParameterScale._get_at_instant constructs affected finite scales from average_rate brackets; the parameter-constructor regression covers this caller. The prior country-repository survey recorded in this PR found no concrete downstream country caller at the surveyed snapshots. That does not establish that every downstream model output is unchanged.

Methodology

Keep the final average rate constant from the final threshold upward. Alternatives are continuing the final interpolation slope or rejecting bases beyond the finite schedule. Existing to_marginal endpoint and upper-tail behavior and the existing infinite-threshold convention support the constant-rate reading. No statute, regulation, form, or instruction applies to this general engine contract. This PR changes master's zero tax at and above a finite final threshold to base * final_rate; this review round makes no further production change. This is the explicit public-semantic choice for Max to assess.

The 2014 enabled implementation omitted upper-tail handling while retaining conversion to a constant final marginal rate. The earlier commented draft had upper-tail terms, but a different offset; it is historical evidence of omitted handling, not proof of this exact convention.

The historical Landais–Piketty–Saez reform configuration is an example, not statutory authority. Its last two brackets give slope (0.6 - 0.5) / (100000 - 40000) = 1/600000. By hand, tax at 99999 is 99999 * (0.6 - 1/600000) = 59999.233335; this PR gives 250000 * 0.6 = 150000 above the final threshold. Extending that slope would reach 100% at 340000 and exceed it beyond that base.

Invariants

The four Hypothesis properties retain their generated domain: 1–6 brackets, distinct finite integer thresholds from 0 through 1000000 (first threshold at most 1000), and rates in [-1, 1] in increments of 0.001. They cover thresholds, adjacent representable bases, upper-tail bases through 1e9, and random finite bases in [-1e7, 1e7].

  1. From the first threshold up, tax equals base * numpy.interp(base, thresholds, rates) within floating-point tolerance. Below it, tax is zero except for the unchanged one-bracket shortcut.
  2. At each threshold, tax equals threshold * rate. Tax is continuous at thresholds after the first; the neighbor comparison now bounds the actual movement using local slope and neighbor distance, plus interpolation rounding.
  3. Appending (inf, any_finite_rate) leaves finite-base outputs exactly unchanged. For one-bracket scales, this comparison is restricted to bases at or above the first threshold.
  4. With first threshold zero and at least two brackets, tax agrees with to_marginal().calc at thresholds and above the final one, within accumulated rounding and threshold-shift error.

The tolerance change keeps the existing 1e-6 absolute floor and uses local numerical exposure rather than loosening every comparison globally. All four properties still reject current master's original zero-tail bug with brackets ([0, 1], [0, 0.001]).

Review round

Reproduced P2 with valid thresholds [0, 999999, 1000000] and rates [0, 1, 0]: the old assertions gave 2 failed, 2 passed. Immediately below the last threshold the tax differs by approximately 0.0001164153; conversion at that threshold leaves approximately 0.0002328306. Added that scale as an explicit Hypothesis example in both affected properties, with hand arithmetic in comments.

The continuity bound includes (threshold - neighbor) * (abs(rate) + abs(neighbor * slope)) plus interpolation rounding. The conversion bound is eps * (bracket_count + 8) * sum(abs(marginal_rate) * (lower + min(base, upper))) over active brackets, plus the retained absolute floor. It accounts for the unchanged marginal threshold epsilon shift, conversion, bracket-width arithmetic, and accumulation. Numerical sources are NumPy's documented float64 epsilon and adjacent representable values. The generated domain and production code are unchanged.

P3 is corrected below. The public semantics and methodology choice are explicit, and the historical draft mechanism claim is qualified.

Interaction with #588 and master

#588 remains open at 0dcab378b09ebc229d771572de61fa1f47d92802. Its current conversion property already checks independent threshold * rate expectations at every threshold and base * final_rate above the final threshold. It does not exclude the final threshold or use the faulty average calc as its reference. The former paragraph described obsolete coverage and an older head; no current trial-merge test claim is made. That property and this PR's differential property can coexist.

Fetched master is 78a6c503ca6623f2735410412b8ca9e93816ec0d. It has no overlapping tax-scale, parameter-constructor, or tax-scale-test changes since the merge base. No merge or rebase was performed; this round preserves additive branch history.

Validation

Each affected test file was run separately, in the foreground, using uv run pytest -q with a workspace-local environment and cache:

  • tests/core/tax_scales/test_linear_average_rate_tax_scale.py: 14 passed.
  • tests/core/tax_scales/test_linear_average_rate_tax_scale_property.py: 4 passed before formatting and 4 passed after formatting (seed 596), including both explicit regressions and 300 passing generated examples per property, verified with Hypothesis statistics.
  • Current-master comparison: minimal before/after example verified; unchanged below-threshold, one-bracket, infinite-sentinel, nonfinite-base, input/return-type, and empty-scale-error cases verified. All four property witnesses reject current master.
  • Ruff format and lint on changed Python paths, and git diff --check: passed.

The failing reproduction used the prepared read-only dependency environment through uv run --active --no-sync; passing validation uses the workspace-local environment, Python 3.13, NumPy 2.4.2 and Hypothesis 6.168.3. No full suite or microsimulation was run; CI and the hub cover those.

Documentation: the existing calc contract docstring is rendered by the API reference. Additional durable documentation is not required for this test-only repair. Impact: medium for the PR's public caller behavior change; confidence: high for the tested engine domain. No concrete downstream country impact is established.

axiom: n/a: general engine contract, no policy encoding.

LinearAverageRateTaxScale.calc built brackets only between consecutive
thresholds, so a base at or above the last threshold fell in no bracket
and got tax 0. Brackets (0, 0), (1, 0.1), (2, 0.2) gave
[0.025, 0.1, 0.225, 0, 0, 0] at [0.5, 1, 1.5, 2, 2.5, 100].

From the last threshold up the average rate now stays at the last rate.
That is what the class already implies elsewhere: bracket_indices puts
those bases in the last bracket, to_marginal's top bracket levies the
last rate on them, and a scale closed by an infinite threshold (as
MarginalRateTaxScale.to_average makes) already gives them that rate.
Scales loaded from average_rate parameter brackets have no infinite
threshold, so they hit the gap.

Adds regression examples, Hypothesis properties (numpy.interp reference,
continuity at thresholds, an infinite last threshold changes nothing,
agreement with to_marginal) and a changelog fragment.

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.

1 participant