diff --git a/changelog.d/uk-centax-childcare-parent-targets.added.md b/changelog.d/uk-centax-childcare-parent-targets.added.md new file mode 100644 index 0000000000..06ef01ab47 --- /dev/null +++ b/changelog.d/uk-centax-childcare-parent-targets.added.md @@ -0,0 +1,7 @@ +The UK national line now calibrates parents of 3- and 4-year-olds to CenTax's HMRC tabulations of their incomes (microcosm#1399, for the £100k childcare costing in scorecard#207). + +**A derived national target family, `centax_childcare_parents`.** `tools/build_uk_centax_childcare_parent_facts.py` reads Figures 3.1, 3.3, 3.6 and 3.7 of the workbook CenTax released with Advani, Flew, Pepin-Hall and Summers (2026), *Removing the childcare cliff-edge* (its SHA-256 is pinned), and writes nine counts at England grain to `uk/centax_childcare_parent_facts.json`: four adjusted net income bands that partition (0, £200k] in 2021-22 pounds, and five splits of (£80k, £150k] (women, a main income source other than employment, and London, the South excluding London and the North and Midlands). Each count is CenTax's published share times the baseline build's own parents, so the targets set the income distribution's shape and leave its level where the DfE caseloads put it. The edges are aged to the calibration year by the OBR average earnings index policyengine-uk carries. Nine contract targets (`microcosm.centax_childcare_parents.*`) bind them on the national line only. CenTax's model outputs (bunching estimates, frictions, elasticities, marginal bunchers and costs) are never targets; Figure 2.1, Figure 3.12 and Table A.1's levels stay diagnostics in the resource. + +**Two person measures.** `centax_parent_of_eligible_3_4_year_old` marks the adults of a benefit unit with a dependent child in England of DfE age 3 to 5 who is before school in at least one of the year's terms, on the DfE age and term share `uk_runtime/dfe_spring_term.py` reads. `centax_employment_main_income_source` marks people whose year's pay is positive and at least every other income source. The resolver also computes `adjusted_net_income_annual` on the annual-income simulation. + +**One loader for the derived facts.** `uk_runtime/derived_facts.py` merges the high-value home stock and the CenTax cells beside the Chronicle feed at every national compile site (the full and national target inputs, the release-cut certifier, the compile-parity receipts, the UC matrix diagnostic and the incumbent surface evaluation), and the target-input receipts and the reference membership report name both resources. `tools/generate_uk_target_references.py` streams the feed line by line instead of holding its text. `tools/diagnose_uk_centax_parents.py` reports each cell's fit and support (records, Kish effective households, top-ten household share) on a national H5. diff --git a/packages/microcosm-build/src/microcosm/build/uk/centax_childcare_parent_facts.json b/packages/microcosm-build/src/microcosm/build/uk/centax_childcare_parent_facts.json new file mode 100644 index 0000000000..847fdc58bf --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/uk/centax_childcare_parent_facts.json @@ -0,0 +1,812 @@ +{ + "built_on": "2026-10-25", + "description": "Microcosm-derived counts of parents of age-eligible 3- and 4-year-olds in England by adjusted net income, from CenTax's HMRC tabulations for 2021-22 (Advani, Flew, Pepin-Hall and Summers 2026, Figures 3.1, 3.3, 3.6 and 3.7): each published share times the baseline build's own parent count. Not Chronicle facts, and no CenTax model output.", + "generator": { + "tool": "tools/build_uk_centax_childcare_parent_facts.py", + "version": "uk-centax-childcare-parents-v1" + }, + "inputs": { + "baseline": { + "dataset": "a6299504", + "diagnostics_schema": "microcosm.uk.centax_parent_diagnostics.v1", + "policyengine_uk_version": "2.151.0", + "sha256": "b96a8cd625b645a409c063b1de5158532708a2f0d3c3897a794a8fb0bbd5064e" + }, + "method": "uk-centax-childcare-parents-v1", + "workbook": { + "figures": [ + "Fig. 3.1", + "Fig. 3.3", + "Fig. 3.6", + "Fig. 3.7" + ], + "filename": "AdvaniFlewPepinhallSummers2026_ChildcareThreshold_data.xlsx", + "received": "Arun Advani to Vahid Ahmadi, 21 October 2026", + "report_url": "https://centax.org.uk/wp-content/uploads/2026/09/AdvaniFlewPepin-HallSummers2026_Removing-the-childcare-cliff-edge.pdf", + "sha256": "8c23855745154a601064e766552c64bc2e88de51e043b4df1397c580e969354b" + } + }, + "inputs_sha256": "36c260846ae473c642e7b1974bf4f1e7e14ed3867d33e7c69dd63c5ff8e3b9d5", + "kind": "uk_derived_consumer_facts", + "licence": { + "attribution": [ + "Advani, A., Flew, D., Pepin-Hall, J. and Summers, A. (2026), Removing the childcare cliff-edge, CenTax. HMRC administrative data; the authors' calculations." + ] + }, + "parameters": { + "ani_bands_2022": { + "ani_0_to_50k": [ + 0.0, + 50000.0 + ], + "ani_120k_to_200k": [ + 120000.0, + 200000.0 + ], + "ani_50k_to_80k": [ + 50000.0, + 80000.0 + ], + "ani_80k_to_120k": [ + 80000.0, + 120000.0 + ] + }, + "ani_uprating": 1.2477167636706619, + "ani_uprating_basis": "policyengine-uk gov.economic_assumptions.indices.obr.average_earnings, 2025 over 2021", + "calibration_year": 2025, + "diagnostic_comparators": { + "fig_2_1": "Live DfE childcare account shares by ANI. Not bound: the frame has no childcare-account measure; the dashboard's take-up assumption is the place to compare it.", + "fig_3_12_non_working_partner_share_80k_to_150k_unweighted_bin_mean": { + "men": 0.08059792021949463, + "note": "Mean of the \u00a32,500 bins' shares (the figure gives shares, not counts). Not bound: the denominator is parents with an identified partner, and the workbook's 'Men & Women' column repeats 'Men'.", + "women": 0.0342720636497626 + }, + "table_a1_3_4_year_olds": { + "note": "Levels of identified parents in HMRC's data for 2021-22. Not bound: HMRC identifies parents through its own linkage, so the level is not the FRS's family count, and the DfE caseloads already set the level.", + "parents_80k_to_140k": 77615, + "parents_whole_population": 1648632, + "share_female_80k_to_140k_pct": 26.6, + "share_female_whole_population_pct": 51.58 + } + }, + "model_ani_edges": { + "ani_0_to_50k": [ + 0, + 62386 + ], + "ani_120k_to_200k": [ + 149726, + 249543 + ], + "ani_50k_to_80k": [ + 62386, + 99817 + ], + "ani_80k_to_120k": [ + 99817, + 149726 + ], + "female_ani_80k_to_150k": [ + 99817, + 187158 + ], + "london_ani_80k_to_150k": [ + 99817, + 187158 + ], + "non_employee_ani_80k_to_150k": [ + 99817, + 187158 + ], + "north_and_midlands_ani_80k_to_150k": [ + 99817, + 187158 + ], + "south_excl_london_ani_80k_to_150k": [ + 99817, + 187158 + ] + }, + "never_targets": [ + "Tables 3.1, 3.2 and 4.1 (bunching estimates)", + "friction parameters, elasticities and marginal bunchers", + "every fiscal cost" + ], + "parent_count": 2165343.6945219897, + "parent_count_basis": "baseline parents of age-eligible 3- and 4-year-olds with annual ANI in (0, 249,543] at 2025", + "shares": { + "ani_0_to_50k": 0.8359264009044982, + "ani_120k_to_200k": 0.01778138743206698, + "ani_50k_to_80k": 0.10572124719041488, + "ani_80k_to_120k": 0.04057096447301996, + "female_ani_80k_to_150k": 0.013423096491074506, + "london_ani_80k_to_150k": 0.014449687249872977, + "non_employee_ani_80k_to_150k": 0.007329915956221696, + "north_and_midlands_ani_80k_to_150k": 0.012634013198321498, + "south_excl_london_ani_80k_to_150k": 0.023535735153831262 + }, + "split_band_2022": [ + 80000.0, + 150000.0 + ], + "split_band_share": 0.05061943560202574, + "workbook_sums": { + "fig_3_1_parents_0_to_200k": 1620346.0, + "fig_3_1_parents_80k_to_150k": 82021.0, + "fig_3_3_parents_80k_to_150k": 82021.0, + "fig_3_6_parents_80k_to_150k": 82017.0, + "fig_3_7_england_parents_80k_to_150k_pooled_2019_2022": 283503.0, + "split_shares_within_80k_to_150k": { + "female": 0.26517673165319383, + "london": 0.28545729674818254, + "non_employee": 0.14480437936625987, + "north_and_midlands": 0.24958818777931804, + "south_excl_london": 0.4649545154724994 + } + } + }, + "resource": "centax_childcare_parent_facts.json", + "row_count": 9, + "rows": [ + { + "aggregate_fact_key": "microcosm.derived_fact.centax_childcare_parents.v1:1f3c1a77b8caa92f3c454850", + "aggregation": { + "method": "sum" + }, + "assertion": "observation", + "concept_alignment": { + "canonical_concept": "uk.person.count", + "relation": "derived", + "source_concept": "microcosm.centax_childcare_parents.count" + }, + "dimension_labels": { + "microcosm.centax_cell": "CenTax parent cell (2021-22 ANI pounds)" + }, + "dimension_value_labels": { + "microcosm.centax_cell": { + "ani_80k_to_120k": "ANI over \u00a380,000 to \u00a3120,000" + } + }, + "dimensions": { + "microcosm.centax_cell": "ani_80k_to_120k" + }, + "entity": { + "name": "person", + "role": "parent" + }, + "geography": { + "id": "E92000001", + "level": "country", + "name": "England", + "vintage": "current" + }, + "label": "Parents of age-eligible 3- and 4-year-olds, ANI over \u00a380,000 to \u00a3120,000 (2021-22 pounds), England", + "layout": { + "measure_id": "parents_of_eligible_3_4_year_olds", + "record_set_id": "uk.centax_childcare_parents.ani_80k_to_120k", + "record_set_spec_id": "uk.centax_childcare_parents.ani_80k_to_120k.v1" + }, + "lineage": { + "source_record_id": "microcosm.centax_childcare_parents.ani_80k_to_120k" + }, + "measure": { + "unit": "count" + }, + "observed_measure": { + "source_concept": "microcosm.centax_childcare_parents.count", + "source_measure_id": "parents_of_eligible_3_4_year_olds", + "unit": "count" + }, + "period": { + "type": "calendar_year", + "value": "2025" + }, + "period_coverage": { + "basis": "flow", + "end_date": "2025-12-31", + "notes": "CenTax's share for tax year 2021-22 times the baseline build's parents at 2025; the ANI edges are aged to 2025 pounds (99,817 to 149,726).", + "start_date": "2025-01-01" + }, + "provenance_class": "derived_administrative", + "schema_version": "microcosm.derived_consumer_fact.v1", + "semantic_fact_key": "microcosm.semantic_fact.centax_childcare_parents.v1:1f3c1a77b8caa92f3c454850", + "source": { + "source_file": "centax_childcare_parent_facts.json", + "source_name": "microcosm", + "source_sha256": "36c260846ae473c642e7b1974bf4f1e7e14ed3867d33e7c69dd63c5ff8e3b9d5", + "source_table": "CenTax, Removing the childcare cliff-edge (2026), Fig. 3.1; Microcosm derivation", + "url": "https://centax.org.uk/wp-content/uploads/2026/09/AdvaniFlewPepin-HallSummers2026_Removing-the-childcare-cliff-edge.pdf", + "vintage": "centax_2026_09" + }, + "value": 87850.082, + "value_type": "float" + }, + { + "aggregate_fact_key": "microcosm.derived_fact.centax_childcare_parents.v1:37d510f8af3e31c921818be8", + "aggregation": { + "method": "sum" + }, + "assertion": "observation", + "concept_alignment": { + "canonical_concept": "uk.person.count", + "relation": "derived", + "source_concept": "microcosm.centax_childcare_parents.count" + }, + "dimension_labels": { + "microcosm.centax_cell": "CenTax parent cell (2021-22 ANI pounds)" + }, + "dimension_value_labels": { + "microcosm.centax_cell": { + "london_ani_80k_to_150k": "London, ANI over \u00a380,000 to \u00a3150,000" + } + }, + "dimensions": { + "microcosm.centax_cell": "london_ani_80k_to_150k" + }, + "entity": { + "name": "person", + "role": "parent" + }, + "geography": { + "id": "E92000001", + "level": "country", + "name": "England", + "vintage": "current" + }, + "label": "Parents of age-eligible 3- and 4-year-olds, London, ANI over \u00a380,000 to \u00a3150,000 (2021-22 pounds), England", + "layout": { + "measure_id": "parents_of_eligible_3_4_year_olds", + "record_set_id": "uk.centax_childcare_parents.london_ani_80k_to_150k", + "record_set_spec_id": "uk.centax_childcare_parents.london_ani_80k_to_150k.v1" + }, + "lineage": { + "source_record_id": "microcosm.centax_childcare_parents.london_ani_80k_to_150k" + }, + "measure": { + "unit": "count" + }, + "observed_measure": { + "source_concept": "microcosm.centax_childcare_parents.count", + "source_measure_id": "parents_of_eligible_3_4_year_olds", + "unit": "count" + }, + "period": { + "type": "calendar_year", + "value": "2025" + }, + "period_coverage": { + "basis": "flow", + "end_date": "2025-12-31", + "notes": "CenTax's share for tax year 2021-22 times the baseline build's parents at 2025; the ANI edges are aged to 2025 pounds (99,817 to 187,158).", + "start_date": "2025-01-01" + }, + "provenance_class": "derived_administrative", + "schema_version": "microcosm.derived_consumer_fact.v1", + "semantic_fact_key": "microcosm.semantic_fact.centax_childcare_parents.v1:37d510f8af3e31c921818be8", + "source": { + "source_file": "centax_childcare_parent_facts.json", + "source_name": "microcosm", + "source_sha256": "36c260846ae473c642e7b1974bf4f1e7e14ed3867d33e7c69dd63c5ff8e3b9d5", + "source_table": "CenTax, Removing the childcare cliff-edge (2026), Fig. 3.7; Microcosm derivation", + "url": "https://centax.org.uk/wp-content/uploads/2026/09/AdvaniFlewPepin-HallSummers2026_Removing-the-childcare-cliff-edge.pdf", + "vintage": "centax_2026_09" + }, + "value": 31288.539, + "value_type": "float" + }, + { + "aggregate_fact_key": "microcosm.derived_fact.centax_childcare_parents.v1:69e109821b6669c67691b5c4", + "aggregation": { + "method": "sum" + }, + "assertion": "observation", + "concept_alignment": { + "canonical_concept": "uk.person.count", + "relation": "derived", + "source_concept": "microcosm.centax_childcare_parents.count" + }, + "dimension_labels": { + "microcosm.centax_cell": "CenTax parent cell (2021-22 ANI pounds)" + }, + "dimension_value_labels": { + "microcosm.centax_cell": { + "south_excl_london_ani_80k_to_150k": "South excluding London, ANI over \u00a380,000 to \u00a3150,000" + } + }, + "dimensions": { + "microcosm.centax_cell": "south_excl_london_ani_80k_to_150k" + }, + "entity": { + "name": "person", + "role": "parent" + }, + "geography": { + "id": "E92000001", + "level": "country", + "name": "England", + "vintage": "current" + }, + "label": "Parents of age-eligible 3- and 4-year-olds, South excluding London, ANI over \u00a380,000 to \u00a3150,000 (2021-22 pounds), England", + "layout": { + "measure_id": "parents_of_eligible_3_4_year_olds", + "record_set_id": "uk.centax_childcare_parents.south_excl_london_ani_80k_to_150k", + "record_set_spec_id": "uk.centax_childcare_parents.south_excl_london_ani_80k_to_150k.v1" + }, + "lineage": { + "source_record_id": "microcosm.centax_childcare_parents.south_excl_london_ani_80k_to_150k" + }, + "measure": { + "unit": "count" + }, + "observed_measure": { + "source_concept": "microcosm.centax_childcare_parents.count", + "source_measure_id": "parents_of_eligible_3_4_year_olds", + "unit": "count" + }, + "period": { + "type": "calendar_year", + "value": "2025" + }, + "period_coverage": { + "basis": "flow", + "end_date": "2025-12-31", + "notes": "CenTax's share for tax year 2021-22 times the baseline build's parents at 2025; the ANI edges are aged to 2025 pounds (99,817 to 187,158).", + "start_date": "2025-01-01" + }, + "provenance_class": "derived_administrative", + "schema_version": "microcosm.derived_consumer_fact.v1", + "semantic_fact_key": "microcosm.semantic_fact.centax_childcare_parents.v1:69e109821b6669c67691b5c4", + "source": { + "source_file": "centax_childcare_parent_facts.json", + "source_name": "microcosm", + "source_sha256": "36c260846ae473c642e7b1974bf4f1e7e14ed3867d33e7c69dd63c5ff8e3b9d5", + "source_table": "CenTax, Removing the childcare cliff-edge (2026), Fig. 3.7; Microcosm derivation", + "url": "https://centax.org.uk/wp-content/uploads/2026/09/AdvaniFlewPepin-HallSummers2026_Removing-the-childcare-cliff-edge.pdf", + "vintage": "centax_2026_09" + }, + "value": 50962.956, + "value_type": "float" + }, + { + "aggregate_fact_key": "microcosm.derived_fact.centax_childcare_parents.v1:88c35d50b2aff6d5cf6921b7", + "aggregation": { + "method": "sum" + }, + "assertion": "observation", + "concept_alignment": { + "canonical_concept": "uk.person.count", + "relation": "derived", + "source_concept": "microcosm.centax_childcare_parents.count" + }, + "dimension_labels": { + "microcosm.centax_cell": "CenTax parent cell (2021-22 ANI pounds)" + }, + "dimension_value_labels": { + "microcosm.centax_cell": { + "female_ani_80k_to_150k": "women, ANI over \u00a380,000 to \u00a3150,000" + } + }, + "dimensions": { + "microcosm.centax_cell": "female_ani_80k_to_150k" + }, + "entity": { + "name": "person", + "role": "parent" + }, + "geography": { + "id": "E92000001", + "level": "country", + "name": "England", + "vintage": "current" + }, + "label": "Parents of age-eligible 3- and 4-year-olds, women, ANI over \u00a380,000 to \u00a3150,000 (2021-22 pounds), England", + "layout": { + "measure_id": "parents_of_eligible_3_4_year_olds", + "record_set_id": "uk.centax_childcare_parents.female_ani_80k_to_150k", + "record_set_spec_id": "uk.centax_childcare_parents.female_ani_80k_to_150k.v1" + }, + "lineage": { + "source_record_id": "microcosm.centax_childcare_parents.female_ani_80k_to_150k" + }, + "measure": { + "unit": "count" + }, + "observed_measure": { + "source_concept": "microcosm.centax_childcare_parents.count", + "source_measure_id": "parents_of_eligible_3_4_year_olds", + "unit": "count" + }, + "period": { + "type": "calendar_year", + "value": "2025" + }, + "period_coverage": { + "basis": "flow", + "end_date": "2025-12-31", + "notes": "CenTax's share for tax year 2021-22 times the baseline build's parents at 2025; the ANI edges are aged to 2025 pounds (99,817 to 187,158).", + "start_date": "2025-01-01" + }, + "provenance_class": "derived_administrative", + "schema_version": "microcosm.derived_consumer_fact.v1", + "semantic_fact_key": "microcosm.semantic_fact.centax_childcare_parents.v1:88c35d50b2aff6d5cf6921b7", + "source": { + "source_file": "centax_childcare_parent_facts.json", + "source_name": "microcosm", + "source_sha256": "36c260846ae473c642e7b1974bf4f1e7e14ed3867d33e7c69dd63c5ff8e3b9d5", + "source_table": "CenTax, Removing the childcare cliff-edge (2026), Fig. 3.6; Microcosm derivation", + "url": "https://centax.org.uk/wp-content/uploads/2026/09/AdvaniFlewPepin-HallSummers2026_Removing-the-childcare-cliff-edge.pdf", + "vintage": "centax_2026_09" + }, + "value": 29065.617, + "value_type": "float" + }, + { + "aggregate_fact_key": "microcosm.derived_fact.centax_childcare_parents.v1:c5eb9446004e5cb65486f201", + "aggregation": { + "method": "sum" + }, + "assertion": "observation", + "concept_alignment": { + "canonical_concept": "uk.person.count", + "relation": "derived", + "source_concept": "microcosm.centax_childcare_parents.count" + }, + "dimension_labels": { + "microcosm.centax_cell": "CenTax parent cell (2021-22 ANI pounds)" + }, + "dimension_value_labels": { + "microcosm.centax_cell": { + "north_and_midlands_ani_80k_to_150k": "North and Midlands, ANI over \u00a380,000 to \u00a3150,000" + } + }, + "dimensions": { + "microcosm.centax_cell": "north_and_midlands_ani_80k_to_150k" + }, + "entity": { + "name": "person", + "role": "parent" + }, + "geography": { + "id": "E92000001", + "level": "country", + "name": "England", + "vintage": "current" + }, + "label": "Parents of age-eligible 3- and 4-year-olds, North and Midlands, ANI over \u00a380,000 to \u00a3150,000 (2021-22 pounds), England", + "layout": { + "measure_id": "parents_of_eligible_3_4_year_olds", + "record_set_id": "uk.centax_childcare_parents.north_and_midlands_ani_80k_to_150k", + "record_set_spec_id": "uk.centax_childcare_parents.north_and_midlands_ani_80k_to_150k.v1" + }, + "lineage": { + "source_record_id": "microcosm.centax_childcare_parents.north_and_midlands_ani_80k_to_150k" + }, + "measure": { + "unit": "count" + }, + "observed_measure": { + "source_concept": "microcosm.centax_childcare_parents.count", + "source_measure_id": "parents_of_eligible_3_4_year_olds", + "unit": "count" + }, + "period": { + "type": "calendar_year", + "value": "2025" + }, + "period_coverage": { + "basis": "flow", + "end_date": "2025-12-31", + "notes": "CenTax's share for tax year 2021-22 times the baseline build's parents at 2025; the ANI edges are aged to 2025 pounds (99,817 to 187,158).", + "start_date": "2025-01-01" + }, + "provenance_class": "derived_administrative", + "schema_version": "microcosm.derived_consumer_fact.v1", + "semantic_fact_key": "microcosm.semantic_fact.centax_childcare_parents.v1:c5eb9446004e5cb65486f201", + "source": { + "source_file": "centax_childcare_parent_facts.json", + "source_name": "microcosm", + "source_sha256": "36c260846ae473c642e7b1974bf4f1e7e14ed3867d33e7c69dd63c5ff8e3b9d5", + "source_table": "CenTax, Removing the childcare cliff-edge (2026), Fig. 3.7; Microcosm derivation", + "url": "https://centax.org.uk/wp-content/uploads/2026/09/AdvaniFlewPepin-HallSummers2026_Removing-the-childcare-cliff-edge.pdf", + "vintage": "centax_2026_09" + }, + "value": 27356.981, + "value_type": "float" + }, + { + "aggregate_fact_key": "microcosm.derived_fact.centax_childcare_parents.v1:d5f3ebdb85aa3510ac086fad", + "aggregation": { + "method": "sum" + }, + "assertion": "observation", + "concept_alignment": { + "canonical_concept": "uk.person.count", + "relation": "derived", + "source_concept": "microcosm.centax_childcare_parents.count" + }, + "dimension_labels": { + "microcosm.centax_cell": "CenTax parent cell (2021-22 ANI pounds)" + }, + "dimension_value_labels": { + "microcosm.centax_cell": { + "ani_120k_to_200k": "ANI over \u00a3120,000 to \u00a3200,000" + } + }, + "dimensions": { + "microcosm.centax_cell": "ani_120k_to_200k" + }, + "entity": { + "name": "person", + "role": "parent" + }, + "geography": { + "id": "E92000001", + "level": "country", + "name": "England", + "vintage": "current" + }, + "label": "Parents of age-eligible 3- and 4-year-olds, ANI over \u00a3120,000 to \u00a3200,000 (2021-22 pounds), England", + "layout": { + "measure_id": "parents_of_eligible_3_4_year_olds", + "record_set_id": "uk.centax_childcare_parents.ani_120k_to_200k", + "record_set_spec_id": "uk.centax_childcare_parents.ani_120k_to_200k.v1" + }, + "lineage": { + "source_record_id": "microcosm.centax_childcare_parents.ani_120k_to_200k" + }, + "measure": { + "unit": "count" + }, + "observed_measure": { + "source_concept": "microcosm.centax_childcare_parents.count", + "source_measure_id": "parents_of_eligible_3_4_year_olds", + "unit": "count" + }, + "period": { + "type": "calendar_year", + "value": "2025" + }, + "period_coverage": { + "basis": "flow", + "end_date": "2025-12-31", + "notes": "CenTax's share for tax year 2021-22 times the baseline build's parents at 2025; the ANI edges are aged to 2025 pounds (149,726 to 249,543).", + "start_date": "2025-01-01" + }, + "provenance_class": "derived_administrative", + "schema_version": "microcosm.derived_consumer_fact.v1", + "semantic_fact_key": "microcosm.semantic_fact.centax_childcare_parents.v1:d5f3ebdb85aa3510ac086fad", + "source": { + "source_file": "centax_childcare_parent_facts.json", + "source_name": "microcosm", + "source_sha256": "36c260846ae473c642e7b1974bf4f1e7e14ed3867d33e7c69dd63c5ff8e3b9d5", + "source_table": "CenTax, Removing the childcare cliff-edge (2026), Fig. 3.1; Microcosm derivation", + "url": "https://centax.org.uk/wp-content/uploads/2026/09/AdvaniFlewPepin-HallSummers2026_Removing-the-childcare-cliff-edge.pdf", + "vintage": "centax_2026_09" + }, + "value": 38502.815, + "value_type": "float" + }, + { + "aggregate_fact_key": "microcosm.derived_fact.centax_childcare_parents.v1:db267cebf5fe33e408a4face", + "aggregation": { + "method": "sum" + }, + "assertion": "observation", + "concept_alignment": { + "canonical_concept": "uk.person.count", + "relation": "derived", + "source_concept": "microcosm.centax_childcare_parents.count" + }, + "dimension_labels": { + "microcosm.centax_cell": "CenTax parent cell (2021-22 ANI pounds)" + }, + "dimension_value_labels": { + "microcosm.centax_cell": { + "non_employee_ani_80k_to_150k": "main income source other than employment, ANI over \u00a380,000 to \u00a3150,000" + } + }, + "dimensions": { + "microcosm.centax_cell": "non_employee_ani_80k_to_150k" + }, + "entity": { + "name": "person", + "role": "parent" + }, + "geography": { + "id": "E92000001", + "level": "country", + "name": "England", + "vintage": "current" + }, + "label": "Parents of age-eligible 3- and 4-year-olds, main income source other than employment, ANI over \u00a380,000 to \u00a3150,000 (2021-22 pounds), England", + "layout": { + "measure_id": "parents_of_eligible_3_4_year_olds", + "record_set_id": "uk.centax_childcare_parents.non_employee_ani_80k_to_150k", + "record_set_spec_id": "uk.centax_childcare_parents.non_employee_ani_80k_to_150k.v1" + }, + "lineage": { + "source_record_id": "microcosm.centax_childcare_parents.non_employee_ani_80k_to_150k" + }, + "measure": { + "unit": "count" + }, + "observed_measure": { + "source_concept": "microcosm.centax_childcare_parents.count", + "source_measure_id": "parents_of_eligible_3_4_year_olds", + "unit": "count" + }, + "period": { + "type": "calendar_year", + "value": "2025" + }, + "period_coverage": { + "basis": "flow", + "end_date": "2025-12-31", + "notes": "CenTax's share for tax year 2021-22 times the baseline build's parents at 2025; the ANI edges are aged to 2025 pounds (99,817 to 187,158).", + "start_date": "2025-01-01" + }, + "provenance_class": "derived_administrative", + "schema_version": "microcosm.derived_consumer_fact.v1", + "semantic_fact_key": "microcosm.semantic_fact.centax_childcare_parents.v1:db267cebf5fe33e408a4face", + "source": { + "source_file": "centax_childcare_parent_facts.json", + "source_name": "microcosm", + "source_sha256": "36c260846ae473c642e7b1974bf4f1e7e14ed3867d33e7c69dd63c5ff8e3b9d5", + "source_table": "CenTax, Removing the childcare cliff-edge (2026), Fig. 3.3; Microcosm derivation", + "url": "https://centax.org.uk/wp-content/uploads/2026/09/AdvaniFlewPepin-HallSummers2026_Removing-the-childcare-cliff-edge.pdf", + "vintage": "centax_2026_09" + }, + "value": 15871.787, + "value_type": "float" + }, + { + "aggregate_fact_key": "microcosm.derived_fact.centax_childcare_parents.v1:ebe2802703d16c038d9b2dc8", + "aggregation": { + "method": "sum" + }, + "assertion": "observation", + "concept_alignment": { + "canonical_concept": "uk.person.count", + "relation": "derived", + "source_concept": "microcosm.centax_childcare_parents.count" + }, + "dimension_labels": { + "microcosm.centax_cell": "CenTax parent cell (2021-22 ANI pounds)" + }, + "dimension_value_labels": { + "microcosm.centax_cell": { + "ani_50k_to_80k": "ANI over \u00a350,000 to \u00a380,000" + } + }, + "dimensions": { + "microcosm.centax_cell": "ani_50k_to_80k" + }, + "entity": { + "name": "person", + "role": "parent" + }, + "geography": { + "id": "E92000001", + "level": "country", + "name": "England", + "vintage": "current" + }, + "label": "Parents of age-eligible 3- and 4-year-olds, ANI over \u00a350,000 to \u00a380,000 (2021-22 pounds), England", + "layout": { + "measure_id": "parents_of_eligible_3_4_year_olds", + "record_set_id": "uk.centax_childcare_parents.ani_50k_to_80k", + "record_set_spec_id": "uk.centax_childcare_parents.ani_50k_to_80k.v1" + }, + "lineage": { + "source_record_id": "microcosm.centax_childcare_parents.ani_50k_to_80k" + }, + "measure": { + "unit": "count" + }, + "observed_measure": { + "source_concept": "microcosm.centax_childcare_parents.count", + "source_measure_id": "parents_of_eligible_3_4_year_olds", + "unit": "count" + }, + "period": { + "type": "calendar_year", + "value": "2025" + }, + "period_coverage": { + "basis": "flow", + "end_date": "2025-12-31", + "notes": "CenTax's share for tax year 2021-22 times the baseline build's parents at 2025; the ANI edges are aged to 2025 pounds (62,386 to 99,817).", + "start_date": "2025-01-01" + }, + "provenance_class": "derived_administrative", + "schema_version": "microcosm.derived_consumer_fact.v1", + "semantic_fact_key": "microcosm.semantic_fact.centax_childcare_parents.v1:ebe2802703d16c038d9b2dc8", + "source": { + "source_file": "centax_childcare_parent_facts.json", + "source_name": "microcosm", + "source_sha256": "36c260846ae473c642e7b1974bf4f1e7e14ed3867d33e7c69dd63c5ff8e3b9d5", + "source_table": "CenTax, Removing the childcare cliff-edge (2026), Fig. 3.1; Microcosm derivation", + "url": "https://centax.org.uk/wp-content/uploads/2026/09/AdvaniFlewPepin-HallSummers2026_Removing-the-childcare-cliff-edge.pdf", + "vintage": "centax_2026_09" + }, + "value": 228922.836, + "value_type": "float" + }, + { + "aggregate_fact_key": "microcosm.derived_fact.centax_childcare_parents.v1:f6de8bd8008f7bb3d6775898", + "aggregation": { + "method": "sum" + }, + "assertion": "observation", + "concept_alignment": { + "canonical_concept": "uk.person.count", + "relation": "derived", + "source_concept": "microcosm.centax_childcare_parents.count" + }, + "dimension_labels": { + "microcosm.centax_cell": "CenTax parent cell (2021-22 ANI pounds)" + }, + "dimension_value_labels": { + "microcosm.centax_cell": { + "ani_0_to_50k": "ANI up to \u00a350,000" + } + }, + "dimensions": { + "microcosm.centax_cell": "ani_0_to_50k" + }, + "entity": { + "name": "person", + "role": "parent" + }, + "geography": { + "id": "E92000001", + "level": "country", + "name": "England", + "vintage": "current" + }, + "label": "Parents of age-eligible 3- and 4-year-olds, ANI up to \u00a350,000 (2021-22 pounds), England", + "layout": { + "measure_id": "parents_of_eligible_3_4_year_olds", + "record_set_id": "uk.centax_childcare_parents.ani_0_to_50k", + "record_set_spec_id": "uk.centax_childcare_parents.ani_0_to_50k.v1" + }, + "lineage": { + "source_record_id": "microcosm.centax_childcare_parents.ani_0_to_50k" + }, + "measure": { + "unit": "count" + }, + "observed_measure": { + "source_concept": "microcosm.centax_childcare_parents.count", + "source_measure_id": "parents_of_eligible_3_4_year_olds", + "unit": "count" + }, + "period": { + "type": "calendar_year", + "value": "2025" + }, + "period_coverage": { + "basis": "flow", + "end_date": "2025-12-31", + "notes": "CenTax's share for tax year 2021-22 times the baseline build's parents at 2025; the ANI edges are aged to 2025 pounds (0 to 62,386).", + "start_date": "2025-01-01" + }, + "provenance_class": "derived_administrative", + "schema_version": "microcosm.derived_consumer_fact.v1", + "semantic_fact_key": "microcosm.semantic_fact.centax_childcare_parents.v1:f6de8bd8008f7bb3d6775898", + "source": { + "source_file": "centax_childcare_parent_facts.json", + "source_name": "microcosm", + "source_sha256": "36c260846ae473c642e7b1974bf4f1e7e14ed3867d33e7c69dd63c5ff8e3b9d5", + "source_table": "CenTax, Removing the childcare cliff-edge (2026), Fig. 3.1; Microcosm derivation", + "url": "https://centax.org.uk/wp-content/uploads/2026/09/AdvaniFlewPepin-HallSummers2026_Removing-the-childcare-cliff-edge.pdf", + "vintage": "centax_2026_09" + }, + "value": 1810067.961, + "value_type": "float" + } + ], + "rows_sha256": "063834e192da6aea5150925d9336380e1d5859f8397d691b281e552604008e91", + "schema_version": 1 +} diff --git a/packages/microcosm-build/src/microcosm/build/uk/country_package.json b/packages/microcosm-build/src/microcosm/build/uk/country_package.json index 6b95f1e4f7..a4fc525369 100644 --- a/packages/microcosm-build/src/microcosm/build/uk/country_package.json +++ b/packages/microcosm-build/src/microcosm/build/uk/country_package.json @@ -232,6 +232,11 @@ "kind": "legacy_json", "schema_id": "legacy_json" }, + { + "path": "centax_childcare_parent_facts.json", + "kind": "legacy_json", + "schema_id": "legacy_json" + }, { "path": "high_value_home_value_knots.json", "kind": "legacy_json", diff --git a/packages/microcosm-build/src/microcosm/build/uk/uk_population_targets.json b/packages/microcosm-build/src/microcosm/build/uk/uk_population_targets.json index c4771002da..01558b8162 100644 --- a/packages/microcosm-build/src/microcosm/build/uk/uk_population_targets.json +++ b/packages/microcosm-build/src/microcosm/build/uk/uk_population_targets.json @@ -877,7 +877,16 @@ "dfe.funded_childcare.working_parent_children_age_2": "Requested by María on 2026-10-21 for the £100k childcare costing fixes (microcosm#1278, #1095): bind every DfE funded-childcare count the model can carry, even on few records, and sign out only those with a real reason. The working parent registrations at 9 to 11 months and by single year of age (1, 2 and 3) and the universal registrations in nursery at age 3, from the DfE January census, England, measured at the spring term (María's ruling of 2026-10-22); the age-4 cells are signed out, as the bound rows fix them (accounting note dfe_funded_childcare_single_ages). No incumbent row has this row's definition.", "dfe.funded_childcare.working_parent_children_age_3": "Requested by María on 2026-10-21 for the £100k childcare costing fixes (microcosm#1278, #1095): bind every DfE funded-childcare count the model can carry, even on few records, and sign out only those with a real reason. The working parent registrations at 9 to 11 months and by single year of age (1, 2 and 3) and the universal registrations in nursery at age 3, from the DfE January census, England, measured at the spring term (María's ruling of 2026-10-22); the age-4 cells are signed out, as the bound rows fix them (accounting note dfe_funded_childcare_single_ages). No incumbent row has this row's definition.", "dfe.funded_childcare.universal_nursery_children_age_3": "Requested by María on 2026-10-21 for the £100k childcare costing fixes (microcosm#1278, #1095): bind every DfE funded-childcare count the model can carry, even on few records, and sign out only those with a real reason. The working parent registrations at 9 to 11 months and by single year of age (1, 2 and 3) and the universal registrations in nursery at age 3, from the DfE January census, England, measured at the spring term (María's ruling of 2026-10-22); the age-4 cells are signed out, as the bound rows fix them (accounting note dfe_funded_childcare_single_ages). No incumbent row has this row's definition.", - "mhclg.council_tax_stock.single_adult_discount": "No incumbent target: uk-data binds no council-tax discount; microcosm#953 binds MHCLG Council Taxbase line 8 now that policyengine-uk applies the single adult discount (pe-uk#1864)." + "mhclg.council_tax_stock.single_adult_discount": "No incumbent target: uk-data binds no council-tax discount; microcosm#953 binds MHCLG Council Taxbase line 8 now that policyengine-uk applies the single adult discount (pe-uk#1864).", + "microcosm.centax_childcare_parents.ani_0_to_50k": "Microcosm-derived fact family (parents of age-eligible 3- and 4-year-olds in England by adjusted net income, from CenTax's HMRC tabulations; uk/centax_childcare_parent_facts.json, microcosm#1399), no uk-data metric. Bound on the national line only (calibration_lines).", + "microcosm.centax_childcare_parents.ani_50k_to_80k": "Microcosm-derived fact family (parents of age-eligible 3- and 4-year-olds in England by adjusted net income, from CenTax's HMRC tabulations; uk/centax_childcare_parent_facts.json, microcosm#1399), no uk-data metric. Bound on the national line only (calibration_lines).", + "microcosm.centax_childcare_parents.ani_80k_to_120k": "Microcosm-derived fact family (parents of age-eligible 3- and 4-year-olds in England by adjusted net income, from CenTax's HMRC tabulations; uk/centax_childcare_parent_facts.json, microcosm#1399), no uk-data metric. Bound on the national line only (calibration_lines).", + "microcosm.centax_childcare_parents.ani_120k_to_200k": "Microcosm-derived fact family (parents of age-eligible 3- and 4-year-olds in England by adjusted net income, from CenTax's HMRC tabulations; uk/centax_childcare_parent_facts.json, microcosm#1399), no uk-data metric. Bound on the national line only (calibration_lines).", + "microcosm.centax_childcare_parents.female_ani_80k_to_150k": "Microcosm-derived fact family (parents of age-eligible 3- and 4-year-olds in England by adjusted net income, from CenTax's HMRC tabulations; uk/centax_childcare_parent_facts.json, microcosm#1399), no uk-data metric. Bound on the national line only (calibration_lines).", + "microcosm.centax_childcare_parents.non_employee_ani_80k_to_150k": "Microcosm-derived fact family (parents of age-eligible 3- and 4-year-olds in England by adjusted net income, from CenTax's HMRC tabulations; uk/centax_childcare_parent_facts.json, microcosm#1399), no uk-data metric. Bound on the national line only (calibration_lines).", + "microcosm.centax_childcare_parents.london_ani_80k_to_150k": "Microcosm-derived fact family (parents of age-eligible 3- and 4-year-olds in England by adjusted net income, from CenTax's HMRC tabulations; uk/centax_childcare_parent_facts.json, microcosm#1399), no uk-data metric. Bound on the national line only (calibration_lines).", + "microcosm.centax_childcare_parents.south_excl_london_ani_80k_to_150k": "Microcosm-derived fact family (parents of age-eligible 3- and 4-year-olds in England by adjusted net income, from CenTax's HMRC tabulations; uk/centax_childcare_parent_facts.json, microcosm#1399), no uk-data metric. Bound on the national line only (calibration_lines).", + "microcosm.centax_childcare_parents.north_and_midlands_ani_80k_to_150k": "Microcosm-derived fact family (parents of age-eligible 3- and 4-year-olds in England by adjusted net income, from CenTax's HMRC tabulations; uk/centax_childcare_parent_facts.json, microcosm#1399), no uk-data metric. Bound on the national line only (calibration_lines)." }, "suppressed_ancestors": { "hmrc/salary_sacrifice_it_relief_basic_rate": { @@ -912,7 +921,8 @@ "ons_15_19_sex_band_split": "María's ruling of 2026-10-13 (microcosm#1095, uk-data#563): the two national 15_19 sex bands are split into ages 15, 16-17 and 18-19. uk-data#563 found weights that fit the five-year band while holding too many 16-year-olds and too few 18- and 19-year-olds, and nothing in this contract bound the mix inside the band either. The six rows partition the two bands and read the same ONS mid-year estimate by single year of age, so each sex's three rows sum to the band they replace. The incumbent ons/female_15_19 and ons/male_15_19 rows move from mapped to excluded: no single contract row has their definition.", "ons_single_year_ages_0_2": "microcosm#1278 (the pre-Budget PR's plan, approved by María on 2026-10-20; microcosm#1095): three all-persons rows bind ages 0, 1 and 2, read from the same ONS mid-year estimate by single year of age as the 0_4 sex bands. Unlike the 15_19 split, whose pieces replaced the bands, the 0_4 sex bands keep their incumbent mapping and stay bound: the three rows cover ages 0 to 2 for both sexes together, so neither band is their sum. The three are contract-only declarations; no incumbent row moves.", "ons_single_year_ages_0_6": "Requested by María on 2026-10-21 for the £100k childcare costing fixes (item 6; microcosm#1278, #1095). Eleven all-persons rows extend the single years of age: the UK rows go on from age 2 to age 6, and England binds ages 0 to 6 at E92000001. Every row reads the ONS mid-2025 estimate by single year of age (Population estimates for the UK, England, Wales, Scotland and Northern Ireland: mid-2025, published 1 October 2026; on the feed since the 942a1fa re-pin, microcosm#1123), the series the 0_4 and 5_9 sex bands and the region 0_9 cells sum, so the nested rows are consistent: the UK rows for ages 0 to 4 sum to the two 0_4 sex bands, and England's rows sit inside England's nine region 0_9 cells. Every band stays bound; the eleven are contract-only declarations and no incumbent row moves. Scotland's age 0 is already bound as ons.population.scotland_babies_under_1; Wales and Northern Ireland are left to the UK rows, as each nation's single year rests on too few FRS households.", - "dfe_funded_childcare_single_ages": "Requested by María on 2026-10-21 for the £100k childcare costing fixes (microcosm#1278, #1095): bind every DfE funded-childcare count the model can carry, even on few records, and sign out only those with a real reason. María's ruling of 2026-10-22 (Vahid's round-2 finding A on microcosm#1338) measures every DfE row at the spring term. DfE counts the children registered in its January census by their age on the preceding 31 December, so each row counts the children funded in the term that starts on 1 January of the model year's April-to-March span, by their age on 31 December (uk_runtime/dfe_spring_term.py). policyengine-uk 2.147.2 stops the free hours when a child starts school, from its date of birth (reception the September after the fourth birthday), and starts working parent hours the term after the minimum age; the universal and 2-year-old measures count every child of the DfE age, as the term after the birthday does, where the engine still gates them on the age on 6 October. No row reads current_education. Bound, England, January 2025 census: working parent registrations at 9 to 11 months and at ages 1, 2 and 3; universal registrations in nursery at age 3; and the three earlier rows, working parent 2 to 4, early learning for 2-year-olds and universal-only (nursery ages 3 and 4 less working parent ages 3 and 4). The under-2 rows take their own take-up: a child under 2 in a family that claims working-parent hours draws whether it uses them, at its own contract rate, as DfE registers far fewer of the youngest eligible children (parental leave) than of those aged 2 to 4. The model year is 2025, so its spring term is January 2026: the January 2025 census is the same classification one birth cohort earlier, the in-year snapshot the holds record. Signed out, each for its reason: the three age-4 cells (working parent and universal in nursery: the bound 2 to 4 and universal-only rows, less the bound ages 2 and 3, already fix them on the facts and on the measures; universal in reception: the school place meets those children's entitlement, the model funds no early-years hours for them, and who is in reception follows only the date of birth, which the survey does not record); reception at age 3 (the same reason, and DfE records a handful); the 9 months to 2 years, 3 to 4 years and Total working parent cells, the nursery and reception Totals, the early learning for 2-year-olds Total and the all-entitlements Total (sums or copies of bound cells, or sums that include a signed-out cell); the universal all-provision cells at ages 3, 4 and Total (nursery plus reception, a few children off their sum in DfE's own table); the estimated eligible children, eligible nursery children and all-children estimates (DfE builds them from population and survey-based inputs, not administrative counts, and the all-children estimates restate the ONS England single years already bound at a different reference date); and the registered percentages (ratios of a bound count to one of those estimates). The FRS records no deferred school start and no council-funded independent reception place, so the model starts every child at school on the date its date of birth implies, which slightly undercounts the 4-year-olds in nursery; DfE notes that in 2025 some 2-year-olds eligible for both entitlements were recorded under working parents rather than early learning." + "dfe_funded_childcare_single_ages": "Requested by María on 2026-10-21 for the £100k childcare costing fixes (microcosm#1278, #1095): bind every DfE funded-childcare count the model can carry, even on few records, and sign out only those with a real reason. María's ruling of 2026-10-22 (Vahid's round-2 finding A on microcosm#1338) measures every DfE row at the spring term. DfE counts the children registered in its January census by their age on the preceding 31 December, so each row counts the children funded in the term that starts on 1 January of the model year's April-to-March span, by their age on 31 December (uk_runtime/dfe_spring_term.py). policyengine-uk 2.147.2 stops the free hours when a child starts school, from its date of birth (reception the September after the fourth birthday), and starts working parent hours the term after the minimum age; the universal and 2-year-old measures count every child of the DfE age, as the term after the birthday does, where the engine still gates them on the age on 6 October. No row reads current_education. Bound, England, January 2025 census: working parent registrations at 9 to 11 months and at ages 1, 2 and 3; universal registrations in nursery at age 3; and the three earlier rows, working parent 2 to 4, early learning for 2-year-olds and universal-only (nursery ages 3 and 4 less working parent ages 3 and 4). The under-2 rows take their own take-up: a child under 2 in a family that claims working-parent hours draws whether it uses them, at its own contract rate, as DfE registers far fewer of the youngest eligible children (parental leave) than of those aged 2 to 4. The model year is 2025, so its spring term is January 2026: the January 2025 census is the same classification one birth cohort earlier, the in-year snapshot the holds record. Signed out, each for its reason: the three age-4 cells (working parent and universal in nursery: the bound 2 to 4 and universal-only rows, less the bound ages 2 and 3, already fix them on the facts and on the measures; universal in reception: the school place meets those children's entitlement, the model funds no early-years hours for them, and who is in reception follows only the date of birth, which the survey does not record); reception at age 3 (the same reason, and DfE records a handful); the 9 months to 2 years, 3 to 4 years and Total working parent cells, the nursery and reception Totals, the early learning for 2-year-olds Total and the all-entitlements Total (sums or copies of bound cells, or sums that include a signed-out cell); the universal all-provision cells at ages 3, 4 and Total (nursery plus reception, a few children off their sum in DfE's own table); the estimated eligible children, eligible nursery children and all-children estimates (DfE builds them from population and survey-based inputs, not administrative counts, and the all-children estimates restate the ONS England single years already bound at a different reference date); and the registered percentages (ratios of a bound count to one of those estimates). The FRS records no deferred school start and no council-funded independent reception place, so the model starts every child at school on the date its date of birth implies, which slightly undercounts the 4-year-olds in nursery; DfE notes that in 2025 some 2-year-olds eligible for both entitlements were recorded under working parents rather than early learning.", + "centax_childcare_parents": "Requested for the £100k childcare costing (scorecard#207, microcosm#1399): nine Microcosm-derived parent cells from CenTax's HMRC tabulations of parents of age-eligible 3- and 4-year-olds in England for 2021-22 (Advani, Flew, Pepin-Hall and Summers 2026). Four bands partition (0, £200k] in 2021-22 pounds (Figure 3.1); five splits of (£80k, £150k] give the female share (3.6), the share whose main income source is not employment (3.3) and the three region groups (3.7, pooled 2019-2022, England column), which partition the split band. Microcosm targets are sums, so every share is a count: the share times the baseline build's parents (a6299504 at 2025). Figure 2.1 (childcare-account shares), Figure 3.12 (non-working partners) and Table A.1's levels stay diagnostics in the resource. CenTax's model outputs are never targets (d2014). The rows have no uk-data metric and bind on the national line only." }, "scope_target_ids": [ "obr.income_tax", @@ -1290,7 +1300,16 @@ "microcosm.high_value_homes.over_1m_by_region", "microcosm.high_value_homes.over_1_5m_by_region", "microcosm.high_value_homes.over_2m_by_region", - "mhclg.council_tax_stock.single_adult_discount" + "mhclg.council_tax_stock.single_adult_discount", + "microcosm.centax_childcare_parents.ani_0_to_50k", + "microcosm.centax_childcare_parents.ani_50k_to_80k", + "microcosm.centax_childcare_parents.ani_80k_to_120k", + "microcosm.centax_childcare_parents.ani_120k_to_200k", + "microcosm.centax_childcare_parents.female_ani_80k_to_150k", + "microcosm.centax_childcare_parents.non_employee_ani_80k_to_150k", + "microcosm.centax_childcare_parents.london_ani_80k_to_150k", + "microcosm.centax_childcare_parents.south_excl_london_ani_80k_to_150k", + "microcosm.centax_childcare_parents.north_and_midlands_ani_80k_to_150k" ] }, "profile_parity": { @@ -29764,6 +29783,700 @@ "category_id": "mhclg.council_tax_stock", "label": "Single adult discount" }, + { + "target_id": "microcosm.centax_childcare_parents.ani_0_to_50k", + "family": "centax_childcare_parents", + "geography_levels": [ + "country" + ], + "calibration_lines": [ + "national" + ], + "measurement": { + "entity": "person", + "map_to": "household", + "concept": "uk.person.count", + "observation_basis": "fiscal_year_flow", + "filters": [ + { + "concept": "uk.childcare.centax_parent_of_eligible_3_4_year_old", + "equals": 1 + }, + { + "concept": "uk.income.adjusted_net.amount", + "operator": ">", + "value": 0 + }, + { + "concept": "uk.income.adjusted_net.amount", + "operator": "<=", + "value": 62386 + } + ] + }, + "bindings": { + "policyengine": { + "metric_name": "microcosm/centax_childcare_parents/ani_0_to_50k", + "value_variable": "person_count", + "from_entity": "person", + "map_to": "household", + "filters": [ + { + "variable": "centax_parent_of_eligible_3_4_year_old", + "equals": 1 + }, + { + "variable": "adjusted_net_income_annual", + "operator": ">", + "value": 0 + }, + { + "variable": "adjusted_net_income_annual", + "operator": "<=", + "value": 62386 + } + ], + "notes": "One of the nine CenTax parent cells (microcosm#1399). centax_parent_of_eligible_3_4_year_old is 1 for an adult (not an HBAI dependent child) of a benefit unit with a dependent child in England whose DfE age (the model year less birth_year, as uk_runtime/dfe_spring_term.py keys it) is 3, 4 or 5 and whose early_years_term_share_before_school exceeds a sixth: a child age-eligible for the universal hours in at least one of the year's three terms, as CenTax counts it. The DfE caseloads read the same age and term share but count the spring term only (share above a half). adjusted_net_income_annual is the year's ANI on the annual-income simulation, because HMRC's data count what each parent received in the tax year. The edges are CenTax's 2021-22 pounds aged to the calibration year by the OBR average earnings index policyengine-uk carries, rounded to whole pounds; the derived-facts loader refuses a resource whose edges differ." + }, + "axiom": { + "metric_name": "microcosm/centax_childcare_parents/ani_0_to_50k", + "status": "pending", + "value_rule": "uk.person.count" + } + }, + "ledger_selector": { + "source_name": "microcosm", + "source_measure_id": "parents_of_eligible_3_4_year_olds", + "record_set_spec_id": "uk.centax_childcare_parents.ani_0_to_50k.v1" + }, + "category_id": "microcosm.centax_childcare_parents", + "label": "Parents of age-eligible 3- and 4-year-olds, ANI up to £50,000 (2021-22 pounds)", + "selector_note": "Microcosm-derived counts (uk/centax_childcare_parent_facts.json, built by tools/build_uk_centax_childcare_parent_facts.py from the workbook CenTax released with Advani, Flew, Pepin-Hall and Summers 2026, Removing the childcare cliff-edge; its SHA-256 is pinned). Each value is a share CenTax published from HMRC's 2021-22 tabulations (Figures 3.1, 3.3, 3.6 and 3.7) times the baseline build's own parents in the top band's range at the calibration year, so the cells set the income distribution's shape and leave its level where the DfE caseloads put it. The bands are wide on purpose: in 2021-22, with the cliff at £100k, the (£80k, £120k] band held both the parents who bunched below it and those they came from above it, and the FRS has too few effective households above £80k to bind narrower bins. Aged to the calibration year, though, that band and the split band start at £99,817 while the cliff stays at £100k, so a parent who bunches that year to below £99,817 leaves them for the band below. Their counts therefore assume no bunching under the frozen cliff, and lean high by about the parents who bunch below that edge: CenTax estimates 2,610 bunching parents of 3- and 4-year-olds in 2022 (p. 22). The edges are aged by average earnings because the spine's incomes are, so each cell keeps its place in the distribution. The five splits cover (£80k, £150k], the widest band every split figure covers, and the three region groups partition it. England only: CenTax tabulates England's free hours. CenTax finds parents through childcare accounts (2018-2025), PAYE parental leave (from 2014-15) and address matching of partners (pp. 12-13 and Appendix A, Figure A.1), so it misses couples with none of these, mostly self-employed or company-owning parents over £100k; the report says this understates parents above £100k relative to those below, so the upper cells may lean low. CenTax's model outputs (the bunching estimates of Tables 3.1, 3.2 and 4.1, frictions, elasticities, marginal bunchers and costs) never enter. Bound on the national line only (calibration_lines); the local line drops the rows." + }, + { + "target_id": "microcosm.centax_childcare_parents.ani_50k_to_80k", + "family": "centax_childcare_parents", + "geography_levels": [ + "country" + ], + "calibration_lines": [ + "national" + ], + "measurement": { + "entity": "person", + "map_to": "household", + "concept": "uk.person.count", + "observation_basis": "fiscal_year_flow", + "filters": [ + { + "concept": "uk.childcare.centax_parent_of_eligible_3_4_year_old", + "equals": 1 + }, + { + "concept": "uk.income.adjusted_net.amount", + "operator": ">", + "value": 62386 + }, + { + "concept": "uk.income.adjusted_net.amount", + "operator": "<=", + "value": 99817 + } + ] + }, + "bindings": { + "policyengine": { + "metric_name": "microcosm/centax_childcare_parents/ani_50k_to_80k", + "value_variable": "person_count", + "from_entity": "person", + "map_to": "household", + "filters": [ + { + "variable": "centax_parent_of_eligible_3_4_year_old", + "equals": 1 + }, + { + "variable": "adjusted_net_income_annual", + "operator": ">", + "value": 62386 + }, + { + "variable": "adjusted_net_income_annual", + "operator": "<=", + "value": 99817 + } + ], + "notes": "One of the nine CenTax parent cells (microcosm#1399). centax_parent_of_eligible_3_4_year_old is 1 for an adult (not an HBAI dependent child) of a benefit unit with a dependent child in England whose DfE age (the model year less birth_year, as uk_runtime/dfe_spring_term.py keys it) is 3, 4 or 5 and whose early_years_term_share_before_school exceeds a sixth: a child age-eligible for the universal hours in at least one of the year's three terms, as CenTax counts it. The DfE caseloads read the same age and term share but count the spring term only (share above a half). adjusted_net_income_annual is the year's ANI on the annual-income simulation, because HMRC's data count what each parent received in the tax year. The edges are CenTax's 2021-22 pounds aged to the calibration year by the OBR average earnings index policyengine-uk carries, rounded to whole pounds; the derived-facts loader refuses a resource whose edges differ." + }, + "axiom": { + "metric_name": "microcosm/centax_childcare_parents/ani_50k_to_80k", + "status": "pending", + "value_rule": "uk.person.count" + } + }, + "ledger_selector": { + "source_name": "microcosm", + "source_measure_id": "parents_of_eligible_3_4_year_olds", + "record_set_spec_id": "uk.centax_childcare_parents.ani_50k_to_80k.v1" + }, + "category_id": "microcosm.centax_childcare_parents", + "label": "Parents of age-eligible 3- and 4-year-olds, ANI over £50,000 to £80,000 (2021-22 pounds)", + "selector_note": "Microcosm-derived counts (uk/centax_childcare_parent_facts.json, built by tools/build_uk_centax_childcare_parent_facts.py from the workbook CenTax released with Advani, Flew, Pepin-Hall and Summers 2026, Removing the childcare cliff-edge; its SHA-256 is pinned). Each value is a share CenTax published from HMRC's 2021-22 tabulations (Figures 3.1, 3.3, 3.6 and 3.7) times the baseline build's own parents in the top band's range at the calibration year, so the cells set the income distribution's shape and leave its level where the DfE caseloads put it. The bands are wide on purpose: in 2021-22, with the cliff at £100k, the (£80k, £120k] band held both the parents who bunched below it and those they came from above it, and the FRS has too few effective households above £80k to bind narrower bins. Aged to the calibration year, though, that band and the split band start at £99,817 while the cliff stays at £100k, so a parent who bunches that year to below £99,817 leaves them for the band below. Their counts therefore assume no bunching under the frozen cliff, and lean high by about the parents who bunch below that edge: CenTax estimates 2,610 bunching parents of 3- and 4-year-olds in 2022 (p. 22). The edges are aged by average earnings because the spine's incomes are, so each cell keeps its place in the distribution. The five splits cover (£80k, £150k], the widest band every split figure covers, and the three region groups partition it. England only: CenTax tabulates England's free hours. CenTax finds parents through childcare accounts (2018-2025), PAYE parental leave (from 2014-15) and address matching of partners (pp. 12-13 and Appendix A, Figure A.1), so it misses couples with none of these, mostly self-employed or company-owning parents over £100k; the report says this understates parents above £100k relative to those below, so the upper cells may lean low. CenTax's model outputs (the bunching estimates of Tables 3.1, 3.2 and 4.1, frictions, elasticities, marginal bunchers and costs) never enter. Bound on the national line only (calibration_lines); the local line drops the rows." + }, + { + "target_id": "microcosm.centax_childcare_parents.ani_80k_to_120k", + "family": "centax_childcare_parents", + "geography_levels": [ + "country" + ], + "calibration_lines": [ + "national" + ], + "measurement": { + "entity": "person", + "map_to": "household", + "concept": "uk.person.count", + "observation_basis": "fiscal_year_flow", + "filters": [ + { + "concept": "uk.childcare.centax_parent_of_eligible_3_4_year_old", + "equals": 1 + }, + { + "concept": "uk.income.adjusted_net.amount", + "operator": ">", + "value": 99817 + }, + { + "concept": "uk.income.adjusted_net.amount", + "operator": "<=", + "value": 149726 + } + ] + }, + "bindings": { + "policyengine": { + "metric_name": "microcosm/centax_childcare_parents/ani_80k_to_120k", + "value_variable": "person_count", + "from_entity": "person", + "map_to": "household", + "filters": [ + { + "variable": "centax_parent_of_eligible_3_4_year_old", + "equals": 1 + }, + { + "variable": "adjusted_net_income_annual", + "operator": ">", + "value": 99817 + }, + { + "variable": "adjusted_net_income_annual", + "operator": "<=", + "value": 149726 + } + ], + "notes": "One of the nine CenTax parent cells (microcosm#1399). centax_parent_of_eligible_3_4_year_old is 1 for an adult (not an HBAI dependent child) of a benefit unit with a dependent child in England whose DfE age (the model year less birth_year, as uk_runtime/dfe_spring_term.py keys it) is 3, 4 or 5 and whose early_years_term_share_before_school exceeds a sixth: a child age-eligible for the universal hours in at least one of the year's three terms, as CenTax counts it. The DfE caseloads read the same age and term share but count the spring term only (share above a half). adjusted_net_income_annual is the year's ANI on the annual-income simulation, because HMRC's data count what each parent received in the tax year. The edges are CenTax's 2021-22 pounds aged to the calibration year by the OBR average earnings index policyengine-uk carries, rounded to whole pounds; the derived-facts loader refuses a resource whose edges differ." + }, + "axiom": { + "metric_name": "microcosm/centax_childcare_parents/ani_80k_to_120k", + "status": "pending", + "value_rule": "uk.person.count" + } + }, + "ledger_selector": { + "source_name": "microcosm", + "source_measure_id": "parents_of_eligible_3_4_year_olds", + "record_set_spec_id": "uk.centax_childcare_parents.ani_80k_to_120k.v1" + }, + "category_id": "microcosm.centax_childcare_parents", + "label": "Parents of age-eligible 3- and 4-year-olds, ANI over £80,000 to £120,000 (2021-22 pounds)", + "selector_note": "Microcosm-derived counts (uk/centax_childcare_parent_facts.json, built by tools/build_uk_centax_childcare_parent_facts.py from the workbook CenTax released with Advani, Flew, Pepin-Hall and Summers 2026, Removing the childcare cliff-edge; its SHA-256 is pinned). Each value is a share CenTax published from HMRC's 2021-22 tabulations (Figures 3.1, 3.3, 3.6 and 3.7) times the baseline build's own parents in the top band's range at the calibration year, so the cells set the income distribution's shape and leave its level where the DfE caseloads put it. The bands are wide on purpose: in 2021-22, with the cliff at £100k, the (£80k, £120k] band held both the parents who bunched below it and those they came from above it, and the FRS has too few effective households above £80k to bind narrower bins. Aged to the calibration year, though, that band and the split band start at £99,817 while the cliff stays at £100k, so a parent who bunches that year to below £99,817 leaves them for the band below. Their counts therefore assume no bunching under the frozen cliff, and lean high by about the parents who bunch below that edge: CenTax estimates 2,610 bunching parents of 3- and 4-year-olds in 2022 (p. 22). The edges are aged by average earnings because the spine's incomes are, so each cell keeps its place in the distribution. The five splits cover (£80k, £150k], the widest band every split figure covers, and the three region groups partition it. England only: CenTax tabulates England's free hours. CenTax finds parents through childcare accounts (2018-2025), PAYE parental leave (from 2014-15) and address matching of partners (pp. 12-13 and Appendix A, Figure A.1), so it misses couples with none of these, mostly self-employed or company-owning parents over £100k; the report says this understates parents above £100k relative to those below, so the upper cells may lean low. CenTax's model outputs (the bunching estimates of Tables 3.1, 3.2 and 4.1, frictions, elasticities, marginal bunchers and costs) never enter. Bound on the national line only (calibration_lines); the local line drops the rows." + }, + { + "target_id": "microcosm.centax_childcare_parents.ani_120k_to_200k", + "family": "centax_childcare_parents", + "geography_levels": [ + "country" + ], + "calibration_lines": [ + "national" + ], + "measurement": { + "entity": "person", + "map_to": "household", + "concept": "uk.person.count", + "observation_basis": "fiscal_year_flow", + "filters": [ + { + "concept": "uk.childcare.centax_parent_of_eligible_3_4_year_old", + "equals": 1 + }, + { + "concept": "uk.income.adjusted_net.amount", + "operator": ">", + "value": 149726 + }, + { + "concept": "uk.income.adjusted_net.amount", + "operator": "<=", + "value": 249543 + } + ] + }, + "bindings": { + "policyengine": { + "metric_name": "microcosm/centax_childcare_parents/ani_120k_to_200k", + "value_variable": "person_count", + "from_entity": "person", + "map_to": "household", + "filters": [ + { + "variable": "centax_parent_of_eligible_3_4_year_old", + "equals": 1 + }, + { + "variable": "adjusted_net_income_annual", + "operator": ">", + "value": 149726 + }, + { + "variable": "adjusted_net_income_annual", + "operator": "<=", + "value": 249543 + } + ], + "notes": "One of the nine CenTax parent cells (microcosm#1399). centax_parent_of_eligible_3_4_year_old is 1 for an adult (not an HBAI dependent child) of a benefit unit with a dependent child in England whose DfE age (the model year less birth_year, as uk_runtime/dfe_spring_term.py keys it) is 3, 4 or 5 and whose early_years_term_share_before_school exceeds a sixth: a child age-eligible for the universal hours in at least one of the year's three terms, as CenTax counts it. The DfE caseloads read the same age and term share but count the spring term only (share above a half). adjusted_net_income_annual is the year's ANI on the annual-income simulation, because HMRC's data count what each parent received in the tax year. The edges are CenTax's 2021-22 pounds aged to the calibration year by the OBR average earnings index policyengine-uk carries, rounded to whole pounds; the derived-facts loader refuses a resource whose edges differ." + }, + "axiom": { + "metric_name": "microcosm/centax_childcare_parents/ani_120k_to_200k", + "status": "pending", + "value_rule": "uk.person.count" + } + }, + "ledger_selector": { + "source_name": "microcosm", + "source_measure_id": "parents_of_eligible_3_4_year_olds", + "record_set_spec_id": "uk.centax_childcare_parents.ani_120k_to_200k.v1" + }, + "category_id": "microcosm.centax_childcare_parents", + "label": "Parents of age-eligible 3- and 4-year-olds, ANI over £120,000 to £200,000 (2021-22 pounds)", + "selector_note": "Microcosm-derived counts (uk/centax_childcare_parent_facts.json, built by tools/build_uk_centax_childcare_parent_facts.py from the workbook CenTax released with Advani, Flew, Pepin-Hall and Summers 2026, Removing the childcare cliff-edge; its SHA-256 is pinned). Each value is a share CenTax published from HMRC's 2021-22 tabulations (Figures 3.1, 3.3, 3.6 and 3.7) times the baseline build's own parents in the top band's range at the calibration year, so the cells set the income distribution's shape and leave its level where the DfE caseloads put it. The bands are wide on purpose: in 2021-22, with the cliff at £100k, the (£80k, £120k] band held both the parents who bunched below it and those they came from above it, and the FRS has too few effective households above £80k to bind narrower bins. Aged to the calibration year, though, that band and the split band start at £99,817 while the cliff stays at £100k, so a parent who bunches that year to below £99,817 leaves them for the band below. Their counts therefore assume no bunching under the frozen cliff, and lean high by about the parents who bunch below that edge: CenTax estimates 2,610 bunching parents of 3- and 4-year-olds in 2022 (p. 22). The edges are aged by average earnings because the spine's incomes are, so each cell keeps its place in the distribution. The five splits cover (£80k, £150k], the widest band every split figure covers, and the three region groups partition it. England only: CenTax tabulates England's free hours. CenTax finds parents through childcare accounts (2018-2025), PAYE parental leave (from 2014-15) and address matching of partners (pp. 12-13 and Appendix A, Figure A.1), so it misses couples with none of these, mostly self-employed or company-owning parents over £100k; the report says this understates parents above £100k relative to those below, so the upper cells may lean low. CenTax's model outputs (the bunching estimates of Tables 3.1, 3.2 and 4.1, frictions, elasticities, marginal bunchers and costs) never enter. Bound on the national line only (calibration_lines); the local line drops the rows." + }, + { + "target_id": "microcosm.centax_childcare_parents.female_ani_80k_to_150k", + "family": "centax_childcare_parents", + "geography_levels": [ + "country" + ], + "calibration_lines": [ + "national" + ], + "measurement": { + "entity": "person", + "map_to": "household", + "concept": "uk.person.count", + "observation_basis": "fiscal_year_flow", + "filters": [ + { + "concept": "uk.childcare.centax_parent_of_eligible_3_4_year_old", + "equals": 1 + }, + { + "concept": "uk.income.adjusted_net.amount", + "operator": ">", + "value": 99817 + }, + { + "concept": "uk.income.adjusted_net.amount", + "operator": "<=", + "value": 187158 + }, + { + "concept": "uk.demographics.gender", + "equals": "FEMALE" + } + ] + }, + "bindings": { + "policyengine": { + "metric_name": "microcosm/centax_childcare_parents/female_ani_80k_to_150k", + "value_variable": "person_count", + "from_entity": "person", + "map_to": "household", + "filters": [ + { + "variable": "centax_parent_of_eligible_3_4_year_old", + "equals": 1 + }, + { + "variable": "adjusted_net_income_annual", + "operator": ">", + "value": 99817 + }, + { + "variable": "adjusted_net_income_annual", + "operator": "<=", + "value": 187158 + }, + { + "variable": "gender", + "equals": "FEMALE" + } + ], + "notes": "One of the nine CenTax parent cells (microcosm#1399). centax_parent_of_eligible_3_4_year_old is 1 for an adult (not an HBAI dependent child) of a benefit unit with a dependent child in England whose DfE age (the model year less birth_year, as uk_runtime/dfe_spring_term.py keys it) is 3, 4 or 5 and whose early_years_term_share_before_school exceeds a sixth: a child age-eligible for the universal hours in at least one of the year's three terms, as CenTax counts it. The DfE caseloads read the same age and term share but count the spring term only (share above a half). adjusted_net_income_annual is the year's ANI on the annual-income simulation, because HMRC's data count what each parent received in the tax year. The edges are CenTax's 2021-22 pounds aged to the calibration year by the OBR average earnings index policyengine-uk carries, rounded to whole pounds; the derived-facts loader refuses a resource whose edges differ." + }, + "axiom": { + "metric_name": "microcosm/centax_childcare_parents/female_ani_80k_to_150k", + "status": "pending", + "value_rule": "uk.person.count" + } + }, + "ledger_selector": { + "source_name": "microcosm", + "source_measure_id": "parents_of_eligible_3_4_year_olds", + "record_set_spec_id": "uk.centax_childcare_parents.female_ani_80k_to_150k.v1" + }, + "category_id": "microcosm.centax_childcare_parents", + "label": "Parents of age-eligible 3- and 4-year-olds, women, ANI over £80,000 to £150,000 (2021-22 pounds)", + "selector_note": "Microcosm-derived counts (uk/centax_childcare_parent_facts.json, built by tools/build_uk_centax_childcare_parent_facts.py from the workbook CenTax released with Advani, Flew, Pepin-Hall and Summers 2026, Removing the childcare cliff-edge; its SHA-256 is pinned). Each value is a share CenTax published from HMRC's 2021-22 tabulations (Figures 3.1, 3.3, 3.6 and 3.7) times the baseline build's own parents in the top band's range at the calibration year, so the cells set the income distribution's shape and leave its level where the DfE caseloads put it. The bands are wide on purpose: in 2021-22, with the cliff at £100k, the (£80k, £120k] band held both the parents who bunched below it and those they came from above it, and the FRS has too few effective households above £80k to bind narrower bins. Aged to the calibration year, though, that band and the split band start at £99,817 while the cliff stays at £100k, so a parent who bunches that year to below £99,817 leaves them for the band below. Their counts therefore assume no bunching under the frozen cliff, and lean high by about the parents who bunch below that edge: CenTax estimates 2,610 bunching parents of 3- and 4-year-olds in 2022 (p. 22). The edges are aged by average earnings because the spine's incomes are, so each cell keeps its place in the distribution. The five splits cover (£80k, £150k], the widest band every split figure covers, and the three region groups partition it. England only: CenTax tabulates England's free hours. CenTax finds parents through childcare accounts (2018-2025), PAYE parental leave (from 2014-15) and address matching of partners (pp. 12-13 and Appendix A, Figure A.1), so it misses couples with none of these, mostly self-employed or company-owning parents over £100k; the report says this understates parents above £100k relative to those below, so the upper cells may lean low. CenTax's model outputs (the bunching estimates of Tables 3.1, 3.2 and 4.1, frictions, elasticities, marginal bunchers and costs) never enter. Bound on the national line only (calibration_lines); the local line drops the rows." + }, + { + "target_id": "microcosm.centax_childcare_parents.non_employee_ani_80k_to_150k", + "family": "centax_childcare_parents", + "geography_levels": [ + "country" + ], + "calibration_lines": [ + "national" + ], + "measurement": { + "entity": "person", + "map_to": "household", + "concept": "uk.person.count", + "observation_basis": "fiscal_year_flow", + "filters": [ + { + "concept": "uk.childcare.centax_parent_of_eligible_3_4_year_old", + "equals": 1 + }, + { + "concept": "uk.income.adjusted_net.amount", + "operator": ">", + "value": 99817 + }, + { + "concept": "uk.income.adjusted_net.amount", + "operator": "<=", + "value": 187158 + }, + { + "concept": "uk.income.employment.main_source", + "equals": 0 + } + ] + }, + "bindings": { + "policyengine": { + "metric_name": "microcosm/centax_childcare_parents/non_employee_ani_80k_to_150k", + "value_variable": "person_count", + "from_entity": "person", + "map_to": "household", + "filters": [ + { + "variable": "centax_parent_of_eligible_3_4_year_old", + "equals": 1 + }, + { + "variable": "adjusted_net_income_annual", + "operator": ">", + "value": 99817 + }, + { + "variable": "adjusted_net_income_annual", + "operator": "<=", + "value": 187158 + }, + { + "variable": "centax_employment_main_income_source", + "equals": 0 + } + ], + "notes": "One of the nine CenTax parent cells (microcosm#1399). centax_parent_of_eligible_3_4_year_old is 1 for an adult (not an HBAI dependent child) of a benefit unit with a dependent child in England whose DfE age (the model year less birth_year, as uk_runtime/dfe_spring_term.py keys it) is 3, 4 or 5 and whose early_years_term_share_before_school exceeds a sixth: a child age-eligible for the universal hours in at least one of the year's three terms, as CenTax counts it. The DfE caseloads read the same age and term share but count the spring term only (share above a half). adjusted_net_income_annual is the year's ANI on the annual-income simulation, because HMRC's data count what each parent received in the tax year. The edges are CenTax's 2021-22 pounds aged to the calibration year by the OBR average earnings index policyengine-uk carries, rounded to whole pounds; the derived-facts loader refuses a resource whose edges differ." + }, + "axiom": { + "metric_name": "microcosm/centax_childcare_parents/non_employee_ani_80k_to_150k", + "status": "pending", + "value_rule": "uk.person.count" + } + }, + "ledger_selector": { + "source_name": "microcosm", + "source_measure_id": "parents_of_eligible_3_4_year_olds", + "record_set_spec_id": "uk.centax_childcare_parents.non_employee_ani_80k_to_150k.v1" + }, + "category_id": "microcosm.centax_childcare_parents", + "label": "Parents of age-eligible 3- and 4-year-olds, main income source other than employment, ANI over £80,000 to £150,000 (2021-22 pounds)", + "selector_note": "Microcosm-derived counts (uk/centax_childcare_parent_facts.json, built by tools/build_uk_centax_childcare_parent_facts.py from the workbook CenTax released with Advani, Flew, Pepin-Hall and Summers 2026, Removing the childcare cliff-edge; its SHA-256 is pinned). Each value is a share CenTax published from HMRC's 2021-22 tabulations (Figures 3.1, 3.3, 3.6 and 3.7) times the baseline build's own parents in the top band's range at the calibration year, so the cells set the income distribution's shape and leave its level where the DfE caseloads put it. The bands are wide on purpose: in 2021-22, with the cliff at £100k, the (£80k, £120k] band held both the parents who bunched below it and those they came from above it, and the FRS has too few effective households above £80k to bind narrower bins. Aged to the calibration year, though, that band and the split band start at £99,817 while the cliff stays at £100k, so a parent who bunches that year to below £99,817 leaves them for the band below. Their counts therefore assume no bunching under the frozen cliff, and lean high by about the parents who bunch below that edge: CenTax estimates 2,610 bunching parents of 3- and 4-year-olds in 2022 (p. 22). The edges are aged by average earnings because the spine's incomes are, so each cell keeps its place in the distribution. The five splits cover (£80k, £150k], the widest band every split figure covers, and the three region groups partition it. England only: CenTax tabulates England's free hours. CenTax finds parents through childcare accounts (2018-2025), PAYE parental leave (from 2014-15) and address matching of partners (pp. 12-13 and Appendix A, Figure A.1), so it misses couples with none of these, mostly self-employed or company-owning parents over £100k; the report says this understates parents above £100k relative to those below, so the upper cells may lean low. CenTax's model outputs (the bunching estimates of Tables 3.1, 3.2 and 4.1, frictions, elasticities, marginal bunchers and costs) never enter. Bound on the national line only (calibration_lines); the local line drops the rows." + }, + { + "target_id": "microcosm.centax_childcare_parents.london_ani_80k_to_150k", + "family": "centax_childcare_parents", + "geography_levels": [ + "country" + ], + "calibration_lines": [ + "national" + ], + "measurement": { + "entity": "person", + "map_to": "household", + "concept": "uk.person.count", + "observation_basis": "fiscal_year_flow", + "filters": [ + { + "concept": "uk.childcare.centax_parent_of_eligible_3_4_year_old", + "equals": 1 + }, + { + "concept": "uk.income.adjusted_net.amount", + "operator": ">", + "value": 99817 + }, + { + "concept": "uk.income.adjusted_net.amount", + "operator": "<=", + "value": 187158 + }, + { + "concept": "uk.geography.region", + "in": [ + "LONDON" + ] + } + ] + }, + "bindings": { + "policyengine": { + "metric_name": "microcosm/centax_childcare_parents/london_ani_80k_to_150k", + "value_variable": "person_count", + "from_entity": "person", + "map_to": "household", + "filters": [ + { + "variable": "centax_parent_of_eligible_3_4_year_old", + "equals": 1 + }, + { + "variable": "adjusted_net_income_annual", + "operator": ">", + "value": 99817 + }, + { + "variable": "adjusted_net_income_annual", + "operator": "<=", + "value": 187158 + }, + { + "variable": "region", + "in": [ + "LONDON" + ] + } + ], + "notes": "One of the nine CenTax parent cells (microcosm#1399). centax_parent_of_eligible_3_4_year_old is 1 for an adult (not an HBAI dependent child) of a benefit unit with a dependent child in England whose DfE age (the model year less birth_year, as uk_runtime/dfe_spring_term.py keys it) is 3, 4 or 5 and whose early_years_term_share_before_school exceeds a sixth: a child age-eligible for the universal hours in at least one of the year's three terms, as CenTax counts it. The DfE caseloads read the same age and term share but count the spring term only (share above a half). adjusted_net_income_annual is the year's ANI on the annual-income simulation, because HMRC's data count what each parent received in the tax year. The edges are CenTax's 2021-22 pounds aged to the calibration year by the OBR average earnings index policyengine-uk carries, rounded to whole pounds; the derived-facts loader refuses a resource whose edges differ." + }, + "axiom": { + "metric_name": "microcosm/centax_childcare_parents/london_ani_80k_to_150k", + "status": "pending", + "value_rule": "uk.person.count" + } + }, + "ledger_selector": { + "source_name": "microcosm", + "source_measure_id": "parents_of_eligible_3_4_year_olds", + "record_set_spec_id": "uk.centax_childcare_parents.london_ani_80k_to_150k.v1" + }, + "category_id": "microcosm.centax_childcare_parents", + "label": "Parents of age-eligible 3- and 4-year-olds, London, ANI over £80,000 to £150,000 (2021-22 pounds)", + "selector_note": "Microcosm-derived counts (uk/centax_childcare_parent_facts.json, built by tools/build_uk_centax_childcare_parent_facts.py from the workbook CenTax released with Advani, Flew, Pepin-Hall and Summers 2026, Removing the childcare cliff-edge; its SHA-256 is pinned). Each value is a share CenTax published from HMRC's 2021-22 tabulations (Figures 3.1, 3.3, 3.6 and 3.7) times the baseline build's own parents in the top band's range at the calibration year, so the cells set the income distribution's shape and leave its level where the DfE caseloads put it. The bands are wide on purpose: in 2021-22, with the cliff at £100k, the (£80k, £120k] band held both the parents who bunched below it and those they came from above it, and the FRS has too few effective households above £80k to bind narrower bins. Aged to the calibration year, though, that band and the split band start at £99,817 while the cliff stays at £100k, so a parent who bunches that year to below £99,817 leaves them for the band below. Their counts therefore assume no bunching under the frozen cliff, and lean high by about the parents who bunch below that edge: CenTax estimates 2,610 bunching parents of 3- and 4-year-olds in 2022 (p. 22). The edges are aged by average earnings because the spine's incomes are, so each cell keeps its place in the distribution. The five splits cover (£80k, £150k], the widest band every split figure covers, and the three region groups partition it. England only: CenTax tabulates England's free hours. CenTax finds parents through childcare accounts (2018-2025), PAYE parental leave (from 2014-15) and address matching of partners (pp. 12-13 and Appendix A, Figure A.1), so it misses couples with none of these, mostly self-employed or company-owning parents over £100k; the report says this understates parents above £100k relative to those below, so the upper cells may lean low. CenTax's model outputs (the bunching estimates of Tables 3.1, 3.2 and 4.1, frictions, elasticities, marginal bunchers and costs) never enter. Bound on the national line only (calibration_lines); the local line drops the rows." + }, + { + "target_id": "microcosm.centax_childcare_parents.south_excl_london_ani_80k_to_150k", + "family": "centax_childcare_parents", + "geography_levels": [ + "country" + ], + "calibration_lines": [ + "national" + ], + "measurement": { + "entity": "person", + "map_to": "household", + "concept": "uk.person.count", + "observation_basis": "fiscal_year_flow", + "filters": [ + { + "concept": "uk.childcare.centax_parent_of_eligible_3_4_year_old", + "equals": 1 + }, + { + "concept": "uk.income.adjusted_net.amount", + "operator": ">", + "value": 99817 + }, + { + "concept": "uk.income.adjusted_net.amount", + "operator": "<=", + "value": 187158 + }, + { + "concept": "uk.geography.region", + "in": [ + "EAST_OF_ENGLAND", + "SOUTH_EAST", + "SOUTH_WEST" + ] + } + ] + }, + "bindings": { + "policyengine": { + "metric_name": "microcosm/centax_childcare_parents/south_excl_london_ani_80k_to_150k", + "value_variable": "person_count", + "from_entity": "person", + "map_to": "household", + "filters": [ + { + "variable": "centax_parent_of_eligible_3_4_year_old", + "equals": 1 + }, + { + "variable": "adjusted_net_income_annual", + "operator": ">", + "value": 99817 + }, + { + "variable": "adjusted_net_income_annual", + "operator": "<=", + "value": 187158 + }, + { + "variable": "region", + "in": [ + "EAST_OF_ENGLAND", + "SOUTH_EAST", + "SOUTH_WEST" + ] + } + ], + "notes": "One of the nine CenTax parent cells (microcosm#1399). centax_parent_of_eligible_3_4_year_old is 1 for an adult (not an HBAI dependent child) of a benefit unit with a dependent child in England whose DfE age (the model year less birth_year, as uk_runtime/dfe_spring_term.py keys it) is 3, 4 or 5 and whose early_years_term_share_before_school exceeds a sixth: a child age-eligible for the universal hours in at least one of the year's three terms, as CenTax counts it. The DfE caseloads read the same age and term share but count the spring term only (share above a half). adjusted_net_income_annual is the year's ANI on the annual-income simulation, because HMRC's data count what each parent received in the tax year. The edges are CenTax's 2021-22 pounds aged to the calibration year by the OBR average earnings index policyengine-uk carries, rounded to whole pounds; the derived-facts loader refuses a resource whose edges differ." + }, + "axiom": { + "metric_name": "microcosm/centax_childcare_parents/south_excl_london_ani_80k_to_150k", + "status": "pending", + "value_rule": "uk.person.count" + } + }, + "ledger_selector": { + "source_name": "microcosm", + "source_measure_id": "parents_of_eligible_3_4_year_olds", + "record_set_spec_id": "uk.centax_childcare_parents.south_excl_london_ani_80k_to_150k.v1" + }, + "category_id": "microcosm.centax_childcare_parents", + "label": "Parents of age-eligible 3- and 4-year-olds, South excluding London, ANI over £80,000 to £150,000 (2021-22 pounds)", + "selector_note": "Microcosm-derived counts (uk/centax_childcare_parent_facts.json, built by tools/build_uk_centax_childcare_parent_facts.py from the workbook CenTax released with Advani, Flew, Pepin-Hall and Summers 2026, Removing the childcare cliff-edge; its SHA-256 is pinned). Each value is a share CenTax published from HMRC's 2021-22 tabulations (Figures 3.1, 3.3, 3.6 and 3.7) times the baseline build's own parents in the top band's range at the calibration year, so the cells set the income distribution's shape and leave its level where the DfE caseloads put it. The bands are wide on purpose: in 2021-22, with the cliff at £100k, the (£80k, £120k] band held both the parents who bunched below it and those they came from above it, and the FRS has too few effective households above £80k to bind narrower bins. Aged to the calibration year, though, that band and the split band start at £99,817 while the cliff stays at £100k, so a parent who bunches that year to below £99,817 leaves them for the band below. Their counts therefore assume no bunching under the frozen cliff, and lean high by about the parents who bunch below that edge: CenTax estimates 2,610 bunching parents of 3- and 4-year-olds in 2022 (p. 22). The edges are aged by average earnings because the spine's incomes are, so each cell keeps its place in the distribution. The five splits cover (£80k, £150k], the widest band every split figure covers, and the three region groups partition it. England only: CenTax tabulates England's free hours. CenTax finds parents through childcare accounts (2018-2025), PAYE parental leave (from 2014-15) and address matching of partners (pp. 12-13 and Appendix A, Figure A.1), so it misses couples with none of these, mostly self-employed or company-owning parents over £100k; the report says this understates parents above £100k relative to those below, so the upper cells may lean low. CenTax's model outputs (the bunching estimates of Tables 3.1, 3.2 and 4.1, frictions, elasticities, marginal bunchers and costs) never enter. Bound on the national line only (calibration_lines); the local line drops the rows." + }, + { + "target_id": "microcosm.centax_childcare_parents.north_and_midlands_ani_80k_to_150k", + "family": "centax_childcare_parents", + "geography_levels": [ + "country" + ], + "calibration_lines": [ + "national" + ], + "measurement": { + "entity": "person", + "map_to": "household", + "concept": "uk.person.count", + "observation_basis": "fiscal_year_flow", + "filters": [ + { + "concept": "uk.childcare.centax_parent_of_eligible_3_4_year_old", + "equals": 1 + }, + { + "concept": "uk.income.adjusted_net.amount", + "operator": ">", + "value": 99817 + }, + { + "concept": "uk.income.adjusted_net.amount", + "operator": "<=", + "value": 187158 + }, + { + "concept": "uk.geography.region", + "in": [ + "NORTH_EAST", + "NORTH_WEST", + "YORKSHIRE", + "EAST_MIDLANDS", + "WEST_MIDLANDS" + ] + } + ] + }, + "bindings": { + "policyengine": { + "metric_name": "microcosm/centax_childcare_parents/north_and_midlands_ani_80k_to_150k", + "value_variable": "person_count", + "from_entity": "person", + "map_to": "household", + "filters": [ + { + "variable": "centax_parent_of_eligible_3_4_year_old", + "equals": 1 + }, + { + "variable": "adjusted_net_income_annual", + "operator": ">", + "value": 99817 + }, + { + "variable": "adjusted_net_income_annual", + "operator": "<=", + "value": 187158 + }, + { + "variable": "region", + "in": [ + "NORTH_EAST", + "NORTH_WEST", + "YORKSHIRE", + "EAST_MIDLANDS", + "WEST_MIDLANDS" + ] + } + ], + "notes": "One of the nine CenTax parent cells (microcosm#1399). centax_parent_of_eligible_3_4_year_old is 1 for an adult (not an HBAI dependent child) of a benefit unit with a dependent child in England whose DfE age (the model year less birth_year, as uk_runtime/dfe_spring_term.py keys it) is 3, 4 or 5 and whose early_years_term_share_before_school exceeds a sixth: a child age-eligible for the universal hours in at least one of the year's three terms, as CenTax counts it. The DfE caseloads read the same age and term share but count the spring term only (share above a half). adjusted_net_income_annual is the year's ANI on the annual-income simulation, because HMRC's data count what each parent received in the tax year. The edges are CenTax's 2021-22 pounds aged to the calibration year by the OBR average earnings index policyengine-uk carries, rounded to whole pounds; the derived-facts loader refuses a resource whose edges differ." + }, + "axiom": { + "metric_name": "microcosm/centax_childcare_parents/north_and_midlands_ani_80k_to_150k", + "status": "pending", + "value_rule": "uk.person.count" + } + }, + "ledger_selector": { + "source_name": "microcosm", + "source_measure_id": "parents_of_eligible_3_4_year_olds", + "record_set_spec_id": "uk.centax_childcare_parents.north_and_midlands_ani_80k_to_150k.v1" + }, + "category_id": "microcosm.centax_childcare_parents", + "label": "Parents of age-eligible 3- and 4-year-olds, North and Midlands, ANI over £80,000 to £150,000 (2021-22 pounds)", + "selector_note": "Microcosm-derived counts (uk/centax_childcare_parent_facts.json, built by tools/build_uk_centax_childcare_parent_facts.py from the workbook CenTax released with Advani, Flew, Pepin-Hall and Summers 2026, Removing the childcare cliff-edge; its SHA-256 is pinned). Each value is a share CenTax published from HMRC's 2021-22 tabulations (Figures 3.1, 3.3, 3.6 and 3.7) times the baseline build's own parents in the top band's range at the calibration year, so the cells set the income distribution's shape and leave its level where the DfE caseloads put it. The bands are wide on purpose: in 2021-22, with the cliff at £100k, the (£80k, £120k] band held both the parents who bunched below it and those they came from above it, and the FRS has too few effective households above £80k to bind narrower bins. Aged to the calibration year, though, that band and the split band start at £99,817 while the cliff stays at £100k, so a parent who bunches that year to below £99,817 leaves them for the band below. Their counts therefore assume no bunching under the frozen cliff, and lean high by about the parents who bunch below that edge: CenTax estimates 2,610 bunching parents of 3- and 4-year-olds in 2022 (p. 22). The edges are aged by average earnings because the spine's incomes are, so each cell keeps its place in the distribution. The five splits cover (£80k, £150k], the widest band every split figure covers, and the three region groups partition it. England only: CenTax tabulates England's free hours. CenTax finds parents through childcare accounts (2018-2025), PAYE parental leave (from 2014-15) and address matching of partners (pp. 12-13 and Appendix A, Figure A.1), so it misses couples with none of these, mostly self-employed or company-owning parents over £100k; the report says this understates parents above £100k relative to those below, so the upper cells may lean low. CenTax's model outputs (the bunching estimates of Tables 3.1, 3.2 and 4.1, frictions, elasticities, marginal bunchers and costs) never enter. Bound on the national line only (calibration_lines); the local line drops the rows." + }, { "target_id": "hmrc.self_employment_income.amount", "family": "hmrc", @@ -33949,6 +34662,10 @@ "dft.local_bus_journeys": { "provider_id": "dft", "label": "Local bus passenger journeys" + }, + "microcosm.centax_childcare_parents": { + "provider_id": "microcosm", + "label": "Parents of 3- and 4-year-olds by income (CenTax)" } } } diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/centax_childcare_parents.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/centax_childcare_parents.py new file mode 100644 index 0000000000..68f57afade --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/centax_childcare_parents.py @@ -0,0 +1,560 @@ +"""CenTax's HMRC tabulations of parents of 3- and 4-year-olds, as derived facts. + +Advani, Flew, Pepin-Hall and Summers (CenTax, September 2026), *Removing the +childcare cliff-edge*, tabulate HMRC's administrative data for tax year +2021-22 (their "2022"): the adjusted net income (ANI) of every identified +parent in England with a child age-eligible for the free hours in at least +one term of the year (Figure 3.1), split by main source of income (3.3), sex +(3.6) and region (3.7, pooled 2019-2022). The committed resource +``uk/centax_childcare_parent_facts.json`` turns those tabulations into nine +counts at England grain, built by ``tools/build_uk_centax_childcare_parent_facts.py`` +from the workbook the authors released (its SHA-256 is pinned): + +- four ANI bands, (0, £50k], (£50k, £80k], (£80k, £120k] and (£120k, £200k] + in 2021-22 pounds, each Figure 3.1's share of the parents in (0, £200k]; +- five splits of the parents in (£80k, £150k], the widest band every split + figure covers: female (3.6), main source other than employment (3.3), and + London, the South excluding London and the North and Midlands (3.7). + +Microcosm targets are sums, so each share becomes a count: the share times +``parent_count``, the model's own number of such parents in (0, £200k] at the +calibration year, read off the baseline build named in the resource. The +targets set the distribution's shape and leave its level where the DfE +caseloads put it. Only the published tabulations are used. CenTax's model +outputs (bunching estimates, frictions, elasticities, marginal bunchers, +costs) never enter the resource. + +The band edges are in 2021-22 pounds. The contract filters age them to the +calibration year by the OBR average earnings index policyengine-uk carries +(``ani_uprating``), rounded to whole pounds, and the loader refuses a contract +whose edges differ. ANI is the year's annual concept +(``adjusted_net_income_annual``): HMRC's data count what each parent received +in the tax year. + +This module is the one place that knows the resource's shape and the two +measures the targets read: ``centax_parent_of_eligible_3_4_year_old`` (an +adult in a benefit unit with such a child, as policyengine-uk dates its +terms) and ``centax_employment_main_income_source`` (employment is the +largest income source in the year). +""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from functools import cache +from importlib import resources +from pathlib import Path +from types import MappingProxyType +from typing import Any + +import numpy as np + +from microcosm.calibrate.geography_constants import UK_REGION_TIER_ENUM + +__all__ = [ + "CENTAX_ANI_BANDS_2022", + "CENTAX_ANI_MEASURE", + "CENTAX_DATA_ENGINE_YEAR", + "CENTAX_DATA_TAX_YEAR", + "CENTAX_DERIVED_FACT_KEY_PREFIX", + "CENTAX_ELIGIBLE_DFE_AGES", + "CENTAX_EMPLOYEE_MAIN_SOURCE", + "CENTAX_EMPLOYEE_SOURCE_ENGINE_NAMES", + "CENTAX_EMPLOYEE_SOURCE_INPUTS", + "CENTAX_FACTS_RESOURCE", + "CENTAX_FILTER_CONCEPTS", + "CENTAX_PARENT_ENGINE_INPUTS", + "CENTAX_PARENT_MEASURES", + "CENTAX_PARENT_OF_ELIGIBLE_3_4", + "CENTAX_REGION_GROUPS", + "CENTAX_REGION_GROUP_CODES", + "CENTAX_REPORT_URL", + "CENTAX_SPLIT_BAND_2022", + "CENTAX_SPLITS", + "CENTAX_WORKBOOK_SHA256", + "UKCenTaxChildcareParentFacts", + "centax_ani_uprating", + "centax_cell_filters", + "centax_cell_measurement_filters", + "centax_cell_ids", + "employment_main_income_source", + "load_uk_centax_childcare_parent_facts", + "model_ani_edges", + "parent_of_eligible_3_4_year_old", + "rows_sha256", + "uk_centax_childcare_parent_facts", +] + +CENTAX_FACTS_RESOURCE = "centax_childcare_parent_facts.json" +CENTAX_DERIVED_FACT_KEY_PREFIX = "microcosm.derived_fact.centax_childcare_parents.v1:" +RESOURCE_KIND = "uk_derived_consumer_facts" +ROW_SCHEMA_VERSION = "microcosm.derived_consumer_fact.v1" +#: The workbook the authors released with the report (Arun Advani to Vahid +#: Ahmadi, 21 October 2026): AdvaniFlewPepinhallSummers2026_ChildcareThreshold_data.xlsx. +CENTAX_WORKBOOK_SHA256 = ( + "8c23855745154a601064e766552c64bc2e88de51e043b4df1397c580e969354b" +) +CENTAX_REPORT_URL = ( + "https://centax.org.uk/wp-content/uploads/2026/09/" + "AdvaniFlewPepin-HallSummers2026_Removing-the-childcare-cliff-edge.pdf" +) +#: Tax year of the HMRC data behind Figures 3.1, 3.3 and 3.6; policyengine-uk +#: dates it 2021, the year the tax year starts in. +CENTAX_DATA_TAX_YEAR = "2021-22" +CENTAX_DATA_ENGINE_YEAR = 2021 + +#: ANI bands in 2021-22 pounds, lower edge exclusive and upper edge inclusive, +#: as the workbook's bins are labelled by their upper edge. Wide around £100k +#: on purpose (see the contract's selector notes). +CENTAX_ANI_BANDS_2022: tuple[tuple[str, float, float], ...] = ( + ("ani_0_to_50k", 0.0, 50_000.0), + ("ani_50k_to_80k", 50_000.0, 80_000.0), + ("ani_80k_to_120k", 80_000.0, 120_000.0), + ("ani_120k_to_200k", 120_000.0, 200_000.0), +) +#: The band every split figure covers (Figures 3.3, 3.6 and 3.7 stop at £150k). +CENTAX_SPLIT_BAND_2022 = (80_000.0, 150_000.0) +#: Region groups of Figure 3.7, by English region GSS code. The figure does +#: not list its regions; the East of England sits in the South because only +#: then do the figure's low-income shares match the ONS child-population shares +#: of the three groups. +CENTAX_REGION_GROUP_CODES: Mapping[str, tuple[str, ...]] = MappingProxyType( + { + "london": ("E12000007",), + "south_excl_london": ("E12000006", "E12000008", "E12000009"), + "north_and_midlands": ( + "E12000001", + "E12000002", + "E12000003", + "E12000004", + "E12000005", + ), + } +) +#: The same groups by the spine's ``region`` enum name, the value the +#: contract filters compare against. +CENTAX_REGION_GROUPS: Mapping[str, tuple[str, ...]] = MappingProxyType( + { + group: tuple(UK_REGION_TIER_ENUM[code] for code in codes) + for group, codes in CENTAX_REGION_GROUP_CODES.items() + } +) +#: The split cells, each with the workbook figure it reads. +CENTAX_SPLITS: tuple[tuple[str, str], ...] = ( + ("female", "Fig. 3.6"), + ("non_employee", "Fig. 3.3"), + ("london", "Fig. 3.7"), + ("south_excl_london", "Fig. 3.7"), + ("north_and_midlands", "Fig. 3.7"), +) + +CENTAX_PARENT_OF_ELIGIBLE_3_4 = "centax_parent_of_eligible_3_4_year_old" +CENTAX_EMPLOYEE_MAIN_SOURCE = "centax_employment_main_income_source" +CENTAX_PARENT_MEASURES = (CENTAX_PARENT_OF_ELIGIBLE_3_4, CENTAX_EMPLOYEE_MAIN_SOURCE) +#: Engine variables the parent measure reads at person grain. The spine sets +#: ``is_hbai_dependent_child`` for the FRS child-table members of each benefit +#: unit, so the others are its adults, the claimant and any partner; SPI +#: cloning keeps the roles. +CENTAX_PARENT_ENGINE_INPUTS = ( + "birth_year", + "country", + "early_years_term_share_before_school", + "is_hbai_dependent_child", +) +#: The year's income sources the main-source measure compares, employment +#: first: every component of the engine's ``total_income`` (ITA 2007 s. 23), +#: with ``social_security_income`` taken benefit by benefit. The first four +#: are the annual measures. The investment, property and miscellaneous incomes +#: are annual amounts already. The taxable benefits other than the State +#: Pension are read as the engine computes them for the year. +CENTAX_EMPLOYEE_SOURCE_INPUTS = ( + "employment_income_annual", + "self_employment_income_after_loss_relief_annual", + "private_pension_income_annual", + "state_pension_annual", + "property_income", + "dividend_income", + "savings_interest_income", + "other_investment_income", + "taxable_miscellaneous_income", + "incapacity_benefit", + "jsa_contrib_reported", + "esa_contrib_reported", + "carers_allowance", + "carer_support_payment", + "scottish_carer_supplement", + "widowed_parents_allowance", +) +#: The engine variable each compared source stands for, where the name +#: differs: the annual measures and the loss-relieved self-employment income. +CENTAX_EMPLOYEE_SOURCE_ENGINE_NAMES: Mapping[str, str] = MappingProxyType( + { + "employment_income_annual": "employment_income", + "self_employment_income_after_loss_relief_annual": "self_employment_income", + "private_pension_income_annual": "private_pension_income", + "state_pension_annual": "state_pension", + } +) +#: DfE ages (``Y - birth_year``, the age on 31 December of model year Y) of a +#: child age-eligible for the universal hours in at least one of the year's +#: terms (1 April, 1 September, 1 January). The hours start the term after the +#: third birthday, so a DfE age 2 child is never eligible before April Y + 1; +#: every DfE age 3 and 4 child is; a DfE age 5 child is only while it has not +#: started school, which ``early_years_term_share_before_school`` dates. +CENTAX_ELIGIBLE_DFE_AGES = (3, 4, 5) +#: ``early_years_term_share_before_school`` is 0, 1/3 or 1 in float32, so it +#: is tested against a sixth, never for equality. +_TERM_SHARE_FLOOR = 1.0 / 6.0 +_ENGLAND_GSS = "E92000001" + + +def parent_of_eligible_3_4_year_old( + *, + birth_year: np.ndarray, + country: np.ndarray, + term_share_before_school: np.ndarray, + dependent_child: np.ndarray, + benunit_id: np.ndarray, + year: int, +) -> np.ndarray: + """One for an adult whose benefit unit has an eligible child in England. + + A child is eligible when it is a dependent child of its benefit unit in + England, its DfE age is 3, 4 or 5 and it is before school in at least one + of the year's terms. The parent is any adult of the child's benefit unit, + the FRS's family: CenTax's identified parents and partners. Every array + is at person grain. + """ + + dfe_age = float(year) - np.asarray(birth_year, dtype=float) + child = np.asarray(dependent_child).astype(bool) + eligible = ( + child + & (np.asarray(country).astype(str) == "ENGLAND") + & np.isin(dfe_age, CENTAX_ELIGIBLE_DFE_AGES) + & (np.asarray(term_share_before_school, dtype=float) > _TERM_SHARE_FLOOR) + ) + units = np.asarray(benunit_id) + with_child = np.isin(units, units[eligible]) + return (with_child & ~child).astype(float) + + +def employment_main_income_source(sources: Mapping[str, np.ndarray]) -> np.ndarray: + """One where employment income is positive and at least every other source. + + ``sources`` maps each name in ``CENTAX_EMPLOYEE_SOURCE_INPUTS`` to a person + array. CenTax's employees are taxpayers whose main source of income is + employment; a tie goes to employment. + """ + + missing = [name for name in CENTAX_EMPLOYEE_SOURCE_INPUTS if name not in sources] + if missing: + raise ValueError(f"the main-source measure needs {missing}.") + employment = np.asarray(sources[CENTAX_EMPLOYEE_SOURCE_INPUTS[0]], dtype=float) + others = np.vstack( + [ + np.asarray(sources[name], dtype=float) + for name in CENTAX_EMPLOYEE_SOURCE_INPUTS[1:] + ] + ) + return ((employment > 0) & (employment >= others.max(axis=0))).astype(float) + + +def centax_ani_uprating(calibration_year: int) -> float: + """The OBR average earnings index at ``calibration_year`` over 2021. + + Read from the installed policyengine-uk, which carries the index the + engine uprates earnings by; 2021 is the engine's year of tax year 2021-22. + """ + + from policyengine_uk import CountryTaxBenefitSystem + + index = CountryTaxBenefitSystem().parameters.gov.economic_assumptions.indices.obr.average_earnings + return float(index(f"{int(calibration_year)}-01-01")) / float( + index(f"{CENTAX_DATA_ENGINE_YEAR}-01-01") + ) + + +def centax_cell_ids() -> tuple[str, ...]: + """Every cell of the resource: the four bands, then the five splits.""" + + return tuple(band for band, _, _ in CENTAX_ANI_BANDS_2022) + tuple( + f"{split}_ani_80k_to_150k" for split, _ in CENTAX_SPLITS + ) + + +#: The annual ANI measure every cell bands on. +CENTAX_ANI_MEASURE = "adjusted_net_income_annual" +#: Each split's extra predicate, in the contract's binding filter vocabulary. +_SPLIT_FILTERS: Mapping[str, Mapping[str, Any]] = MappingProxyType( + { + "female": {"variable": "gender", "equals": "FEMALE"}, + "non_employee": {"variable": CENTAX_EMPLOYEE_MAIN_SOURCE, "equals": 0}, + **{ + group: {"variable": "region", "in": list(regions)} + for group, regions in CENTAX_REGION_GROUPS.items() + }, + } +) + + +def centax_cell_filters(cell_id: str, edges: Sequence[int]) -> list[dict[str, Any]]: + """The policyengine binding filters of a cell at its model ANI edges. + + The parent flag, then ANI above the lower edge and at most the upper one, + then the split's own predicate. The contract carries exactly these, which + the tests check. + """ + + lower, upper = (int(edge) for edge in edges) + filters: list[dict[str, Any]] = [ + {"variable": CENTAX_PARENT_OF_ELIGIBLE_3_4, "equals": 1}, + {"variable": CENTAX_ANI_MEASURE, "operator": ">", "value": lower}, + {"variable": CENTAX_ANI_MEASURE, "operator": "<=", "value": upper}, + ] + split = cell_id.removesuffix("_ani_80k_to_150k") + if split != cell_id: + filters.append(dict(_SPLIT_FILTERS[split])) + elif cell_id not in {band for band, _, _ in CENTAX_ANI_BANDS_2022}: + raise KeyError(cell_id) + return filters + + +#: The contract's measurement concept for each variable a cell filters on. +CENTAX_FILTER_CONCEPTS: Mapping[str, str] = MappingProxyType( + { + CENTAX_PARENT_OF_ELIGIBLE_3_4: ( + "uk.childcare.centax_parent_of_eligible_3_4_year_old" + ), + CENTAX_ANI_MEASURE: "uk.income.adjusted_net.amount", + CENTAX_EMPLOYEE_MAIN_SOURCE: "uk.income.employment.main_source", + "gender": "uk.demographics.gender", + "region": "uk.geography.region", + } +) + + +def centax_cell_measurement_filters( + cell_id: str, edges: Sequence[int] +) -> list[dict[str, Any]]: + """The cell's filters in the contract's measurement (concept) vocabulary.""" + + filters = [] + for predicate in centax_cell_filters(cell_id, edges): + concept = dict(predicate) + concept = { + "concept": CENTAX_FILTER_CONCEPTS[concept.pop("variable")], + **concept, + } + filters.append(concept) + return filters + + +@dataclass(frozen=True) +class UKCenTaxChildcareParentFacts: + """The validated derived-facts resource.""" + + rows: tuple[Mapping[str, Any], ...] + rows_sha256: str + inputs_sha256: str + parameters: Mapping[str, Any] + inputs: Mapping[str, Any] + generator: Mapping[str, Any] + shares: Mapping[str, float] + + @property + def ani_uprating(self) -> float: + return float(self.parameters["ani_uprating"]) + + @property + def parent_count(self) -> float: + return float(self.parameters["parent_count"]) + + def model_edges(self, cell_id: str) -> tuple[int, int]: + """A cell's ANI edges at the calibration year, in whole pounds.""" + + return tuple(self.parameters["model_ani_edges"][cell_id]) # type: ignore[return-value] + + def value(self, cell_id: str) -> float: + for row in self.rows: + if row["dimensions"]["microcosm.centax_cell"] == cell_id: + return float(row["value"]) + raise KeyError(cell_id) + + +def _package_text(name: str) -> str: + return ( + resources.files("microcosm.build.uk").joinpath(name).read_text(encoding="utf-8") + ) + + +def rows_sha256(rows: Sequence[Mapping[str, Any]]) -> str: + """Digest of the rows, independent of their order.""" + + digest = hashlib.sha256() + for line in sorted( + json.dumps(row, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + for row in rows + ): + digest.update(line.encode("utf-8")) + digest.update(b"\n") + return digest.hexdigest() + + +def model_ani_edges(uprating: float) -> dict[str, list[int]]: + """Each cell's 2021-22 edges aged by ``uprating`` and rounded to whole pounds.""" + + edges: dict[str, list[int]] = {} + for band, lower, upper in CENTAX_ANI_BANDS_2022: + edges[band] = [round(lower * uprating), round(upper * uprating)] + split_lower, split_upper = CENTAX_SPLIT_BAND_2022 + for split, _ in CENTAX_SPLITS: + edges[f"{split}_ani_80k_to_150k"] = [ + round(split_lower * uprating), + round(split_upper * uprating), + ] + return edges + + +def _validate_row(row: Mapping[str, Any]) -> str: + key = str(row.get("aggregate_fact_key") or "") + if not key.startswith(CENTAX_DERIVED_FACT_KEY_PREFIX): + raise ValueError(f"CenTax fact {key!r} is outside the derived-fact namespace.") + if row.get("schema_version") != ROW_SCHEMA_VERSION: + raise ValueError( + f"CenTax fact {key!r} has schema {row.get('schema_version')!r}." + ) + geography = row.get("geography") or {} + if geography.get("level") != "country" or geography.get("id") != _ENGLAND_GSS: + raise ValueError(f"CenTax fact {key!r} must sit at England (country) grain.") + value = row.get("value") + if ( + not isinstance(value, (int, float)) + or isinstance(value, bool) + or not np.isfinite(value) + or value <= 0 + ): + raise ValueError(f"CenTax fact {key!r} has invalid value {value!r}.") + if (row.get("aggregation") or {}).get("method") != "sum": + raise ValueError(f"CenTax fact {key!r} must aggregate by sum.") + if (row.get("source") or {}).get("source_name") != "microcosm": + raise ValueError(f"CenTax fact {key!r} must name source microcosm.") + cell = str((row.get("dimensions") or {}).get("microcosm.centax_cell") or "") + if cell not in centax_cell_ids(): + raise ValueError(f"CenTax fact {key!r} names an unknown cell {cell!r}.") + spec = str((row.get("layout") or {}).get("record_set_spec_id") or "") + if spec != f"uk.centax_childcare_parents.{cell}.v1": + raise ValueError(f"CenTax fact {key!r} has record set spec {spec!r}.") + if not str(row.get("label") or "").strip(): + raise ValueError(f"CenTax fact {key!r} requires a label.") + return cell + + +def load_uk_centax_childcare_parent_facts( + path: Path | None = None, +) -> UKCenTaxChildcareParentFacts: + """Load and validate the derived-facts resource. + + Refused when the rows differ from their digest, the workbook is not the + pinned one, a cell is missing or repeated, a band share falls outside + (0, 1) or the bands' shares do not sum to one, a value is not its share + times the parent count, or the recorded edges are not the 2021-22 edges + aged by the recorded uprating. + """ + + text = ( + path.read_text(encoding="utf-8") + if path is not None + else _package_text(CENTAX_FACTS_RESOURCE) + ) + payload = json.loads(text) + if payload.get("kind") != RESOURCE_KIND or payload.get("schema_version") != 1: + raise ValueError( + f"{CENTAX_FACTS_RESOURCE} is not a schema-1 derived-facts resource." + ) + rows = payload.get("rows") + if not isinstance(rows, list) or not rows: + raise ValueError(f"{CENTAX_FACTS_RESOURCE} carries no rows.") + if payload.get("row_count") != len(rows): + raise ValueError(f"{CENTAX_FACTS_RESOURCE} row_count disagrees with its rows.") + digest = rows_sha256(rows) + if digest != payload.get("rows_sha256"): + raise ValueError(f"{CENTAX_FACTS_RESOURCE} rows differ from their digest.") + inputs = payload.get("inputs") or {} + if (inputs.get("workbook") or {}).get("sha256") != CENTAX_WORKBOOK_SHA256: + raise ValueError( + f"{CENTAX_FACTS_RESOURCE} was not built from the pinned CenTax workbook." + ) + parameters = payload.get("parameters") or {} + for required in ( + "ani_uprating", + "calibration_year", + "model_ani_edges", + "parent_count", + "shares", + ): + if required not in parameters: + raise ValueError(f"{CENTAX_FACTS_RESOURCE} lacks parameter {required!r}.") + uprating = float(parameters["ani_uprating"]) + if not 1.0 < uprating < 2.0: + raise ValueError(f"implausible ANI uprating {uprating!r}.") + if parameters["model_ani_edges"] != model_ani_edges(uprating): + raise ValueError( + f"{CENTAX_FACTS_RESOURCE} edges are not the 2021-22 edges aged by " + f"{uprating!r}." + ) + parent_count = float(parameters["parent_count"]) + if not parent_count > 0: + raise ValueError("the parent count must be positive.") + shares = {str(cell): float(share) for cell, share in parameters["shares"].items()} + if set(shares) != set(centax_cell_ids()): + raise ValueError(f"{CENTAX_FACTS_RESOURCE} shares do not cover every cell.") + if any(not 0.0 < share < 1.0 for share in shares.values()): + raise ValueError(f"{CENTAX_FACTS_RESOURCE} has a share outside (0, 1).") + band_total = sum(shares[band] for band, _, _ in CENTAX_ANI_BANDS_2022) + if abs(band_total - 1.0) > 1e-9: + raise ValueError(f"the ANI band shares sum to {band_total!r}, not 1.") + region_total = sum( + shares[f"{group}_ani_80k_to_150k"] for group in CENTAX_REGION_GROUPS + ) + split_share = float(parameters.get("split_band_share", 0.0)) + if not 0.0 < split_share < 1.0 or abs(region_total - split_share) > 1e-9: + raise ValueError( + "the region cells must partition the (£80k, £150k] parents: " + f"{region_total!r} vs {split_share!r}." + ) + seen: set[str] = set() + for row in rows: + cell = _validate_row(row) + if cell in seen: + raise ValueError(f"CenTax cell {cell!r} is repeated.") + seen.add(cell) + expected = round(shares[cell] * parent_count, 3) + if abs(float(row["value"]) - expected) > 1e-6: + raise ValueError( + f"CenTax cell {cell!r} value {row['value']!r} is not its share " + f"times the parent count ({expected!r})." + ) + if seen != set(centax_cell_ids()): + raise ValueError( + f"{CENTAX_FACTS_RESOURCE} lacks cells {sorted(set(centax_cell_ids()) - seen)}." + ) + return UKCenTaxChildcareParentFacts( + rows=tuple(rows), + rows_sha256=digest, + inputs_sha256=str(payload.get("inputs_sha256") or ""), + parameters=parameters, + inputs=inputs, + generator=payload.get("generator") or {}, + shares=shares, + ) + + +@cache +def uk_centax_childcare_parent_facts() -> UKCenTaxChildcareParentFacts: + """The committed resource, loaded once.""" + + return load_uk_centax_childcare_parent_facts() diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/derived_facts.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/derived_facts.py new file mode 100644 index 0000000000..bdbb4dbd81 --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/derived_facts.py @@ -0,0 +1,54 @@ +"""The Microcosm-derived fact resources every UK national compile binds. + +Two committed resources carry ``uk_derived_consumer_facts`` rows that contract +targets select with ``source_name: microcosm`` beside the Chronicle feed: the +high-value home stock (``uk/high_value_home_stock_facts.json``; local cells +microcosm#1240, region rows microcosm#1301) and CenTax's tabulations of parents +of 3- and 4-year-olds (``uk/centax_childcare_parent_facts.json``, +microcosm#1399). A compile of the committed national references that misses +either reports its rows unsupported, so every national compile site merges them +through :func:`uk_derived_fact_rows`, and every receipt names both resources +through :func:`uk_derived_fact_provenance`. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from microcosm.build.uk_runtime.centax_childcare_parents import ( + load_uk_centax_childcare_parent_facts, +) +from microcosm.build.uk_runtime.high_value_homes import ( + load_uk_high_value_home_stock_facts, +) + + +def uk_derived_fact_rows() -> tuple[Mapping[str, Any], ...]: + """Both committed derived-fact resources' rows, high-value homes first.""" + + return ( + *load_uk_high_value_home_stock_facts().rows, + *load_uk_centax_childcare_parent_facts().rows, + ) + + +def uk_derived_fact_provenance() -> dict[str, dict[str, Any]]: + """Each committed derived-fact resource's identity, keyed by its stem.""" + + high_value = load_uk_high_value_home_stock_facts() + centax = load_uk_centax_childcare_parent_facts() + return { + "high_value_home_stock_facts": { + "rows": len(high_value.rows), + "rows_sha256": high_value.rows_sha256, + "inputs_sha256": high_value.inputs_sha256, + "generator": dict(high_value.generator), + }, + "centax_childcare_parent_facts": { + "rows": len(centax.rows), + "rows_sha256": centax.rows_sha256, + "inputs_sha256": centax.inputs_sha256, + "generator": dict(centax.generator), + }, + } diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/full_targets.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/full_targets.py index bfb6a15380..87c5e33e56 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/full_targets.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/full_targets.py @@ -22,10 +22,11 @@ load_uk_chronicle_feed, require_committed_uk_chronicle_feed_pin, ) -from microcosm.build.uk_runtime.frs_release import load_uk_frs_release -from microcosm.build.uk_runtime.high_value_homes import ( - load_uk_high_value_home_stock_facts, +from microcosm.build.uk_runtime.derived_facts import ( + uk_derived_fact_provenance, + uk_derived_fact_rows, ) +from microcosm.build.uk_runtime.frs_release import load_uk_frs_release from microcosm.build.uk_runtime.ledger_targets import ( _uk_contract_targets, assert_uk_local_deferrals_in_force, @@ -216,8 +217,9 @@ def load_uk_full_target_inputs( # microcosm#1240: the high-value homes family binds Microcosm-derived facts # (HM Land Registry stock counts) beside the Chronicle feed at local grain, # and its region rows (microcosm#1301) at national grain. - derived = load_uk_high_value_home_stock_facts() - facts = (*artifact.facts, *derived.rows) + # CenTax's parent cells (microcosm#1399) bind the second derived resource + # on the national line. + facts = (*artifact.facts, *uk_derived_fact_rows()) for period in national_periods: compilation = compile_uk_target_registry(facts, target_period=period) if compilation.unsupported: @@ -316,14 +318,7 @@ def load_uk_full_target_inputs( "national_source_pin": pin.to_dict(), "local_source_pin": local_pin, "ledger_provenance": _ledger_provenance(artifact), - "derived_fact_provenance": { - "high_value_home_stock_facts": { - "rows": len(derived.rows), - "rows_sha256": derived.rows_sha256, - "inputs_sha256": derived.inputs_sha256, - "generator": dict(derived.generator), - } - }, + "derived_fact_provenance": uk_derived_fact_provenance(), "register_completeness": { "compiled_registry_version": compiled_version, "reconciled_registry_version": band_edges.version, @@ -400,10 +395,10 @@ def load_uk_national_target_inputs( # on their Microcosm-derived facts included (microcosm#1301); those rows # bind on the local line only, so the national surface drops them before # reconciliation, the exclusions and the frozen-register check, and its - # registry is the one it was without them. - derived = load_uk_high_value_home_stock_facts() + # registry is the one it was without them. CenTax's parent cells + # (microcosm#1399) compile on the second derived resource and stay. compilation = compile_uk_target_registry( - chain(artifact.facts, derived.rows), target_period=year + chain(artifact.facts, uk_derived_fact_rows()), target_period=year ) if compilation.unsupported: raise ValueError( @@ -462,14 +457,7 @@ def load_uk_national_target_inputs( "chronicle_feed_pin": pin.to_dict(), "chronicle_provenance": artifact.provenance(), "ledger_provenance": _ledger_provenance(artifact), - "derived_fact_provenance": { - "high_value_home_stock_facts": { - "rows": len(derived.rows), - "rows_sha256": derived.rows_sha256, - "inputs_sha256": derived.inputs_sha256, - "generator": dict(derived.generator), - } - }, + "derived_fact_provenance": uk_derived_fact_provenance(), "register_completeness": { "compiled_registry_version": line_registry.version, "reconciled_registry_version": reconciled.version, diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/measure_simulation.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/measure_simulation.py index 5858a4cc90..cb2d77d373 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/measure_simulation.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/measure_simulation.py @@ -16,6 +16,15 @@ from microcosm.build.uk_runtime.atomic_area_support import ( without_uk_native_alias_columns, ) +from microcosm.build.uk_runtime.centax_childcare_parents import ( + CENTAX_EMPLOYEE_MAIN_SOURCE, + CENTAX_EMPLOYEE_SOURCE_INPUTS, + CENTAX_PARENT_ENGINE_INPUTS, + CENTAX_PARENT_MEASURES, + CENTAX_PARENT_OF_ELIGIBLE_3_4, + employment_main_income_source, + parent_of_eligible_3_4_year_old, +) from microcosm.build.uk_runtime.cgt_calibration import UK_CGT_DATED_MEASURE from microcosm.build.uk_runtime.dfe_spring_term import ( DFE_SPRING_TERM_ENGINE_INPUTS, @@ -284,6 +293,8 @@ def household_high_value_home(frame: Any, variable: str) -> np.ndarray: #: tax year: the 12 months before the interview, applied to the engine's #: amounts at the calibration year (the stage module says how). Poverty, #: benefits and the engine's published tax keep reading current income. +#: CenTax's tabulations of parents' adjusted net income come from the same +#: HMRC data, so they band on the year's ANI too. UK_ANNUAL_INCOME_MEASURES: Mapping[str, str] = { "employment_income_annual": "employment_income", "self_employment_income_after_loss_relief_annual": ( @@ -294,6 +305,7 @@ def household_high_value_home(frame: Any, variable: str) -> np.ndarray: "total_income_after_loss_relief_annual": UK_TOTAL_INCOME_AFTER_LOSS_RELIEF, "income_tax_annual": "income_tax", "tax_band_annual": "tax_band", + "adjusted_net_income_annual": "adjusted_net_income", } #: The inputs the annual simulation replaces at the measurement year: the #: engine's current amount times its annual share, plus its other spells, @@ -483,6 +495,30 @@ def compute_uk_measure_input( ) return status[variable], f"dfe_spring_term:{variable}" + if variable == CENTAX_PARENT_OF_ELIGIBLE_3_4: + if entity != "person": + raise KeyError(f"{variable} is person-only: {entity}") + inputs = { + name: compute_uk_measure_input(frame, simulation, "person", name, year)[0] + for name in CENTAX_PARENT_ENGINE_INPUTS + } + return ( + parent_of_eligible_3_4_year_old( + birth_year=inputs["birth_year"], + country=inputs["country"], + term_share_before_school=inputs["early_years_term_share_before_school"], + dependent_child=inputs["is_hbai_dependent_child"], + benunit_id=frame.table("person")[_ENTITY_LINK["benunit"]].to_numpy(), + year=year, + ), + f"engine_terms_and_benunit_roles:{variable}", + ) + if variable == CENTAX_EMPLOYEE_MAIN_SOURCE: + raise KeyError( + f"{variable} compares the year's income sources; resolve it through " + "UKMeasureResolver, which computes the annual measures." + ) + if variable in _LOSS_RELIEF_MEASURES: if entity != "person": raise KeyError(f"{variable} is person-only: {entity}") @@ -971,6 +1007,14 @@ def __init__( "DfE spring-term measures must not be persisted as source inputs: " f"{persisted_spring_term}" ) + persisted_centax = sorted( + set(CENTAX_PARENT_MEASURES) & set(map(str, frame.table("person"))) + ) + if persisted_centax: + raise ValueError( + "CenTax parent measures must not be persisted as source inputs: " + f"{persisted_centax}" + ) self.simulation = factory(dataset=str(source_path)) validate_uc_claimant_input(self.simulation, frame.table("person"), self.year) self.contract_targets = _uk_contract_targets() @@ -1084,6 +1128,14 @@ def knows(self, entity: str, variable: str) -> bool: return entity == "person" and all( self.knows("person", name) for name in DFE_SPRING_TERM_ENGINE_INPUTS ) + if variable == CENTAX_PARENT_OF_ELIGIBLE_3_4: + return entity == "person" and all( + self.knows("person", name) for name in CENTAX_PARENT_ENGINE_INPUTS + ) + if variable == CENTAX_EMPLOYEE_MAIN_SOURCE: + return entity == "person" and all( + self.knows("person", name) for name in CENTAX_EMPLOYEE_SOURCE_INPUTS + ) cgt_measure = _cgt_model_measure(variable, self.year) if cgt_measure is not None: return entity == "person" and self.knows(entity, cgt_measure[0]) @@ -1123,7 +1175,11 @@ def entity_for(self, variable: str) -> str | None: return "household" if _cgt_model_measure(variable, self.year) is not None: return "person" - if variable in _LOSS_RELIEF_MEASURES or variable in DFE_SPRING_TERM_MEASURES: + if ( + variable in _LOSS_RELIEF_MEASURES + or variable in DFE_SPRING_TERM_MEASURES + or variable in CENTAX_PARENT_MEASURES + ): return "person" if variable in UK_HOUSEHOLD_ENERGY_MEASURES: return "household" @@ -1144,6 +1200,8 @@ def entity_for(self, variable: str) -> str | None: def compute(self, entity: str, variable: str) -> tuple[np.ndarray, str]: if variable in UK_ANNUAL_INCOME_MEASURES: return self._annual_income_measure(entity, variable) + if variable == CENTAX_EMPLOYEE_MAIN_SOURCE: + return self._employment_main_income_source(entity) if variable in UK_HOUSEHOLD_ENERGY_MEASURES: return self._household_energy(entity, variable) result = compute_uk_measure_input( @@ -1401,6 +1459,20 @@ def _annual_income_measure( ) return values, f"annual_income_simulation:{route}" + def _employment_main_income_source(self, entity: str) -> tuple[np.ndarray, str]: + """One where employment is the largest of the year's income sources.""" + + if entity != "person": + raise KeyError(f"{CENTAX_EMPLOYEE_MAIN_SOURCE} is person-only: {entity}") + sources = { + name: self.compute("person", name)[0] + for name in CENTAX_EMPLOYEE_SOURCE_INPUTS + } + return ( + employment_main_income_source(sources), + "annual_income_sources:" + ",".join(CENTAX_EMPLOYEE_SOURCE_INPUTS), + ) + def _household_energy(self, entity: str, variable: str) -> tuple[np.ndarray, str]: """A household's domestic energy measure from its stored spend.""" diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/target_compile_modules.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/target_compile_modules.py index dd764b9a1e..776289483c 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/target_compile_modules.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/target_compile_modules.py @@ -27,10 +27,12 @@ "microcosm.build.target_materialization", "microcosm.build.target_reference_authoring", "microcosm.build.uk_runtime.bus_fare_pricing", + "microcosm.build.uk_runtime.centax_childcare_parents", "microcosm.build.uk_runtime.cgt_calibration", "microcosm.build.uk_runtime.chronicle_feed", "microcosm.build.uk_runtime.council_tax_constants", "microcosm.build.uk_runtime.cross_grain_declarations", + "microcosm.build.uk_runtime.derived_facts", "microcosm.build.uk_runtime.domestic_energy_targets", "microcosm.build.uk_runtime.energy_pricing", "microcosm.build.uk_runtime.frs_release", diff --git a/packages/microcosm-build/tests/engine/uk/test_uk_centax_main_income_source.py b/packages/microcosm-build/tests/engine/uk/test_uk_centax_main_income_source.py new file mode 100644 index 0000000000..31c5aad7c1 --- /dev/null +++ b/packages/microcosm-build/tests/engine/uk/test_uk_centax_main_income_source.py @@ -0,0 +1,37 @@ +"""CenTax's main-source measure compares every source of the engine's total income. + +CenTax's employees are taxpayers whose main source of income is employment, so +the measure must weigh pay against each source the engine counts in +``total_income`` (ITA 2007 s. 23). A source left out cannot outrank pay, and a +parent whose largest income is that source would count as an employee. +""" + +from __future__ import annotations + +from microcosm.build.uk_runtime.centax_childcare_parents import ( + CENTAX_EMPLOYEE_SOURCE_ENGINE_NAMES, + CENTAX_EMPLOYEE_SOURCE_INPUTS, +) + + +def _components(variables, name): + """``name``'s leaf components, ``social_security_income`` taken apart.""" + + adds = getattr(variables[name], "adds", None) or () + if name == "total_income" or name == "social_security_income": + return [leaf for part in adds for leaf in _components(variables, part)] + return [name] + + +def test_the_main_source_measure_compares_every_total_income_component(): + from policyengine_uk import CountryTaxBenefitSystem + + variables = CountryTaxBenefitSystem().variables + components = _components(variables, "total_income") + compared = [ + CENTAX_EMPLOYEE_SOURCE_ENGINE_NAMES.get(name, name) + for name in CENTAX_EMPLOYEE_SOURCE_INPUTS + ] + assert sorted(compared) == sorted(components) + assert len(set(compared)) == len(compared) + assert compared[0] == "employment_income" diff --git a/packages/microcosm-build/tests/engine_free/shared/test_country_spec.py b/packages/microcosm-build/tests/engine_free/shared/test_country_spec.py index 87d09f0449..cc1318d1de 100644 --- a/packages/microcosm-build/tests/engine_free/shared/test_country_spec.py +++ b/packages/microcosm-build/tests/engine_free/shared/test_country_spec.py @@ -959,6 +959,7 @@ def test_spi_spine_adds_no_country_package_resources(self) -> None: "etb_services_support_bounds.json", "regional_land_values.json", "high_value_home_stock_facts.json", + "centax_childcare_parent_facts.json", "high_value_home_value_knots.json", "high_value_home_stock.provenance.json", "voa_council_tax_band_mix_lsoa.json", @@ -1113,6 +1114,7 @@ def test_uk_package_loads(self) -> None: "etb_services_support_bounds.json", "regional_land_values.json", "high_value_home_stock_facts.json", + "centax_childcare_parent_facts.json", "high_value_home_value_knots.json", "high_value_home_stock.provenance.json", "voa_council_tax_band_mix_lsoa.json", diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_centax_childcare_parents.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_centax_childcare_parents.py new file mode 100644 index 0000000000..0628e9c984 --- /dev/null +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_centax_childcare_parents.py @@ -0,0 +1,522 @@ +"""CenTax's parent tabulations as Microcosm-derived facts (microcosm#1399). + +Invariants, for every input the strategies draw: + +- the four ANI band shares sum to one and the three region cells sum to the + (£80k, £150k] share, so the bands partition (0, £200k] and the regions + partition the split band; +- every committed value is its share times the baseline parent count; +- the aged edges are the 2021-22 edges times the uprating, rounded, so the + bands stay contiguous and increasing and move up with the uprating; +- the parent measure is 0 or 1, never 1 for a child, the same for every adult + of a benefit unit, 0 in a unit with no eligible child, and follows its + people when they are reordered; +- the main-source measure is 0 or 1, 0 without positive pay, never turns off + as pay rises, and ignores the scale of all sources together; +- the contract binds exactly the resource's cells, with its edges. +""" + +from __future__ import annotations + +import copy +import importlib.util +import json +from pathlib import Path + +import numpy as np +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from microcosm.build.uk_runtime.centax_childcare_parents import ( + CENTAX_ANI_BANDS_2022, + CENTAX_ANI_MEASURE, + CENTAX_EMPLOYEE_MAIN_SOURCE, + CENTAX_EMPLOYEE_SOURCE_INPUTS, + CENTAX_FACTS_RESOURCE, + CENTAX_FILTER_CONCEPTS, + CENTAX_PARENT_OF_ELIGIBLE_3_4, + CENTAX_REGION_GROUPS, + CENTAX_SPLIT_BAND_2022, + CENTAX_SPLITS, + centax_cell_filters, + centax_cell_ids, + centax_cell_measurement_filters, + employment_main_income_source, + load_uk_centax_childcare_parent_facts, + model_ani_edges, + parent_of_eligible_3_4_year_old, + rows_sha256, +) +from microcosm.build.uk_runtime.derived_facts import ( + uk_derived_fact_provenance, + uk_derived_fact_rows, +) +from microcosm.build.uk_runtime.high_value_homes import ( + load_uk_high_value_home_stock_facts, +) +from microcosm.build.uk_runtime.ledger_targets import ( + _uk_contract_targets, + uk_contract_target_calibration_lines, +) +from test_support.paths import paths_for + +ROOT = paths_for("microcosm-build").repository +_BUILDER_SPEC = importlib.util.spec_from_file_location( + "build_uk_centax_childcare_parent_facts", + ROOT / "tools/build_uk_centax_childcare_parent_facts.py", +) +assert _BUILDER_SPEC is not None and _BUILDER_SPEC.loader is not None +_builder = importlib.util.module_from_spec(_BUILDER_SPEC) +_BUILDER_SPEC.loader.exec_module(_builder) + +_PREFIX = "microcosm.centax_childcare_parents." +_BANDS = tuple(band for band, _, _ in CENTAX_ANI_BANDS_2022) +_SPLIT_CELLS = tuple(f"{split}_ani_80k_to_150k" for split, _ in CENTAX_SPLITS) +_REGION_CELLS = tuple(f"{group}_ani_80k_to_150k" for group in CENTAX_REGION_GROUPS) + + +def _resource_payload() -> dict: + from importlib.resources import files + + return json.loads( + files("microcosm.build.uk").joinpath(CENTAX_FACTS_RESOURCE).read_text() + ) + + +def _write(tmp_path: Path, payload: dict) -> Path: + path = tmp_path / CENTAX_FACTS_RESOURCE + path.write_text(json.dumps(payload)) + return path + + +# The committed resource. + + +def test_the_committed_resource_partitions_and_scales_its_shares() -> None: + facts = load_uk_centax_childcare_parent_facts() + shares = facts.shares + + assert set(shares) == set(centax_cell_ids()) + assert sum(shares[band] for band in _BANDS) == pytest.approx(1.0, abs=1e-12) + split_share = facts.parameters["split_band_share"] + assert sum(shares[cell] for cell in _REGION_CELLS) == pytest.approx( + split_share, abs=1e-12 + ) + for cell in _SPLIT_CELLS: + assert 0.0 < shares[cell] <= split_share + for cell in centax_cell_ids(): + assert facts.value(cell) == pytest.approx( + round(shares[cell] * facts.parent_count, 3), abs=1e-9 + ) + # The bands' counts add back to the parent count the shares scale. + assert sum(facts.value(band) for band in _BANDS) == pytest.approx( + facts.parent_count, abs=0.01 + ) + + +def test_the_committed_resource_ages_its_edges_contiguously() -> None: + facts = load_uk_centax_childcare_parent_facts() + edges = facts.parameters["model_ani_edges"] + + assert edges == model_ani_edges(facts.ani_uprating) + assert [edges[band][0] for band in _BANDS[1:]] == [ + edges[band][1] for band in _BANDS[:-1] + ] + assert edges[_BANDS[0]][0] == 0 + split = edges[_SPLIT_CELLS[0]] + assert all(edges[cell] == split for cell in _SPLIT_CELLS) + # The split band starts where the (£80k, £120k] band does. + assert split[0] == edges["ani_80k_to_120k"][0] + assert split[1] < edges["ani_120k_to_200k"][1] + + +def test_the_committed_resource_names_its_sources_and_never_targets() -> None: + payload = _resource_payload() + facts = load_uk_centax_childcare_parent_facts() + + assert payload["rows_sha256"] == rows_sha256(payload["rows"]) + assert facts.inputs["workbook"]["sha256"] == ( + "8c23855745154a601064e766552c64bc2e88de51e043b4df1397c580e969354b" + ) + assert {row["source"]["url"] for row in facts.rows} == { + "https://centax.org.uk/wp-content/uploads/2026/09/" + "AdvaniFlewPepin-HallSummers2026_Removing-the-childcare-cliff-edge.pdf" + } + tables = {row["source"]["source_table"] for row in facts.rows} + assert all( + any(f"Fig. {figure}" in table for figure in ("3.1", "3.3", "3.6", "3.7")) + for table in tables + ) + never = " ".join(facts.parameters["never_targets"]) + for excluded in ("3.1, 3.2 and 4.1", "elasticities", "cost"): + assert excluded in never + assert {row["geography"]["id"] for row in facts.rows} == {"E92000001"} + + +@pytest.mark.parametrize( + ("mutate", "message"), + [ + (lambda p: p["rows"][0].__setitem__("value", 1.0), "digest"), + (lambda p: p["rows"].pop(), "row_count"), + ( + lambda p: p["inputs"]["workbook"].__setitem__("sha256", "0" * 64), + "pinned CenTax workbook", + ), + ( + lambda p: p["parameters"].__setitem__("ani_uprating", 1.25), + "edges are not", + ), + ( + lambda p: p["parameters"]["shares"].__setitem__("ani_0_to_50k", 0.5), + "sum to", + ), + ], +) +def test_the_loader_refuses_a_tampered_resource(tmp_path, mutate, message) -> None: + payload = _resource_payload() + mutate(payload) + with pytest.raises(ValueError, match=message): + load_uk_centax_childcare_parent_facts(_write(tmp_path, payload)) + + +def test_the_loader_refuses_a_value_off_its_share(tmp_path) -> None: + payload = _resource_payload() + payload["rows"][0]["value"] = round(payload["rows"][0]["value"] + 1.0, 3) + payload["rows_sha256"] = rows_sha256(payload["rows"]) + with pytest.raises(ValueError, match="share times the parent count"): + load_uk_centax_childcare_parent_facts(_write(tmp_path, payload)) + + +def test_the_loader_refuses_a_repeated_cell(tmp_path) -> None: + payload = _resource_payload() + payload["rows"][1] = copy.deepcopy(payload["rows"][0]) + payload["rows_sha256"] = rows_sha256(payload["rows"]) + with pytest.raises(ValueError, match="repeated"): + load_uk_centax_childcare_parent_facts(_write(tmp_path, payload)) + + +@settings(max_examples=40, deadline=None) +@given(st.permutations(range(9))) +def test_the_rows_digest_ignores_row_order(order) -> None: + rows = _resource_payload()["rows"] + assert rows_sha256([rows[i] for i in order]) == rows_sha256(rows) + + +# The aged edges. + + +@settings(max_examples=200, deadline=None) +@given(st.floats(1.0001, 1.9999, allow_nan=False), st.floats(0.0, 0.5)) +def test_aged_edges_stay_contiguous_and_rise_with_the_uprating(uprating, step): + edges = model_ani_edges(uprating) + higher = model_ani_edges(min(uprating + step, 1.9999)) + + for band, lower, upper in CENTAX_ANI_BANDS_2022: + assert edges[band] == [round(lower * uprating), round(upper * uprating)] + assert edges[band][0] < edges[band][1] + assert higher[band][0] >= edges[band][0] + assert higher[band][1] >= edges[band][1] + assert [edges[band][0] for band in _BANDS[1:]] == [ + edges[band][1] for band in _BANDS[:-1] + ] + lower, upper = CENTAX_SPLIT_BAND_2022 + assert all( + edges[cell] == [round(lower * uprating), round(upper * uprating)] + for cell in _SPLIT_CELLS + ) + + +def test_cell_filters_carry_the_edges_and_one_split_each() -> None: + edges = model_ani_edges(1.25) + for cell in centax_cell_ids(): + filters = centax_cell_filters(cell, edges[cell]) + assert filters[0] == {"variable": CENTAX_PARENT_OF_ELIGIBLE_3_4, "equals": 1} + assert filters[1:3] == [ + {"variable": CENTAX_ANI_MEASURE, "operator": ">", "value": edges[cell][0]}, + { + "variable": CENTAX_ANI_MEASURE, + "operator": "<=", + "value": edges[cell][1], + }, + ] + assert len(filters) == (3 if cell in _BANDS else 4) + # The measurement filters are the binding filters with each variable + # named by its concept. + measured = centax_cell_measurement_filters(cell, edges[cell]) + assert measured == [ + { + "concept": CENTAX_FILTER_CONCEPTS[f["variable"]], + **{k: v for k, v in f.items() if k != "variable"}, + } + for f in filters + ] + with pytest.raises(KeyError): + centax_cell_filters("ani_200k_plus", [0, 1]) + + +# The measures. + +_people = st.integers(1, 12) + + +@st.composite +def _population(draw): + n = draw(_people) + return { + "birth_year": np.asarray( + draw(st.lists(st.integers(1960, 2025), min_size=n, max_size=n)) + ), + "country": np.asarray( + draw( + st.lists( + st.sampled_from(["ENGLAND", "SCOTLAND", "WALES"]), + min_size=n, + max_size=n, + ) + ) + ), + "term_share_before_school": np.asarray( + draw(st.lists(st.sampled_from([0.0, 1 / 3, 1.0]), min_size=n, max_size=n)), + dtype=np.float32, + ), + "dependent_child": np.asarray( + draw(st.lists(st.booleans(), min_size=n, max_size=n)) + ), + "benunit_id": np.asarray( + draw(st.lists(st.integers(0, 3), min_size=n, max_size=n)) + ), + } + + +@settings(max_examples=200, deadline=None) +@given(_population(), st.randoms(use_true_random=False)) +def test_the_parent_measure_marks_whole_units_of_adults(population, rng) -> None: + parent = parent_of_eligible_3_4_year_old(**population, year=2025) + child = population["dependent_child"] + units = population["benunit_id"] + dfe_age = 2025 - population["birth_year"] + eligible = ( + child + & (population["country"] == "ENGLAND") + & np.isin(dfe_age, (3, 4, 5)) + & (population["term_share_before_school"] > 1 / 6) + ) + + assert set(np.unique(parent)) <= {0.0, 1.0} + assert not parent[child].any() + for unit in np.unique(units): + adults = (units == unit) & ~child + expected = 1.0 if eligible[units == unit].any() else 0.0 + assert (parent[adults] == expected).all() + + order = list(range(len(units))) + rng.shuffle(order) + shuffled = parent_of_eligible_3_4_year_old( + **{name: values[order] for name, values in population.items()}, year=2025 + ) + np.testing.assert_array_equal(shuffled, parent[order]) + + +def test_the_parent_measure_reads_the_dfe_age_and_term_share() -> None: + # A couple with a DfE-age-5 child before school in one term, a lone + # parent with a DfE-age-5 child already at school all year, a parent of a + # DfE-age-2 child, and a Welsh parent of a 3-year-old. + parent = parent_of_eligible_3_4_year_old( + birth_year=np.asarray([1990, 1991, 2020, 1985, 2020, 1992, 2023, 1993, 2022]), + country=np.asarray(["ENGLAND"] * 7 + ["WALES"] * 2), + term_share_before_school=np.asarray( + [1, 1, 1 / 3, 1, 0, 1, 1, 1, 1], dtype=np.float32 + ), + dependent_child=np.asarray([0, 0, 1, 0, 1, 0, 1, 0, 1], dtype=bool), + benunit_id=np.asarray([1, 1, 1, 2, 2, 3, 3, 4, 4]), + year=2025, + ) + assert parent.tolist() == [1, 1, 0, 0, 0, 0, 0, 0, 0] + + +_sources = st.lists( + st.floats(-5_000.0, 300_000.0, allow_nan=False), + min_size=len(CENTAX_EMPLOYEE_SOURCE_INPUTS), + max_size=len(CENTAX_EMPLOYEE_SOURCE_INPUTS), +) + + +@settings(max_examples=300, deadline=None) +@given(st.lists(_sources, min_size=1, max_size=8), st.floats(0.0, 50_000.0)) +def test_the_main_source_measure_is_monotone_in_pay(rows, raise_pay) -> None: + matrix = np.asarray(rows, dtype=float) + sources = { + name: matrix[:, column] + for column, name in enumerate(CENTAX_EMPLOYEE_SOURCE_INPUTS) + } + employee = employment_main_income_source(sources) + pay = sources[CENTAX_EMPLOYEE_SOURCE_INPUTS[0]] + + assert set(np.unique(employee)) <= {0.0, 1.0} + assert not employee[pay <= 0].any() + raised = employment_main_income_source( + {**sources, CENTAX_EMPLOYEE_SOURCE_INPUTS[0]: pay + raise_pay} + ) + assert (raised >= employee).all() + scaled = employment_main_income_source( + {name: values * 3.0 for name, values in sources.items()} + ) + np.testing.assert_array_equal(scaled, employee) + + +@pytest.mark.parametrize("source", CENTAX_EMPLOYEE_SOURCE_INPUTS[1:]) +def test_every_other_source_can_outrank_employment(source) -> None: + # Pay of 30,000 against 100,000 from one other source: that source is the + # main one, whichever it is; at 100,000 each, the tie goes to employment. + zeros = {name: np.zeros(2) for name in CENTAX_EMPLOYEE_SOURCE_INPUTS} + sources = { + **zeros, + CENTAX_EMPLOYEE_SOURCE_INPUTS[0]: np.array([30_000.0, 100_000.0]), + source: np.array([100_000.0, 100_000.0]), + } + assert employment_main_income_source(sources).tolist() == [0.0, 1.0] + + +def test_the_main_source_measure_refuses_a_missing_source() -> None: + with pytest.raises(ValueError, match="needs"): + employment_main_income_source({"employment_income_annual": np.ones(2)}) + + +# The contract and the compile. + + +def test_the_contract_binds_the_resource_cells_on_the_national_line() -> None: + facts = load_uk_centax_childcare_parent_facts() + targets = { + target_id: target + for target_id, target in _uk_contract_targets(national_only=False).items() + if target_id.startswith(_PREFIX) + } + specs = { + row["dimensions"]["microcosm.centax_cell"]: row["layout"]["record_set_spec_id"] + for row in facts.rows + } + + assert {target_id.removeprefix(_PREFIX) for target_id in targets} == set( + centax_cell_ids() + ) + for target_id, target in targets.items(): + cell = target_id.removeprefix(_PREFIX) + edges = facts.model_edges(cell) + binding = target["bindings"]["policyengine"] + assert binding["filters"] == centax_cell_filters(cell, edges) + assert target["measurement"]["filters"] == centax_cell_measurement_filters( + cell, edges + ) + assert binding["value_variable"] == "person_count" + assert target["ledger_selector"] == { + "source_name": "microcosm", + "source_measure_id": "parents_of_eligible_3_4_year_olds", + "record_set_spec_id": specs[cell], + } + assert uk_contract_target_calibration_lines(target) == {"national"} + assert target["family"] == "centax_childcare_parents" + assert target["geography_levels"] == ["country"] + variables = {f["variable"] for f in binding["filters"]} + assert variables <= { + CENTAX_PARENT_OF_ELIGIBLE_3_4, + CENTAX_ANI_MEASURE, + CENTAX_EMPLOYEE_MAIN_SOURCE, + "gender", + "region", + } + + +def test_every_national_compile_merges_both_derived_resources() -> None: + high_value = load_uk_high_value_home_stock_facts() + centax = load_uk_centax_childcare_parent_facts() + + assert uk_derived_fact_rows() == (*high_value.rows, *centax.rows) + provenance = uk_derived_fact_provenance() + assert set(provenance) == { + "high_value_home_stock_facts", + "centax_childcare_parent_facts", + } + assert provenance["centax_childcare_parent_facts"]["rows_sha256"] == ( + centax.rows_sha256 + ) + keys = [row["aggregate_fact_key"] for row in uk_derived_fact_rows()] + assert len(keys) == len(set(keys)) + + +def test_the_compile_sites_read_the_merged_rows() -> None: + sites = ( + "packages/microcosm-build/src/microcosm/build/uk_runtime/full_targets.py", + "tools/build_uk_ledger_compile_parity_signed_differences.py", + "tools/certify_uk_release_cut.py", + "tools/diagnose_uk_uc_matrix.py", + "tools/evaluate_uk_incumbent_surface.py", + ) + for site in sites: + text = (ROOT / site).read_text() + assert "uk_derived_fact_rows()" in text, site + assert "load_uk_high_value_home_stock_facts().rows" not in text, site + generator = (ROOT / "tools/generate_uk_target_references.py").read_text() + assert "load_uk_centax_childcare_parent_facts(args.centax_facts)" in generator + + +# The builder's arithmetic on a synthetic workbook. + + +class _Sheet: + def __init__(self, rows): + self._rows = rows + + def iter_rows(self, *, values_only): + assert values_only + return iter(self._rows) + + +def _sheet(name: str, columns: list[list[float]]) -> _Sheet: + header, step = _builder.SHEETS[name] + rows = [(None, *header)] + for index, values in enumerate(zip(*columns, strict=True), start=1): + rows.append((None, index * step, *values)) + return _Sheet(rows) + + +@settings(max_examples=40, deadline=None) +@given(st.integers(0, 2**32 - 1), st.floats(1.0, 1e4), st.floats(1.0, 1e4)) +def test_shares_partition_on_any_positive_workbook(seed, low, spread) -> None: + rng = np.random.default_rng(seed) + + def column(length): + # Positive bin counts; a lognormal draw gives spiky and flat shapes. + return (low * rng.lognormal(0.0, np.log1p(spread) / 4, size=length)).tolist() + + workbook = { + "Fig. 3.1": _sheet("Fig. 3.1", [column(400)]), + "Fig. 3.3": _sheet("Fig. 3.3", [column(150), column(150)]), + "Fig. 3.6": _sheet("Fig. 3.6", [column(300), column(300)]), + "Fig. 3.7": _sheet("Fig. 3.7", [column(150) for _ in range(4)]), + } + shares, extra = _builder.centax_shares(workbook) + split_share = extra["split_band_share"] + + assert set(shares) == set(centax_cell_ids()) + assert sum(shares[band] for band in _BANDS) == pytest.approx(1.0, abs=1e-12) + assert sum(shares[cell] for cell in _REGION_CELLS) == pytest.approx( + split_share, abs=1e-12 + ) + assert all(0.0 < share < 1.0 for share in shares.values()) + assert all(shares[cell] < split_share for cell in _SPLIT_CELLS) + + +def test_band_sums_take_bins_by_their_upper_edge() -> None: + series = {500: 1.0, 1000: 2.0, 1500: 4.0} + assert _builder.band_sum(series, 0.0, 1000.0) == 3.0 + assert _builder.band_sum(series, 500.0, 1500.0) == 6.0 + assert _builder.band_sum(series, 1000.0, 1000.0) == 0.0 + + +def test_the_series_reader_refuses_a_gap_in_the_bins() -> None: + header, _ = _builder.SHEETS["Fig. 3.1"] + sheet = _Sheet([(None, *header), (None, 500, 1.0), (None, 1500, 1.0)]) + with pytest.raises(ValueError, match="bins are not every"): + _builder.read_series({"Fig. 3.1": sheet}, "Fig. 3.1") diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_full_targets.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_full_targets.py index cd8b2cc8cb..64c97d17ea 100644 --- a/packages/microcosm-build/tests/engine_free/uk/test_uk_full_targets.py +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_full_targets.py @@ -457,9 +457,10 @@ def compile_national(facts, *, target_period): exclusions_evaluated_on=date(2026, 10, 16), ) - # Every reference compiles, on the feed and the derived facts together. - derived = runtime.load_uk_high_value_home_stock_facts() - assert compiled_facts == [({"feed": True}, *derived.rows)] + # Every reference compiles, on the feed and the derived facts together + # (the high-value home stock and, from microcosm#1399, CenTax's cells). + derived = runtime.uk_derived_fact_rows() + assert compiled_facts == [({"feed": True}, *derived)] # The region rows leave before reconciliation: the national surface and # its version are the ones it had without them. (reconciled,) = reconciled_from @@ -475,9 +476,7 @@ def compile_national(facts, *, target_period): "dropped_reference_count": 2, "dropped_target_ids": [_REGION_ROW], } - assert result["derived_fact_provenance"]["high_value_home_stock_facts"][ - "rows_sha256" - ] == (derived.rows_sha256) + assert result["derived_fact_provenance"] == runtime.uk_derived_fact_provenance() def test_the_full_line_binds_the_region_rows(prepared, monkeypatch): @@ -502,8 +501,8 @@ def compile_national(facts, *, target_period): lambda registry, exclusions, now: (registry, {}), ) result = _load() - derived = runtime.load_uk_high_value_home_stock_facts() - assert all(facts == (*artifact.facts, *derived.rows) for facts in seen_facts) + derived = runtime.uk_derived_fact_rows() + assert all(facts == (*artifact.facts, *derived) for facts in seen_facts) # The local line binds every row: nothing leaves, the registry is the # compiled object itself. assert reconciled_from == [compiled] diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_high_value_homes_region_control.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_high_value_homes_region_control.py index ec7c83bf0e..25ca41e89a 100644 --- a/packages/microcosm-build/tests/engine_free/uk/test_uk_high_value_homes_region_control.py +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_high_value_homes_region_control.py @@ -33,6 +33,7 @@ author_target_references, ) from microcosm.build.uk_runtime import ledger_targets +from microcosm.build.uk_runtime.derived_facts import uk_derived_fact_rows from microcosm.build.uk_runtime.high_value_homes import ( load_uk_high_value_home_stock_facts, uk_high_value_home_thresholds, @@ -136,10 +137,13 @@ def test_a_malformed_calibration_lines_declaration_is_refused(declared, message) def test_only_the_region_rows_narrow_their_lines_in_the_committed_contract(): + # CenTax's England parent cells (microcosm#1399) are the one other family + # that narrows its lines, to the national line. narrowed = { target_id: uk_contract_target_calibration_lines(target) for target_id, target in _targets().items() if "calibration_lines" in target + and not target_id.startswith("microcosm.centax_childcare_parents.") } assert narrowed == {target_id: {"local"} for target_id in REGION_TARGET_IDS} @@ -445,8 +449,7 @@ def compile_national(facts, *, target_period): monkeypatch.setattr(parity_tool, "compile_uk_target_registry", compile_national) national = next(spec for spec in parity_tool.RECEIPTS if spec.surface == "national") parity_tool._compile_for_receipt(national, [{"feed": True}]) - derived = load_uk_high_value_home_stock_facts().rows - assert seen["facts"] == ({"feed": True}, *derived) + assert seen["facts"] == ({"feed": True}, *uk_derived_fact_rows()) def test_region_ledger_only_rows_carry_the_derived_stock_rationale(): diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_measure_simulation.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_measure_simulation.py index 54f99c484e..7f7ee79d82 100644 --- a/packages/microcosm-build/tests/engine_free/uk/test_uk_measure_simulation.py +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_measure_simulation.py @@ -2133,6 +2133,7 @@ def __init__(self, *, dataset): "carer_support_payment", "scottish_carer_supplement", "total_income", + "adjusted_net_income", "income_tax", ) variables = { @@ -2184,6 +2185,9 @@ def calculate(self, variable, year, map_to=None): ) if variable == "total_income": return total + if variable == "adjusted_net_income": + # No pension contributions or Gift Aid in the stub: ANI is total. + return total if variable == "income_tax": return 0.2 * np.maximum(total - 12_570.0, 0.0) if variable == "tax_band": @@ -2239,6 +2243,9 @@ def test_annual_measures_read_each_persons_year(monkeypatch, tmp_path): np.testing.assert_allclose(total, annual_total) tax, _ = resolver.compute("person", "income_tax_annual") np.testing.assert_allclose(tax, 0.2 * np.maximum(annual_total - 12_570.0, 0.0)) + # CenTax's parent cells read the year's ANI (microcosm#1399). + ani, _ = resolver.compute("person", "adjusted_net_income_annual") + np.testing.assert_allclose(ani, annual_total) band, _ = resolver.compute("person", "tax_band_annual") assert list(band) == ["BASIC", "BASIC", "BASIC"] # The current measures are untouched, and one annual simulation serves all. @@ -2314,3 +2321,165 @@ def test_other_spells_follow_one_engine_factor(monkeypatch, tmp_path): "employment_income_before_lsr": pytest.approx(1.05), "self_employment_income": pytest.approx(1.03), } + + +# CenTax's parent cells (microcosm#1399): the parent of an age-eligible 3- or +# 4-year-old, and employment as the year's main income source. + + +def _centax_parent_simulation() -> SimulationStub: + # Person 1 is an adult in unit 10 with person 2, a DfE-age-3 child before + # school all year; person 3 is a DfE-age-4 child in reception for two + # terms, alone in unit 20, so it has no adult to mark. + simulation = _spring_term_simulation() + simulation.values["is_hbai_dependent_child"] = ( + "person", + np.array([False, True, True]), + ) + simulation.values[("country", "person")] = ( + "person", + np.array(["ENGLAND"] * 3), + ) + return SimulationStub(simulation.values) + + +def test_the_centax_parent_measure_marks_the_adults_of_eligible_units(): + from microcosm.build.uk_runtime.centax_childcare_parents import ( + CENTAX_EMPLOYEE_MAIN_SOURCE, + CENTAX_PARENT_OF_ELIGIBLE_3_4, + ) + + frame, simulation = FrameStub(), _centax_parent_simulation() + values, route = compute_uk_measure_input( + frame, simulation, "person", CENTAX_PARENT_OF_ELIGIBLE_3_4, 2025 + ) + assert values.tolist() == [1.0, 0.0, 0.0] + assert route == f"engine_terms_and_benunit_roles:{CENTAX_PARENT_OF_ELIGIBLE_3_4}" + with pytest.raises(KeyError, match="person-only"): + compute_uk_measure_input( + frame, simulation, "household", CENTAX_PARENT_OF_ELIGIBLE_3_4, 2025 + ) + # The main-source measure needs the annual measures, so only the resolver + # computes it. + with pytest.raises(KeyError, match="UKMeasureResolver"): + compute_uk_measure_input( + frame, simulation, "person", CENTAX_EMPLOYEE_MAIN_SOURCE, 2025 + ) + + +def test_the_resolver_routes_the_centax_parent_measure_to_people( + monkeypatch, tmp_path: Path +): + from microcosm.build.uk_runtime.centax_childcare_parents import ( + CENTAX_PARENT_OF_ELIGIBLE_3_4, + ) + + resolver = _resolver_over(_centax_parent_simulation(), monkeypatch, tmp_path) + assert resolver.entity_for(CENTAX_PARENT_OF_ELIGIBLE_3_4) == "person" + assert resolver.knows("person", CENTAX_PARENT_OF_ELIGIBLE_3_4) + assert not resolver.knows("household", CENTAX_PARENT_OF_ELIGIBLE_3_4) + partial = _centax_parent_simulation() + del partial.tax_benefit_system.variables["is_hbai_dependent_child"] + resolver = _resolver_over(partial, monkeypatch, tmp_path) + assert not resolver.knows("person", CENTAX_PARENT_OF_ELIGIBLE_3_4) + + +@pytest.mark.parametrize( + "column", + ["centax_parent_of_eligible_3_4_year_old", "centax_employment_main_income_source"], +) +def test_a_persisted_centax_column_is_refused(monkeypatch, tmp_path: Path, column): + frame = FrameStub() + frame._tables["person"][column] = [1.0, 0.0, 0.0] + monkeypatch.setitem( + sys.modules, + "policyengine_uk", + SimpleNamespace(__version__="9.9.9", Microsimulation=lambda *, dataset: None), + ) + with pytest.raises(ValueError, match="must not be persisted"): + UKMeasureResolver( + simulation_source=tmp_path / "input.h5", + scratch_dir=tmp_path, + year=2025, + frame=frame, + ) + + +class _CenTaxAnnualSimulation(_AnnualSimulation): + """The annual stub with the year's unearned income and remaining benefits.""" + + unearned = { + "property_income": np.array([0.0, 0.0, 20_000.0]), + "dividend_income": np.array([1_000.0, 12_000.0, 0.0]), + "savings_interest_income": np.array([0.0, 0.0, 0.0]), + "other_investment_income": np.array([0.0, 0.0, 0.0]), + "taxable_miscellaneous_income": np.array([0.0, 0.0, 0.0]), + "incapacity_benefit": np.array([0.0, 0.0, 0.0]), + "widowed_parents_allowance": np.array([0.0, 0.0, 0.0]), + } + + def __init__(self, *, dataset): + super().__init__(dataset=dataset) + for name, values in self.unearned.items(): + self.stored[(name, 2025)] = values + self.tax_benefit_system.variables[name] = SimpleNamespace( + entity=SimpleNamespace(key="person"), value_type=float + ) + + +def test_the_main_source_measure_compares_the_years_sources(monkeypatch, tmp_path): + from microcosm.build.uk_runtime.centax_childcare_parents import ( + CENTAX_EMPLOYEE_MAIN_SOURCE, + ) + + monkeypatch.setitem( + sys.modules, + "policyengine_uk", + SimpleNamespace(__version__="9.9.9", Microsimulation=_CenTaxAnnualSimulation), + ) + resolver = UKMeasureResolver( + simulation_source=tmp_path / "input.h5", + scratch_dir=tmp_path, + year=2025, + frame=_AnnualFrameStub(), + ) + assert resolver.entity_for(CENTAX_EMPLOYEE_MAIN_SOURCE) == "person" + assert resolver.knows("person", CENTAX_EMPLOYEE_MAIN_SOURCE) + assert not resolver.knows("household", CENTAX_EMPLOYEE_MAIN_SOURCE) + employee, route = resolver.compute("person", CENTAX_EMPLOYEE_MAIN_SOURCE) + # Person 1's pay is the largest source; person 2's year's pay (9,450) is + # below its dividends, and person 3's (10,500) below its rents. + assert employee.tolist() == [1.0, 0.0, 0.0] + assert route.startswith("annual_income_sources:employment_income_annual,") + + +class _CenTaxInvestorSimulation(_CenTaxAnnualSimulation): + """Person 1's other investment income (SPI ``OTHERINV``) outranks its pay.""" + + unearned = { + **_CenTaxAnnualSimulation.unearned, + "other_investment_income": np.array([100_000.0, 0.0, 0.0]), + } + + +def test_the_main_source_measure_reads_other_investment_income(monkeypatch, tmp_path): + from microcosm.build.uk_runtime.centax_childcare_parents import ( + CENTAX_EMPLOYEE_MAIN_SOURCE, + ) + + monkeypatch.setitem( + sys.modules, + "policyengine_uk", + SimpleNamespace(__version__="9.9.9", Microsimulation=_CenTaxInvestorSimulation), + ) + resolver = UKMeasureResolver( + simulation_source=tmp_path / "input.h5", + scratch_dir=tmp_path, + year=2025, + frame=_AnnualFrameStub(), + ) + employee, route = resolver.compute("person", CENTAX_EMPLOYEE_MAIN_SOURCE) + # Person 1's year's pay (31,500) is below its 100,000 of other investment + # income, so it is not an employee. + assert employee.tolist() == [0.0, 0.0, 0.0] + assert "other_investment_income" in route.split(":", 1)[1].split(",") diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_measurement_period.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_measurement_period.py index 2280bca166..b90cda0f57 100644 --- a/packages/microcosm-build/tests/engine_free/uk/test_uk_measurement_period.py +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_measurement_period.py @@ -28,6 +28,7 @@ resolve_target_measures, ) from microcosm.build.uk_runtime import measure_simulation +from microcosm.build.uk_runtime.centax_childcare_parents import CENTAX_PARENT_MEASURES from microcosm.build.uk_runtime.hmrc_income import ( LOSS_RELIEF_COLUMN, self_employment_income_after_loss_relief, @@ -425,6 +426,11 @@ def test_a_frame_without_a_period_label_keeps_the_frame_route(): # resolver computes each on its annual simulation at the calibration # period. *measure_simulation.UK_ANNUAL_INCOME_MEASURES, + # The CenTax parent measures: the frame may not store them (the + # resolver refuses them as inputs). The resolver computes the parent + # flag from the engine's term shares at the calibration period and the + # main income source from the annual measures. + *CENTAX_PARENT_MEASURES, } ) diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_population_targets.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_population_targets.py index 23d429dde4..a07666e4b1 100644 --- a/packages/microcosm-build/tests/engine_free/uk/test_uk_population_targets.py +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_population_targets.py @@ -244,10 +244,11 @@ def test_uk_population_targets_shape_order_and_registry_accounting() -> None: # to 6 and England's 0 to 6, and the DfE single-age rows five more # funded-childcare counts (working parent 9 to 11 months and ages 1-3, # nursery age 3; the age-4 cells are signed out, as the bound rows fix - # them). The next policyengine-uk bump retires the OBR welfare-cap split + # them). microcosm#1399 adds CenTax's nine parent cells on the national + # line. The next policyengine-uk bump retires the OBR welfare-cap split # and the England postgraduate repayments to registry_parity.excluded # (microcosm#1095). - assert len(resource["targets"]) == 435 + assert len(resource["targets"]) == 444 providers = resource["hierarchy"]["providers"] categories = resource["hierarchy"]["categories"] @@ -261,10 +262,10 @@ def test_uk_population_targets_shape_order_and_registry_accounting() -> None: target_ids = [target["target_id"] for target in resource["targets"]] registry_scope = resource["registry_parity"]["scope_target_ids"] profile_scope = resource["profile_parity"]["scope_target_ids"] - assert len(registry_scope) == 376 + assert len(registry_scope) == 385 assert len(profile_scope) == 59 - assert target_ids[:376] == registry_scope - assert target_ids[376:] == profile_scope + assert target_ids[:385] == registry_scope + assert target_ids[385:] == profile_scope parity = resource["registry_parity"] assert parity["pinned_ref"] == "12a1e028afeef08d8b2d74ee03fd9de3a78b2dd3" @@ -284,11 +285,12 @@ def test_uk_population_targets_shape_order_and_registry_accounting() -> None: # microcosm#1301's three high-value homes region rows have no uk-data # metric, nor do microcosm#1278's three single years of age or the eleven # the childcare costing fixes add (UK 3 to 6, England 0 to 6), nor the - # five DfE funded-childcare single-age rows. The next policyengine-uk bump - # retires the OBR welfare-cap split and the England postgraduate - # repayments to the excluded rows (microcosm#1095). + # five DfE funded-childcare single-age rows, nor microcosm#1399's nine + # CenTax parent cells. The next policyengine-uk bump retires the OBR + # welfare-cap split and the England postgraduate repayments to the + # excluded rows (microcosm#1095). assert len(mapped_target_ids) == 183 - assert len(unmapped_declarations) == 193 + assert len(unmapped_declarations) == 202 suppressed_ancestors = parity["suppressed_ancestors"] assert len(suppressed_ancestors) == 5 assert set(suppressed_ancestors).isdisjoint(parity["mapped"]) @@ -795,7 +797,7 @@ def test_uk_population_targets_have_unique_target_ids() -> None: resource = _load() target_ids = [target["target_id"] for target in resource["targets"]] - assert len(target_ids) == 435 + assert len(target_ids) == 444 assert len(target_ids) == len(set(target_ids)) @@ -1298,7 +1300,7 @@ def test_uc_payment_bands_share_administrative_family_but_keep_source_window() - def test_paid_joint_diagnostics_do_not_add_active_targets() -> None: targets = _load()["targets"] - assert len(targets) == 435 + assert len(targets) == 444 assert not any( f.get("variable") == "uc_calibration_child_entitlement" for target in targets diff --git a/tools/build_uk_centax_childcare_parent_facts.py b/tools/build_uk_centax_childcare_parent_facts.py new file mode 100644 index 0000000000..0f8266fabb --- /dev/null +++ b/tools/build_uk_centax_childcare_parent_facts.py @@ -0,0 +1,509 @@ +#!/usr/bin/env python +"""Build uk/centax_childcare_parent_facts.json from CenTax's released workbook. + +Advani, Flew, Pepin-Hall and Summers (CenTax, September 2026), *Removing the +childcare cliff-edge*, released their HMRC tabulations as +``AdvaniFlewPepinhallSummers2026_ChildcareThreshold_data.xlsx``; its SHA-256 is +pinned in ``uk_runtime/centax_childcare_parents.py``. This tool reads +Figures 3.1, 3.3, 3.6 and 3.7 from it and writes nine derived facts (see that +module): four ANI band shares of the parents in (0, £200k], and five splits of +the parents in (£80k, £150k], each times ``parent_count``. + +``parent_count`` is the baseline build's own number of such parents at the +calibration year, read from the JSON ``tools/diagnose_uk_centax_parents.py`` +writes on the baseline H5; the resource records that H5's SHA-256. The +uprating that ages the 2021-22 edges is the installed policyengine-uk's OBR +average earnings index, which must agree with the diagnostics'. + +Kept as diagnostics, never targets: Table A.1's levels (1,648,632 parents; 77,615 +on £80k-£140k), Figure 2.1's live childcare account shares and Figure 3.12's +non-working-partner shares. CenTax's model outputs (Tables 3.1, 3.2 and 4.1, +the frictions, elasticities, marginal bunchers and every cost) are not read. + + uv run --no-sync python tools/build_uk_centax_childcare_parent_facts.py \\ + --workbook AdvaniFlewPepinhallSummers2026_ChildcareThreshold_data.xlsx \\ + --baseline-diagnostics centax_parents_a6299504_2025.json +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from collections.abc import Mapping, Sequence +from datetime import date +from pathlib import Path +from typing import Any + +import openpyxl + +from microcosm.build.uk_runtime.centax_childcare_parents import ( + CENTAX_ANI_BANDS_2022, + CENTAX_DATA_TAX_YEAR, + CENTAX_DERIVED_FACT_KEY_PREFIX, + CENTAX_FACTS_RESOURCE, + CENTAX_REGION_GROUPS, + CENTAX_REPORT_URL, + CENTAX_SPLIT_BAND_2022, + CENTAX_SPLITS, + CENTAX_WORKBOOK_SHA256, + RESOURCE_KIND, + ROW_SCHEMA_VERSION, + centax_ani_uprating, + centax_cell_ids, + load_uk_centax_childcare_parent_facts, + model_ani_edges, + rows_sha256, +) + +GENERATOR = { + "tool": "tools/build_uk_centax_childcare_parent_facts.py", + "version": "uk-centax-childcare-parents-v1", +} +DEFAULT_OUT = ( + Path(__file__).resolve().parents[1] + / "packages/microcosm-build/src/microcosm/build/uk" + / CENTAX_FACTS_RESOURCE +) +WORKBOOK_NAME = "AdvaniFlewPepinhallSummers2026_ChildcareThreshold_data.xlsx" +#: Sheet, expected header row (from column B) and the bin step in pounds. +SHEETS: Mapping[str, tuple[tuple[str, ...], int]] = { + "Fig. 3.1": (("Income Bin (ANI)", "Number of Taxpayer in Income Bin"), 500), + "Fig. 3.3": ( + ( + "Income Bin (ANI)", + "Number of Taxpayers in Income Bin (Employees)", + "Number of Taxpayers in Income Bin (Non-Employees)", + ), + 1000, + ), + "Fig. 3.6": ( + ( + "Income Bin (ANI)", + "Number of Taxpayers in Income Bin (Males)", + "Number of Taxpayers in Income Bin (Females)", + ), + 500, + ), + "Fig. 3.7": ( + ( + "Income Bin (ANI)", + "Number of Taxpayers in Income Bin (London)", + "Number of Taxpayers in Income Bin (North and Midlands)", + "Number of Taxpayers in Income Bin (South excl. London)", + "Number of Taxpayers in Income Bin (Scotland)", + ), + 1000, + ), +} +_FIG_3_7_COLUMN = { + "london": "Number of Taxpayers in Income Bin (London)", + "north_and_midlands": "Number of Taxpayers in Income Bin (North and Midlands)", + "south_excl_london": "Number of Taxpayers in Income Bin (South excl. London)", +} +_CELL_LABELS = { + "ani_0_to_50k": "ANI up to £50,000", + "ani_50k_to_80k": "ANI over £50,000 to £80,000", + "ani_80k_to_120k": "ANI over £80,000 to £120,000", + "ani_120k_to_200k": "ANI over £120,000 to £200,000", + "female_ani_80k_to_150k": "women, ANI over £80,000 to £150,000", + "non_employee_ani_80k_to_150k": ( + "main income source other than employment, ANI over £80,000 to £150,000" + ), + "london_ani_80k_to_150k": "London, ANI over £80,000 to £150,000", + "south_excl_london_ani_80k_to_150k": ( + "South excluding London, ANI over £80,000 to £150,000" + ), + "north_and_midlands_ani_80k_to_150k": ( + "North and Midlands, ANI over £80,000 to £150,000" + ), +} +_CELL_FIGURES = { + **{band: "Fig. 3.1" for band, _, _ in CENTAX_ANI_BANDS_2022}, + **{f"{split}_ani_80k_to_150k": figure for split, figure in CENTAX_SPLITS}, +} +SOURCE_MEASURE_ID = "parents_of_eligible_3_4_year_olds" +SOURCE_CONCEPT = "microcosm.centax_childcare_parents.count" + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def read_series(workbook: Any, sheet: str) -> dict[str, dict[int, float]]: + """A figure's columns by header, each a map from bin upper edge to count. + + Refuses a sheet whose header differs from the one this tool was written + against, a non-numeric cell inside the bins, or bins that are not one + step apart from the first step to the last. + """ + + header, step = SHEETS[sheet] + rows = list(workbook[sheet].iter_rows(values_only=True)) + try: + start = next( + index + for index, row in enumerate(rows) + if tuple(row[1 : 1 + len(header)]) == header + ) + except StopIteration: + raise ValueError(f"{sheet}: header {header!r} not found.") from None + series: dict[str, dict[int, float]] = {column: {} for column in header[1:]} + labels: list[int] = [] + for row in rows[start + 1 :]: + label = row[1] + if label is None: + continue + if not isinstance(label, (int, float)) or isinstance(label, bool): + raise ValueError(f"{sheet}: non-numeric bin label {label!r}.") + labels.append(int(label)) + for offset, column in enumerate(header[1:], start=2): + value = row[offset] + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise ValueError(f"{sheet}: bin {label} {column} is {value!r}.") + series[column][int(label)] = float(value) + if labels != list(range(step, labels[-1] + step, step)): + raise ValueError(f"{sheet}: bins are not every £{step} from £{step}.") + return series + + +def band_sum(series: Mapping[int, float], lower: float, upper: float) -> float: + """Sum of the bins whose upper edge is in (lower, upper].""" + + return float( + sum(value for label, value in series.items() if lower < label <= upper) + ) + + +def centax_shares(workbook: Any) -> tuple[dict[str, float], dict[str, Any]]: + """Each cell's share of the parents in (0, £200k], and the workbook sums.""" + + fig31 = read_series(workbook, "Fig. 3.1")["Number of Taxpayer in Income Bin"] + fig33 = read_series(workbook, "Fig. 3.3") + fig36 = read_series(workbook, "Fig. 3.6") + fig37 = read_series(workbook, "Fig. 3.7") + top = CENTAX_ANI_BANDS_2022[-1][2] + if max(fig31) != top: + raise ValueError(f"Fig. 3.1 ends at £{max(fig31)}, not £{top:,.0f}.") + total = band_sum(fig31, 0.0, top) + shares = { + band: band_sum(fig31, lower, upper) / total + for band, lower, upper in CENTAX_ANI_BANDS_2022 + } + lower, upper = CENTAX_SPLIT_BAND_2022 + split_share = band_sum(fig31, lower, upper) / total + + def within(columns: Mapping[str, Mapping[int, float]], numerator: str) -> float: + whole = sum(band_sum(column, lower, upper) for column in columns.values()) + return band_sum(columns[numerator], lower, upper) / whole + + england_regions = { + group: fig37[column] for group, column in _FIG_3_7_COLUMN.items() + } + if set(england_regions) != set(CENTAX_REGION_GROUPS): + raise ValueError("Fig. 3.7 groups differ from the module's region groups.") + split_within = { + "female": within(fig36, "Number of Taxpayers in Income Bin (Females)"), + "non_employee": within( + fig33, "Number of Taxpayers in Income Bin (Non-Employees)" + ), + **{group: within(england_regions, group) for group in england_regions}, + } + for split, _ in CENTAX_SPLITS: + shares[f"{split}_ani_80k_to_150k"] = split_share * split_within[split] + sums = { + "fig_3_1_parents_0_to_200k": total, + "fig_3_1_parents_80k_to_150k": band_sum(fig31, lower, upper), + "split_shares_within_80k_to_150k": split_within, + "fig_3_6_parents_80k_to_150k": sum( + band_sum(column, lower, upper) for column in fig36.values() + ), + "fig_3_3_parents_80k_to_150k": sum( + band_sum(column, lower, upper) for column in fig33.values() + ), + "fig_3_7_england_parents_80k_to_150k_pooled_2019_2022": sum( + band_sum(column, lower, upper) for column in england_regions.values() + ), + } + return shares, {"split_band_share": split_share, "workbook_sums": sums} + + +def diagnostic_comparators(workbook: Any) -> dict[str, Any]: + """The CenTax figures kept as diagnostics: never targets.""" + + table = list(workbook["Tab. A.1"].iter_rows(values_only=True)) + parents = next( + row + for row in table + if row[1] == "3- and 4-year-olds" and row[2] == "Number of Parents" + ) + female = next( + row + for row in table + if row[2] == "Share Female (%)" and table.index(row) < table.index(parents) + 5 + ) + fig312 = { + row[1]: row[2:5] + for row in workbook["Fig. 3.12"].iter_rows(values_only=True) + if isinstance(row[1], (int, float)) and not isinstance(row[1], bool) + } + lower, upper = CENTAX_SPLIT_BAND_2022 + in_band = [values for label, values in fig312.items() if lower < label <= upper] + return { + "table_a1_3_4_year_olds": { + "parents_whole_population": parents[3], + "parents_80k_to_140k": parents[4], + "share_female_whole_population_pct": female[3], + "share_female_80k_to_140k_pct": female[4], + "note": ( + "Levels of identified parents in HMRC's data for 2021-22. Not bound: " + "HMRC identifies parents through its own linkage, so the level is not " + "the FRS's family count, and the DfE caseloads already set the level." + ), + }, + "fig_3_12_non_working_partner_share_80k_to_150k_unweighted_bin_mean": { + "men": sum(values[1] for values in in_band) / len(in_band), + "women": sum(values[2] for values in in_band) / len(in_band), + "note": ( + "Mean of the £2,500 bins' shares (the figure gives shares, not counts). " + "Not bound: the denominator is parents with an identified partner, " + "and the workbook's 'Men & Women' column repeats 'Men'." + ), + }, + "fig_2_1": ( + "Live DfE childcare account shares by ANI. Not bound: the frame has no " + "childcare-account measure; the dashboard's take-up assumption is the " + "place to compare it." + ), + } + + +def fact_row( + *, + cell: str, + value: float, + period: str, + edges: Sequence[int], + inputs_sha256: str, +) -> dict[str, Any]: + record_set_spec_id = f"uk.centax_childcare_parents.{cell}.v1" + identity = { + "record_set_spec_id": record_set_spec_id, + "period": period, + "inputs_sha256": inputs_sha256, + "method": GENERATOR["version"], + } + digest = hashlib.sha256( + json.dumps(identity, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest()[:24] + label = ( + "Parents of age-eligible 3- and 4-year-olds, " + f"{_CELL_LABELS[cell]} (2021-22 pounds), England" + ) + return { + "schema_version": ROW_SCHEMA_VERSION, + "aggregate_fact_key": CENTAX_DERIVED_FACT_KEY_PREFIX + digest, + "semantic_fact_key": ( + "microcosm.semantic_fact.centax_childcare_parents.v1:" + digest + ), + "value": float(round(value, 3)), + "value_type": "float", + "assertion": "observation", + "provenance_class": "derived_administrative", + "period": {"type": "calendar_year", "value": period}, + "period_coverage": { + "start_date": f"{period}-01-01", + "end_date": f"{period}-12-31", + "basis": "flow", + "notes": ( + f"CenTax's share for tax year {CENTAX_DATA_TAX_YEAR} times the " + f"baseline build's parents at {period}; the ANI edges are aged to " + f"{period} pounds ({edges[0]:,} to {edges[1]:,})." + ), + }, + "geography": { + "level": "country", + "id": "E92000001", + "vintage": "current", + "name": "England", + }, + "entity": {"name": "person", "role": "parent"}, + "aggregation": {"method": "sum"}, + "observed_measure": { + "source_concept": SOURCE_CONCEPT, + "source_measure_id": SOURCE_MEASURE_ID, + "unit": "count", + }, + "measure": {"unit": "count"}, + "concept_alignment": { + "source_concept": SOURCE_CONCEPT, + "canonical_concept": "uk.person.count", + "relation": "derived", + }, + "dimensions": {"microcosm.centax_cell": cell}, + "dimension_labels": { + "microcosm.centax_cell": "CenTax parent cell (2021-22 ANI pounds)" + }, + "dimension_value_labels": {"microcosm.centax_cell": {cell: _CELL_LABELS[cell]}}, + "source": { + "source_name": "microcosm", + "source_table": ( + f"CenTax, Removing the childcare cliff-edge (2026), {_CELL_FIGURES[cell]}; " + "Microcosm derivation" + ), + "source_file": CENTAX_FACTS_RESOURCE, + "vintage": "centax_2026_09", + "source_sha256": inputs_sha256, + "url": CENTAX_REPORT_URL, + }, + "lineage": {"source_record_id": f"microcosm.centax_childcare_parents.{cell}"}, + "layout": { + "record_set_id": f"uk.centax_childcare_parents.{cell}", + "record_set_spec_id": record_set_spec_id, + "measure_id": SOURCE_MEASURE_ID, + }, + "label": label, + } + + +def build_resource( + *, + workbook_path: Path, + baseline: Mapping[str, Any], + calibration_year: int, + uprating: float, + built_on: str, +) -> dict[str, Any]: + digest = _sha256(workbook_path) + if digest != CENTAX_WORKBOOK_SHA256: + raise ValueError( + f"{workbook_path} is not the pinned CenTax workbook ({digest})." + ) + edges = model_ani_edges(uprating) + if int(baseline["year"]) != calibration_year: + raise ValueError( + f"the baseline diagnostics are at {baseline['year']}, not {calibration_year}." + ) + if baseline["model_ani_edges"] != edges: + raise ValueError("the baseline diagnostics were banded on other ANI edges.") + if abs(float(baseline["ani_uprating"]) - uprating) > 1e-12: + raise ValueError("the baseline diagnostics used another ANI uprating.") + workbook = openpyxl.load_workbook(workbook_path, read_only=True, data_only=True) + shares, receipts = centax_shares(workbook) + comparators = diagnostic_comparators(workbook) + parent_count = float(baseline["parent_count"]) + inputs = { + "workbook": { + "filename": WORKBOOK_NAME, + "sha256": digest, + "report_url": CENTAX_REPORT_URL, + "figures": ["Fig. 3.1", "Fig. 3.3", "Fig. 3.6", "Fig. 3.7"], + "received": "Arun Advani to Vahid Ahmadi, 21 October 2026", + }, + "baseline": { + "dataset": baseline["dataset"], + "sha256": baseline["dataset_sha256"], + "diagnostics_schema": baseline["schema"], + "policyengine_uk_version": baseline["resolver"]["policyengine_uk_version"], + }, + "method": GENERATOR["version"], + } + inputs_sha256 = hashlib.sha256( + json.dumps(inputs, sort_keys=True).encode() + ).hexdigest() + period = str(calibration_year) + rows = [ + fact_row( + cell=cell, + value=shares[cell] * parent_count, + period=period, + edges=edges[cell], + inputs_sha256=inputs_sha256, + ) + for cell in centax_cell_ids() + ] + rows.sort(key=lambda row: row["aggregate_fact_key"]) + return { + "kind": RESOURCE_KIND, + "schema_version": 1, + "resource": CENTAX_FACTS_RESOURCE, + "description": ( + "Microcosm-derived counts of parents of age-eligible 3- and 4-year-olds in " + "England by adjusted net income, from CenTax's HMRC tabulations for " + f"{CENTAX_DATA_TAX_YEAR} (Advani, Flew, Pepin-Hall and Summers 2026, " + "Figures 3.1, 3.3, 3.6 and 3.7): each published share times the baseline " + "build's own parent count. Not Chronicle facts, and no CenTax model output." + ), + "built_on": built_on, + "generator": GENERATOR, + "licence": { + "attribution": [ + "Advani, A., Flew, D., Pepin-Hall, J. and Summers, A. (2026), Removing " + "the childcare cliff-edge, CenTax. HMRC administrative data; the " + "authors' calculations." + ], + }, + "inputs": inputs, + "inputs_sha256": inputs_sha256, + "parameters": { + "calibration_year": calibration_year, + "ani_uprating": uprating, + "ani_uprating_basis": ( + "policyengine-uk gov.economic_assumptions.indices.obr.average_earnings, " + f"{calibration_year} over 2021" + ), + "ani_bands_2022": { + band: [lower, upper] for band, lower, upper in CENTAX_ANI_BANDS_2022 + }, + "split_band_2022": list(CENTAX_SPLIT_BAND_2022), + "model_ani_edges": edges, + "parent_count": parent_count, + "parent_count_basis": ( + "baseline parents of age-eligible 3- and 4-year-olds with annual ANI in " + f"(0, {edges[CENTAX_ANI_BANDS_2022[-1][0]][1]:,}] at {calibration_year}" + ), + "shares": shares, + "split_band_share": receipts["split_band_share"], + "workbook_sums": receipts["workbook_sums"], + "diagnostic_comparators": comparators, + "never_targets": [ + "Tables 3.1, 3.2 and 4.1 (bunching estimates)", + "friction parameters, elasticities and marginal bunchers", + "every fiscal cost", + ], + }, + "row_count": len(rows), + "rows_sha256": rows_sha256(rows), + "rows": rows, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--workbook", type=Path, required=True) + parser.add_argument("--baseline-diagnostics", type=Path, required=True) + parser.add_argument("--calibration-year", type=int, default=2025) + parser.add_argument("--out", type=Path, default=DEFAULT_OUT) + parser.add_argument("--built-on", default=date.today().isoformat()) + args = parser.parse_args(argv) + baseline = json.loads(args.baseline_diagnostics.read_text(encoding="utf-8")) + resource = build_resource( + workbook_path=args.workbook, + baseline=baseline, + calibration_year=args.calibration_year, + uprating=centax_ani_uprating(args.calibration_year), + built_on=args.built_on, + ) + rendered = json.dumps(resource, indent=2, sort_keys=True, ensure_ascii=True) + "\n" + args.out.write_text(rendered, encoding="utf-8") + facts = load_uk_centax_childcare_parent_facts(args.out) + print(f"wrote {args.out} ({len(facts.rows)} rows, {facts.rows_sha256[:12]})") + for cell in centax_cell_ids(): + print( + f"{cell:38s} share {facts.shares[cell]:.5f} value {facts.value(cell):>11,.0f}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/build_uk_ledger_compile_parity_signed_differences.py b/tools/build_uk_ledger_compile_parity_signed_differences.py index 7846146bcc..ad3a44921d 100644 --- a/tools/build_uk_ledger_compile_parity_signed_differences.py +++ b/tools/build_uk_ledger_compile_parity_signed_differences.py @@ -11,9 +11,7 @@ import numpy as np from microcosm.build.gates import ledger_compile_parity_signed_differences -from microcosm.build.uk_runtime.high_value_homes import ( - load_uk_high_value_home_stock_facts, -) +from microcosm.build.uk_runtime.derived_facts import uk_derived_fact_rows from microcosm.build.uk_runtime.ledger_targets import ( LOCAL_REGISTRY_PARITY_FIXTURE_RESOURCE, align_uk_local_registry_parity_fixture, @@ -759,6 +757,21 @@ def _aligned_fixture(fixture: dict) -> dict: "(calibration_lines; microcosm#1301)." ) +#: microcosm#1399: CenTax's parent cells bind Microcosm-derived counts, each a +#: share CenTax published from HMRC's tabulations times the baseline build's +#: own parents, so the incumbent national register has no row to compare. +_CENTAX_PARENTS_PREFIX = "microcosm.centax_childcare_parents." +_CENTAX_PARENTS_LEDGER_ONLY_RATIONALE = ( + "ledger_only by construction: the row is a Microcosm-derived count of " + "parents of age-eligible 3- and 4-year-olds in England in one adjusted net " + "income cell: CenTax's share of HMRC's 2021-22 tabulation (Advani, Flew, " + "Pepin-Hall and Summers 2026, Figures 3.1, 3.3, 3.6 and 3.7) times the " + "baseline build's own parents (uk/centax_childcare_parent_facts.json), " + "compiled beside the Chronicle feed. The incumbent registry carries no " + "such row, so the difference is the derived target itself. The rows bind " + "on the national line only (calibration_lines; microcosm#1399)." +) + def _add_signed_rationale_notes( report: dict[str, object], @@ -781,6 +794,10 @@ def _add_signed_rationale_notes( and row.get("kind") == "ledger_only" ): row["reason"] = _HIGH_VALUE_HOMES_REGION_LEDGER_ONLY_RATIONALE + elif name.startswith(_CENTAX_PARENTS_PREFIX) and row.get("kind") == ( + "ledger_only" + ): + row["reason"] = _CENTAX_PARENTS_LEDGER_ONLY_RATIONALE elif name in _UC_PAID_WINDOW_NAMES: inherited_change = ( "PR #891 contract with #892 source-window averaging" @@ -1034,15 +1051,16 @@ def _add_local_signed_rationale_notes(report: dict[str, object]) -> None: def _compile_for_receipt(spec: ParityReceiptSpec, facts: list[dict]): if spec.surface == "national": - # The high-value homes region rows (microcosm#1301) select the - # derived facts too; the national compile carries every reference. + # The high-value homes region rows (microcosm#1301) and CenTax's parent + # cells (microcosm#1399) select the derived facts too; the national + # compile carries every reference. return compile_uk_target_registry( - (*facts, *load_uk_high_value_home_stock_facts().rows), + (*facts, *uk_derived_fact_rows()), target_period=spec.target_period, ) if spec.surface == "local": return compile_uk_local_target_registry( - (*facts, *load_uk_high_value_home_stock_facts().rows), + (*facts, *uk_derived_fact_rows()), target_period=spec.target_period, crosswalk=_load_crosswalk(), ) diff --git a/tools/certify_uk_release_cut.py b/tools/certify_uk_release_cut.py index 190f99ebed..545eeba73e 100644 --- a/tools/certify_uk_release_cut.py +++ b/tools/certify_uk_release_cut.py @@ -40,10 +40,8 @@ role_pins_digest, write_error_receipt, ) +from microcosm.build.uk_runtime.derived_facts import uk_derived_fact_rows from microcosm.build.uk_runtime.frs_release import load_uk_frs_release -from microcosm.build.uk_runtime.high_value_homes import ( - load_uk_high_value_home_stock_facts, -) from microcosm.build.uk_runtime.hmrc_uprating import assert_installed_engine_is_pinned from microcosm.build.uk_runtime.ledger_targets import ( compile_uk_local_target_registry, @@ -268,8 +266,9 @@ def _run(args: argparse.Namespace, state: AttemptState) -> dict[str, object]: # producer keeps the partial registry and leaves the verdict to the gates. ledger_unsupported = {} # The high-value homes family binds Microcosm-derived facts beside the feed: - # its local cells (microcosm#1240) and its region rows (microcosm#1301). - facts = (*artifact.facts, *load_uk_high_value_home_stock_facts().rows) + # its local cells (microcosm#1240) and its region rows (microcosm#1301); + # CenTax's parent cells bind the second derived resource (microcosm#1399). + facts = (*artifact.facts, *uk_derived_fact_rows()) for period in _LEDGER_COMPILE_PARITY_PERIODS: compilation = compile_uk_target_registry(facts, target_period=period) ledger_registries[period] = compilation.registry diff --git a/tools/diagnose_uk_centax_parents.py b/tools/diagnose_uk_centax_parents.py new file mode 100644 index 0000000000..a206601e6f --- /dev/null +++ b/tools/diagnose_uk_centax_parents.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python +"""Fit and support of the CenTax parent cells on a Microcosm UK national H5. + +Computes, through the production measure resolver at the calibration year, each +cell of ``uk/centax_childcare_parent_facts.json``: the model's weighted parents, +the records and households behind them, the Kish effective number of households +and the share of the cell's weight on its ten heaviest households. With +``--facts`` it also reports each cell's target and relative error, and the +CenTax figures the resource keeps as diagnostics only (the Table A.1 levels and +the Figure 3.12 non-working-partner shares). ``parent_count`` is the number the +facts tool scales the CenTax shares by when this H5 is the baseline. + +Writes aggregates only: no record-level values leave the H5. + + uv run --no-sync python tools/diagnose_uk_centax_parents.py \\ + --h5 microcosm_uk_2024_25.h5 --out diagnostics.json [--facts resource.json] +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import tempfile +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +import numpy as np + +from microcosm.build.uk_runtime.centax_childcare_parents import ( + CENTAX_ANI_BANDS_2022, + CENTAX_ANI_MEASURE, + CENTAX_EMPLOYEE_MAIN_SOURCE, + CENTAX_PARENT_OF_ELIGIBLE_3_4, + CENTAX_REGION_GROUPS, + centax_ani_uprating, + centax_cell_filters, + centax_cell_ids, + load_uk_centax_childcare_parent_facts, + model_ani_edges, +) +from microcosm.build.uk_runtime.measure_simulation import UKMeasureResolver +from microcosm.build.uk_runtime.national_frame import load_uk_national_frame +from microcosm.build.uk_runtime.weight_year import uk_engine_year_frame + +#: The nominal ANI the free hours and Tax-Free Childcare taper away above. +_CLIFF_GBP = 100_000.0 +_TOP_HOUSEHOLDS = 10 + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _support( + mask: np.ndarray, person_weight: np.ndarray, person_household: np.ndarray +) -> dict[str, Any]: + """Weighted parents and the household support behind them.""" + + households, members = np.unique(person_household[mask], return_counts=True) + count = float(person_weight[mask].sum()) + if not len(households): + return {"model_count": 0.0, "person_records": 0, "households": 0} + weight_by_household = dict( + zip(person_household[mask], person_weight[mask], strict=True) + ) + household_weight = np.asarray( + [weight_by_household[household] for household in households], dtype=float + ) + contribution = household_weight * members + heaviest = np.sort(contribution)[::-1][:_TOP_HOUSEHOLDS] + return { + "model_count": count, + "person_records": int(mask.sum()), + "households": int(len(households)), + "kish_households": float( + household_weight.sum() ** 2 / (household_weight**2).sum() + ), + "kish_contributions": float(contribution.sum() ** 2 / (contribution**2).sum()), + "top10_household_share": float(heaviest.sum() / contribution.sum()), + } + + +def _mask(columns: Mapping[str, np.ndarray], filters: list[Mapping[str, Any]]): + mask = np.ones(len(next(iter(columns.values()))), dtype=bool) + for predicate in filters: + values = columns[str(predicate["variable"])] + if "in" in predicate: + mask &= np.isin(values, predicate["in"]) + elif "equals" in predicate: + mask &= values == predicate["equals"] + elif predicate["operator"] == ">": + mask &= values.astype(float) > float(predicate["value"]) + elif predicate["operator"] == "<=": + mask &= values.astype(float) <= float(predicate["value"]) + else: + raise ValueError(f"unexpected predicate {dict(predicate)!r}") + return mask + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--h5", type=Path, required=True) + parser.add_argument("--year", type=int, default=2025) + parser.add_argument("--out", type=Path, required=True) + parser.add_argument( + "--facts", + type=Path, + default=None, + help="The derived-facts resource to compare against (default: none).", + ) + parser.add_argument("--label", default=None, help="Display name of the H5.") + args = parser.parse_args(argv) + + uprating = centax_ani_uprating(args.year) + edges = model_ani_edges(uprating) + facts = ( + None + if args.facts is None + else load_uk_centax_childcare_parent_facts(args.facts) + ) + if facts is not None and facts.parameters["model_ani_edges"] != edges: + raise SystemExit( + "the facts resource's edges are not this engine's at " + f"{args.year}: {facts.parameters['model_ani_edges']} vs {edges}" + ) + + frame, _provenance = load_uk_national_frame(args.h5) + frame = uk_engine_year_frame(frame, simulation_year=args.year) + with tempfile.TemporaryDirectory() as scratch: + resolver = UKMeasureResolver( + simulation_source=args.h5, + scratch_dir=Path(scratch), + year=args.year, + frame=frame, + ) + + def person(name: str) -> np.ndarray: + return resolver.compute("person", name)[0] + + columns = { + CENTAX_PARENT_OF_ELIGIBLE_3_4: person(CENTAX_PARENT_OF_ELIGIBLE_3_4), + CENTAX_ANI_MEASURE: person(CENTAX_ANI_MEASURE).astype(float), + CENTAX_EMPLOYEE_MAIN_SOURCE: person(CENTAX_EMPLOYEE_MAIN_SOURCE), + "gender": person("gender").astype(str), + "region": person("region").astype(str), + } + current_ani = person("adjusted_net_income").astype(float) + work = person("employment_income_annual").astype(float) + person( + "self_employment_income_after_loss_relief_annual" + ).astype(float) + receipt = resolver.receipt() + + person_table = frame.table("person") + household_table = frame.table("household") + household_weight = dict( + zip( + household_table["household_id"].to_numpy(), + np.asarray(frame.weights_for("household").values, dtype=float), + strict=True, + ) + ) + person_household = person_table["person_household_id"].to_numpy() + person_benunit = person_table["person_benunit_id"].to_numpy() + person_weight = np.asarray( + [household_weight[household] for household in person_household], dtype=float + ) + parent = columns[CENTAX_PARENT_OF_ELIGIBLE_3_4] == 1 + ani = columns[CENTAX_ANI_MEASURE] + top = edges[CENTAX_ANI_BANDS_2022[-1][0]][1] + + cells: dict[str, Any] = {} + for cell in centax_cell_ids(): + entry = _support( + _mask(columns, centax_cell_filters(cell, edges[cell])), + person_weight, + person_household, + ) + entry["model_ani_edges"] = edges[cell] + if facts is not None: + target = facts.value(cell) + entry["target"] = target + entry["relative_error"] = entry["model_count"] / target - 1.0 + cells[cell] = entry + + # Partners: the other adult of a two-adult benefit unit, non-working when + # the year's pay and profit are nil, as Figure 3.12 counts them. + partnered = np.zeros(len(parent), dtype=bool) + partner_not_working = np.zeros(len(parent), dtype=bool) + order = np.flatnonzero(parent) + units: dict[Any, list[int]] = {} + for index in order: + units.setdefault(person_benunit[index], []).append(int(index)) + for members in units.values(): + if len(members) == 2: + first, second = members + partnered[members] = True + partner_not_working[first] = work[second] <= 0 + partner_not_working[second] = work[first] <= 0 + split_edges = edges["female_ani_80k_to_150k"] + in_split = parent & (ani > split_edges[0]) & (ani <= split_edges[1]) + partner_shares = {} + for sex in ("MALE", "FEMALE"): + base = in_split & partnered & (columns["gender"] == sex) + partner_shares[sex.lower()] = { + "partnered_weight": float(person_weight[base].sum()), + "non_working_partner_share": float( + person_weight[base & partner_not_working].sum() + / max(person_weight[base].sum(), 1e-12) + ), + "person_records": int(base.sum()), + } + + result: dict[str, Any] = { + "schema": "microcosm.uk.centax_parent_diagnostics.v1", + "dataset": args.label or args.h5.name, + "dataset_sha256": _sha256(args.h5), + "year": args.year, + "ani_uprating": uprating, + "model_ani_edges": edges, + "parent_count": float(person_weight[parent & (ani > 0) & (ani <= top)].sum()), + "parents": { + "all": _support(parent, person_weight, person_household), + "ani_not_positive": _support( + parent & (ani <= 0), person_weight, person_household + ), + "ani_above_top_edge": _support( + parent & (ani > top), person_weight, person_household + ), + "annual_ani_over_100k": _support( + parent & (ani > _CLIFF_GBP), person_weight, person_household + ), + "current_ani_over_100k": _support( + parent & (current_ani > _CLIFF_GBP), person_weight, person_household + ), + }, + "cells": cells, + "region_groups": { + group: list(regions) for group, regions in CENTAX_REGION_GROUPS.items() + }, + "partners_ani_80k_to_150k": partner_shares, + "resolver": { + "policyengine_uk_version": receipt.get("policyengine_uk_version"), + "annual_income_measures": receipt.get("annual_income_measures", {}).get( + "computed_measures" + ), + }, + } + if facts is not None: + result["facts_rows_sha256"] = facts.rows_sha256 + result["centax_diagnostic_comparators"] = facts.parameters.get( + "diagnostic_comparators" + ) + args.out.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n") + print(f"parent_count {result['parent_count']:,.0f}") + for cell, entry in cells.items(): + line = ( + f"{cell:38s} {entry['model_count']:>11,.0f} rec {entry['person_records']:>5} " + f"kish {entry.get('kish_households', 0.0):7.1f} " + f"top10 {entry.get('top10_household_share', 0.0):5.2f}" + ) + if "target" in entry: + line += ( + f" target {entry['target']:>11,.0f} err {entry['relative_error']:+.3f}" + ) + print(line) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/diagnose_uk_uc_matrix.py b/tools/diagnose_uk_uc_matrix.py index 6ba439f9b9..80c5482658 100644 --- a/tools/diagnose_uk_uc_matrix.py +++ b/tools/diagnose_uk_uc_matrix.py @@ -35,9 +35,7 @@ materialize_target_bindings, resolve_target_measures, ) -from microcosm.build.uk_runtime.high_value_homes import ( - load_uk_high_value_home_stock_facts, -) +from microcosm.build.uk_runtime.derived_facts import uk_derived_fact_rows from microcosm.build.uk_runtime.ledger_targets import ( _candidate_facts_for_reference, _uk_baseline_flag_crosstab, @@ -565,7 +563,7 @@ def factory(**kwargs): # compiles it: every reference on the feed and the derived facts, less # the rows that bind on the local line only (microcosm#1301). compilation = compile_uk_target_registry( - (*artifact.facts, *load_uk_high_value_home_stock_facts().rows), + (*artifact.facts, *uk_derived_fact_rows()), target_period=receipt["model_year"], ) assert not compilation.unsupported, compilation.unsupported diff --git a/tools/evaluate_uk_incumbent_surface.py b/tools/evaluate_uk_incumbent_surface.py index 7029839973..90bff35a70 100644 --- a/tools/evaluate_uk_incumbent_surface.py +++ b/tools/evaluate_uk_incumbent_surface.py @@ -27,11 +27,9 @@ import pandas as pd from microcosm.build.ledger_artifact import load_ledger_consumer_artifact +from microcosm.build.uk_runtime.derived_facts import uk_derived_fact_rows from microcosm.build.uk_runtime.frs_release import load_uk_frs_release from microcosm.build.uk_runtime.full_measure import resolve_uk_full_measures -from microcosm.build.uk_runtime.high_value_homes import ( - load_uk_high_value_home_stock_facts, -) from microcosm.build.uk_runtime.incumbent_surface_evaluation import ( GSS_REGION_CODES, classify_local_rows, @@ -144,9 +142,10 @@ def main(argv=None) -> int: ) period = int(load_uk_frs_release().calibration_year) # The dense candidate's own line binds the high-value homes region rows, - # which select the Microcosm-derived facts beside the feed (microcosm#1301). + # which select the Microcosm-derived facts beside the feed (microcosm#1301), + # and CenTax's parent cells, which select the second resource (microcosm#1399). compilation = compile_uk_target_registry( - (*artifact.facts, *load_uk_high_value_home_stock_facts().rows), + (*artifact.facts, *uk_derived_fact_rows()), target_period=period, ) if compilation.unsupported: diff --git a/tools/generate_uk_target_references.py b/tools/generate_uk_target_references.py index 773f83ce1e..e9d6f69a95 100644 --- a/tools/generate_uk_target_references.py +++ b/tools/generate_uk_target_references.py @@ -2,9 +2,9 @@ """Generate UK Ledger target references from the national contract. This is an offline authoring tool. It consumes an already-exported Ledger -consumer fact JSONL feed and the committed Microcosm-derived facts resource -(uk/high_value_home_stock_facts.json); it does not fetch data or contact -Chronicle. +consumer fact JSONL feed and the committed Microcosm-derived facts resources +(uk/high_value_home_stock_facts.json and uk/centax_childcare_parent_facts.json); +it does not fetch data or contact Chronicle. """ from __future__ import annotations @@ -23,6 +23,10 @@ author_target_references, target_references_resource, ) +from microcosm.build.uk_runtime.centax_childcare_parents import ( + CENTAX_FACTS_RESOURCE, + load_uk_centax_childcare_parent_facts, +) from microcosm.build.uk_runtime.high_value_homes import ( load_uk_high_value_home_stock_facts, ) @@ -112,24 +116,30 @@ def main() -> None: allowed_levels=NATIONAL_GEOGRAPHY_LEVELS, ) _validate_amount_entity_pins(contract) - facts = [ - json.loads(line) - for line in args.ledger_facts.read_text().splitlines() - if line.strip() - ] + # Stream the lines: the feed is several GB, and reading it whole held its + # text and every line beside the parsed rows. + with args.ledger_facts.open(encoding="utf-8") as handle: + facts = [json.loads(line) for line in handle if line.strip()] # microcosm#1301: the high-value homes region rows select the # Microcosm-derived facts the local cells bind (microcosm#1240), carried in # the committed resource rather than the Chronicle feed. Their fan-out # refuses a missing cell, so the national surface always merges them. + # CenTax's parent cells (microcosm#1399) select the second resource. derived = load_uk_high_value_home_stock_facts(args.derived_facts) + centax = load_uk_centax_childcare_parent_facts(args.centax_facts) facts.extend(dict(row) for row in derived.rows) + facts.extend(dict(row) for row in centax.rows) derived_resources = [ { - "resource": "high_value_home_stock_facts.json", - "rows": len(derived.rows), - "rows_sha256": derived.rows_sha256, - "inputs_sha256": derived.inputs_sha256, + "resource": resource, + "rows": len(loaded.rows), + "rows_sha256": loaded.rows_sha256, + "inputs_sha256": loaded.inputs_sha256, } + for resource, loaded in ( + ("high_value_home_stock_facts.json", derived), + (CENTAX_FACTS_RESOURCE, centax), + ) ] inverse_mapping = _registry_inverse(contract) # The facts each family's fan-out leaves unnamed, counted rather than @@ -201,6 +211,15 @@ def _parser() -> argparse.ArgumentParser: "committed uk/high_value_home_stock_facts.json)." ), ) + parser.add_argument( + "--centax-facts", + type=Path, + default=None, + help=( + "CenTax parent facts resource merged beside the feed (default: the " + "committed uk/centax_childcare_parent_facts.json)." + ), + ) parser.add_argument("--output", type=Path, required=True) parser.add_argument("--membership-report", type=Path, required=True) return parser @@ -537,6 +556,9 @@ def _geography_composition( # Heating Payment from winter 2024-25, so DWP publishes the 2024-25 and # 2025-26 statistics for England and Wales (K04000001) only (microcosm#1069). ("dwp.winter_fuel_payment.", "england_and_wales"), + # CenTax tabulates parents in England only, and the derived facts are + # stamped E92000001 (microcosm#1399); the substring rule sees no nation. + ("microcosm.centax_childcare_parents.", "england"), ) # DfT BUS05i rows name their area in the selector; the geography follows the # declared area, never a prefix, so a London or UK row can never be stamped