diff --git a/changelog_entry.yaml b/changelog_entry.yaml index 2ab341e29..4e43d0112 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -1,6 +1,7 @@ - bump: patch changes: added: + - Added is_tax to each detailed_budget program (optional, null in outputs built before it), copied from policyengine.py's program statistics, so consumers sign a program's change as tax revenue or benefit spending without a list of their own; a UK benefit that has no row in the app's list, such as Scottish Child Payment, was otherwise drawn as a tax - Added the rule sim.dependencies.uv-version-pinned, checked by a new uv-version quality guard with proof fixtures taken from the unpinned setup-uv step and the ghcr.io/astral-sh/uv:latest copy on main; every astral-sh/setup-uv step, uv container image reference, Modal uv_sync or uv_pip_install call, uv install script or release download, pip, pipx or uv tool install of uv, and uv self update must pin the one uv release written in the new .uv-version file, 0.13.6 - Added the rule sim.quality.python-file-length, checked by a new file-length quality guard with proof fixtures that its test writes to a temporary directory: a tracked Python file has at most 800 lines, or at most 1,500 if it is test code (a file under a tests or fixtures directory, or a conftest.py); the 14 files over their limit on main are listed in a baseline that may only shrink, so the rule starts ratcheted, with a downgrade section, authorized by the maintainer, whose tracking issue closes when the rule is enforced - Added breakdown "local_areas" to the simulation request for UK requests whose region is absent, uk or one of country/england, country/scotland, country/wales and country/northern_ireland, refused with 422 for other regions (including country/ni), other countries, include_cliffs=true, segmented=true or a region_group (include_cliffs=false stays accepted); the job runs one baseline and reform on the local-area dataset alone, with the region's filter, and returns a LocalAreaBreakdownOutput (kind local_areas, model_version, policyengine_version, dataset, data_version, and constituency_impact and local_authority_impact as lists of their own record types), which the job status result now accepts alongside SingleYearMacroOutput diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/macro_output.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/macro_output.py index 674992ace..7ec8bc815 100644 --- a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/macro_output.py +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/macro_output.py @@ -45,12 +45,15 @@ class BudgetaryImpact(MacroOutputModel): class DetailedBudgetProgramOutput(MacroOutputModel): """Baseline and reform totals for one program. - ``difference`` is reform minus baseline. + ``difference`` is reform minus baseline. ``is_tax`` says whether the + program is a tax (its rise adds to the budget) or a benefit (its rise is + spending); it is None in outputs built before the flag was recorded. """ baseline: float reform: float difference: float + is_tax: bool | None = None class DetailedBudgetOutput(MacroRootModel[dict[str, DetailedBudgetProgramOutput]]): diff --git a/projects/policyengine-simulation-executor/fixtures/macro_output.py b/projects/policyengine-simulation-executor/fixtures/macro_output.py index 228a5ec58..404296b5b 100644 --- a/projects/policyengine-simulation-executor/fixtures/macro_output.py +++ b/projects/policyengine-simulation-executor/fixtures/macro_output.py @@ -52,6 +52,7 @@ "baseline": 100.0, "reform": 125.0, "difference": 25.0, + "is_tax": True, } }, "decile": { 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 a48bfd7e8..888b0c870 100644 --- a/projects/policyengine-simulation-executor/fixtures/test_simulation_output_builder.py +++ b/projects/policyengine-simulation-executor/fixtures/test_simulation_output_builder.py @@ -37,6 +37,7 @@ def fake_analysis() -> SimpleNamespace: [ { "program_name": "income_tax", + "is_tax": True, "baseline_total": 100.0, "reform_total": 125.0, "change": 25.0, diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_budget.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_budget.py index b188fa230..ed7407536 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_budget.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_budget.py @@ -37,7 +37,9 @@ def build_detailed_budget(analysis: object) -> DetailedBudgetOutput: """Return per-program baseline, reform and difference totals from the analysis. Rows without a program name are skipped, and a missing or non-finite - ``change`` falls back to reform minus baseline. + ``change`` falls back to reform minus baseline. Each program carries the + analysis's ``is_tax`` flag, so consumers sign it without a list of their + own; it is None when the row has no flag. """ collection = getattr(analysis, "program_statistics", None) if isinstance(collection, DetailedBudgetOutput): @@ -53,10 +55,19 @@ def build_detailed_budget(analysis: object) -> DetailedBudgetOutput: baseline=baseline, reform=reform, difference=_number(row.get("change"), reform - baseline), + is_tax=_is_tax(row.get("is_tax")), ) return DetailedBudgetOutput(detailed_budget) +def _is_tax(value: object) -> bool | None: + """Return a row's ``is_tax`` flag as a bool, or None when the row has none.""" + item = getattr(value, "item", None) + if callable(item): + value = item() # a numpy scalar + return value if isinstance(value, bool) else None + + def _require_plausible_households(country: str, households: float) -> None: """Refuse a household count no real run of the country could produce. diff --git a/projects/policyengine-simulation-executor/tests/test_simulation_output_budget.py b/projects/policyengine-simulation-executor/tests/test_simulation_output_budget.py index c1c81378c..d61140bdb 100644 --- a/projects/policyengine-simulation-executor/tests/test_simulation_output_budget.py +++ b/projects/policyengine-simulation-executor/tests/test_simulation_output_budget.py @@ -10,6 +10,7 @@ from fixtures.weighted_frames import weighted_entity_frame from policyengine_simulation_executor.simulation_output_budget import ( build_budgetary_impact, + build_detailed_budget, ) COMMON_LOGGER = "policyengine_simulation_executor.simulation_output_common" @@ -111,3 +112,63 @@ def test_change_totals_accept_weights_after_a_float32_round_trip( 50.0 - 40.0 ) assert budget.tax_revenue_impact == pytest.approx(expected_change) + + +def _program_statistics(rows: list[dict]) -> SimpleNamespace: + """Build an analysis whose program statistics are the given rows.""" + return SimpleNamespace(program_statistics=rows) + + +def test_detailed_budget_carries_each_programs_tax_flag(): + # Report 4738: Universal Credit and Scottish Child Payment are benefits, so + # consumers sign them as spending without a list of their own. + analysis = _program_statistics( + [ + { + "program_name": "universal_credit", + "is_tax": False, + "baseline_total": 82_244.8e6, + "reform_total": 81_311.3e6, + "change": -933.5e6, + }, + { + "program_name": "scottish_child_payment", + "is_tax": False, + "baseline_total": 600e6, + "reform_total": 575e6, + "change": -25e6, + }, + { + "program_name": "income_tax", + "is_tax": True, + "baseline_total": 300e9, + "reform_total": 300e9, + "change": 0.0, + }, + ] + ) + + budget = build_detailed_budget(analysis).root + + assert budget["universal_credit"].is_tax is False + assert budget["scottish_child_payment"].is_tax is False + assert budget["scottish_child_payment"].difference == -25e6 + assert budget["income_tax"].is_tax is True + + +def test_detailed_budget_leaves_the_tax_flag_unset_when_rows_have_none(): + analysis = _program_statistics( + [ + { + "program_name": "universal_credit", + "baseline_total": 2.0, + "reform_total": 1.0, + "change": -1.0, + } + ] + ) + + budget = build_detailed_budget(analysis).root + + assert budget["universal_credit"].is_tax is None + assert budget["universal_credit"].model_dump()["is_tax"] is None diff --git a/projects/policyengine-simulation-gateway/tests/golden/openapi.json b/projects/policyengine-simulation-gateway/tests/golden/openapi.json index 715cd2c5a..abe6a6441 100644 --- a/projects/policyengine-simulation-gateway/tests/golden/openapi.json +++ b/projects/policyengine-simulation-gateway/tests/golden/openapi.json @@ -1196,6 +1196,17 @@ "difference": { "type": "number", "title": "Difference" + }, + "is_tax": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Is Tax" } }, "additionalProperties": false, @@ -1206,7 +1217,7 @@ "difference" ], "title": "DetailedBudgetProgramOutput", - "description": "Baseline and reform totals for one program.\n\n``difference`` is reform minus baseline." + "description": "Baseline and reform totals for one program.\n\n``difference`` is reform minus baseline. ``is_tax`` says whether the\nprogram is a tax (its rise adds to the budget) or a benefit (its rise is\nspending); it is None in outputs built before the flag was recorded." }, "GenderPovertyOutput": { "properties": {