From 7700c9f314609da640f901ebdfff63d0959e2074 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 22 Oct 2026 13:06:37 -0400 Subject: [PATCH 1/2] Return decile relative changes as fractions policyengine.py computes decile relative_change as a percentage (100 * absolute_change / baseline_mean). The executor copied it into DecileOutput.relative unchanged, so decile.relative and wealth_decile.relative reached the API 100x too large. Divide by 100 so they keep the fraction contract that the previous API and the app use. Co-Authored-By: Claude Opus 5.5 --- .../test_simulation_output_builder.py | 6 +++--- .../simulation_output_distribution.py | 6 ++++-- .../tests/test_simulation_output_builder.py | 21 +++++++++++++++++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/projects/policyengine-simulation-executor/fixtures/test_simulation_output_builder.py b/projects/policyengine-simulation-executor/fixtures/test_simulation_output_builder.py index 14f6c642a..a48bfd7e8 100644 --- a/projects/policyengine-simulation-executor/fixtures/test_simulation_output_builder.py +++ b/projects/policyengine-simulation-executor/fixtures/test_simulation_output_builder.py @@ -48,12 +48,12 @@ def fake_analysis() -> SimpleNamespace: { "decile": 2, "absolute_change": 20.0, - "relative_change": 0.02, + "relative_change": 2.0, }, { "decile": 1, "absolute_change": 10.0, - "relative_change": 0.01, + "relative_change": 1.0, }, ] ), @@ -62,7 +62,7 @@ def fake_analysis() -> SimpleNamespace: { "decile": 1, "absolute_change": 30.0, - "relative_change": 0.03, + "relative_change": 3.0, } ] ), diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_distribution.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_distribution.py index ed027c1c6..e5ac29b81 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_distribution.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_distribution.py @@ -34,7 +34,9 @@ def build_decile(analysis: object) -> DecileOutput: def build_decile_output(collection: object) -> DecileOutput: """Key absolute and relative changes by decile number (``"1"``..``"10"``). - Rows with a decile of 0 or below are dropped; missing values become 0.0. + policyengine.py reports ``relative_change`` as a percentage; ``relative`` is + a fraction, so it is divided by 100. Rows with a decile of 0 or below are + dropped; missing values become 0.0. """ if isinstance(collection, DecileOutput): return collection @@ -49,7 +51,7 @@ def build_decile_output(collection: object) -> DecileOutput: continue key = str(decile) average[key] = _number(row.get("absolute_change")) - relative[key] = _number(row.get("relative_change")) + relative[key] = _number(row.get("relative_change")) / 100 return DecileOutput(average=average, relative=relative) diff --git a/projects/policyengine-simulation-executor/tests/test_simulation_output_builder.py b/projects/policyengine-simulation-executor/tests/test_simulation_output_builder.py index 162c7620a..b693c7009 100644 --- a/projects/policyengine-simulation-executor/tests/test_simulation_output_builder.py +++ b/projects/policyengine-simulation-executor/tests/test_simulation_output_builder.py @@ -25,6 +25,7 @@ REFORM_POVERTY_BY_AGE, REFORM_POVERTY_BY_GENDER, REFORM_POVERTY_BY_RACE, + FakeCollection, FakeModelOutput, fake_analysis, ) @@ -61,6 +62,9 @@ from policyengine_simulation_executor.simulation_output_builder import ( SimulationOutputBuilder, ) +from policyengine_simulation_executor.simulation_output_distribution import ( + build_decile_output, +) from policyengine_simulation_executor.simulation_output_geographic import ( build_congressional_district_impact, build_congressional_district_impact_output, @@ -953,6 +957,23 @@ def serialize(runtime): ) +def test_decile_relative_is_a_fraction_of_the_baseline_mean(): + # policyengine.py reports relative_change as a percentage (1.1254 is + # 1.1254%); the API's relative field is a fraction (0.011254). + output = build_decile_output( + FakeCollection( + [ + {"decile": 1, "absolute_change": 180.7, "relative_change": 1.1254}, + {"decile": 10, "absolute_change": 1410.3, "relative_change": 0.8227}, + {"decile": 2, "absolute_change": 0.0, "relative_change": None}, + ] + ) + ) + + assert output.average == {"1": 180.7, "2": 0.0, "10": 1410.3} + assert output.relative == pytest.approx({"1": 0.011254, "2": 0.0, "10": 0.008227}) + + def test_builder_maps_uk_wealth_outputs_and_omits_us_only_race(monkeypatch): output = _build_schema_output(monkeypatch, country="uk").model_dump(mode="json") From 3b19937496f078c25bd25a6aa00f2498265dd77a Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 22 Oct 2026 15:42:14 -0400 Subject: [PATCH 2/2] Test decile outputs on policyengine.py's own decile calculation The earlier test fed hand-written relative_change rows, so it only checked the division by 100. This test runs policyengine.py's calculate_decile_impacts on a small in-memory baseline and reform (every household gains 1%) and checks that build_decile_output returns absolute_change / baseline_mean, for income deciles and for a precomputed (wealth) decile variable. If policyengine.py changes the units of relative_change, it fails. Co-Authored-By: Claude Opus 5.5 --- .../test_simulation_output_distribution.py | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 projects/policyengine-simulation-executor/tests/test_simulation_output_distribution.py diff --git a/projects/policyengine-simulation-executor/tests/test_simulation_output_distribution.py b/projects/policyengine-simulation-executor/tests/test_simulation_output_distribution.py new file mode 100644 index 000000000..6ad4692ad --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_simulation_output_distribution.py @@ -0,0 +1,62 @@ +"""Decile outputs built from policyengine.py's own decile calculation.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from fixtures.weighted_frames import weighted_entity_frame +from policyengine.core import Simulation +from policyengine.outputs.decile_impact import calculate_decile_impacts +from policyengine_simulation_executor.simulation_output_distribution import ( + build_decile_output, +) + +BASELINE_INCOMES = [10_000.0, 20_000.0, 30_000.0, 40_000.0] + + +class _InMemorySimulation(Simulation): + """A simulation whose output is already in memory, so nothing runs.""" + + def ensure(self) -> None: + return None + + +def _simulation(incomes: list[float]) -> Simulation: + household = weighted_entity_frame( + { + "household_net_income": incomes, + "household_count_people": [1.0, 2.0, 1.0, 3.0], + "household_wealth_decile": [1, 1, 2, 2], + "household_weight": [1.0, 2.0, 3.0, 4.0], + } + ) + return _InMemorySimulation.model_construct( + output_dataset=SimpleNamespace(data=SimpleNamespace(household=household)), + tax_benefit_model_version=None, + ) + + +@pytest.mark.parametrize("decile_variable", [None, "household_wealth_decile"]) +def test_decile_relative_is_the_fraction_policyengine_computes(decile_variable): + # Every household gains 1%, so each decile's relative change is 0.01. + # policyengine.py's relative_change is a percentage (1.0 here); the + # API's relative field is a fraction. Running policyengine.py's own + # calculation, not hand-written rows, catches a change of its units. + impacts = calculate_decile_impacts( + baseline_simulation=_simulation(BASELINE_INCOMES), + reform_simulation=_simulation([income * 1.01 for income in BASELINE_INCOMES]), + decile_variable=decile_variable, + entity="household", + quantiles=2, + ) + + output = build_decile_output(impacts) + + rows = impacts.dataframe.set_index("decile") + assert set(output.relative) == {"1", "2"} + for decile, relative in output.relative.items(): + row = rows.loc[int(decile)] + assert relative == pytest.approx(row["absolute_change"] / row["baseline_mean"]) + assert relative == pytest.approx(0.01) + assert output.average[decile] == pytest.approx(row["absolute_change"])