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.d/642.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The UK Budget reform contract's population rows no longer report a count of survey records from 1 to 9 (it reads "fewer than 10"), or a total over fewer than 10 changed records, and they state each condition they test as a flat true/false finding in `detail`.
147 changes: 124 additions & 23 deletions src/policyengine/tax_benefit_models/uk/budget_reform_population.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,32 @@
The last two are responsiveness checks: a reform that reaches the parameter
but not the award (as when an award is fixed before the reform applies)
fails them.

Detail
------

Each row's ``detail`` states every condition the check tests as a flat
true/false finding (``no_award_lower``, ``some_pension_higher``,
``income_tax:2027_matches`` and so on), so a failed check can be diagnosed
from the findings alone. A row passes exactly when its findings all hold;
one whose name ends ``_informational`` is reported for diagnosis and is not
a condition. Counts and totals follow the findings for local reruns.

The subsample's households are survey records, so the counts and totals
also follow the small-cell rule: no count from 1 to 9 (it reads ``"fewer
than 10"``), and no total over fewer than 10 changed records. The rule
covers counts and totals only, not an error's message or the difference
between two rows' figures (two routes' rows share a subsample), so the raw
report is not for publication. The qualification ledger publishes from it
only the fixed row fields and the top-level findings; the rule here keeps
raw copies (test fixtures, local runs) free of small counts too. A detail
that cannot be checked turns its row into an error, so the report does not
hold.
"""

from __future__ import annotations

import numbers
import os
import tempfile
import traceback
Expand All @@ -75,6 +97,57 @@
)
RESPONSE_YEAR = 2027

#: The small-cell rule: a count of survey records from 1 to 9 is never
#: published, nor a total over that many.
SMALL_CELL_LIMIT = 10
FEWER_THAN_10 = "fewer than 10"
WITHHELD_FEWER_THAN_10 = "withheld: fewer than 10 records"


def is_small_cell(count: int) -> bool:
return 0 < count < SMALL_CELL_LIMIT


def publishable_detail(detail: Any) -> Any:
"""``detail`` with every count from 1 to 9, at any depth, suppressed.

Booleans are findings, not counts, and stay as they are. A detail holds
only booleans, integers, floats, text, None, and lists or text-keyed
mappings of them; anything else (an array, a set) is an error rather
than passed through unchecked.
"""
if isinstance(detail, (bool, np.bool_)):
return bool(detail)
if isinstance(detail, numbers.Integral):
return FEWER_THAN_10 if is_small_cell(int(detail)) else int(detail)
if isinstance(detail, np.floating):
return float(detail)
if detail is None or isinstance(detail, (float, str)):
return detail
if isinstance(detail, dict) and all(isinstance(key, str) for key in detail):
return {key: publishable_detail(value) for key, value in detail.items()}
if isinstance(detail, (list, tuple)):
return [publishable_detail(value) for value in detail]
raise TypeError(f"A population detail cannot hold {type(detail).__name__}.")


def total_over_records(total: float, *records: int) -> float | str:
"""A total, withheld when any count of the records behind it is 1 to 9.

Pass each count that bounds how few records the total reflects: those
that moved at all, and those that moved by more than a negligible amount
(a few large changes among negligible ones are still a few records).
"""
if any(is_small_cell(count) for count in records):
return WITHHELD_FEWER_THAN_10
return total


def matches(names: Sequence[str], different: Sequence[str]) -> dict[str, bool]:
"""One ``<name>_matches`` finding per compared output."""
return {f"{name}_matches": name not in different for name in names}


POPULATION_BASELINE_ISOLATION = "population_baseline_isolation"
POPULATION_QUERY_ORDER = "population_query_order"
POPULATION_ROUTES_AGREE = "population_routes_agree"
Expand Down Expand Up @@ -223,18 +296,22 @@ def uc_allowance_verdict(
for state_paid, state_award in ((base_paid, base_award), (paid, award))
)
)
holds = (
non_finite == 0
and scaled
and awards_lower == 0
and payments_unaccounted == 0
and payments_below_floor == 0
and payments_higher > 0
)
return holds, {
payments_lower = int(np.sum(paid < base_paid - PENNY))
findings = {
"standard_allowance_scaled_10_percent": scaled,
"no_award_lower": awards_lower == 0,
"payments_are_award_less_deductions": payments_unaccounted == 0,
"no_payment_below_the_payable_floor": payments_below_floor == 0,
"some_payment_higher": payments_higher > 0,
"no_non_finite_units": non_finite == 0,
}
holds = all(findings.values())
return holds, {
**findings,
# Lawful (deductions can rise more than the award), so not a condition.
"no_payment_lower_informational": payments_lower == 0,
"awards_lower": awards_lower,
"payments_lower": int(np.sum(paid < base_paid - PENNY)),
"payments_lower": payments_lower,
"payments_higher": payments_higher,
"payments_not_award_less_deductions": payments_unaccounted,
"payments_below_the_payable_floor": payments_below_floor,
Expand Down Expand Up @@ -380,7 +457,7 @@ def build(route: str | None, reform: dict | None):
POPULATION_BASELINE_ISOLATION,
route,
"fail" if different else "pass",
detail=different or None,
detail=matches(list(before), different),
)
)
after = _arrays(build(None, None))
Expand All @@ -390,7 +467,7 @@ def build(route: str | None, reform: dict | None):
POPULATION_BASELINE_ISOLATION,
"unreformed",
"fail" if different else "pass",
detail=different or None,
detail=matches(list(before), different),
)
)
different = _same(before, _arrays(build(None, None), order=-1))
Expand All @@ -399,7 +476,7 @@ def build(route: str | None, reform: dict | None):
POPULATION_QUERY_ORDER,
"unreformed",
"fail" if different else "pass",
detail=different or None,
detail=matches(list(before), different),
)
)
tax = {
Expand All @@ -411,15 +488,18 @@ def build(route: str | None, reform: dict | None):
}
first = country[0]
for route in country[1:]:
same = all(
np.array_equal(tax[route][year], tax[first][year])
agree = {
f"income_tax:{year}_matches": bool(
np.array_equal(tax[route][year], tax[first][year])
)
for year in (2026, 2027)
)
}
rows.append(
_row(
POPULATION_ROUTES_AGREE,
f"{first} = {route}",
"pass" if same else "fail",
"pass" if all(agree.values()) else "fail",
detail=agree,
)
)
for route in country:
Expand All @@ -431,30 +511,42 @@ def build(route: str | None, reform: dict | None):
# is on the total, and the counts are reported.
total_rises = bool(np.sum(change_2027) > 1e-6)
ok = unchanged_2026 and total_rises
lower = int(np.sum(change_2027 < -1e-6))
higher = int(np.sum(change_2027 > 1e-6))
rows.append(
_row(
POPULATION_APRIL_RATE_CHANGE,
route,
"pass" if ok else "fail",
detail={
"2026_unchanged": unchanged_2026,
"2027_total_change": float(np.sum(change_2027)),
"2027_people_with_lower_tax": int(np.sum(change_2027 < -1e-6)),
"2027_people_with_higher_tax": int(np.sum(change_2027 > 1e-6)),
"2027_total_rises": total_rises,
# Withheld by the records that moved at all, which
# the total sums, and by those past the threshold.
"2027_total_change": total_over_records(
float(np.sum(change_2027)),
int(np.count_nonzero(change_2027)),
lower + higher,
),
"2027_people_with_lower_tax": lower,
"2027_people_with_higher_tax": higher,
},
)
)
for route in country:
simulation = build(route, FINITE)
same = np.array_equal(
np.asarray(simulation.calculate("income_tax", 2028), dtype=float),
before["income_tax:2028"],
same = bool(
np.array_equal(
np.asarray(simulation.calculate("income_tax", 2028), dtype=float),
before["income_tax:2028"],
)
)
rows.append(
_row(
POPULATION_FINITE_CHANGE_EXPIRES,
route,
"pass" if same else "fail",
detail={"income_tax:2028_matches": same},
)
)

Expand Down Expand Up @@ -506,6 +598,8 @@ def values(simulation, variable):
"pass" if scaled and lower == 0 and higher > 0 else "fail",
detail={
"basic_and_new_scaled_10_percent": bool(scaled),
"no_pension_lower": lower == 0,
"some_pension_higher": higher > 0,
"pensions_lower": lower,
"pensions_higher": higher,
},
Expand All @@ -516,12 +610,19 @@ def values(simulation, variable):
_row(
*POPULATION_ERROR_DIAGNOSTIC,
"error",
reason=type(error).__name__,
detail="".join(
traceback.format_exception_only(type(error), error)
).strip()[:1000],
)
)
for row in rows:
if isinstance(row.get("detail"), dict):
try:
row["detail"] = publishable_detail(row["detail"])
except TypeError as error:
# Fail closed: drop what cannot be checked, and the row errs.
row.update(status="error", reason=type(error).__name__, detail=None)
row.setdefault("subsample_households", SUBSAMPLE_HOUSEHOLDS)
row.setdefault("seed", seed)
return rows
Loading
Loading