From afaca2aa487398c16fa1d127d229270df5f5b8fb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 17:44:49 -0400 Subject: [PATCH 1/2] Convert rate scales whose first threshold is not 0 MarginalRateTaxScale.to_average replaced the first threshold with 0. For one bracket from 50 at 0.37 it returned an average scale that taxed every base at 0, and the round trip gave a bracket from 0. With two or more brackets it misstated the tax at the first threshold, and a threshold of 0 above a negative one raised ZeroDivisionError. to_average now starts the average scale at the first threshold with average rate 0. It raises ValueError when no average rate scale can levy the tax: one bracket with a nonzero rate whose threshold is not 0, and nonzero tax at a threshold of 0. One bracket with surplus rates converts as its paired bracket does. LinearAverageRateTaxScale.to_marginal starts at a first threshold other than 0 when that threshold's rate is 0, so the round trip returns the original scale. Conversions of scales from 0 are unchanged. Property tests cover marginal scales with any first threshold, and the linear-average agreement property now includes one bracket. Co-Authored-By: Claude Opus 5.5 --- ...e-scale-conversion-nonzero-origin.fixed.md | 1 + .../linear_average_rate_tax_scale.py | 6 + .../taxscales/marginal_rate_tax_scale.py | 92 +++++++--- .../test_linear_average_rate_tax_scale.py | 23 +++ ..._linear_average_rate_tax_scale_property.py | 32 +++- .../test_marginal_rate_tax_scale.py | 114 +++++++++++- .../test_rate_scale_conversion_property.py | 170 ++++++++++++++---- 7 files changed, 372 insertions(+), 66 deletions(-) create mode 100644 changelog.d/rate-scale-conversion-nonzero-origin.fixed.md diff --git a/changelog.d/rate-scale-conversion-nonzero-origin.fixed.md b/changelog.d/rate-scale-conversion-nonzero-origin.fixed.md new file mode 100644 index 000000000..31a9066e4 --- /dev/null +++ b/changelog.d/rate-scale-conversion-nonzero-origin.fixed.md @@ -0,0 +1 @@ +`MarginalRateTaxScale.to_average` now keeps a first threshold other than 0, so the average scale levies the marginal tax there and `to_marginal` gives the scale back; it raises `ValueError` instead of returning a scale that taxes too little for one bracket with a nonzero rate away from 0 or for tax at a threshold of 0, and gives one bracket with surplus rates the same result as without them. diff --git a/policyengine_core/taxscales/linear_average_rate_tax_scale.py b/policyengine_core/taxscales/linear_average_rate_tax_scale.py index 27057c99f..38102af9d 100644 --- a/policyengine_core/taxscales/linear_average_rate_tax_scale.py +++ b/policyengine_core/taxscales/linear_average_rate_tax_scale.py @@ -92,6 +92,12 @@ def to_marginal(self) -> MarginalRateTaxScale: previous_i = 0 previous_threshold = 0 + if len(self.thresholds) > 1 and self.thresholds[0] != 0 and self.rates[0] == 0: + # The tax is 0 below the first threshold and, at a rate of 0, at + # it too, so the marginal scale starts there. With another rate + # the tax jumps at a first threshold other than 0, which no + # marginal scale can do, so the marginal scale starts at 0. + previous_threshold = self.thresholds[0] terminal_rate = self.rates[0] for threshold, rate in zip(self.thresholds[1:], self.rates[1:]): diff --git a/policyengine_core/taxscales/marginal_rate_tax_scale.py b/policyengine_core/taxscales/marginal_rate_tax_scale.py index feb75a497..47c640010 100644 --- a/policyengine_core/taxscales/marginal_rate_tax_scale.py +++ b/policyengine_core/taxscales/marginal_rate_tax_scale.py @@ -261,35 +261,85 @@ def scale_tax_scales(self, factor: float) -> MarginalRateTaxScale: return scaled_tax_scale.multiply_thresholds(factor) def to_average(self) -> taxscales.LinearAverageRateTaxScale: + """ + Convert the scale to a LinearAverageRateTaxScale. + + Each threshold gets the average rate there: the tax at it divided by + it. So the two scales levy the same tax at every threshold. A last + threshold of infinity carries the top rate, so ``to_marginal`` gives + this scale back. + + A single bracket from 0 has the same average rate at every base, so + the average scale levies the same tax at every base. With two or more + brackets, ``LinearAverageRateTaxScale.calc`` keeps the average rate of + the last finite threshold above it. That matches this scale's tax only + when the top rate is that average rate. + + :raises ValueError: if no average rate scale can levy the tax. This is + so for one bracket with a nonzero rate whose threshold is not 0, + and for a threshold of 0 at which the tax is not 0. + """ average_tax_scale = taxscales.LinearAverageRateTaxScale( name=self.name, option=self.option, unit=self.unit, ) - if len(self.thresholds) == len(self.rates) == 1 and self.thresholds[0] == 0: - # A zero-origin singleton has a constant average rate. Starting - # at a zero rate and interpolating toward infinity would levy no tax. - average_tax_scale.add_bracket(0, self.rates[0]) - else: + if not self.thresholds: average_tax_scale.add_bracket(0, 0) - - if self.thresholds: - i = 0 - previous_threshold = self.thresholds[0] - previous_rate = self.rates[0] - - for threshold, rate in itertools.islice( - zip(self.thresholds, self.rates), - 1, - None, - ): - i += previous_rate * (threshold - previous_threshold) + return average_tax_scale + + # A first threshold of 0 stays the integer 0, as it has always been. + first_threshold = self.thresholds[0] if self.thresholds[0] != 0 else 0 + + if len(self.thresholds) == 1: + # As for longer scales, rates past the paired ones are ignored. + rate = self.rates[0] + if first_threshold != 0 and rate != 0: + # The tax is rate * (base - threshold) from the threshold up, + # so the average rate is rate * (1 - threshold / base). A + # linear average rate scale cannot follow it. + raise ValueError( + f"{self.__class__.__name__}.to_average cannot convert one " + f"bracket with threshold {first_threshold} and rate " + f"{rate}: no linear average rate scale levies its tax. " + "Only one bracket with threshold 0 or rate 0 converts." + ) + # From 0, the average rate is the rate at every base. Starting at + # a rate of 0 and interpolating toward infinity would levy no tax. + average_tax_scale.add_bracket(first_threshold, rate) + average_tax_scale.add_bracket(float("Inf"), rate) + return average_tax_scale + + # The tax at the first threshold is 0, so its average rate is 0. + average_tax_scale.add_bracket(first_threshold, 0) + + i = 0 + previous_threshold = first_threshold + previous_rate = self.rates[0] + + for threshold, rate in itertools.islice( + zip(self.thresholds, self.rates), + 1, + None, + ): + i += previous_rate * (threshold - previous_threshold) + if threshold != 0: average_tax_scale.add_bracket(threshold, i / threshold) - previous_threshold = threshold - previous_rate = rate + elif i == 0: + # Every average rate taxes a base of 0 at 0. + average_tax_scale.add_bracket(threshold, 0) + else: + raise ValueError( + f"{self.__class__.__name__}.to_average cannot convert a " + f"threshold of 0 at which the tax is {i}: an average rate " + "scale taxes a base of 0 at 0." + ) + previous_threshold = threshold + previous_rate = rate - # Far above the last threshold the average rate approaches the top rate. - average_tax_scale.add_bracket(float("Inf"), previous_rate) + # ``to_marginal`` reads the top rate from the infinite threshold. + # ``LinearAverageRateTaxScale.calc`` does not use it. + average_tax_scale.add_bracket(float("Inf"), previous_rate) return average_tax_scale diff --git a/tests/core/tax_scales/test_linear_average_rate_tax_scale.py b/tests/core/tax_scales/test_linear_average_rate_tax_scale.py index 88b7cceec..2fb54b2df 100644 --- a/tests/core/tax_scales/test_linear_average_rate_tax_scale.py +++ b/tests/core/tax_scales/test_linear_average_rate_tax_scale.py @@ -222,6 +222,29 @@ def test_to_marginal_uses_last_paired_rate_at_infinity(rates_type): assert result.rates == [0.2, 0.3] +@pytest.mark.parametrize("first_threshold", [50, -50]) +def test_to_marginal_starts_at_a_first_threshold_with_rate_0(first_threshold): + # The scale taxes nothing up to and at its first threshold, so the + # marginal scale starts there. It used to start at 0, which from 50 taxed + # a base of 50 at 50 * 0.2 = 10 instead of 0. + tax_base = numpy.array([first_threshold, 100, 1_000]) + tax_scale = taxscales.LinearAverageRateTaxScale() + tax_scale.add_bracket(first_threshold, 0) + tax_scale.add_bracket(100, 0.2) + + result = tax_scale.to_marginal() + + # Tax at 100 is 100 * 0.2 = 20, spread over the bracket from the first + # threshold. + assert result.thresholds == [first_threshold, 100] + tools.assert_near( + result.rates, [20 / (100 - first_threshold), 0.2], absolute_error_margin=1e-12 + ) + tools.assert_near( + result.calc(tax_base), tax_scale.calc(tax_base), absolute_error_margin=1e-10 + ) + + def test_to_marginal_of_no_brackets(): result = taxscales.LinearAverageRateTaxScale().to_marginal() diff --git a/tests/core/tax_scales/test_linear_average_rate_tax_scale_property.py b/tests/core/tax_scales/test_linear_average_rate_tax_scale_property.py index ee2342ad1..c32d1a4b0 100644 --- a/tests/core/tax_scales/test_linear_average_rate_tax_scale_property.py +++ b/tests/core/tax_scales/test_linear_average_rate_tax_scale_property.py @@ -9,8 +9,9 @@ * at each threshold the tax is the threshold times its rate, and above the first threshold the tax has no jump there; * adding a last threshold of infinity, at any rate, does not change the tax; -* when the first threshold is 0, ``to_marginal`` levies the same tax at each - threshold and above the last one, within accumulated floating-point error. +* when the first threshold or its rate is 0, so that the tax does not jump at + the first threshold, ``to_marginal`` levies the same tax at each threshold + and above the last one, within accumulated floating-point error. Examples are in ``test_linear_average_rate_tax_scale.py``. """ @@ -141,15 +142,34 @@ def test_infinite_last_threshold_changes_nothing(brackets, top_rate, extra_bases ) +@st.composite +def _brackets_without_a_jump(draw): + # The tax jumps from 0 at the first threshold unless it or its rate is 0. + thresholds, rates = draw( + _brackets( + first_threshold=st.one_of( + st.just(0), st.integers(min_value=-1_000, max_value=1_000) + ) + ) + ) + if thresholds[0] != 0: + rates[0] = 0.0 + return thresholds, rates + + @settings(max_examples=300, deadline=None) -@given(_brackets(first_threshold=st.just(0), min_size=2)) +@given(_brackets_without_a_jump()) @example(brackets=([0, 999_999, 1_000_000], [0, 1, 0])) +@example(brackets=([0], [0.37])) +@example(brackets=([0], [-1.0])) +@example(brackets=([50, 100], [0.0, 0.2])) +@example(brackets=([-50, 100], [0.0, 0.2])) def test_calc_agrees_with_to_marginal_at_thresholds_and_above(brackets): - # One bracket is left out: ``to_marginal`` cannot convert it yet. thresholds, rates = brackets tax_scale = _scale(thresholds, rates) + last = thresholds[-1] tax_base = numpy.array( - thresholds + [thresholds[-1] + 1, 2 * thresholds[-1] + 1, 1e9], dtype=float + thresholds + [last + 1, last + abs(last) + 1, 1e9], dtype=float ) marginal_scale = tax_scale.to_marginal() @@ -158,7 +178,7 @@ def test_calc_agrees_with_to_marginal_at_thresholds_and_above(brackets): # Marginal calc shifts thresholds by 1 + eps. Each active bracket's # endpoint magnitudes bound this shift and width subtraction; weighting # by |marginal rate| also covers cancellation between large bracket taxes. - endpoints = lower + numpy.minimum(tax_base[:, None], upper) + endpoints = numpy.abs(lower) + numpy.abs(numpy.minimum(tax_base[:, None], upper)) endpoints = numpy.where(tax_base[:, None] >= lower, endpoints, 0) # The stored threshold*rate taxes telescope in exact arithmetic. Allow # rounding in secant conversion, widths, products and the bracket sum. diff --git a/tests/core/tax_scales/test_marginal_rate_tax_scale.py b/tests/core/tax_scales/test_marginal_rate_tax_scale.py index 7c71d0150..705b6c266 100644 --- a/tests/core/tax_scales/test_marginal_rate_tax_scale.py +++ b/tests/core/tax_scales/test_marginal_rate_tax_scale.py @@ -214,7 +214,7 @@ def test_to_average(): # Note: assert_near doesn't work for inf. assert result.thresholds == [0, 1, 2, numpy.inf] - assert result.rates, [0, 0, 0.05, 0.2] + assert result.rates == [0, 0, 0.05, 0.2] tools.assert_near( result.calc(tax_base), [0, 0.0375, 0.1, 0.125], @@ -258,6 +258,118 @@ def test_to_average_uses_last_paired_rate(rates_type): assert result.rates == [0, 0.1, 0.2] +@pytest.mark.parametrize("rates_type", [list, numpy.array]) +def test_to_average_of_a_single_bracket_uses_its_paired_rate(rates_type): + # The surplus rate 0.8 used to send one bracket down the path for longer + # scales, which gave an average scale that levied no tax. + tax_scale = taxscales.MarginalRateTaxScale() + tax_scale.thresholds = [0] + tax_scale.rates = rates_type([0.3, 0.8]) + + result = tax_scale.to_average() + + assert result.thresholds == [0, numpy.inf] + assert result.rates == [0.3, 0.3] + tools.assert_near( + result.calc(numpy.array([-1, 0, 1, 1_000])), + [0, 0, 0.3, 300], + absolute_error_margin=1e-10, + ) + + +@pytest.mark.parametrize("threshold", [50, -50]) +def test_to_average_rejects_a_single_bracket_away_from_0(threshold): + # One bracket from 50 taxes 0.37 * (base - 50), so its average rate is + # 0.37 * (1 - 50 / base). No linear average rate scale levies that tax. + # This used to give an average scale that taxed every base at 0, and a + # round trip to a bracket from 0. Before that it raised UnboundLocalError. + # From -50 the tax at base 0 is 0.37 * 50 = 18.5, but an average rate + # scale taxes a base of 0 at 0. + tax_scale = taxscales.MarginalRateTaxScale() + tax_scale.add_bracket(threshold, 0.37) + + with pytest.raises(ValueError, match="cannot convert one bracket"): + tax_scale.to_average() + + +@pytest.mark.parametrize("threshold", [50, -50]) +def test_to_average_of_a_single_zero_rate_bracket_away_from_0(threshold): + tax_scale = taxscales.MarginalRateTaxScale() + tax_scale.add_bracket(threshold, 0) + + result = tax_scale.to_average() + + assert result.thresholds == [threshold, numpy.inf] + assert result.rates == [0, 0] + tools.assert_near( + result.calc(numpy.array([-1_000, threshold, 0, 1_000])), + [0, 0, 0, 0], + absolute_error_margin=1e-10, + ) + round_trip = result.to_marginal() + assert round_trip.thresholds == [threshold] + assert round_trip.rates == [0] + + +@pytest.mark.parametrize( + "thresholds, average_rates", + [ + # Tax at 100 is 0.1 * (100 - 50) = 5, so its average rate is 0.05. + ([50, 100], [0, 0.05, 0.2]), + # Tax at 100 is 0.1 * (100 + 50) = 15, so its average rate is 0.15. + ([-50, 100], [0, 0.15, 0.2]), + ], +) +def test_to_average_keeps_a_first_threshold_away_from_0(thresholds, average_rates): + # The first threshold used to become 0. From 50, that taxed a base of 50 + # at 50 * (50 * 0.05 / 100) = 1.25 instead of 0, and the round trip gave + # brackets from 0 and 100 with rates 0.05 and 0.2. + tax_scale = taxscales.MarginalRateTaxScale() + tax_scale.add_bracket(thresholds[0], 0.1) + tax_scale.add_bracket(thresholds[1], 0.2) + + result = tax_scale.to_average() + + assert result.thresholds == [*thresholds, numpy.inf] + tools.assert_near(result.rates, average_rates, absolute_error_margin=1e-12) + tools.assert_near( + result.calc(numpy.array(thresholds)), + tax_scale.calc(numpy.array(thresholds)), + absolute_error_margin=1e-10, + ) + round_trip = result.to_marginal() + assert round_trip.thresholds == thresholds + tools.assert_near(round_trip.rates, [0.1, 0.2], absolute_error_margin=1e-12) + + +def test_to_average_with_no_tax_at_a_threshold_of_0(): + tax_scale = taxscales.MarginalRateTaxScale() + tax_scale.add_bracket(-50, 0) + tax_scale.add_bracket(0, 0.2) + tax_scale.add_bracket(100, 0.3) + + result = tax_scale.to_average() + + # Tax at 100 is 0.2 * 100 = 20, so its average rate is 0.2. + assert result.thresholds == [-50, 0, 100, numpy.inf] + tools.assert_near(result.rates, [0, 0, 0.2, 0.3], absolute_error_margin=1e-12) + round_trip = result.to_marginal() + assert round_trip.thresholds == [-50, 0, 100] + tools.assert_near(round_trip.rates, [0, 0.2, 0.3], absolute_error_margin=1e-12) + + +def test_to_average_rejects_tax_at_a_threshold_of_0(): + # Tax at 0 is 0.1 * 50 = 5, but an average rate scale taxes 0 at 0. This + # used to raise ZeroDivisionError. + tax_scale = taxscales.MarginalRateTaxScale() + tax_scale.add_bracket(-50, 0.1) + tax_scale.add_bracket(0, 0.2) + tax_scale.add_bracket(100, 0.3) + + with pytest.raises(ValueError, match="threshold of 0 at which the tax is 5"): + tax_scale.to_average() + + def test_rate_from_bracket_indice(): tax_base = numpy.array([0, 1_000, 1_500, 50_000]) tax_scale = taxscales.MarginalRateTaxScale() diff --git a/tests/core/tax_scales/test_rate_scale_conversion_property.py b/tests/core/tax_scales/test_rate_scale_conversion_property.py index e39cbf83d..e600a951a 100644 --- a/tests/core/tax_scales/test_rate_scale_conversion_property.py +++ b/tests/core/tax_scales/test_rate_scale_conversion_property.py @@ -1,22 +1,36 @@ """Properties of converting between marginal and linear-average rate scales. -For equal-length scales with 1 to 6 brackets, rates in [0, 1], first threshold -0, and strictly increasing positive integer upper thresholds up to 1,000,000: +The marginal scales have 1 to 6 brackets, rates in [-1, 1], and a first +threshold of 0 or any integer in [-1,000, 1,000]. Their upper thresholds are +strictly increasing integers up to 1,000,000, none of them 0. Examples in +``test_marginal_rate_tax_scale.py`` cover a threshold of 0 above the first. +For these scales: +* ``to_average`` raises ``ValueError`` for one bracket with a nonzero rate and + a threshold other than 0, and converts every other scale; * ``to_average`` and then ``to_marginal`` give back the same thresholds and - rates (one bracket included; it used to raise ``UnboundLocalError``); -* the average scale's last bracket starts at infinity with the top rate; -* a singleton marginal conversion preserves tax at finite bases within - [-1,000,000, 1,000,000]. - -For such a linear-average rate scale, ``to_marginal`` -gives a marginal scale that levies threshold x rate at every threshold (the -average rate there), and the top rate on the whole base above the last one. -Examples are in ``test_marginal_rate_tax_scale.py`` and -``test_linear_average_rate_tax_scale.py``. + rates (one bracket from 0 used to raise ``UnboundLocalError``, and a first + threshold other than 0 came back as 0); +* the average scale levies the marginal tax at every threshold, and its last + bracket starts at infinity with the top rate; +* for one bracket, the average scale levies the marginal tax at every finite + base within [-1,000,000, 1,000,000]. + +With two or more brackets, the average scale keeps the average rate of the +last finite threshold above it (see ``LinearAverageRateTaxScale.calc``). There +its tax matches the marginal tax only when the top rate is that average rate, +so no property here claims it. -Appending surplus rates to a scale with at least two brackets leaves both +For a linear-average rate scale with first threshold 0, rates in [0, 1] and +such upper thresholds, ``to_marginal`` gives a marginal scale that levies +threshold x rate at every threshold (the average rate there), and the top rate +on the whole base above the last one. + +Appending surplus rates to a scale with first threshold 0 leaves both conversion results unchanged, preserving the last paired terminal rate. + +Examples are in ``test_marginal_rate_tax_scale.py`` and +``test_linear_average_rate_tax_scale.py``. """ from __future__ import annotations @@ -27,53 +41,131 @@ # The smoke job installs Core without the dev extra but collects every module. pytest.importorskip("hypothesis") -from hypothesis import given, settings # noqa: E402 +from hypothesis import assume, example, given, settings # noqa: E402 from hypothesis import strategies as st # noqa: E402 from policyengine_core import taxscales # noqa: E402 +NONNEGATIVE_RATES = st.integers(min_value=0, max_value=1_000).map(lambda r: r / 1_000) +RATES = st.integers(min_value=-1_000, max_value=1_000).map(lambda r: r / 1_000) +FIRST_THRESHOLDS = st.one_of(st.just(0), st.integers(min_value=-1_000, max_value=1_000)) + + @st.composite -def _brackets(draw, min_upper_thresholds=0): +def _brackets( + draw, first_threshold=st.just(0), rate=NONNEGATIVE_RATES, min_upper_thresholds=0 +): + first = draw(first_threshold) upper_thresholds = draw( st.lists( - st.integers(min_value=1, max_value=1_000_000), + st.integers(min_value=first + 1, max_value=1_000_000).filter( + lambda threshold: threshold != 0 + ), min_size=min_upper_thresholds, max_size=5, unique=True, ) ) - thresholds = [0] + sorted(upper_thresholds) - rates = draw( - st.lists( - st.integers(min_value=0, max_value=1_000).map(lambda r: r / 1_000), - min_size=len(thresholds), - max_size=len(thresholds), - ) - ) + thresholds = [first] + sorted(upper_thresholds) + rates = draw(st.lists(rate, min_size=len(thresholds), max_size=len(thresholds))) return thresholds, rates +def _marginal(thresholds, rates): + tax_scale = taxscales.MarginalRateTaxScale() + for threshold, rate in zip(thresholds, rates): + tax_scale.add_bracket(threshold, rate) + return tax_scale + + +def _no_average_scale(thresholds, rates): + # One bracket taxes rate x (base - threshold) from its threshold up, an + # average rate of rate x (1 - threshold / base). That is linear in the base + # only when the threshold or the rate is 0. + return len(thresholds) == 1 and thresholds[0] != 0 and rates[0] != 0 + + @settings(max_examples=300, deadline=None) -@given(_brackets()) +@given(_brackets(first_threshold=FIRST_THRESHOLDS, rate=RATES)) +@example(brackets=([50], [0.37])) +@example(brackets=([-50], [0.37])) +@example(brackets=([50], [0.0])) +@example(brackets=([0], [0.37])) +def test_to_average_rejects_only_one_bracket_away_from_0(brackets): + thresholds, rates = brackets + marginal = _marginal(thresholds, rates) + + if _no_average_scale(thresholds, rates): + with pytest.raises(ValueError, match="cannot convert one bracket"): + marginal.to_average() + else: + marginal.to_average() + + +@settings(max_examples=300, deadline=None) +@given(_brackets(first_threshold=FIRST_THRESHOLDS, rate=RATES)) +@example(brackets=([0], [0.37])) +@example(brackets=([50], [0.0])) +@example(brackets=([-50], [0.0])) +@example(brackets=([50, 100], [0.1, 0.2])) +@example(brackets=([-50, 100], [0.1, 0.2])) +@example(brackets=([-100, -50, 100], [0.1, 0.2, 0.3])) def test_marginal_to_average_round_trips(brackets): thresholds, rates = brackets - marginal = taxscales.MarginalRateTaxScale() - for threshold, rate in zip(thresholds, rates): - marginal.add_bracket(threshold, rate) + assume(not _no_average_scale(thresholds, rates)) + marginal = _marginal(thresholds, rates) average = marginal.to_average() round_trip = average.to_marginal() - assert average.thresholds[-1] == numpy.inf + assert average.thresholds == [*thresholds, numpy.inf] assert average.rates[-1] == rates[-1] assert round_trip.thresholds == thresholds - numpy.testing.assert_allclose(round_trip.rates, rates, rtol=0, atol=1e-9) + assert round_trip.rates[-1] == rates[-1] + # Every other rate comes back as the difference of two thresholds' tax, + # each threshold x average rate, over the bracket's width. Those taxes + # round to within a few eps of the sum of |rate| x width. + eps = numpy.finfo(numpy.float64).eps + widths = numpy.diff(numpy.array(thresholds, dtype=float)) + largest_tax = numpy.abs(numpy.array(rates[:-1])) @ widths + tolerance = 16 * eps * largest_tax / widths + 1e-12 + error = numpy.abs(numpy.array(round_trip.rates[:-1]) - rates[:-1]) + assert numpy.all(error <= tolerance), (error, tolerance) + + +@settings(max_examples=300, deadline=None) +@given(_brackets(first_threshold=FIRST_THRESHOLDS, rate=RATES)) +@example(brackets=([50, 100], [0.1, 0.2])) +@example(brackets=([-50, 100], [0.1, 0.2])) +@example(brackets=([-100, -50, 100], [0.1, 0.2, 0.3])) +def test_marginal_to_average_levies_the_tax_at_every_threshold(brackets): + thresholds, rates = brackets + assume(not _no_average_scale(thresholds, rates)) + marginal = _marginal(thresholds, rates) + tax_base = numpy.array(thresholds, dtype=float) + + result = marginal.to_average().calc(tax_base) + + # Both sides sum bracket taxes: the average scale as it builds its rates, + # the marginal scale with thresholds scaled by 1 + eps. Bound the rounding + # by each bracket's endpoint magnitudes times its rate. + eps = numpy.finfo(numpy.float64).eps + endpoints = numpy.abs(tax_base) + numpy.abs( + numpy.append(tax_base[1:], tax_base[-1]) + ) + rounding = (len(thresholds) + 8) * eps * (numpy.abs(rates) @ endpoints) + numpy.testing.assert_array_less( + numpy.abs(result - marginal.calc(tax_base)), 1e-9 + rounding + ) @settings(max_examples=300, deadline=None) @given( - rate=st.integers(min_value=0, max_value=1_000).map(lambda r: r / 1_000), + bracket=st.one_of( + st.tuples(st.just(0), RATES), + st.tuples(st.integers(min_value=-1_000, max_value=1_000), st.just(0.0)), + ), bases=st.lists( st.floats( min_value=-1_000_000, @@ -85,15 +177,17 @@ def test_marginal_to_average_round_trips(brackets): max_size=10, ), ) -def test_singleton_marginal_to_average_preserves_calculation(rate, bases): +def test_singleton_marginal_to_average_preserves_calculation(bracket, bases): + threshold, rate = bracket marginal = taxscales.MarginalRateTaxScale() - marginal.add_bracket(0, rate) - tax_base = numpy.array([-1_000, -1, 0, 1, 1.5, 1_000, *bases]) + marginal.add_bracket(threshold, rate) + tax_base = numpy.array([-1_000, -1, 0, 1, 1.5, 1_000, threshold, *bases]) average = marginal.to_average() - # One zero-origin marginal bracket taxes max(base, 0) * rate. - expected = numpy.maximum(tax_base, 0) * rate + # One marginal bracket taxes max(base - threshold, 0) * rate. Here that is + # max(base, 0) * rate from 0, and 0 at rate 0. + expected = numpy.maximum(tax_base - threshold, 0) * rate numpy.testing.assert_allclose(average.calc(tax_base), expected, rtol=1e-12) numpy.testing.assert_allclose( average.calc(tax_base), marginal.calc(tax_base), rtol=1e-12 @@ -141,9 +235,9 @@ def test_average_to_marginal_levies_the_average_rate(brackets): ) @settings(max_examples=300, deadline=None) @given( - brackets=_brackets(min_upper_thresholds=1), + brackets=_brackets(), surplus_rates=st.lists( - st.integers(min_value=0, max_value=1_000).map(lambda r: r / 1_000), + NONNEGATIVE_RATES, min_size=1, max_size=3, ), From 5fc2742d9a83e15acbbbf6d4fc6ad34fac078fda Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 17:48:21 -0400 Subject: [PATCH 2/2] Tighten the to_average docstring and the changelog fragment Say what a threshold of 0 gets, and that a single zero-rate bracket from any threshold converts. The changelog no longer says every rejected scale used to tax too little: a threshold of 0 raised ZeroDivisionError, and a negative rate's zero-tax scale taxed too much. Co-Authored-By: Claude Opus 5.5 --- ...e-scale-conversion-nonzero-origin.fixed.md | 2 +- .../linear_average_rate_tax_scale.py | 2 +- .../taxscales/marginal_rate_tax_scale.py | 36 ++++++++++--------- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/changelog.d/rate-scale-conversion-nonzero-origin.fixed.md b/changelog.d/rate-scale-conversion-nonzero-origin.fixed.md index 31a9066e4..70f753d15 100644 --- a/changelog.d/rate-scale-conversion-nonzero-origin.fixed.md +++ b/changelog.d/rate-scale-conversion-nonzero-origin.fixed.md @@ -1 +1 @@ -`MarginalRateTaxScale.to_average` now keeps a first threshold other than 0, so the average scale levies the marginal tax there and `to_marginal` gives the scale back; it raises `ValueError` instead of returning a scale that taxes too little for one bracket with a nonzero rate away from 0 or for tax at a threshold of 0, and gives one bracket with surplus rates the same result as without them. +`MarginalRateTaxScale.to_average` now keeps a first threshold other than 0, so the average scale levies the marginal tax at every threshold and `to_marginal` gives the scale back; it raises `ValueError`, instead of returning a wrong scale or raising `ZeroDivisionError`, when no average rate scale can levy the tax, and it converts one bracket with surplus rates as it converts the paired bracket. diff --git a/policyengine_core/taxscales/linear_average_rate_tax_scale.py b/policyengine_core/taxscales/linear_average_rate_tax_scale.py index 38102af9d..d2935058b 100644 --- a/policyengine_core/taxscales/linear_average_rate_tax_scale.py +++ b/policyengine_core/taxscales/linear_average_rate_tax_scale.py @@ -96,7 +96,7 @@ def to_marginal(self) -> MarginalRateTaxScale: # The tax is 0 below the first threshold and, at a rate of 0, at # it too, so the marginal scale starts there. With another rate # the tax jumps at a first threshold other than 0, which no - # marginal scale can do, so the marginal scale starts at 0. + # marginal scale can follow, and the marginal scale starts at 0. previous_threshold = self.thresholds[0] terminal_rate = self.rates[0] diff --git a/policyengine_core/taxscales/marginal_rate_tax_scale.py b/policyengine_core/taxscales/marginal_rate_tax_scale.py index 47c640010..13a4000a6 100644 --- a/policyengine_core/taxscales/marginal_rate_tax_scale.py +++ b/policyengine_core/taxscales/marginal_rate_tax_scale.py @@ -264,20 +264,23 @@ def to_average(self) -> taxscales.LinearAverageRateTaxScale: """ Convert the scale to a LinearAverageRateTaxScale. - Each threshold gets the average rate there: the tax at it divided by - it. So the two scales levy the same tax at every threshold. A last - threshold of infinity carries the top rate, so ``to_marginal`` gives - this scale back. - - A single bracket from 0 has the same average rate at every base, so - the average scale levies the same tax at every base. With two or more - brackets, ``LinearAverageRateTaxScale.calc`` keeps the average rate of - the last finite threshold above it. That matches this scale's tax only - when the top rate is that average rate. - - :raises ValueError: if no average rate scale can levy the tax. This is - so for one bracket with a nonzero rate whose threshold is not 0, - and for a threshold of 0 at which the tax is not 0. + The average scale has the same thresholds, then a last threshold of + infinity that carries the top rate, so ``to_marginal`` gives this + scale back. Its rate at each threshold is the tax there divided by + the threshold, or 0 where both are 0. So the two scales levy the same + tax at every threshold. + + One bracket from 0 instead gets its rate at both thresholds. Its + average rate is the same at every base, so the two scales levy the + same tax at every base. With two or more brackets, + ``LinearAverageRateTaxScale.calc`` keeps the average rate of the last + finite threshold above it. That matches this scale's tax only when + the top rate is that average rate. + + :raises ValueError: if no average rate scale levies the tax at every + threshold or, for one bracket, at every base. This is so for one + bracket with a nonzero rate whose threshold is not 0, and for a + threshold of 0 at which the tax is not 0. """ average_tax_scale = taxscales.LinearAverageRateTaxScale( name=self.name, @@ -305,8 +308,9 @@ def to_average(self) -> taxscales.LinearAverageRateTaxScale: f"{rate}: no linear average rate scale levies its tax. " "Only one bracket with threshold 0 or rate 0 converts." ) - # From 0, the average rate is the rate at every base. Starting at - # a rate of 0 and interpolating toward infinity would levy no tax. + # From 0, the average rate is the rate at every base. At rate 0 it + # is 0 at every base. Starting at a rate of 0 and interpolating + # toward infinity would levy no tax. average_tax_scale.add_bracket(first_threshold, rate) average_tax_scale.add_bracket(float("Inf"), rate) return average_tax_scale