From ad577d57d22d0b7d7df0ff3f6fd61408aad19b3b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 12:55:22 -0400 Subject: [PATCH 1/5] Convert one-bracket rate scales without UnboundLocalError MarginalRateTaxScale.to_average and LinearAverageRateTaxScale.to_marginal took the top rate from a loop variable that only the second and later brackets set, so a one-bracket scale (for example threshold 0, rate 0) raised UnboundLocalError in both directions. Both now use the scale's own last rate, which is the value that loop variable held for every scale with two or more brackets, so those conversions are unchanged. An empty linear-average scale converts to an empty marginal scale. Adds single-bracket and empty regressions, plus Hypothesis properties: marginal -> average -> marginal returns the original brackets (one to six brackets from threshold 0), and to_marginal keeps the tax an average scale levies at its thresholds. Co-Authored-By: Claude Opus 5.5 --- ...ix-singleton-tax-scale-conversion.fixed.md | 1 + .../linear_average_rate_tax_scale.py | 5 +- .../taxscales/marginal_rate_tax_scale.py | 3 +- .../test_linear_average_rate_tax_scale.py | 24 ++++++ .../test_marginal_rate_tax_scale.py | 14 ++++ .../test_rate_scale_conversion_property.py | 82 +++++++++++++++++++ 6 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 changelog.d/fix-singleton-tax-scale-conversion.fixed.md create mode 100644 tests/core/tax_scales/test_rate_scale_conversion_property.py diff --git a/changelog.d/fix-singleton-tax-scale-conversion.fixed.md b/changelog.d/fix-singleton-tax-scale-conversion.fixed.md new file mode 100644 index 000000000..35f734abf --- /dev/null +++ b/changelog.d/fix-singleton-tax-scale-conversion.fixed.md @@ -0,0 +1 @@ +Converting a one-bracket rate scale with `MarginalRateTaxScale.to_average` or `LinearAverageRateTaxScale.to_marginal` no longer raises `UnboundLocalError`, and converting an empty linear-average scale gives an empty marginal scale. diff --git a/policyengine_core/taxscales/linear_average_rate_tax_scale.py b/policyengine_core/taxscales/linear_average_rate_tax_scale.py index cfe66e055..27378413d 100644 --- a/policyengine_core/taxscales/linear_average_rate_tax_scale.py +++ b/policyengine_core/taxscales/linear_average_rate_tax_scale.py @@ -59,6 +59,9 @@ def to_marginal(self) -> MarginalRateTaxScale: unit=self.unit, ) + if not self.rates: + return marginal_tax_scale + previous_i = 0 previous_threshold = 0 @@ -72,6 +75,6 @@ def to_marginal(self) -> MarginalRateTaxScale: previous_i = i previous_threshold = threshold - marginal_tax_scale.add_bracket(previous_threshold, rate) + marginal_tax_scale.add_bracket(previous_threshold, self.rates[-1]) return marginal_tax_scale diff --git a/policyengine_core/taxscales/marginal_rate_tax_scale.py b/policyengine_core/taxscales/marginal_rate_tax_scale.py index 3453f0199..113512f13 100644 --- a/policyengine_core/taxscales/marginal_rate_tax_scale.py +++ b/policyengine_core/taxscales/marginal_rate_tax_scale.py @@ -284,6 +284,7 @@ def to_average(self) -> taxscales.LinearAverageRateTaxScale: previous_threshold = threshold previous_rate = rate - average_tax_scale.add_bracket(float("Inf"), rate) + # Far above the last threshold the average rate approaches the top rate. + average_tax_scale.add_bracket(float("Inf"), self.rates[-1]) 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 3e62b8987..5d20e385f 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 @@ -88,3 +88,27 @@ def test_to_marginal(): [0.1, 0.25, 0.4, 0.5], absolute_error_margin=1e-10, ) + + +@pytest.mark.parametrize("rate", [0, 0.37]) +def test_to_marginal_of_a_single_bracket(rate): + # One bracket used to raise UnboundLocalError: the top rate was read from + # a loop variable that only the second and later brackets set. + tax_base = numpy.array([0, 1, 1.5, 1_000]) + tax_scale = taxscales.LinearAverageRateTaxScale() + tax_scale.add_bracket(0, rate) + + result = tax_scale.to_marginal() + + assert result.thresholds == [0] + assert result.rates == [rate] + 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() + + assert result.thresholds == [] + assert result.rates == [] 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 cc73f399f..7db3bf94f 100644 --- a/tests/core/tax_scales/test_marginal_rate_tax_scale.py +++ b/tests/core/tax_scales/test_marginal_rate_tax_scale.py @@ -222,6 +222,20 @@ def test_to_average(): ) +@pytest.mark.parametrize("rate", [0, 0.37]) +def test_to_average_of_a_single_bracket(rate): + # One bracket used to raise UnboundLocalError: the top rate was read from + # a loop variable that only the second and later brackets set. + tax_scale = taxscales.MarginalRateTaxScale() + tax_scale.add_bracket(0, rate) + + result = tax_scale.to_average() + + assert result.thresholds == [0, numpy.inf] + assert result.rates == [0, rate] + assert result.to_marginal().rates == [rate] + + 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 new file mode 100644 index 000000000..34823b721 --- /dev/null +++ b/tests/core/tax_scales/test_rate_scale_conversion_property.py @@ -0,0 +1,82 @@ +"""Properties of converting between marginal and linear-average rate scales. + +For any marginal rate scale whose first threshold is 0, with 1 to 6 brackets: + +* ``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. + +For any linear-average rate scale whose first threshold is 0, ``to_marginal`` +gives a marginal scale that levies the same tax at every threshold below the +last one (and everywhere, for one bracket). +Examples are in ``test_marginal_rate_tax_scale.py`` and +``test_linear_average_rate_tax_scale.py``. +""" + +from __future__ import annotations + +import numpy +import pytest + +# 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 strategies as st # noqa: E402 + +from policyengine_core import taxscales # noqa: E402 + + +@st.composite +def _brackets(draw): + upper_thresholds = draw( + st.lists( + st.integers(min_value=1, max_value=1_000_000), + 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), + ) + ) + return thresholds, rates + + +@settings(max_examples=300, deadline=None) +@given(_brackets()) +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) + + average = marginal.to_average() + round_trip = average.to_marginal() + + assert average.thresholds[-1] == 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) + + +@settings(max_examples=300, deadline=None) +@given(_brackets()) +def test_average_to_marginal_keeps_tax_at_thresholds(brackets): + thresholds, rates = brackets + average = taxscales.LinearAverageRateTaxScale() + for threshold, rate in zip(thresholds, rates): + average.add_bracket(threshold, rate) + + marginal = average.to_marginal() + + # ``LinearAverageRateTaxScale.calc`` levies nothing from its last + # threshold up when it has several brackets, so compare below that. + tax_base = numpy.array(thresholds[:-1] if len(thresholds) > 1 else [0, 1, 1e6]) + numpy.testing.assert_allclose( + marginal.calc(tax_base), average.calc(tax_base), rtol=1e-9, atol=1e-6 + ) From 99dbcd14717dda3cd76f50f8a020c9a05ad989e3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 16:59:02 -0400 Subject: [PATCH 2/5] Accept array rates when empty-checking; test the average-rate definition From the independent review of ad577d57 (approved, three nits): - `if not self.rates` raised for a NumPy array of rates (truth value of an array is ambiguous), which master accepted. Use len() instead. - The to_marginal property compared with LinearAverageRateTaxScale.calc below the last threshold only, which left two-bracket scales checking nothing but tax 0 at 0 (a mutant setting the first marginal rate to 999 survived). It now checks the definition directly: the marginal scale levies threshold x rate at every threshold, and the top rate on the whole base above the last one. That mutant is now killed. Co-Authored-By: Claude Opus 5.5 --- .../linear_average_rate_tax_scale.py | 2 +- .../test_rate_scale_conversion_property.py | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/policyengine_core/taxscales/linear_average_rate_tax_scale.py b/policyengine_core/taxscales/linear_average_rate_tax_scale.py index 27378413d..50ff0b1a0 100644 --- a/policyengine_core/taxscales/linear_average_rate_tax_scale.py +++ b/policyengine_core/taxscales/linear_average_rate_tax_scale.py @@ -59,7 +59,7 @@ def to_marginal(self) -> MarginalRateTaxScale: unit=self.unit, ) - if not self.rates: + if len(self.rates) == 0: return marginal_tax_scale previous_i = 0 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 34823b721..1402a9305 100644 --- a/tests/core/tax_scales/test_rate_scale_conversion_property.py +++ b/tests/core/tax_scales/test_rate_scale_conversion_property.py @@ -7,8 +7,8 @@ * the average scale's last bracket starts at infinity with the top rate. For any linear-average rate scale whose first threshold is 0, ``to_marginal`` -gives a marginal scale that levies the same tax at every threshold below the -last one (and everywhere, for one bracket). +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``. """ @@ -66,7 +66,7 @@ def test_marginal_to_average_round_trips(brackets): @settings(max_examples=300, deadline=None) @given(_brackets()) -def test_average_to_marginal_keeps_tax_at_thresholds(brackets): +def test_average_to_marginal_levies_the_average_rate(brackets): thresholds, rates = brackets average = taxscales.LinearAverageRateTaxScale() for threshold, rate in zip(thresholds, rates): @@ -74,9 +74,14 @@ def test_average_to_marginal_keeps_tax_at_thresholds(brackets): marginal = average.to_marginal() - # ``LinearAverageRateTaxScale.calc`` levies nothing from its last - # threshold up when it has several brackets, so compare below that. - tax_base = numpy.array(thresholds[:-1] if len(thresholds) > 1 else [0, 1, 1e6]) + # A linear-average scale's rate at each threshold is the average rate + # there, so the marginal scale levies threshold x rate at every + # threshold, and the top rate on the whole base from the last one up. + tax_base = numpy.array(thresholds, dtype=float) + above = 2.0 * thresholds[-1] + 1 numpy.testing.assert_allclose( - marginal.calc(tax_base), average.calc(tax_base), rtol=1e-9, atol=1e-6 + marginal.calc(numpy.append(tax_base, above)), + numpy.append(tax_base * numpy.array(rates), above * rates[-1]), + rtol=1e-9, + atol=1e-6, ) From 0dcab378b09ebc229d771572de61fa1f47d92802 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 18:52:50 -0400 Subject: [PATCH 3/5] Bound the to_marginal property's rounding by its inputs The independent review of 99dbcd14 found the new property fails every time under Hypothesis's CI profile (derandomized, so --reruns cannot recover it) and about 2% of random runs: brackets ([0, 1, 999517, 999518], [0, 0, 0.005, 0]) were off by 1.2e-6 against atol=1e-6. MarginalRateTaxScale.calc scales thresholds by 1 + eps, which moves the tax by about t x eps x the bracket's rate, and close thresholds near a million make that rate large. The tolerance now adds 8 x eps x (sum of rate x threshold + the base above the last threshold). Under CI=true both properties pass; 0 of 40 random seeds fail; the review's failing cases pass; the first-rate-999 mutant is still killed. Co-Authored-By: Claude Opus 5.5 --- .../tax_scales/test_rate_scale_conversion_property.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 1402a9305..6ac2c92e7 100644 --- a/tests/core/tax_scales/test_rate_scale_conversion_property.py +++ b/tests/core/tax_scales/test_rate_scale_conversion_property.py @@ -79,9 +79,18 @@ def test_average_to_marginal_levies_the_average_rate(brackets): # threshold, and the top rate on the whole base from the last one up. tax_base = numpy.array(thresholds, dtype=float) above = 2.0 * thresholds[-1] + 1 + # ``MarginalRateTaxScale.calc`` shifts each threshold t by t x eps (it + # scales them by 1 + eps), which moves the tax by about that much times + # the bracket's rate. Close thresholds near a million can make that rate + # large, so bound the rounding by the inputs rather than a fixed 1e-6. + eps = numpy.finfo(numpy.float64).eps + marginal_rates = numpy.abs(numpy.diff(tax_base * numpy.array(rates))) / ( + numpy.diff(tax_base) + ) + rounding = 8 * eps * ((marginal_rates * tax_base[1:]).sum() + above) numpy.testing.assert_allclose( marginal.calc(numpy.append(tax_base, above)), numpy.append(tax_base * numpy.array(rates), above * rates[-1]), rtol=1e-9, - atol=1e-6, + atol=1e-6 + rounding, ) From 7bfe61336eb0e3289a7453908356f64b4d48672e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 13:31:50 -0400 Subject: [PATCH 4/5] Preserve tax when converting zero-origin singleton rate scales --- ...ix-singleton-tax-scale-conversion.fixed.md | 2 +- .../taxscales/marginal_rate_tax_scale.py | 7 +++- .../test_marginal_rate_tax_scale.py | 10 ++++- .../test_rate_scale_conversion_property.py | 38 +++++++++++++++++-- 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/changelog.d/fix-singleton-tax-scale-conversion.fixed.md b/changelog.d/fix-singleton-tax-scale-conversion.fixed.md index 35f734abf..44037f0f9 100644 --- a/changelog.d/fix-singleton-tax-scale-conversion.fixed.md +++ b/changelog.d/fix-singleton-tax-scale-conversion.fixed.md @@ -1 +1 @@ -Converting a one-bracket rate scale with `MarginalRateTaxScale.to_average` or `LinearAverageRateTaxScale.to_marginal` no longer raises `UnboundLocalError`, and converting an empty linear-average scale gives an empty marginal scale. +Converting a one-bracket rate scale no longer raises `UnboundLocalError`, zero-origin marginal singletons preserve their tax calculations, and converting an empty linear-average scale gives an empty marginal scale. diff --git a/policyengine_core/taxscales/marginal_rate_tax_scale.py b/policyengine_core/taxscales/marginal_rate_tax_scale.py index 113512f13..4585667dd 100644 --- a/policyengine_core/taxscales/marginal_rate_tax_scale.py +++ b/policyengine_core/taxscales/marginal_rate_tax_scale.py @@ -267,7 +267,12 @@ def to_average(self) -> taxscales.LinearAverageRateTaxScale: unit=self.unit, ) - average_tax_scale.add_bracket(0, 0) + 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: + average_tax_scale.add_bracket(0, 0) if self.thresholds: i = 0 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 7db3bf94f..dd4969005 100644 --- a/tests/core/tax_scales/test_marginal_rate_tax_scale.py +++ b/tests/core/tax_scales/test_marginal_rate_tax_scale.py @@ -231,8 +231,16 @@ def test_to_average_of_a_single_bracket(rate): result = tax_scale.to_average() + # For rate 0.37: 1 * 0.37 = 0.37, 1.5 * 0.37 = 0.555, + # and 1_000 * 0.37 = 370. Negative bases owe no marginal tax. + tax_base = numpy.array([-1, 0, 1, 1.5, 1_000]) + tools.assert_near( + result.calc(tax_base), + [0, 0, rate, 1.5 * rate, 1_000 * rate], + absolute_error_margin=1e-10, + ) assert result.thresholds == [0, numpy.inf] - assert result.rates == [0, rate] + assert result.rates == [rate, rate] assert result.to_marginal().rates == [rate] 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 6ac2c92e7..7be52c38f 100644 --- a/tests/core/tax_scales/test_rate_scale_conversion_property.py +++ b/tests/core/tax_scales/test_rate_scale_conversion_property.py @@ -1,12 +1,15 @@ """Properties of converting between marginal and linear-average rate scales. -For any marginal rate scale whose first threshold is 0, with 1 to 6 brackets: +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: * ``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. +* 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 any linear-average rate scale whose first threshold is 0, ``to_marginal`` +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 @@ -64,6 +67,35 @@ def test_marginal_to_average_round_trips(brackets): numpy.testing.assert_allclose(round_trip.rates, rates, rtol=0, atol=1e-9) +@settings(max_examples=300, deadline=None) +@given( + rate=st.integers(min_value=0, max_value=1_000).map(lambda r: r / 1_000), + bases=st.lists( + st.floats( + min_value=-1_000_000, + max_value=1_000_000, + allow_nan=False, + allow_infinity=False, + ), + min_size=1, + max_size=10, + ), +) +def test_singleton_marginal_to_average_preserves_calculation(rate, bases): + marginal = taxscales.MarginalRateTaxScale() + marginal.add_bracket(0, rate) + tax_base = numpy.array([-1_000, -1, 0, 1, 1.5, 1_000, *bases]) + + average = marginal.to_average() + + # One zero-origin marginal bracket taxes max(base, 0) * rate. + expected = numpy.maximum(tax_base, 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 + ) + + @settings(max_examples=300, deadline=None) @given(_brackets()) def test_average_to_marginal_levies_the_average_rate(brackets): From e8cbb056aa62b8945f0428adaade49ed080576d8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 13:48:51 -0400 Subject: [PATCH 5/5] Retain the last paired rate in tax scale conversions --- .../linear_average_rate_tax_scale.py | 4 +- .../taxscales/marginal_rate_tax_scale.py | 2 +- .../test_linear_average_rate_tax_scale.py | 28 +++++++++++++ .../test_marginal_rate_tax_scale.py | 14 +++++++ .../test_rate_scale_conversion_property.py | 39 ++++++++++++++++++- 5 files changed, 84 insertions(+), 3 deletions(-) diff --git a/policyengine_core/taxscales/linear_average_rate_tax_scale.py b/policyengine_core/taxscales/linear_average_rate_tax_scale.py index 50ff0b1a0..01baa40a7 100644 --- a/policyengine_core/taxscales/linear_average_rate_tax_scale.py +++ b/policyengine_core/taxscales/linear_average_rate_tax_scale.py @@ -64,8 +64,10 @@ def to_marginal(self) -> MarginalRateTaxScale: previous_i = 0 previous_threshold = 0 + terminal_rate = self.rates[0] for threshold, rate in zip(self.thresholds[1:], self.rates[1:]): + terminal_rate = rate if threshold != float("Inf"): i = rate * threshold marginal_tax_scale.add_bracket( @@ -75,6 +77,6 @@ def to_marginal(self) -> MarginalRateTaxScale: previous_i = i previous_threshold = threshold - marginal_tax_scale.add_bracket(previous_threshold, self.rates[-1]) + marginal_tax_scale.add_bracket(previous_threshold, terminal_rate) return marginal_tax_scale diff --git a/policyengine_core/taxscales/marginal_rate_tax_scale.py b/policyengine_core/taxscales/marginal_rate_tax_scale.py index 4585667dd..feb75a497 100644 --- a/policyengine_core/taxscales/marginal_rate_tax_scale.py +++ b/policyengine_core/taxscales/marginal_rate_tax_scale.py @@ -290,6 +290,6 @@ def to_average(self) -> taxscales.LinearAverageRateTaxScale: previous_rate = rate # Far above the last threshold the average rate approaches the top rate. - average_tax_scale.add_bracket(float("Inf"), self.rates[-1]) + 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 5d20e385f..6c9dc5d2a 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 @@ -107,6 +107,34 @@ def test_to_marginal_of_a_single_bracket(rate): ) +@pytest.mark.parametrize("rates_type", [list, numpy.array]) +def test_to_marginal_uses_last_paired_rate(rates_type): + tax_scale = taxscales.LinearAverageRateTaxScale() + tax_scale.thresholds = [0, 100] + tax_scale.rates = rates_type([0.1, 0.2, 0.8]) + + result = tax_scale.to_marginal() + + assert result.thresholds == [0, 100] + # Tax at 100 is 100 * 0.2 = 20, so the first marginal rate is 20 / 100. + # The terminal rate is the last paired rate 0.2, ignoring surplus 0.8. + assert result.rates == [0.2, 0.2] + + +@pytest.mark.parametrize("rates_type", [list, numpy.array]) +def test_to_marginal_uses_last_paired_rate_at_infinity(rates_type): + tax_scale = taxscales.LinearAverageRateTaxScale() + tax_scale.thresholds = [0, 100, numpy.inf] + tax_scale.rates = rates_type([0.1, 0.2, 0.3, 0.8]) + + result = tax_scale.to_marginal() + + assert result.thresholds == [0, 100] + # The finite knot gives 100 * 0.2 / 100 = 0.2; the infinity knot + # supplies the terminal rate 0.3 even though its tax is not integrated. + assert result.rates == [0.2, 0.3] + + def test_to_marginal_of_no_brackets(): result = taxscales.LinearAverageRateTaxScale().to_marginal() 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 dd4969005..7c71d0150 100644 --- a/tests/core/tax_scales/test_marginal_rate_tax_scale.py +++ b/tests/core/tax_scales/test_marginal_rate_tax_scale.py @@ -244,6 +244,20 @@ def test_to_average_of_a_single_bracket(rate): assert result.to_marginal().rates == [rate] +@pytest.mark.parametrize("rates_type", [list, numpy.array]) +def test_to_average_uses_last_paired_rate(rates_type): + tax_scale = taxscales.MarginalRateTaxScale() + tax_scale.thresholds = [0, 100] + tax_scale.rates = rates_type([0.1, 0.2, 0.8]) + + result = tax_scale.to_average() + + assert result.thresholds == [0, 100, numpy.inf] + # Tax at 100 is 100 * 0.1 = 10, so its average rate is 10 / 100 = 0.1. + # The terminal marginal rate paired with threshold 100 is 0.2, not 0.8. + assert result.rates == [0, 0.1, 0.2] + + 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 7be52c38f..e39cbf83d 100644 --- a/tests/core/tax_scales/test_rate_scale_conversion_property.py +++ b/tests/core/tax_scales/test_rate_scale_conversion_property.py @@ -14,6 +14,9 @@ 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``. + +Appending surplus rates to a scale with at least two brackets leaves both +conversion results unchanged, preserving the last paired terminal rate. """ from __future__ import annotations @@ -31,10 +34,11 @@ @st.composite -def _brackets(draw): +def _brackets(draw, min_upper_thresholds=0): upper_thresholds = draw( st.lists( st.integers(min_value=1, max_value=1_000_000), + min_size=min_upper_thresholds, max_size=5, unique=True, ) @@ -126,3 +130,36 @@ def test_average_to_marginal_levies_the_average_rate(brackets): rtol=1e-9, atol=1e-6 + rounding, ) + + +@pytest.mark.parametrize( + "scale_type, conversion", + [ + (taxscales.MarginalRateTaxScale, "to_average"), + (taxscales.LinearAverageRateTaxScale, "to_marginal"), + ], +) +@settings(max_examples=300, deadline=None) +@given( + brackets=_brackets(min_upper_thresholds=1), + surplus_rates=st.lists( + st.integers(min_value=0, max_value=1_000).map(lambda r: r / 1_000), + min_size=1, + max_size=3, + ), +) +def test_conversions_ignore_surplus_rates( + scale_type, conversion, brackets, surplus_rates +): + thresholds, rates = brackets + paired = scale_type() + for threshold, rate in zip(thresholds, rates): + paired.add_bracket(threshold, rate) + surplus = paired.copy() + surplus.rates.extend(surplus_rates) + + expected = getattr(paired, conversion)() + result = getattr(surplus, conversion)() + + assert result.thresholds == expected.thresholds + assert result.rates == expected.rates