diff --git a/changelog.d/frs-uc-startup-period.fixed.md b/changelog.d/frs-uc-startup-period.fixed.md new file mode 100644 index 00000000..b05a4852 --- /dev/null +++ b/changelog.d/frs-uc-startup-period.fixed.md @@ -0,0 +1 @@ +Set policyengine-uk's Universal Credit input uc_is_in_startup_period from the FRS (UC Regs 2013 reg 63 as amended from 23 September 2020): true for a self-employed adult whose benefit unit's UC claim (DWP administrative start date, UCSTART) or current business (SEJBLONG) began less than 12 months before interview. UC records without a linked start date are drawn at the survey-weighted share of linked self-employed claims that began within the window. diff --git a/policyengine_uk_data/datasets/frs.py b/policyengine_uk_data/datasets/frs.py index e440d486..183ad190 100644 --- a/policyengine_uk_data/datasets/frs.py +++ b/policyengine_uk_data/datasets/frs.py @@ -53,6 +53,10 @@ EmploymentStatus.LONG_TERM_DISABLED.name, EmploymentStatus.SHORT_TERM_DISABLED.name, ) +SELF_EMPLOYED_STATUSES = ( + EmploymentStatus.FT_SELF_EMPLOYED.name, + EmploymentStatus.PT_SELF_EMPLOYED.name, +) FORMULA_MODELED_EDUCATION_GRANT_VARIABLES = ( "childcare_grant", "parents_learning_allowance", @@ -570,6 +574,218 @@ def validate_frs_survey_year(raw_frs_folder, year: int) -> None: ) +# Universal Credit start-up period (UC Regs 2013 reg 63). The FRS codes below +# are from the 2024-25 data dictionaries. +UC_BENEFIT_CODE = 95 # BENEFITS.BENEFIT +UC_START_UP_PERIOD_MONTHS = 12 # reg 63(1) +FRS_SELF_EMPLOYED_EMPSTATI = (3, 4) # ADULT.EMPSTATI: FT / PT self-employed +FRS_SELF_EMPLOYED_ACTIVITIES = (3, 4) # ADULT.SDEMP01-12: FT / PT self-employed +FRS_SELF_EMPLOYED_JOB_ETYPES = (2, 3, 4, 5, 6, 7) # JOB.ETYPE other than employee +FRS_JOBBUS_BUSINESS = 2 # JOB.JOBBUS: "A business", not "Job" +UC_CLAIM_RECENCY_SEED = 63 + + +def frs_interview_date(intdate) -> pd.Series: + """Interview dates from FRS ``INTDATE``, a SAS date (days since 1 January 1960).""" + return pd.to_datetime( + pd.Series(intdate, dtype=float), unit="D", origin="1960-01-01" + ) + + +def parse_frs_uc_claim_start(raw) -> pd.Series: + """UC claim start dates from FRS ``UCSTART``. + + ``UCSTART`` comes from DWP administrative data and is written as + month/day/year (in 2024-25 the first field never exceeds 12 and the second + reaches 31). It is blank where the survey's UC record is not linked to an + administrative record, which leaves every other admin UC field blank too. + Any other value raises, so a changed format cannot pass silently. + """ + text = pd.Series(raw, dtype="string").str.strip() + text = text.mask(text == "") + parsed = pd.to_datetime(text, format="%m/%d/%Y", errors="coerce") + unparsed = text.notna() & parsed.isna() + if unparsed.any(): + raise ValueError( + f"{int(unparsed.sum())} FRS UCSTART values are not month/day/year dates." + ) + return parsed + + +def completed_months(later, earlier) -> np.ndarray: + """Whole calendar months from ``earlier`` to ``later``; NaN where either is missing.""" + later, earlier = pd.DatetimeIndex(later), pd.DatetimeIndex(earlier) + months = ( + (later.year - earlier.year) * 12 + + (later.month - earlier.month) + - (later.day < earlier.day) + ) + return np.where(later.isna() | earlier.isna(), np.nan, months) + + +def uc_claim_began_in_start_up_window( + months_since_claim_start, reports_uc, draws, unlinked_share +) -> np.ndarray: + """Whether each benefit unit's UC claim began within the last 12 months. + + A linked claim decides from its start date. An unlinked UC record has no + start date, so it is drawn at ``unlinked_share``, the share of linked + claims that began within the window. A benefit unit without UC is false. + """ + months = np.asarray(months_since_claim_start, dtype=float) + linked = ~np.isnan(months) + recent = linked & (months < UC_START_UP_PERIOD_MONTHS) + unlinked = np.asarray(reports_uc, dtype=bool) & ~linked + return recent | (unlinked & (np.asarray(draws, dtype=float) < unlinked_share)) + + +def years_running_trade( + years_in_job, describes_business, self_employed_all_year +) -> np.ndarray: + """Completed years in the trade behind a self-employed job; NaN when unknown. + + SEJBLONG asks someone running a business how long they have run it, and + anyone else how long they have been in their current self-employed job. + For a person self-employed throughout the last 12 months, a job under a + year old is a new engagement in the same trade (ADM H4102 example 4 treats + a hairdresser turned hairstylist as one trade), so it does not date the + trade. + """ + years = np.asarray(years_in_job, dtype=float) + new_engagement = ( + (years < 1) + & ~np.asarray(describes_business, dtype=bool) + & np.asarray(self_employed_all_year, dtype=bool) + ) + return np.where(new_engagement, np.nan, years) + + +def derive_uc_is_in_startup_period( + self_employed, uc_claim_began_in_window, years_running_business +) -> np.ndarray: + """Whether each person is in a Universal Credit start-up period. + + Reg 63(1) starts a 12-month start-up period in the assessment period in + which DWP determines the claimant is in gainful self-employment, if the + minimum income floor has not already applied to them for the trade that is + now their main employment. Since 23 September 2020 (SI 2019/1152) this is + not limited to new trades: an established trader who newly claims gets + one. DWP determines gainful self-employment at the start of a claim, or + when a claimant reports a new trade, so in survey terms the period is + running when the person is self-employed and either their benefit unit's + UC claim or their trade began less than 12 months before interview. + + The flag says whether a period was running at interview. Across a steady + population that equals the expected share of the year spent in one. The + 2024-25 survey year overlapped tax credit managed migration, but the share + of linked UC claims under 12 months old held at 27-29% in every interview + quarter, so the cross-section shows no migration bump. + + The FRS cannot see earlier UC awards, so a re-claim after the floor + applied for the same trade, and a second start-up period within five + years (reg 63(2)), count as start-up periods here. Nor can it see a move + into the all-work-related-requirements group on an old claim (DWP decides + gainful self-employment only then, for example when the youngest child + turns 3), which starts a period the flag misses. + """ + years = np.asarray(years_running_business, dtype=float) + return np.asarray(self_employed, dtype=bool) & ( + np.asarray(uc_claim_began_in_window, dtype=bool) | (years < 1) + ) + + +def add_uc_start_up_period( + pe_person: pd.DataFrame, + pe_benunit: pd.DataFrame, + person: pd.DataFrame, + household: pd.DataFrame, + job: pd.DataFrame, + benefits: pd.DataFrame, +) -> None: + """Set ``uc_is_in_startup_period`` on ``pe_person`` from the raw FRS tables. + + ``benefits.ucstart_raw`` must hold UCSTART as read, before numeric + conversion. + """ + interview = frs_interview_date(household.intdate) + interview.index = household.index + if interview.isna().any(): + raise ValueError("FRS INTDATE (interview date) is missing for some households.") + uc = benefits.benefit.to_numpy() == UC_BENEFIT_CODE + claim = pd.DataFrame( + { + "benunit_id": benefits.benunit_id.to_numpy()[uc], + "months": completed_months( + interview.reindex(benefits.household_id.to_numpy()[uc]), + parse_frs_uc_claim_start(benefits.ucstart_raw.to_numpy()[uc]), + ), + } + ) + benunit_ids = pe_benunit.benunit_id.to_numpy() + # One UC claim per benefit unit; take the latest start if rows disagree. + months_by_benunit = claim.groupby("benunit_id").months.min().reindex(benunit_ids) + reports_uc = np.isin(benunit_ids, claim.benunit_id) + + # Self-employed jobs still held (SEEND is the date a respondent stopped). + se_jobs = job[ + job.etype.isin(FRS_SELF_EMPLOYED_JOB_ETYPES) & ~(job.seend.fillna(0) > 0) + ] + # The person's first self-employed job (main job first) is the trade the + # start-up period follows. + first_se_job = ( + se_jobs.sort_values("jobtype") + .drop_duplicates("person_id") + .set_index("person_id") + ) + person_ids = person.person_id.to_numpy() + calendar = person[[f"sdemp{month:02d}" for month in range(1, 13)]] + main_job_self_employed = person.empstati.isin(FRS_SELF_EMPLOYED_EMPSTATI).to_numpy() + self_employed_all_year = main_job_self_employed & ( + (person.samesit == 2).to_numpy() + | calendar.isin(FRS_SELF_EMPLOYED_ACTIVITIES).all(axis=1).to_numpy() + ) + years_running_business = years_running_trade( + first_se_job.sejblong.where(lambda years: years >= 0).reindex(person_ids), + (first_se_job.jobbus == FRS_JOBBUS_BUSINESS).reindex( + person_ids, fill_value=False + ), + self_employed_all_year, + ) + self_employed = ( + main_job_self_employed + | np.isin(person_ids, se_jobs.person_id) + | (person.seincam2.to_numpy() != 0) + ) + + # Unlinked UC records are drawn at the share of linked self-employed + # claimants (survey-weighted) whose claim began within the window. That + # treats a missing link as unrelated to the claim's age. + person_benunit = pd.Index(benunit_ids).get_indexer(person.benunit_id) + assert (person_benunit >= 0).all(), "a person's benefit unit is missing" + person_months = months_by_benunit.to_numpy()[person_benunit] + linked_se = self_employed & ~np.isnan(person_months) + linked_recent = uc_claim_began_in_start_up_window( + person_months, + np.zeros(len(person_months), dtype=bool), + np.ones(len(person_months)), + 0.0, + ) + weight = household.gross4.reindex(person.household_id).to_numpy(dtype=float) + linked_weight = weight[linked_se].sum() + unlinked_share = ( + weight[linked_se & linked_recent].sum() / linked_weight + if linked_weight > 0 + else 0.0 + ) + draws = np.random.default_rng(UC_CLAIM_RECENCY_SEED).random(len(benunit_ids)) + claim_in_window = uc_claim_began_in_start_up_window( + months_by_benunit.to_numpy(), reports_uc, draws, unlinked_share + ) + pe_person["uc_is_in_startup_period"] = derive_uc_is_in_startup_period( + self_employed, claim_in_window[person_benunit], years_running_business + ) + + def create_frs( raw_frs_folder: str, year: int, @@ -630,9 +846,20 @@ def create_frs( # '1' = Yes, '2' = No, ' ' or blank = skip/not asked if table_name == "job" and "salsac" in df_raw.columns: job_salsac_raw = df_raw["salsac"].copy() + # UCSTART is a month/day/year date string, which numeric conversion + # would blank, so keep it as read. + if table_name == "benefits": + if "ucstart" not in df_raw.columns: + raise ValueError( + "The FRS BENEFITS table has no UCSTART column (the UC claim " + "start date), which the UC start-up period input needs." + ) + ucstart_raw = df_raw["ucstart"].to_numpy() # Make numeric where possible df = df_raw.apply(pd.to_numeric, errors="coerce") + if table_name == "benefits": + df["ucstart_raw"] = ucstart_raw # Standardise column names to lower case (already done above) # df.columns = df.columns.str.lower() @@ -1166,7 +1393,7 @@ def determine_education_level(fted_val, typeed2_val, age_val): state_pension=5, winter_fuel_allowance=62, incapacity_benefit=17, - universal_credit=95, + universal_credit=UC_BENEFIT_CODE, pip_m=97, pip_dl=96, ) @@ -1179,6 +1406,7 @@ def determine_education_level(fted_val, typeed2_val, age_val): ) * WEEKS_IN_YEAR ) + add_uc_start_up_period(pe_person, pe_benunit, person, household, job, benefits) pe_person = add_disability_benefit_categories_from_reported_amounts( pe_person, diff --git a/policyengine_uk_data/datasets/imputations/income.py b/policyengine_uk_data/datasets/imputations/income.py index feafcb27..a8a93bb4 100644 --- a/policyengine_uk_data/datasets/imputations/income.py +++ b/policyengine_uk_data/datasets/imputations/income.py @@ -298,6 +298,17 @@ def impute_income(dataset: UKSingleYearDataset) -> UKSingleYearDataset: ["dividend_income"], ) + # The copy keeps its donor's start-up flag and employment status; clear the + # flag where that status and the copy's imputed income leave no + # self-employment. + if "uc_is_in_startup_period" in zero_weight_copy.person.columns: + from policyengine_uk_data.datasets.frs import SELF_EMPLOYED_STATUSES + + person = zero_weight_copy.person + person["uc_is_in_startup_period"] &= person.employment_status.isin( + SELF_EMPLOYED_STATUSES + ) | (person.self_employment_income != 0) + zero_weight_copy.validate() dataset.validate() diff --git a/policyengine_uk_data/tests/test_legacy_benefit_proxies.py b/policyengine_uk_data/tests/test_legacy_benefit_proxies.py index 5f1acd85..fa5eddd3 100644 --- a/policyengine_uk_data/tests/test_legacy_benefit_proxies.py +++ b/policyengine_uk_data/tests/test_legacy_benefit_proxies.py @@ -465,6 +465,8 @@ def fake_read_csv(path, *args, **kwargs): "allpay4": 0, "grtdir1": 100, "grtdir2": 0, + "samesit": 2, + **{f"sdemp{month:02d}": 0 for month in range(1, 13)}, } ] ) @@ -483,6 +485,7 @@ def fake_read_csv(path, *args, **kwargs): "cwatamtd": 0, "gross4": 0, "gvtregno": 1, + "intdate": 23650, "hhrent": 0, "mortint": 0, "ptentyp2": 0, @@ -518,9 +521,30 @@ def fake_read_csv(path, *args, **kwargs): "accounts": pd.DataFrame( columns=["person", "sernum", "accint", "acctax", "invtax", "account"] ), - "job": pd.DataFrame(columns=["person", "sernum", "deduc1", "spnamt", "salsac"]), + "job": pd.DataFrame( + columns=[ + "person", + "sernum", + "deduc1", + "spnamt", + "salsac", + "jobtype", + "etype", + "sejblong", + "jobbus", + "seend", + ] + ), "benefits": pd.DataFrame( - columns=["person", "sernum", "benamt", "benefit", "var2"] + columns=[ + "person", + "sernum", + "benunit", + "benamt", + "benefit", + "var2", + "ucstart", + ] ), "maint": pd.DataFrame(columns=["person", "sernum", "mramt", "mruamt", "mrus"]), "penprov": pd.DataFrame(columns=["person", "sernum", "penamt", "stemppen"]), @@ -548,7 +572,9 @@ def fake_read_csv(path, *args, **kwargs): "age_started_or_accepted_current_education_or_training", "is_before_universal_credit_qualifying_young_person_terminal_date", "is_parent", + "uc_is_in_startup_period", }.issubset(dataset.person.columns) + assert not dataset.person["uc_is_in_startup_period"].iloc[0] assert not dataset.person["is_parent"].iloc[0] assert not dataset.person["is_in_non_advanced_education"].iloc[0] assert not dataset.person["is_in_approved_training"].iloc[0] diff --git a/policyengine_uk_data/tests/test_uc_startup_period.py b/policyengine_uk_data/tests/test_uc_startup_period.py new file mode 100644 index 00000000..f3cdbda1 --- /dev/null +++ b/policyengine_uk_data/tests/test_uc_startup_period.py @@ -0,0 +1,619 @@ +import numpy as np +import pandas as pd +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine_uk_data.datasets.frs import ( + SELF_EMPLOYED_STATUSES, + UC_BENEFIT_CODE, + UC_START_UP_PERIOD_MONTHS, + add_uc_start_up_period, + completed_months, + derive_uc_is_in_startup_period, + frs_interview_date, + parse_frs_uc_claim_start, + uc_claim_began_in_start_up_window, + years_running_trade, +) +from policyengine_uk_data.tests.test_imputation_source_flags import ( + _FakeDataset, + _stack_without_remapping, +) + +START_UP = "uc_is_in_startup_period" +months = st.one_of(st.just(np.nan), st.floats(0, 200, allow_nan=False)) +years = st.one_of(st.just(np.nan), st.integers(0, 60).map(float)) +draws = st.floats(0, 1, allow_nan=False, exclude_max=True) +shares = st.floats(0, 1, allow_nan=False) +dates = st.dates( + min_value=pd.Timestamp("2000-01-01").date(), + max_value=pd.Timestamp("2030-12-31").date(), +) + + +def sas_date(day: str) -> int: + return (pd.Timestamp(day) - pd.Timestamp("1960-01-01")).days + + +def test_interview_date_is_a_sas_date(): + assert frs_interview_date([0])[0] == pd.Timestamp("1960-01-01") + assert frs_interview_date([sas_date("2024-04-01")])[0] == pd.Timestamp("2024-04-01") + + +def test_claim_start_is_month_day_year_and_blank_when_unlinked(): + parsed = parse_frs_uc_claim_start(["1/31/2024", " 12/1/2019 ", "", None, " "]) + assert parsed[0] == pd.Timestamp("2024-01-31") + assert parsed[1] == pd.Timestamp("2019-12-01") + assert parsed[2:].isna().all() + + +@pytest.mark.parametrize("value", ["31/1/2024", "2024-01-31", "45000", "1/2024"]) +def test_claim_start_in_another_format_raises(value): + with pytest.raises(ValueError, match="UCSTART"): + parse_frs_uc_claim_start(["1/31/2024", value]) + + +def test_completed_months_counts_whole_calendar_months(): + later = pd.Series(pd.to_datetime(["2024-10-01"] * 3 + ["2025-01-31", None])) + earlier = pd.Series( + pd.to_datetime( + ["2023-10-01", "2023-10-02", "2024-06-15", "2024-12-31", "2024-01-01"] + ) + ) + result = completed_months(later, earlier) + assert result[:4].tolist() == [12, 11, 3, 1] + assert np.isnan(result[4]) + + +@settings(max_examples=500, deadline=None) +@given(dates, dates, st.integers(0, 40)) +def test_completed_months_invariants(a, b, k): + def m(later, earlier): + return completed_months( + pd.Series([pd.Timestamp(later)]), pd.Series([pd.Timestamp(earlier)]) + )[0] + + assert m(a, a) == 0 + # Swapping the dates negates the count, less one unless the days match. + assert m(a, b) + m(b, a) == -(a.day != b.day) + # k calendar months on: k whole months, or k - 1 when the day clips at a month end. + shifted = pd.Timestamp(a) + pd.DateOffset(months=k) + assert m(shifted, a) in (k, k - 1) + if a.day <= 28: + assert m(shifted, a) == k + # A later end date never counts fewer months. + assert m(pd.Timestamp(b) + pd.Timedelta(days=1), a) >= m(b, a) + + +def test_start_up_window_is_twelve_months(): + assert UC_START_UP_PERIOD_MONTHS == 12 + window = uc_claim_began_in_start_up_window( + [0.0, 11.0, 12.0, 30.0, np.nan, np.nan], + [True, True, True, True, False, True], + np.full(6, 0.5), + 0.0, + ) + assert window.tolist() == [True, True, False, False, False, False] + + +@settings(max_examples=500, deadline=None) +@given(months, st.booleans(), draws, shares) +def test_claim_window_invariants(month, reports_uc, draw, share): + def window(m=month, r=reports_uc, d=draw, s=share): + return uc_claim_began_in_start_up_window([m], [r], [d], s)[0] + + result = window() + if np.isnan(month): + # No start date: an unlinked UC record is drawn, no UC is false. + assert result == (reports_uc and draw < share) + assert not window(r=False) + else: + # A linked claim decides from its date alone. + assert result == (month < UC_START_UP_PERIOD_MONTHS) + assert window(d=0.0, s=1.0) == window(d=0.999, s=0.0) == result + # A later start never takes the window away; an earlier one never adds it. + assert window(m=month / 2) >= result + assert window(m=month + 12) <= result + # A higher imputed share never removes the window. + assert window(s=1.0) >= result >= window(s=0.0) + + +@settings(max_examples=500, deadline=None) +@given(years, st.booleans(), st.booleans()) +def test_trade_age_invariants(years_in_job, business, all_year): + trade = years_running_trade([years_in_job], [business], [all_year])[0] + # The trade's age is the job's, or unknown; never a different number. + assert np.isnan(trade) or trade == years_in_job + # Unknown only for a job under a year old, held by someone self-employed + # all year, that is not described as running a business. + lost = np.isnan(trade) and not np.isnan(years_in_job) + assert lost == (years_in_job < 1 and not business and all_year) + # A business's age is always its own. + if business: + assert np.array_equal( + years_running_trade([years_in_job], [True], [all_year]), + [years_in_job], + equal_nan=True, + ) + + +@settings(max_examples=500, deadline=None) +@given(st.booleans(), st.booleans(), years) +def test_start_up_invariants(self_employed, claim_in_window, years_running): + def start_up(se=self_employed, c=claim_in_window, y=years_running): + return derive_uc_is_in_startup_period([se], [c], [y])[0] + + result = start_up() + # Only the self-employed have a start-up period, and only with a recent + # claim or a trade under a year old. + if result: + assert self_employed + assert claim_in_window or years_running < 1 + assert not start_up(se=False) + # Either route alone suffices for the self-employed. + assert start_up(se=True, c=True) + assert start_up(se=True, y=0.0) + # An older trade never adds the period; a recent claim never removes it. + if not np.isnan(years_running): + assert start_up(y=years_running + 1) <= result + assert start_up(c=True) >= result + + +@settings(max_examples=100, deadline=None) +@given(st.lists(st.tuples(st.booleans(), st.booleans(), years), max_size=40)) +def test_start_up_vectorised_matches_elementwise(rows): + se, claim, yrs = ([r[i] for r in rows] for i in range(3)) + result = derive_uc_is_in_startup_period(se, claim, yrs) + assert result.dtype == bool + assert result.tolist() == [ + derive_uc_is_in_startup_period([a], [b], [c])[0] for a, b, c in rows + ] + + +INTERVIEW = "2024-10-01" +RECENT_CLAIM, OLD_CLAIM, UNLINKED = "6/1/2024", "1/15/2020", "" +SE, EMPLOYEE = 3, 1 +BUSINESS, JOB = 2, 1 +SE_MONTH, EMPLOYEE_MONTH = 3, 1 + + +def job(jobtype=1, etype=4, years=5, jobbus=BUSINESS, seend=None): + return dict( + jobtype=jobtype, etype=etype, sejblong=years, jobbus=jobbus, seend=seend + ) + + +def adult( + hh, + bu=1, + empstati=SE, + profit=100.0, + jobs=None, + claims=(), + weight=1.0, + samesit=2, + calendar=None, +): + """One FRS adult. ``claims`` are UCSTART values for UC rows on their + benefit unit (blank = unlinked); ``calendar`` is SDEMP01-12.""" + return dict( + hh=hh, + bu=bu, + empstati=empstati, + profit=profit, + jobs=[job()] if jobs is None and empstati == SE else (jobs or []), + claims=claims, + weight=weight, + samesit=samesit, + calendar=calendar or [0] * 12, + ) + + +def start_up_flags(people, intdate=None): + """Run add_uc_start_up_period on raw-shaped tables (ids combined as create_frs does).""" + person_rows, job_rows, benefit_rows, households = [], [], [], {} + for i, p in enumerate(people): + person_id = p["hh"] * 1000 + i + 1 + benunit_id = p["hh"] * 100 + p["bu"] + households[p["hh"]] = p["weight"] + person_rows.append( + dict( + person_id=person_id, + benunit_id=benunit_id, + household_id=p["hh"], + empstati=p["empstati"], + seincam2=p["profit"], + samesit=p["samesit"], + **{ + f"sdemp{m:02d}": code + for m, code in enumerate(p["calendar"], start=1) + }, + ) + ) + job_rows += [dict(person_id=person_id, **j) for j in p["jobs"]] + benefit_rows += [ + dict( + household_id=p["hh"], + benunit_id=benunit_id, + benefit=UC_BENEFIT_CODE, + ucstart_raw=c, + ) + for c in p["claims"] + ] + person = pd.DataFrame(person_rows) + # A non-UC benefit row on the last person's benefit unit, which must not + # count as a UC claim. + benefit_rows.append( + dict( + household_id=person.household_id.iloc[-1], + benunit_id=person.benunit_id.iloc[-1], + benefit=3, + ucstart_raw="", + ) + ) + household = pd.DataFrame( + { + "household_id": list(households), + "intdate": [ + (intdate or {}).get(h, sas_date(INTERVIEW)) for h in households + ], + "gross4": list(households.values()), + } + ).set_index("household_id") + jobs = pd.DataFrame( + job_rows, + columns=["person_id", "jobtype", "etype", "sejblong", "jobbus", "seend"], + ) + jobs[["jobtype", "etype", "sejblong", "jobbus", "seend"]] = jobs[ + ["jobtype", "etype", "sejblong", "jobbus", "seend"] + ].astype(float) + benefits = pd.DataFrame(benefit_rows).astype({"ucstart_raw": object}) + pe_person = pd.DataFrame({"person_id": person.person_id}) + pe_benunit = pd.DataFrame({"benunit_id": np.unique(person.benunit_id)}) + add_uc_start_up_period(pe_person, pe_benunit, person, household, jobs, benefits) + return pe_person[START_UP].tolist() + + +CASES = [ + # (description, adults, expected flags) + ( + "couple, claim four months old: the self-employed partner only", + [ + adult(1, claims=[RECENT_CLAIM]), + adult(1, empstati=EMPLOYEE, profit=0.0, jobs=[job(etype=1, years=-1)]), + ], + [True, False], + ), + ( + "old claim, old business: the floor's ordinary case", + [adult(1, claims=[OLD_CLAIM])], + [False], + ), + ( + "old claim, business under a year old", + [adult(1, jobs=[job(years=0)], claims=[OLD_CLAIM])], + [True], + ), + ( + "a claim exactly 12 calendar months old is outside", + [adult(1, claims=["10/1/2023"])], + [False], + ), + ( + "a claim a day under 12 months old is inside", + [adult(1, claims=["10/2/2023"])], + [True], + ), + ( + "two UC rows on one unit: the latest start", + [adult(1, claims=[OLD_CLAIM, RECENT_CLAIM])], + [True], + ), + ("no UC: a new business", [adult(1, jobs=[job(years=0)])], [True]), + ("no UC: an old business", [adult(1)], [False]), + ( + "a new side trade beside a job", + [ + adult( + 1, + empstati=EMPLOYEE, + profit=50.0, + jobs=[job(etype=1, years=-1), job(jobtype=2, years=0, jobbus=JOB)], + claims=[OLD_CLAIM], + ) + ], + [True], + ), + ( + "a new side trade with no profit yet: self-employed by its job row", + [ + adult( + 1, + empstati=EMPLOYEE, + profit=0.0, + jobs=[job(etype=1, years=-1), job(jobtype=2, years=0)], + ) + ], + [True], + ), + ( + "an old main trade with a new side trade: the main trade decides", + [ + adult( + 1, + jobs=[job(etype=2, years=6), job(jobtype=2, etype=6, years=0)], + claims=[OLD_CLAIM], + ) + ], + [False], + ), + ( + "self-employed by profit alone, recent claim", + [adult(1, empstati=0, profit=-20.0, jobs=[], claims=[RECENT_CLAIM])], + [True], + ), + ( + "not self-employed, recent claim", + [ + adult( + 1, + empstati=EMPLOYEE, + profit=0.0, + jobs=[job(etype=1, years=-1)], + claims=[RECENT_CLAIM], + ) + ], + [False], + ), + ( + "unknown business duration, old claim", + [adult(1, jobs=[job(years=-9)], claims=[OLD_CLAIM])], + [False], + ), + ( + "a business in its second year", + [adult(1, jobs=[job(years=1)], claims=[OLD_CLAIM])], + [False], + ), + ( + "self-employed all year, a new 'job': a new engagement in the same trade", + [adult(1, jobs=[job(years=0, jobbus=JOB)], claims=[OLD_CLAIM])], + [False], + ), + ( + "self-employed every month by the calendar, a new 'job'", + [ + adult( + 1, + jobs=[job(years=0, jobbus=JOB)], + samesit=1, + calendar=[SE_MONTH, 4] * 6, + ) + ], + [False], + ), + ( + "employed earlier in the year, a new 'job': a new trade", + [ + adult( + 1, + jobs=[job(years=0, jobbus=JOB)], + samesit=1, + calendar=[SE_MONTH] * 4 + [EMPLOYEE_MONTH] * 8, + ) + ], + [True], + ), + ( + "self-employed all year, a new business: a new trade", + [adult(1, jobs=[job(years=0)])], + [True], + ), + ( + "a self-employed job given up (SEEND) is not self-employment", + [ + adult( + 1, + empstati=5, + profit=0.0, + jobs=[job(years=0, seend=sas_date("2024-08-01"))], + claims=[RECENT_CLAIM], + ) + ], + [False], + ), +] + + +@pytest.mark.parametrize( + "description, adults, expected", CASES, ids=[c[0] for c in CASES] +) +def test_start_up_cases(description, adults, expected): + assert start_up_flags(adults) == expected + + +@pytest.mark.parametrize( + "linked_claim, expected", [(RECENT_CLAIM, True), (OLD_CLAIM, False)] +) +def test_unlinked_claims_take_the_linked_self_employed_share(linked_claim, expected): + people = [adult(h, claims=[linked_claim], weight=2.0) for h in (1, 2)] + # Linked claimants who are not self-employed do not set the share. + people += [ + adult( + h, + empstati=EMPLOYEE, + profit=0.0, + jobs=[job(etype=1, years=-1)], + claims=[RECENT_CLAIM if not expected else OLD_CLAIM], + ) + for h in range(3, 13) + ] + people += [adult(h, claims=[UNLINKED]) for h in range(20, 40)] + # Last: no UC claim, only another benefit, and an old business. + people.append(adult(99)) + flags = start_up_flags(people) + assert flags[:2] == [expected] * 2 + assert flags[12:-1] == [expected] * 20 + assert not flags[-1] + + +@pytest.mark.parametrize( + "people, low, high", + [ + # A recent claim with weight 3 and an old one with weight 1: 0.75. + ( + [adult(1, claims=[RECENT_CLAIM], weight=3.0), adult(2, claims=[OLD_CLAIM])], + 0.69, + 0.81, + ), + # Weighted per person, not per benefit unit: a self-employed couple on + # a recent claim and a single trader on an old one give 2/3, not 1/2. + ( + [adult(1, claims=[RECENT_CLAIM]), adult(1), adult(2, claims=[OLD_CLAIM])], + 0.61, + 0.72, + ), + ], +) +def test_unlinked_share_is_survey_weighted_per_person(people, low, high): + linked = len(people) + flags = start_up_flags( + people + [adult(10 + h, claims=[UNLINKED]) for h in range(600)] + ) + assert low < np.mean(flags[linked:]) < high + + +def test_missing_interview_date_raises(): + with pytest.raises(ValueError, match="INTDATE"): + start_up_flags([adult(1, claims=[RECENT_CLAIM]), adult(2)], intdate={2: np.nan}) + + +def test_benefits_table_without_ucstart_raises(tmp_path): + from policyengine_uk_data.datasets.frs import create_frs + + pd.DataFrame(columns=["sernum", "benunit", "person", "benefit", "benamt"]).to_csv( + tmp_path / "benefits.tab", sep="\t", index=False + ) + with pytest.warns(UserWarning), pytest.raises(ValueError, match="UCSTART"): + create_frs(tmp_path, 2024) + + +def test_imputation_leaves_global_random_state_alone(): + state = np.random.get_state()[1].copy() + start_up_flags([adult(1, claims=[UNLINKED])]) + assert np.array_equal(np.random.get_state()[1], state) + + +@settings(max_examples=50, deadline=None) +@given( + st.lists( + st.tuples( + st.sampled_from( + ["FT_EMPLOYED", "FT_SELF_EMPLOYED", "PT_SELF_EMPLOYED", "UNEMPLOYED"] + ), + st.booleans(), + st.sampled_from([0.0, 5_000.0]), + ), + min_size=1, + max_size=10, + ) +) +def test_spi_copy_keeps_the_flag_only_with_self_employment(rows): + from policyengine_uk_data.datasets import disability_benefits + from policyengine_uk_data.datasets.imputations import frs_only + from policyengine_uk_data.datasets.imputations import income as income_module + + n = len(rows) + imputed_profit = [r[2] for r in rows] + person = pd.DataFrame( + { + "person_id": np.arange(1, n + 1), + "person_household_id": np.arange(1, n + 1), + "person_benunit_id": np.arange(1, n + 1), + "employment_status": [r[0] for r in rows], + START_UP: [r[1] for r in rows], + "employment_income": 0.0, + "self_employment_income": 1_000.0, + "savings_interest_income": 0.0, + "dividend_income": 0.0, + "private_pension_income": 0.0, + "property_income": 0.0, + } + ) + household = pd.DataFrame( + { + "household_id": np.arange(1, n + 1), + "household_weight": 1.0, + "region": "LONDON", + } + ) + + def impute_over_incomes(dataset, _model, output_variables): + dataset = dataset.copy() + if "self_employment_income" in output_variables: + dataset.person["self_employment_income"] = imputed_profit + return dataset + + with pytest.MonkeyPatch.context() as m: + m.setattr(income_module, "create_income_model", lambda: object()) + m.setattr(income_module, "subsample_dataset", lambda d, _n: d.copy()) + m.setattr(income_module, "impute_over_incomes", impute_over_incomes) + m.setattr( + frs_only, + "impute_frs_only_variables", + lambda train_dataset, target_dataset: target_dataset, + ) + m.setattr( + disability_benefits, + "strip_internal_disability_reported_amounts", + lambda dataset: dataset, + ) + m.setattr(income_module, "stack_datasets", _stack_without_remapping) + result = income_module.impute_income( + _FakeDataset(person=person, household=household) + ) + + flag = result.person[START_UP].to_numpy(dtype=bool) + donor = np.array([r[1] for r in rows]) + # FRS rows keep their flag. + assert np.array_equal(flag[:n], donor) + # Copies keep it only where they are still self-employed. + still_self_employed = np.isin([r[0] for r in rows], SELF_EMPLOYED_STATUSES) | ( + np.array(imputed_profit) != 0 + ) + assert np.array_equal(flag[n:], donor & still_self_employed) + + +@pytest.mark.parametrize("fixture", ["frs", "enhanced_frs"]) +def test_built_dataset_start_up_period(fixture, request): + dataset = request.getfixturevalue(fixture) + person = dataset.person + if START_UP not in person.columns: + pytest.skip(f"{fixture} was built before this input existed") + flag = person[START_UP].to_numpy() + assert flag.dtype == bool + # Children have no jobs and no claims of their own. + assert not flag[person.age.to_numpy() < 16].any() + se = person.employment_status.astype(str).isin(SELF_EMPLOYED_STATUSES).to_numpy() + weight = ( + dataset.household.set_index("household_id") + .household_weight.reindex(person.person_household_id) + .to_numpy() + ) + + def share(mask): + return (weight * flag)[mask].sum() / weight[mask].sum() + + # FRS 2024-25: about 9% of self-employed adults are flagged, mostly for a + # business under a year old. + assert 0.03 < share(se) < 0.25 + # Among self-employed adults in benefit units reporting UC, about 36% have + # a claim or trade under a year old: the claim route dominates there. + reports_uc = ( + pd.Series(person.universal_credit_reported.to_numpy() > 0) + .groupby(person.person_benunit_id.to_numpy()) + .transform("any") + .to_numpy() + ) + assert 0.2 < share(se & reports_uc) < 0.55 diff --git a/pyproject.toml b/pyproject.toml index beff7f1a..58de5b1d 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 a3e9f44d..c301c6c9 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" },