From abef7e9a4c7a64a98146436cf4bdf93a98f8d27f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 09:47:32 -0400 Subject: [PATCH 1/2] Test that AFCS payments never confer severe disability; document AFCS vs AFIP Stacked on #1946, which reads armed_forces_independence_payment (not afcs) for CTC Regs 2002 reg 8(5) / WTC reg 17(4). This adds what #1946 lacks: - afcs_reported documented as FRS benefit code 8 (AFCS including guaranteed income payments, and war disablement pensions); armed_forces_independence_payment documented with AFCS Order 2011 art 24A. - YAML cases: AFCS with DLA care middle / PIP daily living standard; CTC severely disabled child element, WTC severe disability element (AFCS 0, AFIP 1,705 in 2024-25) and the UC higher disabled child addition (AFCS 0, PIP enhanced 5,950.44 in 2025-26). - Hypothesis properties: differential against the tax credit condition (CTC reg 8(3)-(5) + WTC reg 17(2) higher-rate AA); AFCS invariance of the flag and of every amount that reads it; AFIP monotone. Co-Authored-By: Claude Opus 5.5 --- changelog.d/afcs-severe-disability.changed.md | 1 + .../is_severely_disabled_for_benefits.yaml | 118 ++++++++++++ .../test_severe_disability_afcs_properties.py | 178 ++++++++++++++++++ .../variables/gov/dwp/afcs_reported.py | 8 + policyengine_uk/variables/gov/dwp/afip.py | 7 + 5 files changed, 312 insertions(+) create mode 100644 changelog.d/afcs-severe-disability.changed.md create mode 100644 policyengine_uk/tests/test_severe_disability_afcs_properties.py diff --git a/changelog.d/afcs-severe-disability.changed.md b/changelog.d/afcs-severe-disability.changed.md new file mode 100644 index 000000000..e73d498f4 --- /dev/null +++ b/changelog.d/afcs-severe-disability.changed.md @@ -0,0 +1 @@ +Document that `afcs_reported` is Family Resources Survey benefit code 8 (Armed Forces Compensation Scheme payments, including guaranteed income payments, and war disablement pensions) and that armed forces independence payment is the separate `armed_forces_independence_payment`, and test that no AFCS payment other than armed forces independence payment makes anyone severely disabled or changes the Universal Credit higher disabled child addition, the CTC severely disabled child element, the WTC severe disability element or the severe disability premium. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/is_severely_disabled_for_benefits.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/is_severely_disabled_for_benefits.yaml index cbc0d7f8a..3978cd63f 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/is_severely_disabled_for_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/is_severely_disabled_for_benefits.yaml @@ -68,3 +68,121 @@ output: # WTC Regs 2002 reg 17(2) and Sch 2 para 6: 1,705 a year in 2024-25. WTC_severely_disabled_element: 1_705 + +# Armed Forces Compensation Scheme payments other than armed forces +# independence payment (lump sums, supplementary awards, guaranteed income +# payments; AFCS Order 2011 art 15(1)) and war disablement pensions are in +# afcs. They meet no paragraph of CTC reg 8 or WTC reg 17, alone or with a +# lower award of another benefit. + +- name: DLA care component at the middle rate with an AFCS payment is not severe + period: 2025 + input: + age: 10 + dla_sc_category: MIDDLE + afcs_reported: 3_000 + output: + is_severely_disabled_for_benefits: false + +- name: PIP daily living component at the standard rate with an AFCS payment is not severe + period: 2025 + input: + age: 17 + pip_dl_category: STANDARD + afcs_reported: 3_000 + output: + is_severely_disabled_for_benefits: false + +- name: A child with an AFCS payment adds no CTC severely disabled child element + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + child: + age: 8 + afcs_reported: 2_000 + benunits: + benunit: + members: [parent, child] + is_CTC_eligible: true + output: + CTC_severely_disabled_child_element: 0 + +- name: An AFCS payment adds no WTC severe disability element + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 45 + afcs_reported: 12_000 + benunits: + benunit: + members: [adult] + is_WTC_eligible: true + output: + WTC_severely_disabled_element: 0 + +- name: WTC severe disability element for a claimant on armed forces independence payment + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 45 + # AFCS Order 2011 art 24A(3): 194.60 a week. + armed_forces_independence_payment: 10_119.20 + benunits: + benunit: + members: [adult] + is_WTC_eligible: true + output: + # WTC Regs 2002 reg 17(4) and Sch 2 para 6: 1,705 a year in 2024-25. + WTC_severely_disabled_element: 1_705 + +# UC Regs 2013 reg 24(2)(b) gives the higher rate of the disabled child +# addition for the DLA care component at the highest rate, the PIP daily living +# component at the enhanced rate (or a Scottish equivalent) or blindness; no +# armed forces payment is listed. Higher rate 2025-26: 495.87 a month (reg 36 +# table); 495.87 * 12 = 5,950.44. + +- name: A qualifying young person with an AFCS payment gets no UC higher disabled child addition + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 17 + is_in_non_advanced_education: true + afcs_reported: 5_000 + benunits: + benunit: + members: [parent, young_person] + output: + is_severely_disabled_for_benefits: [false, false] + uc_individual_severely_disabled_child_element: [0, 0] + +- name: A qualifying young person on the enhanced PIP daily living rate gets the UC higher addition + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 17 + is_in_non_advanced_education: true + pip_dl_category: ENHANCED + benunits: + benunit: + members: [parent, young_person] + output: + is_severely_disabled_for_benefits: [false, true] + uc_individual_severely_disabled_child_element: [0, 5_950.44] diff --git a/policyengine_uk/tests/test_severe_disability_afcs_properties.py b/policyengine_uk/tests/test_severe_disability_afcs_properties.py new file mode 100644 index 000000000..fbc137047 --- /dev/null +++ b/policyengine_uk/tests/test_severe_disability_afcs_properties.py @@ -0,0 +1,178 @@ +"""Property-based tests for the severe disability test and Armed Forces +Compensation Scheme payments. + +Law: +- Child Tax Credit Regulations 2002 (SI 2002/2007) reg 8(3)-(5): a person is + severely disabled if the DLA care component is payable at the highest rate, + the PIP daily living component at the enhanced rate, or "an armed forces + independence payment is payable in respect of him". +- Working Tax Credit (Entitlement and Maximum Rate) Regulations 2002 reg + 17(2)-(4): the same, plus Attendance Allowance at the higher rate. +- Armed forces independence payment is one of the injury benefits in the Armed + Forces and Reserve Forces (Compensation Scheme) Order 2011 art 15(1), next to + lump sums, supplementary awards and guaranteed income payments. Only it is + named; the afcs variables hold the other payments. + +Invariants, for any generated population of families: + +1. Differential: is_severely_disabled_for_benefits equals the tax credit + condition (CTC reg 8(3)-(5) with WTC reg 17(2)'s higher-rate Attendance + Allowance) recomputed here from the DLA care, PIP daily living and + Attendance Allowance categories and armed forces independence payment. +2. Metamorphic: changing every person's AFCS payment (to zero or another + amount) changes neither the severe disability test nor the amounts that read + it: the UC higher rate disabled child addition, the CTC severely disabled + child element, the WTC severe disability element and the severe disability + premium. +3. Monotone: adding armed forces independence payment to a person never turns + the severe disability test off, and turns it on. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +UC_YEAR = 2025 +TAX_CREDIT_YEAR = 2024 +YEARS = (TAX_CREDIT_YEAR, UC_YEAR) +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +DLA_CARE = ["NONE", "LOWER", "MIDDLE", "HIGHER"] +ATTENDANCE_ALLOWANCE = ["NONE", "LOWER", "HIGHER"] +PIP_DAILY_LIVING = ["NONE", "STANDARD", "ENHANCED"] +amounts = st.one_of(st.just(0.0), st.floats(1, 40_000)) + + +@st.composite +def people(draw, ages): + return dict( + age=draw(ages), + dla_sc_category=draw(st.sampled_from(DLA_CARE)), + pip_dl_category=draw(st.sampled_from(PIP_DAILY_LIVING)), + aa_category=draw(st.sampled_from(ATTENDANCE_ALLOWANCE)), + afcs_reported=draw(amounts), + armed_forces_independence_payment=draw( + st.one_of(st.just(0.0), st.just(0.0), st.floats(1, 15_000)) + ), + ) + + +@st.composite +def families(draw): + return dict( + adults=draw(st.lists(people(st.integers(25, 60)), min_size=1, max_size=2)), + # 16 to 19 year olds are in non-advanced education, so qualifying young + # persons; younger ones are children. + dependants=draw(st.lists(people(st.integers(0, 19)), max_size=2)), + ) + + +populations = st.lists(families(), min_size=10, max_size=20) + + +def situation(units, afcs=None, afip=None): + """Build a situation; afcs/afip map a person name to a replacement.""" + people_, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + names = [] + for role, members in (("a", unit["adults"]), ("d", unit["dependants"])): + for j, person in enumerate(members): + name = f"{role}{i}_{j}" + values = dict(person) + if afcs is not None: + values["afcs_reported"] = afcs(name, values["afcs_reported"]) + if afip is not None: + values["armed_forces_independence_payment"] = afip( + name, values["armed_forces_independence_payment"] + ) + if role == "a": + values["is_parent"] = bool(unit["dependants"]) + elif values["age"] >= 16: + values["is_in_non_advanced_education"] = True + people_[name] = {k: {y: v for y in YEARS} for k, v in values.items()} + names.append(name) + benunits[f"b{i}"] = { + "members": names, + "is_CTC_eligible": {TAX_CREDIT_YEAR: True}, + "is_WTC_eligible": {TAX_CREDIT_YEAR: True}, + } + households[f"h{i}"] = {"members": names} + return {"people": people_, "benunits": benunits, "households": households} + + +def reference_severely_disabled(units): + """CTC Regs 2002 reg 8(3)-(5) and WTC Regs 2002 reg 17(2)-(4), from the + inputs alone.""" + flags = [] + for unit in units: + for person in unit["adults"] + unit["dependants"]: + flags.append( + person["dla_sc_category"] == "HIGHER" + or person["pip_dl_category"] == "ENHANCED" + or person["aa_category"] == "HIGHER" + or person["armed_forces_independence_payment"] > 0 + ) + return np.array(flags) + + +def outputs(sim): + return dict( + flag=sim.calculate("is_severely_disabled_for_benefits", UC_YEAR), + uc_addition=sim.calculate( + "uc_individual_severely_disabled_child_element", UC_YEAR + ), + ctc_element=sim.calculate( + "CTC_severely_disabled_child_element", TAX_CREDIT_YEAR + ), + wtc_element=sim.calculate("WTC_severely_disabled_element", TAX_CREDIT_YEAR), + premium=sim.calculate("severe_disability_premium", UC_YEAR), + ) + + +@PROPERTY_SETTINGS +@given(populations) +def test_severe_disability_matches_the_tax_credit_condition(units): + sim = Simulation(situation=situation(units)) + flag = np.asarray(sim.calculate("is_severely_disabled_for_benefits", UC_YEAR)) + np.testing.assert_array_equal(flag, reference_severely_disabled(units)) + + +@PROPERTY_SETTINGS +@given(populations, st.data()) +def test_afcs_payments_never_change_severe_disability(units, data): + replacements = {} + + def new_afcs(name, _): + if name not in replacements: + replacements[name] = data.draw(amounts, label=f"afcs {name}") + return replacements[name] + + before = outputs(Simulation(situation=situation(units))) + no_afcs = outputs(Simulation(situation=situation(units, afcs=lambda n, v: 0.0))) + other_afcs = outputs(Simulation(situation=situation(units, afcs=new_afcs))) + for name, value in before.items(): + np.testing.assert_allclose(no_afcs[name], value, atol=1e-6, err_msg=name) + np.testing.assert_allclose(other_afcs[name], value, atol=1e-6, err_msg=name) + + +@PROPERTY_SETTINGS +@given(populations, st.floats(1, 15_000)) +def test_armed_forces_independence_payment_confers_severe_disability(units, amount): + before = np.asarray( + Simulation(situation=situation(units)).calculate( + "is_severely_disabled_for_benefits", UC_YEAR + ) + ) + after = np.asarray( + Simulation(situation=situation(units, afip=lambda n, v: amount)).calculate( + "is_severely_disabled_for_benefits", UC_YEAR + ) + ) + assert after.all() + assert (after >= before).all() diff --git a/policyengine_uk/variables/gov/dwp/afcs_reported.py b/policyengine_uk/variables/gov/dwp/afcs_reported.py index b42daaa61..66625a4cb 100644 --- a/policyengine_uk/variables/gov/dwp/afcs_reported.py +++ b/policyengine_uk/variables/gov/dwp/afcs_reported.py @@ -5,6 +5,14 @@ class afcs_reported(Variable): value_type = float entity = Person label = "Armed Forces Compensation Scheme (reported)" + documentation = ( + "Reported Armed Forces Compensation Scheme and war disablement pension " + "payments. In the Family Resources Survey this is benefit code 8, asked " + "as the Armed Forces Compensation Scheme (formerly War Disablement " + "Pension), including Guaranteed Income Payments. Armed forces " + "independence payment is the separate armed_forces_independence_payment " + "variable." + ) definition_period = YEAR unit = GBP uprating = "gov.economic_assumptions.indices.obr.consumer_price_index" diff --git a/policyengine_uk/variables/gov/dwp/afip.py b/policyengine_uk/variables/gov/dwp/afip.py index 69625ca85..1bea921f1 100644 --- a/policyengine_uk/variables/gov/dwp/afip.py +++ b/policyengine_uk/variables/gov/dwp/afip.py @@ -3,7 +3,14 @@ class armed_forces_independence_payment(Variable): label = "Armed Forces Independence Payment" + documentation = ( + "Armed forces independence payment under article 24A of the Armed " + "Forces and Reserve Forces (Compensation Scheme) Order 2011, payable " + "to people with a guaranteed income payment at a relevant percentage " + "of 50% or more. It is not part of afcs." + ) entity = Person definition_period = YEAR value_type = float unit = GBP + reference = "https://www.legislation.gov.uk/uksi/2011/517/article/24A" From 0b718f844c5c8a6a363183cc5434170c5ec6d4d3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 07:53:52 -0400 Subject: [PATCH 2/2] Address review: 2024-25 AFIP rate, reg 8(2) wording, QYP ages, SDP check - The 2024 WTC case's AFIP input uses the 2024-25 rate, 184.30 a week (AFCS Order 2011 art 24A(3) as amended by SI 2024/347 art 4): 9,583.60. - The YAML comment says AFCS payments meet none of the *severe* disability paragraphs (CTC reg 8(3)-(6), WTC reg 17(2)-(6)); a middle-rate DLA care or standard-rate PIP daily living award still meets reg 8(2). - Generated dependants are 0 to 18, all children or qualifying young persons for UC and CTC; a 19-year-old needs the course-start condition too. - The severe disability premium reads its own qualifying-benefit list, so the property docstring calls its AFCS invariance an independent check. - Changelog fragment written as a short bullet (docs/engineering/skills/github-prs.md). Co-Authored-By: Claude Opus 5.5 --- changelog.d/afcs-severe-disability.changed.md | 2 +- .../family/is_severely_disabled_for_benefits.yaml | 11 +++++++---- .../test_severe_disability_afcs_properties.py | 15 ++++++++++----- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/changelog.d/afcs-severe-disability.changed.md b/changelog.d/afcs-severe-disability.changed.md index e73d498f4..0c9753835 100644 --- a/changelog.d/afcs-severe-disability.changed.md +++ b/changelog.d/afcs-severe-disability.changed.md @@ -1 +1 @@ -Document that `afcs_reported` is Family Resources Survey benefit code 8 (Armed Forces Compensation Scheme payments, including guaranteed income payments, and war disablement pensions) and that armed forces independence payment is the separate `armed_forces_independence_payment`, and test that no AFCS payment other than armed forces independence payment makes anyone severely disabled or changes the Universal Credit higher disabled child addition, the CTC severely disabled child element, the WTC severe disability element or the severe disability premium. +- Documented that `afcs_reported` is FRS benefit code 8 (Armed Forces Compensation Scheme payments and war disablement pensions, not armed forces independence payment) and tested that AFCS payments never confer severe disability. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/is_severely_disabled_for_benefits.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/is_severely_disabled_for_benefits.yaml index a2ecb969a..a8c8f2751 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/is_severely_disabled_for_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/is_severely_disabled_for_benefits.yaml @@ -112,8 +112,10 @@ # Armed Forces Compensation Scheme payments other than armed forces # independence payment (lump sums, supplementary awards, guaranteed income # payments; AFCS Order 2011 art 15(1)) and war disablement pensions are in -# afcs. They meet no paragraph of CTC reg 8 or WTC reg 17, alone or with a -# lower award of another benefit. +# afcs. They meet none of the severe disability paragraphs (CTC reg 8(3)-(6); +# WTC reg 17(2)-(6)), alone or next to an award that is not severe. A DLA care +# award at the middle rate or a PIP daily living award at the standard rate +# still makes a child disabled under CTC reg 8(2), but not severely disabled. - name: DLA care component at the middle rate with an AFCS payment is not severe period: 2025 @@ -173,8 +175,9 @@ people: adult: age: 45 - # AFCS Order 2011 art 24A(3): 194.60 a week. - armed_forces_independence_payment: 10_119.20 + # AFCS Order 2011 art 24A(3), as amended by SI 2024/347 art 4: 184.30 + # a week in 2024-25; 184.30 * 52 = 9,583.60. + armed_forces_independence_payment: 9_583.60 benunits: benunit: members: [adult] diff --git a/policyengine_uk/tests/test_severe_disability_afcs_properties.py b/policyengine_uk/tests/test_severe_disability_afcs_properties.py index fbc137047..0e6b2b32a 100644 --- a/policyengine_uk/tests/test_severe_disability_afcs_properties.py +++ b/policyengine_uk/tests/test_severe_disability_afcs_properties.py @@ -22,8 +22,11 @@ 2. Metamorphic: changing every person's AFCS payment (to zero or another amount) changes neither the severe disability test nor the amounts that read it: the UC higher rate disabled child addition, the CTC severely disabled - child element, the WTC severe disability element and the severe disability - premium. + child element and the WTC severe disability element. The legacy severe + disability premium, which reads its own qualifying-benefit list + (receives_severe_disability_premium_qualifying_benefit) rather than this + flag, is checked the same way as an independent case: its list also names + armed forces independence payment and no other AFCS payment. 3. Monotone: adding armed forces independence payment to a person never turns the severe disability test off, and turns it on. """ @@ -67,9 +70,11 @@ def people(draw, ages): def families(draw): return dict( adults=draw(st.lists(people(st.integers(25, 60)), min_size=1, max_size=2)), - # 16 to 19 year olds are in non-advanced education, so qualifying young - # persons; younger ones are children. - dependants=draw(st.lists(people(st.integers(0, 19)), max_size=2)), + # 16 to 18 year olds are in non-advanced education, so qualifying young + # persons for both UC and CTC; younger ones are children. A 19-year-old + # would also need their course to have started before 19, so none is + # drawn. + dependants=draw(st.lists(people(st.integers(0, 18)), max_size=2)), )