diff --git a/changelog.d/claims-all-per-benunit.fixed.md b/changelog.d/claims-all-per-benunit.fixed.md new file mode 100644 index 0000000000..9ae40e94d8 --- /dev/null +++ b/changelog.d/claims-all-per-benunit.fixed.md @@ -0,0 +1 @@ +- Evaluated `claims_all_entitled_benefits` per benefit unit in hand-entered situations, so one family's reported benefits no longer switch off another family's take-up. Data-built simulations keep main's result, false for every family, so survey take-up of tax credits and Income Support still follows reported claims; Council Tax Reduction and Housing Benefit take-up there is limited to reporters (d1390 and d1455) until calibrated take-up inputs land (policyengine-uk-data#448). Explicit take-up inputs continue to override these defaults. diff --git a/docs/book/programs/gov/dwp/housing-benefit.ipynb b/docs/book/programs/gov/dwp/housing-benefit.ipynb index a3e9c25f03..90c8415910 100644 --- a/docs/book/programs/gov/dwp/housing-benefit.ipynb +++ b/docs/book/programs/gov/dwp/housing-benefit.ipynb @@ -28,7 +28,7 @@ "\n", "**Guarantee Credit passport.** If the claimant or partner is in receipt of a guarantee credit (`in_receipt_of_guarantee_credit`: the benunit is eligible for Pension Credit, claims it, and its guarantee credit is positive, so the Pension Credit paid includes a guarantee credit), the whole of their capital and income is disregarded ([SI 2006/214 reg. 26](https://www.legislation.gov.uk/uksi/2006/214/regulation/26); Northern Ireland: [SR 2006/406 reg. 24](https://www.legislation.gov.uk/nisr/2006/406/regulation/24)). `housing_benefit_assessable_capital`, `housing_benefit_tariff_income` and `housing_benefit_applicable_income` are then zero, so the capital limit cannot bite and no income is tapered. A guarantee credit computed for a family that does not claim Pension Credit (`would_claim_pc`), or that PolicyEngine does not treat as eligible for it (`is_pension_credit_eligible`), does not count. The passport applies within the pension-age regulations (`housing_benefit_pension_age_regulations_apply`). `is_pension_credit_eligible` retains mixed-age couples covered by the [SI 2019/37 art. 4](https://www.legislation.gov.uk/uksi/2019/37/article/4) saving through `meets_pension_credit_age_conditions`; these couples keep the passport when they receive Guarantee Credit. When Pension Credit is frozen, receipt follows the baseline award, including its Guarantee Credit component. Savings Credit alone follows the separate assessment in [reg. 27](https://www.legislation.gov.uk/uksi/2006/214/regulation/27), including its capital limit, and does not give this passport.\n", "\n", - "Take-up is handled by `would_claim_housing_benefit`, which is true for benunits that report Housing Benefit and, when no reported benefits are supplied (as in household calculations), for everyone. In the Enhanced FRS take-up therefore follows reported claims." + "Take-up is handled per benefit unit by `would_claim_housing_benefit`. In data-built simulations, including the Enhanced FRS, its default is true only for families reporting positive Housing Benefit (d1455, following the CTR survey ruling d1390). In hand-entered household calculations, it is true when the family reports Housing Benefit or its `claims_all_entitled_benefits` flag is true. That flag tests seven reported benefits within the family; it does not test Pension Credit, Child Benefit or Council Tax Reduction. Dataset and situation inputs can override the take-up flag directly." ] }, { diff --git a/docs/engineering/skills/dataset-sources.md b/docs/engineering/skills/dataset-sources.md index 6b54e39151..17dcc889c3 100644 --- a/docs/engineering/skills/dataset-sources.md +++ b/docs/engineering/skills/dataset-sources.md @@ -60,3 +60,20 @@ without downloading or opening private survey files. Use real H5 files only when the test specifically needs schema or data behavior. If the artifact is private, large, or credential-dependent, skip cleanly or keep the verification outside the ordinary PR test suite. + +## Data provenance and benefit take-up + +`policyengine_uk.utils.data_source.built_from_data` distinguishes dataset-built +simulations from hand-entered situations, including supported clones, branches +and UK `build_from_situation` rebuilds. Council Tax Reduction and Housing Benefit +use this distinction for their take-up defaults: data-built simulations use +positive reported claims only (d1390 and d1455); situations also use their +per-benefit-unit `claims_all_entitled_benefits` flag. That flag is itself false +for every family on data-built simulations, as it was on main for any dataset +with a reporting family, so Child Tax Credit, Working Tax Credit and Income +Support take-up also follow reported claims there. Explicit inputs override +these defaults, including a future calibrated dataset column (uk-data#448). + +Do not rebuild an existing data-built simulation by calling core's +`SimulationBuilder.build_from_dict` directly. In core 3.33.2 this leaves stale +dataset provenance, which copies inherit. Use UK `build_from_situation` instead. diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/takeup.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/takeup.yaml index 26c847e978..74d8d770d4 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/takeup.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/takeup.yaml @@ -1,7 +1,9 @@ description: Share of eligible Housing Benefit recipients that participate. Not used by - the model, which takes Housing Benefit take-up from reported claims in survey data and - assumes full take-up in household calculations. Pension-age families can make new - claims; working-age families only continue existing awards. + the model. Data-built simulations default to positive reported Housing Benefit claims + only (d1455 following d1390). Hand-entered households use positive reported Housing + Benefit or their per-benefit-unit claims_all_entitled_benefits flag. Direct take-up + inputs override either default. Pension-age families can make new claims; + working-age families only continue existing awards. metadata: economy: false label: Housing benefit take-up rate diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/income_support_take_up_and_person_awards.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/income_support_take_up_and_person_awards.yaml index 561dd35b63..9a9b4e688b 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/income_support_take_up_and_person_awards.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/income_support_take_up_and_person_awards.yaml @@ -1,7 +1,7 @@ # would_claim_IS is the family's take-up flag: the claimant or partner # reports Income Support. An Income Support report from a member who is neither # the claimant, the partner nor their dependant ("other") is that member's own -# claim. Any report in the simulation turns claims_all_entitled_benefits off. +# claim. A report turns claims_all_entitled_benefits off for that benefit unit. - name: Another member's Income Support report is not this family's claim period: 2025 @@ -49,7 +49,8 @@ # unit on the Income Support claim path when anyone in the household is on it, # and otherwise shares it by claimants and partners. With no claimant or # partner reporting Income Support, £10,000 of savings is shared 1:1 between -# the two benefit units. +# the two benefit units. The non-reporting lodger explicitly opts out of +# claiming all entitled benefits. - name: Another member's Income Support report does not move household capital to their benefit unit period: 2025 @@ -72,6 +73,7 @@ members: [claimant, other] lodger_unit: members: [lodger] + claims_all_entitled_benefits: false households: household: members: [claimant, other, lodger] diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/ctr_takeup.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/ctr_takeup.yaml new file mode 100644 index 0000000000..be3dbcbc9c --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/ctr_takeup.yaml @@ -0,0 +1,156 @@ +# Hand-entered households retain the default and explicit take-up rules. +# Survey reporters-only behavior is covered with MOCK data in Python tests. +- name: WALES entitled non-reporting family entered by hand claims CTR by default + period: 2026 + input: + age: 35 + country: WALES + local_authority: CARDIFF + council_tax: 1800 + savings: 0 + would_claim_uc: false + claims_all_entitled_benefits: true + output: + council_tax_benefit_reported: 0 + would_claim_council_tax_reduction: true + simulated_council_tax_reduction_benunit: 1800 + council_tax_benefit: 1800 + council_tax_reduction: 1800 + +- name: WALES entitled non-reporting family under explicit CTR claim + period: 2026 + input: + age: 35 + country: WALES + local_authority: CARDIFF + council_tax: 1800 + savings: 0 + would_claim_uc: false + claims_all_entitled_benefits: true + would_claim_council_tax_reduction: true + output: + council_tax_benefit_reported: 0 + would_claim_council_tax_reduction: true + simulated_council_tax_reduction_benunit: 1800 + council_tax_benefit: 1800 + council_tax_reduction: 1800 + +- name: SCOTLAND entitled non-reporting family entered by hand claims CTR by default + period: 2026 + input: + age: 35 + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1800 + savings: 0 + would_claim_uc: false + claims_all_entitled_benefits: true + output: + council_tax_benefit_reported: 0 + would_claim_council_tax_reduction: true + simulated_council_tax_reduction_benunit: 1800 + council_tax_benefit: 1800 + council_tax_reduction: 1800 + +- name: SCOTLAND entitled non-reporting family under explicit CTR claim + period: 2026 + input: + age: 35 + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1800 + savings: 0 + would_claim_uc: false + claims_all_entitled_benefits: true + would_claim_council_tax_reduction: true + output: + council_tax_benefit_reported: 0 + would_claim_council_tax_reduction: true + simulated_council_tax_reduction_benunit: 1800 + council_tax_benefit: 1800 + council_tax_reduction: 1800 + +- name: ENGLAND entitled non-reporting family entered by hand claims CTR by default + period: 2026 + input: + age: 70 + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1800 + savings: 0 + would_claim_uc: false + claims_all_entitled_benefits: true + output: + council_tax_benefit_reported: 0 + would_claim_council_tax_reduction: true + simulated_council_tax_reduction_benunit: 1800 + council_tax_benefit: 1800 + council_tax_reduction: 1800 + +- name: ENGLAND entitled non-reporting family under explicit CTR claim + period: 2026 + input: + age: 70 + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1800 + savings: 0 + would_claim_uc: false + claims_all_entitled_benefits: true + would_claim_council_tax_reduction: true + output: + council_tax_benefit_reported: 0 + would_claim_council_tax_reduction: true + simulated_council_tax_reduction_benunit: 1800 + council_tax_benefit: 1800 + council_tax_reduction: 1800 + +# Vahid's calculator probes from PR 1831 review 5514292316. These households +# supply no benefit reports, claimant roles, or take-up inputs. +- name: North West pensioner calculator household retains full CTR without reports + period: 2026 + absolute_error_margin: 0.01 + input: + age: 70 + region: NORTH_WEST + rent: 8_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + employment_income: 0 + output: + council_tax_benefit_reported: 0 + claims_all_entitled_benefits: true + would_claim_council_tax_reduction: true + council_tax_reduction: 1_800 + +- name: Welsh pensioner calculator household retains full CTR without reports + period: 2026 + absolute_error_margin: 0.01 + input: + age: 70 + region: WALES + rent: 8_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + employment_income: 0 + output: + council_tax_benefit_reported: 0 + claims_all_entitled_benefits: true + would_claim_council_tax_reduction: true + council_tax_reduction: 1_800 + +- name: Scottish working-age calculator household retains CTR without reports + period: 2026 + absolute_error_margin: 0.01 + input: + age: 30 + region: SCOTLAND + rent: 8_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + employment_income: 0 + output: + council_tax_benefit_reported: 0 + claims_all_entitled_benefits: true + would_claim_council_tax_reduction: true + council_tax_reduction: 633.96 diff --git a/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py b/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py index 682e1fa939..298b11d1ee 100644 --- a/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py @@ -220,8 +220,6 @@ def situation(units): benunit = { "members": names, "would_claim_uc": {PERIOD: unit["would_claim_uc"]}, - # claims_all_entitled_benefits sums reported benefits across the - # whole simulation, so it is set per family. "claims_all_entitled_benefits": { PERIOD: unit["hb_reported"] == 0 and unit["ctb_reported"] == 0 }, diff --git a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py index 9648177f44..33ab054b7b 100644 --- a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -403,6 +403,9 @@ def capped_family(scheme): **CAPPED_FAMILY, "universal_credit_pre_benefit_cap": 30_000 if scheme == "uc" else 0, "housing_benefit_pre_benefit_cap": 30_000 if scheme == "hb" else 0, + # This supplied award exercises the cap independently of take-up; + # another member's reports must not mask its final Housing Benefit. + "would_claim_housing_benefit": scheme == "hb", } diff --git a/policyengine_uk/tests/test_claims_all_entitled_benefits.py b/policyengine_uk/tests/test_claims_all_entitled_benefits.py new file mode 100644 index 0000000000..5f85663716 --- /dev/null +++ b/policyengine_uk/tests/test_claims_all_entitled_benefits.py @@ -0,0 +1,556 @@ +"""Regression tests for `claims_all_entitled_benefits`. + +The flag is a benefit-unit attribute: a family that reports under £1 in total +across the seven means-tested benefits the formula checks is assumed to claim +everything it is entitled to, and a family that reports £1 or more is assumed +to have reported completely. The formula used to sum the seven reported columns over the whole +simulation (`add(...).sum() < 1`), so in microdata it was False for every +benefit unit as soon as any benefit unit reported any of them, and the +consumers that gate on it collapsed to reporters only. Hand-entered situations +now test each family on its own. Data-built simulations keep main's survey +result, False for every family, so Child Tax Credit, Working Tax Credit and +Income Support take-up follow reported claims there; Council Tax Reduction and +Housing Benefit are reporters-only on data-built simulations in any case +(d1390 and d1455). +""" + +import numpy as np +import pandas as pd +import pytest +from hypothesis import HealthCheck, example, given, settings, strategies as st + +from policyengine_uk import Microsimulation, Simulation +from policyengine_uk.data import UKMultiYearDataset, UKSingleYearDataset + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2026 + +SEVEN_REPORTED = [ + "child_tax_credit_reported", + "working_tax_credit_reported", + "universal_credit_reported", + "housing_benefit_reported", + "jsa_income_reported", + "income_support_reported", + "esa_income_reported", +] + + +def person(reported_uc=0, reported_ctr=0): + return { + "age": {YEAR: 40}, + "universal_credit_reported": {YEAR: reported_uc}, + "council_tax_benefit_reported": {YEAR: reported_ctr}, + } + + +def single_household(reported_uc): + """One person, one benefit unit, one household — the calculator path.""" + return { + "people": {"a": person(reported_uc)}, + "benunits": {"bu_a": {"members": ["a"]}}, + "households": {"hh_a": {"members": ["a"]}}, + } + + +def separate_households(reported_uc_a, reported_uc_b): + """Two benefit units in two households; `a` is index 0, `b` index 1.""" + return { + "people": {"a": person(reported_uc_a), "b": person(reported_uc_b)}, + "benunits": {"bu_a": {"members": ["a"]}, "bu_b": {"members": ["b"]}}, + "households": {"hh_a": {"members": ["a"]}, "hh_b": {"members": ["b"]}}, + } + + +def shared_household(reported_uc_a, reported_uc_b): + """Two benefit units sharing one household (e.g. a parent and an adult child).""" + return { + "people": {"a": person(reported_uc_a), "b": person(reported_uc_b)}, + "benunits": {"bu_a": {"members": ["a"]}, "bu_b": {"members": ["b"]}}, + "households": {"hh": {"members": ["a", "b"]}}, + } + + +class TestPerBenefitUnit: + def test_one_reporter_does_not_switch_off_the_other_benefit_unit(self): + sim = Simulation(situation=separate_households(5_000, 0)) + flag = sim.calculate("claims_all_entitled_benefits", YEAR) + assert flag.tolist() == [False, True] + + def test_per_benefit_unit_within_one_household(self): + sim = Simulation(situation=shared_household(5_000, 0)) + flag = sim.calculate("claims_all_entitled_benefits", YEAR) + assert flag.tolist() == [False, True] + + def test_situation_ctr_default_follows_each_familys_flag(self): + sim = Simulation(situation=separate_households(5_000, 0)) + would_claim = sim.calculate("would_claim_council_tax_reduction", YEAR) + assert would_claim.tolist() == [False, True] + + def test_reported_ctr_still_claims_when_the_flag_is_false(self): + situation = separate_households(5_000, 0) + situation["people"]["a"] = person(reported_uc=5_000, reported_ctr=800) + sim = Simulation(situation=situation) + assert sim.calculate("claims_all_entitled_benefits", YEAR).tolist() == [ + False, + True, + ] + assert sim.calculate("would_claim_council_tax_reduction", YEAR).tolist() == [ + True, + True, + ] + + def test_reporters_of_any_of_the_seven_are_false(self): + for variable in SEVEN_REPORTED: + situation = separate_households(0, 0) + situation["people"]["a"][variable] = {YEAR: 100} + sim = Simulation(situation=situation) + flag = sim.calculate("claims_all_entitled_benefits", YEAR) + assert flag.tolist() == [False, True], variable + + def test_single_benefit_unit_semantics_unchanged(self): + none_reported = Simulation(situation=single_household(0)) + assert none_reported.calculate( + "claims_all_entitled_benefits", YEAR + ).tolist() == [True] + reporter = Simulation(situation=single_household(5_000)) + assert reporter.calculate("claims_all_entitled_benefits", YEAR).tolist() == [ + False + ] + + def test_homogeneous_benefit_units_agree(self): + none_reported = Simulation(situation=separate_households(0, 0)) + assert none_reported.calculate("claims_all_entitled_benefits", YEAR).all() + both_report = Simulation(situation=separate_households(5_000, 5_000)) + assert not both_report.calculate("claims_all_entitled_benefits", YEAR).any() + + def test_under_one_pound_of_reported_benefits_counts_as_none(self): + sim = Simulation(situation=separate_households(0.5, 0)) + assert sim.calculate("claims_all_entitled_benefits", YEAR).tolist() == [ + True, + True, + ] + + +@pytest.mark.microsimulation +def test_survey_takeup_follows_reports_in_microdata(): + """On the survey the flag keeps main's value, false for every family, so + the legacy take-up flags that read it are reporters only (review r6).""" + sim = Microsimulation() + for year in (2024, YEAR): + flag = sim.calculate("claims_all_entitled_benefits", year).values + assert not flag.astype(bool).any(), year + for takeup, reported in ( + ("would_claim_CTC", "child_tax_credit_reported"), + ("would_claim_WTC", "working_tax_credit_reported"), + ): + reports = sim.calculate(reported, year, map_to="benunit").values > 0 + claims = sim.calculate(takeup, year).values.astype(bool) + assert np.array_equal(claims, reports), (takeup, year) + is_reports = ( + sim.calculate("income_support_reported", year).values + * sim.calculate("is_claimant_or_partner", year).values + ) + reported_is = sim.map_result(is_reports, "person", "benunit") > 0 + claims_is = sim.calculate("would_claim_IS", year).values.astype(bool) + assert np.array_equal(claims_is, reported_is), year + + +def mock_dataset(situation, year=YEAR): + """Synthetic post-ingestion MOCK tables using the normal UK data schema.""" + tables = {} + person_ids = {name: i + 1 for i, name in enumerate(situation["people"])} + memberships = {} + for plural, entity in ( + ("people", "person"), + ("benunits", "benunit"), + ("households", "household"), + ): + rows = [] + for index, (_, inputs) in enumerate(situation[plural].items(), start=1): + row = {f"{entity}_id": index} + for name, value in inputs.items(): + if name == "members": + for member in value: + memberships.setdefault(person_ids[member], {})[ + f"person_{entity}_id" + ] = index + else: + row[name] = value[year] if isinstance(value, dict) else value + rows.append(row) + tables[entity] = pd.DataFrame(rows).fillna(0) + for column in ("person_benunit_id", "person_household_id"): + tables["person"][column] = [ + memberships[person_id][column] for person_id in person_ids.values() + ] + return UKMultiYearDataset( + datasets=[UKSingleYearDataset(**tables, fiscal_year=year)] + ) + + +def pensioner_households(): + situation = separate_households(0, 0) + for person_inputs in situation["people"].values(): + person_inputs["age"] = {YEAR: 70} + for home in situation["households"].values(): + home.update( + region={YEAR: "NORTH_WEST"}, + tenure_type={YEAR: "RENT_PRIVATELY"}, + rent={YEAR: 8_000}, + council_tax={YEAR: 1_800}, + ) + return situation + + +@pytest.mark.parametrize("simulation_class", [Simulation, Microsimulation]) +@pytest.mark.parametrize( + "takeup, reported, benefit, expected_award", + [ + ( + "would_claim_council_tax_reduction", + "council_tax_benefit_reported", + "council_tax_reduction", + 1_800, + ), + ( + "would_claim_housing_benefit", + "housing_benefit_reported", + "housing_benefit", + 8_000, + ), + ], +) +def test_mock_data_takeup_is_reporters_only( + simulation_class, takeup, reported, benefit, expected_award +): + situation = pensioner_households() + situation["people"]["b"][reported] = {YEAR: 100} + simulation = simulation_class(dataset=mock_dataset(situation)) + assert simulation.calculate(takeup, YEAR).tolist() == [False, True] + np.testing.assert_allclose(simulation.calculate(benefit, YEAR), [0, expected_award]) + # The non-reporter is entitled: an explicit input enables its award. + for family in situation["benunits"].values(): + family[takeup] = {YEAR: True} + explicit = simulation_class(dataset=mock_dataset(situation)) + np.testing.assert_allclose( + explicit.calculate(benefit, YEAR), [expected_award, expected_award] + ) + + +def test_hand_entered_housing_benefit_keeps_main_takeup(): + simulation = Simulation(situation=pensioner_households()) + assert simulation.calculate("would_claim_housing_benefit", YEAR).tolist() == [ + True, + True, + ] + np.testing.assert_allclose( + simulation.calculate("housing_benefit", YEAR), [8_000, 8_000] + ) + + +@pytest.mark.parametrize( + "reported", + [ + "pension_credit_reported", + "child_benefit_reported", + "council_tax_benefit_reported", + ], +) +def test_reports_outside_the_seven_leave_the_all_benefits_flag_true(reported): + situation = separate_households(0, 0) + situation["people"]["a"][reported] = {YEAR: 100} + simulation = Simulation(situation=situation) + assert simulation.calculate("claims_all_entitled_benefits", YEAR).tolist() == [ + True, + True, + ] + + +# Each generated family is independently eligible or ineligible and may have +# reported CTR. No explicit take-up column is supplied; other families cannot enable it. +CTR_FAMILIES = st.lists( + st.tuples( + st.integers(min_value=18, max_value=90), + st.sampled_from(("ENGLAND", "SCOTLAND", "WALES")), + st.integers(min_value=0, max_value=50_000), + st.integers(min_value=0, max_value=50_000), + st.booleans(), + st.sampled_from((0, 100)), + ), + min_size=1, + max_size=4, +) + +# The draws are cheap, but on a saturated host one stalled for 4.9 s (and +# 11.8 s in the tax credit property below) between simulations, against the +# speed check's 30 s for its first ten inputs. Suppress only that timing +# check, keeping every example and every other check. +TIMING_TOLERANT = dict(deadline=None, suppress_health_check=[HealthCheck.too_slow]) + + +@given(families=CTR_FAMILIES) +@example(families=[(40, "SCOTLAND", 0, 0, True, 0)]) +@example( + families=[ + (40, "WALES", 0, 0, True, 0), + (40, "WALES", 0, 0, True, 100), + (70, "ENGLAND", 0, 0, False, 100), + ] +) +@settings(max_examples=30, **TIMING_TOLERANT) +def test_ctr_is_zero_for_every_nonreporter_without_an_explicit_claim_flag(families): + situation = {"people": {}, "benunits": {}, "households": {}} + must_be_zero = [] + councils = { + "ENGLAND": "MAIDSTONE", + "SCOTLAND": "CITY_OF_EDINBURGH", + "WALES": "CARDIFF", + } + for index, (age, country, income, savings, claims_all, reported) in enumerate( + families + ): + name = f"person_{index}" + situation["people"][name] = { + "age": {YEAR: age}, + "employment_income": {YEAR: income}, + "council_tax_benefit_reported": {YEAR: reported}, + } + benunit = { + "members": [name], + "claims_all_entitled_benefits": {YEAR: claims_all}, + "would_claim_uc": {YEAR: False}, + } + situation["benunits"][f"bu_{index}"] = benunit + situation["households"][f"hh_{index}"] = { + "members": [name], + "country": {YEAR: country}, + "local_authority": {YEAR: councils[country]}, + "council_tax": {YEAR: 1_800}, + "savings": {YEAR: savings}, + } + must_be_zero.append(reported == 0) + sim = Simulation(dataset=mock_dataset(situation)) + mask = np.array(must_be_zero, dtype=bool) + assert not sim.calculate("would_claim_council_tax_reduction", YEAR)[mask].any() + for variable in ( + "simulated_council_tax_reduction_benunit", + "council_tax_benefit", + "council_tax_reduction", + ): + assert (sim.calculate(variable, YEAR)[mask] == 0).all(), variable + + +# Tax credits pay awards until 5 April 2025, so 2024 is a year in which the +# legacy take-up flags still reach an award. +TAX_CREDIT_YEAR = 2024 + + +def tax_credit_families(): + """Review r6's MOCK probe: a non-reporting lone parent and a non-reporting + working pensioner, beside a family that reports Child Tax Credit and CTR.""" + year = TAX_CREDIT_YEAR + renting = { + "region": {year: "NORTH_WEST"}, + "tenure_type": {year: "RENT_PRIVATELY"}, + "rent": {year: 8_000}, + "council_tax": {year: 1_800}, + } + return { + "people": { + "parent": { + "age": {year: 30}, + "employment_income": {year: 12_000}, + "weekly_hours": {year: 20}, + }, + "child": {"age": {year: 5}}, + "pensioner": { + "age": {year: 68}, + "employment_income": {year: 9_000}, + "weekly_hours": {year: 20}, + }, + "reporter": { + "age": {year: 35}, + "employment_income": {year: 12_000}, + "weekly_hours": {year: 20}, + "child_tax_credit_reported": {year: 3_000}, + "council_tax_benefit_reported": {year: 500}, + }, + "reporter_child": {"age": {year: 6}}, + }, + "benunits": { + "bu_parent": { + "members": ["parent", "child"], + "would_claim_uc": {year: False}, + }, + "bu_pensioner": { + "members": ["pensioner"], + "would_claim_uc": {year: False}, + }, + "bu_reporter": { + "members": ["reporter", "reporter_child"], + "would_claim_uc": {year: False}, + }, + }, + "households": { + "hh_parent": {"members": ["parent", "child"], **renting}, + "hh_pensioner": { + "members": ["pensioner"], + "region": {year: "NORTH_WEST"}, + "tenure_type": {year: "OWNED_OUTRIGHT"}, + "council_tax": {year: 1_800}, + }, + "hh_reporter": {"members": ["reporter", "reporter_child"], **renting}, + }, + } + + +def test_data_built_nonreporters_get_no_tax_credits(): + """Survey runs keep main's take-up for tax credits (review r6). Tested per + family on data, the lone parent's flag turned true, so would_claim_CTC + did too, and £3,455 of tax credits reached its CTR and HB means tests, + its tax credit award and the 2024 Winter Fuel Payment benefit test.""" + year = TAX_CREDIT_YEAR + situation = tax_credit_families() + data = Simulation(dataset=mock_dataset(situation, year)) + assert data.calculate("claims_all_entitled_benefits", year).tolist() == [ + False, + False, + False, + ] + assert data.calculate("would_claim_CTC", year).tolist() == [False, False, True] + for takeup in ("would_claim_WTC", "would_claim_IS"): + assert not data.calculate(takeup, year).any(), takeup + tax_credits = data.calculate("tax_credits", year) + assert tax_credits[:2].tolist() == [0, 0] + assert tax_credits[2] > 0 + # People: parent, child, pensioner, reporter, reporter_child. + assert data.calculate("tax_credit_award", year)[0] == 0 + assert not data.calculate("is_on_winter_fuel_payment_relevant_benefit", year)[0] + # The same families entered by hand keep the per-family flag. + hand = Simulation(situation=situation) + assert hand.calculate("claims_all_entitled_benefits", year).tolist() == [ + True, + True, + False, + ] + + +# Each take-up flag that reads claims_all_entitled_benefits, and the report +# that sets it on data-built simulations. +TAKEUP_REPORTS = { + "would_claim_CTC": "child_tax_credit_reported", + "would_claim_WTC": "working_tax_credit_reported", + "would_claim_IS": "income_support_reported", + "would_claim_housing_benefit": "housing_benefit_reported", + "would_claim_council_tax_reduction": "council_tax_benefit_reported", +} + +# Outputs downstream of the take-up flags: tax credits, their award and the +# means tests and benefit tests that read them, and household net income. +TAKEUP_OUTPUTS = ( + "tax_credits", + "child_tax_credit", + "working_tax_credit", + "income_support", + "housing_benefit", + "council_tax_reduction", + "council_tax_reduction_applicable_income", + "housing_benefit_applicable_income", + "tax_credit_award", + "is_on_winter_fuel_payment_relevant_benefit", + "household_net_income", +) + +LEGACY_FAMILIES = st.lists( + st.fixed_dictionaries( + { + "age": st.integers(min_value=18, max_value=80), + "children": st.integers(min_value=0, max_value=2), + "earnings": st.integers(min_value=0, max_value=40_000), + "hours": st.sampled_from((0, 10, 20, 35)), + "renter": st.booleans(), + "reports": st.frozensets( + st.sampled_from(SEVEN_REPORTED + ["council_tax_benefit_reported"]) + ), + } + ), + min_size=1, + max_size=3, +) + + +def legacy_families(families, year, takeup=None): + """One benefit unit and household per family; the adult reports £100 of + each benefit in `reports`. `takeup` adds explicit take-up columns.""" + situation = {"people": {}, "benunits": {}, "households": {}} + for index, family in enumerate(families): + adult = f"adult_{index}" + members = [adult] + situation["people"][adult] = { + "age": {year: family["age"]}, + "employment_income": {year: family["earnings"]}, + "weekly_hours": {year: family["hours"]}, + **{report: {year: 100} for report in sorted(family["reports"])}, + } + for child in range(family["children"]): + name = f"child_{index}_{child}" + situation["people"][name] = {"age": {year: 4 + child}} + members.append(name) + benunit = {"members": members} + for variable, values in (takeup or {}).items(): + benunit[variable] = {year: bool(values[index])} + situation["benunits"][f"bu_{index}"] = benunit + situation["households"][f"hh_{index}"] = { + "members": members, + "region": {year: "NORTH_WEST"}, + "tenure_type": { + year: "RENT_PRIVATELY" if family["renter"] else "OWNED_OUTRIGHT" + }, + "rent": {year: 8_000 if family["renter"] else 0}, + "council_tax": {year: 1_800}, + } + return situation + + +NO_REPORTS = dict(age=30, children=1, earnings=12_000, hours=20, renter=True) + + +@given(families=LEGACY_FAMILIES, year=st.sampled_from((TAX_CREDIT_YEAR, YEAR))) +# No family reports anything: main's simulation-wide sum set every flag true +# here, so this example differs from main by design (reports-only on data). +@example(families=[{**NO_REPORTS, "reports": frozenset()}], year=TAX_CREDIT_YEAR) +@example( + families=[ + {**NO_REPORTS, "reports": frozenset()}, + { + **NO_REPORTS, + "reports": frozenset( + {"child_tax_credit_reported", "council_tax_benefit_reported"} + ), + }, + ], + year=TAX_CREDIT_YEAR, +) +@settings(max_examples=10, **TIMING_TOLERANT) +def test_data_built_takeup_equals_reported_only(families, year): + """On data, every take-up flag that reads claims_all_entitled_benefits is + the family's own report, and every downstream output equals the same data + with those reported-only take-up flags given as explicit inputs.""" + reported = { + takeup: [report in family["reports"] for family in families] + for takeup, report in TAKEUP_REPORTS.items() + } + default = Simulation(dataset=mock_dataset(legacy_families(families, year), year)) + explicit = Simulation( + dataset=mock_dataset(legacy_families(families, year, reported), year) + ) + assert not default.calculate("claims_all_entitled_benefits", year).any() + for takeup, expected in reported.items(): + assert default.calculate(takeup, year).tolist() == expected, takeup + for variable in TAKEUP_OUTPUTS: + np.testing.assert_array_equal( + default.calculate(variable, year), + explicit.calculate(variable, year), + err_msg=variable, + ) diff --git a/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py b/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py index 811373c441..8de7c8e4cd 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py @@ -206,8 +206,6 @@ def situation( claim_pc = unit["would_claim_pc"] if would_claim_pc is None else would_claim_pc benunit = { "members": names, - # claims_all_entitled_benefits sums reported benefits across the - # whole simulation, so set it for every family. "claims_all_entitled_benefits": {YEAR: True}, "would_claim_pc": {YEAR: claim_pc}, "would_claim_uc": {YEAR: unit.get("would_claim_uc", False)}, diff --git a/policyengine_uk/tests/test_data_source_provenance.py b/policyengine_uk/tests/test_data_source_provenance.py new file mode 100644 index 0000000000..d4d2d701c0 --- /dev/null +++ b/policyengine_uk/tests/test_data_source_provenance.py @@ -0,0 +1,115 @@ +"""Pin data provenance through supported builders using only synthetic data.""" + +import pandas as pd +import pytest + +from policyengine_core.simulations import Simulation as CoreSimulation +from policyengine_uk import Microsimulation, Simulation +from policyengine_uk.data import UKMultiYearDataset, UKSingleYearDataset +from policyengine_uk.system import system +from policyengine_uk.utils.data_source import built_from_data + + +YEAR = 2026 +SITUATION = { + "people": {"adult": {"age": {YEAR: 70}}}, + "benunits": {"family": {"members": ["adult"]}}, + "households": { + "home": { + "members": ["adult"], + "region": {YEAR: "NORTH_WEST"}, + "tenure_type": {YEAR: "RENT_PRIVATELY"}, + "rent": {YEAR: 8000}, + "council_tax": {YEAR: 1800}, + } + }, +} + + +@pytest.fixture(scope="module") +def mock_dataset(): + return UKMultiYearDataset( + datasets=[ + UKSingleYearDataset( + person=pd.DataFrame( + { + "person_id": [1], + "person_benunit_id": [1], + "person_household_id": [1], + "age": [70], + } + ), + benunit=pd.DataFrame({"benunit_id": [1]}), + household=pd.DataFrame( + { + "household_id": [1], + "region": ["NORTH_WEST"], + "tenure_type": ["RENT_PRIVATELY"], + "rent": [8000], + "council_tax": [1800], + } + ), + fiscal_year=YEAR, + ) + ] + ) + + +@pytest.fixture(scope="module") +def mock_frame(): + return pd.DataFrame( + { + f"person_id__{YEAR}": [1], + f"person_benunit_id__{YEAR}": [1], + f"person_household_id__{YEAR}": [1], + f"benunit_id__{YEAR}": [1], + f"household_id__{YEAR}": [1], + f"age__{YEAR}": [70], + f"region__{YEAR}": ["NORTH_WEST"], + f"tenure_type__{YEAR}": ["RENT_PRIVATELY"], + f"rent__{YEAR}": [8000], + f"council_tax__{YEAR}": [1800], + } + ) + + +@pytest.fixture( + scope="module", + params=[Simulation, Microsimulation, CoreSimulation], + ids=["uk-simulation", "uk-microsimulation", "core-simulation"], +) +def source_simulations(request, mock_dataset, mock_frame): + simulation_class = request.param + if simulation_class is CoreSimulation: + return { + "data": CoreSimulation(tax_benefit_system=system, dataset=mock_frame), + "situation": CoreSimulation(tax_benefit_system=system, situation=SITUATION), + } + return { + "data": simulation_class(dataset=mock_dataset), + "situation": simulation_class(situation=SITUATION), + } + + +@pytest.mark.parametrize("source, expected", [("data", True), ("situation", False)]) +@pytest.mark.parametrize("derivation", ["original", "clone", "branch"]) +def test_built_from_data_keeps_source_when_copying( + source_simulations, source, expected, derivation +): + simulation = source_simulations[source] + if derivation == "clone": + simulation = simulation.clone() + elif derivation == "branch": + simulation = simulation.get_branch() + + assert built_from_data(simulation) is expected + + +@pytest.mark.parametrize("simulation_class", [Simulation, Microsimulation]) +def test_built_from_data_tracks_uk_situation_rebuild(simulation_class, mock_dataset): + simulation = simulation_class(dataset=mock_dataset) + assert built_from_data(simulation) is True + + simulation.build_from_situation(SITUATION) + + assert built_from_data(simulation) is False diff --git a/policyengine_uk/tests/test_savings_credit_only_receipt_properties.py b/policyengine_uk/tests/test_savings_credit_only_receipt_properties.py index 98133c2046..98d8f646c7 100644 --- a/policyengine_uk/tests/test_savings_credit_only_receipt_properties.py +++ b/policyengine_uk/tests/test_savings_credit_only_receipt_properties.py @@ -220,8 +220,6 @@ def situation(units, would_claim_pc=None): claims = unit["would_claim_pc"] if would_claim_pc is None else would_claim_pc benunits[f"b{i}"] = { "members": names, - # claims_all_entitled_benefits sums reported benefits across the - # whole simulation, so set it for every family. "claims_all_entitled_benefits": {YEAR: True}, "would_claim_pc": {YEAR: claims}, "would_claim_uc": {YEAR: False}, diff --git a/policyengine_uk/utils/data_source.py b/policyengine_uk/utils/data_source.py index e9008e1f91..b26f96b116 100644 --- a/policyengine_uk/utils/data_source.py +++ b/policyengine_uk/utils/data_source.py @@ -7,8 +7,15 @@ def built_from_data(simulation) -> bool: carries little weight and is still data. policyengine_uk.Simulation records it as ``built_from_dataset``, and its builders keep it current. A policyengine-core Simulation built over the UK system records - ``is_over_dataset``, which core's SimulationBuilder leaves set if it later - overwrites that simulation in place with a situation. + ``is_over_dataset``. + + Known limitation in policyengine-core 3.33.2: directly overwriting a + data-built simulation with ``SimulationBuilder.build_from_dict`` leaves + its source flags and dataset unchanged and records no new situation + marker. This helper therefore still returns True for that unsupported + overwrite, its clones and branches, and a UK simulation overwritten by + the core builder. Use UK's ``build_from_situation`` for in-place rebuilds; + it resets the source flag correctly. """ flag = getattr(simulation, "built_from_dataset", None) if flag is None: diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/would_claim_housing_benefit.py b/policyengine_uk/variables/gov/dwp/housing_benefit/would_claim_housing_benefit.py index 6c9df4b9b7..c07780f754 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/would_claim_housing_benefit.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/would_claim_housing_benefit.py @@ -1,14 +1,23 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.data_source import built_from_data class would_claim_housing_benefit(Variable): value_type = bool entity = BenUnit label = "Would claim the Housing Benefit" - documentation = "Whether this family would claim Housing Benefit if eligible" + documentation = ( + "Whether this family would claim Housing Benefit if eligible. " + "Data-built simulations default to reporting families only; hand-entered " + "situations also claim when claims_all_entitled_benefits is true. " + "Datasets and situations can override this directly." + ) definition_period = YEAR def formula(benunit, period, parameters): - claims_all_entitled_benefits = benunit("claims_all_entitled_benefits", period) reported_hb = add(benunit, period, ["housing_benefit_reported"]) > 0 - return claims_all_entitled_benefits | reported_hb + # d1455 applies d1390's survey reporters-only rule to HB. Hand-entered + # situations retain main's per-family take-up rule (PR 1831 review). + if built_from_data(benunit.simulation): + return reported_hb + return benunit("claims_all_entitled_benefits", period) | reported_hb diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/would_claim_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/would_claim_council_tax_reduction.py index 68fdd511c9..403f3c7fe9 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/would_claim_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/would_claim_council_tax_reduction.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.data_source import built_from_data class would_claim_council_tax_reduction(Variable): @@ -6,11 +7,18 @@ class would_claim_council_tax_reduction(Variable): entity = BenUnit label = "Would claim Council Tax Reduction" documentation = ( - "Whether this benefit unit would claim Council Tax Reduction if eligible." + "Whether this benefit unit would claim Council Tax Reduction if eligible. " + "Data-built simulations default to reporting families only; hand-entered " + "situations also claim when claims_all_entitled_benefits is true. " + "Datasets and situations can override this directly." ) definition_period = YEAR def formula(benunit, period, parameters): - claims_all_entitled_benefits = benunit("claims_all_entitled_benefits", period) reported_ctr = benunit("council_tax_benefit_reported", period) > 0 - return claims_all_entitled_benefits | reported_ctr + # Survey take-up is reporters-only under d1390 until calibrated inputs + # land: https://github.com/PolicyEngine/policyengine-uk-data/issues/448 + # Calculator households retain main's rule (Vahid's PR 1831 review). + if built_from_data(benunit.simulation): + return reported_ctr + return benunit("claims_all_entitled_benefits", period) | reported_ctr diff --git a/policyengine_uk/variables/household/income/claims_all_entitled_benefits.py b/policyengine_uk/variables/household/income/claims_all_entitled_benefits.py index bb6643e7b8..545a7153a5 100644 --- a/policyengine_uk/variables/household/income/claims_all_entitled_benefits.py +++ b/policyengine_uk/variables/household/income/claims_all_entitled_benefits.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.data_source import built_from_data class claims_all_entitled_benefits(Variable): @@ -6,10 +7,33 @@ class claims_all_entitled_benefits(Variable): entity = BenUnit label = "Claims all eligible benefits" definition_period = YEAR - documentation = "Whether this family would claim any benefit they are entitled to" + documentation = ( + "Whether this family would claim any benefit it is entitled to. In " + "hand-entered situations it is true where the benefit unit reports " + "under £1 in total across the seven means-tested benefits the formula " + "checks (Child Tax Credit, Working Tax Credit, Universal Credit, " + "Housing Benefit, income-based JSA, Income Support and income-related " + "ESA), and false where it reports £1 or more, in which case its " + "reported benefits are taken as the complete set. Council Tax " + "Reduction, Pension Credit and Child Benefit are not among these " + "seven: reporting only Pension Credit still leaves this flag true. In " + "data-built simulations it is false for every family, so take-up of " + "the benefits that read it follows reported claims. Datasets and " + "situations can override it directly, except that Council Tax " + "Reduction and Housing Benefit ignore it on data-built simulations." + ) def formula(benunit, period, parameters): - # Return false if we have any reported values in the simulation for benefits. + # Survey runs keep main's result: no family is assumed to claim a + # benefit it does not report, so would_claim_CTC, _WTC and _IS follow + # reported claims (CTR and HB are reporters-only there anyway, d1390 + # and d1455). Main got there through a sum over the whole simulation, + # which any reporting family made false for every family. Tested per + # family instead, every non-reporting survey family would turn true + # and claim tax credits and Income Support (PR 1831 review r6). + if built_from_data(benunit.simulation): + return benunit.filled_array(False) + # Reporting under £1 across the seven benefits counts as reporting none. return ( add( benunit, @@ -23,6 +47,6 @@ def formula(benunit, period, parameters): "income_support_reported", "esa_income_reported", ], - ).sum() + ) < 1 )