From d3984002c17d2bee0cca5606071e9a5aa2dc4c18 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 12:33:40 -0400 Subject: [PATCH 1/5] Map FRS EMPSTATI 11 to OTHER_INACTIVE and reject unknown adult codes The FRS employment_status mapping zipped range(12) with 11 statuses, so EMPSTATI 11 ("Other inactive" in the UKDS FRS 2024-25 data dictionary) was unmapped and the fillna fallback made it LONG_TERM_DISABLED. That also put other-inactive working-age adults into the ESA health-condition and support-group proxies. Map the adult codes through an explicit table keyed to the data dictionary, make child-table rows CHILD, and fail the build on any adult code the table does not know rather than guessing a status. Every adult in the 2020-21, 2022-23, 2023-24 and 2024-25 releases carries a code from 1 to 11. Tests cover codes 0-11 exhaustively, Hypothesis properties of the mapping and the ESA proxies, create_frs end to end for every code and a child row, and the built datasets. Adds hypothesis as a dev dependency (same pyproject and uv.lock change as #522 and #525). Co-Authored-By: Claude Opus 5.5 --- .../frs-empstati-other-inactive.fixed.md | 1 + policyengine_uk_data/datasets/frs.py | 59 ++++-- .../tests/test_frs_employment_status.py | 169 ++++++++++++++++++ .../tests/test_legacy_benefit_proxies.py | 52 +++++- pyproject.toml | 1 + uv.lock | 73 +++++++- 6 files changed, 332 insertions(+), 23 deletions(-) create mode 100644 changelog.d/frs-empstati-other-inactive.fixed.md create mode 100644 policyengine_uk_data/tests/test_frs_employment_status.py diff --git a/changelog.d/frs-empstati-other-inactive.fixed.md b/changelog.d/frs-empstati-other-inactive.fixed.md new file mode 100644 index 00000000..f4613046 --- /dev/null +++ b/changelog.d/frs-empstati-other-inactive.fixed.md @@ -0,0 +1 @@ +Map FRS EMPSTATI code 11 ("Other inactive") to `employment_status` OTHER_INACTIVE; it had fallen through to LONG_TERM_DISABLED, which also put other-inactive adults into the ESA health-condition and support-group proxies. An adult EMPSTATI code the mapping does not know now fails the build instead of defaulting. diff --git a/policyengine_uk_data/datasets/frs.py b/policyengine_uk_data/datasets/frs.py index e440d486..05c7c8dc 100644 --- a/policyengine_uk_data/datasets/frs.py +++ b/policyengine_uk_data/datasets/frs.py @@ -49,6 +49,22 @@ LEGACY_JOBSEEKER_MIN_AGE = 18 HOURS_WORKED_WEEKS_PER_YEAR = 52 ESA_MIN_AGE = 16 +# Adult-table EMPSTATI ("Adult - Employment Status - ILO definition") value +# labels from the UKDS FRS 2024-25 data dictionary, mapped to PolicyEngine +# statuses. Children have no EMPSTATI and are CHILD. +FRS_EMPSTATI_EMPLOYMENT_STATUS = { + 1: EmploymentStatus.FT_EMPLOYED.name, # Full-time employee + 2: EmploymentStatus.PT_EMPLOYED.name, # Part-time employee + 3: EmploymentStatus.FT_SELF_EMPLOYED.name, # Full-time self-employed + 4: EmploymentStatus.PT_SELF_EMPLOYED.name, # Part-time self-employed + 5: EmploymentStatus.UNEMPLOYED.name, # Unemployed + 6: EmploymentStatus.RETIRED.name, # Retired + 7: EmploymentStatus.STUDENT.name, # Student + 8: EmploymentStatus.CARER.name, # Looking after family/home + 9: EmploymentStatus.LONG_TERM_DISABLED.name, # Permanently sick/disabled + 10: EmploymentStatus.SHORT_TERM_DISABLED.name, # Temporarily sick/injured + 11: EmploymentStatus.OTHER_INACTIVE.name, # Other inactive +} ESA_HEALTH_EMPLOYMENT_STATUSES = ( EmploymentStatus.LONG_TERM_DISABLED.name, EmploymentStatus.SHORT_TERM_DISABLED.name, @@ -112,6 +128,29 @@ def require_variable(name: str, description: str) -> None: ) +def derive_employment_status_from_frs(empstati, is_adult_record) -> np.ndarray: + """Map FRS EMPSTATI codes to ``employment_status``. + + Rows from the child table are CHILD. Every adult must carry a code in + ``FRS_EMPSTATI_EMPLOYMENT_STATUS``: a missing or unknown adult code fails + the build rather than falling back to a guessed status, as the old + fallback silently made code 11 (Other inactive) LONG_TERM_DISABLED. + """ + + codes = pd.Series(np.asarray(empstati, dtype=float)) + is_adult_record = np.asarray(is_adult_record, dtype=bool) + adult_status = codes.map(FRS_EMPSTATI_EMPLOYMENT_STATUS).to_numpy() + unknown = is_adult_record & pd.isna(adult_status) + if unknown.any(): + counts = codes[unknown].value_counts(dropna=False).sort_index().to_dict() + raise ValueError( + f"{int(unknown.sum())} FRS adults have EMPSTATI codes missing from " + f"FRS_EMPSTATI_EMPLOYMENT_STATUS (code: adults): {counts}. Map " + "them from the release's data dictionary." + ) + return np.where(is_adult_record, adult_status, EmploymentStatus.CHILD.name) + + def derive_legacy_jobseeker_proxy( age, employment_status, @@ -854,23 +893,9 @@ def determine_education_level(fted_val, typeed2_val, age_val): "UPPER_SECONDARY" ) - # Add employment status - EMPLOYMENTS = [ - "CHILD", - "FT_EMPLOYED", - "PT_EMPLOYED", - "FT_SELF_EMPLOYED", - "PT_SELF_EMPLOYED", - "UNEMPLOYED", - "RETIRED", - "STUDENT", - "CARER", - "LONG_TERM_DISABLED", - "SHORT_TERM_DISABLED", - ] - pe_person["employment_status"] = categorical( - person.empstati, 1, range(12), EMPLOYMENTS - ).fillna("LONG_TERM_DISABLED") + pe_person["employment_status"] = derive_employment_status_from_frs( + person.empstati, person.person_id.isin(frs["adult"].person_id) + ) # Add employer sector of the main job from FRS `mjobsect` # (1 = private, 2 = public; missing/blank = not in paid work). diff --git a/policyengine_uk_data/tests/test_frs_employment_status.py b/policyengine_uk_data/tests/test_frs_employment_status.py new file mode 100644 index 00000000..b035358f --- /dev/null +++ b/policyengine_uk_data/tests/test_frs_employment_status.py @@ -0,0 +1,169 @@ +"""FRS EMPSTATI codes to ``employment_status``, and what the ESA proxies read.""" + +import numpy as np +import pytest +from hypothesis import given, strategies as st +from policyengine_uk.variables.household.income.employment_status import ( + EmploymentStatus, +) + +from policyengine_uk_data.datasets.frs import ( + ESA_HEALTH_EMPLOYMENT_STATUSES, + ESA_MIN_AGE, + FRS_EMPSTATI_EMPLOYMENT_STATUS, + derive_employment_status_from_frs, + derive_esa_health_condition_proxy, + derive_esa_support_group_proxy, +) + +# EMPSTATI ("Adult - Employment Status - ILO definition") value labels in the +# UKDS FRS 2024-25 data dictionary (SN 9563, adult table), with the status each +# label means. The 2020-21, 2022-23 and 2023-24 releases use the same codes. +DATA_DICTIONARY = { + 1: ("Full-time Employee", "FT_EMPLOYED"), + 2: ("Part-time Employee", "PT_EMPLOYED"), + 3: ("Full-time Self-Employed", "FT_SELF_EMPLOYED"), + 4: ("Part-time Self-Employed", "PT_SELF_EMPLOYED"), + 5: ("Unemployed", "UNEMPLOYED"), + 6: ("Retired", "RETIRED"), + 7: ("Student", "STUDENT"), + 8: ("Looking after family/home", "CARER"), + 9: ("Permanently sick/disabled", "LONG_TERM_DISABLED"), + 10: ("Temporarily sick/injured", "SHORT_TERM_DISABLED"), + 11: ("Other Inactive", "OTHER_INACTIVE"), +} +ADULT_CODES = sorted(DATA_DICTIONARY) +HEALTH_CODES = (9, 10) + +adult_rows = st.tuples(st.just(True), st.sampled_from(ADULT_CODES)) +# Child-table rows have no EMPSTATI (0 once the person table fills blanks); the +# code must not matter for them. +child_rows = st.tuples( + st.just(False), + st.one_of(st.just(0), st.just(np.nan), st.integers(-9, 99)), +) +people = st.lists(st.one_of(adult_rows, child_rows), max_size=60) +unknown_adult_codes = st.one_of( + st.just(np.nan), + st.integers(-99, 0), + st.integers(12, 999), + st.floats(0.5, 11.5).filter(lambda x: not float(x).is_integer()), +) + + +def derive(rows): + is_adult = [adult for adult, _ in rows] + codes = [code for _, code in rows] + return derive_employment_status_from_frs(codes, is_adult) + + +def expected(adult, code): + return DATA_DICTIONARY[code][1] if adult else "CHILD" + + +@pytest.mark.parametrize("code", range(12)) +def test_every_code_from_0_to_11(code): + assert derive_employment_status_from_frs([code], [False]).tolist() == ["CHILD"] + if code == 0: + with pytest.raises(ValueError, match="EMPSTATI"): + derive_employment_status_from_frs([code], [True]) + else: + label, status = DATA_DICTIONARY[code] + assert derive_employment_status_from_frs([code], [True]).tolist() == [status], ( + label + ) + + +def test_other_inactive_is_not_long_term_disabled(): + result = derive_employment_status_from_frs([9, 11], [True, True]) + assert result.tolist() == ["LONG_TERM_DISABLED", "OTHER_INACTIVE"] + + +def test_code_table_is_the_data_dictionary(): + assert FRS_EMPSTATI_EMPLOYMENT_STATUS == { + code: status for code, (_, status) in DATA_DICTIONARY.items() + } + + +def test_adult_codes_and_child_rows_cover_each_status_once(): + statuses = [*FRS_EMPSTATI_EMPLOYMENT_STATUS.values(), "CHILD"] + assert len(set(statuses)) == len(statuses) + assert set(statuses) == set(EmploymentStatus.__members__) + + +@pytest.mark.parametrize("code", [0, -1, 12, 11.5, np.nan]) +def test_unknown_adult_code_fails_the_build(code): + with pytest.raises(ValueError, match="EMPSTATI"): + derive_employment_status_from_frs([1, code], [True, True]) + + +@given(people) +def test_each_row_maps_on_its_own(rows): + result = derive(rows) + assert result.tolist() == [expected(adult, code) for adult, code in rows] + + +@given(people, st.randoms(use_true_random=False)) +def test_mapping_commutes_with_row_order(rows, rng): + order = list(range(len(rows))) + rng.shuffle(order) + assert derive([rows[i] for i in order]).tolist() == [derive(rows)[i] for i in order] + + +@given(people, unknown_adult_codes, st.integers(0, 60)) +def test_any_unknown_adult_code_fails_the_build(rows, bad_code, position): + rows = list(rows) + rows.insert(min(position, len(rows)), (True, bad_code)) + with pytest.raises(ValueError, match="EMPSTATI"): + derive(rows) + + +@given( + st.lists( + st.tuples( + st.integers(0, 100), # age + st.sampled_from(ADULT_CODES), + st.integers(60, 68), # State Pension age + st.integers(0, 3_000), # annual hours worked + st.booleans(), # EMPSTATI reported + ), + min_size=1, + max_size=60, + ) +) +def test_esa_proxies_read_only_the_sick_or_disabled_codes(rows): + age, codes, spa, hours, reported = map(np.array, zip(*rows)) + status = derive_employment_status_from_frs(codes, np.ones(len(rows), bool)) + health = derive_esa_health_condition_proxy( + age=age, + employment_status=status, + employment_status_reported=reported, + state_pension_age=spa, + ) + support = derive_esa_support_group_proxy( + age=age, + employment_status=status, + hours_worked=hours, + esa_health_condition_proxy=health, + employment_status_reported=reported, + state_pension_age=spa, + ) + working_age = (age >= ESA_MIN_AGE) & (age < spa) + assert ( + health.tolist() + == (reported & working_age & np.isin(codes, HEALTH_CODES)).tolist() + ) + assert not (support & ~health).any() + assert not support[codes != 9].any() + assert not health[codes == 11].any() + + +@pytest.mark.parametrize("dataset_name", ["frs", "enhanced_frs"]) +def test_built_dataset_statuses(dataset_name, request): + person = request.getfixturevalue(dataset_name).person + status = person["employment_status"].astype(str) + assert set(status) <= set(EmploymentStatus.__members__) + assert (status == "OTHER_INACTIVE").any() + not_sick = ~status.isin(ESA_HEALTH_EMPLOYMENT_STATUSES) + assert not person.loc[not_sick, "esa_health_condition_proxy"].any() + assert not person.loc[not_sick, "esa_support_group_proxy"].any() diff --git a/policyengine_uk_data/tests/test_legacy_benefit_proxies.py b/policyengine_uk_data/tests/test_legacy_benefit_proxies.py index 5f1acd85..ebc8ab59 100644 --- a/policyengine_uk_data/tests/test_legacy_benefit_proxies.py +++ b/policyengine_uk_data/tests/test_legacy_benefit_proxies.py @@ -1,9 +1,11 @@ import numpy as np import pandas as pd +import pytest import policyengine_uk import policyengine_uk_data.datasets.frs as frs_module from policyengine_uk_data.datasets.frs import ( + FRS_EMPSTATI_EMPLOYMENT_STATUS, add_legacy_benefit_proxies, attach_legacy_benefit_proxies_from_frs_person, apply_legacy_benefit_proxies, @@ -359,7 +361,7 @@ def calculate(self, variable, year=None): if variable == "household_id": return np.array([100]) if variable == "state_pension_age": - return pd.Series([66]) + return pd.Series([66] * len(self.dataset.person)) if variable in ( "childcare_grant", "parents_learning_allowance", @@ -373,7 +375,7 @@ def calculate(self, variable, year=None): raise KeyError(variable) -def test_create_frs_smoke_includes_legacy_proxy_columns(tmp_path, monkeypatch): +def create_single_adult_frs(tmp_path, monkeypatch, empstati=8, with_child=False): original_read_csv = frs_module.pd.read_csv def fake_read_csv(path, *args, **kwargs): @@ -426,7 +428,7 @@ def fake_read_csv(path, *args, **kwargs): "educqual": 0, "eduma": 0, "edumaamt": 0, - "empstati": 8, + "empstati": empstati, "mjobsect": 0, "sic": 0, "fsbval": 0, @@ -468,7 +470,13 @@ def fake_read_csv(path, *args, **kwargs): } ] ) - child = pd.DataFrame(columns=adult.columns) + # The FRS child table has no EMPSTATI column. + child_columns = adult.columns.drop("empstati") + child = pd.DataFrame(columns=child_columns) + if with_child: + child = pd.DataFrame( + [{**dict.fromkeys(child_columns, 0), "sernum": 100, "benunit": 1}] + ).assign(person=2, age=5, uperson=2) benunit = pd.DataFrame([{"sernum": 100, "benunit": 1, "famtypb2": 1}]) househol = pd.DataFrame( [ @@ -536,7 +544,11 @@ def fake_read_csv(path, *args, **kwargs): for name, table in raw_tables.items(): table.to_csv(tmp_path / f"{name}.tab", sep="\t", index=False) - dataset = create_frs(tmp_path, 2025) + return create_frs(tmp_path, 2025) + + +def test_create_frs_smoke_includes_legacy_proxy_columns(tmp_path, monkeypatch): + dataset = create_single_adult_frs(tmp_path, monkeypatch) assert { "legacy_jobseeker_proxy", @@ -561,3 +573,33 @@ def fake_read_csv(path, *args, **kwargs): ].iloc[0] assert dataset.person["education_grants"].iloc[0] == 100 assert dataset.person["disabled_students_allowance_eligible_expenses"].iloc[0] == 0 + + +@pytest.mark.parametrize("empstati", sorted(FRS_EMPSTATI_EMPLOYMENT_STATUS)) +def test_create_frs_maps_every_empstati_code(tmp_path, monkeypatch, empstati): + person = create_single_adult_frs(tmp_path, monkeypatch, empstati).person + + assert ( + person["employment_status"].iloc[0] + == (FRS_EMPSTATI_EMPLOYMENT_STATUS[empstati]) + ) + # A working-age adult reporting no hours: only the sick/disabled codes + # (9 permanently, 10 temporarily) are ESA health states. + assert person["esa_health_condition_proxy"].iloc[0] == (empstati in (9, 10)) + assert person["esa_support_group_proxy"].iloc[0] == (empstati == 9) + + +def test_create_frs_child_rows_are_child(tmp_path, monkeypatch): + person = create_single_adult_frs( + tmp_path, monkeypatch, empstati=11, with_child=True + ).person.set_index("person_id") + + assert person["employment_status"].to_dict() == { + 100_001: "OTHER_INACTIVE", + 100_002: "CHILD", + } + + +def test_create_frs_rejects_unknown_adult_empstati(tmp_path, monkeypatch): + with pytest.raises(ValueError, match="EMPSTATI"): + create_single_adult_frs(tmp_path, monkeypatch, empstati=12) 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" }, From fb0266593cab1dbe4464c4c6db63e4868af43db5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 13:20:46 -0400 Subject: [PATCH 2/5] Suppress small counts in the unknown-EMPSTATI build error CI build logs are public, so the error now names the unknown codes without per-code counts and reports a total under 10 adults as "fewer than 10". Raised by the microcosm port session; no change on current FRS releases, where every adult has a code from 1 to 11. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk_data/datasets/frs.py | 11 +++++++---- .../tests/test_frs_employment_status.py | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/policyengine_uk_data/datasets/frs.py b/policyengine_uk_data/datasets/frs.py index 05c7c8dc..e53e1ede 100644 --- a/policyengine_uk_data/datasets/frs.py +++ b/policyengine_uk_data/datasets/frs.py @@ -142,11 +142,14 @@ def derive_employment_status_from_frs(empstati, is_adult_record) -> np.ndarray: adult_status = codes.map(FRS_EMPSTATI_EMPLOYMENT_STATUS).to_numpy() unknown = is_adult_record & pd.isna(adult_status) if unknown.any(): - counts = codes[unknown].value_counts(dropna=False).sort_index().to_dict() + # Build logs are public: name the codes, not per-code counts, and + # suppress a total under 10 adults. + n = int(unknown.sum()) + adults = f"{n} FRS adults" if n >= 10 else "Fewer than 10 FRS adults" raise ValueError( - f"{int(unknown.sum())} FRS adults have EMPSTATI codes missing from " - f"FRS_EMPSTATI_EMPLOYMENT_STATUS (code: adults): {counts}. Map " - "them from the release's data dictionary." + f"{adults} have EMPSTATI codes missing from " + f"FRS_EMPSTATI_EMPLOYMENT_STATUS: {sorted(set(codes[unknown].astype(str)))}. " + "Map them from the release's data dictionary." ) return np.where(is_adult_record, adult_status, EmploymentStatus.CHILD.name) diff --git a/policyengine_uk_data/tests/test_frs_employment_status.py b/policyengine_uk_data/tests/test_frs_employment_status.py index b035358f..c09582f8 100644 --- a/policyengine_uk_data/tests/test_frs_employment_status.py +++ b/policyengine_uk_data/tests/test_frs_employment_status.py @@ -97,6 +97,21 @@ def test_unknown_adult_code_fails_the_build(code): derive_employment_status_from_frs([1, code], [True, True]) +@given(st.integers(1, 30), st.integers(1, 30)) +def test_failure_message_suppresses_small_counts(n_twelve, n_thirteen): + codes = [12] * n_twelve + [13] * n_thirteen + with pytest.raises(ValueError) as error: + derive_employment_status_from_frs(codes, [True] * len(codes)) + total = n_twelve + n_thirteen + prefix = f"{total} FRS adults" if total >= 10 else "Fewer than 10 FRS adults" + message = str(error.value) + assert message.startswith(prefix) + assert "['12.0', '13.0']" in message + # No other number (such as a per-code count) is disclosed. + rest = message.removeprefix(prefix).replace("['12.0', '13.0']", "") + assert not any(character.isdigit() for character in rest) + + @given(people) def test_each_row_maps_on_its_own(rows): result = derive(rows) From 89c48e074f77f196b1a91bbb2a69f78bb29884fc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 13:24:41 -0400 Subject: [PATCH 3/5] List unknown EMPSTATI codes without Series.astype(str) Under pandas 3, Series.astype(str) keeps NaN as a float, so sorting a set holding a blank code and another unknown code raised TypeError and hid the codes. Format from the float codes instead ("0, 12, blank"), which behaves the same under pandas 2 and 3. Found by the microcosm port session's differential under pandas 3.0.3. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk_data/datasets/frs.py | 7 +++++-- .../tests/test_frs_employment_status.py | 11 +++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/policyengine_uk_data/datasets/frs.py b/policyengine_uk_data/datasets/frs.py index e53e1ede..3195cb18 100644 --- a/policyengine_uk_data/datasets/frs.py +++ b/policyengine_uk_data/datasets/frs.py @@ -146,10 +146,13 @@ def derive_employment_status_from_frs(empstati, is_adult_record) -> np.ndarray: # suppress a total under 10 adults. n = int(unknown.sum()) adults = f"{n} FRS adults" if n >= 10 else "Fewer than 10 FRS adults" + bad = codes[unknown] + listed = [f"{code:g}" for code in sorted(bad.dropna().unique())] + listed += ["blank"] if bad.isna().any() else [] raise ValueError( f"{adults} have EMPSTATI codes missing from " - f"FRS_EMPSTATI_EMPLOYMENT_STATUS: {sorted(set(codes[unknown].astype(str)))}. " - "Map them from the release's data dictionary." + f"FRS_EMPSTATI_EMPLOYMENT_STATUS: {', '.join(listed)}. Map them " + "from the release's data dictionary." ) return np.where(is_adult_record, adult_status, EmploymentStatus.CHILD.name) diff --git a/policyengine_uk_data/tests/test_frs_employment_status.py b/policyengine_uk_data/tests/test_frs_employment_status.py index c09582f8..283f46b7 100644 --- a/policyengine_uk_data/tests/test_frs_employment_status.py +++ b/policyengine_uk_data/tests/test_frs_employment_status.py @@ -97,6 +97,13 @@ def test_unknown_adult_code_fails_the_build(code): derive_employment_status_from_frs([1, code], [True, True]) +def test_failure_message_lists_blank_and_numeric_codes(): + # Formatted from the float codes, not Series.astype(str), whose NaN + # handling differs between pandas 2 and 3. + with pytest.raises(ValueError, match=": 0, 12, blank. "): + derive_employment_status_from_frs([np.nan, 0, 12, 1], [True] * 4) + + @given(st.integers(1, 30), st.integers(1, 30)) def test_failure_message_suppresses_small_counts(n_twelve, n_thirteen): codes = [12] * n_twelve + [13] * n_thirteen @@ -106,9 +113,9 @@ def test_failure_message_suppresses_small_counts(n_twelve, n_thirteen): prefix = f"{total} FRS adults" if total >= 10 else "Fewer than 10 FRS adults" message = str(error.value) assert message.startswith(prefix) - assert "['12.0', '13.0']" in message + assert ": 12, 13. " in message # No other number (such as a per-code count) is disclosed. - rest = message.removeprefix(prefix).replace("['12.0', '13.0']", "") + rest = message.removeprefix(prefix).replace(": 12, 13. ", "") assert not any(character.isdigit() for character in rest) From 5f9912df1ddd28a8de0fcb41bce1c9159e08bc95 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 22:53:16 -0400 Subject: [PATCH 4/5] Address review r1: derive once per example, tighten a test comment The row-order property recomputed the mapping for every index; compute it once. The test comment now says what was checked for the older FRS releases (every adult has a code from 1 to 11), not that their labels match. Drop redundant parentheses in the create_frs test. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk_data/tests/test_frs_employment_status.py | 6 ++++-- policyengine_uk_data/tests/test_legacy_benefit_proxies.py | 6 ++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/policyengine_uk_data/tests/test_frs_employment_status.py b/policyengine_uk_data/tests/test_frs_employment_status.py index 283f46b7..735de2a6 100644 --- a/policyengine_uk_data/tests/test_frs_employment_status.py +++ b/policyengine_uk_data/tests/test_frs_employment_status.py @@ -18,7 +18,8 @@ # EMPSTATI ("Adult - Employment Status - ILO definition") value labels in the # UKDS FRS 2024-25 data dictionary (SN 9563, adult table), with the status each -# label means. The 2020-21, 2022-23 and 2023-24 releases use the same codes. +# label means. Every adult in the 2020-21, 2022-23 and 2023-24 releases also +# has a code from 1 to 11. DATA_DICTIONARY = { 1: ("Full-time Employee", "FT_EMPLOYED"), 2: ("Part-time Employee", "PT_EMPLOYED"), @@ -129,7 +130,8 @@ def test_each_row_maps_on_its_own(rows): def test_mapping_commutes_with_row_order(rows, rng): order = list(range(len(rows))) rng.shuffle(order) - assert derive([rows[i] for i in order]).tolist() == [derive(rows)[i] for i in order] + statuses = derive(rows) + assert derive([rows[i] for i in order]).tolist() == [statuses[i] for i in order] @given(people, unknown_adult_codes, st.integers(0, 60)) diff --git a/policyengine_uk_data/tests/test_legacy_benefit_proxies.py b/policyengine_uk_data/tests/test_legacy_benefit_proxies.py index ebc8ab59..ba20ad60 100644 --- a/policyengine_uk_data/tests/test_legacy_benefit_proxies.py +++ b/policyengine_uk_data/tests/test_legacy_benefit_proxies.py @@ -579,10 +579,8 @@ def test_create_frs_smoke_includes_legacy_proxy_columns(tmp_path, monkeypatch): def test_create_frs_maps_every_empstati_code(tmp_path, monkeypatch, empstati): person = create_single_adult_frs(tmp_path, monkeypatch, empstati).person - assert ( - person["employment_status"].iloc[0] - == (FRS_EMPSTATI_EMPLOYMENT_STATUS[empstati]) - ) + status = person["employment_status"].iloc[0] + assert status == FRS_EMPSTATI_EMPLOYMENT_STATUS[empstati] # A working-age adult reporting no hours: only the sick/disabled codes # (9 permanently, 10 temporarily) are ESA health states. assert person["esa_health_condition_proxy"].iloc[0] == (empstati in (9, 10)) From 1832adeb2296b53fe5a7d7194e770118aa34d1f8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 07:27:34 -0400 Subject: [PATCH 5/5] Address review r2: print no count in the unknown-EMPSTATI error Adults are not survey households, so a total of 10 or more adults could still come from fewer than 10 households in a public build log. The error now names the unknown codes only. Tests pin that the message depends only on which codes occur, and that codes are listed once each in numeric order with blank last (a string sort or an unsorted list now fails). Co-Authored-By: Claude Opus 5.5 --- policyengine_uk_data/datasets/frs.py | 8 ++-- .../tests/test_frs_employment_status.py | 42 +++++++++++++------ 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/policyengine_uk_data/datasets/frs.py b/policyengine_uk_data/datasets/frs.py index 3195cb18..f2412fe8 100644 --- a/policyengine_uk_data/datasets/frs.py +++ b/policyengine_uk_data/datasets/frs.py @@ -142,15 +142,13 @@ def derive_employment_status_from_frs(empstati, is_adult_record) -> np.ndarray: adult_status = codes.map(FRS_EMPSTATI_EMPLOYMENT_STATUS).to_numpy() unknown = is_adult_record & pd.isna(adult_status) if unknown.any(): - # Build logs are public: name the codes, not per-code counts, and - # suppress a total under 10 adults. - n = int(unknown.sum()) - adults = f"{n} FRS adults" if n >= 10 else "Fewer than 10 FRS adults" + # Build logs are public, and adults are not survey households, so + # name the codes and never a count. bad = codes[unknown] listed = [f"{code:g}" for code in sorted(bad.dropna().unique())] listed += ["blank"] if bad.isna().any() else [] raise ValueError( - f"{adults} have EMPSTATI codes missing from " + "FRS adults have EMPSTATI codes missing from " f"FRS_EMPSTATI_EMPLOYMENT_STATUS: {', '.join(listed)}. Map them " "from the release's data dictionary." ) diff --git a/policyengine_uk_data/tests/test_frs_employment_status.py b/policyengine_uk_data/tests/test_frs_employment_status.py index 735de2a6..c7ccae1e 100644 --- a/policyengine_uk_data/tests/test_frs_employment_status.py +++ b/policyengine_uk_data/tests/test_frs_employment_status.py @@ -98,26 +98,44 @@ def test_unknown_adult_code_fails_the_build(code): derive_employment_status_from_frs([1, code], [True, True]) -def test_failure_message_lists_blank_and_numeric_codes(): +def listed_codes(message): + return message.split("FRS_EMPSTATI_EMPLOYMENT_STATUS: ")[1].split(". Map")[0] + + +def test_failure_message_lists_codes_in_numeric_order_with_blank_last(): # Formatted from the float codes, not Series.astype(str), whose NaN - # handling differs between pandas 2 and 3. - with pytest.raises(ValueError, match=": 0, 12, blank. "): - derive_employment_status_from_frs([np.nan, 0, 12, 1], [True] * 4) + # handling differs between pandas 2 and 3. A string sort would put 100 + # before 11.5. + codes = [100, 12, np.nan, -1, 12, 11.5, 1] + with pytest.raises(ValueError) as error: + derive_employment_status_from_frs(codes, [True] * len(codes)) + assert listed_codes(str(error.value)) == "-1, 11.5, 12, 100, blank" + + +@given(st.lists(unknown_adult_codes, min_size=1, max_size=20)) +def test_failure_message_lists_each_unknown_code_once_in_order(bad_codes): + with pytest.raises(ValueError) as error: + derive_employment_status_from_frs(bad_codes, [True] * len(bad_codes)) + listed = listed_codes(str(error.value)).split(", ") + blank = any(np.isnan(code) for code in bad_codes) + assert (listed[-1] == "blank") == blank + numbers = [float(code) for code in listed[: len(listed) - blank]] + assert numbers == sorted(numbers) + assert set(listed) - {"blank"} == { + f"{code:g}" for code in bad_codes if not np.isnan(code) + } @given(st.integers(1, 30), st.integers(1, 30)) -def test_failure_message_suppresses_small_counts(n_twelve, n_thirteen): +def test_failure_message_discloses_no_count(n_twelve, n_thirteen): + # Adults are not survey households, so no count of them is safe to print + # in a public build log: the message depends only on which codes occur. codes = [12] * n_twelve + [13] * n_thirteen with pytest.raises(ValueError) as error: derive_employment_status_from_frs(codes, [True] * len(codes)) - total = n_twelve + n_thirteen - prefix = f"{total} FRS adults" if total >= 10 else "Fewer than 10 FRS adults" message = str(error.value) - assert message.startswith(prefix) - assert ": 12, 13. " in message - # No other number (such as a per-code count) is disclosed. - rest = message.removeprefix(prefix).replace(": 12, 13. ", "") - assert not any(character.isdigit() for character in rest) + assert listed_codes(message) == "12, 13" + assert not any(character.isdigit() for character in message.replace("12, 13", "")) @given(people)