Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog_entry.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]]):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"baseline": 100.0,
"reform": 125.0,
"difference": 25.0,
"is_tax": True,
}
},
"decile": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -1196,6 +1196,17 @@
"difference": {
"type": "number",
"title": "Difference"
},
"is_tax": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"title": "Is Tax"
}
},
"additionalProperties": false,
Expand All @@ -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": {
Expand Down
Loading