From 602fae28434d9e0b0de37ff4265966f1187afd83 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 12:56:50 -0400 Subject: [PATCH 1/8] Map FRS EMPSTATI 11 to OTHER_INACTIVE and refuse unknown adult codes EMPSTATI 11 is "Other Inactive" in the UKDS FRS 2024-25 data dictionary (adult table; 9 is "Permanently sick/disabled"). The UK runtime kept the incumbent truncated map's artifact and sent 11 to LONG_TERM_DISABLED, plus a post-map fillna that sent any code above the domain there too. That put other-inactive adults into the ESA health-condition and support-group proxies. FRS_EMPSTATI_EMPLOYMENT_STATUS now maps codes 1-11 one data-dictionary label per line, matching policyengine-uk-data#526. People outside adult.tab stay CHILD; an adult with a blank or unknown code refuses the build, with the error naming the codes and suppressing adult counts under 10. Co-Authored-By: Claude Opus 5.5 --- .../build/uk_runtime/frs_employment.py | 99 ++++++-- .../tests/engine/uk/test_uk_frs_employment.py | 24 ++ .../engine_free/uk/test_uk_frs_employment.py | 218 +++++++++++++++++- .../uk/test_uk_frs_legacy_proxies.py | 79 +++++++ 4 files changed, 383 insertions(+), 37 deletions(-) create mode 100644 packages/microcosm-build/tests/engine/uk/test_uk_frs_employment.py diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/frs_employment.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/frs_employment.py index 9f42a2d63..d295a0e90 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/frs_employment.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/frs_employment.py @@ -4,8 +4,10 @@ from collections.abc import Mapping from pathlib import Path +from types import MappingProxyType from typing import Any +import numpy as np import pandas as pd from microcosm.build.source_manifest import SourceStageSpec @@ -21,20 +23,27 @@ ) from microcosm.frame import Frame -EMPLOYMENT_STATUS_MAP = { - 0: "CHILD", - 1: "FT_EMPLOYED", - 2: "PT_EMPLOYED", - 3: "FT_SELF_EMPLOYED", - 4: "PT_SELF_EMPLOYED", - 5: "UNEMPLOYED", - 6: "RETIRED", - 7: "STUDENT", - 8: "CARER", - 9: "LONG_TERM_DISABLED", - 10: "SHORT_TERM_DISABLED", - 11: "LONG_TERM_DISABLED", -} +# Adult-table EMPSTATI ("Adult - Employment Status - ILO definition") value +# labels from the UKDS FRS 2024-25 data dictionary (SN 9563), mapped to +# policyengine-uk EmploymentStatus names. Matches policyengine-uk-data's +# FRS_EMPSTATI_EMPLOYMENT_STATUS (policyengine-uk-data#526). Child-table +# people have no EMPSTATI and are CHILD. +FRS_EMPSTATI_EMPLOYMENT_STATUS = MappingProxyType( + { + 1: "FT_EMPLOYED", # Full-time employee + 2: "PT_EMPLOYED", # Part-time employee + 3: "FT_SELF_EMPLOYED", # Full-time self-employed + 4: "PT_SELF_EMPLOYED", # Part-time self-employed + 5: "UNEMPLOYED", # Unemployed + 6: "RETIRED", # Retired + 7: "STUDENT", # Student + 8: "CARER", # Looking after family/home + 9: "LONG_TERM_DISABLED", # Permanently sick/disabled + 10: "SHORT_TERM_DISABLED", # Temporarily sick/injured + 11: "OTHER_INACTIVE", # Other inactive + } +) +CHILD_EMPLOYMENT_STATUS = "CHILD" EMPLOYMENT_SECTOR_MAP = { 0: "NOT_EMPLOYED", 1: "PRIVATE", @@ -69,7 +78,9 @@ def add_frs_employment( artifacts = _artifact_by_table(stage) adult = normalize_ids( - read_pinned_tab(Path(raw_dir) / str(artifacts["adult"]["locator"]), artifacts["adult"]) + read_pinned_tab( + Path(raw_dir) / str(artifacts["adult"]["locator"]), artifacts["adult"] + ) ) derived = derive_frs_employment(frame.table("person"), adult) person = frame.table("person").copy() @@ -93,31 +104,69 @@ def derive_frs_employment(person: pd.DataFrame, adult: pd.DataFrame) -> pd.DataF ``empstati``, ``mjobsect``, and ``sic`` are intentionally direct-indexed so missing source columns fail loudly, matching the incumbent FRS port. + A person is an adult record when their ``person_id`` is in ``adult.tab``; + everyone else is CHILD. """ raw = adult.set_index("person_id") aligned = raw.reindex(person["person_id"]) values = pd.DataFrame(index=person.index) - empstati = pd.to_numeric(aligned["empstati"], errors="coerce").fillna(0) - # Unmapped codes above the declared domain follow the incumbent's - # post-map fillna to LONG_TERM_DISABLED; 0/NaN rows land on CHILD via - # the explicit map entry. - values["employment_status"] = ( - empstati.astype(int) - .map(EMPLOYMENT_STATUS_MAP) - .fillna("LONG_TERM_DISABLED") - .to_numpy() + values["employment_status"] = derive_employment_status_from_frs( + aligned["empstati"], person["person_id"].isin(adult["person_id"]) ) sector = pd.to_numeric(aligned["mjobsect"], errors="coerce").fillna(0) values["employment_sector"] = ( sector.astype(int).map(EMPLOYMENT_SECTOR_MAP).fillna("NOT_EMPLOYED").to_numpy() ) values["sic_industry_division"] = ( - pd.to_numeric(aligned["sic"], errors="coerce").fillna(0).clip(lower=0).astype(int).to_numpy() + pd.to_numeric(aligned["sic"], errors="coerce") + .fillna(0) + .clip(lower=0) + .astype(int) + .to_numpy() ) return values +def derive_employment_status_from_frs(empstati, is_adult_record) -> np.ndarray: + """Map FRS EMPSTATI codes to ``employment_status``. + + People who are not adult records (child-table people) are CHILD whatever + their code. Every adult must carry a code in + ``FRS_EMPSTATI_EMPLOYMENT_STATUS``: a missing or unknown adult code refuses + 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(pd.to_numeric(empstati, errors="coerce"), dtype="float64") + ) + adult = np.asarray(is_adult_record, dtype=bool) + if adult.shape != codes.shape: + raise ValueError( + "FRS EMPSTATI codes and adult-record flags must have the same length." + ) + adult_status = codes.map(FRS_EMPSTATI_EMPLOYMENT_STATUS).to_numpy(dtype=object) + unknown = adult & pd.isna(adult_status) + if unknown.any(): + bad = codes[unknown] + labels = [_code_label(code) for code in sorted(bad.dropna().unique())] + if bad.isna().any(): + labels.append("blank or non-numeric") + count = int(unknown.sum()) + adults = str(count) if count >= 10 else "fewer than 10" + raise ValueError( + f"FRS adult.tab carries {adults} adults with EMPSTATI code(s) " + f"{labels} outside FRS_EMPSTATI_EMPLOYMENT_STATUS; map them from the " + "release's data dictionary." + ) + return np.where(adult, adult_status, CHILD_EMPLOYMENT_STATUS).astype(object) + + +def _code_label(code: float) -> str: + return str(int(code)) if float(code).is_integer() else str(code) + + def _artifact_by_table(stage: SourceStageSpec) -> dict[str, Mapping[str, Any]]: by_table = {str(artifact.get("table")): artifact for artifact in stage.artifacts} if "adult" not in by_table: diff --git a/packages/microcosm-build/tests/engine/uk/test_uk_frs_employment.py b/packages/microcosm-build/tests/engine/uk/test_uk_frs_employment.py new file mode 100644 index 000000000..8edd9f21b --- /dev/null +++ b/packages/microcosm-build/tests/engine/uk/test_uk_frs_employment.py @@ -0,0 +1,24 @@ +"""FRS EMPSTATI statuses against the engine's EmploymentStatus enum.""" + +from policyengine_uk.variables.household.income.employment_status import ( + EmploymentStatus, +) + +from microcosm.build.uk_runtime.frs_employment import ( + CHILD_EMPLOYMENT_STATUS, + FRS_EMPSTATI_EMPLOYMENT_STATUS, +) +from microcosm.build.uk_runtime.frs_legacy_proxies import ( + ESA_HEALTH_EMPLOYMENT_STATUSES, +) + + +def test_adult_codes_and_child_rows_are_a_bijection_onto_the_engine_enum() -> None: + statuses = [*FRS_EMPSTATI_EMPLOYMENT_STATUS.values(), CHILD_EMPLOYMENT_STATUS] + assert len(set(statuses)) == len(statuses) + assert set(statuses) == set(EmploymentStatus.__members__) + + +def test_esa_health_statuses_are_engine_statuses() -> None: + assert set(ESA_HEALTH_EMPLOYMENT_STATUSES) <= set(EmploymentStatus.__members__) + assert "OTHER_INACTIVE" not in ESA_HEALTH_EMPLOYMENT_STATUSES diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_employment.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_employment.py index 23f44ca8f..4e9879d41 100644 --- a/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_employment.py +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_employment.py @@ -1,33 +1,86 @@ from __future__ import annotations +import re + import numpy as np import pandas as pd import pytest +from hypothesis import given +from hypothesis import strategies as st + +from microcosm.build.uk_runtime.frs_employment import ( + CHILD_EMPLOYMENT_STATUS, + FRS_EMPSTATI_EMPLOYMENT_STATUS, + derive_employment_status_from_frs, + derive_frs_employment, +) + +# 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. policyengine-uk-data#526 pins the same table. +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) + +adult_rows = st.tuples(st.just(True), st.sampled_from(ADULT_CODES)) +# Child-table people have no EMPSTATI (NaN once aligned to adult.tab); 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) + -from microcosm.build.uk_runtime.frs_employment import derive_frs_employment +def _expected(adult, code): + return DATA_DICTIONARY[code][1] if adult else "CHILD" def test_employment_maps_status_sector_and_sic() -> None: - person = pd.DataFrame({"person_id": [1, 2, 3, 4, 5]}) + # Person 6 is not in adult.tab (a child-table person), so every adult.tab + # column aligns to NaN for them. + person = pd.DataFrame({"person_id": [1, 2, 3, 4, 5, 6]}) adult = pd.DataFrame( { - "person_id": [1, 2, 3, 4, 5], - "empstati": [0, 10, 11, np.nan, 12], - "mjobsect": [0, 1, 2, np.nan, 1], - "sic": [-5, 84.9, np.nan, 7, 20], + "person_id": [5, 4, 3, 2, 1], + "empstati": [8, 1.0, 11, 10, 9], + "mjobsect": [1, np.nan, 2, 1, 0], + "sic": [20, 7, np.nan, 84.9, -5], } ) result = derive_frs_employment(person, adult) assert result["employment_status"].tolist() == [ - "CHILD", - "SHORT_TERM_DISABLED", "LONG_TERM_DISABLED", + "SHORT_TERM_DISABLED", + "OTHER_INACTIVE", + "FT_EMPLOYED", + "CARER", "CHILD", - # Beyond-domain codes take the incumbent's post-map fillna, not the - # CHILD default reserved for 0/NaN rows. - "LONG_TERM_DISABLED", ] assert result["employment_sector"].tolist() == [ "NOT_EMPLOYED", @@ -35,8 +88,149 @@ def test_employment_maps_status_sector_and_sic() -> None: "PUBLIC", "NOT_EMPLOYED", "PRIVATE", + "NOT_EMPLOYED", + ] + assert result["sic_industry_division"].tolist() == [0, 84, 0, 7, 20, 0] + + +def test_other_inactive_is_not_long_term_disabled() -> None: + result = derive_employment_status_from_frs([9, 11], [True, True]) + assert result.tolist() == ["LONG_TERM_DISABLED", "OTHER_INACTIVE"] + + +def test_only_code_11_moves_from_the_truncated_map() -> None: + # The map this replaces zipped codes 0-11 with 11 statuses and sent the + # unmatched code 11 to LONG_TERM_DISABLED; codes 1-10 keep their status. + truncated = { + code: status for code, (_, status) in DATA_DICTIONARY.items() if code != 11 + } | {11: "LONG_TERM_DISABLED"} + moved = { + code + for code in ADULT_CODES + if FRS_EMPSTATI_EMPLOYMENT_STATUS[code] != truncated[code] + } + assert moved == {11} + + +@pytest.mark.parametrize("code", range(12)) +def test_every_code_from_0_to_11(code) -> None: + 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_code_table_is_the_data_dictionary() -> None: + assert dict(FRS_EMPSTATI_EMPLOYMENT_STATUS) == { + code: status for code, (_, status) in DATA_DICTIONARY.items() + } + + +def test_code_table_is_immutable() -> None: + with pytest.raises(TypeError): + FRS_EMPSTATI_EMPLOYMENT_STATUS[12] = "OTHER_INACTIVE" # type: ignore[index] + + +def test_adult_codes_and_child_rows_cover_each_status_once() -> None: + statuses = [*FRS_EMPSTATI_EMPLOYMENT_STATUS.values(), CHILD_EMPLOYMENT_STATUS] + assert CHILD_EMPLOYMENT_STATUS == "CHILD" + assert len(set(statuses)) == len(statuses) == 12 + + +@pytest.mark.parametrize("code", [0, -1, 12, 11.5, np.nan, "x"]) +def test_unknown_adult_code_fails_the_build(code) -> None: + with pytest.raises(ValueError, match="EMPSTATI"): + derive_employment_status_from_frs([1, code], [True, True]) + + +@pytest.mark.parametrize("code", [0, 12, np.nan]) +def test_adult_record_with_unknown_or_blank_code_fails_the_stage(code) -> None: + person = pd.DataFrame({"person_id": [1, 2]}) + adult = pd.DataFrame( + {"person_id": [1, 2], "empstati": [1, code], "mjobsect": [1, 1], "sic": [1, 1]} + ) + + with pytest.raises(ValueError, match="EMPSTATI"): + derive_frs_employment(person, adult) + + +def test_unknown_code_message_names_codes_and_suppresses_small_counts() -> None: + with pytest.raises(ValueError) as small: + derive_employment_status_from_frs([12, 12, np.nan, 11.5], [True] * 4) + message = str(small.value) + assert "fewer than 10 adults" in message + assert "['11.5', '12', 'blank or non-numeric']" in message + + with pytest.raises(ValueError) as large: + derive_employment_status_from_frs([13] * 10, [True] * 10) + assert "carries 10 adults" in str(large.value) + + +def test_mismatched_lengths_fail() -> None: + with pytest.raises(ValueError, match="same length"): + derive_employment_status_from_frs([1, 2], [True]) + + +@given(people) +def test_each_row_maps_on_its_own(rows) -> None: + 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) -> None: + 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) -> None: + rows = list(rows) + rows.insert(min(position, len(rows)), (True, bad_code)) + with pytest.raises(ValueError, match="EMPSTATI"): + _derive(rows) + + +@given(st.lists(unknown_adult_codes, min_size=1, max_size=30)) +def test_unknown_code_message_never_prints_a_count_under_10(bad_codes) -> None: + with pytest.raises(ValueError) as error: + derive_employment_status_from_frs(bad_codes, [True] * len(bad_codes)) + printed = re.search(r"carries (.+?) adults", str(error.value)).group(1) + if len(bad_codes) < 10: + assert printed == "fewer than 10" + else: + assert printed == str(len(bad_codes)) + + +@given(people) +def test_stage_marks_adults_by_adult_tab_membership(rows) -> None: + # The stage aligns adult.tab by person_id: people missing from it get NaN + # codes and are CHILD; everyone in it is mapped strictly. + person = pd.DataFrame({"person_id": np.arange(len(rows)) + 101}) + adult = pd.DataFrame( + { + "person_id": [ + pid + for pid, (is_adult, _) in zip(person["person_id"], rows, strict=True) + if is_adult + ], + "empstati": [code for is_adult, code in rows if is_adult], + } + ).assign(mjobsect=0, sic=0) + + result = derive_frs_employment(person, adult.iloc[::-1]) + + assert result["employment_status"].tolist() == [ + _expected(is_adult, code) for is_adult, code in rows ] - assert result["sic_industry_division"].tolist() == [0, 84, 0, 7, 20] @pytest.mark.parametrize("missing", ["empstati", "mjobsect", "sic"]) diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_legacy_proxies.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_legacy_proxies.py index eba5fdd0d..e2c628499 100644 --- a/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_legacy_proxies.py +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_legacy_proxies.py @@ -1,7 +1,15 @@ from __future__ import annotations +import numpy as np import pandas as pd +import pytest +from hypothesis import given +from hypothesis import strategies as st +from microcosm.build.uk_runtime.frs_employment import ( + derive_employment_status_from_frs, + derive_frs_employment, +) from microcosm.build.uk_runtime.frs_legacy_proxies import derive_frs_legacy_proxies from microcosm.build.uk_runtime.frs_spine import WEEKS_IN_YEAR @@ -41,3 +49,74 @@ def test_legacy_proxy_truth_table_and_jsa_hours_boundary() -> None: assert result["legacy_jobseeker_proxy"].tolist() == [True, False, False, False] assert result["esa_health_condition_proxy"].tolist() == [False, False, True, True] assert result["esa_support_group_proxy"].tolist() == [False, False, False, True] + + +@pytest.mark.parametrize("empstati", range(1, 12)) +def test_esa_proxies_read_only_the_sick_or_disabled_codes(empstati) -> None: + # A working-age adult with no hours: only EMPSTATI 9 (permanently + # sick/disabled) and 10 (temporarily sick/injured) are ESA health states, + # and only 9 is in the support group. Code 11 (other inactive) is neither. + status = derive_frs_employment( + pd.DataFrame({"person_id": [1]}), + pd.DataFrame( + {"person_id": [1], "empstati": [empstati], "mjobsect": [0], "sic": [0]} + ), + )["employment_status"] + + result = derive_frs_legacy_proxies( + _working_age_person(status.to_numpy(), hours=[0]), + employment_status_reported=[True], + state_pension_age=[66], + max_annual_hours=16 * WEEKS_IN_YEAR, + ) + + assert result["esa_health_condition_proxy"].tolist() == [empstati in (9, 10)] + assert result["esa_support_group_proxy"].tolist() == [empstati == 9] + assert result["legacy_jobseeker_proxy"].tolist() == [empstati == 5] + + +@given( + st.lists( + st.tuples( + st.integers(0, 100), # age + st.sampled_from(range(1, 12)), # EMPSTATI + 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_follow_the_corrected_statuses(rows) -> None: + age, codes, spa, hours, reported = map(np.array, zip(*rows, strict=True)) + status = derive_employment_status_from_frs(codes, np.ones(len(rows), bool)) + person = _working_age_person(status, hours=hours).assign(age=age) + + result = derive_frs_legacy_proxies( + person, + employment_status_reported=reported, + state_pension_age=spa, + max_annual_hours=16 * WEEKS_IN_YEAR, + ) + health = result["esa_health_condition_proxy"].to_numpy() + support = result["esa_support_group_proxy"].to_numpy() + + working_age = (age >= 16) & (age < spa) + assert ( + health.tolist() == (reported & working_age & np.isin(codes, (9, 10))).tolist() + ) + assert not (support & ~health).any() + assert not support[codes != 9].any() + assert not health[codes == 11].any() + + +def _working_age_person(status, *, hours) -> pd.DataFrame: + return pd.DataFrame( + { + "age": np.full(len(status), 30), + "employment_status": status, + "hours_worked": hours, + "current_education": "NOT_IN_EDUCATION", + } + ) From a2c57abe2c8f523441acdf64b32bda781cbcbeae Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 13:02:54 -0400 Subject: [PATCH 2/8] Restate the frs_employment stage notes and re-pin the coverage manifest The stage notes in uk/source_stages.json and uk/spec/sources.yaml (and the charter-H2 fixture's copy) described code 11 as the incumbent truncated-map artifact. They now state the data-dictionary mapping, the CHILD rule for people outside adult.tab and the refusal for blank or unknown adult codes, citing uk-data#526 the way other stage notes cite the incumbent (the source manifest refuses the package name). release_input_coverage_manifest.json pins sha256(source_stages.json) in all 15 families; regenerated with tools/build_uk_release_input_coverage_manifest.py (--check passes). The notes are outside stage_contract_sha256, so uk_spine.json does not move. Co-Authored-By: Claude Opus 5.5 --- .../uk-frs-empstati-other-inactive.fixed.md | 1 + .../uk/release_input_coverage_manifest.json | 30 +++++++++---------- .../src/microcosm/build/uk/source_stages.json | 2 +- .../src/microcosm/build/uk/spec/sources.yaml | 2 +- .../build/uk_runtime/frs_employment.py | 6 ++-- .../engine_free/uk/test_uk_frs_employment.py | 2 +- .../parity/uk_spine/sources/fixture.json | 2 +- 7 files changed, 23 insertions(+), 22 deletions(-) create mode 100644 changelog.d/uk-frs-empstati-other-inactive.fixed.md diff --git a/changelog.d/uk-frs-empstati-other-inactive.fixed.md b/changelog.d/uk-frs-empstati-other-inactive.fixed.md new file mode 100644 index 000000000..71904b5bb --- /dev/null +++ b/changelog.d/uk-frs-empstati-other-inactive.fixed.md @@ -0,0 +1 @@ +The UK `frs_employment` stage maps FRS EMPSTATI code 11 ("Other Inactive" in the UKDS FRS 2024-25 data dictionary) to `employment_status` OTHER_INACTIVE instead of LONG_TERM_DISABLED, matching policyengine-uk-data#526, so other-inactive adults no longer enter the ESA health-condition and support-group proxies. Codes 1-11 are mapped one data-dictionary label each; people outside `adult.tab` stay CHILD, and an adult with a blank or unknown code now refuses the build instead of defaulting to LONG_TERM_DISABLED. diff --git a/packages/microcosm-build/src/microcosm/build/uk/release_input_coverage_manifest.json b/packages/microcosm-build/src/microcosm/build/uk/release_input_coverage_manifest.json index 1edc791c4..f0840d6fe 100644 --- a/packages/microcosm-build/src/microcosm/build/uk/release_input_coverage_manifest.json +++ b/packages/microcosm-build/src/microcosm/build/uk/release_input_coverage_manifest.json @@ -468,7 +468,7 @@ "required_mass_change_reason": "Capital-gains incidence anchor moves the mass of non-liable clone households back to their paired originals until the sub-exempt and loss-making clone mass match the Advani-Summers reporter composition at the redrawn liable mass; every pair's mass and the total household mass are conserved.", "rewrites": [], "source_manifest": "source_stages.json", - "source_manifest_sha256": "c461c338a92a48409c1537bd1c142852f750ab2529b302fa74bf08af32da4752", + "source_manifest_sha256": "727c1bab51d6db91e2078fe2b04effb0f35c925b0cce94ccca9b9ad6ba551939", "source_vintages": { "source": "Advani and Summers (2020), Capital Gains and UK Inequality, CAGE Working Paper 465", "survey": "Family Resources Survey 2024-25, SPI synthetic support, the HMRC Table 3 redrawn gains on the spine and Advani-Summers capital-gains incidence" @@ -491,7 +491,7 @@ "capital_gains" ], "source_manifest": "source_stages.json", - "source_manifest_sha256": "c461c338a92a48409c1537bd1c142852f750ab2529b302fa74bf08af32da4752", + "source_manifest_sha256": "727c1bab51d6db91e2078fe2b04effb0f35c925b0cce94ccca9b9ad6ba551939", "source_vintages": { "source": "Advani and Summers (2020), Capital Gains and UK Inequality, CAGE Working Paper 465", "survey": "Family Resources Survey 2024-25, SPI synthetic support, and Advani-Summers capital-gains incidence" @@ -513,7 +513,7 @@ "required_mass_change_reason": "Capital-gains support split divides the wealthiest households of each HMRC Table 3 income band into light copies at equal weight; every household's mass and the total household mass are conserved.", "rewrites": [], "source_manifest": "source_stages.json", - "source_manifest_sha256": "c461c338a92a48409c1537bd1c142852f750ab2529b302fa74bf08af32da4752", + "source_manifest_sha256": "727c1bab51d6db91e2078fe2b04effb0f35c925b0cce94ccca9b9ad6ba551939", "source_vintages": { "source": "HMRC Capital Gains Tax statistics, July 2026 release, Table 3 for 2024-25, via the vendored conditioning facts", "survey": "Family Resources Survey 2024-25, SPI synthetic support and HMRC Capital Gains Tax statistics Table 3 (2024-25 individuals by size of gain and taxable income)" @@ -542,7 +542,7 @@ "required_mass_change_reason": "ETB public-services imputation on the source spine: household weights pass through unchanged and total household mass is conserved.", "rewrites": [], "source_manifest": "source_stages.json", - "source_manifest_sha256": "c461c338a92a48409c1537bd1c142852f750ab2529b302fa74bf08af32da4752", + "source_manifest_sha256": "727c1bab51d6db91e2078fe2b04effb0f35c925b0cce94ccca9b9ad6ba551939", "source_vintages": { "source": "UK Data Service SN 8856 Effects of Taxes and Benefits household tab, DfT rail fare index, and public NHS activity/cost table.", "survey": "Effects of Taxes and Benefits 1977-2024 and NHS age-gender public table" @@ -562,7 +562,7 @@ "required_mass_change_reason": "ETB VAT expenditure-rate imputation on the source spine: household weights pass through unchanged and total household mass is conserved.", "rewrites": [], "source_manifest": "source_stages.json", - "source_manifest_sha256": "c461c338a92a48409c1537bd1c142852f750ab2529b302fa74bf08af32da4752", + "source_manifest_sha256": "727c1bab51d6db91e2078fe2b04effb0f35c925b0cce94ccca9b9ad6ba551939", "source_vintages": { "source": "UK Data Service SN 8856 Effects of Taxes and Benefits household tab and cited VAT anchor resource.", "survey": "Effects of Taxes and Benefits 1977-2024" @@ -584,7 +584,7 @@ "required_mass_change_reason": "CGT asset-type assignment on the source spine: household weights pass through unchanged and total household mass is conserved.", "rewrites": [], "source_manifest": "source_stages.json", - "source_manifest_sha256": "c461c338a92a48409c1537bd1c142852f750ab2529b302fa74bf08af32da4752", + "source_manifest_sha256": "727c1bab51d6db91e2078fe2b04effb0f35c925b0cce94ccca9b9ad6ba551939", "source_vintages": { "source": "https://www.gov.uk/government/statistics/capital-gains-tax-statistics", "survey": "HMRC Capital Gains Tax statistics 2026 release, Table 8 (UK residential property disposals, 2024-25, administrative), Table 4.1 (individuals claiming Business Asset Disposal Relief or Investors' Relief by band of qualifying gain, 2024-25, administrative) and Table 7 (disposals, proceeds and gains by asset type, 2023-24, sample-based), vendored from the pinned Chronicle feed" @@ -608,7 +608,7 @@ "capital_gains" ], "source_manifest": "source_stages.json", - "source_manifest_sha256": "c461c338a92a48409c1537bd1c142852f750ab2529b302fa74bf08af32da4752", + "source_manifest_sha256": "727c1bab51d6db91e2078fe2b04effb0f35c925b0cce94ccca9b9ad6ba551939", "source_vintages": { "conditioning_resource": "hmrc_cgt_conditioning_facts.json", "conditioning_resource_sha256": "067ba21c12aeb4e95e6a8a16141dbaa075e8787ed875a005f549cab6e91a6b56", @@ -697,7 +697,7 @@ "full_frs_tei_band_unavailable" ], "source_manifest": "source_stages.json", - "source_manifest_sha256": "c461c338a92a48409c1537bd1c142852f750ab2529b302fa74bf08af32da4752", + "source_manifest_sha256": "727c1bab51d6db91e2078fe2b04effb0f35c925b0cce94ccca9b9ad6ba551939", "source_vintages": { "hmrc_surface": "2023-24", "mapped_build_period": "2024", @@ -738,7 +738,7 @@ "required_mass_change_reason": "LCFS consumption imputation on the source spine: household weights pass through unchanged and total household mass is conserved.", "rewrites": [], "source_manifest": "source_stages.json", - "source_manifest_sha256": "c461c338a92a48409c1537bd1c142852f750ab2529b302fa74bf08af32da4752", + "source_manifest_sha256": "727c1bab51d6db91e2078fe2b04effb0f35c925b0cce94ccca9b9ad6ba551939", "source_vintages": { "source": "UK Data Service SN 9468 Living Costs and Food Survey 2023-24 household/person tabs and the vendored Chronicle facts for NEED 2023 mean kWh, the FY2024-25 Ofgem cap levels, road-fuel outturn (DESNZ, HMRC, ONS), the DfT VEH1103 licensed-car stock, NTS bus use and the DfT and devolved bus finance tables.", "survey": "Living Costs and Food Survey 2023-24" @@ -764,7 +764,7 @@ "required_mass_change_reason": "NTS bus-travel imputation on the source spine: household weights pass through unchanged and total household mass is conserved.", "rewrites": [], "source_manifest": "source_stages.json", - "source_manifest_sha256": "c461c338a92a48409c1537bd1c142852f750ab2529b302fa74bf08af32da4752", + "source_manifest_sha256": "727c1bab51d6db91e2078fe2b04effb0f35c925b0cce94ccca9b9ad6ba551939", "source_vintages": { "source": "Department for Transport National Travel Survey, UK Data Service SN 5340 (End User Licence, 19th edition, November 2025), DOI 10.5255/UKDA-SN-5340-19; local licensed tab files (household, individual, trip); England residents only from 2013.", "survey": "National Travel Survey 2002-2024" @@ -785,7 +785,7 @@ "property_wealth" ], "source_manifest": "source_stages.json", - "source_manifest_sha256": "c461c338a92a48409c1537bd1c142852f750ab2529b302fa74bf08af32da4752", + "source_manifest_sha256": "727c1bab51d6db91e2078fe2b04effb0f35c925b0cce94ccca9b9ad6ba551939", "source_vintages": { "source": "MHCLG dwellings and ONS UK House Price Index December 2025 regional average prices.", "survey": "Public regional property reference" @@ -811,7 +811,7 @@ "employment_income" ], "source_manifest": "source_stages.json", - "source_manifest_sha256": "c461c338a92a48409c1537bd1c142852f750ab2529b302fa74bf08af32da4752", + "source_manifest_sha256": "727c1bab51d6db91e2078fe2b04effb0f35c925b0cce94ccca9b9ad6ba551939", "source_vintages": { "source": "HMRC, Salary sacrifice reform for pension contributions effective from 6 April 2029", "survey": "Family Resources Survey 2024-25 salary-sacrifice respondents and HMRC salary-sacrifice reform analysis" @@ -833,7 +833,7 @@ "student_loan_plan" ], "source_manifest": "source_stages.json", - "source_manifest_sha256": "c461c338a92a48409c1537bd1c142852f750ab2529b302fa74bf08af32da4752", + "source_manifest_sha256": "727c1bab51d6db91e2078fe2b04effb0f35c925b0cce94ccca9b9ad6ba551939", "source_vintages": { "source": "Explore Education Statistics Table 6a, Higher education total", "survey": "Family Resources Survey 2024-25 and Student Loans Company borrower forecasts for England" @@ -855,7 +855,7 @@ "required_mass_change_reason": "WAS Lifetime ISA imputation on the source spine: household weights pass through unchanged and total household mass is conserved.", "rewrites": [], "source_manifest": "source_stages.json", - "source_manifest_sha256": "c461c338a92a48409c1537bd1c142852f750ab2529b302fa74bf08af32da4752", + "source_manifest_sha256": "727c1bab51d6db91e2078fe2b04effb0f35c925b0cce94ccca9b9ad6ba551939", "source_vintages": { "source": "Office for National Statistics Wealth and Assets Survey, UK Data Service SN 7215, DOI 10.5255/UKDA-SN-7215-20; local licensed round-8 person and household tabs (interviews April 2020 to March 2022, Great Britain).", "survey": "Wealth and Assets Survey round 8" @@ -890,7 +890,7 @@ "required_mass_change_reason": "WAS wealth imputation on the source spine: household weights pass through unchanged and total household mass is conserved.", "rewrites": [], "source_manifest": "source_stages.json", - "source_manifest_sha256": "c461c338a92a48409c1537bd1c142852f750ab2529b302fa74bf08af32da4752", + "source_manifest_sha256": "727c1bab51d6db91e2078fe2b04effb0f35c925b0cce94ccca9b9ad6ba551939", "source_vintages": { "source": "Office for National Statistics Wealth and Assets Survey, UK Data Service SN 7215, DOI 10.5255/UKDA-SN-7215-20; local licensed 2006-22 household tab.", "survey": "Wealth and Assets Survey round 8" diff --git a/packages/microcosm-build/src/microcosm/build/uk/source_stages.json b/packages/microcosm-build/src/microcosm/build/uk/source_stages.json index 27840af3a..1a4c8a826 100644 --- a/packages/microcosm-build/src/microcosm/build/uk/source_stages.json +++ b/packages/microcosm-build/src/microcosm/build/uk/source_stages.json @@ -676,7 +676,7 @@ "nonnegative_outputs": [ "sic_industry_division" ], - "notes": "Ports FRS employment derivations. empstati code 11 preserves the incumbent truncated-map artifact as LONG_TERM_DISABLED, so OTHER_INACTIVE is not emitted; mjobsect and sic are direct-indexed and fail loudly if absent." + "notes": "Ports FRS employment derivations. empstati codes 1-11 each map to the status their UKDS SN 9563 adult-table data-dictionary label names, as uk-data#526 does: 11 (Other Inactive) is OTHER_INACTIVE and only 9 (Permanently sick/disabled) is LONG_TERM_DISABLED. People outside adult.tab are CHILD; an adult with a blank or unknown code refuses the build. mjobsect and sic are direct-indexed and fail loudly if absent." }, { "stage": "frs_council_tax", diff --git a/packages/microcosm-build/src/microcosm/build/uk/spec/sources.yaml b/packages/microcosm-build/src/microcosm/build/uk/spec/sources.yaml index 3442ecfe4..28c2805ac 100644 --- a/packages/microcosm-build/src/microcosm/build/uk/spec/sources.yaml +++ b/packages/microcosm-build/src/microcosm/build/uk/spec/sources.yaml @@ -685,7 +685,7 @@ stages: - sic_industry_division nonnegative_outputs: - sic_industry_division - notes: Ports FRS employment derivations. empstati code 11 preserves the incumbent truncated-map artifact as LONG_TERM_DISABLED, so OTHER_INACTIVE is not emitted; mjobsect and sic are direct-indexed and fail loudly if absent. + notes: "Ports FRS employment derivations. empstati codes 1-11 each map to the status their UKDS SN 9563 adult-table data-dictionary label names, as uk-data#526 does: 11 (Other Inactive) is OTHER_INACTIVE and only 9 (Permanently sick/disabled) is LONG_TERM_DISABLED. People outside adult.tab are CHILD; an adult with a blank or unknown code refuses the build. mjobsect and sic are direct-indexed and fail loudly if absent." - stage: frs_council_tax survey: Family Resources Survey 2024-25 source: Department for Work and Pensions Family Resources Survey 2024-25, UK Data Service SN 9563, DOI 10.5255/UKDA-SN-9563-1; local licensed 2024_25 tabs. diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/frs_employment.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/frs_employment.py index d295a0e90..8ac7e490e 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/frs_employment.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/frs_employment.py @@ -25,9 +25,9 @@ # Adult-table EMPSTATI ("Adult - Employment Status - ILO definition") value # labels from the UKDS FRS 2024-25 data dictionary (SN 9563), mapped to -# policyengine-uk EmploymentStatus names. Matches policyengine-uk-data's -# FRS_EMPSTATI_EMPLOYMENT_STATUS (policyengine-uk-data#526). Child-table -# people have no EMPSTATI and are CHILD. +# policyengine-uk EmploymentStatus names. Matches the incumbent's +# FRS_EMPSTATI_EMPLOYMENT_STATUS (uk-data#526). Child-table people have no +# EMPSTATI and are CHILD. FRS_EMPSTATI_EMPLOYMENT_STATUS = MappingProxyType( { 1: "FT_EMPLOYED", # Full-time employee diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_employment.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_employment.py index 4e9879d41..60e3ea63b 100644 --- a/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_employment.py +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_employment.py @@ -17,7 +17,7 @@ # 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. policyengine-uk-data#526 pins the same table. +# label means. The incumbent's uk-data#526 pins the same table. DATA_DICTIONARY = { 1: ("Full-time Employee", "FT_EMPLOYED"), 2: ("Part-time Employee", "PT_EMPLOYED"), diff --git a/packages/microcosm-graph/tests/fixtures/parity/uk_spine/sources/fixture.json b/packages/microcosm-graph/tests/fixtures/parity/uk_spine/sources/fixture.json index ef551bb80..bb423fcf2 100644 --- a/packages/microcosm-graph/tests/fixtures/parity/uk_spine/sources/fixture.json +++ b/packages/microcosm-graph/tests/fixtures/parity/uk_spine/sources/fixture.json @@ -1092,7 +1092,7 @@ "nonnegative_outputs": [ "sic_industry_division" ], - "notes": "Ports FRS employment derivations. empstati code 11 preserves the incumbent truncated-map artifact as LONG_TERM_DISABLED, so OTHER_INACTIVE is not emitted; mjobsect and sic are direct-indexed and fail loudly if absent.", + "notes": "Ports FRS employment derivations. empstati codes 1-11 each map to the status their UKDS SN 9563 adult-table data-dictionary label names, as uk-data#526 does: 11 (Other Inactive) is OTHER_INACTIVE and only 9 (Permanently sick/disabled) is LONG_TERM_DISABLED. People outside adult.tab are CHILD; an adult with a blank or unknown code refuses the build. mjobsect and sic are direct-indexed and fail loudly if absent.", "operations": [ { "delimiter": "\t", From 37773ad2cb0a95a646b6b410855ba6f0b41d5018 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 13:03:49 -0400 Subject: [PATCH 3/8] Add the EMPSTATI receipts and the differential against uk-data#526 The receipt records the pinned FRS 2024-25 adult.tab's EMPSTATI code counts (every adult carries 1-11; code 11 is 916 adults, 2.05m grossed) and the consumers of employment_status. The one-off differential loads uk-data#526's code table and derive function from the PR head by AST and agrees with microcosm on 34 fixed cases, 2,000 Hypothesis examples and all 34,966 licensed FRS 2024-25 people (0 mismatches). Aggregates only. Co-Authored-By: Claude Opus 5.5 --- .../uk-frs-empstati-other-inactive/README.md | 71 ++++++++ .../differential_vs_uk_data_526.py | 158 ++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 experiments/uk-frs-empstati-other-inactive/README.md create mode 100644 experiments/uk-frs-empstati-other-inactive/differential_vs_uk_data_526.py diff --git a/experiments/uk-frs-empstati-other-inactive/README.md b/experiments/uk-frs-empstati-other-inactive/README.md new file mode 100644 index 000000000..f10c63434 --- /dev/null +++ b/experiments/uk-frs-empstati-other-inactive/README.md @@ -0,0 +1,71 @@ +# FRS EMPSTATI 11 → OTHER_INACTIVE: receipts + +The UK `frs_employment` stage now maps EMPSTATI codes 1-11 one data-dictionary +label each (UKDS SN 9563, FRS 2024-25 adult table), so code 11 ("Other +Inactive") is `OTHER_INACTIVE` rather than `LONG_TERM_DISABLED`. This is the +same mapping as the incumbent's uk-data#526 (head `d3984002`). Everything below +was run on 2026-10-02 against the pinned `adult.tab` +(sha256 `4eaea080…658d`). Only aggregates are reported; no cell here is under +10 survey people. + +## The licensed input + +Every one of the 27,714 adults in `adult.tab` carries a code from 1 to 11. No +code is blank, non-integer or outside that range, and no `person_id` repeats. +So the new refusal of unknown adult codes does not stop the real build. +`child.tab` has no EMPSTATI column. + +| EMPSTATI | Data-dictionary label | Status | Adults | Grossed (GROSS4) | +|---:|---|---|---:|---:| +| 1 | Full-time Employee | FT_EMPLOYED | 10,297 | 22.51m | +| 2 | Part-time Employee | PT_EMPLOYED | 2,701 | 5.57m | +| 3 | Full-time Self-Employed | FT_SELF_EMPLOYED | 1,446 | 2.95m | +| 4 | Part-time Self-Employed | PT_SELF_EMPLOYED | 664 | 1.25m | +| 5 | Unemployed | UNEMPLOYED | 484 | 1.31m | +| 6 | Retired | RETIRED | 8,585 | 12.04m | +| 7 | Student | STUDENT | 383 | 1.34m | +| 8 | Looking after family/home | CARER | 429 | 1.00m | +| 9 | Permanently sick/disabled | LONG_TERM_DISABLED | 1,678 | 3.24m | +| 10 | Temporarily sick/injured | SHORT_TERM_DISABLED | 131 | 0.29m | +| 11 | Other Inactive | OTHER_INACTIVE (was LONG_TERM_DISABLED) | 916 | 2.05m | + +Before this change, `LONG_TERM_DISABLED` held 2,594 adults (codes 9 and 11); +it now holds the 1,678 with code 9. These are the same counts uk-data#526 +reports for its FRS 2024-25 build. + +## Differential against uk-data#526 + +`differential_vs_uk_data_526.py` loads uk-data's code table and +`derive_employment_status_from_frs` from the PR head by AST and compares them +with microcosm's: + +``` +code tables identical: 11 codes +fixed cases agree: 34 +hypothesis cases agree: 2000 examples +licensed FRS 2024-25 people: 34966; mismatches: 0 +``` + +The fixed cases are codes 0-12, -1, 11.5, NaN and 99, each as an adult and as +a child row. Agreement means both return the same statuses or both refuse. +The licensed comparison runs microcosm's stage derivation +(`derive_frs_employment`, reading `adult.tab` through the sha-pinned reader) +against uk-data's function fed the way `create_frs` feeds it (child rows filled +with 0, adult records by `adult.tab` membership), over all 34,966 people. + +## What reads `employment_status` + +- `frs_legacy_proxies` reads the frame's `employment_status`. + `ESA_HEALTH_EMPLOYMENT_STATUSES` is (`LONG_TERM_DISABLED`, + `SHORT_TERM_DISABLED`), and the support group needs `LONG_TERM_DISABLED`. + Code-11 adults therefore leave both ESA proxies. `legacy_jobseeker_proxy` + reads `UNEMPLOYED` only and does not change. +- In policyengine-uk 2.100.0 (microcosm's lock), no variable formula reads + `employment_status`. The labour-supply dynamics read the self-employed and + student statuses only, and the three proxies are not engine variables. +- The eFRS parity reference (uk-data 1.56.16), the release input-coverage gate + and the input-mass parity compare `employment_status` only as a non-empty + or non-default (engine default `UNEMPLOYED`) share, which this change leaves + as it was. No CI job reads a released uk-data artifact. Until uk-data ships + #526, microcosm's code-11 adults and ESA proxies differ from the pinned + incumbent, and no committed instrument measures that difference. diff --git a/experiments/uk-frs-empstati-other-inactive/differential_vs_uk_data_526.py b/experiments/uk-frs-empstati-other-inactive/differential_vs_uk_data_526.py new file mode 100644 index 000000000..c93d67347 --- /dev/null +++ b/experiments/uk-frs-empstati-other-inactive/differential_vs_uk_data_526.py @@ -0,0 +1,158 @@ +"""Differential check: microcosm's EMPSTATI mapping against policyengine-uk-data#526. + +policyengine-uk-data is not a microcosm dependency and #526 is unreleased, so +this runs once, by hand, rather than in CI. It loads uk-data's +``FRS_EMPSTATI_EMPLOYMENT_STATUS`` and ``derive_employment_status_from_frs`` +from a checkout of the PR head (by AST, so the rest of ``frs.py`` and its +imports are never executed), then checks the two implementations agree: + +1. on every code 0-11 and a set of unknown codes, as adult and child rows; +2. on Hypothesis-generated mixes of adult and child rows, raising together; +3. on the licensed FRS 2024-25 person set (``adult.tab`` plus ``child.tab``, + read through microcosm's sha-pinned reader), element by element. + +Only aggregates are printed: per-status counts, with cells under 10 people +suppressed, and the mismatch count. Usage (UK engine environment):: + + uv run --no-sync python \ + experiments/uk-frs-empstati-other-inactive/differential_vs_uk_data_526.py \ + --uk-data-checkout \ + --frs-dir +""" + +from __future__ import annotations + +import argparse +import ast +import math +import subprocess +from pathlib import Path + +import numpy as np +import pandas as pd +from hypothesis import given, settings +from hypothesis import strategies as st +from policyengine_uk.variables.household.income.employment_status import ( + EmploymentStatus, +) + +from microcosm.build.country_spec import load_country_spec +from microcosm.build.uk_runtime.frs_employment import ( + FRS_EMPSTATI_EMPLOYMENT_STATUS, + derive_employment_status_from_frs, + derive_frs_employment, +) +from microcosm.build.uk_runtime.frs_spine import normalize_ids, read_pinned_tab + +UK_DATA_526_HEAD = "d3984002c17d2bee0cca5606071e9a5aa2dc4c18" +UK_DATA_NAMES = ("FRS_EMPSTATI_EMPLOYMENT_STATUS", "derive_employment_status_from_frs") + + +def load_uk_data_mapping(checkout: Path): + head = subprocess.run( + ["git", "-C", str(checkout), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + if head != UK_DATA_526_HEAD: + raise SystemExit(f"{checkout} is at {head}, not #526's {UK_DATA_526_HEAD}.") + source = (checkout / "policyengine_uk_data/datasets/frs.py").read_text() + tree = ast.parse(source) + wanted = [ + node + for node in tree.body + if ( + isinstance(node, ast.Assign) + and any(getattr(t, "id", None) in UK_DATA_NAMES for t in node.targets) + ) + or (isinstance(node, ast.FunctionDef) and node.name in UK_DATA_NAMES) + ] + if len(wanted) != len(UK_DATA_NAMES): + raise SystemExit(f"Expected {UK_DATA_NAMES} in uk-data frs.py.") + namespace = {"EmploymentStatus": EmploymentStatus, "np": np, "pd": pd} + exec(compile(ast.Module(body=wanted, type_ignores=[]), "frs.py", "exec"), namespace) + return namespace[UK_DATA_NAMES[0]], namespace[UK_DATA_NAMES[1]] + + +def outcome(function, codes, is_adult): + try: + return ("ok", list(function(codes, is_adult))) + except ValueError: + return ("refused", None) + + +def suppressed(counts: pd.Series) -> dict[str, object]: + return { + str(key): (int(value) if value >= 10 else "<10") + for key, value in counts.items() + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--uk-data-checkout", type=Path, required=True) + parser.add_argument("--frs-dir", type=Path, required=True) + args = parser.parse_args() + uk_data_table, uk_data_derive = load_uk_data_mapping(args.uk_data_checkout) + + assert dict(FRS_EMPSTATI_EMPLOYMENT_STATUS) == uk_data_table + print("code tables identical: 11 codes") + + fixed = [*range(0, 13), -1, 11.5, math.nan, 99] + for code in fixed: + for is_adult in (True, False): + ours = outcome(derive_employment_status_from_frs, [code], [is_adult]) + theirs = outcome(uk_data_derive, [code], [is_adult]) + assert ours == theirs, (code, is_adult, ours, theirs) + print(f"fixed cases agree: {len(fixed) * 2}") + + code_values = st.one_of( + st.integers(-5, 15), st.just(math.nan), st.floats(0.5, 11.5) + ) + rows = st.lists(st.tuples(st.booleans(), code_values), max_size=40) + + @settings(max_examples=2_000, deadline=None) + @given(rows) + def agree(sample): + codes = [code for _, code in sample] + is_adult = [adult for adult, _ in sample] + assert outcome(derive_employment_status_from_frs, codes, is_adult) == outcome( + uk_data_derive, codes, is_adult + ) + + agree() + print("hypothesis cases agree: 2000 examples") + + stages = {stage.stage: stage for stage in load_country_spec("uk").sources.stages} + employment = {a["table"]: a for a in stages["frs_employment"].artifacts} + spine = {a["table"]: a for a in stages["frs_spine"].artifacts} + adult = normalize_ids( + read_pinned_tab(args.frs_dir / "adult.tab", employment["adult"]) + ) + child = normalize_ids( + read_pinned_tab( + args.frs_dir / "child.tab", spine["child"], columns=("sernum", "person") + ) + ) + person = pd.DataFrame( + {"person_id": np.concatenate([adult["person_id"], child["person_id"]])} + ) + ours = derive_frs_employment(person, adult)["employment_status"].to_numpy() + # uk-data's person table fills the child table's absent EMPSTATI with 0. + uk_data_codes = person["person_id"].map(adult.set_index("person_id")["empstati"]) + theirs = uk_data_derive( + uk_data_codes.fillna(0).to_numpy(), + person["person_id"].isin(adult["person_id"]).to_numpy(), + ) + mismatches = int((ours != theirs).sum()) + print(f"licensed FRS 2024-25 people: {len(person)}; mismatches: {mismatches}") + print( + "employment_status counts (unweighted, cells under 10 suppressed):", + suppressed(pd.Series(ours).value_counts().sort_index()), + ) + assert mismatches == 0 + + +if __name__ == "__main__": + main() From f4154ce64d8c12883dd68bb813b0e4e9b96474b4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 13:17:46 -0400 Subject: [PATCH 4/8] Add the EMPSTATI mutation check and its receipt Ten mutations of the mapping and the ESA proxy statuses, each applied to a temporary copy of microcosm-build's sources placed first on PYTHONPATH; all ten fail the employment, legacy-proxy and engine enum tests. Co-Authored-By: Claude Opus 5.5 --- .../uk-frs-empstati-other-inactive/README.md | 22 +++ .../mutation_check.py | 149 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 experiments/uk-frs-empstati-other-inactive/mutation_check.py diff --git a/experiments/uk-frs-empstati-other-inactive/README.md b/experiments/uk-frs-empstati-other-inactive/README.md index f10c63434..0cade5962 100644 --- a/experiments/uk-frs-empstati-other-inactive/README.md +++ b/experiments/uk-frs-empstati-other-inactive/README.md @@ -69,3 +69,25 @@ with 0, adult records by `adult.tab` membership), over all 34,966 people. as it was. No CI job reads a released uk-data artifact. Until uk-data ships #526, microcosm's code-11 adults and ESA proxies differ from the pinned incumbent, and no committed instrument measures that difference. + +## Mutation check + +`mutation_check.py` applies each mutation to a temporary copy of +microcosm-build's sources, placed first on `PYTHONPATH`, and runs the three +test files (engine-free employment and legacy-proxy tests, and the engine-uk +enum test). Every mutation must fail them: + +``` +baseline (unmutated copy): rc=0 +killed: 11 back to LONG_TERM_DISABLED (rc=1) +killed: unknown adult codes default to LONG_TERM_DISABLED (rc=1) +killed: every row treated as an adult (rc=1) +killed: adult records by code presence, not adult.tab membership (rc=1) +killed: 9 and 10 swapped (rc=1) +killed: small counts printed (rc=1) +killed: children get a non-CHILD status (rc=1) +killed: code 0 accepted for adults (rc=1) +killed: non-integer codes truncated (rc=1) +killed: OTHER_INACTIVE added to the ESA health statuses (rc=1) +10/10 mutations killed +``` diff --git a/experiments/uk-frs-empstati-other-inactive/mutation_check.py b/experiments/uk-frs-empstati-other-inactive/mutation_check.py new file mode 100644 index 000000000..9c2d27cd3 --- /dev/null +++ b/experiments/uk-frs-empstati-other-inactive/mutation_check.py @@ -0,0 +1,149 @@ +"""Mutation check for the EMPSTATI port: each mutation must fail the tests. + +Runs against a copy of microcosm-build's sources placed first on PYTHONPATH, so +the worktree itself is never mutated. Prints one line per mutation. Usage (UK +engine environment):: + + uv run --no-sync python experiments/uk-frs-empstati-other-inactive/mutation_check.py +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +COPY = Path(tempfile.mkdtemp(prefix="empstati-mutants-")) / "src" +EMPLOYMENT = "microcosm/build/uk_runtime/frs_employment.py" +PROXIES = "microcosm/build/uk_runtime/frs_legacy_proxies.py" +TESTS = [ + "packages/microcosm-build/tests/engine_free/uk/test_uk_frs_employment.py", + "packages/microcosm-build/tests/engine_free/uk/test_uk_frs_legacy_proxies.py", + "packages/microcosm-build/tests/engine/uk/test_uk_frs_employment.py", +] + +MUTATIONS = { + "11 back to LONG_TERM_DISABLED": ( + EMPLOYMENT, + '11: "OTHER_INACTIVE", # Other inactive', + '11: "LONG_TERM_DISABLED",', + ), + "unknown adult codes default to LONG_TERM_DISABLED": ( + EMPLOYMENT, + " unknown = adult & pd.isna(adult_status)\n", + ' adult_status = pd.Series(adult_status).fillna("LONG_TERM_DISABLED")' + ".to_numpy(dtype=object)\n unknown = adult & pd.isna(adult_status)\n", + ), + "every row treated as an adult": ( + EMPLOYMENT, + " adult = np.asarray(is_adult_record, dtype=bool)\n", + " adult = np.ones(len(codes), dtype=bool)\n", + ), + "adult records by code presence, not adult.tab membership": ( + EMPLOYMENT, + 'aligned["empstati"], person["person_id"].isin(adult["person_id"])', + 'aligned["empstati"], aligned["empstati"].notna().to_numpy()', + ), + "9 and 10 swapped": ( + EMPLOYMENT, + '9: "LONG_TERM_DISABLED", # Permanently sick/disabled\n' + ' 10: "SHORT_TERM_DISABLED",', + '9: "SHORT_TERM_DISABLED", # Permanently sick/disabled\n' + ' 10: "LONG_TERM_DISABLED",', + ), + "small counts printed": ( + EMPLOYMENT, + "if count >= 10 else", + "if count >= 0 else", + ), + "children get a non-CHILD status": ( + EMPLOYMENT, + "np.where(adult, adult_status, CHILD_EMPLOYMENT_STATUS)", + 'np.where(adult, adult_status, "OTHER_INACTIVE")', + ), + "code 0 accepted for adults": ( + EMPLOYMENT, + ' 1: "FT_EMPLOYED",', + ' 0: "CHILD",\n 1: "FT_EMPLOYED",', + ), + "non-integer codes truncated": ( + EMPLOYMENT, + 'np.asarray(pd.to_numeric(empstati, errors="coerce"), dtype="float64")', + 'np.floor(np.asarray(pd.to_numeric(empstati, errors="coerce"), dtype="float64"))', + ), + "OTHER_INACTIVE added to the ESA health statuses": ( + PROXIES, + 'ESA_HEALTH_EMPLOYMENT_STATUSES = ("LONG_TERM_DISABLED", "SHORT_TERM_DISABLED")', + 'ESA_HEALTH_EMPLOYMENT_STATUSES = ("LONG_TERM_DISABLED", "SHORT_TERM_DISABLED", ' + '"OTHER_INACTIVE")', + ), +} + + +def run(env: dict[str, str]) -> int: + return subprocess.run( + [ + sys.executable, + "-W", + "ignore", + "-m", + "pytest", + *TESTS, + "-x", + "-q", + "-p", + "no:cacheprovider", + "--hypothesis-seed=0", + ], + cwd=ROOT, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode + + +def main() -> int: + source = ROOT / "packages/microcosm-build/src" + env = {**os.environ, "PYTHONPATH": str(COPY)} + shutil.rmtree(COPY, ignore_errors=True) + shutil.copytree(source, COPY) + resolved = subprocess.run( + [ + sys.executable, + "-W", + "ignore", + "-c", + "import microcosm.build.uk_runtime.frs_employment as m; print(m.__file__)", + ], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + check=True, + ).stdout.strip() + assert resolved.startswith(str(COPY)), resolved + baseline = run(env) + print(f"baseline (unmutated copy): rc={baseline}") + assert baseline == 0 + killed = 0 + for name, (relative, old, new) in MUTATIONS.items(): + path = COPY / relative + original = (source / relative).read_text() + assert original.count(old) == 1, name + path.write_text(original.replace(old, new)) + rc = run(env) + path.write_text(original) + verdict = "killed" if rc != 0 else "SURVIVED" + killed += rc != 0 + print(f"{verdict}: {name} (rc={rc})") + print(f"{killed}/{len(MUTATIONS)} mutations killed") + shutil.rmtree(COPY.parent, ignore_errors=True) + return 0 if killed == len(MUTATIONS) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From 37c6abf532d848d7ed8b92ea8a092f6de9afff6e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 13:23:37 -0400 Subject: [PATCH 5/8] Pin the uk-data#526 differential to its new head fb026659 #526's mapping is byte-identical to d3984002; only its refusal message changed. The differential now records each side's refusal exception type: under pandas 3, uk-data's new message raises TypeError for a blank code next to another unknown code (ValueError under its locked pandas 2.3.3). Statuses and refusals still agree everywhere, with 0 mismatches over 34,966 licensed FRS 2024-25 people. Co-Authored-By: Claude Opus 5.5 --- .../uk-frs-empstati-other-inactive/README.md | 14 ++++++-- .../differential_vs_uk_data_526.py | 33 ++++++++++++++----- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/experiments/uk-frs-empstati-other-inactive/README.md b/experiments/uk-frs-empstati-other-inactive/README.md index 0cade5962..827cbfd6f 100644 --- a/experiments/uk-frs-empstati-other-inactive/README.md +++ b/experiments/uk-frs-empstati-other-inactive/README.md @@ -3,7 +3,7 @@ The UK `frs_employment` stage now maps EMPSTATI codes 1-11 one data-dictionary label each (UKDS SN 9563, FRS 2024-25 adult table), so code 11 ("Other Inactive") is `OTHER_INACTIVE` rather than `LONG_TERM_DISABLED`. This is the -same mapping as the incumbent's uk-data#526 (head `d3984002`). Everything below +same mapping as the incumbent's uk-data#526 (head `fb026659`; the mapping is byte-identical to its earlier head `d3984002`). Everything below was run on 2026-10-02 against the pinned `adult.tab` (sha256 `4eaea080…658d`). Only aggregates are reported; no cell here is under 10 survey people. @@ -36,16 +36,26 @@ reports for its FRS 2024-25 build. ## Differential against uk-data#526 `differential_vs_uk_data_526.py` loads uk-data's code table and -`derive_employment_status_from_frs` from the PR head by AST and compares them +`derive_employment_status_from_frs` from the PR head (`fb026659`) by AST and compares them with microcosm's: ``` code tables identical: 11 codes fixed cases agree: 34 hypothesis cases agree: 2000 examples +refusal exception types (pandas 3.0.3): {'microcosm': ['ValueError'], 'uk-data': ['TypeError', 'ValueError']} licensed FRS 2024-25 people: 34966; mismatches: 0 ``` +Agreement means the same statuses, or a refusal on both sides. The refusal's +exception type is recorded, not compared. At `fb026659`, uk-data's message +formats the codes with `sorted(set(codes.astype(str)))`. Under pandas 3, +`astype(str)` keeps NaN as a float, so a blank adult code next to another +unknown code makes `sorted` raise `TypeError` instead of `ValueError`. The +build still stops. Under uk-data's locked pandas 2.3.3 it raises +`ValueError`, which was checked in that checkout's own environment. This was +reported to the #526 owner. Microcosm's refusal is always `ValueError`. + The fixed cases are codes 0-12, -1, 11.5, NaN and 99, each as an adult and as a child row. Agreement means both return the same statuses or both refuse. The licensed comparison runs microcosm's stage derivation diff --git a/experiments/uk-frs-empstati-other-inactive/differential_vs_uk_data_526.py b/experiments/uk-frs-empstati-other-inactive/differential_vs_uk_data_526.py index c93d67347..445eda5a6 100644 --- a/experiments/uk-frs-empstati-other-inactive/differential_vs_uk_data_526.py +++ b/experiments/uk-frs-empstati-other-inactive/differential_vs_uk_data_526.py @@ -44,7 +44,7 @@ ) from microcosm.build.uk_runtime.frs_spine import normalize_ids, read_pinned_tab -UK_DATA_526_HEAD = "d3984002c17d2bee0cca5606071e9a5aa2dc4c18" +UK_DATA_526_HEAD = "fb0266593cab1dbe4464c4c6db63e4868af43db5" UK_DATA_NAMES = ("FRS_EMPSTATI_EMPLOYMENT_STATUS", "derive_employment_status_from_frs") @@ -75,10 +75,20 @@ def load_uk_data_mapping(checkout: Path): return namespace[UK_DATA_NAMES[0]], namespace[UK_DATA_NAMES[1]] -def outcome(function, codes, is_adult): +REFUSAL_TYPES: dict[str, set[str]] = {"microcosm": set(), "uk-data": set()} + + +def outcome(function, codes, is_adult, *, side): + """Statuses, or "refused" for any exception (its type is recorded). + + Either side refusing stops the build. The refusal's exception type can + differ by environment: uk-data's message formatting depends on the pandas + version, so it is recorded and reported rather than compared. + """ try: return ("ok", list(function(codes, is_adult))) - except ValueError: + except Exception as error: # noqa: BLE001 - every exception refuses a build + REFUSAL_TYPES[side].add(type(error).__name__) return ("refused", None) @@ -102,8 +112,10 @@ def main() -> None: fixed = [*range(0, 13), -1, 11.5, math.nan, 99] for code in fixed: for is_adult in (True, False): - ours = outcome(derive_employment_status_from_frs, [code], [is_adult]) - theirs = outcome(uk_data_derive, [code], [is_adult]) + ours = outcome( + derive_employment_status_from_frs, [code], [is_adult], side="microcosm" + ) + theirs = outcome(uk_data_derive, [code], [is_adult], side="uk-data") assert ours == theirs, (code, is_adult, ours, theirs) print(f"fixed cases agree: {len(fixed) * 2}") @@ -117,12 +129,17 @@ def main() -> None: def agree(sample): codes = [code for _, code in sample] is_adult = [adult for adult, _ in sample] - assert outcome(derive_employment_status_from_frs, codes, is_adult) == outcome( - uk_data_derive, codes, is_adult - ) + assert outcome( + derive_employment_status_from_frs, codes, is_adult, side="microcosm" + ) == outcome(uk_data_derive, codes, is_adult, side="uk-data") agree() print("hypothesis cases agree: 2000 examples") + print( + f"refusal exception types (pandas {pd.__version__}):", + {side: sorted(types) for side, types in REFUSAL_TYPES.items()}, + ) + assert REFUSAL_TYPES["microcosm"] <= {"ValueError"} stages = {stage.stage: stage for stage in load_country_spec("uk").sources.stages} employment = {a["table"]: a for a in stages["frs_employment"].artifacts} From 070497e7c2e0add9de9a4403e5387c398a3c2a08 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 13:28:24 -0400 Subject: [PATCH 6/8] Pin the uk-data#526 differential to 89c48e07 #526's refusal message now formats float codes explicitly, so it refuses with ValueError under pandas 3 too; the mapping is unchanged since d3984002. The differential asserts ValueError on both sides and still finds 0 mismatches over 34,966 licensed FRS 2024-25 people. Co-Authored-By: Claude Opus 5.5 --- .../uk-frs-empstati-other-inactive/README.md | 27 +++++++++---------- .../differential_vs_uk_data_526.py | 11 +++++--- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/experiments/uk-frs-empstati-other-inactive/README.md b/experiments/uk-frs-empstati-other-inactive/README.md index 827cbfd6f..36f3b92da 100644 --- a/experiments/uk-frs-empstati-other-inactive/README.md +++ b/experiments/uk-frs-empstati-other-inactive/README.md @@ -3,7 +3,7 @@ The UK `frs_employment` stage now maps EMPSTATI codes 1-11 one data-dictionary label each (UKDS SN 9563, FRS 2024-25 adult table), so code 11 ("Other Inactive") is `OTHER_INACTIVE` rather than `LONG_TERM_DISABLED`. This is the -same mapping as the incumbent's uk-data#526 (head `fb026659`; the mapping is byte-identical to its earlier head `d3984002`). Everything below +same mapping as the incumbent's uk-data#526 (head `89c48e07`; the mapping is byte-identical to its earlier heads `d3984002` and `fb026659`). Everything below was run on 2026-10-02 against the pinned `adult.tab` (sha256 `4eaea080…658d`). Only aggregates are reported; no cell here is under 10 survey people. @@ -36,29 +36,28 @@ reports for its FRS 2024-25 build. ## Differential against uk-data#526 `differential_vs_uk_data_526.py` loads uk-data's code table and -`derive_employment_status_from_frs` from the PR head (`fb026659`) by AST and compares them -with microcosm's: +`derive_employment_status_from_frs` from the PR head (`89c48e07`) by AST and +compares them with microcosm's: ``` code tables identical: 11 codes fixed cases agree: 34 hypothesis cases agree: 2000 examples -refusal exception types (pandas 3.0.3): {'microcosm': ['ValueError'], 'uk-data': ['TypeError', 'ValueError']} +refusal exception types (pandas 3.0.3): {'microcosm': ['ValueError'], 'uk-data': ['ValueError']} licensed FRS 2024-25 people: 34966; mismatches: 0 ``` -Agreement means the same statuses, or a refusal on both sides. The refusal's -exception type is recorded, not compared. At `fb026659`, uk-data's message -formats the codes with `sorted(set(codes.astype(str)))`. Under pandas 3, -`astype(str)` keeps NaN as a float, so a blank adult code next to another -unknown code makes `sorted` raise `TypeError` instead of `ValueError`. The -build still stops. Under uk-data's locked pandas 2.3.3 it raises -`ValueError`, which was checked in that checkout's own environment. This was -reported to the #526 owner. Microcosm's refusal is always `ValueError`. +Agreement means the same statuses, or a refusal on both sides. Each side's +refusal exception type is recorded, and the script asserts both are +`ValueError`. The earlier run at #526's head `fb026659` found that +uk-data's message formatted the codes with `sorted(set(codes.astype(str)))`. +Under pandas 3, `astype(str)` keeps NaN as a float, so a blank adult code +next to another unknown code raised `TypeError` instead (`ValueError` under +uk-data's locked pandas 2.3.3). That was reported to the #526 owner and fixed +in `89c48e07`. The fixed cases are codes 0-12, -1, 11.5, NaN and 99, each as an adult and as -a child row. Agreement means both return the same statuses or both refuse. -The licensed comparison runs microcosm's stage derivation +a child row. The licensed comparison runs microcosm's stage derivation (`derive_frs_employment`, reading `adult.tab` through the sha-pinned reader) against uk-data's function fed the way `create_frs` feeds it (child rows filled with 0, adult records by `adult.tab` membership), over all 34,966 people. diff --git a/experiments/uk-frs-empstati-other-inactive/differential_vs_uk_data_526.py b/experiments/uk-frs-empstati-other-inactive/differential_vs_uk_data_526.py index 445eda5a6..4d6f5e9e5 100644 --- a/experiments/uk-frs-empstati-other-inactive/differential_vs_uk_data_526.py +++ b/experiments/uk-frs-empstati-other-inactive/differential_vs_uk_data_526.py @@ -44,7 +44,7 @@ ) from microcosm.build.uk_runtime.frs_spine import normalize_ids, read_pinned_tab -UK_DATA_526_HEAD = "fb0266593cab1dbe4464c4c6db63e4868af43db5" +UK_DATA_526_HEAD = "89c48e074f77f196b1a91bbb2a69f78bb29884fc" UK_DATA_NAMES = ("FRS_EMPSTATI_EMPLOYMENT_STATUS", "derive_employment_status_from_frs") @@ -81,9 +81,9 @@ def load_uk_data_mapping(checkout: Path): def outcome(function, codes, is_adult, *, side): """Statuses, or "refused" for any exception (its type is recorded). - Either side refusing stops the build. The refusal's exception type can - differ by environment: uk-data's message formatting depends on the pandas - version, so it is recorded and reported rather than compared. + Either side refusing stops the build. The refusal's exception type is + recorded and checked separately, because message formatting has depended + on the pandas version before. """ try: return ("ok", list(function(codes, is_adult))) @@ -139,7 +139,10 @@ def agree(sample): f"refusal exception types (pandas {pd.__version__}):", {side: sorted(types) for side, types in REFUSAL_TYPES.items()}, ) + # Both sides refuse with ValueError at the pinned head, under pandas 2 or 3 + # (fb026659's message raised TypeError under pandas 3; 89c48e07 fixed it). assert REFUSAL_TYPES["microcosm"] <= {"ValueError"} + assert REFUSAL_TYPES["uk-data"] <= {"ValueError"} stages = {stage.stage: stage for stage in load_country_spec("uk").sources.stages} employment = {a["table"]: a for a in stages["frs_employment"].artifacts} From c37618889a300e6014ee4d7509b4184264071218 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 23:05:45 -0400 Subject: [PATCH 7/8] Answer review round 1 and the live-tree incumbent scan - The changelog fragment cites uk-data#526 instead of the incumbent package name, which test_no_incumbent_data_package_references_in_live_tree refuses (CI engine-free's only failure). - The differential suppresses a mismatch count from 1 to 9 and is re-pinned to uk-data#526's head 5f9912df (frs.py byte-identical to 89c48e07). - The ESA support-group property asserts equality with reported, working age, code 9 and no hours; the mutation check gains "support group ignores hours worked" (11/11 killed). - The receipt describes each parity instrument separately and says CI loads only the committed eFRS extraction. Co-Authored-By: Claude Opus 5.5 --- .../uk-frs-empstati-other-inactive.fixed.md | 2 +- .../uk-frs-empstati-other-inactive/README.md | 27 ++++++++++++------- .../differential_vs_uk_data_526.py | 8 +++--- .../mutation_check.py | 5 ++++ .../uk/test_uk_frs_legacy_proxies.py | 5 +++- 5 files changed, 33 insertions(+), 14 deletions(-) diff --git a/changelog.d/uk-frs-empstati-other-inactive.fixed.md b/changelog.d/uk-frs-empstati-other-inactive.fixed.md index 71904b5bb..9518c1a86 100644 --- a/changelog.d/uk-frs-empstati-other-inactive.fixed.md +++ b/changelog.d/uk-frs-empstati-other-inactive.fixed.md @@ -1 +1 @@ -The UK `frs_employment` stage maps FRS EMPSTATI code 11 ("Other Inactive" in the UKDS FRS 2024-25 data dictionary) to `employment_status` OTHER_INACTIVE instead of LONG_TERM_DISABLED, matching policyengine-uk-data#526, so other-inactive adults no longer enter the ESA health-condition and support-group proxies. Codes 1-11 are mapped one data-dictionary label each; people outside `adult.tab` stay CHILD, and an adult with a blank or unknown code now refuses the build instead of defaulting to LONG_TERM_DISABLED. +The UK `frs_employment` stage maps FRS EMPSTATI code 11 ("Other Inactive" in the UKDS FRS 2024-25 data dictionary) to `employment_status` OTHER_INACTIVE instead of LONG_TERM_DISABLED, matching the incumbent's uk-data#526, so other-inactive adults no longer enter the ESA health-condition and support-group proxies. Codes 1-11 are mapped one data-dictionary label each; people outside `adult.tab` stay CHILD, and an adult with a blank or unknown code now refuses the build instead of defaulting to LONG_TERM_DISABLED. diff --git a/experiments/uk-frs-empstati-other-inactive/README.md b/experiments/uk-frs-empstati-other-inactive/README.md index 36f3b92da..11ff93f45 100644 --- a/experiments/uk-frs-empstati-other-inactive/README.md +++ b/experiments/uk-frs-empstati-other-inactive/README.md @@ -3,7 +3,7 @@ The UK `frs_employment` stage now maps EMPSTATI codes 1-11 one data-dictionary label each (UKDS SN 9563, FRS 2024-25 adult table), so code 11 ("Other Inactive") is `OTHER_INACTIVE` rather than `LONG_TERM_DISABLED`. This is the -same mapping as the incumbent's uk-data#526 (head `89c48e07`; the mapping is byte-identical to its earlier heads `d3984002` and `fb026659`). Everything below +same mapping as the incumbent's uk-data#526 (head `5f9912df`; its `frs.py` is byte-identical to `89c48e07`, and the mapping to the earlier heads `d3984002` and `fb026659`). Everything below was run on 2026-10-02 against the pinned `adult.tab` (sha256 `4eaea080…658d`). Only aggregates are reported; no cell here is under 10 survey people. @@ -36,7 +36,7 @@ reports for its FRS 2024-25 build. ## Differential against uk-data#526 `differential_vs_uk_data_526.py` loads uk-data's code table and -`derive_employment_status_from_frs` from the PR head (`89c48e07`) by AST and +`derive_employment_status_from_frs` from the PR head (`5f9912df`) by AST and compares them with microcosm's: ``` @@ -72,12 +72,20 @@ with 0, adult records by `adult.tab` membership), over all 34,966 people. - In policyengine-uk 2.100.0 (microcosm's lock), no variable formula reads `employment_status`. The labour-supply dynamics read the self-employed and student statuses only, and the three proxies are not engine variables. -- The eFRS parity reference (uk-data 1.56.16), the release input-coverage gate - and the input-mass parity compare `employment_status` only as a non-empty - or non-default (engine default `UNEMPLOYED`) share, which this change leaves - as it was. No CI job reads a released uk-data artifact. Until uk-data ships - #526, microcosm's code-11 adults and ESA proxies differ from the pinned - incumbent, and no committed instrument measures that difference. +- The instruments that see the column are blind to this change: + - The eFRS parity reference (uk-data 1.56.16) records `employment_status` as + an unweighted non-empty-string share (`_nonzero_share` in + `tools/build_uk_efrs_parity_reference.py`). + - The release input-coverage gate counts a row as signal when it differs + from the engine default, `UNEMPLOYED` (`_nondefault_signal_mask`). + - The input-mass parity skips string columns entirely + (`microcosm/build/input_mass.py`). The ESA proxies are not engine + variables, so the reference totals leave them out as well. +- PR CI never compares these categories or proxies with a released uk-data + H5. The engine-uk parity-reference tests load only the committed extraction. +- Until uk-data ships #526, microcosm's code-11 adults and ESA proxies differ + from the pinned incumbent, and no committed instrument measures that + difference. ## Mutation check @@ -97,6 +105,7 @@ killed: small counts printed (rc=1) killed: children get a non-CHILD status (rc=1) killed: code 0 accepted for adults (rc=1) killed: non-integer codes truncated (rc=1) +killed: support group ignores hours worked (rc=1) killed: OTHER_INACTIVE added to the ESA health statuses (rc=1) -10/10 mutations killed +11/11 mutations killed ``` diff --git a/experiments/uk-frs-empstati-other-inactive/differential_vs_uk_data_526.py b/experiments/uk-frs-empstati-other-inactive/differential_vs_uk_data_526.py index 4d6f5e9e5..7f27a1a7c 100644 --- a/experiments/uk-frs-empstati-other-inactive/differential_vs_uk_data_526.py +++ b/experiments/uk-frs-empstati-other-inactive/differential_vs_uk_data_526.py @@ -44,7 +44,7 @@ ) from microcosm.build.uk_runtime.frs_spine import normalize_ids, read_pinned_tab -UK_DATA_526_HEAD = "89c48e074f77f196b1a91bbb2a69f78bb29884fc" +UK_DATA_526_HEAD = "5f9912df1ddd28a8de0fcb41bce1c9159e08bc95" UK_DATA_NAMES = ("FRS_EMPSTATI_EMPLOYMENT_STATUS", "derive_employment_status_from_frs") @@ -140,7 +140,8 @@ def agree(sample): {side: sorted(types) for side, types in REFUSAL_TYPES.items()}, ) # Both sides refuse with ValueError at the pinned head, under pandas 2 or 3 - # (fb026659's message raised TypeError under pandas 3; 89c48e07 fixed it). + # (fb026659's message raised TypeError under pandas 3; 89c48e07 fixed it, + # and 5f9912df changed only tests). assert REFUSAL_TYPES["microcosm"] <= {"ValueError"} assert REFUSAL_TYPES["uk-data"] <= {"ValueError"} @@ -166,7 +167,8 @@ def agree(sample): person["person_id"].isin(adult["person_id"]).to_numpy(), ) mismatches = int((ours != theirs).sum()) - print(f"licensed FRS 2024-25 people: {len(person)}; mismatches: {mismatches}") + shown = mismatches if mismatches == 0 or mismatches >= 10 else "1-9 (suppressed)" + print(f"licensed FRS 2024-25 people: {len(person)}; mismatches: {shown}") print( "employment_status counts (unweighted, cells under 10 suppressed):", suppressed(pd.Series(ours).value_counts().sort_index()), diff --git a/experiments/uk-frs-empstati-other-inactive/mutation_check.py b/experiments/uk-frs-empstati-other-inactive/mutation_check.py index 9c2d27cd3..4c9a0483f 100644 --- a/experiments/uk-frs-empstati-other-inactive/mutation_check.py +++ b/experiments/uk-frs-empstati-other-inactive/mutation_check.py @@ -75,6 +75,11 @@ 'np.asarray(pd.to_numeric(empstati, errors="coerce"), dtype="float64")', 'np.floor(np.asarray(pd.to_numeric(empstati, errors="coerce"), dtype="float64"))', ), + "support group ignores hours worked": ( + PROXIES, + '(status == "LONG_TERM_DISABLED") & (hours <= 0)', + '(status == "LONG_TERM_DISABLED")', + ), "OTHER_INACTIVE added to the ESA health statuses": ( PROXIES, 'ESA_HEALTH_EMPLOYMENT_STATUSES = ("LONG_TERM_DISABLED", "SHORT_TERM_DISABLED")', diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_legacy_proxies.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_legacy_proxies.py index e2c628499..3a05e259b 100644 --- a/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_legacy_proxies.py +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_legacy_proxies.py @@ -106,8 +106,11 @@ def test_esa_proxies_follow_the_corrected_statuses(rows) -> None: assert ( health.tolist() == (reported & working_age & np.isin(codes, (9, 10))).tolist() ) + assert ( + support.tolist() + == (reported & working_age & (codes == 9) & (hours <= 0)).tolist() + ) assert not (support & ~health).any() - assert not support[codes != 9].any() assert not health[codes == 11].any() From 8c51a1c892f5bba9a9645b68f387808e217bb76d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 07:44:36 -0400 Subject: [PATCH 8/8] Name the unknown EMPSTATI codes but never a count in the refusal Adults are not survey households, so even a count of 10 or more adults could describe fewer than 10 households in a build log. The refusal now names the offending codes only (uk-data#526 made the same change at 1832adeb). A Hypothesis test checks that no digit appears outside the code list, and the mutation check's "adult count printed" mutation is killed (11/11). The differential is re-pinned to uk-data#526's 1832adeb (mapping and accept/refuse logic unchanged since d3984002; 0 mismatches over 34,966 licensed FRS 2024-25 people) and suppresses its status table by households, not people. Co-Authored-By: Claude Opus 5.5 --- .../uk-frs-empstati-other-inactive/README.md | 11 +++--- .../differential_vs_uk_data_526.py | 24 ++++++++----- .../mutation_check.py | 6 ++-- .../build/uk_runtime/frs_employment.py | 9 ++--- .../engine_free/uk/test_uk_frs_employment.py | 34 +++++++++++-------- 5 files changed, 48 insertions(+), 36 deletions(-) diff --git a/experiments/uk-frs-empstati-other-inactive/README.md b/experiments/uk-frs-empstati-other-inactive/README.md index 11ff93f45..bd29b38df 100644 --- a/experiments/uk-frs-empstati-other-inactive/README.md +++ b/experiments/uk-frs-empstati-other-inactive/README.md @@ -3,10 +3,11 @@ The UK `frs_employment` stage now maps EMPSTATI codes 1-11 one data-dictionary label each (UKDS SN 9563, FRS 2024-25 adult table), so code 11 ("Other Inactive") is `OTHER_INACTIVE` rather than `LONG_TERM_DISABLED`. This is the -same mapping as the incumbent's uk-data#526 (head `5f9912df`; its `frs.py` is byte-identical to `89c48e07`, and the mapping to the earlier heads `d3984002` and `fb026659`). Everything below +same mapping as the incumbent's uk-data#526 (head `1832adeb`; its mapping and accept/refuse logic are unchanged since `d3984002`, and only the refusal message has changed). Everything below was run on 2026-10-02 against the pinned `adult.tab` -(sha256 `4eaea080…658d`). Only aggregates are reported; no cell here is under -10 survey people. +(sha256 `4eaea080…658d`). Only aggregates are reported, and every cell here +covers at least 127 survey households, so none falls under the 10-household +suppression rule. ## The licensed input @@ -36,7 +37,7 @@ reports for its FRS 2024-25 build. ## Differential against uk-data#526 `differential_vs_uk_data_526.py` loads uk-data's code table and -`derive_employment_status_from_frs` from the PR head (`5f9912df`) by AST and +`derive_employment_status_from_frs` from the PR head (`1832adeb`) by AST and compares them with microcosm's: ``` @@ -101,7 +102,7 @@ killed: unknown adult codes default to LONG_TERM_DISABLED (rc=1) killed: every row treated as an adult (rc=1) killed: adult records by code presence, not adult.tab membership (rc=1) killed: 9 and 10 swapped (rc=1) -killed: small counts printed (rc=1) +killed: adult count printed (rc=1) killed: children get a non-CHILD status (rc=1) killed: code 0 accepted for adults (rc=1) killed: non-integer codes truncated (rc=1) diff --git a/experiments/uk-frs-empstati-other-inactive/differential_vs_uk_data_526.py b/experiments/uk-frs-empstati-other-inactive/differential_vs_uk_data_526.py index 7f27a1a7c..ba9ab6676 100644 --- a/experiments/uk-frs-empstati-other-inactive/differential_vs_uk_data_526.py +++ b/experiments/uk-frs-empstati-other-inactive/differential_vs_uk_data_526.py @@ -11,8 +11,9 @@ 3. on the licensed FRS 2024-25 person set (``adult.tab`` plus ``child.tab``, read through microcosm's sha-pinned reader), element by element. -Only aggregates are printed: per-status counts, with cells under 10 people -suppressed, and the mismatch count. Usage (UK engine environment):: +Only aggregates are printed: per-status counts of people, with cells covering +fewer than 10 survey households suppressed, and the mismatch count, with 1-9 +suppressed. Usage (UK engine environment):: uv run --no-sync python \ experiments/uk-frs-empstati-other-inactive/differential_vs_uk_data_526.py \ @@ -44,7 +45,7 @@ ) from microcosm.build.uk_runtime.frs_spine import normalize_ids, read_pinned_tab -UK_DATA_526_HEAD = "5f9912df1ddd28a8de0fcb41bce1c9159e08bc95" +UK_DATA_526_HEAD = "1832adeb2296b53fe5a7d7194e770118aa34d1f8" UK_DATA_NAMES = ("FRS_EMPSTATI_EMPLOYMENT_STATUS", "derive_employment_status_from_frs") @@ -92,10 +93,15 @@ def outcome(function, codes, is_adult, *, side): return ("refused", None) -def suppressed(counts: pd.Series) -> dict[str, object]: +def suppressed(statuses: np.ndarray, household_ids: np.ndarray) -> dict[str, object]: + """People per status, shown only for cells covering 10 or more households.""" + cells = pd.DataFrame({"status": statuses, "household": household_ids}) + grouped = cells.groupby("status") + people = grouped.size() + households = grouped["household"].nunique() return { - str(key): (int(value) if value >= 10 else "<10") - for key, value in counts.items() + str(status): (int(people[status]) if households[status] >= 10 else "suppressed") + for status in people.index } @@ -141,7 +147,7 @@ def agree(sample): ) # Both sides refuse with ValueError at the pinned head, under pandas 2 or 3 # (fb026659's message raised TypeError under pandas 3; 89c48e07 fixed it, - # and 5f9912df changed only tests). + # 5f9912df changed only tests, and 1832adeb dropped the count from it). assert REFUSAL_TYPES["microcosm"] <= {"ValueError"} assert REFUSAL_TYPES["uk-data"] <= {"ValueError"} @@ -170,8 +176,8 @@ def agree(sample): shown = mismatches if mismatches == 0 or mismatches >= 10 else "1-9 (suppressed)" print(f"licensed FRS 2024-25 people: {len(person)}; mismatches: {shown}") print( - "employment_status counts (unweighted, cells under 10 suppressed):", - suppressed(pd.Series(ours).value_counts().sort_index()), + "employment_status people (unweighted; cells under 10 households suppressed):", + suppressed(ours, (person["person_id"] // 1000).to_numpy()), ) assert mismatches == 0 diff --git a/experiments/uk-frs-empstati-other-inactive/mutation_check.py b/experiments/uk-frs-empstati-other-inactive/mutation_check.py index 4c9a0483f..0e65d54b0 100644 --- a/experiments/uk-frs-empstati-other-inactive/mutation_check.py +++ b/experiments/uk-frs-empstati-other-inactive/mutation_check.py @@ -55,10 +55,10 @@ '9: "SHORT_TERM_DISABLED", # Permanently sick/disabled\n' ' 10: "LONG_TERM_DISABLED",', ), - "small counts printed": ( + "adult count printed": ( EMPLOYMENT, - "if count >= 10 else", - "if count >= 0 else", + '"FRS adult.tab carries adults with EMPSTATI code(s) "', + 'f"FRS adult.tab carries {int(unknown.sum())} adults with EMPSTATI code(s) "', ), "children get a non-CHILD status": ( EMPLOYMENT, diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/frs_employment.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/frs_employment.py index 8ac7e490e..9dddd5153 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/frs_employment.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/frs_employment.py @@ -135,7 +135,8 @@ def derive_employment_status_from_frs(empstati, is_adult_record) -> np.ndarray: their code. Every adult must carry a code in ``FRS_EMPSTATI_EMPLOYMENT_STATUS``: a missing or unknown adult code refuses the build rather than falling back to a guessed status, as the old fallback - silently made code 11 (Other inactive) LONG_TERM_DISABLED. + silently made code 11 (Other inactive) LONG_TERM_DISABLED. The refusal + names the offending codes but never how many adults carry them. """ codes = pd.Series( @@ -153,10 +154,10 @@ def derive_employment_status_from_frs(empstati, is_adult_record) -> np.ndarray: labels = [_code_label(code) for code in sorted(bad.dropna().unique())] if bad.isna().any(): labels.append("blank or non-numeric") - count = int(unknown.sum()) - adults = str(count) if count >= 10 else "fewer than 10" + # Build logs are not licensed outputs, and adults are not survey + # households, so the refusal names the codes and never a count. raise ValueError( - f"FRS adult.tab carries {adults} adults with EMPSTATI code(s) " + "FRS adult.tab carries adults with EMPSTATI code(s) " f"{labels} outside FRS_EMPSTATI_EMPLOYMENT_STATUS; map them from the " "release's data dictionary." ) diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_employment.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_employment.py index 60e3ea63b..3b23d9f6d 100644 --- a/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_employment.py +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_employment.py @@ -159,16 +159,23 @@ def test_adult_record_with_unknown_or_blank_code_fails_the_stage(code) -> None: derive_frs_employment(person, adult) -def test_unknown_code_message_names_codes_and_suppresses_small_counts() -> None: - with pytest.raises(ValueError) as small: +def _digits_outside_code_list(message: str) -> list[str]: + return re.findall(r"\d", re.sub(r"\[.*?\]", "", message)) + + +def test_unknown_code_message_names_codes_and_prints_no_count() -> None: + # Adults are not survey households, so even a count of 10 or more could + # describe fewer than 10 households: the refusal never prints one. + with pytest.raises(ValueError) as few: derive_employment_status_from_frs([12, 12, np.nan, 11.5], [True] * 4) - message = str(small.value) - assert "fewer than 10 adults" in message + message = str(few.value) assert "['11.5', '12', 'blank or non-numeric']" in message + assert _digits_outside_code_list(message) == [] - with pytest.raises(ValueError) as large: - derive_employment_status_from_frs([13] * 10, [True] * 10) - assert "carries 10 adults" in str(large.value) + with pytest.raises(ValueError) as many: + derive_employment_status_from_frs([13] * 25, [True] * 25) + assert "['13']" in str(many.value) + assert _digits_outside_code_list(str(many.value)) == [] def test_mismatched_lengths_fail() -> None: @@ -199,15 +206,12 @@ def test_any_unknown_adult_code_fails_the_build(rows, bad_code, position) -> Non _derive(rows) -@given(st.lists(unknown_adult_codes, min_size=1, max_size=30)) -def test_unknown_code_message_never_prints_a_count_under_10(bad_codes) -> None: +@given(st.lists(unknown_adult_codes, min_size=1, max_size=30), people) +def test_unknown_code_message_never_prints_a_count(bad_codes, rows) -> None: + rows = [*rows, *((True, code) for code in bad_codes)] with pytest.raises(ValueError) as error: - derive_employment_status_from_frs(bad_codes, [True] * len(bad_codes)) - printed = re.search(r"carries (.+?) adults", str(error.value)).group(1) - if len(bad_codes) < 10: - assert printed == "fewer than 10" - else: - assert printed == str(len(bad_codes)) + _derive(rows) + assert _digits_outside_code_list(str(error.value)) == [] @given(people)