diff --git a/changelog.d/2588.fixed.md b/changelog.d/2588.fixed.md new file mode 100644 index 0000000000..bdde46a084 --- /dev/null +++ b/changelog.d/2588.fixed.md @@ -0,0 +1,3 @@ +- Start England’s universal and targeted childcare provision in the term after the qualifying birthday, allocating base and working-parent hours once in common terms while preserving supplied annual inputs. +- Preserve genuine and model-owned input provenance when aggregate checks perturb childcare awards. +- Keep the standalone aggregate-impact tool's model fingerprint ahead of model construction by loading input-provenance helpers only during simulation operations. diff --git a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_childcare_provenance.py b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_childcare_provenance.py new file mode 100644 index 0000000000..eca0398709 --- /dev/null +++ b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_childcare_provenance.py @@ -0,0 +1,463 @@ +"""Public-only RED: guard intervention must not manufacture legacy user choices. + +Uses the already independent DfE calendar oracle and tiny synthetic situations. +No dataset, certified fixture, private ID or amount, country default or download. +""" + +from datetime import date +import numpy as np +import pytest +from policyengine_core.periods import period +from policyengine_uk.tests.test_childcare_birthday_terms import ( + simulate, + calendar_reference, + VALUES, + U, + T, + W, + E, +) +from policyengine_uk.utils.childcare import uses_legacy_childcare_inputs +from policyengine_uk.utils.supplied_inputs import supplied_input, set_model_value +from policyengine_uk.utils.aggregate_impact import guard +from policyengine_uk.utils.aggregate_impact.partition import parse_partition + +YEAR = 2026 +BIRTHS = [date(2023, 8, 31), date(2023, 10, 7)] +LEGACY = [ + "early_years_term_share_before_school", + "extended_childcare_entitlement_term_share", + U, + T, + W, +] +X = "extended_childcare_entitlement" +HEADS = [U, T, X] + + +def make(born=date(2023, 10, 7), annual_inputs=None): + sim = simulate(born, working=True, annual_inputs=annual_inputs) + sim.set_input("household_weight", YEAR, [1]) + return sim + + +def values(sim): + return {v: float(sim.calculate(v, YEAR)[0]) for v in VALUES} + + +def is_legacy(sim): + return uses_legacy_childcare_inputs( + sim.get_variable_population(U), period(str(YEAR)) + ) + + +def genuine(sim, name): + return supplied_input(sim.get_variable_population(name), name, period(str(YEAR))) + + +def baselines(sim, names=HEADS): + return {v: sim.calculate(v, YEAR).astype(np.float64).copy() for v in names} + + +def small_partition(): + return parse_partition( + { + "schema_version": 1, + "totals": {"gov_spending": {}}, + "tax_heads": [], + "spending_heads": [ + { + "id": v, + "label": v, + "variable": v, + "totals": ["gov_spending"], + "entity": "benunit" if v == X else "person", + } + for v in HEADS + ], + "model_double_counts": [], + } + ) + + +@pytest.mark.parametrize("born", BIRTHS, ids=["aug", "oct"]) +@pytest.mark.parametrize("component", [U, T], ids=["U", "T"]) +def test_native_probe_keeps_genuine_input_route(born, component): + sim = make(born) + expected, _ = calendar_reference( + born, targeted=True, working=True, claims_universal=True, claims_targeted=True + ) + assert values(sim) == pytest.approx(expected, abs=0.01) + baseline = baselines(sim) + assert not is_legacy(sim) + guard._perturb(sim, component, YEAR, baseline, np.array([1.0]), False) + assert genuine(sim, component) is None + assert not is_legacy(sim) + + +@pytest.mark.parametrize( + "born,component,other", + [(BIRTHS[0], T, U), (BIRTHS[1], U, T)], + ids=["target-probe-universal-stable", "universal-probe-target-stable"], +) +def test_probe_does_not_raise_unrelated_base_head(born, component, other): + sim = make(born) + expected, _ = calendar_reference( + born, targeted=True, working=True, claims_universal=True, claims_targeted=True + ) + before = values(sim) + assert before == pytest.approx(expected, abs=0.01) + baseline = baselines(sim) + for added in [1.0, 2.0]: + guard._perturb(sim, component, YEAR, baseline, np.array([added]), False) + assert float(sim.calculate(other, YEAR)[0]) == pytest.approx( + expected[other], abs=0.01 + ) + + +@pytest.mark.parametrize("born", BIRTHS, ids=["aug", "oct"]) +def test_native_counted_once_childcare_partition(born): + sim = make(born) + part = small_partition() + snapshot = guard.snapshot_inputs(sim, part.component_variables()) + result = guard.counted_once( + sim, part, YEAR, 1e-6, snapshot, variables_to_check=[U, T] + ) + assert result["reset_reproduces_baseline"] + assert {r["variable"] for r in result["components"]} == {U, T} + assert all(r["status"] == "pass" for r in result["components"]) + assert all( + r["coefficients"]["gov_spending"] == pytest.approx(1, abs=1e-3) + for r in result["components"] + ) + + +@pytest.mark.parametrize( + "name", LEGACY, ids=["school-share", "work-share", "U", "T", "working-total"] +) +@pytest.mark.parametrize("entered", ["zero", "equal"]) +def test_original_genuine_input_is_restored(name, entered): + sim = make() + value = 0.0 if entered == "zero" else float(sim.calculate(name, YEAR)[0]) + sim.set_input(name, YEAR, [value]) + sim._invalidate_all_caches() + assert is_legacy(sim) + original = genuine(sim, name).copy() + snapshot = guard.snapshot_inputs(sim, [name]) + guard._perturb( + sim, + name, + YEAR, + {name: sim.calculate(name, YEAR).astype(np.float64)}, + np.array([1.0]), + False, + ) + assert is_legacy(sim) + guard._restore(sim, name, snapshot) + assert is_legacy(sim) + np.testing.assert_array_equal(genuine(sim, name), original) + + +@pytest.mark.parametrize("name", [U, T], ids=["U", "T"]) +def test_original_model_owned_amount_restores_origin(name): + sim = make() + before = values(sim) + set_model_value(sim, name, YEAR, sim.calculate(name, YEAR).copy()) + assert genuine(sim, name) is None + snapshot = guard.snapshot_inputs(sim, [name]) + baseline = {name: sim.calculate(name, YEAR).astype(np.float64)} + guard._perturb(sim, name, YEAR, baseline, np.array([1.0]), False) + guard._restore(sim, name, snapshot) + assert genuine(sim, name) is None + assert not is_legacy(sim) + assert values(sim) == pytest.approx(before, abs=0.01) + + +@pytest.mark.parametrize("held", [U, T], ids=["U", "T"]) +def test_held_head_write_does_not_manufacture_legacy_input(held): + sim = make() + baseline = baselines(sim) + snapshot = guard.snapshot_inputs(sim, HEADS) + guard._perturb(sim, X, YEAR, baseline, np.array([1.0]), False, hold=[held]) + assert genuine(sim, held) is None + assert not is_legacy(sim) + for name in HEADS: + guard._restore(sim, name, snapshot) + assert not is_legacy(sim) + + +@pytest.mark.parametrize("kind", ["clone", "branch"]) +def test_probe_and_restore_do_not_change_parent_origin(kind): + parent = make() + before = values(parent) + child = parent.clone() if kind == "clone" else parent.get_branch("guard_probe") + baseline = baselines(child) + snapshot = guard.snapshot_inputs(child, HEADS) + guard._perturb(child, U, YEAR, baseline, np.array([1.0]), False) + assert not is_legacy(parent) + assert values(parent) == before + for name in HEADS: + guard._restore(child, name, snapshot) + assert not is_legacy(child) + assert values(child) == pytest.approx(before, abs=0.01) + + +def test_warm_cache_does_not_create_a_user_choice(): + sim = make() + before = values(sim) + for name in reversed(VALUES): + sim.calculate(name, YEAR) + assert not is_legacy(sim) + assert values(sim) == before + + +def test_counted_once_exception_restores_probe_origin(monkeypatch): + sim = make() + before = values(sim) + part = small_partition() + snapshot = guard.snapshot_inputs(sim, part.component_variables()) + original = guard._head_arrays + + class DeliberatePublicFailure(Exception): + pass + + calls = 0 + + def injected(simulation, *args, **kwargs): + nonlocal calls + calls += 1 + if calls == 2: + raise DeliberatePublicFailure() + return original(simulation, *args, **kwargs) + + monkeypatch.setattr(guard, "_head_arrays", injected) + with pytest.raises(DeliberatePublicFailure): + guard.counted_once(sim, part, YEAR, 1e-6, snapshot, variables_to_check=[U]) + assert genuine(sim, U) is None + assert not is_legacy(sim) + assert values(sim) == pytest.approx(before, abs=0.01) + + +def test_real_wrong_head_count_still_fails(): + from policyengine_uk.tests.aggregate_impact.test_aggregate_impact_guard import ( + run, + model, + partition, + by_variable, + kinds, + ) + + _, result = run(model(contains=True), partition(["tax_a", "tax_b"])) + row = by_variable(result)["tax_b"] + assert row["status"] == "fail" and ("tax_a", "moves") in kinds(row) + + +@pytest.mark.parametrize( + "origin,operation", + [ + (o, p) + for p in ["normal", "held", "exception"] + for o in ["zero", "equal", "model"] + ], + ids=[ + o + "-" + p + for p in ["normal", "held", "exception"] + for o in ["zero", "equal", "model"] + ], +) +def test_inherited_input_origin_and_slot_placement_are_restored(origin, operation): + parent = make() + native = parent.calculate(U, YEAR).copy() + if origin == "model": + set_model_value(parent, U, YEAR, native) + else: + parent.set_input(U, YEAR, [0.0] if origin == "zero" else native) + child = parent.get_branch("inherited_probe") + sibling = parent.get_branch("untouched_sibling") + p = period(str(YEAR)) + ancestor = parent.branch_name + local = child.branch_name + before_parent = values(parent) + before_sibling = values(sibling) + parent_keys = set(parent._user_input_keys) + sibling_keys = set(sibling._user_input_keys) + holder = child.get_holder(U) + inherited_before = holder._get_array_from_storage(p, ancestor).copy() + assert holder._get_array_from_storage(p, local) is None + model_before = { + k: v.copy() for k, v in getattr(child, "_model_values", {}).items() if k[0] == U + } + keys_before = {k for k in child._user_input_keys if k[0] == U} + expected_genuine = origin != "model" + assert (genuine(child, U) is not None) == expected_genuine + snapshot = guard.snapshot_inputs(child, HEADS) + baseline = baselines(child) + try: + if operation == "held": + guard._perturb(child, T, YEAR, baseline, np.array([1.0]), False, hold=[U]) + else: + guard._perturb(child, U, YEAR, baseline, np.array([1.0]), False) + if operation == "exception": + raise RuntimeError("deliberate public cleanup control") + except RuntimeError as error: + assert ( + operation == "exception" + and str(error) == "deliberate public cleanup control" + ) + finally: + for name in HEADS: + guard._restore(child, name, snapshot) + # Exact branch placement matters even if fallback values happen to be equal. + assert holder._get_array_from_storage(p, local) is None + np.testing.assert_array_equal( + holder._get_array_from_storage(p, ancestor), inherited_before + ) + assert {k for k in child._user_input_keys if k[0] == U} == keys_before + after_model = { + k: v for k, v in getattr(child, "_model_values", {}).items() if k[0] == U + } + assert set(after_model) == set(model_before) + for key in model_before: + np.testing.assert_array_equal(after_model[key], model_before[key]) + assert (genuine(child, U) is not None) == expected_genuine + assert ( + parent._user_input_keys == parent_keys + and sibling._user_input_keys == sibling_keys + ) + assert values(parent) == before_parent and values(sibling) == before_sibling + + +@pytest.mark.parametrize("origin", ["absent", "genuine-zero", "genuine-equal", "model"]) +def test_registered_nonchildcare_guard_origin_round_trip(origin): + sim = make() + name = "adjusted_net_income" + p = period(str(YEAR)) + native = sim.calculate(name, YEAR).copy() + if origin == "genuine-zero": + sim.set_input(name, YEAR, [0.0]) + elif origin == "genuine-equal": + sim.set_input(name, YEAR, native) + elif origin == "model": + set_model_value(sim, name, YEAR, native) + genuine_before = genuine(sim, name) + genuine_before = None if genuine_before is None else genuine_before.copy() + holder = sim.get_holder(name) + stored_before = holder._get_array_from_storage(p, sim.branch_name) + stored_before = None if origin == "absent" else stored_before.copy() + snapshot = guard.snapshot_inputs(sim, [name]) + baseline = {name: sim.calculate(name, YEAR).astype(np.float64)} + guard._perturb(sim, name, YEAR, baseline, np.array([1.0]), False) + assert (genuine(sim, name) is not None) == origin.startswith("genuine") + guard._restore(sim, name, snapshot) + after = genuine(sim, name) + if genuine_before is None: + assert after is None + else: + np.testing.assert_array_equal(after, genuine_before) + if stored_before is None: + assert holder._get_array_from_storage(p, sim.branch_name) is None + else: + np.testing.assert_array_equal( + holder._get_array_from_storage(p, sim.branch_name), stored_before + ) + + +def _subset_origin_fixture(): + parent = make() + parent.set_input("employment_income", YEAR, [0.0]) + set_model_value( + parent, + "adjusted_net_income", + YEAR, + parent.calculate("adjusted_net_income", YEAR).copy(), + ) + child = parent.get_branch("subset_probe") + sibling = parent.get_branch("subset_sibling") + names = HEADS + ["employment_income", "adjusted_net_income"] + part = parse_partition( + { + "schema_version": 1, + "totals": {"gov_spending": {}}, + "tax_heads": [], + "spending_heads": [ + { + "id": v, + "label": v, + "variable": v, + "totals": ["gov_spending"], + "entity": "benunit" if v == X else "person", + } + for v in names + ], + "model_double_counts": [], + } + ) + p = period(str(YEAR)) + ancestor = parent.branch_name + saved = { + v: child.get_holder(v)._get_array_from_storage(p, ancestor).copy() + for v in names[-2:] + } + keys = {k for k in child._user_input_keys if k[0] in names[-2:]} + model = { + k: v.copy() + for k, v in getattr(child, "_model_values", {}).items() + if k[0] in names[-2:] + } + return parent, child, sibling, part, saved, keys, model + + +def _assert_unselected_origin_placement(parent, child, sibling, saved, keys, model): + p = period(str(YEAR)) + for name, value in saved.items(): + holder = child.get_holder(name) + assert holder._get_array_from_storage(p, child.branch_name) is None + np.testing.assert_array_equal( + holder._get_array_from_storage(p, parent.branch_name), value + ) + np.testing.assert_array_equal(parent.calculate(name, YEAR), value) + np.testing.assert_array_equal(sibling.calculate(name, YEAR), value) + assert {k for k in child._user_input_keys if k[0] in saved} == keys + current = { + k: v for k, v in getattr(child, "_model_values", {}).items() if k[0] in saved + } + assert set(current) == set(model) + for key, value in model.items(): + np.testing.assert_array_equal(current[key], value) + + +def test_subset_selection_leaves_unselected_input_origins_untouched(): + parent, child, sibling, part, saved, keys, model = _subset_origin_fixture() + snapshot = guard.snapshot_inputs(child, part.component_variables()) + result = guard.counted_once( + child, part, YEAR, 1e-6, snapshot, variables_to_check=[U] + ) + assert result["reset_reproduces_baseline"] + assert [r["variable"] for r in result["components"]] == [U] + assert result["components"][0]["status"] == "pass" + _assert_unselected_origin_placement(parent, child, sibling, saved, keys, model) + + +def test_partial_setter_failure_restores_only_touched_input(monkeypatch): + parent, child, sibling, part, saved, keys, model = _subset_origin_fixture() + before = values(child) + snapshot = guard.snapshot_inputs(child, part.component_variables()) + original = child.set_input + failed = False + + class DeliberatePartialSetterFailure(Exception): + pass + + def partial(name, when, value): + nonlocal failed + original(name, when, value) + if name == U and not failed: + failed = True + raise DeliberatePartialSetterFailure() + + monkeypatch.setattr(child, "set_input", partial) + with pytest.raises(DeliberatePartialSetterFailure): + guard.counted_once(child, part, YEAR, 1e-6, snapshot, variables_to_check=[U]) + assert failed and genuine(child, U) is None + assert values(child) == pytest.approx(before, abs=0.01) + _assert_unselected_origin_placement(parent, child, sibling, saved, keys, model) diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement.yaml index 9fb1a9963c..a7bde1fbd0 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement.yaml @@ -1,11 +1,11 @@ -# extended_childcare_entitlement is the working parent hours above the universal -# (3-4) and targeted (2) hours each child also gets: Childcare Act 2016 s.1(6)(a) -# counts those hours towards the 30. Every child here is in England (the default) -# and claims the universal entitlement (the default), so a 3- or 4-year-old's -# universal 570 hours a year come off the 1,140 the parameter gives. Rates per -# hour: 2025 £8.53 (age 2) and £6.12 (3-4); 2026 £8.90 (age 2) and -# £6.61 (3-4). -- name: 3-year-old gets the 15 working parent hours above the universal 15 +# Whole-age fallback birthdays are 6 April: base provision begins in September, +# so April working-parent hours have no universal base to subtract. Each term +# is valued at its qualifying age band within the same annual funding table. +# Working-parent hours above base provision are allocated within each common +# term (Childcare Act 2016 s.1(6)(a)). The 2025 annual hours schedule is kept; +# the funding-year rates are £11.54/£8.53/£6.12 by qualifying age band, and +# the corresponding 2026 rates are £12.04/£8.90/£6.61. +- name: A 3-year-old gets summer working hours and two universal top-ups period: 2025 absolute_error_margin: 0.05 input: @@ -17,9 +17,8 @@ members: [child1] extended_childcare_entitlement_eligible: true output: - # (1,140 - 570) x 6.12 - extended_childcare_entitlement: 3488.40 - universal_childcare_entitlement: 3488.40 + extended_childcare_entitlement: 5_567.00 + universal_childcare_entitlement: 2_325.60 - name: Eligible for 15 hours - 2 year old (evaluated at Jan 1, 2025 before Sept expansion) period: 2025 @@ -33,8 +32,7 @@ members: [child1] extended_childcare_entitlement_eligible: true output: - # 15 x 38 x 8.53; no targeted hours, so nothing comes off. - extended_childcare_entitlement: 4862.10 + extended_childcare_entitlement: 5_434.00 - name: Not eligible (one condition not met) period: 2025 @@ -58,14 +56,13 @@ child1: age: 2 # 15 hours at Jan 1, 2025 child2: - age: 3 # 30 hours, 15 of them universal + age: 3 # 30 annual-schedule hours; universal starts in September benunits: benunit1: members: [child1, child2] extended_childcare_entitlement_eligible: true output: - # 570 x 8.53 + (1,140 - 570) x 6.12 - extended_childcare_entitlement: 8350.50 + extended_childcare_entitlement: 11_001.00 - name: Not eligible - Family with multiple children but conditions not met period: 2025 @@ -91,17 +88,16 @@ child1: age: 2 # 15 hours at Jan 1, 2025 child2: - age: 3 # 30 hours, 15 of them universal + age: 3 # 30 annual-schedule hours; universal starts in September child3: - age: 3 # 30 hours, 15 of them universal + age: 3 # 30 annual-schedule hours; universal starts in September benunits: benunit1: members: [child1, child2, child3] family_type: COUPLE_WITH_CHILDREN extended_childcare_entitlement_eligible: true output: - # 570 x 8.53 + 2 x (1,140 - 570) x 6.12 - extended_childcare_entitlement: 11838.90 + extended_childcare_entitlement: 16_568.00 - name: No expenses for zero hours period: 2025 @@ -130,10 +126,8 @@ members: [child1] extended_childcare_entitlement_eligible: true output: - # (20 x 38 - 570) x 6.12: 190 working parent hours on top of the - # universal 570. - extended_childcare_entitlement: 1162.80 - universal_childcare_entitlement: 3488.40 + extended_childcare_entitlement: 2_936.13 + universal_childcare_entitlement: 2_325.60 - name: Child using fewer hours than maximum entitlement - multiple children period: 2025 @@ -151,8 +145,7 @@ members: [child1, child2] extended_childcare_entitlement_eligible: true output: - # 10 x 38 x 8.53 + max(0, 15 x 38 - 570) x 6.12 - extended_childcare_entitlement: 3241.40 + extended_childcare_entitlement: 5_243.37 - name: Benefit unit maximum hours cap applied period: 2025 @@ -171,10 +164,9 @@ extended_childcare_entitlement_eligible: true maximum_extended_childcare_hours_usage: 40 output: - # The cap applies to each child: 2 x (1,140 - 570) x 6.12 - extended_childcare_entitlement: 6976.80 + extended_childcare_entitlement: 11_134.00 -- name: A family cap below the universal hours leaves the universal hours whole +- name: A cap below 15 preserves later universal hours and summer working hours period: 2025 absolute_error_margin: 0.05 input: @@ -188,12 +180,10 @@ extended_childcare_entitlement_eligible: true maximum_extended_childcare_hours_usage: 10 output: - # 10 x 38 = 380 hours is under the universal 570, so no working parent - # hours, and the universal 570 stay. - extended_childcare_entitlement: 0 - universal_childcare_entitlement: 3488.40 + extended_childcare_entitlement: 1_080.47 + universal_childcare_entitlement: 2_325.60 -- name: A family cap above 15 adds the hours between 15 and the cap +- name: A cap above 15 adds common-term top-ups and summer working hours period: 2025 absolute_error_margin: 0.05 input: @@ -207,10 +197,9 @@ extended_childcare_entitlement_eligible: true maximum_extended_childcare_hours_usage: 18 output: - # (18 x 38 - 570) x 6.12 - extended_childcare_entitlement: 697.68 + extended_childcare_entitlement: 2_409.96 -- name: Benefit unit without maximum hours cap applied with 3 years old child +- name: A child using 15 hours has summer working hours before universal starts period: 2025 absolute_error_margin: 0.05 input: @@ -224,9 +213,8 @@ extended_childcare_entitlement_eligible: true maximum_extended_childcare_hours_usage: 18 output: - # The child uses 15 hours, all of them universal. - extended_childcare_entitlement: 0 - universal_childcare_entitlement: 3488.40 + extended_childcare_entitlement: 1_620.70 + universal_childcare_entitlement: 2_325.60 - name: Eligible for 30 hours - 2 year old after September 2025 expansion period: 2026 @@ -240,8 +228,7 @@ members: [child1] extended_childcare_entitlement_eligible: true output: - # 30 x 38 x 8.90; no targeted hours. - extended_childcare_entitlement: 10146.00 + extended_childcare_entitlement: 11_339.20 - name: Eligible for 30 hours - Family with multiple 2-year-olds after expansion period: 2026 @@ -251,18 +238,17 @@ child1: age: 2 # 30 hours after September 2025 child2: - age: 3 # 30 hours, 15 of them universal + age: 3 # 30 annual-schedule hours; universal starts in September benunits: benunit1: members: [child1, child2] extended_childcare_entitlement_eligible: true output: - # 1,140 x 8.90 + (1,140 - 570) x 6.61 - extended_childcare_entitlement: 13913.70 + extended_childcare_entitlement: 17_233.00 # SI 2022/1134 reg 13(2)(b): a child looked after by a local authority is not a # qualifying child, so only the other three-year-old's working parent hours -# count (the same £3,435.39 as the single three-year-old above). The +# count. The # looked-after child keeps the universal hours. - name: A looked-after child's hours do not count period: 2025 @@ -279,5 +265,5 @@ members: [child1, child2] extended_childcare_entitlement_eligible: true output: - extended_childcare_entitlement: 3488.40 - universal_childcare_entitlement: [3488.40, 3488.40] + extended_childcare_entitlement: 5_567.00 + universal_childcare_entitlement: [2_325.60, 2_325.60] diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.yaml index e0fb42771b..9053d9c4e8 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.yaml @@ -1,9 +1,13 @@ +# Birthday terms are intersected, not multiplied as annual shares. Whole ages +# imply 6 April birthdays: base hours start in September, while working-parent +# hours may already exist in April. April uses the preceding qualifying age +# band; later terms use the new band, from the same annual funding table. # Each child's working parent hours are the part of its funded hours above the # universal (3-4) or targeted (2) hours: Childcare Act 2016 s.1(6)(a), and DfE's # statutory guidance (April 2026) para A1.11 for a 2-year-old eligible for both. # Rates per hour: 2025 £8.53 (age 2); 2026 £12.04 (under 2), # £8.90 (age 2) and £6.61 (3-4). -- name: A 2-year-old eligible for both gets 15 targeted plus 15 working parent hours +- name: A 2-year-old has targeted first hours only from September period: 2026 absolute_error_margin: 0.05 input: @@ -16,12 +20,11 @@ extended_childcare_entitlement_eligible: true targeted_childcare_entitlement_eligible: true output: - # 570 x 8.90 each - targeted_childcare_entitlement: 5073.00 - extended_childcare_entitlement_per_child: 5073.00 - extended_childcare_entitlement: 5073.00 + targeted_childcare_entitlement: 3_382.00 + extended_childcare_entitlement_per_child: 7_957.20 + extended_childcare_entitlement: 7_957.20 -- name: Before September 2025 a 2-year-old eligible for both gets the targeted 15 hours +- name: The annual 2025 schedule has summer working hours before targeted provision period: 2025 absolute_error_margin: 0.05 input: @@ -34,10 +37,8 @@ extended_childcare_entitlement_eligible: true targeted_childcare_entitlement_eligible: true output: - # The working parent total (15 hours at 1 January 2025) is no more than - # the targeted 570 hours: 570 x 8.53 targeted, nothing on top. - targeted_childcare_entitlement: 4862.10 - extended_childcare_entitlement_per_child: 0 + targeted_childcare_entitlement: 3_241.40 + extended_childcare_entitlement_per_child: 2_192.60 - name: A 1-year-old has no universal or targeted hours to come off period: 2026 @@ -107,9 +108,9 @@ extended_childcare_entitlement_per_child: 9150.40 # Until April 2024 the minimum age was three (reg 13(2)(a) as made). A -# standalone universal claim runs all year; the working parent hours top it up -# to 30 a week only in the terms after the third birthday. Rate: £5.88 (3-4). -- name: A 3-year-old qualifying from September keeps a full year of universal hours +# universal and working-parent claims both start after the third birthday. +# They share the same two terms here. Rate: £5.88 (3-4). +- name: A 3-year-old qualifying from September gets two common funded terms period: 2023 absolute_error_margin: 0.05 input: @@ -122,8 +123,7 @@ members: [child] extended_childcare_entitlement_eligible: true output: - # 570 x 5.88, then 15 more hours a week in two of the three terms. - universal_childcare_entitlement: 3351.60 + universal_childcare_entitlement: 2_234.40 extended_childcare_entitlement_per_child: 2234.40 - name: Without standalone take-up such a child's universal hours come only in those terms @@ -157,14 +157,13 @@ extended_childcare_entitlement_eligible: true would_claim_universal_childcare: false output: - # 15 x 38 x 6.61 in each programme, preserving all 30 funded hours. universal_childcare_entitlement_eligible: true - universal_childcare_entitlement: 3767.70 - extended_childcare_entitlement_per_child: 3767.70 + universal_childcare_entitlement: 2_511.80 + extended_childcare_entitlement_per_child: 5_893.80 is_child_receiving_universal_childcare: true is_child_receiving_extended_childcare: true -- name: Below 15 hours all working parent usage is universal despite false standalone take-up +- name: Below 15 hours universal funds only its two common terms period: 2026 absolute_error_margin: 0.05 input: @@ -178,11 +177,10 @@ extended_childcare_entitlement_eligible: true would_claim_universal_childcare: false output: - # 10 x 38 x 6.61; the unpaid standalone floor is not added. - universal_childcare_entitlement: 2511.80 - extended_childcare_entitlement_per_child: 0 + universal_childcare_entitlement: 1_674.5333 + extended_childcare_entitlement_per_child: 1_127.33 is_child_receiving_universal_childcare: true - is_child_receiving_extended_childcare: false + is_child_receiving_extended_childcare: true - name: False universal take-up with a ten-hour family cap keeps the ten funded hours period: 2026 @@ -234,13 +232,12 @@ would_claim_targeted_childcare: false output: targeted_childcare_entitlement_eligible: true - # 15 x 38 x 8.90 in each programme (DfE A1.11). - targeted_childcare_entitlement: 5073.00 - extended_childcare_entitlement_per_child: 5073.00 + targeted_childcare_entitlement: 3_382.00 + extended_childcare_entitlement_per_child: 7_957.20 is_child_receiving_targeted_childcare: true is_child_receiving_extended_childcare: true -- name: Below 15 hours all working parent usage is targeted despite false standalone take-up +- name: Below 15 hours targeted funds only its two common terms period: 2026 absolute_error_margin: 0.05 input: @@ -255,13 +252,12 @@ extended_childcare_entitlement_eligible: true would_claim_targeted_childcare: false output: - # 10 x 38 x 8.90, with no unfunded floor subtracted. - targeted_childcare_entitlement: 3382.00 - extended_childcare_entitlement_per_child: 0 + targeted_childcare_entitlement: 2_254.6667 + extended_childcare_entitlement_per_child: 1_525.07 is_child_receiving_targeted_childcare: true - is_child_receiving_extended_childcare: false + is_child_receiving_extended_childcare: true -- name: Before the 30-hour expansion working parent participation is entirely targeted +- name: Before expansion the summer working hours precede targeted provision period: 2025 absolute_error_margin: 0.05 input: @@ -275,8 +271,8 @@ extended_childcare_entitlement_eligible: true would_claim_targeted_childcare: false output: - targeted_childcare_entitlement: 4862.10 - extended_childcare_entitlement_per_child: 0 + targeted_childcare_entitlement: 3_241.40 + extended_childcare_entitlement_per_child: 2_192.60 - name: Legal targeted eligibility alone does not create take-up period: 2026 @@ -318,19 +314,25 @@ age: 30 child: age: 3 + # Deliberate synthetic birthday: universal provision starts on + # 1 April, retaining this fixture's full-570-hour assumption. + date_of_birth: 20230331 benunits: benunit: members: [parent, child] extended_childcare_entitlement_eligible: true output: - # (1,140 - 570) x 6.61 for the child only - extended_childcare_entitlement_per_child: [0, 3767.70] - extended_childcare_entitlement: 3767.70 + # The adult gets zero; the child's 1,140 funded hours include 570 + # universal hours, leaving 570 * £6.61 in the working-parent top-up. + universal_childcare_entitlement: [0, 3_767.70] + targeted_childcare_entitlement: [0, 0] + extended_childcare_entitlement_per_child: [0, 3_767.70] + extended_childcare_entitlement: 3_767.70 # The same overlap through the targeted eligibility formula itself: Income # Support is a qualifying benefit, and eligibility for the working parent # entitlement no longer switches the targeted hours off. -- name: Income Support family eligible for both keeps the targeted hours and gets 15 on top +- name: Income Support family has targeted first hours in the two common terms period: 2026 absolute_error_margin: 0.05 input: @@ -348,13 +350,12 @@ country: ENGLAND output: targeted_childcare_entitlement_eligible: true - # 570 x 8.90 each - targeted_childcare_entitlement: 5073.00 - extended_childcare_entitlement_per_child: 5073.00 + targeted_childcare_entitlement: 3_382.00 + extended_childcare_entitlement_per_child: 7_957.20 is_child_receiving_targeted_childcare: true is_child_receiving_extended_childcare: true -- name: Income Support family eligible for both before September 2025 gets the targeted 15 hours +- name: Income Support family has summer working hours before targeted starts in 2025 period: 2025 absolute_error_margin: 0.05 input: @@ -372,8 +373,7 @@ country: ENGLAND output: targeted_childcare_entitlement_eligible: true - # 570 x 8.53 targeted; the 15-hour working parent total adds nothing. - targeted_childcare_entitlement: 4862.10 - extended_childcare_entitlement_per_child: 0 + targeted_childcare_entitlement: 3_241.40 + extended_childcare_entitlement_per_child: 2_192.60 is_child_receiving_targeted_childcare: true - is_child_receiving_extended_childcare: false + is_child_receiving_extended_childcare: true diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/is_child_receiving_extended_childcare.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/is_child_receiving_extended_childcare.yaml index d33c288224..f3e37b3ffe 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/is_child_receiving_extended_childcare.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/is_child_receiving_extended_childcare.yaml @@ -1,3 +1,5 @@ +# A whole-age three-year-old starts universal provision in September. Even +# below 15 weekly hours it may receive working-parent-only hours in April. # A supplied family amount (extended_childcare_entitlement as an input) with no # working parent hours calculated for any child counts for every qualifying # child of an eligible age, as before; a supplied zero means nobody receives it. @@ -80,9 +82,9 @@ is_child_receiving_extended_childcare: false # A child is receiving the working parent entitlement when it gets working -# parent hours beyond any universal or targeted hours, so a 3- or 4-year-old -# whose family uses no more than 15 hours a week is on the universal -# entitlement only. +# parent hours beyond any universal or targeted hours in the same terms. +# Universal provision begins after the third birthday, so a transition year +# can include working-parent-only hours before its first universal term. - name: Child age 2 in an eligible family is receiving period: 2025 input: @@ -106,7 +108,7 @@ is_child_receiving_extended_childcare: true is_child_receiving_universal_childcare: true -- name: Child age 3 in an eligible family using 10 hours is on the universal hours only +- name: Child age 3 using 10 hours receives working hours before universal starts period: 2025 input: person: @@ -116,7 +118,7 @@ extended_childcare_entitlement_eligible: true maximum_extended_childcare_hours_usage: 10 output: - is_child_receiving_extended_childcare: false + is_child_receiving_extended_childcare: true is_child_receiving_universal_childcare: true - name: Child age 3 in a family that is not eligible @@ -177,9 +179,7 @@ extended_childcare_entitlement_eligible: true maximum_extended_childcare_hours_usage: 10 output: - # The 1-year-old gets 10 working parent hours; the 3-year-old's 10 hours - # are within its universal 15. - is_child_receiving_extended_childcare: [true, false] + is_child_receiving_extended_childcare: [true, true] is_child_receiving_universal_childcare: [false, true] # Benefit units of different sizes, so person-level and benefit unit-level @@ -209,9 +209,7 @@ household2: members: [child3] output: - # Family 1 as in the case above. Family 2's 4-year-old uses 30 hours: - # 15 universal plus 15 working parent hours. - is_child_receiving_extended_childcare: [true, false, true] + is_child_receiving_extended_childcare: [true, true, true] - name: A supplied family amount counts for each qualifying child, not an older sibling period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/birthday_terms.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/birthday_terms.yaml new file mode 100644 index 0000000000..72d2ed3aba --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/birthday_terms.yaml @@ -0,0 +1,132 @@ +# DfE guidance effective 1 April 2026, A1.2 and A1.4; SI 2014/2147 reg 3. +# https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026 +# https://www.legislation.gov.uk/uksi/2014/2147/regulation/3 +# The existing annual proxy values 190 targeted hours per funded term at +# £8.90 in 2026. It is not a provider fee or a claim about actual term lengths. + +- name: Case 1, a late second birthday starts targeted hours in January. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 1 + date_of_birth: 20241007 + country: ENGLAND + targeted_childcare_entitlement_eligible: true + extended_childcare_entitlement_eligible: false + output: + targeted_childcare_entitlement: 1_691 + universal_childcare_entitlement: 0 + +- name: Case 2, a second birthday on 31 March funds all three terms. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 2 + date_of_birth: 20240331 + country: ENGLAND + targeted_childcare_entitlement_eligible: true + extended_childcare_entitlement_eligible: false + output: + targeted_childcare_entitlement: 5_073 + +- name: Case 3, a second birthday on 1 April waits for September. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 2 + date_of_birth: 20240401 + country: ENGLAND + targeted_childcare_entitlement_eligible: true + extended_childcare_entitlement_eligible: false + output: + targeted_childcare_entitlement: 3_382 + +- name: Case 4, a second birthday on 31 August starts in September. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 2 + date_of_birth: 20240831 + country: ENGLAND + targeted_childcare_entitlement_eligible: true + extended_childcare_entitlement_eligible: false + output: + targeted_childcare_entitlement: 3_382 + +- name: Case 5, a second birthday on 1 September waits for January. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 2 + date_of_birth: 20240901 + country: ENGLAND + targeted_childcare_entitlement_eligible: true + extended_childcare_entitlement_eligible: false + output: + targeted_childcare_entitlement: 1_691 + +- name: Case 6, a second birthday on 31 December funds January. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 1 + date_of_birth: 20241231 + country: ENGLAND + targeted_childcare_entitlement_eligible: true + extended_childcare_entitlement_eligible: false + output: + targeted_childcare_entitlement: 1_691 + +- name: Case 7, a second birthday on 1 January waits until the next model year. + period: 2026 + input: + age: 1 + date_of_birth: 20250101 + country: ENGLAND + targeted_childcare_entitlement_eligible: true + extended_childcare_entitlement_eligible: false + output: + targeted_childcare_entitlement: 0 + +- name: Case 8, a qualifying family route remains necessary. + period: 2026 + input: + age: 1 + date_of_birth: 20241007 + country: ENGLAND + targeted_childcare_entitlement_eligible: false + extended_childcare_entitlement_eligible: false + output: + targeted_childcare_entitlement: 0 + +- name: Case 9, a new targeted term uses the same band as its working parent hours. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 1 + date_of_birth: 20241007 + country: ENGLAND + targeted_childcare_entitlement_eligible: true + extended_childcare_entitlement_eligible: true + would_claim_targeted_childcare: false + output: + # April/September use the under-two proxy, January the targeted band. + extended_childcare_entitlement_total_per_child: 12_532.40 + targeted_childcare_entitlement: 1_691 + extended_childcare_entitlement_per_child: 10_841.40 + +- name: Case 10, targeted and working parent starts share the same two terms in 2024. + period: 2024 + absolute_error_margin: 0.01 + input: + age: 2 + date_of_birth: 20220406 + country: ENGLAND + targeted_childcare_entitlement_eligible: true + extended_childcare_entitlement_eligible: true + would_claim_targeted_childcare: false + output: + # Two common terms * 190 hours * the unchanged 2024 £8.28 proxy. + targeted_childcare_entitlement: 3_146.40 + extended_childcare_entitlement_total_per_child: 3_146.40 + extended_childcare_entitlement_per_child: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/targeted_childcare_entitlement.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/targeted_childcare_entitlement.yaml index cdf663efdf..133bcd1d16 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/targeted_childcare_entitlement.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/targeted_childcare_entitlement.yaml @@ -1,3 +1,6 @@ +# Whole ages imply a 6 April birthday. Targeted provision begins in September +# after the second birthday; after the third birthday it continues through +# the summer term until universal provision starts (DfE guidance A1.4). - name: Child under 2 (ineligible) period: 2024 input: @@ -22,9 +25,9 @@ members: [child] targeted_childcare_entitlement_eligible: True output: - targeted_childcare_entitlement: 4_719.60 # 15 hours * 38 weeks * £8.28 per hour + targeted_childcare_entitlement: 3_146.40 -- name: Child aged 3 (not eligible by age, but has targeting) +- name: Child aged 3 keeps the targeted summer term before universal starts period: 2024 input: people: @@ -35,7 +38,7 @@ members: [child] targeted_childcare_entitlement_eligible: True output: - targeted_childcare_entitlement: 0.0 # Not eligible by age even if targeting criteria met + targeted_childcare_entitlement: 1_573.20 - name: Child using fewer hours than maximum entitlement period: 2024 @@ -49,7 +52,7 @@ members: [child] targeted_childcare_entitlement_eligible: True output: - targeted_childcare_entitlement: 3_146.4 # 10 hours * 38 weeks * £8.28 per hour + targeted_childcare_entitlement: 2_097.60 - name: Child using exactly maximum entitlement period: 2024 @@ -63,7 +66,7 @@ members: [child] targeted_childcare_entitlement_eligible: True output: - targeted_childcare_entitlement: 4_719.6 # 15 hours * 38 weeks * £8.28 per hour + targeted_childcare_entitlement: 3_146.40 - name: Child using more than maximum entitlement (capped) period: 2024 @@ -77,4 +80,4 @@ members: [child] targeted_childcare_entitlement_eligible: True output: - targeted_childcare_entitlement: 4_719.6 # Capped at 15 hours * 38 weeks * £8.28 per hour \ No newline at end of file + targeted_childcare_entitlement: 3_146.40 \ No newline at end of file diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/birthday_terms.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/birthday_terms.yaml new file mode 100644 index 0000000000..1c493e7a5c --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/birthday_terms.yaml @@ -0,0 +1,143 @@ +# DfE guidance effective 1 April 2026, A1.7 and A1.4; SI 2014/2147 reg 3. +# https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026 +# https://www.legislation.gov.uk/uksi/2014/2147/regulation/3 +# Each of the model's three terms has 570 / 3 = 190 base hours. The retained +# 2026 national-average funding proxy is £6.61 for universal hours and £8.90 +# for targeted hours, sampled once for the year, not again in January 2027. + +- name: Case 1, a late third birthday starts universal hours in the spring term. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 2 + date_of_birth: 20231007 + country: ENGLAND + targeted_childcare_entitlement_eligible: true + extended_childcare_entitlement_eligible: false + output: + # Targeted April and September, then universal January: never both in a term. + universal_childcare_entitlement: 1_255.90 + targeted_childcare_entitlement: 3_382 + +- name: Case 2, a third birthday on 31 March funds all three terms. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 3 + date_of_birth: 20230331 + country: ENGLAND + targeted_childcare_entitlement_eligible: true + extended_childcare_entitlement_eligible: false + output: + universal_childcare_entitlement: 3_767.70 + targeted_childcare_entitlement: 0 + +- name: Case 3, a third birthday on 1 April waits for September. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 3 + date_of_birth: 20230401 + country: ENGLAND + targeted_childcare_entitlement_eligible: true + extended_childcare_entitlement_eligible: false + output: + universal_childcare_entitlement: 2_511.80 + targeted_childcare_entitlement: 1_691 + +- name: Case 4, a third birthday on 31 August starts in September. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 3 + date_of_birth: 20230831 + country: ENGLAND + targeted_childcare_entitlement_eligible: true + extended_childcare_entitlement_eligible: false + output: + universal_childcare_entitlement: 2_511.80 + targeted_childcare_entitlement: 1_691 + +- name: Case 5, a third birthday on 1 September waits for January. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 3 + date_of_birth: 20230901 + country: ENGLAND + targeted_childcare_entitlement_eligible: true + extended_childcare_entitlement_eligible: false + output: + universal_childcare_entitlement: 1_255.90 + targeted_childcare_entitlement: 3_382 + +- name: Case 6, a third birthday on 31 December funds January. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 2 + date_of_birth: 20231231 + country: ENGLAND + targeted_childcare_entitlement_eligible: true + extended_childcare_entitlement_eligible: false + output: + universal_childcare_entitlement: 1_255.90 + targeted_childcare_entitlement: 3_382 + +- name: Case 7, a third birthday on 1 January does not fund that same term. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 2 + date_of_birth: 20240101 + country: ENGLAND + targeted_childcare_entitlement_eligible: true + extended_childcare_entitlement_eligible: false + output: + universal_childcare_entitlement: 0 + targeted_childcare_entitlement: 5_073 + +- name: Case 8, first base hours are allocated once within genuine working parent hours. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 2 + date_of_birth: 20231007 + country: ENGLAND + targeted_childcare_entitlement_eligible: true + extended_childcare_entitlement_eligible: true + would_claim_universal_childcare: false + would_claim_targeted_childcare: false + output: + # 380 working hours in each term, at £8.90, £8.90 and £6.61. + extended_childcare_entitlement_total_per_child: 9_275.80 + targeted_childcare_entitlement: 3_382 + universal_childcare_entitlement: 1_255.90 + extended_childcare_entitlement_per_child: 4_637.90 + +- name: Case 9, working parent participation does not duplicate transition hours. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 3 + date_of_birth: 20230831 + country: ENGLAND + targeted_childcare_entitlement_eligible: true + extended_childcare_entitlement_eligible: true + output: + extended_childcare_entitlement_total_per_child: 8_405.60 + targeted_childcare_entitlement: 1_691 + universal_childcare_entitlement: 2_511.80 + extended_childcare_entitlement_per_child: 4_202.80 + +- name: Case 10, universal hours remain an England entitlement. + period: 2026 + input: + age: 2 + date_of_birth: 20231007 + country: WALES + targeted_childcare_entitlement_eligible: false + extended_childcare_entitlement_eligible: false + output: + universal_childcare_entitlement_eligible: false + universal_childcare_entitlement: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.yaml index 73fa700671..4e8157ff24 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.yaml @@ -1,3 +1,5 @@ +# Whole age 3 implies a 6 April birthday: universal hours start in September, +# so two terms receive 190 hours each (or the corresponding usage cap). - name: Child under 3 isn't eligible (person-level) period: 2024 input: @@ -16,7 +18,7 @@ age: 3 universal_childcare_entitlement_eligible: True output: - universal_childcare_entitlement: 3_351.60 + universal_childcare_entitlement: 2_234.40 # A whole age is the middle of the year of age: born on 6 April 2020, so in # reception from September 2024 and funded for the summer term only. @@ -58,7 +60,7 @@ age: 3 universal_childcare_entitlement_eligible: True output: - universal_childcare_entitlement: 3_351.60 + universal_childcare_entitlement: 2_234.40 - name: Child aged 4 (eligible) period: 2024 @@ -89,7 +91,7 @@ universal_childcare_entitlement_eligible: True max_free_entitlement_hours_used: 10 output: - universal_childcare_entitlement: 2_234.4 # 10 hours * 38 weeks * £5.88 per hour + universal_childcare_entitlement: 1_489.60 - name: Child using half of maximum entitlement period: 2024 @@ -100,7 +102,7 @@ universal_childcare_entitlement_eligible: True max_free_entitlement_hours_used: 15 output: - universal_childcare_entitlement: 3_351.6 # 15 hours * 38 weeks * £5.88 per hour + universal_childcare_entitlement: 2_234.40 - name: Child using exactly maximum entitlement period: 2024 @@ -124,7 +126,7 @@ universal_childcare_entitlement_eligible: True max_free_entitlement_hours_used: 20 output: - universal_childcare_entitlement: 3_351.6 # Capped at 15 hours * 38 weeks * £5.88 per hour + universal_childcare_entitlement: 2_234.40 # DfE's statutory guidance (April 2026, footnotes 7 and 17, paras A4.8 and # C.6): a child in a state-funded or council-funded reception class is "not diff --git a/policyengine_uk/tests/policy/integration/entitledto_scenarios.yaml b/policyengine_uk/tests/policy/integration/entitledto_scenarios.yaml index 41c2963f3d..b98348f936 100644 --- a/policyengine_uk/tests/policy/integration/entitledto_scenarios.yaml +++ b/policyengine_uk/tests/policy/integration/entitledto_scenarios.yaml @@ -56,6 +56,9 @@ age: 5 child_2: age: 3 + # Deliberate synthetic birthday, not an externally verified date: + # this regression assumes universal provision for all three terms. + date_of_birth: 20220331 benunits: benunit: members: [adult_1, adult_2, child_1, child_2] @@ -74,8 +77,10 @@ universal_credit: 12_490.86 housing_benefit: 0 child_benefit: 2_251.60 - # The 3yo's 570 universal hours at the 2025 funding rate of £6.12 - # are valued at £3,488.40, up £53.01 from £3,435.39. + # The explicit 31 March birthday qualifies from 1 April, preserving + # this worked fixture's 570-hour assumption at the 2025 £6.12 proxy. + # An unlabelled whole-age household instead retains the 6 April + # fallback and receives only the two terms after its third birthday. universal_childcare_entitlement: [0, 0, 0, 3_488.40] household_net_income: 36_167.91 diff --git a/policyengine_uk/tests/test_childcare_birthday_receiving.py b/policyengine_uk/tests/test_childcare_birthday_receiving.py new file mode 100644 index 0000000000..41d94b4f72 --- /dev/null +++ b/policyengine_uk/tests/test_childcare_birthday_receiving.py @@ -0,0 +1,157 @@ +"""Receiving flags follow positive native awards in actual funded terms. + +DfE guidance effective 1 April 2026 A1.2/A1.4/A1.7 and SI2014/2147 reg3 +allow both annual base awards in different terms of a transition year. +https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026 +https://www.legislation.gov.uk/uksi/2014/2147/regulation/3 + +The existing equal-third annual valuation uses 190 base hours per term at +2026 national-average proxies: £8.90 for targeted and £6.61 for universal. +These tests do not import internal term helpers. Supplied annual amounts or +shares preserve their original snapshot-age receiving predicates. +""" + +from datetime import date + +import pytest + +from policyengine_uk import Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +YEAR = 2026 +U = "universal_childcare_entitlement" +T = "targeted_childcare_entitlement" +RU = "is_child_receiving_universal_childcare" +RT = "is_child_receiving_targeted_childcare" + + +def simulation( + born, *, country="ENGLAND", targeted=True, claim=True, annual=None, reform=None +): + age = YEAR - born.year - ((10, 6) < (born.month, born.day)) + child = {"age": {YEAR: age}, "date_of_birth": {YEAR: int(born.strftime("%Y%m%d"))}} + child.update({name: {YEAR: value} for name, value in (annual or {}).items()}) + return Simulation( + situation={ + "people": {"child": child}, + "benunits": { + "family": { + "members": ["child"], + "targeted_childcare_entitlement_eligible": {YEAR: targeted}, + "extended_childcare_entitlement_eligible": {YEAR: False}, + "would_claim_universal_childcare": {YEAR: claim}, + "would_claim_targeted_childcare": {YEAR: claim}, + } + }, + "households": {"home": {"members": ["child"], "country": {YEAR: country}}}, + }, + reform=reform, + ) + + +def amount(sim, variable): + return float(sim.calculate(variable, YEAR)[0]) + + +def receiving(sim, variable): + return bool(sim.calculate(variable, YEAR)[0]) + + +@pytest.mark.parametrize( + "born,award,flag,expected", + [ + (date(2023, 10, 7), U, RU, 1255.90), + (date(2024, 10, 7), T, RT, 1691.00), + (date(2023, 8, 31), T, RT, 1691.00), + (date(2023, 9, 1), U, RU, 1255.90), + ], +) +def test_receiving_follows_a_positive_native_birthday_term(born, award, flag, expected): + sim = simulation(born) + assert amount(sim, award) == pytest.approx(expected, abs=0.01) + assert receiving(sim, flag) + + +def test_transition_year_receives_both_programmes_in_disjoint_terms(): + sim = simulation(date(2023, 8, 31)) + # Targeted April, universal September and January; not double funding. + assert amount(sim, T) == pytest.approx(1691, abs=0.01) + assert amount(sim, U) == pytest.approx(2511.80, abs=0.01) + assert receiving(sim, RT) + assert receiving(sim, RU) + + +@pytest.mark.parametrize( + "born,award,flag", + [ + (date(2025, 1, 1), T, RT), + (date(2024, 1, 1), U, RU), + ], +) +def test_a_birthday_on_the_last_term_start_does_not_create_receipt(born, award, flag): + sim = simulation(born) + assert amount(sim, award) == 0 + assert not receiving(sim, flag) + + +@pytest.mark.parametrize( + "born,country,targeted", + [ + (date(2023, 10, 7), "WALES", False), + (date(2021, 4, 6), "ENGLAND", True), + ], +) +def test_region_and_school_age_guards_still_prevent_receipt(born, country, targeted): + sim = simulation(born, country=country, targeted=targeted) + assert amount(sim, U) == amount(sim, T) == 0 + assert not receiving(sim, RU) + assert not receiving(sim, RT) + + +@pytest.mark.parametrize( + "born,award,flag", + [ + (date(2023, 10, 7), U, RU), + (date(2023, 8, 31), T, RT), + (date(2021, 4, 6), U, RU), + ], +) +def test_supplied_positive_annual_amount_keeps_original_age_guard(born, award, flag): + sim = simulation(born, annual={award: 1000}) + assert amount(sim, award) == 1000 + assert not receiving(sim, flag) + + +def test_non_third_legacy_share_keeps_snapshot_receiving_semantics(): + sim = simulation( + date(2023, 10, 7), annual={"early_years_term_share_before_school": 0.4} + ) + assert amount(sim, U) == 0 + assert amount(sim, T) == pytest.approx(5073, abs=0.01) + assert not receiving(sim, RU) + assert receiving(sim, RT) + + +def test_independent_universal_reform_does_not_erase_targeted_term_receipt(): + sim = simulation( + date(2023, 8, 31), reform={"gov.dfe.universal_childcare_entitlement.age.min": 4} + ) + assert amount(sim, U) == 0 + assert amount(sim, T) == pytest.approx(1691, abs=0.01) + assert not receiving(sim, RU) + assert receiving(sim, RT) + + +def test_legal_term_eligibility_without_take_up_does_not_create_receipt(): + sim = simulation(date(2023, 8, 31), claim=False) + assert amount(sim, U) == amount(sim, T) == 0 + assert not receiving(sim, RU) + assert not receiving(sim, RT) + + +def test_reformed_universal_upper_age_guard_stays_independent(): + sim = simulation( + date(2023, 10, 7), reform={"gov.dfe.universal_childcare_entitlement.age.max": 2} + ) + assert amount(sim, U) == 0 + assert not receiving(sim, RU) diff --git a/policyengine_uk/tests/test_childcare_birthday_terms.py b/policyengine_uk/tests/test_childcare_birthday_terms.py new file mode 100644 index 0000000000..0224ab89b3 --- /dev/null +++ b/policyengine_uk/tests/test_childcare_birthday_terms.py @@ -0,0 +1,380 @@ +"""England's base hours begin with the term after the qualifying birthday. + +DfE guidance effective 1 April 2026, A1.2/A1.7, and SI 2014/2147 reg 3 +provide the birthday bands. A1.4 retains targeted provision until universal +eligibility begins; A1.11 allocates the first hours within working-parent +provision. The calendar oracle uses no implementation date or term helper. +https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026 +https://www.legislation.gov.uk/uksi/2014/2147/regulation/3 + +The retained annual model uses three equal terms and national-average funding +proxies, not actual provider term lengths or fees. The 2026 rates stay fixed +for all three terms, including January 2027. Supplied legacy annual amounts +or shares retain their original opaque annual calculation, without a claim +that they describe individual terms. +""" + +from datetime import date +from decimal import Decimal + +import pytest + +from policyengine_uk import Simulation +from policyengine_uk.utils.supplied_inputs import set_model_value + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2026 +VALUES = ( + "universal_childcare_entitlement", + "targeted_childcare_entitlement", + "extended_childcare_entitlement_total_per_child", + "extended_childcare_entitlement_per_child", +) +U, T, W, E = VALUES +TERMS = (date(YEAR, 4, 1), date(YEAR, 9, 1), date(YEAR + 1, 1, 1)) +RATES = (Decimal("12.04"), Decimal("8.90"), Decimal("6.61")) + + +def age_on_6_october(born, year=YEAR): + return year - born.year - ((10, 6) < (born.month, born.day)) + + +def birthday(born, years): + try: + return born.replace(year=born.year + years) + except ValueError: + # Either leap-day anniversary falls in the same statutory term band. + return date(born.year + years, 2, 28) + + +def situation( + born=date(2023, 10, 7), + *, + age=None, + targeted=True, + working=False, + claims_universal=True, + claims_targeted=True, + child_hours=30, + family_hours=30, + country="ENGLAND", + deferred=False, + annual_inputs=None, +): + child = { + "age": {YEAR: age_on_6_october(born) if age is None else age}, + "max_free_entitlement_hours_used": {YEAR: child_hours}, + "early_years_school_start_deferred": {YEAR: deferred}, + } + if born is not None: + child["date_of_birth"] = {YEAR: int(born.strftime("%Y%m%d"))} + for variable, value in (annual_inputs or {}).items(): + child[variable] = {YEAR: value} + return { + "people": {"child": child}, + "benunits": { + "family": { + "members": ["child"], + "targeted_childcare_entitlement_eligible": {YEAR: targeted}, + "extended_childcare_entitlement_eligible": {YEAR: working}, + "would_claim_universal_childcare": {YEAR: claims_universal}, + "would_claim_targeted_childcare": {YEAR: claims_targeted}, + "maximum_extended_childcare_hours_usage": {YEAR: family_hours}, + } + }, + "households": {"home": {"members": ["child"], "country": {YEAR: country}}}, + } + + +def simulate(*args, reform=None, **kwargs): + return Simulation(situation=situation(*args, **kwargs), reform=reform) + + +def amounts(simulation): + return { + variable: float(simulation.calculate(variable, YEAR)[0]) for variable in VALUES + } + + +def calendar_reference(born, *, targeted, working, claims_universal, claims_targeted): + """Source rules, term by term, at full usage and the unchanged 2026 proxy.""" + second, third, fourth = (birthday(born, n) for n in (2, 3, 4)) + school = date(fourth.year + (fourth >= date(fourth.year, 9, 1)), 9, 1) + out = {variable: Decimal(0) for variable in VALUES} + universal_terms = [] + for start in TERMS: + before_school = start < school + universal = third < start and before_school + target = targeted and second < start <= third and before_school + universal_terms.append(universal) + rate = RATES[2] if third < start else RATES[1] if second < start else RATES[0] + base = Decimal(190) * rate # 570 / 3, unchanged equal-term convention. + total = Decimal(380) * rate if working and before_school else Decimal(0) + universal_value = ( + base if universal and (claims_universal or total) else Decimal(0) + ) + targeted_value = base if target and (claims_targeted or total) else Decimal(0) + out[U] += universal_value + out[T] += targeted_value + out[W] += total + out[E] += max(total - universal_value - targeted_value, Decimal(0)) + return {k: float(v) for k, v in out.items()}, any(universal_terms) + + +@pytest.mark.parametrize( + "born", + [ + date(2023, 3, 31), + date(2023, 4, 1), + date(2023, 8, 31), + date(2023, 9, 1), + date(2023, 10, 6), + date(2023, 10, 7), + date(2023, 12, 31), + date(2024, 1, 1), + ], +) +@pytest.mark.parametrize("working,claim", [(False, True), (True, True), (True, False)]) +def test_calendar_birthday_terms_and_first_hours_allocation(born, working, claim): + simulation = simulate( + born, working=working, claims_universal=claim, claims_targeted=claim + ) + expected, eligible = calendar_reference( + born, + targeted=True, + working=working, + claims_universal=claim, + claims_targeted=claim, + ) + assert amounts(simulation) == pytest.approx(expected, abs=0.01) + assert ( + bool(simulation.calculate("universal_childcare_entitlement_eligible", YEAR)[0]) + is eligible + ) + + +def test_a_new_targeted_term_uses_its_qualifying_band(): + simulation = simulate(date(2024, 10, 7), working=True, claims_targeted=False) + # SOURCE: 190 targeted hours in January at £8.90; working hours use + # £12.04 in April/September and £8.90 in January, not an age-1 rate all year. + assert amounts(simulation) == pytest.approx( + {U: 0, T: 1691, W: 12532.40, E: 10841.40}, abs=0.01 + ) + + +def test_no_targeted_route_does_not_erase_universal_or_working_hours(): + simulation = simulate(date(2023, 8, 31), targeted=False, working=True) + assert amounts(simulation) == pytest.approx( + {U: 2511.80, T: 0, W: 8405.60, E: 5893.80}, abs=0.01 + ) + + +@pytest.mark.parametrize("claim", [False, True]) +def test_a_later_working_minimum_counts_only_common_terms(claim): + # Hypothetical age reform: universal September+January, working January + # only. Do not multiply the already prorated 2/3 base by a 1/3 share. + simulation = simulate( + date(2023, 4, 30), + targeted=False, + working=True, + claims_universal=claim, + reform={"gov.dfe.extended_childcare_entitlement.young_child_minimum_age": 3.5}, + ) + expected_universal = 2511.80 if claim else 1255.90 + assert amounts(simulation) == pytest.approx( + {U: expected_universal, T: 0, W: 2511.80, E: 1255.90}, abs=0.01 + ) + + +def test_independent_age_reforms_do_not_bridge_a_gap(): + # The targeted scale still ends at its own third-birthday boundary. + # Raising universal's minimum to four must not extend targeted provision. + simulation = simulate( + date(2023, 4, 30), reform={"gov.dfe.universal_childcare_entitlement.age.min": 4} + ) + assert amounts(simulation)[U] == 0 + assert amounts(simulation)[T] == pytest.approx(1691, abs=0.01) + + +def test_whole_age_fallback_matches_the_existing_six_april_birthday(): + implicit = simulate(None, age=3, working=True) + explicit = simulate(date(2023, 4, 6), working=True) + assert amounts(implicit) == pytest.approx(amounts(explicit), abs=0.01) + assert amounts(implicit) == pytest.approx( + {U: 2511.80, T: 1691, W: 8405.60, E: 4202.80}, abs=0.01 + ) + + +@pytest.mark.parametrize("deferred,universal", [(False, 1255.90), (True, 3767.70)]) +def test_the_existing_school_and_deferred_guards_remain(deferred, universal): + simulation = simulate( + date(2022, 8, 31), targeted=False, working=True, deferred=deferred + ) + actual = amounts(simulation) + assert actual[U] == pytest.approx(universal, abs=0.01) + assert actual[E] == pytest.approx(universal, abs=0.01) + assert actual[W] == pytest.approx(2 * universal, abs=0.01) + + +@pytest.mark.parametrize("which", ["child", "family"]) +def test_usage_caps_do_not_remove_standalone_base_hours(which): + simulation = simulate( + date(2023, 8, 31), + working=True, + child_hours=10 if which == "child" else 30, + family_hours=10 if which == "family" else 30, + ) + actual = amounts(simulation) + base = 4202.80 * (2 / 3 if which == "child" else 1) + assert actual[U] + actual[T] == pytest.approx(base, abs=0.01) + assert actual[E] == 0 + + +@pytest.mark.parametrize( + "name,value,expected", + [ + ("early_years_term_share_before_school", 0, {U: 0, T: 0, W: 0, E: 0}), + ( + "early_years_term_share_before_school", + 0.5, + {U: 1883.85, T: 0, W: 3767.70, E: 1883.85}, + ), + ( + "early_years_term_share_before_school", + 0.4, + {U: 1507.08, T: 0, W: 3014.16, E: 1507.08}, + ), + ( + "early_years_term_share_before_school", + 1, + {U: 3767.70, T: 0, W: 7535.40, E: 3767.70}, + ), + ( + "extended_childcare_entitlement_term_share", + 0, + {U: 3767.70, T: 0, W: 0, E: 0}, + ), + ( + "extended_childcare_entitlement_term_share", + 0.5, + {U: 3767.70, T: 0, W: 3767.70, E: 1883.85}, + ), + ], +) +def test_explicit_legacy_shares_keep_the_original_annual_route(name, value, expected): + simulation = simulate( + date(2023, 4, 6), targeted=False, working=True, annual_inputs={name: value} + ) + assert amounts(simulation) == pytest.approx(expected, abs=0.01) + + +@pytest.mark.parametrize( + "name,value,expected", + [ + (U, 0, {U: 0, T: 5073, W: 10146, E: 5073}), + (T, 0, {U: 0, T: 0, W: 10146, E: 10146}), + (W, 0, {U: 0, T: 5073, W: 0, E: 0}), + (U, 1000, {U: 1000, T: 5073, W: 10146, E: 4073}), + ], +) +def test_supplied_annual_amounts_remain_authoritative(name, value, expected): + simulation = simulate(working=True, annual_inputs={name: value}) + assert amounts(simulation) == pytest.approx(expected, abs=0.01) + + +def clear_derived_amounts(simulation, *, keep=()): + for variable in (*VALUES, "universal_childcare_entitlement_eligible"): + if variable not in keep: + simulation.delete_arrays(variable, YEAR) + + +def test_a_warm_calculated_cache_is_not_an_annual_input(): + simulation = simulate(working=True) + first = amounts(simulation) + for variable in reversed(VALUES): + simulation.calculate(variable, YEAR) + assert amounts(simulation) == first + assert first == pytest.approx( + {U: 1255.90, T: 3382, W: 9275.80, E: 4637.90}, abs=0.01 + ) + + +def test_an_equal_entered_value_is_still_authoritative_after_a_warm_cache(): + simulation = simulate(working=True) + native_universal = simulation.calculate(U, YEAR).copy() + simulation.set_input(U, YEAR, native_universal) + clear_derived_amounts(simulation, keep=(U,)) + # Genuine input detection, not equality with a previously calculated array, + # selects the documented whole legacy route and its snapshot eligibility. + assert amounts(simulation)[W] == pytest.approx(10146, abs=0.01) + assert not bool( + simulation.calculate("universal_childcare_entitlement_eligible", YEAR)[0] + ) + + +def test_deleting_an_entered_amount_restores_the_native_route(): + simulation = simulate(working=True, annual_inputs={U: 0}) + assert amounts(simulation)[W] == pytest.approx(10146, abs=0.01) + clear_derived_amounts(simulation) + assert amounts(simulation)[W] == pytest.approx(9275.80, abs=0.01) + + +def test_replacing_an_entered_amount_keeps_the_authoritative_route(): + simulation = simulate(working=True, annual_inputs={U: 0}) + amounts(simulation) + simulation.set_input(U, YEAR, [1000]) + clear_derived_amounts(simulation, keep=(U,)) + assert amounts(simulation) == pytest.approx( + {U: 1000, T: 5073, W: 10146, E: 4073}, abs=0.01 + ) + + +def test_a_partial_person_input_keeps_core_defaults_and_annual_semantics(): + data = situation(working=True, annual_inputs={U: 0}) + other = { + key: value.copy() for key, value in data["people"]["child"].items() if key != U + } + data["people"]["other"] = other + data["benunits"]["family"]["members"].append("other") + data["households"]["home"]["members"].append("other") + simulation = Simulation(situation=data) + # Core supplies the variable's default zero for the other person in the + # same input array. Do not infer per-person term identities from that zero. + assert list(simulation.calculate(U, YEAR)) == [0, 0] + assert list(simulation.calculate(W, YEAR)) == pytest.approx( + [10146, 10146], abs=0.01 + ) + assert list(simulation.calculate(E, YEAR)) == pytest.approx([5073, 5073], abs=0.01) + + +def test_rebuilding_without_the_annual_input_does_not_keep_legacy_provenance(): + simulation = simulate(working=True, annual_inputs={U: 0}) + amounts(simulation) + simulation.build_from_situation(situation(working=True)) + assert amounts(simulation) == pytest.approx( + {U: 1255.90, T: 3382, W: 9275.80, E: 4637.90}, abs=0.01 + ) + + +@pytest.mark.parametrize("kind", ["clone", "branch"]) +def test_an_entered_amount_in_a_copy_does_not_change_its_parent(kind): + simulation = simulate(working=True) + original = amounts(simulation) + copied = ( + simulation.clone() + if kind == "clone" + else simulation.get_branch("entered_childcare") + ) + copied.set_input(U, YEAR, [0]) + clear_derived_amounts(copied, keep=(U,)) + assert amounts(copied)[W] == pytest.approx(10146, abs=0.01) + assert amounts(simulation) == original + + +def test_a_model_stored_value_is_not_misread_as_a_supplied_amount(): + simulation = simulate(working=True) + native = simulation.calculate(U, YEAR).copy() + set_model_value(simulation, U, YEAR, native) + clear_derived_amounts(simulation, keep=(U,)) + assert amounts(simulation)[W] == pytest.approx(9275.80, abs=0.01) diff --git a/policyengine_uk/tests/test_childcare_entitlement_floor_properties.py b/policyengine_uk/tests/test_childcare_entitlement_floor_properties.py index d68fc70948..3add29cbbb 100644 --- a/policyengine_uk/tests/test_childcare_entitlement_floor_properties.py +++ b/policyengine_uk/tests/test_childcare_entitlement_floor_properties.py @@ -46,8 +46,9 @@ from September 2024 (SI 2023/1330 reg 2). No child gets more hours than it uses. These bounds are written from the law, not read from the model's parameters; annual simulations still evaluate the schedule at January. -9. No child gets both universal and targeted hours, so its universal or - targeted floor is at most 15 hours a week. +9. No term gets both universal and targeted hours at baseline. A child can + receive both programmes in different terms of a transition year; its base + floor is still at most 15 hours in each week (guidance A1.4). 10. Outside England no child gets universal or working parent hours (Childcare Act 2006 s.7 and 2016 s.1(2)(b) are duties for England). 11. No universal or working parent hours in the terms after a child starts @@ -231,74 +232,118 @@ def split(values, families, n_copies): return out -def reference(c, f, year, eligible): - """Each child's funded value, written from s.1(6) and para A1.11.""" +def reference_terms(c, f, year, eligible): + """Independent calendar allocation, without implementation term helpers. + + A1.4 preserves targeted provision through the term before universal + provision begins. The same annual funding-year proxy values each term's + qualifying age group; January does not resample the following year's rate. + """ p = system.parameters(f"{year}-01-01").gov.dfe - age = c["age"] - rate = float(p.childcare_funding_rate.calc(age)) - weeks = p.weeks_per_year - under_school_age = age < 5 - in_england = f["england"] - # The terms before the child starts school: none of these hours after it. - school = before_school(c, year) - universal_hours = ( - min(c["hours_used"] * weeks, p.universal_childcare_entitlement.hours) - if in_england and 3 <= age < 5 and school > 0 - else 0 - ) - targeted_hours = ( - min( - c["hours_used"] * weeks, - p.targeted_childcare_entitlement.hours_entitlement - * float(p.targeted_childcare_entitlement.age_eligibility.calc(age)), - ) - if f["targeted"] - else 0 - ) - universal_value = universal_hours * rate * school - targeted_value = targeted_hours * rate - floor = ( - universal_value * f["claims_universal"] + targeted_value * f["claims_targeted"] + born = born_on(c["age"], year) + starts = year_terms(year) + school_count = round(3 * before_school(c, year)) + minimum = p.extended_childcare_entitlement.young_child_minimum_age + working_birthday = months_later(born, round(12 * minimum)) + universal_birthday = months_later( + born, round(12 * p.universal_childcare_entitlement.age.min) ) - minimum_age = p.extended_childcare_entitlement.young_child_minimum_age - share = term_share(age, year, minimum_age) - qualifying = ( - in_england - and under_school_age - and not c["looked_after"] - and school > 0 - and share > 0 + + def calendar_scale(scale, start): + amount = 0 + for threshold, next_amount in zip(scale.thresholds, scale.amounts): + if threshold == -math.inf or ( + math.isfinite(threshold) + and months_later(born, round(12 * threshold)) < start + ): + amount = float(next_amount) + return amount + + weekly_working = min( + float(p.extended_childcare_entitlement.hours.calc(max(c["age"], minimum))), + c["hours_used"], + f["cap"], ) - weekly = ( - min( - # The hours for its age, or for the minimum age if it reaches - # that later in the year. - float(p.extended_childcare_entitlement.hours.calc(max(age, minimum_age))), - c["hours_used"], - f["cap"], + rows = [] + for index, start in enumerate(starts): + school = index < school_count + universal = ( + f["england"] + and c["age"] < p.universal_childcare_entitlement.age.max + and universal_birthday < start + and school ) - if qualifying - else 0 - ) - # A child starting school is years past the minimum age, so the terms it - # is old enough for include every term before it starts school. - working_parent_total = weekly * weeks * rate * school if eligible else 0 - # In the terms the child is old enough for: the larger of the claimed - # standalone value and the working parent total, with legal eligibility, - # independent of standalone take-up, determining which programme funds - # the first hours. In the other terms: the claimed standalone value. - participating = max(floor, working_parent_total) - total = share * participating + (1 - share) * floor + targeted = ( + f["targeted"] + and bool( + calendar_scale(p.targeted_childcare_entitlement.age_eligibility, start) + ) + and school + ) + working = ( + eligible + and f["england"] + and c["age"] < 5 + and not c["looked_after"] + and school + and working_birthday < start + ) + u = ( + min( + c["hours_used"] * p.weeks_per_year, + p.universal_childcare_entitlement.hours, + ) + / 3 + if universal + else 0 + ) + t = ( + min( + c["hours_used"] * p.weeks_per_year, + p.targeted_childcare_entitlement.hours_entitlement, + ) + / 3 + if targeted + else 0 + ) + w = weekly_working * p.weeks_per_year / 3 if working else 0 + rate = calendar_scale(p.childcare_funding_rate, start) + # At baseline the two base programmes occupy disjoint terms; neither + # is multiplied by a second annual working-parent share. + assert not (u > 0 and t > 0), (c, f, year, start) + allocations = { + "universal_childcare_entitlement": ( + u if f["claims_universal"] else min(u, max(w - min(t, w), 0)) + ) + * rate, + "targeted_childcare_entitlement": (t if f["claims_targeted"] else min(t, w)) + * rate, + "extended_childcare_entitlement_per_child": max(w - u - t, 0) * rate, + } + floor = (u * f["claims_universal"] + t * f["claims_targeted"]) * rate + rows.append( + { + "school": school, + "universal": universal, + "rate": rate, + "floor": floor, + "total": max(floor, w * rate), + "allocations": allocations, + } + ) + return rows + + +def reference(c, f, year, eligible): + """Each child's funded value, independently allocated in common terms.""" + rows = reference_terms(c, f, year, eligible) + floor = sum(row["floor"] for row in rows) + total = sum(row["total"] for row in rows) allocations = { - "universal_childcare_entitlement": share * min(universal_value, participating) - + (1 - share) * universal_value * f["claims_universal"], - "targeted_childcare_entitlement": share * min(targeted_value, participating) - + (1 - share) * targeted_value * f["claims_targeted"], + variable: sum(row["allocations"][variable] for row in rows) + for variable in ENTITLEMENTS } - allocations["extended_childcare_entitlement_per_child"] = total - sum( - allocations.values() - ) - return floor, total, participating, allocations + return floor, total, total, allocations def funded(child_values): @@ -323,35 +368,41 @@ def assert_statutory_maximum(c, f, instant, eligible, hours): def assert_statutory_bounds(c, f, instant, eligible, child_values): - """Invariants 8 to 10 for one child, eligible or not.""" - rate = float( - system.parameters(instant).gov.dfe.childcare_funding_rate.calc(c["age"]) - ) - hours = funded(child_values) / rate - context = (instant, f, c, eligible, child_values, hours) - # 8. At most 30 hours a week over 38 weeks; at most the universal or - # targeted 570 hours without the working parent hours; and never more - # hours than the child uses. Under-threes have a separate 15-hour limit - # before September 2025, independent of the model's hours schedule. - assert_statutory_maximum(c, f, instant, eligible, hours) - # 9. Never both universal and targeted hours. - assert not ( - child_values["universal_childcare_entitlement"] > 0 - and child_values["targeted_childcare_entitlement"] > 0 - ), context - # 10. No universal or working parent hours outside England. (These tests - # supply targeted eligibility as an input, which bypasses its own England - # condition.) + """Actual award bounds valued at each source-defined term's age band. + + Dividing an annual award by one snapshot rate would invent an hours + measure when a birthday changes its funding band during the year. + """ + rows = reference_terms(c, f, int(instant[:4]), eligible) + annual_limit = statutory_max_hours(c, instant, eligible) + hourly_limit = min(annual_limit, WEEKS * max(c["hours_used"], 0)) / 3 + maximum_value = sum(row["rate"] * hourly_limit for row in rows if row["school"]) + context = (instant, f, c, eligible, child_values) + assert funded(child_values) <= maximum_value + 0.02, context + # Baseline base programmes cannot overlap in a term. Positive annual + # values in both are permitted only when the independent source calendar + # supplies the corresponding disjoint allocations. + for variable in ENTITLEMENTS[:2]: + ceiling = sum(row["allocations"][variable] for row in rows) + assert child_values[variable] <= ceiling + 0.02, context if not f["england"]: assert child_values["universal_childcare_entitlement"] == 0, context assert child_values["extended_childcare_entitlement_per_child"] == 0, context - # Legally universal children can have only the additional 15 weekly - # hours classified as extended, irrespective of standalone take-up. - if f["england"] and 3 <= c["age"] < 5: - extended_hours = child_values["extended_childcare_entitlement_per_child"] / rate - assert extended_hours <= UNIVERSAL_OR_TARGETED_MAX_HOURS + HOURS_TOLERANCE, ( - context + # Only the extra 15 weekly hours can be extended in a universal term; + # other terms may have the full working-parent allowance. + extra_maximum = sum( + row["rate"] + * max( + annual_limit / 3 + - (UNIVERSAL_OR_TARGETED_MAX_HOURS / 3 if row["universal"] else 0), + 0, ) + for row in rows + if row["school"] + ) + assert ( + child_values["extended_childcare_entitlement_per_child"] <= extra_maximum + 0.02 + ), context @pytest.mark.parametrize( @@ -548,25 +599,29 @@ def test_becoming_eligible_never_lowers_childcare_support(year, families): assert close(copy[0]["family_extended"], per_child), (year, f, copy) for c, b, a in zip(f["children"], before, after): assert funded(a) >= funded(b) - 0.02, (year, f, c, b, a) - school = before_school(c, year) - legal_universal = f["england"] and 3 <= c["age"] < 5 and school > 0 + calendar = reference_terms(c, f, year, True) + legal_universal = any(row["universal"] for row in calendar) assert a["universal_eligible"] == b["universal_eligible"] == legal_universal - # 11. A school place, not funded hours, once the child starts - # school: at most the terms before it. - rate = float( - system.parameters(f"{year}-01-01").gov.dfe.childcare_funding_rate.calc( - c["age"] - ) - ) + # 11. A school place meets the duty after reception starts. Value + # the bound term by term rather than divide by a snapshot rate. for copy in (a, b): - universal_hours = copy["universal_childcare_entitlement"] / rate - assert universal_hours <= ( - school * UNIVERSAL_OR_TARGETED_MAX_HOURS + HOURS_TOLERANCE + universal_bound = sum( + row["rate"] * UNIVERSAL_OR_TARGETED_MAX_HOURS / 3 + for row in calendar + if row["universal"] + ) + school_bound = sum( + row["rate"] * WORKING_PARENT_MAX_HOURS / 3 + for row in calendar + if row["school"] + ) + assert ( + copy["universal_childcare_entitlement"] <= universal_bound + 0.02 ), (year, f, c, copy) assert ( - universal_hours - + copy["extended_childcare_entitlement_per_child"] / rate - <= school * WORKING_PARENT_MAX_HOURS + HOURS_TOLERANCE + copy["universal_childcare_entitlement"] + + copy["extended_childcare_entitlement_per_child"] + <= school_bound + 0.02 ), (year, f, c, copy) # 8-10. Statutory maximum, no double floor, England only. assert_statutory_bounds(c, f, f"{year}-01-01", False, b) @@ -624,7 +679,9 @@ def test_more_hours_used_never_lowers_childcare_support(year, families, bump): assert funded(b) >= funded(a) - 0.02, (year, f, c, bump, a, b) # 8. However many hours are used, never above 30 a week. used = {**c, "hours_used": c["hours_used"] + hours_bump} - assert_statutory_bounds(used, f, f"{year}-01-01", True, b) + assert_statutory_bounds( + used, {**f, "cap": f["cap"] + cap_bump}, f"{year}-01-01", True, b + ) # The real eligibility route: a working couple with a child, one parent's diff --git a/policyengine_uk/tests/test_childcare_funding_rates.py b/policyengine_uk/tests/test_childcare_funding_rates.py index 212064b47e..085c425236 100644 --- a/policyengine_uk/tests/test_childcare_funding_rates.py +++ b/policyengine_uk/tests/test_childcare_funding_rates.py @@ -72,8 +72,8 @@ def test_april_anchors_reach_processed_annual_rates(year): @pytest.mark.parametrize( "year,universal,extended_per_child,extended_total", [ - (2025, [0, 0, 0, 3488.40], [0, 6577.80, 4862.10, 3488.40], 14928.30), - (2026, [0, 0, 0, 3767.70], [0, 13725.60, 10146.00, 3767.70], 27639.30), + (2025, [0, 0, 0, 2325.60], [0, 6577.80, 5434.00, 5567.00], 17578.80), + (2026, [0, 0, 0, 2511.80], [0, 13725.60, 11339.20, 5893.80], 30958.60), ], ) def test_published_rates_reach_genuine_working_family_awards( @@ -81,7 +81,9 @@ def test_published_rates_reach_genuine_working_family_awards( ): sim = Simulation(situation=working_family(year)) # 2025 retains the existing 15-hour under-three annual schedule; 2026 - # gives 30. A three-year-old gets 570 universal hours plus 570 extended. + # gives 30. Whole ages imply 6 April birthdays: the age-three child + # starts universal provision in September, and age-two funding begins + # after the second-birthday term. Each common term uses one age-band rate. np.testing.assert_allclose( sim.calculate("universal_childcare_entitlement", year), universal, @@ -102,12 +104,13 @@ def test_published_rates_reach_genuine_working_family_awards( ) -@pytest.mark.parametrize("year,expected", [(2025, 4862.10), (2026, 5073.00)]) +@pytest.mark.parametrize("year,expected", [(2025, 3241.40), (2026, 3382.00)]) def test_published_rates_reach_targeted_award_for_reported_qualifying_benefit( year, expected ): # An ordinary reported Income Support award is an existing qualifying # benefit input. Eligibility and the childcare amount are calculated. + # Born 6 April, the child receives two targeted terms from September. sim = Simulation( situation={ "people": {"parent": {"age": {year: 35}}, "child": {"age": {year: 2}}}, diff --git a/policyengine_uk/utils/aggregate_impact/guard.py b/policyengine_uk/utils/aggregate_impact/guard.py index 67e05c854d..eaf9514024 100644 --- a/policyengine_uk/utils/aggregate_impact/guard.py +++ b/policyengine_uk/utils/aggregate_impact/guard.py @@ -84,15 +84,68 @@ def discard_formula_results(sim) -> bool: return True +class _InputSnapshot(dict): + """Original arrays with guard-private provenance; mapping entries stay arrays.""" + + def __init__(self, values, *, owner, origins): + super().__init__(values) + self.owner = owner + self.origins = origins + + +def _visible_branches(sim): + branches = [] + branch = sim + while branch is not None: + if branch.branch_name not in branches: + branches.append(branch.branch_name) + branch = getattr(branch, "parent_branch", None) + if "default" not in branches: + branches.append("default") + return branches + + +def _capture_input_origins(sim, variables): + """Copy actual registered input slots before any synthetic write.""" + from policyengine_uk.utils.supplied_inputs import SUPPLIED_INPUT_VARIABLES + + names = set(variables) & SUPPLIED_INPUT_VARIABLES + if not names: + return {} + branches = set(_visible_branches(sim)) + keys = set(getattr(sim, "_user_input_keys", None) or ()) + model = getattr(sim, "_model_values", None) or {} + origins = {} + for name in names: + if name not in sim.tax_benefit_system.variables: + continue + holder = sim.get_holder(name) + slots = [] + for key in keys | set(model): + variable, branch, period = key + if variable != name or branch not in branches: + continue + value = holder._get_array_from_storage(period, branch) + if value is None: + continue + marker = model.get(key) + is_model = marker is not None and np.array_equal(marker, value) + if key in keys or is_model: + slots.append((branch, period, np.array(value, copy=True), is_model)) + origins[name] = tuple(slots) + return origins + + def snapshot_inputs(sim, variables) -> dict: - """Copy the supplied input arrays of ``variables``. + """Copy original input arrays and registered origin/branch placement. - Formula results are discarded first: a holder's known periods include - calculated values, and restoring one of those as an input would freeze - it for every later perturbation. + Formula caches are discarded first. The provenance sidecar is an attribute, + not a mapping entry, so existing callers still receive variable/array pairs. """ + variables = tuple(variables) discard_formula_results(sim) - snapshot = {} + origins = _capture_input_origins(sim, variables) + snapshot = _InputSnapshot({}, owner=sim, origins=origins) for variable in variables: if variable not in sim.tax_benefit_system.variables: continue @@ -106,10 +159,73 @@ def snapshot_inputs(sim, variables) -> dict: return snapshot +def _guard_snapshot(sim, snapshot, variables): + if snapshot is None: + return snapshot_inputs(sim, variables) + if isinstance(snapshot, _InputSnapshot): + if snapshot.owner is not sim: + raise ValueError("Input snapshot belongs to a different simulation") + return snapshot + from policyengine_uk.utils.supplied_inputs import SUPPLIED_INPUT_VARIABLES + + origins = _capture_input_origins(sim, variables) + # Plain mappings remain supported when their registered values agree with + # actual original storage. Never infer origin from a post-probe cache. + for name in set(snapshot) & SUPPLIED_INPUT_VARIABLES: + for period, value in snapshot[name]: + if not any( + saved_period == period and np.array_equal(saved_value, value) + for _, saved_period, saved_value, _ in origins.get(name, ()) + ): + raise ValueError("Plain input snapshot has no matching original slot") + return _InputSnapshot(snapshot, owner=sim, origins=origins) + + +def _set_guard_value(sim, variable, period, value, touched=None): + """A synthetic value is not a new user declaration of a legacy input.""" + from policyengine_uk.utils.supplied_inputs import ( + SUPPLIED_INPUT_VARIABLES, + set_model_value, + ) + + if touched is not None: + touched.add(variable) + if variable in SUPPLIED_INPUT_VARIABLES: + set_model_value(sim, variable, period, value) + else: + # Keep other UK provenance hooks, including CGT, on their existing path. + sim.set_input(variable, period, value) + + def _restore(sim, variable, snapshot): - sim.delete_arrays(variable) - for period, values in snapshot.get(variable, []): - sim.set_input(variable, period, values) + from policyengine_uk.utils.supplied_inputs import ( + SUPPLIED_INPUT_VARIABLES, + drop_missing_model_values, + drop_missing_supplied_inputs, + set_model_value, + ) + + if variable not in SUPPLIED_INPUT_VARIABLES: + sim.delete_arrays(variable) + for period, values in snapshot.get(variable, []): + sim.set_input(variable, period, values) + return + if not isinstance(snapshot, _InputSnapshot) or snapshot.owner is not sim: + raise ValueError("Registered restoration needs its original input snapshot") + if variable not in snapshot.origins: + raise ValueError("Registered variable is absent from original origin capture") + holder = sim.get_holder(variable) + # Simulation.delete_arrays would purge visible ancestor/default branches in + # this holder too. Only remove the current branch's synthetic slots here. + holder.delete_arrays(branch_name=sim.branch_name) + drop_missing_supplied_inputs(sim, variable) + drop_missing_model_values(sim, variable) + for branch, period, value, is_model in snapshot.origins[variable]: + if branch == sim.branch_name: + if is_model: + set_model_value(sim, variable, period, value) + else: + sim.set_input(variable, period, value) def _months(year): @@ -304,7 +420,9 @@ def containment(head): } -def _perturb(sim, variable, year, baseline, added, monthly, hold=(), held_at=None): +def _perturb( + sim, variable, year, baseline, added, monthly, hold=(), held_at=None, touched=None +): """Add ``added`` to ``variable``, holding each of ``hold`` fixed. Held variables keep their values in ``held_at`` (by default the @@ -314,16 +432,18 @@ def _perturb(sim, variable, year, baseline, added, monthly, hold=(), held_at=Non discard_formula_results(sim) if monthly: for month, values in baseline[variable]: - sim.set_input(variable, month, values + added / 12) + _set_guard_value(sim, variable, month, values + added / 12, touched=touched) else: - sim.set_input(variable, year, baseline[variable] + added) + _set_guard_value( + sim, variable, year, baseline[variable] + added, touched=touched + ) held_at = baseline if held_at is None else held_at for held in hold: if isinstance(held_at[held], list): for month, values in held_at[held]: - sim.set_input(held, month, values) + _set_guard_value(sim, held, month, values, touched=touched) else: - sim.set_input(held, year, held_at[held]) + _set_guard_value(sim, held, year, held_at[held], touched=touched) def _capture(sim, variables, year, layout) -> dict: @@ -392,7 +512,7 @@ def counted_once( "status": "skipped", "reason": "this policyengine-core has no way to discard formula results", } - snapshot = snapshot or {} + snapshot = _guard_snapshot(sim, snapshot, partition.component_variables()) active = set(active) definitions = sim.tax_benefit_system.variables totals = [t for t in partition.totals if partition.heads_in(t, active)] @@ -417,274 +537,309 @@ def counted_once( head_by_variable = {h.variable: h for h in partition.heads} selected = None if variables_to_check is None else set(variables_to_check) results, double_count_results = [], [] - for variable in partition.component_variables(): - if selected is not None and variable not in selected: - continue - definition = definitions[variable] - entity = definition.entity.key - in_totals = [t for t in totals if variable in partition.components(t, active)] - record = { - "variable": variable, - "totals": in_totals, - "coefficients": {}, - "model_coefficients": {}, - "responses": {}, - "problems": [], - "mediated": [], - "units": 0, - "status": "pass", - "detail": "", - } - if definition.value_type is not float: - record.update(status="skipped", detail="not a money variable") - results.append(record) - continue - monthly = definition.definition_period == "month" - year_values = ( - sum(values for _, values in baseline[variable]) - if monthly - else baseline[variable] - ) - nonzero = year_values != 0 - zero_baseline = not bool(nonzero.any()) - mask = np.ones_like(nonzero) if zero_baseline else nonzero - added = mask.astype(np.float64) - expected = _household(sim, added, entity) - # Perturbed units in households with weight: a zero-weight record - # adds nothing to any published figure. - record["units"] = int(round(float(np.sum(expected[hw > 0])))) - if not float(np.sum(hw * expected)) > 0: - record.update( - status="skipped", - detail="non-zero only in households with no weight", - ) - results.append(record) - continue - own = declared[variable].contained_in if variable in declared else variable - own_head = head_by_variable[own] - needed = list(dict.fromkeys(v for t in in_totals for v in heads_of[t])) - readers_of = { - total: [ - v for v in heads_of[total] if own_head.id in head_by_variable[v].reads + touched = set() + try: + for variable in partition.component_variables(): + if selected is not None and variable not in selected: + continue + definition = definitions[variable] + entity = definition.entity.key + in_totals = [ + t for t in totals if variable in partition.components(t, active) ] - for total in in_totals - } - - _perturb(sim, variable, year, baseline, added, monthly) - heads1 = _head_arrays(sim, year, needed) - model1 = { - t: _raw(sim, t, year).astype(np.float64) for t in in_totals if t in model0 - } - # A head that may respond (a declared reader, or any benefit when the - # component was zero for everyone) is checked for containment at a - # second point, which tells a slope of 1 from a step. - may_respond = { - v - for total in in_totals - for v in heads_of[total] - if v != own - and ( - v in readers_of[total] - or (zero_baseline and not partition.is_tax_total(total)) - ) - } - heads2 = None - if may_respond: - _perturb(sim, variable, year, baseline, 2 * added, monthly) - heads2 = _head_arrays(sim, year, sorted(may_respond)) - problems, screens = [], [] - for total in in_totals: - verdict = judge_total( - total=total, - is_tax=partition.is_tax_total(total), - component=variable, - own_variable=own, - declared_double_count=variable in declared - and total in declared[variable].totals, - zero_baseline=zero_baseline, - weights=hw, - expected=expected, - head_changes={v: heads1[v] - heads0[v] for v in heads_of[total]}, - head_scales={v: heads0[v] for v in heads_of[total]}, - model_change=( - model1[total] - model0[total] if total in model1 else None - ), - model_scale=model0.get(total), - allowed_readers=readers_of[total], - second_changes=None - if heads2 is None - else {v: heads2[v] - heads0[v] for v in heads2}, + record = { + "variable": variable, + "totals": in_totals, + "coefficients": {}, + "model_coefficients": {}, + "responses": {}, + "problems": [], + "mediated": [], + "units": 0, + "status": "pass", + "detail": "", + } + if definition.value_type is not float: + record.update(status="skipped", detail="not a money variable") + results.append(record) + continue + monthly = definition.definition_period == "month" + year_values = ( + sum(values for _, values in baseline[variable]) + if monthly + else baseline[variable] ) - record["coefficients"][total] = verdict["coefficient"] - if verdict["model_coefficient"] is not None: - record["model_coefficients"][total] = verdict["model_coefficient"] - record["responses"][total] = { - v: r for v, r in verdict["responses"].items() if abs(r) > tolerance + nonzero = year_values != 0 + zero_baseline = not bool(nonzero.any()) + mask = np.ones_like(nonzero) if zero_baseline else nonzero + added = mask.astype(np.float64) + expected = _household(sim, added, entity) + # Perturbed units in households with weight: a zero-weight record + # adds nothing to any published figure. + record["units"] = int(round(float(np.sum(expected[hw > 0])))) + if not float(np.sum(hw * expected)) > 0: + record.update( + status="skipped", + detail="non-zero only in households with no weight", + ) + results.append(record) + continue + own = declared[variable].contained_in if variable in declared else variable + own_head = head_by_variable[own] + needed = list(dict.fromkeys(v for t in in_totals for v in heads_of[t])) + readers_of = { + total: [ + v + for v in heads_of[total] + if own_head.id in head_by_variable[v].reads + ] + for total in in_totals + } + + _perturb(sim, variable, year, baseline, added, monthly, touched=touched) + heads1 = _head_arrays(sim, year, needed) + model1 = { + t: _raw(sim, t, year).astype(np.float64) + for t in in_totals + if t in model0 } - problems += verdict["problems"] - screens += verdict["screens"] - # A declared double count whose container no longer moves is - # reported once, as a stale declaration, below. - if verdict["own_problem"] and variable not in declared: - problems.append(verdict["own_problem"]) - # Measure each head that broke a rule again with every other head - # held at its baseline value: a response carried by another head - # (the head reading that head's change) is not containment. - mediated = [] - perturbed_units = expected > 0 - for head in dict.fromkeys( - [p["head"] for p in problems if p["kind"] in DIRECT_KINDS] - + [screen["head"] for screen in screens] - ): - held = [v for v in all_heads if v not in (variable, own, head)] - _perturb(sim, variable, year, baseline, added, monthly, hold=held) - first = _head_arrays(sim, year, [head])[head] - heads0[head] - second = None - if any( - p["head"] == head and p["kind"] == "contains" - for p in problems + screens + # A head that may respond (a declared reader, or any benefit when the + # component was zero for everyone) is checked for containment at a + # second point, which tells a slope of 1 from a step. + may_respond = { + v + for total in in_totals + for v in heads_of[total] + if v != own + and ( + v in readers_of[total] + or (zero_baseline and not partition.is_tax_total(total)) + ) + } + heads2 = None + if may_respond: + _perturb( + sim, variable, year, baseline, 2 * added, monthly, touched=touched + ) + heads2 = _head_arrays(sim, year, sorted(may_respond)) + problems, screens = [], [] + for total in in_totals: + verdict = judge_total( + total=total, + is_tax=partition.is_tax_total(total), + component=variable, + own_variable=own, + declared_double_count=variable in declared + and total in declared[variable].totals, + zero_baseline=zero_baseline, + weights=hw, + expected=expected, + head_changes={v: heads1[v] - heads0[v] for v in heads_of[total]}, + head_scales={v: heads0[v] for v in heads_of[total]}, + model_change=( + model1[total] - model0[total] if total in model1 else None + ), + model_scale=model0.get(total), + allowed_readers=readers_of[total], + second_changes=None + if heads2 is None + else {v: heads2[v] - heads0[v] for v in heads2}, + ) + record["coefficients"][total] = verdict["coefficient"] + if verdict["model_coefficient"] is not None: + record["model_coefficients"][total] = verdict["model_coefficient"] + record["responses"][total] = { + v: r for v, r in verdict["responses"].items() if abs(r) > tolerance + } + problems += verdict["problems"] + screens += verdict["screens"] + # A declared double count whose container no longer moves is + # reported once, as a stale declaration, below. + if verdict["own_problem"] and variable not in declared: + problems.append(verdict["own_problem"]) + # Measure each head that broke a rule again with every other head + # held at its baseline value: a response carried by another head + # (the head reading that head's change) is not containment. + mediated = [] + perturbed_units = expected > 0 + for head in dict.fromkeys( + [p["head"] for p in problems if p["kind"] in DIRECT_KINDS] + + [screen["head"] for screen in screens] ): - _perturb(sim, variable, year, baseline, 2 * added, monthly, hold=held) - second = _head_arrays(sim, year, [head])[head] - heads0[head] - for v in held: - _restore(sim, v, snapshot) - # Second test: hold the other heads at their values after the - # first point, and move the component on to the second. A head - # gated by another head (h = c * (b > 0), with b a passport on c) - # keeps its own slope here, though holding b at its baseline - # switched it off above. - _perturb(sim, variable, year, baseline, added, monthly) - at_first = _capture(sim, held, year, baseline) - head_first = _head_arrays(sim, year, [head])[head] - _perturb( - sim, - variable, - year, - baseline, - 2 * added, - monthly, - hold=held, - held_at=at_first, - ) - slope_held = _head_arrays(sim, year, [head])[head] - head_first - for v in held: - _restore(sim, v, snapshot) - slack = _slack(heads0[head]) - kept = [] - for p in problems: - if p["head"] != head or p["kind"] not in DIRECT_KINDS: - kept.append(p) - continue - direct = still_breaks( - p["kind"], p["where"], expected, slack, first, second - ) | breaks_on_slope(p["kind"], p["where"], expected, slack, slope_held) - cleared = p["where"] & ~direct - if cleared.any(): - # The other heads that moved there: candidates for the - # route, not each shown to carry it. - via = [ - v - for v in needed - if v not in (variable, own, head) - and bool( - ( - np.abs(heads1[v] - heads0[v])[cleared] - > _slack(heads0[v])[cleared] - ).any() + held = [v for v in all_heads if v not in (variable, own, head)] + _perturb( + sim, + variable, + year, + baseline, + added, + monthly, + hold=held, + touched=touched, + ) + first = _head_arrays(sim, year, [head])[head] - heads0[head] + second = None + if any( + p["head"] == head and p["kind"] == "contains" + for p in problems + screens + ): + _perturb( + sim, + variable, + year, + baseline, + 2 * added, + monthly, + hold=held, + touched=touched, + ) + second = _head_arrays(sim, year, [head])[head] - heads0[head] + for v in held: + _restore(sim, v, snapshot) + # Second test: hold the other heads at their values after the + # first point, and move the component on to the second. A head + # gated by another head (h = c * (b > 0), with b a passport on c) + # keeps its own slope here, though holding b at its baseline + # switched it off above. + _perturb(sim, variable, year, baseline, added, monthly, touched=touched) + at_first = _capture(sim, held, year, baseline) + head_first = _head_arrays(sim, year, [head])[head] + _perturb( + sim, + variable, + year, + baseline, + 2 * added, + monthly, + hold=held, + held_at=at_first, + touched=touched, + ) + slope_held = _head_arrays(sim, year, [head])[head] - head_first + for v in held: + _restore(sim, v, snapshot) + slack = _slack(heads0[head]) + kept = [] + for p in problems: + if p["head"] != head or p["kind"] not in DIRECT_KINDS: + kept.append(p) + continue + direct = still_breaks( + p["kind"], p["where"], expected, slack, first, second + ) | breaks_on_slope( + p["kind"], p["where"], expected, slack, slope_held + ) + cleared = p["where"] & ~direct + if cleared.any(): + # The other heads that moved there: candidates for the + # route, not each shown to carry it. + via = [ + v + for v in needed + if v not in (variable, own, head) + and bool( + ( + np.abs(heads1[v] - heads0[v])[cleared] + > _slack(heads0[v])[cleared] + ).any() + ) + ] + mediated.append( + { + "total": p["total"], + "head": head, + "kind": p["kind"], + "households": int(np.count_nonzero(cleared & (hw > 0))), + "via": via, + } + ) + if direct.any(): + kept.append( + _problem( + p["total"], + head, + p["kind"], + direct, + p["text"], + hw, + perturbed_units, + ) ) - ] - mediated.append( - { - "total": p["total"], - "head": head, - "kind": p["kind"], - "households": int(np.count_nonzero(cleared & (hw > 0))), - "via": via, - } + # A linear response that was not one for one may hide a + # containment that another head's response added to. + for screen in screens: + if screen["head"] != head: + continue + direct = still_breaks( + "contains", screen["where"], expected, slack, first, second + ) | breaks_on_slope( + "contains", screen["where"], expected, slack, slope_held ) - if direct.any(): + if not direct.any(): + continue + same = next( + ( + k + for k in kept + if (k["total"], k["head"], k["kind"]) + == (screen["total"], head, "contains") + ), + None, + ) + if same is not None: + kept.remove(same) + direct = direct | same["where"] kept.append( _problem( - p["total"], + screen["total"], head, - p["kind"], + "contains", direct, - p["text"], + screen["text"], hw, perturbed_units, ) ) - # A linear response that was not one for one may hide a - # containment that another head's response added to. - for screen in screens: - if screen["head"] != head: - continue - direct = still_breaks( - "contains", screen["where"], expected, slack, first, second - ) | breaks_on_slope( - "contains", screen["where"], expected, slack, slope_held + problems = kept + problems = [ + {k: v for k, v in p.items() if k not in ("where", "text")} + for p in problems + ] + record["mediated"] = mediated + if variable in declared: + dc = declared[variable] + container_change = heads1[own] - heads0[own] + k = float(np.sum(hw * container_change)) / float(np.sum(hw * expected)) + present = not bool( + (np.abs(container_change - expected) > _slack(heads0[own])).any() ) - if not direct.any(): - continue - same = next( - ( - k - for k in kept - if (k["total"], k["head"], k["kind"]) - == (screen["total"], head, "contains") - ), - None, + double_count_results.append( + { + "variable": variable, + "contained_in": dc.contained_in, + "coefficient": k, + "issue": dc.issue, + "status": "pass" if present else "fail", + "detail": "" + if present + else ( + f"{dc.contained_in} moves by {k:.4f} per unit of {variable}: " + "the model no longer counts it twice, so remove the " + "declaration and give it its own head" + ), + } ) - if same is not None: - kept.remove(same) - direct = direct | same["where"] - kept.append( - _problem( - screen["total"], - head, - "contains", - direct, - screen["text"], - hw, - perturbed_units, - ) + if problems: + record.update( + status="fail", + problems=problems, + detail="; ".join(p["message"] for p in problems), ) - problems = kept - problems = [ - {k: v for k, v in p.items() if k not in ("where", "text")} for p in problems - ] - record["mediated"] = mediated - if variable in declared: - dc = declared[variable] - container_change = heads1[own] - heads0[own] - k = float(np.sum(hw * container_change)) / float(np.sum(hw * expected)) - present = not bool( - (np.abs(container_change - expected) > _slack(heads0[own])).any() - ) - double_count_results.append( - { - "variable": variable, - "contained_in": dc.contained_in, - "coefficient": k, - "issue": dc.issue, - "status": "pass" if present else "fail", - "detail": "" - if present - else ( - f"{dc.contained_in} moves by {k:.4f} per unit of {variable}: " - "the model no longer counts it twice, so remove the " - "declaration and give it its own head" - ), - } - ) - if problems: - record.update( - status="fail", - problems=problems, - detail="; ".join(p["message"] for p in problems), - ) - results.append(record) - _restore(sim, variable, snapshot) + results.append(record) + _restore(sim, variable, snapshot) + finally: + for variable in sorted(touched): + _restore(sim, variable, snapshot) discard_formula_results(sim) heads_again = _head_arrays(sim, year, all_heads) diff --git a/policyengine_uk/utils/childcare.py b/policyengine_uk/utils/childcare.py new file mode 100644 index 0000000000..308d4dea6e --- /dev/null +++ b/policyengine_uk/utils/childcare.py @@ -0,0 +1,187 @@ +"""Internal term budgets for England's funded childcare entitlements. + +The existing annual model gives equal weight to terms beginning in April, +September and January. DfE guidance effective 1 April 2026, A1.2/A1.7 and +SI 2014/2147 reg 3 start the base entitlements after the relevant birthday; +A1.4 keeps targeted provision until the universal term begins. A1.11 counts +the first base hours within working-parent provision in those same terms. + +The national-average age-group funding table remains an annual valuation +proxy, sampled once for the model year, not a provider fee or term calendar. +The qualifying age band is shared by all hours funded in a term. Annual +working-parent hours/rollout and family eligibility/take-up stay annual. + +Existing explicitly supplied annual shares or amounts have no term identity. +Their complete original calculation remains authoritative; these helpers do +not infer timing for them or introduce a consumer-facing term input schema. + +https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026 +https://www.legislation.gov.uk/uksi/2014/2147/regulation/3 +""" + +import numpy as np +from policyengine_core.model_api import MONTHS_IN_YEAR + +from policyengine_uk.utils.dates import add_months_to_yyyymmdd, birth_day, term_starts +from policyengine_uk.utils.supplied_inputs import supplied_input + + +LEGACY_CHILDCARE_INPUTS = ( + "early_years_term_share_before_school", + "extended_childcare_entitlement_term_share", + "universal_childcare_entitlement", + "targeted_childcare_entitlement", + "extended_childcare_entitlement_total_per_child", +) + + +def uses_legacy_childcare_inputs(person, period) -> bool: + """Recognise genuine stored inputs, including zero, never calculated caches.""" + return any( + supplied_input(person, name, period) is not None + for name in LEGACY_CHILDCARE_INPUTS + ) + + +def _school_terms(person, period, starts): + # Only the native calculated route calls this: its before-school share is + # a count of whole terms, with float32 rounding. Opaque inputs use legacy. + count = np.rint( + person("early_years_term_share_before_school", period) * len(starts) + ) + return tuple(index < count for index in range(len(starts))) + + +def _scale_in_term(scale, born, start): + """Apply an existing age scale from the term after each threshold birthday.""" + result = np.zeros(born.shape, dtype=np.asarray(scale.amounts).dtype) + for threshold, amount in zip(scale.thresholds, scale.amounts): + if np.isneginf(threshold): + result = np.full(born.shape, amount) + elif not np.isposinf(threshold): + reaches = add_months_to_yyyymmdd(born, MONTHS_IN_YEAR * threshold) + result = np.where(reaches < start, amount, result) + return result + + +def universal_childcare_terms(person, period, parameters) -> tuple: + """Native age/school term intersection; upper-age guards remain in eligibility.""" + starts = term_starts(period.start.year) + born = birth_day(person, period) + minimum = parameters(period).gov.dfe.universal_childcare_entitlement.age.min + reaches = add_months_to_yyyymmdd(born, MONTHS_IN_YEAR * minimum) + return tuple( + (reaches < start) & school + for start, school in zip(starts, _school_terms(person, period, starts)) + ) + + +def targeted_childcare_terms(person, period, parameters) -> tuple: + """Native targeted age-scale and before-school term intersection.""" + starts = term_starts(period.start.year) + born = birth_day(person, period) + scale = parameters(period).gov.dfe.targeted_childcare_entitlement.age_eligibility + return tuple( + _scale_in_term(scale, born, start).astype(bool) & school + for start, school in zip(starts, _school_terms(person, period, starts)) + ) + + +def childcare_term_value(person, period, parameters, component: str): + """Compute one native component without adding unrelated award dependencies.""" + p = parameters(period).gov.dfe + starts = term_starts(period.start.year) + count = len(starts) + born = birth_day(person, period) + age = person("age", period) + school = _school_terms(person, period, starts) + participating = person.benunit("extended_childcare_entitlement_eligible", period) + child_hours = person("max_free_entitlement_hours_used", period) + used_annual = child_hours * p.weeks_per_year + universal_hours = ( + np.minimum(used_annual, p.universal_childcare_entitlement.hours) / count + ) + targeted_hours = ( + np.minimum(used_annual, p.targeted_childcare_entitlement.hours_entitlement) + / count + ) + working_hours = np.zeros(person.count) + working_terms = tuple(np.zeros(person.count, dtype=bool) for _ in starts) + # This is a scalar shortcut for a whole population, not an array branch. + # As before, a non-participating family need not calculate child work rules. + if np.any(participating): + qualifying = person("extended_childcare_entitlement_qualifying_child", period) + family_hours = person.benunit("maximum_extended_childcare_hours_usage", period) + # Keep the existing annual hours/rollout schedule unchanged. + hours_age = np.maximum( + age, p.extended_childcare_entitlement.young_child_minimum_age + ) + weekly_working = np.minimum( + np.minimum( + p.extended_childcare_entitlement.hours.calc(hours_age), child_hours + ), + family_hours, + ) + working_hours = weekly_working * p.weeks_per_year / count + working_count = np.rint( + person("extended_childcare_entitlement_term_share", period) * count + ) + working_terms = tuple( + (index >= count - working_count) + & school[index] + & participating + & qualifying + for index in range(count) + ) + + universal_terms = tuple(np.zeros(person.count, dtype=bool) for _ in starts) + targeted_terms = targeted_childcare_terms(person, period, parameters) + universal_eligible = np.zeros(person.count, dtype=bool) + targeted_eligible = np.zeros(person.count, dtype=bool) + if component in ("universal", "extended"): + universal_terms = universal_childcare_terms(person, period, parameters) + universal_eligible = person("universal_childcare_entitlement_eligible", period) + # A working-total calculation does not depend on either base programme. + # A universal calculation needs targeted eligibility only if a hypothetical + # reform makes their native terms overlap; baseline terms are disjoint. + if component in ("targeted", "extended") or ( + component == "universal" + and any(np.any(u & t) for u, t in zip(universal_terms, targeted_terms)) + ): + targeted_eligible = person.benunit( + "targeted_childcare_entitlement_eligible", period + ) + claims_universal = ( + person.benunit("would_claim_universal_childcare", period) + if component == "universal" + else False + ) + claims_targeted = ( + person.benunit("would_claim_targeted_childcare", period) + if component == "targeted" + else False + ) + value = np.zeros(person.count, dtype=np.float64) + for index, start in enumerate(starts): + rate = _scale_in_term(p.childcare_funding_rate, born, start) + target = targeted_terms[index] & targeted_eligible + universal = universal_terms[index] & universal_eligible + target_value = targeted_hours * target * rate + universal_value = universal_hours * universal * rate + working_value = working_hours * working_terms[index] * rate + if component == "working": + value += working_value + elif component == "targeted": + value += np.where( + claims_targeted, target_value, np.minimum(target_value, working_value) + ) + elif component == "universal": + # Targeted funding has priority if a reform overlaps the age bands. + common = np.minimum( + universal_value, + np.maximum(working_value - np.minimum(target_value, working_value), 0), + ) + value += np.where(claims_universal, universal_value, common) + elif component == "extended": + value += np.maximum(working_value - target_value - universal_value, 0) + return np.round(value, 2) if component == "extended" else value diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index 58bc6f0cef..9651d773d2 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -43,6 +43,9 @@ "bus_fare_spending", "bus_in_london_trips", "current_education", + "early_years_term_share_before_school", + "extended_childcare_entitlement_term_share", + "extended_childcare_entitlement_total_per_child", "in_HE", "other_local_bus_trips", "pip_dl_category", @@ -54,9 +57,11 @@ "ni_class_4_losses_brought_forward", "ni_class_4_trading_loss", "tenure_type", + "targeted_childcare_entitlement", "trading_loss", "uc_carer_element", "uc_LCWRA_element", + "universal_childcare_entitlement", } ) diff --git a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.py b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.py index 4126fbe0c8..a33fc34e51 100644 --- a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.py +++ b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.py @@ -1,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.childcare import ( + childcare_term_value, + uses_legacy_childcare_inputs, +) class extended_childcare_entitlement_per_child(Variable): @@ -21,7 +25,11 @@ class extended_childcare_entitlement_per_child(Variable): "here. The comparison is term by term: a child old enough for only " "some of the year's terms (extended_childcare_entitlement_term_share) " "has the working parent total for those terms, set against the " - "universal or targeted hours in the same terms." + "universal or targeted hours in the same terms. Base entitlements " + "also start by birthday term, so their prorated annual values are " + "not multiplied by a second annual share. Explicit legacy annual " + "shares or amounts retain the original annual calculation and " + "actual supplied downstream amounts." ) definition_period = YEAR unit = GBP @@ -36,6 +44,9 @@ class extended_childcare_entitlement_per_child(Variable): ) def formula(person, period, parameters): + if not uses_legacy_childcare_inputs(person, period): + return childcare_term_value(person, period, parameters, "extended") + # Opaque annual inputs retain the complete original valuation route. total_value = person("extended_childcare_entitlement_total_per_child", period) # Childcare Act 2016 s.1(6)(a) and SI 2022/1134 reg 44(3): the diff --git a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_total_per_child.py b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_total_per_child.py index 1cb9c14534..f217c203b6 100644 --- a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_total_per_child.py +++ b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_total_per_child.py @@ -1,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.childcare import ( + childcare_term_value, + uses_legacy_childcare_inputs, +) class extended_childcare_entitlement_total_per_child(Variable): @@ -10,11 +14,11 @@ class extended_childcare_entitlement_total_per_child(Variable): "parent hours, before allocating the first hours to any applicable " "universal or targeted programme. Limited by the statutory age " "schedule, the child's usage and the family's usage cap, over the " - "share of the year's terms the child is old enough for " - "(extended_childcare_entitlement_term_share) and not yet at school " - "(early_years_term_share_before_school). The minimum age is below " - "the school starting age, so the two shares never both fall short " - "of the whole year." + "terms the child is old enough for and not yet at school. Native " + "awards intersect those terms and use the same qualifying-age " + "funding proxy as base hours in each common term, with the existing " + "annual hours and funding-year schedules. Supplied legacy annual " + "shares or amounts retain their original annual calculation." ) definition_period = YEAR unit = GBP @@ -27,6 +31,9 @@ class extended_childcare_entitlement_total_per_child(Variable): ) def formula(person, period, parameters): + if not uses_legacy_childcare_inputs(person, period): + return childcare_term_value(person, period, parameters, "working") + # Opaque annual inputs retain the complete original valuation route. p = parameters(period).gov.dfe age = person("age", period) qualifying_child = person( diff --git a/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/is_child_receiving_targeted_childcare.py b/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/is_child_receiving_targeted_childcare.py index 07e6c8a8fa..d9c5aaefcc 100644 --- a/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/is_child_receiving_targeted_childcare.py +++ b/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/is_child_receiving_targeted_childcare.py @@ -1,11 +1,26 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.childcare import ( + targeted_childcare_terms, + uses_legacy_childcare_inputs, +) class is_child_receiving_targeted_childcare(Variable): value_type = bool entity = Person - label = "child is eligible for targeted childcare entitlement based on age and entitlement" + label = "child is receiving targeted childcare entitlement" definition_period = YEAR + documentation = ( + "A positive targeted award in at least one native eligible term. " + "A transition year can contain targeted and universal awards in " + "different terms. The targeted age scale remains independently " + "reformable. Genuinely supplied legacy annual shares or amounts " + "retain the original snapshot-age receiving predicate." + ) + reference = ( + "https://www.legislation.gov.uk/uksi/2014/2147/regulation/3", + "https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026", + ) def formula(person, period, parameters): # Get child's age @@ -16,6 +31,10 @@ def formula(person, period, parameters): # Check if age is eligible age_eligible = p.age_eligibility.calc(age) + if not uses_legacy_childcare_inputs(person, period): + age_eligible = np.logical_or.reduce( + targeted_childcare_terms(person, period, parameters) + ) # Get the targeted childcare entitlement amount entitlement_amount = person("targeted_childcare_entitlement", period) diff --git a/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/targeted_childcare_entitlement.py b/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/targeted_childcare_entitlement.py index a588aed06e..34273fe1a3 100644 --- a/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/targeted_childcare_entitlement.py +++ b/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/targeted_childcare_entitlement.py @@ -1,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.childcare import ( + childcare_term_value, + uses_legacy_childcare_inputs, +) class targeted_childcare_entitlement(Variable): @@ -12,7 +16,13 @@ class targeted_childcare_entitlement(Variable): "Targeted hours claimed on their own, or allocated first from a " "participating child's working parent hours, in the terms it gets " "them, when standalone take-up is false, as required by DfE " - "guidance A1.11." + "guidance A1.11. Native awards follow the existing age scale's " + "birthday-term boundaries; the baseline targeted place continues " + "until the universal term starts (A1.4). Both can therefore have " + "positive annual values in different terms. The year's retained " + "national-average proxy uses each term's qualifying age band. " + "Supplied legacy annual shares or amounts retain their original " + "annual calculation; family eligibility/take-up remain annual." ) reference = ( "https://www.legislation.gov.uk/uksi/2014/2147/regulation/3", @@ -21,6 +31,9 @@ class targeted_childcare_entitlement(Variable): ) def formula(person, period, parameters): + if not uses_legacy_childcare_inputs(person, period): + return childcare_term_value(person, period, parameters, "targeted") + # Opaque annual inputs retain the complete original valuation route. p = parameters(period).gov.dfe age = person("age", period) eligible_by_age = p.targeted_childcare_entitlement.age_eligibility.calc(age) diff --git a/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/is_child_receiving_universal_childcare.py b/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/is_child_receiving_universal_childcare.py index b0d6f19c12..4fcfc0f05e 100644 --- a/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/is_child_receiving_universal_childcare.py +++ b/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/is_child_receiving_universal_childcare.py @@ -1,11 +1,26 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.childcare import ( + universal_childcare_terms, + uses_legacy_childcare_inputs, +) class is_child_receiving_universal_childcare(Variable): value_type = bool entity = Person - label = "child is eligible for universal childcare entitlement based on age and entitlement" + label = "child is receiving universal childcare entitlement" definition_period = YEAR + documentation = ( + "A positive universal award in at least one native eligible term, " + "including a term after a birthday later than the annual age " + "snapshot. The existing upper-age guard remains. Genuinely supplied " + "legacy annual shares or amounts retain the original snapshot-age " + "predicate, without an inferred allocation to individual terms." + ) + reference = ( + "https://www.legislation.gov.uk/uksi/2014/2147/regulation/3", + "https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026", + ) def formula(person, period, parameters): # Get child's age @@ -16,6 +31,10 @@ def formula(person, period, parameters): # Check if age is within eligible range meets_age_condition = (age >= p.age.min) & (age < p.age.max) + if not uses_legacy_childcare_inputs(person, period): + meets_age_condition = (age < p.age.max) & np.logical_or.reduce( + universal_childcare_terms(person, period, parameters) + ) # Get the universal childcare entitlement amount entitlement_amount = person("universal_childcare_entitlement", period) diff --git a/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.py b/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.py index 3052ceac90..b4af98945a 100644 --- a/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.py +++ b/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.py @@ -1,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.childcare import ( + childcare_term_value, + uses_legacy_childcare_inputs, +) class universal_childcare_entitlement(Variable): @@ -12,8 +16,11 @@ class universal_childcare_entitlement(Variable): "Universal hours claimed on their own, or allocated first from a " "participating child's working parent hours, in the terms it gets " "them, when standalone take-up is false. Legal eligibility " - "determines this allocation. Only the terms before the child starts " - "school count (early_years_term_share_before_school)." + "determines this allocation. Native date-based awards count only " + "the terms after the minimum-age birthday and before school. " + "The year's existing national-average funding proxy uses the " + "qualifying age band in each term. Explicit legacy annual shares " + "or amounts retain their original annual calculation." ) reference = ( "https://www.legislation.gov.uk/ukpga/2016/5/section/1", @@ -22,6 +29,9 @@ class universal_childcare_entitlement(Variable): ) def formula(person, period, parameters): + if not uses_legacy_childcare_inputs(person, period): + return childcare_term_value(person, period, parameters, "universal") + # Opaque annual inputs retain the complete original valuation route. p = parameters(period).gov.dfe age = person("age", period) hours = p.universal_childcare_entitlement.hours diff --git a/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement_eligible.py b/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement_eligible.py index f4631369f1..60f52075e4 100644 --- a/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement_eligible.py +++ b/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement_eligible.py @@ -1,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.childcare import ( + universal_childcare_terms, + uses_legacy_childcare_inputs, +) class universal_childcare_entitlement_eligible(Variable): @@ -10,7 +14,9 @@ class universal_childcare_entitlement_eligible(Variable): "Legal eligibility, independent of standalone take-up, in at least " "one of the year's terms. A child in a reception class gets no " "universal hours: its school place meets the entitlement " - "(early_years_term_share_before_school)." + "(early_years_term_share_before_school). Native eligibility starts " + "with the term after the minimum-age birthday. Supplied legacy " + "annual shares or amounts retain snapshot-age eligibility." ) reference = ( "https://www.legislation.gov.uk/ukpga/2006/21/section/7", @@ -30,6 +36,10 @@ def formula(person, period, parameters): age = person("age", period) p = parameters(period).gov.dfe.universal_childcare_entitlement meets_age_condition = (age >= p.age.min) & (age < p.age.max) + if not uses_legacy_childcare_inputs(person, period): + meets_age_condition = (age < p.age.max) & np.logical_or.reduce( + universal_childcare_terms(person, period, parameters) + ) not_compulsory_age = ~person("is_of_compulsory_school_age", period) # Eligibility for the working parent entitlement does not remove the