From 2ac3c221ab7c1c6027bf5e786abd66a0d048a6ad Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 10:40:14 -0400 Subject: [PATCH 1/2] 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/2] 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)