From 2ac3c221ab7c1c6027bf5e786abd66a0d048a6ad Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 10:40:14 -0400 Subject: [PATCH 1/4] Move legacy-benefit reporters onto Universal Credit at Move to UC claim rates Every working-age legacy means-tested benefit has closed or is closing, but would_claim_uc is drawn without regard to legacy receipt: a legacy reporter that drew it false lost its legacy award and got no Universal Credit. Draw would_claim_uc_at_legacy_closure, which policyengine-uk reads once a legacy benefit the unit reports has closed, at DWP's Move to Universal Credit claim rates by combination of legacy benefits (Stat-Xplore table MtUC Households 3, notices to end of March 2026). Units reporting UC, and units with no legacy benefit, are true (the model default). The draw has its own seeded generator, so the seed=100 sequence behind every other flag is unchanged; SPI-synthetic rows are redrawn after stage 2 rewrites their receipt. would_claim_uc itself is untouched, so 2024-25 legacy receipt is unchanged. Fixes #492. Co-Authored-By: Claude Opus 5.5 --- .../uc-managed-migration-takeup.fixed.md | 1 + policyengine_uk_data/datasets/frs.py | 22 ++- .../datasets/imputations/income.py | 20 ++ policyengine_uk_data/parameters/__init__.py | 16 ++ .../take_up/uc_managed_migration.yaml | 42 ++++ .../tests/test_uc_managed_migration_takeup.py | 181 ++++++++++++++++++ policyengine_uk_data/utils/takeup.py | 79 ++++++++ pyproject.toml | 1 + uv.lock | 73 ++++++- 9 files changed, 433 insertions(+), 2 deletions(-) create mode 100644 changelog.d/uc-managed-migration-takeup.fixed.md create mode 100644 policyengine_uk_data/parameters/take_up/uc_managed_migration.yaml create mode 100644 policyengine_uk_data/tests/test_uc_managed_migration_takeup.py diff --git a/changelog.d/uc-managed-migration-takeup.fixed.md b/changelog.d/uc-managed-migration-takeup.fixed.md new file mode 100644 index 000000000..8d8ca1686 --- /dev/null +++ b/changelog.d/uc-managed-migration-takeup.fixed.md @@ -0,0 +1 @@ +Draw `would_claim_uc_at_legacy_closure` for benefit units that report a legacy means-tested benefit but not Universal Credit, at DWP's Move to Universal Credit claim rates for their combination of legacy benefits (Stat-Xplore, notices to end of March 2026). policyengine-uk reads it once one of those benefits closes, so a legacy reporter whose `would_claim_uc` draw was false now moves to Universal Credit at the observed rate instead of losing its legacy award with nothing in its place. Units reporting Universal Credit, and units with no legacy benefit, are true. The draw has its own seeded generator, so every other take-up flag is unchanged, and SPI-synthetic rows are redrawn after stage-2 imputation rewrites their benefit receipt (#492). diff --git a/policyengine_uk_data/datasets/frs.py b/policyengine_uk_data/datasets/frs.py index e440d4869..91ff52df1 100644 --- a/policyengine_uk_data/datasets/frs.py +++ b/policyengine_uk_data/datasets/frs.py @@ -32,7 +32,11 @@ fill_with_mean, STORAGE_FOLDER, ) -from policyengine_uk_data.parameters import load_take_up_rate, load_parameter +from policyengine_uk_data.parameters import ( + load_parameter, + load_take_up_rate, + load_uc_managed_migration_claim_rates, +) from policyengine_uk_data.datasets.childcare.assumptions import ( EXTENDED_HOURS_MEAN, EXTENDED_HOURS_SD, @@ -1508,7 +1512,9 @@ def determine_education_level(fted_val, typeed2_val, age_val): # certainty; the remaining non-reporters are filled probabilistically to # hit the aggregate target rate. See policyengine_uk_data/utils/takeup.py. from policyengine_uk_data.utils.takeup import ( + UC_MANAGED_MIGRATION_SEED, assign_takeup_with_reported_anchors, + assign_uc_claim_at_legacy_closure, ) def _reported_benunit_mask(person_column: str) -> np.ndarray: @@ -1542,6 +1548,20 @@ def _reported_benunit_mask(person_column: str) -> np.ndarray: universal_credit_rate, reported_mask=_reported_benunit_mask("universal_credit_reported"), ) + # Whether a legacy-benefit family claims Universal Credit once its legacy + # benefits close, at DWP's Move to Universal Credit claim rates. Its own + # generator keeps every other draw on the seed=100 sequence. Checked + # because the dataset loader drops columns the model does not define. + require_variable( + "would_claim_uc_at_legacy_closure", + "Move to Universal Credit claim flag", + ) + pe_benunit["would_claim_uc_at_legacy_closure"] = assign_uc_claim_at_legacy_closure( + pe_person, + pe_benunit, + load_uc_managed_migration_claim_rates(), + seed=UC_MANAGED_MIGRATION_SEED, + ) pe_benunit["would_claim_tfc"] = generator.random(len(pe_benunit)) < tfc_rate pe_benunit["would_claim_extended_childcare"] = ( diff --git a/policyengine_uk_data/datasets/imputations/income.py b/policyengine_uk_data/datasets/imputations/income.py index feafcb27c..9db8c40c0 100644 --- a/policyengine_uk_data/datasets/imputations/income.py +++ b/policyengine_uk_data/datasets/imputations/income.py @@ -291,6 +291,26 @@ def impute_income(dataset: UKSingleYearDataset) -> UKSingleYearDataset: train_dataset=dataset, target_dataset=zero_weight_copy, ) + # Stage 2 rewrote these rows' legacy benefit and Universal Credit + # receipt, so redraw the Move to Universal Credit claim flag that + # create_frs drew from their donors' receipt. + if "would_claim_uc_at_legacy_closure" in zero_weight_copy.benunit.columns: + from policyengine_uk_data.parameters import ( + load_uc_managed_migration_claim_rates, + ) + from policyengine_uk_data.utils.takeup import ( + UC_MANAGED_MIGRATION_SPI_SEED, + assign_uc_claim_at_legacy_closure, + ) + + zero_weight_copy.benunit["would_claim_uc_at_legacy_closure"] = ( + assign_uc_claim_at_legacy_closure( + zero_weight_copy.person, + zero_weight_copy.benunit, + load_uc_managed_migration_claim_rates(), + seed=UC_MANAGED_MIGRATION_SPI_SEED, + ) + ) dataset = impute_over_incomes( dataset, diff --git a/policyengine_uk_data/parameters/__init__.py b/policyengine_uk_data/parameters/__init__.py index 06f3b5582..7aa2ab427 100644 --- a/policyengine_uk_data/parameters/__init__.py +++ b/policyengine_uk_data/parameters/__init__.py @@ -62,3 +62,19 @@ def load_take_up_rate(variable_name: str, year: int = 2015) -> float: Take-up rate as a float between 0 and 1 """ return load_parameter("take_up", variable_name, year) + + +def load_uc_managed_migration_claim_rates() -> dict[str, float]: + """Load Move to Universal Credit claim rates by legacy benefit combination. + + Returns: + Claim rate for each combination in ``take_up/uc_managed_migration.yaml`` + (benefit names joined by "+", plus "all"): households that claimed + Universal Credit over those that claimed or did not claim. + """ + with open(PARAMETERS_DIR / "take_up" / "uc_managed_migration.yaml") as f: + households = yaml.safe_load(f)["households"] + return { + combination: counts["claimed"] / (counts["claimed"] + counts["did_not_claim"]) + for combination, counts in households.items() + } diff --git a/policyengine_uk_data/parameters/take_up/uc_managed_migration.yaml b/policyengine_uk_data/parameters/take_up/uc_managed_migration.yaml new file mode 100644 index 000000000..9b6b2d938 --- /dev/null +++ b/policyengine_uk_data/parameters/take_up/uc_managed_migration.yaml @@ -0,0 +1,42 @@ +description: >- + Households sent a Move to Universal Credit migration notice in Great Britain + (July 2022 to end of March 2026), by the legacy benefits they received and + whether they claimed Universal Credit. The claim rate for a combination is + claimed / (claimed + did_not_claim): the 538 households still in progress + are left out. Combinations DWP does not report use the all-households row. + "esa_income" is Stat-Xplore's "Employment Support Allowance"; notices go only + to claimants of an existing benefit, which is income-related ESA. +metadata: + unit: /1 + label: Move to Universal Credit claim rate by legacy benefit combination + reference: + - title: >- + DWP Stat-Xplore, "Households invited to Move to Universal Credit", + table "MtUC Households 3 - Migration notices by legacy benefit and + move status" (retrieved 2026-10-03) + href: https://stat-xplore.dwp.gov.uk + - title: "DWP: Move to Universal Credit, July 2022 to end March 2026 (12 May 2026)" + href: https://www.gov.uk/government/statistics/move-to-universal-credit-july-2022-to-end-march-2026 +# Benefit names follow the order child_tax_credit, working_tax_credit, +# housing_benefit, esa_income, income_support, jsa_income, joined by "+". +households: + child_tax_credit: {claimed: 36_723, did_not_claim: 17_733} + working_tax_credit: {claimed: 53_993, did_not_claim: 51_805} + child_tax_credit+working_tax_credit: {claimed: 363_984, did_not_claim: 126_397} + housing_benefit: {claimed: 30_900, did_not_claim: 10_121} + esa_income: {claimed: 321_075, did_not_claim: 13_944} + income_support: {claimed: 17_719, did_not_claim: 1_351} + jsa_income: {claimed: 5_867, did_not_claim: 329} + child_tax_credit+housing_benefit: {claimed: 15_646, did_not_claim: 1_042} + child_tax_credit+esa_income: {claimed: 11_171, did_not_claim: 381} + child_tax_credit+income_support: {claimed: 8_254, did_not_claim: 276} + child_tax_credit+jsa_income: {claimed: 381, did_not_claim: 8} + working_tax_credit+housing_benefit: {claimed: 7_820, did_not_claim: 1_042} + housing_benefit+esa_income: {claimed: 448_253, did_not_claim: 9_117} + housing_benefit+income_support: {claimed: 31_087, did_not_claim: 750} + housing_benefit+jsa_income: {claimed: 11_019, did_not_claim: 333} + child_tax_credit+working_tax_credit+housing_benefit: {claimed: 81_962, did_not_claim: 5_352} + child_tax_credit+housing_benefit+esa_income: {claimed: 73_859, did_not_claim: 949} + child_tax_credit+housing_benefit+income_support: {claimed: 58_764, did_not_claim: 691} + child_tax_credit+housing_benefit+jsa_income: {claimed: 2_286, did_not_claim: 30} + all: {claimed: 1_580_761, did_not_claim: 241_662} diff --git a/policyengine_uk_data/tests/test_uc_managed_migration_takeup.py b/policyengine_uk_data/tests/test_uc_managed_migration_takeup.py new file mode 100644 index 000000000..796ef8d4e --- /dev/null +++ b/policyengine_uk_data/tests/test_uc_managed_migration_takeup.py @@ -0,0 +1,181 @@ +"""Tests for the Move to Universal Credit claim flag. + +``would_claim_uc_at_legacy_closure`` says whether a benefit unit claims +Universal Credit once a legacy benefit it reports closes (policyengine-uk +``legacy_benefits_closed``). Invariants: + +1. Source: every combination is a "+"-join of LEGACY_BENEFITS in order, each + rate is claimed / (claimed + did_not_claim) and lies in (0, 1), and the + combinations add up to DWP's all-households row. +2. Every benefit unit reporting Universal Credit is True. +3. Every benefit unit reporting no legacy benefit is True, the model's + default, so the column changes nothing outside the legacy cohorts. +4. A legacy reporter not on Universal Credit is True exactly when its draw is + below its combination's rate ("all" for combinations DWP does not report). +5. Each cohort's share lands within sampling error of its rate. +6. The draw is deterministic, has its own generator and leaves NumPy's global + random state alone. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest +import yaml +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine_uk_data.parameters import ( + PARAMETERS_DIR, + load_uc_managed_migration_claim_rates, +) +from policyengine_uk_data.utils.takeup import ( + LEGACY_BENEFITS, + UC_MANAGED_MIGRATION_SEED, + assign_uc_claim_at_legacy_closure, + legacy_benefit_combination, +) + +RATES = load_uc_managed_migration_claim_rates() +REPORTED = [f"{benefit}_reported" for benefit in LEGACY_BENEFITS] + [ + "universal_credit_reported" +] + + +def frames(units: list[list[dict]]) -> tuple[pd.DataFrame, pd.DataFrame]: + """Person and benefit unit tables from a list of units of people.""" + rows = [ + {"person_benunit_id": 10 + i, **{c: person.get(c, 0.0) for c in REPORTED}} + for i, people in enumerate(units) + for person in people + ] + person = pd.DataFrame(rows, columns=["person_benunit_id", *REPORTED]) + # Benefit unit ids out of order, to catch positional mix-ups. + benunit = pd.DataFrame({"benunit_id": [10 + i for i in range(len(units))]}) + return person, benunit.iloc[::-1].reset_index(drop=True) + + +people = st.lists( + st.fixed_dictionaries( + {c: st.sampled_from([0.0, 0.0, 0.0, 50.0, 3_000.0]) for c in REPORTED} + ), + min_size=1, + max_size=3, +) + + +def test_source_combinations_and_rates(): + with open(PARAMETERS_DIR / "take_up" / "uc_managed_migration.yaml") as f: + households = yaml.safe_load(f)["households"] + assert set(households) == set(RATES) + totals = {"claimed": 0, "did_not_claim": 0} + for combination, counts in households.items(): + rate = counts["claimed"] / (counts["claimed"] + counts["did_not_claim"]) + assert RATES[combination] == rate and 0 < rate < 1 + if combination == "all": + continue + benefits = combination.split("+") + assert benefits == [b for b in LEGACY_BENEFITS if b in benefits] + for key in totals: + totals[key] += counts[key] + # Stat-Xplore applies disclosure control, so rows need not add exactly. + for key, total in totals.items(): + assert abs(total - households["all"][key]) <= 20 + + +def test_published_rates(): + # Spot checks against Stat-Xplore table MtUC Households 3. + assert RATES["housing_benefit"] == pytest.approx(30_900 / 41_021) + assert RATES["housing_benefit+esa_income"] == pytest.approx(448_253 / 457_370) + assert RATES["working_tax_credit"] == pytest.approx(53_993 / 105_798) + assert RATES["all"] == pytest.approx(0.8674, abs=1e-4) + + +@settings(max_examples=200, deadline=None, derandomize=True) +@given(st.lists(people, min_size=1, max_size=40), st.integers(0, 2**32 - 1)) +def test_anchors_and_draws(units, seed): + person, benunit = frames(units) + flags = assign_uc_claim_at_legacy_closure(person, benunit, RATES, seed=seed) + combination = legacy_benefit_combination(person, benunit) + draws = np.random.default_rng(seed).random(len(benunit)) + assert flags.dtype == bool and len(flags) == len(benunit) + for i, unit_id in enumerate(benunit["benunit_id"]): + members = units[unit_id - 10] + reports = {c for c in REPORTED if any(p[c] > 0 for p in members)} + expected_combination = "+".join( + b for b in LEGACY_BENEFITS if f"{b}_reported" in reports + ) + assert combination[i] == expected_combination + if "universal_credit_reported" in reports or not expected_combination: + assert flags[i] + else: + rate = RATES.get(expected_combination, RATES["all"]) + assert flags[i] == (draws[i] < rate) + + +@pytest.mark.parametrize("combination", sorted(RATES)) +def test_cohort_shares_within_sampling_error(combination): + benefits = [] if combination == "all" else combination.split("+") + if combination == "all": + # A combination DWP does not report (income-related ESA with Income + # Support) takes the all-households rate. + benefits = ["esa_income", "income_support"] + n = 20_000 + units = [[{f"{b}_reported": 100.0 for b in benefits}] for _ in range(n)] + person, benunit = frames(units) + flags = assign_uc_claim_at_legacy_closure( + person, benunit, RATES, seed=UC_MANAGED_MIGRATION_SEED + ) + rate = RATES[combination] + assert abs(flags.mean() - rate) <= 4 * np.sqrt(rate * (1 - rate) / n) + + +def test_deterministic_and_isolated(): + units = [[{"housing_benefit_reported": 1.0}] for _ in range(500)] + person, benunit = frames(units) + np.random.seed(7) + before = np.random.get_state()[1].copy() + first = assign_uc_claim_at_legacy_closure(person, benunit, RATES, seed=1) + after = np.random.get_state()[1] + assert (before == after).all() + assert (first == assign_uc_claim_at_legacy_closure(person, benunit, RATES, 1)).all() + assert (first != assign_uc_claim_at_legacy_closure(person, benunit, RATES, 2)).any() + + +def _built_flags(dataset): + benunit = dataset.benunit + if "would_claim_uc_at_legacy_closure" not in benunit.columns: + pytest.skip("Dataset predates the Move to Universal Credit claim flag") + combination = legacy_benefit_combination(dataset.person, benunit) + on_uc = ( + benunit["benunit_id"] + .isin( + dataset.person.loc[ + dataset.person["universal_credit_reported"] > 0, "person_benunit_id" + ] + ) + .values + ) + return benunit["would_claim_uc_at_legacy_closure"].values, combination, on_uc + + +@pytest.mark.parametrize("fixture", ["frs", "enhanced_frs"]) +def test_built_dataset_anchors(fixture, request): + flags, combination, on_uc = _built_flags(request.getfixturevalue(fixture)) + assert flags[on_uc].all() + assert flags[combination == ""].all() + + +def test_built_frs_cohort_shares(frs): + # The FRS build, before SPI rows and geography clones, has one row per + # surveyed benefit unit, so its draws are independent. + flags, combination, on_uc = _built_flags(frs) + drawn = ~on_uc & (combination != "") + for c in set(combination[drawn]): + in_cohort = drawn & (combination == c) + n = in_cohort.sum() + rate = RATES.get(c, RATES["all"]) + if n >= 30: + share = flags[in_cohort].mean() + assert abs(share - rate) <= 4 * np.sqrt(rate * (1 - rate) / n), c diff --git a/policyengine_uk_data/utils/takeup.py b/policyengine_uk_data/utils/takeup.py index ab5f6241c..486b71ba1 100644 --- a/policyengine_uk_data/utils/takeup.py +++ b/policyengine_uk_data/utils/takeup.py @@ -13,6 +13,7 @@ from typing import Optional import numpy as np +import pandas as pd def assign_takeup_with_reported_anchors( @@ -57,3 +58,81 @@ def assign_takeup_with_reported_anchors( adjusted_rate = remaining_needed / int(non_reporters.sum()) result |= non_reporters & (draws < adjusted_rate) return result + + +# Legacy means-tested benefits, in the order their names are joined into a +# combination key in parameters/take_up/uc_managed_migration.yaml. +LEGACY_BENEFITS = ( + "child_tax_credit", + "working_tax_credit", + "housing_benefit", + "esa_income", + "income_support", + "jsa_income", +) +# Seeds for the Move to Universal Credit draw. Each has its own generator, so +# the draws behind every other take-up flag are unchanged. +UC_MANAGED_MIGRATION_SEED = 492 +UC_MANAGED_MIGRATION_SPI_SEED = 493 + + +def reported_benunit_mask( + person: pd.DataFrame, benunit: pd.DataFrame, column: str +) -> np.ndarray: + """Benefit units with a member reporting a positive ``column``.""" + reporters = person.loc[person[column] > 0, "person_benunit_id"].unique() + return benunit["benunit_id"].isin(reporters).values + + +def legacy_benefit_combination( + person: pd.DataFrame, benunit: pd.DataFrame +) -> np.ndarray: + """The legacy benefits each benefit unit reports, joined by "+". + + An empty string marks a benefit unit that reports none of them. + """ + reported = [ + reported_benunit_mask(person, benunit, f"{benefit}_reported") + for benefit in LEGACY_BENEFITS + ] + return np.array( + [ + "+".join(b for b, has in zip(LEGACY_BENEFITS, row) if has) + for row in zip(*reported) + ], + dtype=object, + ) + + +def assign_uc_claim_at_legacy_closure( + person: pd.DataFrame, + benunit: pd.DataFrame, + rates: dict[str, float], + seed: int, +) -> np.ndarray: + """Draw ``would_claim_uc_at_legacy_closure`` for each benefit unit. + + policyengine-uk reads it once a legacy benefit the unit reports has + closed: the unit then claims Universal Credit if this is True and loses + its legacy awards either way. A unit reporting legacy benefits but not + Universal Credit claims with DWP's Move to Universal Credit claim rate for + its combination of benefits (``rates``; "all" where DWP reports none). + Units reporting Universal Credit, and units reporting no legacy benefit, + are True, the model's default. + + Args: + person: Person table with ``person_benunit_id`` and the + ``_reported`` columns. + benunit: Benefit unit table with ``benunit_id``. + rates: Claim rate by combination, from + ``load_uc_managed_migration_claim_rates``. + seed: Seed for this draw's own generator. + + Returns: + Boolean array aligned with ``benunit``. + """ + combination = legacy_benefit_combination(person, benunit) + on_uc = reported_benunit_mask(person, benunit, "universal_credit_reported") + rate = np.array([rates.get(c, rates["all"]) for c in combination]) + draws = np.random.default_rng(seed).random(len(benunit)) + return on_uc | (combination == "") | (draws < rate) diff --git a/pyproject.toml b/pyproject.toml index beff7f1a4..58de5b1d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ dev = [ "ruff>=0.9.0", "pytest", + "hypothesis", "torch", "l0-python>=0.4.0", "tables", diff --git a/uv.lock b/uv.lock index a3e9f44d9..c301c6c9f 100644 --- a/uv.lock +++ b/uv.lock @@ -577,6 +577,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/f4/124858007ddf3c61e9b144107304c9152fa80b5b6c168da07d86fe583cc1/huggingface_hub-1.1.5-py3-none-any.whl", hash = "sha256:e88ecc129011f37b868586bbcfae6c56868cae80cd56a79d61575426a3aa0d7d", size = 516000, upload-time = "2025-11-20T15:49:30.926Z" }, ] +[[package]] +name = "hypothesis" +version = "6.168.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/b7/13118bbc45d6d8b9d04e2de779e2a4ff23145ea39efa692b994fb874ca72/hypothesis-6.168.3.tar.gz", hash = "sha256:a43388f9067678fef6e13bdff325b6cfa6961a590498bb37f7ff31589c83bc75", size = 511022, upload-time = "2026-09-28T05:20:58.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/cd/746a2fc1e5e5ba34f07ea6ee1ad07525a3dde5ff4836db3a7980af49a891/hypothesis-6.168.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d20972ca134e652e9928ecd200966a8a2857adc2812c1d74b12a872e5cd503eb", size = 791536, upload-time = "2026-09-28T05:18:57.162Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0f/7a158e377b69556c8e12c25c8fd811d0103e54c24a12dab1f3b9819f202e/hypothesis-6.168.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:eafbec09d3e87d13d8242411d1f5868b5e879f1bd6d95e233e9ff80c527bea1c", size = 787313, upload-time = "2026-09-28T05:20:18.122Z" }, + { url = "https://files.pythonhosted.org/packages/45/c6/7df9104ee359e8fcd77791781c47e70794964ca69673665e0de2a6590f4c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73c5627497968cc62d140e9fde1ea21e12cac7b64dc419fea8286cd34ff1ad4e", size = 1124036, upload-time = "2026-09-28T05:20:04.372Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/8a61715404a73b9a82cb1f976803428d603cdea976a9c30266c72ad6a7ce/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dc56e6dc6eeb0aeb08ac463f847279bde2336c4786faf7ee0af4b93cd031a7", size = 1147875, upload-time = "2026-09-28T05:19:15.495Z" }, + { url = "https://files.pythonhosted.org/packages/e4/3f/2b16e95cc3b9a069afa23830a012aefe377ce4a457a227e5a5026eb827ff/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:753bb501f8560d2e321ed3b62596b4496c56f15668b0a0669231ccc3e6c4e80d", size = 1149489, upload-time = "2026-09-28T05:19:18.886Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e7/d7cf6dc068bb02732b2a6e38a35a0dcb7f2137608198fc79d740f69d197c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71ab606c472449cb872ef2a7acaec679ee4f651b7f0a477a04ebc85d8933ef8c", size = 1191992, upload-time = "2026-09-28T05:19:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/5f/26/f1e5b25dec15998e8375d722825ec3dae1075cbe7cb1f74221b2312a2e33/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26928956c54748e4dfa587333741ab750246123b93484955646ff316cca27eef", size = 1169911, upload-time = "2026-09-28T05:19:27.996Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/901234a49147e6bf8a5b9e54b775706a223c2b52befea1106ea2aaf15d48/hypothesis-6.168.3-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:0bdfc53c041b61c854fa3761735736b991bc2bddafee45a361cfe4d43027c1ef", size = 1129406, upload-time = "2026-09-28T05:18:42.632Z" }, + { url = "https://files.pythonhosted.org/packages/55/c1/5fc9ff91ec9fba6bb527833386c66812524b5cc75fe7d4a4f9122ee1c420/hypothesis-6.168.3-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:650528e2b1e2a45e95c2df624d4b9364b8ac5a027ba84e1eea2bc5398dd01dcd", size = 1160399, upload-time = "2026-09-28T05:20:23.911Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d2/49ad1ef5c55547c8abfd0fc571b3493806dda530fd5cb3d8263dbad86a72/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:209dc54cdb1b4d6d7020ad8d09e44c49756361b7183adacea4d6f5705085b595", size = 1299906, upload-time = "2026-09-28T05:18:58.417Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5f/f98a26094c4f462c4215807bed0f9a6b5508b27a3c329bcba1bc8adc7ac7/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:af8ca98cd11dc7f9427bb90483abcd9688d4df2e8e63893a30a2423b027ebb11", size = 1425538, upload-time = "2026-09-28T05:20:49.074Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b8/c5b4406e3a6591e51a42f85469351d1d824e0a54b84167aec9b2353759fe/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:f8122cfdd0bc0ba843063effa22ac310bdebdb3a1319bf1e63f14baa119c72ab", size = 1377084, upload-time = "2026-09-28T05:20:06.618Z" }, + { url = "https://files.pythonhosted.org/packages/e0/01/0d84a8ea469d024c15f602799c0b8410fbe03d99fdb0370449b33c3c916f/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4bedbb379eab34f792af7ee9a05aae04e9c08bbb52e0f90f5a2110d4fe4b2fbd", size = 1281162, upload-time = "2026-09-28T05:19:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/22/b5/ea6435038de1a795f005cb603531e8ccc053febc50180f3ab55583c9c60e/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:5a953115b9f5c95133ab2d04efffeec96e5658c3207923dca7285f7db3e6bbef", size = 1300433, upload-time = "2026-09-28T05:18:55.634Z" }, + { url = "https://files.pythonhosted.org/packages/6c/78/fa6c77d9f64b6d592e69e582d83cbce7d7e56897faa4762885a2123b0166/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6173558e676ad25ed1e20507fa4024a0d816dd90f715f77006e5a28f19109026", size = 1336273, upload-time = "2026-09-28T05:19:08.273Z" }, + { url = "https://files.pythonhosted.org/packages/f1/56/4acd0d778bab818c9ea336fcba768bf43bc70db7f84cc373579298df4641/hypothesis-6.168.3-cp310-abi3-win32.whl", hash = "sha256:dc66390fb12d80585aa9222bf538ce8b7aa22cf5d118250647355a1c9e8f62f4", size = 678211, upload-time = "2026-09-28T05:19:23.355Z" }, + { url = "https://files.pythonhosted.org/packages/b0/db/0a02b146ad1c30f9716362f2e1a379dfd6b0f7be03ac1e22c9da24c9e2a9/hypothesis-6.168.3-cp310-abi3-win_amd64.whl", hash = "sha256:92325b276360fe86c5bf71a568c0d53a6d140b0de36dcf029f9164a17803bb24", size = 684906, upload-time = "2026-09-28T05:18:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c0/958deaf726848f96f52250740bf39f13f476b068e22f73e2b358eaa07532/hypothesis-6.168.3-cp310-abi3-win_arm64.whl", hash = "sha256:3cf6f1eeaf41cd8d60cf1f88fde905ca1dd77c906929a507d6ac7f66f2ccba2a", size = 683292, upload-time = "2026-09-28T05:19:57.404Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a2/6787da846d929e52fc3344d803299c45782dbeac287528af08380e984bf8/hypothesis-6.168.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b230a850de63334c16654a34a2d547e0179d36b9071d4439b3e7237f6d077e7", size = 793254, upload-time = "2026-09-28T05:19:36.294Z" }, + { url = "https://files.pythonhosted.org/packages/89/ba/2893f5ca42501f4d562cba3229c8994c3a3e5fac66ef60c1e0e0b5220a5d/hypothesis-6.168.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d63b0226cd3e0d8bdd97c3384b22a21934ed4d53246c1c93575dada616672499", size = 784699, upload-time = "2026-09-28T05:18:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/b7/aa/7d7349daf75b71f6f35876f8de115e974c5c04d600e86df1b2779b0883ab/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0369f5df055f96e117ab12e5f249668ff144731ab5280bc7a205fdf81f990b89", size = 1123016, upload-time = "2026-09-28T05:20:36.372Z" }, + { url = "https://files.pythonhosted.org/packages/00/f0/7774e1ea072708ea46cb25c4aeee5f9978b6c271764809069e8a6789858e/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f076bcd0f77fdcdb7797826c879099573d03a02e228ebe649ea917081b962ac0", size = 1168989, upload-time = "2026-09-28T05:20:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7d/113992abed9efbd7944e3a496a382da58152dce126fddf6419ff31a0a57d/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b823ba1fcec8da730f29316d010b06d3f7e0c3828dcf91020e24c55e7d24652a", size = 1298626, upload-time = "2026-09-28T05:19:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/d5/65/3659fa5e733027e5b37a27486e57f4053535e40853d23501c422bb275043/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dd2849c269d674e4618590f3b48d443bd4c06b5aef3d8d869086c2e6d213d248", size = 1335184, upload-time = "2026-09-28T05:20:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/b4/6c/35aab2221b5ea65125340f236e1a765a9e9f3b28d89a389ea0033fed29f7/hypothesis-6.168.3-cp313-cp313-win_amd64.whl", hash = "sha256:3ef7d26f5789e691401d5f87eafed9bd2763f0dbe47af6d6b66012a509404766", size = 682173, upload-time = "2026-09-28T05:19:12.762Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/27b0cb56ff5d2bc92458bf6fcebdb1f0dc01f57f043a178e9f9d74498169/hypothesis-6.168.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e2d4c68729a13df9af4998d2652cfb5d541c5609b88c306880a5dfb284938ec2", size = 793287, upload-time = "2026-09-28T05:20:38.522Z" }, + { url = "https://files.pythonhosted.org/packages/e8/bd/5342f95c3bc36586777ef8cb8eba87ac7acf0184210327fb0e6d8654e295/hypothesis-6.168.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b345f818083ec99966a43ca4f7b38feb62c6920ce28572bc2bde4948a8b7eaba", size = 784857, upload-time = "2026-09-28T05:20:42.609Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5f/fc774241518a0588d362680a3084275058aab86d6bc02a2c61e1073142d0/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0608c610fc002978fc5de0f471770d8817e9455cb8649a47983c9f8bccfc1897", size = 1123330, upload-time = "2026-09-28T05:19:29.735Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/131f16775a3dca5f4fe27f0d6ad9a9851600098a54a872d163f6ebf0b664/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dcf6448b1ecc37f2b23f2d1b3ddfc9ff6b6910614c15a642dc82f419b96037b", size = 1169178, upload-time = "2026-09-28T05:19:53.436Z" }, + { url = "https://files.pythonhosted.org/packages/06/61/c9f5bff8b73321c9fd12f2d69666c6e4861b97b60f5f24fdd6ac293007fd/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f9f094041dcce5119ebd7bab71062743ab02b6e654d056b370beda78c19e2", size = 1299127, upload-time = "2026-09-28T05:20:14.486Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6d/d4618f8ab12dd76c4d58ea052252759aa2bf0c4e71f4b502ba5d577e0b9f/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d479985fe73af97badfb72dc6d20c6a353e486a36f6d065f600026e0cf954a86", size = 1335390, upload-time = "2026-09-28T05:20:10.326Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/727161e17cc297df1aeea945de05c532033b66751625bec192bc5b5ff707/hypothesis-6.168.3-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:785e2c45f8c08e274b4bf1ccae97f1a4e09407e9a68e80790cfab1d17a4a45fa", size = 624291, upload-time = "2026-09-28T05:18:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/77/23/2f9b506392a0b8712440bcb781abc5713be9ffac1303b6d10f3669050882/hypothesis-6.168.3-cp314-cp314-win_amd64.whl", hash = "sha256:320920b1e3dae8611eee8a03d063cf2187446f2a17c38cfb8a7fc1466f71eee2", size = 682064, upload-time = "2026-09-28T05:20:16.336Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/efabfd95eb2b69c0c9fa1e1c83c8290aa2715d46baeb5a7764faf09c133a/hypothesis-6.168.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:35380baa981108a7f60c4eab71e46acd8d8f58440520346a1a6aba06dca7e074", size = 791875, upload-time = "2026-09-28T05:19:37.789Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fc/2a0ada1623a9a33048bbf9f00a7c92513398c6b8865cba5efb854262ce30/hypothesis-6.168.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f0aaaed00438fa6673d856aaee12a4a8afbde82a9f3c5ff487cacfb064239afe", size = 783412, upload-time = "2026-09-28T05:18:48.266Z" }, + { url = "https://files.pythonhosted.org/packages/41/31/73c615d37eeb12da0209555612c32ced628d860267c23cfd2691c6207aea/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27e6df1576bf838e7f484d4ae5cba92114497ca30afb917056bf1ea33e95675", size = 1121662, upload-time = "2026-09-28T05:19:26.225Z" }, + { url = "https://files.pythonhosted.org/packages/92/54/1893344a3b8bbf83fa7c9bb7e96f6836580213854f46671cb749f7b4706c/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3bc85014577982ec6d2e266edc5cd6e7a7d3674c648791ca983c67a52f89a0c4", size = 1167761, upload-time = "2026-09-28T05:18:49.85Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/a61d82968febd25a0f08ffbbfabfaeead66f68fc5fa73f3c9ece1c36fc8f/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ccfc29505aa1cdcc254cb9cd701fd0811fef5480addd97d4127a00df023410f7", size = 1297309, upload-time = "2026-09-28T05:19:51.711Z" }, + { url = "https://files.pythonhosted.org/packages/bc/01/fe8e4cf6d39d02f4efadaa351427fa13722086cfc54a2da3579741c5177a/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32d0699566aaa93f9e97a44705d7164386f1e91de78d277f53bada35e78bcc96", size = 1334260, upload-time = "2026-09-28T05:19:00.121Z" }, + { url = "https://files.pythonhosted.org/packages/39/6a/09177ebc62f4778f94379ecade6a51299d3d8c4f4b75ba636bf3a0202364/hypothesis-6.168.3-cp314-cp314t-win_amd64.whl", hash = "sha256:28d88fa174ecbd4ecbd7bb290f06d0db084a3971c3f511ae2830b65b5e25500f", size = 681992, upload-time = "2026-09-28T05:19:48.043Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f6/890bf33d608cd63348d3146a3ca83362fbf09e589c5e5230a00c403070f4/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:61f5782d807b1e6aa5c9beef1054cb2037e7d1add48a64972cd6ee447778781f", size = 791231, upload-time = "2026-09-28T05:18:43.998Z" }, + { url = "https://files.pythonhosted.org/packages/03/dd/fc36b204f8aa7437c676576d9437f0422de46aa8a729e6cd5bbb0224e759/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:7aacf3cf40c7ce8f9e4924d5b57bc0b068beafdfed2347bcf160a28b4cfbba7b", size = 783171, upload-time = "2026-09-28T05:19:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d2/3cd087d577db67c404991d7723e64d7cd75570cdc82a0f7329aed6f2086c/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01768a03a30dc54df7fe457c0b34016c84d598fa00d5965ededab93ba4eb3408", size = 1121152, upload-time = "2026-09-28T05:18:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/42/6f/a05a68cc66e7a22701c50bf94fb9ddeb36f4f5d0de3cc130066709eda621/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1a8a4ffc6c6e6e577f2bfbcfebf7cffbb310283ba2532c729570a0a752cebaaa", size = 1144069, upload-time = "2026-09-28T05:20:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f4/fdd7a093fb2fb3a0ce24256c92ad5bf67936f4669bd06d5ceae4298150ec/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b987d73eba95183a7e59cca6d1925c588aa4307922d9852cc2b4d282a2ae4128", size = 1146692, upload-time = "2026-09-28T05:20:25.83Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/6dbd4377e935505ae4fc8ee4c7b18c69994c4bbaca015447c470218bdbee/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4569c39bd97d9573e946429ed676f3b55a7c8ed80920addd67d384a47d59b38", size = 1189480, upload-time = "2026-09-28T05:20:46.734Z" }, + { url = "https://files.pythonhosted.org/packages/ba/bf/ff288b496b690000d2686dcfa7f67855e4c5a6dddb464ed8e4880be83bb2/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6042b8707a4b25bbbfe20b110258fb7549a68951e5525608a8ce06091039e9fc", size = 1167109, upload-time = "2026-09-28T05:19:24.758Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d1/3fee2bc29fc747fabbb27e174515592e390809ca465d3bbbabbcfa3235a8/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:f413629de94d38a7a2ad259697a6752526143cb49c2e2d7bdba19381c80693f6", size = 1126823, upload-time = "2026-09-28T05:18:52.636Z" }, + { url = "https://files.pythonhosted.org/packages/58/6a/585294392fa6a6d9719446a042a777ba98ecca265d9e48cbedc10c88df0f/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f071737e4e775bebba07e319eb645a880d1e0186b4d24bad23255e849d86d483", size = 1155773, upload-time = "2026-09-28T05:20:51.268Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4d/fa283ff79996debf1ff08593e01f8b773eb8211b19b3d6d5d491317a65bf/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:f59a3912858d0609c26054aa1c474847c797937f5e0e1c77f0d3f08032e50f09", size = 1296671, upload-time = "2026-09-28T05:18:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/42/05/98f9c2f628da5afabb6c3e8df5d2d299d2d50bf306b14a6e26d3954c4951/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:f09c05a23a8025dd5cad07c2ed299a46778e72d49ff0dd5bf353bcc0f7dc1580", size = 1422001, upload-time = "2026-09-28T05:19:04.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c9/f2109ade29a7ec284d55bca328d784743e8b13e321576fd34ee7e65f99af/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:44ace770bda3a0301739fc5a413c764de790c739df1f1a7218dd049f8594d9f5", size = 1374182, upload-time = "2026-09-28T05:20:27.764Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6d/e05d5f72441564a3bebc71fa155deafd0fd3b015d6014ec8a00edf6e42bb/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:03b131043608f94a2578a2a896a1a72092acb7079815eef5b8513b08447e0e62", size = 1278402, upload-time = "2026-09-28T05:20:56.332Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/6988a7f1f69c5c530687dce03a32c157e2d878e3a2e32af9269ce016b78a/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:e9784aca26eddfe99b03a0292320db742cc8a74200ef864fdce949f526f973cd", size = 1297770, upload-time = "2026-09-28T05:19:11.387Z" }, + { url = "https://files.pythonhosted.org/packages/a2/28/5bb82b60b836bd2329e1fe01ad94efc2b14dfa806f8e50ed14b795d78770/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:2b52ac363096232bebc2add117e9178d91f4248f4cdb919fd1026b5f86a4bb16", size = 1333977, upload-time = "2026-09-28T05:20:02.483Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7d/d419841b8f65481ea1a50c4ba36f2670d48b8c7a51e4e8586089564cf7f6/hypothesis-6.168.3-cp315-abi3.abi3t-win32.whl", hash = "sha256:d28e3a6b511a74ce37df5274b51f36c2b274fea7365e00b16f0c81e22acd5957", size = 675394, upload-time = "2026-09-28T05:19:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/1de6a2ad100e44817f2e9a8e8ba3eeaf4769621f4f87f2d4966c4971fcc4/hypothesis-6.168.3-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:7b9638789548361a57d984f56619ac694a914c328181d911271618409261ff4a", size = 681699, upload-time = "2026-09-28T05:19:09.957Z" }, + { url = "https://files.pythonhosted.org/packages/c0/79/be3370fd02734d6b1d950183ae9224580d19fa8d356b95348d60f1e3eeb7/hypothesis-6.168.3-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:65d78e4357ec48ed2c67825f06740ee3599be4cfe770a6092bed07108d679ac5", size = 679849, upload-time = "2026-09-28T05:19:06.884Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -1364,7 +1433,7 @@ wheels = [ [[package]] name = "policyengine-uk-data" -version = "1.56.16" +version = "1.57.4" source = { editable = "." } dependencies = [ { name = "google-auth" }, @@ -1392,6 +1461,7 @@ dependencies = [ dev = [ { name = "build" }, { name = "furo" }, + { name = "hypothesis" }, { name = "itables" }, { name = "l0-python" }, { name = "pytest" }, @@ -1410,6 +1480,7 @@ requires-dist = [ { name = "google-auth" }, { name = "google-cloud-storage" }, { name = "huggingface-hub" }, + { name = "hypothesis", marker = "extra == 'dev'" }, { name = "itables", marker = "extra == 'dev'" }, { name = "l0-python", marker = "extra == 'dev'", specifier = ">=0.4.0" }, { name = "microcalibrate", specifier = ">=0.18.0" }, From c67077a66404e3bd67805277c72ff7165ad65b3c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 17:52:29 -0400 Subject: [PATCH 2/4] Draw the closure flag so each cohort's closure-claim share is DWP's rate policyengine-uk claims at the closure when would_claim_uc OR the flag is true, so drawing the flag at DWP's rate r gave a share of p + (1 - p) r, where p is the cohort's would_claim_uc share (review r1 of #534: HB only 0.866 against 0.753). Units already claiming are now True, and the rest are drawn at (r - p) / (1 - p), clipped to [0, 1], with p computed per combination, unweighted, before calibration. The SPI copy uses its redrawn would_claim_uc. Tests: the closure-claim share (not the flag share) is within sampling error of r with would_claim_uc at 0 and 0.3; an exact characterisation of the conditional draw; the built-FRS cohort test checks the flag share among units not claiming anyway against the conditional rate (it needs a fresh build). Co-Authored-By: Claude Opus 5.5 --- policyengine_uk_data/datasets/frs.py | 1 + .../datasets/imputations/income.py | 1 + .../tests/test_uc_managed_migration_takeup.py | 74 ++++++++++++++++--- policyengine_uk_data/utils/takeup.py | 35 +++++++-- 4 files changed, 91 insertions(+), 20 deletions(-) diff --git a/policyengine_uk_data/datasets/frs.py b/policyengine_uk_data/datasets/frs.py index bc9f53709..e69d09633 100644 --- a/policyengine_uk_data/datasets/frs.py +++ b/policyengine_uk_data/datasets/frs.py @@ -1770,6 +1770,7 @@ def determine_education_level(fted_val, typeed2_val, age_val): pe_benunit, load_uc_managed_migration_claim_rates(), seed=UC_MANAGED_MIGRATION_SEED, + would_claim_uc=pe_benunit["would_claim_uc"].values, ) pe_benunit["would_claim_tfc"] = generator.random(len(pe_benunit)) < tfc_rate diff --git a/policyengine_uk_data/datasets/imputations/income.py b/policyengine_uk_data/datasets/imputations/income.py index 20e6a86b7..baaa2ac65 100644 --- a/policyengine_uk_data/datasets/imputations/income.py +++ b/policyengine_uk_data/datasets/imputations/income.py @@ -550,6 +550,7 @@ def impute_income(dataset: UKSingleYearDataset) -> UKSingleYearDataset: zero_weight_copy.benunit, load_uc_managed_migration_claim_rates(), seed=UC_MANAGED_MIGRATION_SPI_SEED, + would_claim_uc=zero_weight_copy.benunit.get("would_claim_uc"), ) ) diff --git a/policyengine_uk_data/tests/test_uc_managed_migration_takeup.py b/policyengine_uk_data/tests/test_uc_managed_migration_takeup.py index 90a245f1d..274db2630 100644 --- a/policyengine_uk_data/tests/test_uc_managed_migration_takeup.py +++ b/policyengine_uk_data/tests/test_uc_managed_migration_takeup.py @@ -10,9 +10,13 @@ 2. Every benefit unit reporting Universal Credit is True. 3. Every benefit unit reporting no legacy benefit is True, the model's default, so the column changes nothing outside the legacy cohorts. -4. A legacy reporter not on Universal Credit is True exactly when its draw is - below its combination's rate ("all" for combinations DWP does not report). -5. Each cohort's share lands within sampling error of its rate. +4. A legacy reporter not on Universal Credit is True if it would claim + anyway (would_claim_uc), and otherwise exactly when its draw is below + (r - p) / (1 - p), where r is its combination's rate ("all" for + combinations DWP does not report) and p its combination's share claiming + anyway. +5. Each cohort's share claiming at the closure (would_claim_uc or the flag) + lands within sampling error of its rate. 6. The draw is deterministic, has its own generator and leaves NumPy's global random state alone. """ @@ -114,8 +118,11 @@ def test_anchors_and_draws(units, seed): assert flags[i] == (draws[i] < rate) +@pytest.mark.parametrize("anyway_share", [0.0, 0.3]) @pytest.mark.parametrize("combination", sorted(RATES)) -def test_cohort_shares_within_sampling_error(combination): +def test_cohort_claim_shares_within_sampling_error(combination, anyway_share): + """The share claiming at the closure (would_claim_uc or the flag) is + DWP's rate, whatever share would claim anyway.""" benefits = [] if combination == "all" else combination.split("+") if combination == "all": # A combination DWP does not report (income-related ESA with Income @@ -124,11 +131,49 @@ def test_cohort_shares_within_sampling_error(combination): n = 20_000 units = [[{f"{b}_reported": 100.0 for b in benefits}] for _ in range(n)] person, benunit = frames(units) + anyway = np.random.default_rng(5).random(n) < anyway_share flags = assign_uc_claim_at_legacy_closure( - person, benunit, RATES, seed=UC_MANAGED_MIGRATION_SEED + person, benunit, RATES, seed=UC_MANAGED_MIGRATION_SEED, would_claim_uc=anyway ) + assert flags[anyway].all() rate = RATES[combination] - assert abs(flags.mean() - rate) <= 4 * np.sqrt(rate * (1 - rate) / n) + claims = anyway | flags + assert abs(claims.mean() - rate) <= 4 * np.sqrt(rate * (1 - rate) / n) + + +@settings(max_examples=100, deadline=None, derandomize=True) +@given( + st.lists(people, min_size=1, max_size=40), + st.integers(0, 2**32 - 1), + st.data(), +) +def test_conditional_draw(units, seed, data): + """Exact characterisation with would_claim_uc: units claiming anyway are + True, and the rest are True exactly when their draw is below + (rate - p) / (1 - p) for their combination.""" + person, benunit = frames(units) + anyway = np.array( + data.draw(st.lists(st.booleans(), min_size=len(units), max_size=len(units))) + ) + flags = assign_uc_claim_at_legacy_closure( + person, benunit, RATES, seed=seed, would_claim_uc=anyway + ) + combination = legacy_benefit_combination(person, benunit) + on_uc = ( + benunit["benunit_id"] + .isin(person.loc[person["universal_credit_reported"] > 0, "person_benunit_id"]) + .values + ) + draws = np.random.default_rng(seed).random(len(benunit)) + drawn = ~on_uc & (combination != "") + assert flags[~drawn | anyway].all() + for c in set(combination[drawn]): + cohort = drawn & (combination == c) + p = anyway[cohort].mean() + rate = RATES.get(c, RATES["all"]) + q = 0.0 if p >= 1 else min(max((rate - p) / (1 - p), 0.0), 1.0) + free = cohort & ~anyway + assert (flags[free] == (draws[free] < q)).all(), c def test_deterministic_and_isolated(): @@ -169,16 +214,21 @@ def test_built_dataset_anchors(fixture, request): def test_built_frs_cohort_shares(frs): # The FRS build, before SPI rows and geography clones, has one row per - # surveyed benefit unit, so its draws are independent. + # surveyed benefit unit, so its draws are independent. Among units not + # claiming anyway, the flag's share is the conditional rate. flags, combination, on_uc = _built_flags(frs) + anyway = frs.benunit["would_claim_uc"].values.astype(bool) drawn = ~on_uc & (combination != "") for c in set(combination[drawn]): - in_cohort = drawn & (combination == c) - n = in_cohort.sum() + cohort = drawn & (combination == c) + p = anyway[cohort].mean() rate = RATES.get(c, RATES["all"]) - if n >= 30: - share = flags[in_cohort].mean() - assert abs(share - rate) <= 4 * np.sqrt(rate * (1 - rate) / n), c + q = 0.0 if p >= 1 else min(max((rate - p) / (1 - p), 0.0), 1.0) + free = cohort & ~anyway + m = free.sum() + if m >= 30: + share = flags[free].mean() + assert abs(share - q) <= 4 * np.sqrt(max(q * (1 - q), 1e-9) / m) + 1e-9, c def test_closure_draw_leaves_the_seed_100_sequence_alone(tmp_path, monkeypatch): diff --git a/policyengine_uk_data/utils/takeup.py b/policyengine_uk_data/utils/takeup.py index 5cdac4df9..7d20549bd 100644 --- a/policyengine_uk_data/utils/takeup.py +++ b/policyengine_uk_data/utils/takeup.py @@ -136,16 +136,22 @@ def assign_uc_claim_at_legacy_closure( benunit: pd.DataFrame, rates: dict[str, float], seed: int, + would_claim_uc: Optional[np.ndarray] = None, ) -> np.ndarray: """Draw ``would_claim_uc_at_legacy_closure`` for each benefit unit. policyengine-uk reads it once a legacy benefit the unit reports has - closed: the unit then claims Universal Credit if this is True and loses - its legacy awards either way. A unit reporting legacy benefits but not - Universal Credit claims with DWP's Move to Universal Credit claim rate for - its combination of benefits (``rates``; "all" where DWP reports none). - Units reporting Universal Credit, and units reporting no legacy benefit, - are True, the model's default. + closed: the unit then claims Universal Credit if it would claim anyway + (``would_claim_uc``) or if this is True, and loses its legacy awards + either way. So that the share claiming at the closure in each combination + of legacy benefits is DWP's Move to Universal Credit claim rate ``r`` + (``rates``; "all" where DWP reports none), a unit reporting legacy + benefits but not Universal Credit and not already claiming is drawn at + ``(r - p) / (1 - p)``, clipped to [0, 1], where ``p`` is the share of the + combination's such units with ``would_claim_uc`` True (unweighted: the + draw precedes calibration). Without ``would_claim_uc``, ``p`` is 0. + Units reporting Universal Credit, units already claiming, and units + reporting no legacy benefit are True, the model's default. Args: person: Person table with ``person_benunit_id`` and the @@ -154,12 +160,25 @@ def assign_uc_claim_at_legacy_closure( rates: Claim rate by combination, from ``load_uc_managed_migration_claim_rates``. seed: Seed for this draw's own generator. + would_claim_uc: The units' ``would_claim_uc``, aligned with + ``benunit``. Returns: Boolean array aligned with ``benunit``. """ combination = legacy_benefit_combination(person, benunit) on_uc = reported_benunit_mask(person, benunit, "universal_credit_reported") - rate = np.array([rates.get(c, rates["all"]) for c in combination]) + claims_anyway = ( + np.zeros(len(benunit), dtype=bool) + if would_claim_uc is None + else np.asarray(would_claim_uc, dtype=bool) + ) + drawn = ~on_uc & (combination != "") + probability = np.zeros(len(benunit)) + for c in set(combination[drawn]): + cohort = drawn & (combination == c) + rate = rates.get(c, rates["all"]) + p = claims_anyway[cohort].mean() + probability[cohort] = 0.0 if p >= 1 else np.clip((rate - p) / (1 - p), 0, 1) draws = np.random.default_rng(seed).random(len(benunit)) - return on_uc | (combination == "") | (draws < rate) + return ~drawn | claims_anyway | (draws < probability) From 49dd6f8042b5741f3303c96c55887109719978fc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 21:53:25 -0400 Subject: [PATCH 3/4] Draw the closure flag only over units the model moves to Universal Credit Review r2 (P2): the conditional rate counted Pension Credit route families in p, although policyengine-uk never moves them to Universal Credit, so the units that do move claimed above DWP's rate (78.3% against 67.4% on a synthetic Child Tax Credit cohort). derive_moves_to_uc_at_legacy_closure follows policyengine-uk's closure rule with every closure in force, reading meets_pension_credit_age_conditions, is_mixed_age_couple and is_SP_age from the build's simulation; only those units are drawn and counted. Tests: the moved units' share is DWP's rate with Pension Credit route units alongside; a Universal Credit reporter does not set the rate (the r2 mutant survived the property test); the closure rule case by case. Both mutants fail. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk_data/datasets/frs.py | 90 +++++++++++++++++++ .../tests/test_legacy_benefit_proxies.py | 4 + .../tests/test_uc_managed_migration_takeup.py | 89 +++++++++++++++++- policyengine_uk_data/utils/takeup.py | 17 +++- 4 files changed, 195 insertions(+), 5 deletions(-) diff --git a/policyengine_uk_data/datasets/frs.py b/policyengine_uk_data/datasets/frs.py index 8c7ac5476..0acdddc3d 100644 --- a/policyengine_uk_data/datasets/frs.py +++ b/policyengine_uk_data/datasets/frs.py @@ -40,6 +40,7 @@ load_uc_managed_migration_claim_rates, ) from policyengine_uk_data.utils.takeup import ( + LEGACY_BENEFITS, UC_MANAGED_MIGRATION_SEED, assign_takeup_with_reported_anchors, assign_uc_claim_at_legacy_closure, @@ -575,6 +576,91 @@ def derive_all_claimants_over_state_pension_age( return ((counts.claimants > 0) & (counts.over == counts.claimants)).to_numpy() +def derive_moves_to_uc_at_legacy_closure( + reports: dict, + pension_age_route, + mixed_age_couple, + any_over_state_pension_age, +) -> np.ndarray: + """Identify the benefit units policyengine-uk moves to Universal Credit + when their legacy benefits close, the units that read + ``would_claim_uc_at_legacy_closure``. + + ``reports`` maps each legacy benefit in ``LEGACY_BENEFITS`` and + ``pension_credit`` to whether the unit reports it; the other arguments + are policyengine-uk's ``meets_pension_credit_age_conditions``, + ``is_mixed_age_couple`` and whether any member ``is_SP_age``. The rule is + policyengine-uk's ``legacy_benefits_closed`` with every closure in force. + Tax credit units move unless DWP sends them to Pension Credit: those on + it, and those meeting its age conditions without Working Tax Credit (SI + 2019/167 art. 3A(1); SI 2014/1230 reg 60A). Income Support, income-based + JSA and income-related ESA units move unless on Pension Credit or wholly + over the qualifying age (reg 8(2B)). Housing Benefit units move unless a + member is over State Pension age (SI 2025/1148 art. 7(4)(a)). + policyengine-uk reads the flag for income-related ESA and Housing + Benefit units only once it ends those awards (#1914, PE-UK #1910); a unit + on neither until then never reads it, so drawing it is harmless. + """ + + def reported(benefit): + return np.asarray(reports[benefit], dtype=bool) + + pension_age_route = np.asarray(pension_age_route, dtype=bool) + on_pension_credit = reported("pension_credit") + on_wtc = reported("working_tax_credit") + pension_credit_route = on_pension_credit | (pension_age_route & ~on_wtc) + tax_credits = reported("child_tax_credit") | on_wtc + working_age_benefits = ( + reported("income_support") | reported("jsa_income") | reported("esa_income") + ) + wholly_pension_age = pension_age_route & ~np.asarray(mixed_age_couple, dtype=bool) + return ( + (tax_credits & ~pension_credit_route) + | (working_age_benefits & ~on_pension_credit & ~wholly_pension_age) + | ( + reported("housing_benefit") + & ~np.asarray(any_over_state_pension_age, dtype=bool) + ) + ) + + +def moves_to_uc_at_legacy_closure(sim, person, benunit, year) -> np.ndarray: + """``derive_moves_to_uc_at_legacy_closure`` for the benefit units in + ``benunit``, reading the age conditions from the policyengine-uk + simulation ``sim`` built on the same people.""" + sim_benunit_ids = sim.calculate("benunit_id", year).values + + def sim_benunit_values(variable): + return ( + pd.Series( + np.asarray(sim.calculate(variable, year).values, dtype=bool), + index=sim_benunit_ids, + ) + .reindex(benunit.benunit_id, fill_value=False) + .to_numpy() + ) + + any_over_state_pension_age = ( + pd.Series( + np.asarray(sim.calculate("is_SP_age", year).values, dtype=bool), + index=sim.calculate("person_benunit_id", year).values, + ) + .groupby(level=0) + .any() + .reindex(benunit.benunit_id, fill_value=False) + .to_numpy() + ) + return derive_moves_to_uc_at_legacy_closure( + reports={ + benefit: reported_benunit_mask(person, benunit, f"{benefit}_reported") + for benefit in (*LEGACY_BENEFITS, "pension_credit") + }, + pension_age_route=sim_benunit_values("meets_pension_credit_age_conditions"), + mixed_age_couple=sim_benunit_values("is_mixed_age_couple"), + any_over_state_pension_age=any_over_state_pension_age, + ) + + def derive_is_claimant_or_partner_from_frs_microdata( person_ids, person_benunit_ids, @@ -1788,12 +1874,16 @@ def determine_education_level(fted_val, typeed2_val, age_val): "would_claim_uc_at_legacy_closure", "Move to Universal Credit claim flag", ) + # Only units the model moves to Universal Credit read the flag, so only + # they are drawn and counted against DWP's rates. + moves_to_uc = moves_to_uc_at_legacy_closure(sim, pe_person, pe_benunit, year) pe_benunit["would_claim_uc_at_legacy_closure"] = assign_uc_claim_at_legacy_closure( pe_person, pe_benunit, load_uc_managed_migration_claim_rates(), seed=UC_MANAGED_MIGRATION_SEED, would_claim_uc=pe_benunit["would_claim_uc"].values, + moves_to_uc=moves_to_uc, ) pe_benunit["would_claim_tfc"] = generator.random(len(pe_benunit)) < tfc_rate diff --git a/policyengine_uk_data/tests/test_legacy_benefit_proxies.py b/policyengine_uk_data/tests/test_legacy_benefit_proxies.py index 92017b0e7..54235df49 100644 --- a/policyengine_uk_data/tests/test_legacy_benefit_proxies.py +++ b/policyengine_uk_data/tests/test_legacy_benefit_proxies.py @@ -422,6 +422,10 @@ def calculate(self, variable, year=None): return pd.Series(self.dataset.person.age.values >= 18) if variable == "is_SP_age": return pd.Series(self.dataset.person.age.values >= 66) + if variable == "benunit_id": + return pd.Series(self.dataset.benunit.benunit_id.values) + if variable in ("meets_pension_credit_age_conditions", "is_mixed_age_couple"): + return pd.Series(np.zeros(len(self.dataset.benunit), dtype=bool)) if variable in ( "childcare_grant", "parents_learning_allowance", diff --git a/policyengine_uk_data/tests/test_uc_managed_migration_takeup.py b/policyengine_uk_data/tests/test_uc_managed_migration_takeup.py index 274db2630..c7eba8339 100644 --- a/policyengine_uk_data/tests/test_uc_managed_migration_takeup.py +++ b/policyengine_uk_data/tests/test_uc_managed_migration_takeup.py @@ -214,11 +214,19 @@ def test_built_dataset_anchors(fixture, request): def test_built_frs_cohort_shares(frs): # The FRS build, before SPI rows and geography clones, has one row per - # surveyed benefit unit, so its draws are independent. Among units not - # claiming anyway, the flag's share is the conditional rate. + # surveyed benefit unit, so its draws are independent. Among units the + # model moves to Universal Credit and not claiming anyway, the flag's + # share is the conditional rate. + from policyengine_uk import Microsimulation + + from policyengine_uk_data.datasets.frs import moves_to_uc_at_legacy_closure + flags, combination, on_uc = _built_flags(frs) anyway = frs.benunit["would_claim_uc"].values.astype(bool) - drawn = ~on_uc & (combination != "") + moves = moves_to_uc_at_legacy_closure( + Microsimulation(dataset=frs), frs.person, frs.benunit, frs.time_period + ) + drawn = ~on_uc & (combination != "") & moves for c in set(combination[drawn]): cohort = drawn & (combination == c) p = anyway[cohort].mean() @@ -330,3 +338,78 @@ def stack(left, right): # FRS rows keep their flag; SPI rows report no legacy benefit, so True. assert not flags[:n].any() assert flags[n:].all() + + +def test_units_not_moved_are_neither_drawn_nor_counted(): + """Only units the model moves to Universal Credit set and take the + conditional rate. Pension Credit route families reporting the same + benefit, none of whom would claim anyway, stay True (the default) and do + not pull the moved units' claim share above DWP's rate.""" + n = 20_000 + units = [[{"child_tax_credit_reported": 100.0}] for _ in range(2 * n)] + person, benunit = frames(units) + moves = (benunit["benunit_id"] < 10 + n).values + anyway = moves & (np.random.default_rng(5).random(2 * n) < 0.3) + flags = assign_uc_claim_at_legacy_closure( + person, + benunit, + RATES, + seed=UC_MANAGED_MIGRATION_SEED, + would_claim_uc=anyway, + moves_to_uc=moves, + ) + assert flags[~moves].all() + rate = RATES["child_tax_credit"] + claims = (anyway | flags)[moves] + assert abs(claims.mean() - rate) <= 4 * np.sqrt(rate * (1 - rate) / n) + + +@pytest.mark.parametrize("seed", range(50)) +def test_universal_credit_reporters_do_not_set_the_rate(seed): + """A unit already reporting Universal Credit is True and outside ``p``, + so the other units in its combination draw at DWP's rate itself.""" + units = [[{"child_tax_credit_reported": 100.0}] for _ in range(2)] + [ + [{"child_tax_credit_reported": 100.0, "universal_credit_reported": 100.0}] + ] + person, benunit = frames(units) + on_uc = (benunit["benunit_id"] == 12).values + flags = assign_uc_claim_at_legacy_closure( + person, benunit, RATES, seed=seed, would_claim_uc=on_uc + ) + draws = np.random.default_rng(seed).random(len(benunit)) + assert flags[on_uc].all() + rate = RATES["child_tax_credit"] + assert (flags[~on_uc] == (draws[~on_uc] < rate)).all() + + +def test_moves_to_uc_follows_the_closure_rule(): + """Who policyengine-uk moves to Universal Credit, case by case.""" + from policyengine_uk_data.datasets.frs import ( + derive_moves_to_uc_at_legacy_closure, + ) + + cases = [ + # (reported benefits, pension-age route, mixed-age couple, + # member over State Pension age, moved) + ({"child_tax_credit"}, False, False, False, True), + ({"child_tax_credit"}, True, False, True, False), + ({"child_tax_credit", "pension_credit"}, False, False, False, False), + ({"working_tax_credit"}, True, False, True, True), + ({"child_tax_credit"}, True, True, True, False), + ({"income_support"}, True, True, True, True), + ({"income_support"}, True, False, True, False), + ({"jsa_income", "pension_credit"}, True, True, True, False), + ({"esa_income"}, False, False, False, True), + ({"housing_benefit"}, False, False, False, True), + ({"housing_benefit"}, False, False, True, False), + ({"housing_benefit"}, True, True, True, False), + (set(), False, False, False, False), + ] + benefits = (*LEGACY_BENEFITS, "pension_credit") + moved = derive_moves_to_uc_at_legacy_closure( + reports={b: np.array([b in case[0] for case in cases]) for b in benefits}, + pension_age_route=np.array([case[1] for case in cases]), + mixed_age_couple=np.array([case[2] for case in cases]), + any_over_state_pension_age=np.array([case[3] for case in cases]), + ) + assert moved.tolist() == [case[4] for case in cases] diff --git a/policyengine_uk_data/utils/takeup.py b/policyengine_uk_data/utils/takeup.py index 7d20549bd..aaa1e613c 100644 --- a/policyengine_uk_data/utils/takeup.py +++ b/policyengine_uk_data/utils/takeup.py @@ -137,6 +137,7 @@ def assign_uc_claim_at_legacy_closure( rates: dict[str, float], seed: int, would_claim_uc: Optional[np.ndarray] = None, + moves_to_uc: Optional[np.ndarray] = None, ) -> np.ndarray: """Draw ``would_claim_uc_at_legacy_closure`` for each benefit unit. @@ -150,8 +151,14 @@ def assign_uc_claim_at_legacy_closure( ``(r - p) / (1 - p)``, clipped to [0, 1], where ``p`` is the share of the combination's such units with ``would_claim_uc`` True (unweighted: the draw precedes calibration). Without ``would_claim_uc``, ``p`` is 0. - Units reporting Universal Credit, units already claiming, and units - reporting no legacy benefit are True, the model's default. + Only units that policyengine-uk moves to Universal Credit when their + legacy benefits close (``moves_to_uc``) are drawn and counted in ``p``: + a unit DWP sends to Pension Credit instead never reads the flag, and + counting it would move the share among the units that do away from ``r``. + Units reporting Universal Credit, units already claiming, units reporting + no legacy benefit and units not moved are True, the model's default. + The share matches ``r`` in expectation, not exactly: the draw is random, + and ``(r - p) / (1 - p)`` is clipped at 0 when ``p`` exceeds ``r``. Args: person: Person table with ``person_benunit_id`` and the @@ -162,6 +169,10 @@ def assign_uc_claim_at_legacy_closure( seed: Seed for this draw's own generator. would_claim_uc: The units' ``would_claim_uc``, aligned with ``benunit``. + moves_to_uc: Whether policyengine-uk moves each unit to Universal + Credit when its legacy benefits close, aligned with ``benunit`` + (``derive_moves_to_uc_at_legacy_closure``). Without it every + unit reporting a legacy benefit is drawn. Returns: Boolean array aligned with ``benunit``. @@ -174,6 +185,8 @@ def assign_uc_claim_at_legacy_closure( else np.asarray(would_claim_uc, dtype=bool) ) drawn = ~on_uc & (combination != "") + if moves_to_uc is not None: + drawn &= np.asarray(moves_to_uc, dtype=bool) probability = np.zeros(len(benunit)) for c in set(combination[drawn]): cohort = drawn & (combination == c) From fcce07afa9464650b79fbb9eaa7bc8255e9a5ee0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 23:51:25 -0400 Subject: [PATCH 4/4] Draw the closure flag by the year each unit first moves to Universal Credit Review r3 (P2): the migration population was taken in the survey year only, and a unit outside it kept the default True. The model recomputes the Pension Credit route each year from ages held at survey values, so a mixed-age couple saved in 2024 loses the saving in 2025 and moves then, claiming with certainty. first_year_moved_to_uc evaluates the age conditions in each model year from 2025-26 to 2035-36; the draw is taken within each combination and first year, so every year's movers claim at DWP's rate. Reports are now the claimant's and partner's, as in policyengine-uk main, and Housing Benefit units move unless they meet the Pension Credit age conditions, as in PE-UK #1910 at 2d0e49417. Tests: each first-year group claims at the rate (pooling the groups fails it); a unit leaving the route in 2025 is drawn (r3's regression); the closure-rule table. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk_data/datasets/frs.py | 123 +++++++++++------- .../tests/test_legacy_benefit_proxies.py | 4 + .../tests/test_uc_managed_migration_takeup.py | 115 ++++++++++++---- policyengine_uk_data/utils/takeup.py | 40 ++++-- 4 files changed, 192 insertions(+), 90 deletions(-) diff --git a/policyengine_uk_data/datasets/frs.py b/policyengine_uk_data/datasets/frs.py index 0acdddc3d..58414e140 100644 --- a/policyengine_uk_data/datasets/frs.py +++ b/policyengine_uk_data/datasets/frs.py @@ -42,6 +42,7 @@ from policyengine_uk_data.utils.takeup import ( LEGACY_BENEFITS, UC_MANAGED_MIGRATION_SEED, + UC_MANAGED_MIGRATION_YEARS, assign_takeup_with_reported_anchors, assign_uc_claim_at_legacy_closure, reported_benunit_mask, @@ -580,26 +581,27 @@ def derive_moves_to_uc_at_legacy_closure( reports: dict, pension_age_route, mixed_age_couple, - any_over_state_pension_age, ) -> np.ndarray: """Identify the benefit units policyengine-uk moves to Universal Credit when their legacy benefits close, the units that read ``would_claim_uc_at_legacy_closure``. ``reports`` maps each legacy benefit in ``LEGACY_BENEFITS`` and - ``pension_credit`` to whether the unit reports it; the other arguments - are policyengine-uk's ``meets_pension_credit_age_conditions``, - ``is_mixed_age_couple`` and whether any member ``is_SP_age``. The rule is - policyengine-uk's ``legacy_benefits_closed`` with every closure in force. - Tax credit units move unless DWP sends them to Pension Credit: those on - it, and those meeting its age conditions without Working Tax Credit (SI - 2019/167 art. 3A(1); SI 2014/1230 reg 60A). Income Support, income-based - JSA and income-related ESA units move unless on Pension Credit or wholly - over the qualifying age (reg 8(2B)). Housing Benefit units move unless a - member is over State Pension age (SI 2025/1148 art. 7(4)(a)). - policyengine-uk reads the flag for income-related ESA and Housing - Benefit units only once it ends those awards (#1914, PE-UK #1910); a unit - on neither until then never reads it, so drawing it is harmless. + ``pension_credit`` to whether the claimant or partner reports it; the + other arguments are policyengine-uk's ``meets_pension_credit_age_conditions`` + and ``is_mixed_age_couple`` for one model year. The rule is + ``legacy_benefits_closed`` with every closure in force. Tax credit units + move unless DWP sends them to Pension Credit: those on it, and those + meeting its age conditions without Working Tax Credit (SI 2019/167 art. + 3A(1); SI 2014/1230 reg 60A). Income Support, income-based JSA and + income-related ESA units move unless on Pension Credit or wholly over the + qualifying age (reg 8(2B)). Housing Benefit units move unless they meet + the Pension Credit age conditions (SI 2025/1148 art. 7(4)(a), reg + 6A(4)-(5)); the specified or temporary accommodation saving has no input + in the data. policyengine-uk reads the flag for income-related ESA and + Housing Benefit units only once it ends those awards (#1914, PE-UK + #1910); a unit on neither until then never reads it, so drawing it is + harmless. """ def reported(benefit): @@ -617,48 +619,67 @@ def reported(benefit): return ( (tax_credits & ~pension_credit_route) | (working_age_benefits & ~on_pension_credit & ~wholly_pension_age) - | ( - reported("housing_benefit") - & ~np.asarray(any_over_state_pension_age, dtype=bool) - ) + | (reported("housing_benefit") & ~pension_age_route) ) -def moves_to_uc_at_legacy_closure(sim, person, benunit, year) -> np.ndarray: - """``derive_moves_to_uc_at_legacy_closure`` for the benefit units in - ``benunit``, reading the age conditions from the policyengine-uk - simulation ``sim`` built on the same people.""" - sim_benunit_ids = sim.calculate("benunit_id", year).values +def first_year_moved_to_uc(sim, benunit, year, years) -> np.ndarray: + """The first of ``years`` in which policyengine-uk moves each unit in + ``benunit`` to Universal Credit at the closure of its legacy benefits, or + 0 if it moves in none. + + ``sim`` is the policyengine-uk simulation on the survey-year (``year``) + data. The claimant's and partner's reports are read in the survey year; + the age conditions in each of ``years``. The model holds ages at their + survey values, so inferred dates of birth move with the model year: a + unit can leave the Pension Credit route in a later year, for example a + mixed-age couple whose older member is then born too late for the SI + 2019/37 art. 4 saving, and move from then. + """ + benunit_ids = benunit.benunit_id + person_benunit = sim.calculate("person_benunit_id", year).values + owners = np.asarray( + sim.calculate( + claimant_or_partner_variable(sim.tax_benefit_system.variables), year + ).values, + dtype=bool, + ) - def sim_benunit_values(variable): + def owners_report(variable): + reported = np.asarray(sim.calculate(variable, year).values) > 0 return ( - pd.Series( - np.asarray(sim.calculate(variable, year).values, dtype=bool), - index=sim_benunit_ids, - ) - .reindex(benunit.benunit_id, fill_value=False) + pd.Series(reported & owners, index=person_benunit) + .groupby(level=0) + .any() + .reindex(benunit_ids, fill_value=False) .to_numpy() ) - any_over_state_pension_age = ( - pd.Series( - np.asarray(sim.calculate("is_SP_age", year).values, dtype=bool), - index=sim.calculate("person_benunit_id", year).values, + reports = { + benefit: owners_report(f"{benefit}_reported") + for benefit in (*LEGACY_BENEFITS, "pension_credit") + } + sim_benunit_ids = sim.calculate("benunit_id", year).values + first_year = np.zeros(len(benunit_ids), dtype=int) + for model_year in sorted(years, reverse=True): + + def benunit_values(variable): + return ( + pd.Series( + np.asarray(sim.calculate(variable, model_year).values, dtype=bool), + index=sim_benunit_ids, + ) + .reindex(benunit_ids, fill_value=False) + .to_numpy() + ) + + moves = derive_moves_to_uc_at_legacy_closure( + reports, + pension_age_route=benunit_values("meets_pension_credit_age_conditions"), + mixed_age_couple=benunit_values("is_mixed_age_couple"), ) - .groupby(level=0) - .any() - .reindex(benunit.benunit_id, fill_value=False) - .to_numpy() - ) - return derive_moves_to_uc_at_legacy_closure( - reports={ - benefit: reported_benunit_mask(person, benunit, f"{benefit}_reported") - for benefit in (*LEGACY_BENEFITS, "pension_credit") - }, - pension_age_route=sim_benunit_values("meets_pension_credit_age_conditions"), - mixed_age_couple=sim_benunit_values("is_mixed_age_couple"), - any_over_state_pension_age=any_over_state_pension_age, - ) + first_year = np.where(moves, model_year, first_year) + return first_year def derive_is_claimant_or_partner_from_frs_microdata( @@ -1875,15 +1896,17 @@ def determine_education_level(fted_val, typeed2_val, age_val): "Move to Universal Credit claim flag", ) # Only units the model moves to Universal Credit read the flag, so only - # they are drawn and counted against DWP's rates. - moves_to_uc = moves_to_uc_at_legacy_closure(sim, pe_person, pe_benunit, year) + # they are drawn and counted against DWP's rates, by the year they move. + first_move_year = first_year_moved_to_uc( + sim, pe_benunit, year, UC_MANAGED_MIGRATION_YEARS + ) pe_benunit["would_claim_uc_at_legacy_closure"] = assign_uc_claim_at_legacy_closure( pe_person, pe_benunit, load_uc_managed_migration_claim_rates(), seed=UC_MANAGED_MIGRATION_SEED, would_claim_uc=pe_benunit["would_claim_uc"].values, - moves_to_uc=moves_to_uc, + first_move_year=first_move_year, ) pe_benunit["would_claim_tfc"] = generator.random(len(pe_benunit)) < tfc_rate diff --git a/policyengine_uk_data/tests/test_legacy_benefit_proxies.py b/policyengine_uk_data/tests/test_legacy_benefit_proxies.py index 54235df49..d2a323ab2 100644 --- a/policyengine_uk_data/tests/test_legacy_benefit_proxies.py +++ b/policyengine_uk_data/tests/test_legacy_benefit_proxies.py @@ -424,6 +424,10 @@ def calculate(self, variable, year=None): return pd.Series(self.dataset.person.age.values >= 66) if variable == "benunit_id": return pd.Series(self.dataset.benunit.benunit_id.values) + if variable.endswith("_reported"): + if variable in self.dataset.person.columns: + return pd.Series(self.dataset.person[variable].values) + return pd.Series(np.zeros(len(self.dataset.person))) if variable in ("meets_pension_credit_age_conditions", "is_mixed_age_couple"): return pd.Series(np.zeros(len(self.dataset.benunit), dtype=bool)) if variable in ( diff --git a/policyengine_uk_data/tests/test_uc_managed_migration_takeup.py b/policyengine_uk_data/tests/test_uc_managed_migration_takeup.py index c7eba8339..9a14b1f93 100644 --- a/policyengine_uk_data/tests/test_uc_managed_migration_takeup.py +++ b/policyengine_uk_data/tests/test_uc_managed_migration_takeup.py @@ -37,6 +37,7 @@ from policyengine_uk_data.utils.takeup import ( LEGACY_BENEFITS, UC_MANAGED_MIGRATION_SEED, + UC_MANAGED_MIGRATION_YEARS, assign_uc_claim_at_legacy_closure, legacy_benefit_combination, ) @@ -215,20 +216,23 @@ def test_built_dataset_anchors(fixture, request): def test_built_frs_cohort_shares(frs): # The FRS build, before SPI rows and geography clones, has one row per # surveyed benefit unit, so its draws are independent. Among units the - # model moves to Universal Credit and not claiming anyway, the flag's - # share is the conditional rate. + # model moves to Universal Credit, by first year moved, and not claiming + # anyway, the flag's share is the conditional rate. from policyengine_uk import Microsimulation - from policyengine_uk_data.datasets.frs import moves_to_uc_at_legacy_closure + from policyengine_uk_data.datasets.frs import first_year_moved_to_uc flags, combination, on_uc = _built_flags(frs) anyway = frs.benunit["would_claim_uc"].values.astype(bool) - moves = moves_to_uc_at_legacy_closure( - Microsimulation(dataset=frs), frs.person, frs.benunit, frs.time_period + first_year = first_year_moved_to_uc( + Microsimulation(dataset=frs), + frs.benunit, + frs.time_period, + UC_MANAGED_MIGRATION_YEARS, ) - drawn = ~on_uc & (combination != "") & moves - for c in set(combination[drawn]): - cohort = drawn & (combination == c) + drawn = ~on_uc & (combination != "") & (first_year > 0) + for c, year in set(zip(combination[drawn], first_year[drawn])): + cohort = drawn & (combination == c) & (first_year == year) p = anyway[cohort].mean() rate = RATES.get(c, RATES["all"]) q = 0.0 if p >= 1 else min(max((rate - p) / (1 - p), 0.0), 1.0) @@ -356,7 +360,7 @@ def test_units_not_moved_are_neither_drawn_nor_counted(): RATES, seed=UC_MANAGED_MIGRATION_SEED, would_claim_uc=anyway, - moves_to_uc=moves, + first_move_year=np.where(moves, 2025, 0), ) assert flags[~moves].all() rate = RATES["child_tax_credit"] @@ -389,27 +393,86 @@ def test_moves_to_uc_follows_the_closure_rule(): ) cases = [ - # (reported benefits, pension-age route, mixed-age couple, - # member over State Pension age, moved) - ({"child_tax_credit"}, False, False, False, True), - ({"child_tax_credit"}, True, False, True, False), - ({"child_tax_credit", "pension_credit"}, False, False, False, False), - ({"working_tax_credit"}, True, False, True, True), - ({"child_tax_credit"}, True, True, True, False), - ({"income_support"}, True, True, True, True), - ({"income_support"}, True, False, True, False), - ({"jsa_income", "pension_credit"}, True, True, True, False), - ({"esa_income"}, False, False, False, True), - ({"housing_benefit"}, False, False, False, True), - ({"housing_benefit"}, False, False, True, False), - ({"housing_benefit"}, True, True, True, False), - (set(), False, False, False, False), + # (claimant or partner reports, Pension Credit age conditions, + # mixed-age couple, moved) + ({"child_tax_credit"}, False, False, True), + ({"child_tax_credit"}, True, False, False), + ({"child_tax_credit", "pension_credit"}, False, False, False), + ({"working_tax_credit"}, True, False, True), + ({"child_tax_credit"}, True, True, False), + ({"income_support"}, True, True, True), + ({"income_support"}, True, False, False), + ({"jsa_income", "pension_credit"}, True, True, False), + ({"esa_income"}, False, False, True), + ({"housing_benefit"}, False, False, True), + # A mixed-age couple without the 2019 saving loses its award. + ({"housing_benefit"}, False, True, True), + ({"housing_benefit"}, True, True, False), + ({"housing_benefit"}, True, False, False), + (set(), False, False, False), ] benefits = (*LEGACY_BENEFITS, "pension_credit") moved = derive_moves_to_uc_at_legacy_closure( reports={b: np.array([b in case[0] for case in cases]) for b in benefits}, pension_age_route=np.array([case[1] for case in cases]), mixed_age_couple=np.array([case[2] for case in cases]), - any_over_state_pension_age=np.array([case[3] for case in cases]), ) - assert moved.tolist() == [case[4] for case in cases] + assert moved.tolist() == [case[3] for case in cases] + + +def test_each_first_year_group_claims_at_the_rate(): + """Units first moving in a later year are drawn on their own, so the + movers of every year claim at DWP's rate: here half of the 2025 movers + claim anyway and none of the 2027 movers do.""" + n = 20_000 + units = [[{"child_tax_credit_reported": 100.0}] for _ in range(2 * n)] + person, benunit = frames(units) + early = (benunit["benunit_id"] < 10 + n).values + anyway = early & (np.random.default_rng(5).random(2 * n) < 0.5) + flags = assign_uc_claim_at_legacy_closure( + person, + benunit, + RATES, + seed=UC_MANAGED_MIGRATION_SEED, + would_claim_uc=anyway, + first_move_year=np.where(early, 2025, 2027), + ) + rate = RATES["child_tax_credit"] + claims = anyway | flags + for movers in (early, np.ones(2 * n, dtype=bool)): + share = claims[movers].mean() + assert abs(share - rate) <= 4 * np.sqrt(rate * (1 - rate) / movers.sum()) + + +class _YearlySimulation: + """A simulation whose Pension Credit route depends on the year: the + couple meets the age conditions only in 2024, as a mixed-age couple + whose older member is born too late for the saving from 2025.""" + + class tax_benefit_system: + variables = {"is_claimant_or_partner": None} + + def calculate(self, variable, year): + values = { + "person_benunit_id": [10, 10, 10], + "is_claimant_or_partner": [True, True, False], + "child_tax_credit_reported": [100.0, 0.0, 0.0], + "housing_benefit_reported": [100.0, 0.0, 0.0], + "benunit_id": [10], + "meets_pension_credit_age_conditions": [year < 2025], + "is_mixed_age_couple": [True], + }.get(variable, [0.0, 0.0, 0.0]) + return pd.Series(values) + + +def test_a_unit_leaving_the_pension_route_later_is_drawn(): + """A unit on the Pension Credit route in the survey year that the model + moves from 2025 is drawn, not given the default True (review r3).""" + from policyengine_uk_data.datasets.frs import first_year_moved_to_uc + + benunit = pd.DataFrame({"benunit_id": [10]}) + sim = _YearlySimulation() + assert first_year_moved_to_uc(sim, benunit, 2024, [2024]).tolist() == [0] + assert first_year_moved_to_uc(sim, benunit, 2024, range(2025, 2036)).tolist() == [ + 2025 + ] diff --git a/policyengine_uk_data/utils/takeup.py b/policyengine_uk_data/utils/takeup.py index aaa1e613c..067925a9d 100644 --- a/policyengine_uk_data/utils/takeup.py +++ b/policyengine_uk_data/utils/takeup.py @@ -101,6 +101,9 @@ def solve_fill_probability( # the draws behind every other take-up flag are unchanged. UC_MANAGED_MIGRATION_SEED = 492 UC_MANAGED_MIGRATION_SPI_SEED = 493 +# Model years whose movers the draw targets: from 2025-26, when tax credits +# closed, to 2035-36. +UC_MANAGED_MIGRATION_YEARS = tuple(range(2025, 2036)) def reported_benunit_mask( @@ -137,7 +140,7 @@ def assign_uc_claim_at_legacy_closure( rates: dict[str, float], seed: int, would_claim_uc: Optional[np.ndarray] = None, - moves_to_uc: Optional[np.ndarray] = None, + first_move_year: Optional[np.ndarray] = None, ) -> np.ndarray: """Draw ``would_claim_uc_at_legacy_closure`` for each benefit unit. @@ -152,11 +155,17 @@ def assign_uc_claim_at_legacy_closure( combination's such units with ``would_claim_uc`` True (unweighted: the draw precedes calibration). Without ``would_claim_uc``, ``p`` is 0. Only units that policyengine-uk moves to Universal Credit when their - legacy benefits close (``moves_to_uc``) are drawn and counted in ``p``: - a unit DWP sends to Pension Credit instead never reads the flag, and - counting it would move the share among the units that do away from ``r``. + legacy benefits close are drawn and counted in ``p``: a unit DWP sends to + Pension Credit instead never reads the flag, and counting it would move + the share among the units that do away from ``r``. ``first_move_year`` + is the first model year in which the model moves each unit (0 for never; + ``first_year_moved_to_uc``). A unit can first move after others in its + combination, since the model recomputes the Pension Credit route each + year from ages held at their survey values. So ``p`` and the draw are + taken within each combination and first year: each such group claims at + ``r``, and so do each year's movers, the groups that have moved by then. Units reporting Universal Credit, units already claiming, units reporting - no legacy benefit and units not moved are True, the model's default. + no legacy benefit and units never moved are True, the model's default. The share matches ``r`` in expectation, not exactly: the draw is random, and ``(r - p) / (1 - p)`` is clipped at 0 when ``p`` exceeds ``r``. @@ -169,10 +178,10 @@ def assign_uc_claim_at_legacy_closure( seed: Seed for this draw's own generator. would_claim_uc: The units' ``would_claim_uc``, aligned with ``benunit``. - moves_to_uc: Whether policyengine-uk moves each unit to Universal - Credit when its legacy benefits close, aligned with ``benunit`` - (``derive_moves_to_uc_at_legacy_closure``). Without it every - unit reporting a legacy benefit is drawn. + first_move_year: The first model year in which policyengine-uk + moves each unit to Universal Credit, 0 for never, aligned with + ``benunit``. Without it every unit reporting a legacy benefit is + drawn, as one group per combination. Returns: Boolean array aligned with ``benunit``. @@ -184,12 +193,15 @@ def assign_uc_claim_at_legacy_closure( if would_claim_uc is None else np.asarray(would_claim_uc, dtype=bool) ) - drawn = ~on_uc & (combination != "") - if moves_to_uc is not None: - drawn &= np.asarray(moves_to_uc, dtype=bool) + first_year = ( + np.ones(len(benunit), dtype=int) + if first_move_year is None + else np.asarray(first_move_year, dtype=int) + ) + drawn = ~on_uc & (combination != "") & (first_year > 0) probability = np.zeros(len(benunit)) - for c in set(combination[drawn]): - cohort = drawn & (combination == c) + for c, year in set(zip(combination[drawn], first_year[drawn])): + cohort = drawn & (combination == c) & (first_year == year) rate = rates.get(c, rates["all"]) p = claims_anyway[cohort].mean() probability[cohort] = 0.0 if p >= 1 else np.clip((rate - p) / (1 - p), 0, 1)