From 24c488ff1530f4567bba4543c863307c1a8f2a0e Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 24 Feb 2026 19:39:57 +0100 Subject: [PATCH 1/9] Fix intra-decile income change formula that doubled all percentage changes The intra_decile_impact and intra_wealth_decile_impact functions computed capped_reform_income as max(reform, 1) + absolute_change, which double-counted the change since reform already includes the change relative to baseline. Extracted a shared compute_income_change helper that correctly computes absolute_change / max(baseline, 1). Added 12 regression tests. Co-Authored-By: Claude Opus 4.6 --- changelog_entry.yaml | 4 + .../calculate_economy_comparison.py | 26 +- tests/test_intra_decile.py | 253 ++++++++++++++++++ 3 files changed, 269 insertions(+), 14 deletions(-) create mode 100644 tests/test_intra_decile.py diff --git a/changelog_entry.yaml b/changelog_entry.yaml index e69de29b..75ac6044 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -0,0 +1,4 @@ +- bump: patch + changes: + fixed: + - Fix intra-decile income change formula that doubled all percentage changes. diff --git a/policyengine/outputs/macro/comparison/calculate_economy_comparison.py b/policyengine/outputs/macro/comparison/calculate_economy_comparison.py index 04c7081a..4d8babca 100644 --- a/policyengine/outputs/macro/comparison/calculate_economy_comparison.py +++ b/policyengine/outputs/macro/comparison/calculate_economy_comparison.py @@ -410,6 +410,14 @@ class IntraDecileImpact(BaseModel): all: Dict[str, float] +def compute_income_change(baseline_values, reform_values): + """Percentage income change with a floor of 1 on the baseline + to avoid division by zero for zero/negative incomes.""" + absolute_change = reform_values - baseline_values + capped_baseline = np.maximum(baseline_values, 1) + return absolute_change / capped_baseline + + def intra_decile_impact( baseline: SingleEconomy, reform: SingleEconomy ) -> IntraDecileImpact: @@ -423,14 +431,9 @@ def intra_decile_impact( baseline.household_count_people, weights=baseline_income.weights ) decile = MicroSeries(baseline.household_income_decile).values - absolute_change = (reform_income - baseline_income).values - capped_baseline_income = np.maximum(baseline_income.values, 1) - capped_reform_income = ( - np.maximum(reform_income.values, 1) + absolute_change + income_change = compute_income_change( + baseline_income.values, reform_income.values ) - income_change = ( - capped_reform_income - capped_baseline_income - ) / capped_baseline_income # Within each decile, calculate the percentage of people who: # 1. Gained more than 5% of their income @@ -497,14 +500,9 @@ def intra_wealth_decile_impact( baseline.household_count_people, weights=baseline_income.weights ) decile = MicroSeries(baseline.household_wealth_decile).values - absolute_change = (reform_income - baseline_income).values - capped_baseline_income = np.maximum(baseline_income.values, 1) - capped_reform_income = ( - np.maximum(reform_income.values, 1) + absolute_change + income_change = compute_income_change( + baseline_income.values, reform_income.values ) - income_change = ( - capped_reform_income - capped_baseline_income - ) / capped_baseline_income # Within each decile, calculate the percentage of people who: # 1. Gained more than 5% of their income diff --git a/tests/test_intra_decile.py b/tests/test_intra_decile.py new file mode 100644 index 00000000..04fcff2f --- /dev/null +++ b/tests/test_intra_decile.py @@ -0,0 +1,253 @@ +"""Regression tests for intra_decile_impact and intra_wealth_decile_impact. + +These tests verify the fix for the double-counting bug where +capped_reform_income was computed as max(reform, 1) + absolute_change, +effectively doubling the percentage income change. +""" + +import pytest +import numpy as np +from unittest.mock import MagicMock + +from policyengine.outputs.macro.comparison.calculate_economy_comparison import ( + compute_income_change, + intra_decile_impact, + intra_wealth_decile_impact, +) + + +def _make_single_economy( + incomes, + deciles, + weights=None, + people=None, + decile_attr="household_income_decile", +): + """Build a mock SingleEconomy with the fields needed by + intra_decile_impact / intra_wealth_decile_impact.""" + n = len(incomes) + economy = MagicMock() + economy.household_net_income = np.array(incomes, dtype=float) + economy.household_weight = np.array( + weights if weights else [1.0] * n, dtype=float + ) + economy.household_count_people = np.array( + people if people else [1.0] * n, dtype=float + ) + setattr(economy, decile_attr, np.array(deciles, dtype=float)) + return economy + + +class TestComputeIncomeChange: + """Direct unit tests for the income change formula.""" + + def test__income_change_formula_exact(self): + result = compute_income_change(np.array([1000.0]), np.array([1040.0])) + assert result[0] == pytest.approx(0.04) + + +class TestIntraDecileImpact: + """Tests for intra_decile_impact — verifying correct percentage + change calculation and bucket assignment.""" + + def test__5pct_gain_classified_below_5pct(self): + """A uniform 5% income gain must NOT land in 'Gain more than 5%'. + + This is the key regression test for the double-counting bug where + income_change was 2x the true value, pushing 5% gains into the + >5% bucket. + """ + baseline = _make_single_economy( + incomes=[1000.0] * 10, + deciles=list(range(1, 11)), + ) + reform = _make_single_economy( + incomes=[1050.0] * 10, + deciles=list(range(1, 11)), + ) + result = intra_decile_impact(baseline, reform) + + for pct in result.deciles["Gain more than 5%"]: + assert pct == 0.0, f"5% gain incorrectly classified as >5% (got {pct})" + for pct in result.deciles["Gain less than 5%"]: + assert pct == 1.0, f"5% gain not classified as <5% (got {pct})" + + def test__10pct_gain_classified_above_5pct(self): + """A 10% gain should be in 'Gain more than 5%'.""" + baseline = _make_single_economy( + incomes=[1000.0] * 10, + deciles=list(range(1, 11)), + ) + reform = _make_single_economy( + incomes=[1100.0] * 10, + deciles=list(range(1, 11)), + ) + result = intra_decile_impact(baseline, reform) + + for pct in result.deciles["Gain more than 5%"]: + assert pct == 1.0 + + def test__3pct_loss_classified_below_5pct(self): + """A 3% loss should be in 'Lose less than 5%'.""" + baseline = _make_single_economy( + incomes=[1000.0] * 10, + deciles=list(range(1, 11)), + ) + reform = _make_single_economy( + incomes=[970.0] * 10, + deciles=list(range(1, 11)), + ) + result = intra_decile_impact(baseline, reform) + + for pct in result.deciles["Lose less than 5%"]: + assert pct == 1.0 + for pct in result.deciles["Lose more than 5%"]: + assert pct == 0.0 + + def test__no_change_classified_correctly(self): + """Zero change should land in 'No change'.""" + baseline = _make_single_economy( + incomes=[1000.0] * 10, + deciles=list(range(1, 11)), + ) + reform = _make_single_economy( + incomes=[1000.0] * 10, + deciles=list(range(1, 11)), + ) + result = intra_decile_impact(baseline, reform) + + for pct in result.deciles["No change"]: + assert pct == 1.0 + + def test__near_zero_baseline_no_division_error(self): + """Households with near-zero baseline income should not cause + division errors — the floor of 1 handles this.""" + baseline = _make_single_economy( + incomes=[0.0] * 10, + deciles=list(range(1, 11)), + ) + reform = _make_single_economy( + incomes=[100.0] * 10, + deciles=list(range(1, 11)), + ) + result = intra_decile_impact(baseline, reform) + + total = sum(result.all[label] for label in result.all) + assert abs(total - 1.0) < 1e-9, f"Proportions should sum to 1, got {total}" + + def test__zero_baseline_uses_floor_of_one(self): + """When baseline income is 0, the max(B, 1) floor means the + effective denominator is 1. A $0 -> $100 change should give + income_change = 100/1 = 100 (10000%), landing in >5%.""" + baseline = _make_single_economy( + incomes=[0.0] * 10, + deciles=list(range(1, 11)), + ) + reform = _make_single_economy( + incomes=[100.0] * 10, + deciles=list(range(1, 11)), + ) + result = intra_decile_impact(baseline, reform) + + for pct in result.deciles["Gain more than 5%"]: + assert pct == 1.0, f"Zero baseline with $100 gain should be >5% (got {pct})" + for label in result.all: + assert not np.isnan(result.all[label]) + assert not np.isinf(result.all[label]) + + def test__negative_baseline_handled(self): + """Households with negative baseline income should be handled + by the max(B, 1) floor without producing NaN or Inf.""" + baseline = _make_single_economy( + incomes=[-500.0] * 10, + deciles=list(range(1, 11)), + ) + reform = _make_single_economy( + incomes=[500.0] * 10, + deciles=list(range(1, 11)), + ) + result = intra_decile_impact(baseline, reform) + + for label in result.all: + assert not np.isnan(result.all[label]) + assert not np.isinf(result.all[label]) + + def test__4pct_gain_not_doubled_into_above_5pct(self): + """A 4% gain must stay in <5%. With the doubling bug, 4% * 2 = 8% + would incorrectly land in >5%. This is the tightest regression + test for the doubling bug on the gain side.""" + baseline = _make_single_economy( + incomes=[10000.0] * 10, + deciles=list(range(1, 11)), + ) + reform = _make_single_economy( + incomes=[10400.0] * 10, + deciles=list(range(1, 11)), + ) + result = intra_decile_impact(baseline, reform) + + for pct in result.deciles["Gain more than 5%"]: + assert pct == 0.0, "4% gain incorrectly classified as >5% (doubling bug)" + for pct in result.deciles["Gain less than 5%"]: + assert pct == 1.0, "4% gain not classified as <5%" + + def test__all_field_averages_deciles(self): + """The 'all' field should be the mean of the 10 decile values.""" + baseline = _make_single_economy( + incomes=[1000.0] * 10, + deciles=list(range(1, 11)), + ) + reform = _make_single_economy( + incomes=[1050.0] * 10, + deciles=list(range(1, 11)), + ) + result = intra_decile_impact(baseline, reform) + + for label in result.all: + expected = sum(result.deciles[label]) / 10 + assert abs(result.all[label] - expected) < 1e-9 + + +class TestIntraWealthDecileImpact: + """Tests for intra_wealth_decile_impact — same formula, keyed by + wealth decile instead of income decile.""" + + def test__5pct_gain_classified_below_5pct(self): + """Regression test: 5% gain must not be doubled into >5% bucket.""" + baseline = _make_single_economy( + incomes=[1000.0] * 10, + deciles=list(range(1, 11)), + decile_attr="household_wealth_decile", + ) + reform = _make_single_economy( + incomes=[1050.0] * 10, + deciles=list(range(1, 11)), + decile_attr="household_wealth_decile", + ) + + result = intra_wealth_decile_impact(baseline, reform, "uk") + + for pct in result.deciles["Gain more than 5%"]: + assert ( + pct == 0.0 + ), f"5% gain incorrectly classified as >5% in wealth decile (got {pct})" + + def test__4pct_gain_not_doubled(self): + """A 4% gain must stay in the <5% bucket for wealth deciles too.""" + baseline = _make_single_economy( + incomes=[10000.0] * 10, + deciles=list(range(1, 11)), + decile_attr="household_wealth_decile", + ) + reform = _make_single_economy( + incomes=[10400.0] * 10, + deciles=list(range(1, 11)), + decile_attr="household_wealth_decile", + ) + + result = intra_wealth_decile_impact(baseline, reform, "uk") + + for pct in result.deciles["Gain more than 5%"]: + assert pct == 0.0, "4% gain incorrectly classified as >5%" + for pct in result.deciles["Gain less than 5%"]: + assert pct == 1.0, "4% gain not classified as <5%" From 0ee3acac6f14c933dfa3f5e3414c734943407553 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 24 Feb 2026 19:51:45 +0100 Subject: [PATCH 2/9] Fix same double-counting bug in decile.py (dead code) calculate_income_specific_decile_winners_losers had the identical capped_reform_income = max(reform, 1) + absolute_change bug. Currently unreachable but fixing for consistency. Co-Authored-By: Claude Opus 4.6 --- policyengine/outputs/macro/comparison/decile.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/policyengine/outputs/macro/comparison/decile.py b/policyengine/outputs/macro/comparison/decile.py index 9c84b295..e73b0de2 100644 --- a/policyengine/outputs/macro/comparison/decile.py +++ b/policyengine/outputs/macro/comparison/decile.py @@ -108,12 +108,7 @@ def calculate_income_specific_decile_winners_losers( # Filter out negative decile values due to negative incomes absolute_change = (reform_income - baseline_income).values capped_baseline_income = np.maximum(baseline_income.values, 1) - capped_reform_income = ( - np.maximum(reform_income.values, 1) + absolute_change - ) - income_change = ( - capped_reform_income - capped_baseline_income - ) / capped_baseline_income + income_change = absolute_change / capped_baseline_income # Within each decile, calculate the percentage of people who: # 1. Gained more than 5% of their income From d53a9f134a09225949b6450b80414567c0c591af Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 24 Feb 2026 20:10:31 +0100 Subject: [PATCH 3/9] Refactor: make _compute_income_change private, move test fixtures, rename tests - Prefix compute_income_change with underscore (internal helper) - Move mock factory and constants to tests/fixtures/test_intra_decile.py - Rename all tests to test__given_X__Y naming convention Co-Authored-By: Claude Opus 4.6 --- .../calculate_economy_comparison.py | 6 +- tests/fixtures/test_intra_decile.py | 51 +++++ tests/test_intra_decile.py | 179 ++++-------------- 3 files changed, 90 insertions(+), 146 deletions(-) create mode 100644 tests/fixtures/test_intra_decile.py diff --git a/policyengine/outputs/macro/comparison/calculate_economy_comparison.py b/policyengine/outputs/macro/comparison/calculate_economy_comparison.py index 4d8babca..754f987c 100644 --- a/policyengine/outputs/macro/comparison/calculate_economy_comparison.py +++ b/policyengine/outputs/macro/comparison/calculate_economy_comparison.py @@ -410,7 +410,7 @@ class IntraDecileImpact(BaseModel): all: Dict[str, float] -def compute_income_change(baseline_values, reform_values): +def _compute_income_change(baseline_values, reform_values): """Percentage income change with a floor of 1 on the baseline to avoid division by zero for zero/negative incomes.""" absolute_change = reform_values - baseline_values @@ -431,7 +431,7 @@ def intra_decile_impact( baseline.household_count_people, weights=baseline_income.weights ) decile = MicroSeries(baseline.household_income_decile).values - income_change = compute_income_change( + income_change = _compute_income_change( baseline_income.values, reform_income.values ) @@ -500,7 +500,7 @@ def intra_wealth_decile_impact( baseline.household_count_people, weights=baseline_income.weights ) decile = MicroSeries(baseline.household_wealth_decile).values - income_change = compute_income_change( + income_change = _compute_income_change( baseline_income.values, reform_income.values ) diff --git a/tests/fixtures/test_intra_decile.py b/tests/fixtures/test_intra_decile.py new file mode 100644 index 00000000..a2a49886 --- /dev/null +++ b/tests/fixtures/test_intra_decile.py @@ -0,0 +1,51 @@ +"""Fixtures for intra_decile_impact and intra_wealth_decile_impact tests.""" + +import numpy as np +from unittest.mock import MagicMock + + +# Standard decile assignment: one household per decile (1-10) +DECILES_1_TO_10 = list(range(1, 11)) +NUM_DECILES = 10 + + +def make_single_economy( + incomes, + deciles, + weights=None, + people=None, + decile_attr="household_income_decile", +): + """Build a mock SingleEconomy with the fields needed by + intra_decile_impact / intra_wealth_decile_impact.""" + n = len(incomes) + economy = MagicMock() + economy.household_net_income = np.array(incomes, dtype=float) + economy.household_weight = np.array( + weights if weights else [1.0] * n, dtype=float + ) + economy.household_count_people = np.array( + people if people else [1.0] * n, dtype=float + ) + setattr(economy, decile_attr, np.array(deciles, dtype=float)) + return economy + + +def make_uniform_pair( + baseline_income, + reform_income, + decile_attr="household_income_decile", +): + """Build a baseline/reform pair where every household has the same + income, one per decile.""" + baseline = make_single_economy( + incomes=[baseline_income] * NUM_DECILES, + deciles=DECILES_1_TO_10, + decile_attr=decile_attr, + ) + reform = make_single_economy( + incomes=[reform_income] * NUM_DECILES, + deciles=DECILES_1_TO_10, + decile_attr=decile_attr, + ) + return baseline, reform diff --git a/tests/test_intra_decile.py b/tests/test_intra_decile.py index 04fcff2f..cff63dd7 100644 --- a/tests/test_intra_decile.py +++ b/tests/test_intra_decile.py @@ -7,42 +7,25 @@ import pytest import numpy as np -from unittest.mock import MagicMock from policyengine.outputs.macro.comparison.calculate_economy_comparison import ( - compute_income_change, + _compute_income_change, intra_decile_impact, intra_wealth_decile_impact, ) - - -def _make_single_economy( - incomes, - deciles, - weights=None, - people=None, - decile_attr="household_income_decile", -): - """Build a mock SingleEconomy with the fields needed by - intra_decile_impact / intra_wealth_decile_impact.""" - n = len(incomes) - economy = MagicMock() - economy.household_net_income = np.array(incomes, dtype=float) - economy.household_weight = np.array( - weights if weights else [1.0] * n, dtype=float - ) - economy.household_count_people = np.array( - people if people else [1.0] * n, dtype=float - ) - setattr(economy, decile_attr, np.array(deciles, dtype=float)) - return economy +from tests.fixtures.test_intra_decile import ( + make_single_economy, + make_uniform_pair, +) class TestComputeIncomeChange: """Direct unit tests for the income change formula.""" - def test__income_change_formula_exact(self): - result = compute_income_change(np.array([1000.0]), np.array([1040.0])) + def test__given_4pct_gain__returns_0_04(self): + result = _compute_income_change( + np.array([1000.0]), np.array([1040.0]) + ) assert result[0] == pytest.approx(0.04) @@ -50,21 +33,14 @@ class TestIntraDecileImpact: """Tests for intra_decile_impact — verifying correct percentage change calculation and bucket assignment.""" - def test__5pct_gain_classified_below_5pct(self): + def test__given_5pct_gain__classifies_as_below_5pct(self): """A uniform 5% income gain must NOT land in 'Gain more than 5%'. This is the key regression test for the double-counting bug where income_change was 2x the true value, pushing 5% gains into the >5% bucket. """ - baseline = _make_single_economy( - incomes=[1000.0] * 10, - deciles=list(range(1, 11)), - ) - reform = _make_single_economy( - incomes=[1050.0] * 10, - deciles=list(range(1, 11)), - ) + baseline, reform = make_uniform_pair(1000.0, 1050.0) result = intra_decile_impact(baseline, reform) for pct in result.deciles["Gain more than 5%"]: @@ -72,31 +48,15 @@ def test__5pct_gain_classified_below_5pct(self): for pct in result.deciles["Gain less than 5%"]: assert pct == 1.0, f"5% gain not classified as <5% (got {pct})" - def test__10pct_gain_classified_above_5pct(self): - """A 10% gain should be in 'Gain more than 5%'.""" - baseline = _make_single_economy( - incomes=[1000.0] * 10, - deciles=list(range(1, 11)), - ) - reform = _make_single_economy( - incomes=[1100.0] * 10, - deciles=list(range(1, 11)), - ) + def test__given_10pct_gain__classifies_as_above_5pct(self): + baseline, reform = make_uniform_pair(1000.0, 1100.0) result = intra_decile_impact(baseline, reform) for pct in result.deciles["Gain more than 5%"]: assert pct == 1.0 - def test__3pct_loss_classified_below_5pct(self): - """A 3% loss should be in 'Lose less than 5%'.""" - baseline = _make_single_economy( - incomes=[1000.0] * 10, - deciles=list(range(1, 11)), - ) - reform = _make_single_economy( - incomes=[970.0] * 10, - deciles=list(range(1, 11)), - ) + def test__given_3pct_loss__classifies_as_below_5pct_loss(self): + baseline, reform = make_uniform_pair(1000.0, 970.0) result = intra_decile_impact(baseline, reform) for pct in result.deciles["Lose less than 5%"]: @@ -104,49 +64,25 @@ def test__3pct_loss_classified_below_5pct(self): for pct in result.deciles["Lose more than 5%"]: assert pct == 0.0 - def test__no_change_classified_correctly(self): - """Zero change should land in 'No change'.""" - baseline = _make_single_economy( - incomes=[1000.0] * 10, - deciles=list(range(1, 11)), - ) - reform = _make_single_economy( - incomes=[1000.0] * 10, - deciles=list(range(1, 11)), - ) + def test__given_no_change__classifies_as_no_change(self): + baseline, reform = make_uniform_pair(1000.0, 1000.0) result = intra_decile_impact(baseline, reform) for pct in result.deciles["No change"]: assert pct == 1.0 - def test__near_zero_baseline_no_division_error(self): - """Households with near-zero baseline income should not cause - division errors — the floor of 1 handles this.""" - baseline = _make_single_economy( - incomes=[0.0] * 10, - deciles=list(range(1, 11)), - ) - reform = _make_single_economy( - incomes=[100.0] * 10, - deciles=list(range(1, 11)), - ) + def test__given_zero_baseline__proportions_sum_to_one(self): + baseline, reform = make_uniform_pair(0.0, 100.0) result = intra_decile_impact(baseline, reform) total = sum(result.all[label] for label in result.all) assert abs(total - 1.0) < 1e-9, f"Proportions should sum to 1, got {total}" - def test__zero_baseline_uses_floor_of_one(self): + def test__given_zero_baseline__classifies_gain_as_above_5pct(self): """When baseline income is 0, the max(B, 1) floor means the - effective denominator is 1. A $0 -> $100 change should give - income_change = 100/1 = 100 (10000%), landing in >5%.""" - baseline = _make_single_economy( - incomes=[0.0] * 10, - deciles=list(range(1, 11)), - ) - reform = _make_single_economy( - incomes=[100.0] * 10, - deciles=list(range(1, 11)), - ) + effective denominator is 1. A $0 -> $100 change gives + income_change = 100/1 = 10000%, landing in >5%.""" + baseline, reform = make_uniform_pair(0.0, 100.0) result = intra_decile_impact(baseline, reform) for pct in result.deciles["Gain more than 5%"]: @@ -155,35 +91,18 @@ def test__zero_baseline_uses_floor_of_one(self): assert not np.isnan(result.all[label]) assert not np.isinf(result.all[label]) - def test__negative_baseline_handled(self): - """Households with negative baseline income should be handled - by the max(B, 1) floor without producing NaN or Inf.""" - baseline = _make_single_economy( - incomes=[-500.0] * 10, - deciles=list(range(1, 11)), - ) - reform = _make_single_economy( - incomes=[500.0] * 10, - deciles=list(range(1, 11)), - ) + def test__given_negative_baseline__produces_no_nan_or_inf(self): + baseline, reform = make_uniform_pair(-500.0, 500.0) result = intra_decile_impact(baseline, reform) for label in result.all: assert not np.isnan(result.all[label]) assert not np.isinf(result.all[label]) - def test__4pct_gain_not_doubled_into_above_5pct(self): + def test__given_4pct_gain__does_not_double_into_above_5pct(self): """A 4% gain must stay in <5%. With the doubling bug, 4% * 2 = 8% - would incorrectly land in >5%. This is the tightest regression - test for the doubling bug on the gain side.""" - baseline = _make_single_economy( - incomes=[10000.0] * 10, - deciles=list(range(1, 11)), - ) - reform = _make_single_economy( - incomes=[10400.0] * 10, - deciles=list(range(1, 11)), - ) + would incorrectly land in >5%.""" + baseline, reform = make_uniform_pair(10000.0, 10400.0) result = intra_decile_impact(baseline, reform) for pct in result.deciles["Gain more than 5%"]: @@ -191,16 +110,8 @@ def test__4pct_gain_not_doubled_into_above_5pct(self): for pct in result.deciles["Gain less than 5%"]: assert pct == 1.0, "4% gain not classified as <5%" - def test__all_field_averages_deciles(self): - """The 'all' field should be the mean of the 10 decile values.""" - baseline = _make_single_economy( - incomes=[1000.0] * 10, - deciles=list(range(1, 11)), - ) - reform = _make_single_economy( - incomes=[1050.0] * 10, - deciles=list(range(1, 11)), - ) + def test__given_uniform_gain__all_field_averages_deciles(self): + baseline, reform = make_uniform_pair(1000.0, 1050.0) result = intra_decile_impact(baseline, reform) for label in result.all: @@ -212,19 +123,10 @@ class TestIntraWealthDecileImpact: """Tests for intra_wealth_decile_impact — same formula, keyed by wealth decile instead of income decile.""" - def test__5pct_gain_classified_below_5pct(self): - """Regression test: 5% gain must not be doubled into >5% bucket.""" - baseline = _make_single_economy( - incomes=[1000.0] * 10, - deciles=list(range(1, 11)), - decile_attr="household_wealth_decile", - ) - reform = _make_single_economy( - incomes=[1050.0] * 10, - deciles=list(range(1, 11)), - decile_attr="household_wealth_decile", + def test__given_5pct_gain__classifies_as_below_5pct(self): + baseline, reform = make_uniform_pair( + 1000.0, 1050.0, decile_attr="household_wealth_decile" ) - result = intra_wealth_decile_impact(baseline, reform, "uk") for pct in result.deciles["Gain more than 5%"]: @@ -232,19 +134,10 @@ def test__5pct_gain_classified_below_5pct(self): pct == 0.0 ), f"5% gain incorrectly classified as >5% in wealth decile (got {pct})" - def test__4pct_gain_not_doubled(self): - """A 4% gain must stay in the <5% bucket for wealth deciles too.""" - baseline = _make_single_economy( - incomes=[10000.0] * 10, - deciles=list(range(1, 11)), - decile_attr="household_wealth_decile", - ) - reform = _make_single_economy( - incomes=[10400.0] * 10, - deciles=list(range(1, 11)), - decile_attr="household_wealth_decile", + def test__given_4pct_gain__does_not_double_into_above_5pct(self): + baseline, reform = make_uniform_pair( + 10000.0, 10400.0, decile_attr="household_wealth_decile" ) - result = intra_wealth_decile_impact(baseline, reform, "uk") for pct in result.deciles["Gain more than 5%"]: From 867c604aea903a7d0b9d162efc7c9cd0cf67883f Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 24 Feb 2026 20:20:52 +0100 Subject: [PATCH 4/9] style: Apply black formatting Co-Authored-By: Claude Opus 4.6 --- tests/test_intra_decile.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/test_intra_decile.py b/tests/test_intra_decile.py index cff63dd7..57e8a07a 100644 --- a/tests/test_intra_decile.py +++ b/tests/test_intra_decile.py @@ -23,9 +23,7 @@ class TestComputeIncomeChange: """Direct unit tests for the income change formula.""" def test__given_4pct_gain__returns_0_04(self): - result = _compute_income_change( - np.array([1000.0]), np.array([1040.0]) - ) + result = _compute_income_change(np.array([1000.0]), np.array([1040.0])) assert result[0] == pytest.approx(0.04) @@ -44,7 +42,9 @@ def test__given_5pct_gain__classifies_as_below_5pct(self): result = intra_decile_impact(baseline, reform) for pct in result.deciles["Gain more than 5%"]: - assert pct == 0.0, f"5% gain incorrectly classified as >5% (got {pct})" + assert ( + pct == 0.0 + ), f"5% gain incorrectly classified as >5% (got {pct})" for pct in result.deciles["Gain less than 5%"]: assert pct == 1.0, f"5% gain not classified as <5% (got {pct})" @@ -76,7 +76,9 @@ def test__given_zero_baseline__proportions_sum_to_one(self): result = intra_decile_impact(baseline, reform) total = sum(result.all[label] for label in result.all) - assert abs(total - 1.0) < 1e-9, f"Proportions should sum to 1, got {total}" + assert ( + abs(total - 1.0) < 1e-9 + ), f"Proportions should sum to 1, got {total}" def test__given_zero_baseline__classifies_gain_as_above_5pct(self): """When baseline income is 0, the max(B, 1) floor means the @@ -86,7 +88,9 @@ def test__given_zero_baseline__classifies_gain_as_above_5pct(self): result = intra_decile_impact(baseline, reform) for pct in result.deciles["Gain more than 5%"]: - assert pct == 1.0, f"Zero baseline with $100 gain should be >5% (got {pct})" + assert ( + pct == 1.0 + ), f"Zero baseline with $100 gain should be >5% (got {pct})" for label in result.all: assert not np.isnan(result.all[label]) assert not np.isinf(result.all[label]) @@ -106,7 +110,9 @@ def test__given_4pct_gain__does_not_double_into_above_5pct(self): result = intra_decile_impact(baseline, reform) for pct in result.deciles["Gain more than 5%"]: - assert pct == 0.0, "4% gain incorrectly classified as >5% (doubling bug)" + assert ( + pct == 0.0 + ), "4% gain incorrectly classified as >5% (doubling bug)" for pct in result.deciles["Gain less than 5%"]: assert pct == 1.0, "4% gain not classified as <5%" From 34761a93deb7da9fc9b5c9001f183c4d5839eb55 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 24 Feb 2026 20:25:51 +0100 Subject: [PATCH 5/9] ci: Scope 0.x versioning workflow to 0.x branch only The 0.x versioning.yaml was also triggering on main pushes, which is redundant since main has its own copy. Removes main from the trigger branches to avoid conflicts. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/versioning.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/versioning.yaml b/.github/workflows/versioning.yaml index 52059849..d5ff5369 100644 --- a/.github/workflows/versioning.yaml +++ b/.github/workflows/versioning.yaml @@ -4,8 +4,7 @@ name: Versioning updates on: push: branches: - - main - - '0.x' # Maintenance branch for legacy 0.x releases + - '0.x' paths: - changelog_entry.yaml From f18b26cda3d38e7c2d615e74b3671cd49cf31982 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 24 Feb 2026 20:32:22 +0100 Subject: [PATCH 6/9] style: Fix black 26.x formatting in test fixture Co-Authored-By: Claude Opus 4.6 --- tests/fixtures/test_intra_decile.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/fixtures/test_intra_decile.py b/tests/fixtures/test_intra_decile.py index a2a49886..5d7c5976 100644 --- a/tests/fixtures/test_intra_decile.py +++ b/tests/fixtures/test_intra_decile.py @@ -3,7 +3,6 @@ import numpy as np from unittest.mock import MagicMock - # Standard decile assignment: one household per decile (1-10) DECILES_1_TO_10 = list(range(1, 11)) NUM_DECILES = 10 From fdc2a9d445366a06cd3b78fea8e79fc3df17228f Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 24 Feb 2026 20:45:07 +0100 Subject: [PATCH 7/9] ci: Restore main branch trigger for versioning workflow Co-Authored-By: Claude Opus 4.6 --- .github/workflows/versioning.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/versioning.yaml b/.github/workflows/versioning.yaml index d5ff5369..f37156aa 100644 --- a/.github/workflows/versioning.yaml +++ b/.github/workflows/versioning.yaml @@ -4,6 +4,7 @@ name: Versioning updates on: push: branches: + - main - '0.x' paths: From 672e8acd2a1d2983d55b8160ae86657b4ae8d2c6 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 24 Feb 2026 20:48:30 +0100 Subject: [PATCH 8/9] ci: Install towncrier in versioning workflow The main branch Makefile uses towncrier for changelog generation but the workflow only installed yaml-changelog, causing 'towncrier: not found'. Install both so the workflow works for both 0.x (yaml-changelog) and main (towncrier). Co-Authored-By: Claude Opus 4.6 --- .github/workflows/versioning.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/versioning.yaml b/.github/workflows/versioning.yaml index f37156aa..eac93cce 100644 --- a/.github/workflows/versioning.yaml +++ b/.github/workflows/versioning.yaml @@ -28,7 +28,7 @@ jobs: with: python-version: 3.12 - name: Build changelog - run: pip install yaml-changelog && make changelog + run: pip install yaml-changelog towncrier && make changelog - name: Preview changelog update run: ".github/get-changelog-diff.sh" - name: Update changelog From dff6bfe8c6243f3c14b5f8031528900cf6ad8568 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 24 Feb 2026 21:07:53 +0100 Subject: [PATCH 9/9] ci: Restore comment for 0.x branch trigger Co-Authored-By: Claude Opus 4.6 --- .github/workflows/versioning.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/versioning.yaml b/.github/workflows/versioning.yaml index eac93cce..1b5fa912 100644 --- a/.github/workflows/versioning.yaml +++ b/.github/workflows/versioning.yaml @@ -5,7 +5,7 @@ on: push: branches: - main - - '0.x' + - '0.x' # Maintenance branch for legacy 0.x releases paths: - changelog_entry.yaml