Repository navigation
Take SPI Scottish taxpayer status from SCOT_TXP and keep unknown regions simulable - #540
Merged
Merged
Conversation
… simulable create_spi labels GORCODE 13 (address abroad), 14 (address unknown) and -1 (composite records) UNKNOWN. policyengine-uk 2.104.5 (PolicyEngine/policyengine-uk#1985) uprates their rent by the UK-wide index, so the dataset now simulates as built. Scottish taxpayer status comes from HMRC's SCOT_TXP flag rather than the region: the two disagree for 1,394 GORCODE-11 records and 606 Scottish taxpayers elsewhere on the 2022-23 tape. load_spi_dataset relabels UNKNOWN as SOUTH_EAST only on releases before 2.104.5, and ensure_spi_dataset rebuilds a cached H5 without the flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #540 round 1 (Sol 6.1, REQUEST CHANGES): - model_simulates_unknown_region now simulates one UNKNOWN household on the imported policyengine-uk instead of reading installed package metadata, which differs from the imported code under make data-local. Only a missing parameter for UNKNOWN reads as "cannot simulate"; any other error raises. - A differential test ties the probe to the release: where the imported model is the installed distribution, the probe agrees with version >= 2.104.5. - The unknown-region test xfails only on the missing private rent index for UNKNOWN; any other error fails it. - The region invariant test checks HMRC's codebook mapping written out in the test, not REGION_MAP itself. - New test: records whose SCOT_TXP agrees with their region keep exactly the income tax of the region-derived rule; the others change. - Income tax tests now cover 2030, the last year policyengine-uk carries dataset inputs to; the create_spi comment records that horizon. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #540 round 2 (Sol 6.1, REQUEST CHANGES): - model_simulates_unknown_region no longer matches error text. A failure with region UNKNOWN reads as "cannot simulate" only if the same household labelled SOUTH_EAST simulates, which is exactly when the loader's relabel helps; otherwise the control's error is raised. The household mirrors the SPI-shaped fixture policyengine-uk's own test_rent_uprating.py simulates. (ParameterNotFoundError.name is reset to None by AttributeError.__init__, so the parameter name is not available to match.) - Probe tests cover unrelated parameters ending in .UNKNOWN, unrelated parameters and schema errors (all raised), and a non-parameter failure that relabelling cures (read as "no"). - The release differential test runs only for an unmodified final release installed from a package index and imported from there: it skips pre, dev and local versions, direct-URL or editable installs, and an economic_assumptions.py that does not match the release's RECORD. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aises Review of #540 round 3 (Sol 6.1, APPROVE WITH CHANGES, three nits): - The probe docstring says what it establishes: that the imported model can build a simulation of an SPI-shaped household in Region.UNKNOWN. - The release differential test describes its checks as they are: a final release not installed from a direct URL, whose imported economic_assumptions.py is the installed file and matches its RECORD. - The shared-failure test gives the UNKNOWN and SOUTH_EAST households distinct errors and requires the control's, with the UNKNOWN error as its context. A mutant that re-raised the UNKNOWN error had survived. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
Author
Hand-off to the UK hub
|
51 of 76 tasks
2 of 4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #505.
What and why
create_spilabels SPI records whoseGORCODEis not a UK region asUNKNOWN. HMRC's codebook (SN 9422, Annex A) gives these codes as 13 "Address abroad" and 14 "Address unknown or not available", and composite records carry -1. On the 2022-23 public use tape that is 18,859 of 836,850 records: 16,194 abroad, 353 unknown and 2,312 composite. Two things were wrong:Microsimulation(dataset=create_spi(tab, 2022))failed while the simulation was being built, withParameterNotFoundError: ... private_rental_prices.UNKNOWN. policyengine-uk 2.104.5 (released 2026-10-02) is the first release with Uprate rent for unknown-region households by the UK-wide index policyengine-uk#1985, which uprates rent forRegion.UNKNOWNby the UK-wide index. With it,UNKNOWNsimulates as built.UNKNOWNstays the right label: none of these records has a UK region, and relabelling them would distort regional totals.SCOT_TXP("Indicates whether the record is treated as Scottish, for tax purposes"), and its liabilities (TOTTAX_DEVO_TXP,TAX_ST_*_DEVO_TXP) are "based on SCOT_TXP or WELSH_TXP". The dataset didn't carry the flag, so policyengine-uk derivedpays_scottish_income_taxfrom the region. The two disagree for 2,000 records. HMRC notes that the region reflects residence at the end of the tax year, which can differ from Scottish taxpayer status. Records with an address abroad and composite records can also be Scottish taxpayers. HMRC built composites from Scottish, Welsh or other taxpayers only, never a mix.Changes:
datasets/spi.py:pays_scottish_income_taxfromSCOT_TXP. HMRC documents the flag as.or1, but the 2022-23 tape holds0or1; both parse.WELSH_TXPis not used, because policyengine-uk has no separate Welsh rates of income tax (no Welsh parameters undergov.hmrc.income_taxin 2.93.0 or 2.121.0).REGION_MAPcomment now gives the codes correctly.model_simulates_unknown_region(). It checks that the imported policyengine-uk can build a simulation of one SPI-shaped household labelledUNKNOWN, once per process (a few seconds). A failure means "cannot simulate" only if the same household labelledSOUTH_EASTbuilds, which is exactly when relabelling helps. Otherwise theSOUTH_EASThousehold's error is raised.test_rent_uprating.pysimulates withUNKNOWN, so the model keeps supporting that shape.make data-localputs a policyengine-uk checkout onPYTHONPATHahead of the installed release.utils/incomes_projection.py:load_spi_datasetrelabelsUNKNOWNasSOUTH_EASTonly when the imported model cannot simulateUNKNOWN. This replaces create_spi output cannot be simulated: unknown regions crash policyengine-uk, and Scottish taxpayer status ignores SCOT_TXP #505's to-do ("remove the relabelling once the lock reaches #1985"): the relabel switches itself off at the relock, with no follow-up PR.ensure_spi_datasetalso rebuilds a cachedspi_2022_23.h5that predates the flag. Without that, a stale cache would silently fall back to deriving the flag from the region.Horizon. policyengine-uk carries a single-year dataset's inputs forward to 2030 (
extend_single_year_dataset,end_year=2030, in 2.93.0 and 2.121.0). From 2031 it computespays_scottish_income_taxfrom the region again, as it does for any input variable that has a formula. A two-person synthetic check on both releases confirmed this: the input held in 2029 and 2030, and the region rule replaced it in 2031 and 2032. The tests cover 2022, 2026 and 2030.Evidence
Everything here is aggregate. No count under 10 records is shown. Each count is at least 10, and so is its complement within its group. A count of zero counts as under 10, so no figure here implies one by differencing.
HMRC tape documentation for 2022-23 (SN 9422) and 2020-21 (SN 9121) covers:
GORCODEcodes 1-14;SCOT_TXPandWELSH_TXPdefinitions;SCOT_TXPis on both tapes.Tape counts (2022-23):
GORCODE11 but not a Scottish taxpayer: 1,394 records (90,000 weighted).GORCODE11: 606 records (33,000 weighted).Historical income-tax evidence against HMRC's own
TOTTAX_DEVO_TXP, on the full 2022-23 tape, collected before this refresh. Both datasets ran under policyengine-uk 2.104.5, because the pre-batchcreate_spioutput could not be simulated under the then-locked 2.93.0. "main" in this historical table iscreate_spiat b45c373. Groups overlap. The within-£10 column is a weighted share (byFACT), not a share of records.GORCODE11, not a Scottish taxpayerGORCODEnot 11GORCODE13, 14 or -1git tag --containsputs it first in 2.104.5, and 2.104.4 does not contain it. PyPI shows 2.104.4 uploaded at 11:12 UTC and 2.104.5 at 11:15 UTC that day. The latest release when these tests ran (2026-10-05) was 2.122.0.Invariants
GORCODEalone. Codes 1-12 map to HMRC's regions, and every other code becomesUNKNOWN. Every label is aRegionmember, and Scottish taxpayers with codes 13, 14 or -1 are not relabelledSCOTLAND.pays_scottish_income_taxis true exactly whenSCOT_TXPparses to 1, whatever theGORCODEand however "no" is written (.,0or blank).SCOT_TXP. This holds in the data year, after uprating (2026), and in 2030, the last year policyengine-uk carries dataset inputs to.UNKNOWNor with the legacySOUTH_EASTgives equal income tax record for record, in 2022, 2026 and 2030.SCOT_TXPagrees with their region get exactly the income tax of the old region-derived rule, so only records where the two disagree can change. This is tested in 2022, 2026 and 2030.model_simulates_unknown_region():UNKNOWNhousehold fails and the same household labelledSOUTH_EASTsimulates;SOUTH_EASThousehold's error when both fail;economic_assumptions.pyis the installed file and matches itsRECORD, is true exactly when that release is 2.104.5 or later.load_spi_datasetchanges a region label only when the imported model cannot simulateUNKNOWN. A cached H5 is reused only if it has the release year and the flag.Tests
All in
tests/test_spi_build.py.SCOT_TXPis added to the fake-tape columns there and intests/test_spi_allowance_deductions.py.test_create_spi_region_and_scottish_taxpayer_invariants[0|.|blank](invariants 1-2): exhaustive overGORCODE∈ {-1, 1…14, 99} ×SCOT_TXP∈ {no, 1}, for each encoding of "no". It checks against HMRC's codebook mapping written out in the test, notREGION_MAP.test_create_spi_scottish_taxpayer_status_survives_h5_round_trip: the flag andUNKNOWNsurvivesaveand load.test_spi_income_tax_follows_scottish_taxpayer_flag(invariant 3): uses the legacy label, so it also runs under the historical 2.93.0 lock.test_spi_income_tax_changes_only_where_scottish_flag_and_region_disagree(invariant 5): compares against the same dataset with the flag column dropped, which is main's rule. It usesUNKNOWNwhere the model can simulate it, andSOUTH_EASTotherwise.test_create_spi_output_with_unknown_region_can_be_simulated(invariant 4): uses the defaultUNKNOWNlabel. Where the imported model cannot simulateUNKNOWN, the build must raiseParameterNotFoundErrorforprivate_rental_prices.UNKNOWNand nothing else, and the test reports an xfail. Otherwise it runs in full.test_unknown_region_probe_reads_the_simulation[…]: three cases. It simulates; the rent index is missing; or a non-parameter error that relabelling cures. In each case the test checks which households were simulated.test_unknown_region_probe_raises_failures_relabelling_would_not_cure[…]: an unrelated parameter ending in.UNKNOWN, an unrelated parameter, and a schema error, each also hitting theSOUTH_EASThousehold. Each must be raised as theSOUTH_EASThousehold's own error, with theUNKNOWNhousehold's distinct error as its context.test_unknown_region_probe_agrees_with_policyengine_uk_release. It skips:economic_assumptions.py(the module #1985 changed) that does not match the release'sRECORD.test_income_projection_loads_local_h5_dataset[False|True]test_income_projection_rebuilds_spi_dataset_without_scottish_flag, which uses real H5 files.Historical results from before this refresh (both files,
TESTING=1, old PR head4f86ceeeb844d6fcd76f8da7e68e5d2a8062525c; these were not rerun in this refresh session):PYTHONPATHPYTHONPATHThe last two rows are the version skew the first review found; the old gate read the metadata there and got the answer wrong both ways. The
RECORDcheck was also exercised directly on a 2.121.0 install. It reports a mismatch after a one-line edit toeconomic_assumptions.py, and a match once the edit is removed.Historical mutation checks from before this refresh. Each had to fail at least one test; all ran under the then-locked 2.93.0 unless noted. These mutation checks were not rerun in this refresh session.
GORCODE == 11(main's rule)UNKNOWNSCOTLANDSCOT_TXPas ScottishSCOT_TXPwithout parsing itREGION_MAP[8]set toLONDON(survived round 1)UNKNOWNhousehold's error instead of the control's (survived round 3)policyengine-uk lock
The 10/8 release batch landed on 10/7 as uk-data 1.58.0. Its main head
4cbedbecca352f52dffe47e849553c2759a00711pins policyengine-uk 2.122.2 and policyengine-core 3.32.13. This refresh inherits those lock and dependency changes by merging main. A separate one-line fix aligns the lock's local-project version withpyproject.tomlat 1.58.0; no dependency version changes.2.122.2 is newer than the first UNKNOWN-capable release, 2.104.5. The refreshed validation below confirmed that the unknown-region simulation test passes in full,
model_simulates_unknown_region()is true, andload_spi_datasetkeepsUNKNOWNwithout the legacySOUTH_EASTrelabel. The compatibility fallback remains for imported models that cannot simulateUNKNOWN.Impact: completed real post-refresh build
Measured from the completed files on 9 October 2026. All results are aggregates.
Baseline: uk-data 1.58.0 release tip
57ce68ac14ebe2bb6da1965b30555a5a1a919488, with the seeded-dropout patch348dd493c7cba66b329a384410e4bb21de97cd4d, built at1fc2a372317b19c59ee6ce4d309a2fc6e7b489d2. After: PR head4fbd8be43b7dc550d9f6c2871ddc7270fec38c01, with the same seed patch, built atb3ea202266bf695f6d6d8371ef7fe4cedcd18612. The completed build finished at2026-10-09T17:45:08Zwith exit 0. No dataset was rebuilt to finish this report.Both builds and all reported simulations use policyengine-uk 2.122.2 and policyengine-core 3.32.13, Python 3.13.9. Build settings: 512 epochs per calibration, dropout seed 0,
PE_UK_DATA_OA_CLONES=1,TESTINGunset, the same prerequisites and frozen HTTP responses. Calibration year 2025, final FRS input year 2024; simulation years 2025 and 2026. The seed patch IDs match. Relative to the release tip, implementation changes are confined to #540’s SPI/loader files and tests; other changes are release bookkeeping.National amounts from fresh Microsimulation runs on
efrs_combined.h5andefrs_ud540.h5(£bn; baseline → completed #540 build):Positive-value counts, in millions of each variable’s own entity. Population and household totals include all rows. All counts use direct simulation weights.
Poverty rates use person weights; BHC/AHC means before/after housing costs.
Scottish status is
pays_scottish_income_tax. Scottish income tax here means allincome_taxof flagged people, including tax on savings and dividends. NSND isearned_income_tax + property_income_taxbefore downstream reliefs. Status counts include people with no positive liability; positive taxpayer counts also requireincome_tax > 0. All amounts and counts below use person weights. The enhanced FRS source flaghousehold_is_spi_syntheticidentifies FRS donor households with SPI-imputed incomes, rather than raw HMRC SPI tape records. Neither enhanced-FRS file contains an explicitpays_scottish_income_taxinput, so the installed engine derives its status from the FRS donor household’s country.UNKNOWN-region rows in the finished files, using direct year-specific simulation weights. SPI-synthetic rows retain donor geography; the counts here describe generated FRS rows, not raw HMRC tape records. Public counts below 10 rows and their weights are suppressed to preserve the existing PR disclosure convention; exact computed aggregates remain in the local impact report.
National target fit from the gzipped calibration logs, at the last saved-weight logged epoch 510 of each 512-epoch solve.
name=UKselects the national matrix, which contains UK, country and region constraints. Within 10% meansabs(estimate-target)/abs(target) < 0.1. There are 645 targets; the one zero target counts outside that share and is omitted from the median over 644 nonzero targets. These log fits precede final public-service/fuel scaling and differ from the logged dropout training metric.Both decompressed calibration logs match their baseline counterparts exactly. All four HDF5 files open successfully and match their respective checksum manifests; the two completed-build file hashes differ from the baseline. The detailed HDF5 array-content audit is retained with the local impact report.
Across 2025 and 2026, the enhanced FRS has 0 changed national totals, counts or poverty results; income tax is £297.803997bn → £297.803997bn in 2025 and £317.626600bn → £317.626600bn in 2026. Scottish-flagged income tax is £22.442166bn → £22.442166bn in 2025 and £23.875370bn → £23.875370bn in 2026, with 5.825043m → 5.825043m Scottish-status people in 2025. The constituency calibration has 86.821705% → 86.821705% of national targets within 10%, with median error 4.034333% → 4.034333%.
The expectation of an essentially unchanged enhanced FRS holds: the national measurements and Scottish measurements are exactly equal in both years, including the FRS/SPI source split.
Why the effect is zero: the enhanced-FRS pipeline trains its income imputation through
generate_spi_table(datasets/imputations/income.py), notcreate_spi. #540 changes only comments in the SPI mapping constants that the imputation imports (REGION_MAP,AGE_RANGESand the release constants keep their values). Calibration reads the committedincomes_projection.csv, which this PR leaves unchanged.create_income_projectionsbuilds that file from HMRC's official income-band tables and uprating factors, not from SPI microdata or Scottish taxpayer flags, and it holds national income bands only. So #540 reaches the standalone SPI dataset (create_spi), and regenerating the projection would not carry it into the calibration targets.Separately audited earlier standalone SPI evidence, retained from the interrupted job. These numbers come from its saved real Microsimulation runs on the full 2022–23 tape at
57ce68aand4fbd8be4, age-imputation seed 0, under the same engine. They agree exactly with the saved run log. They describe standalone SPI materializations, not the finished enhanced FRS files, and were not rerun or rebuilt in this reporting job.Limitation: no standalone SPI HDF5 was retained. Fresh 2025/2026 UNKNOWN-region weights for raw SPI could therefore not be measured from a saved file within the no-rebuild scope. The draft scripts reconstructed those weights from population growth; those extrapolated results are excluded. The year-specific UNKNOWN weights above are fresh measurements of the finished enhanced FRS/FRS files. The raw tape count and FACT weight are earlier measured input aggregates, not year-specific simulated weights.
The completed build logged 22 HTTP requests and 0 cache misses. The response log matches the baseline after normalising its one HMRC fetch to a cache hit. Both build logs show exit 0.
Failed to download/parse OBR, failed target sources and errors/tracebacks each have no matches in either log. The 10/8 baseline log was available and confirms the same Class 2 and Class 4 NICs “no row matched” warnings: six of each per build. These warnings mean those component targets were unavailable in both builds.The remaining shared warnings are constant-zero employment/self-employment imputations (two each), constant-zero incapacity benefit (one), skipped
maternity_allowance_reported(one), no Northern Ireland OA crosswalk (one), and skipped OA assignment for 5,660 Northern Ireland households (one). Both logs contain 20 warnings in total. The warnings are unchanged.Post-batch refresh (10/7)
4fbd8be43b7dc550d9f6c2871ddc7270fec38c01, a fast-forward descendant of4f86ceeeb844d6fcd76f8da7e68e5d2a8062525c, with main4cbedbecca352f52dffe47e849553c2759a00711as the merge commit's other parent.tests/test_spi_build.pyby keeping both the PR's region/flag invariants and unknown-region simulation tests and Supply is_claimant_or_partner from the FRS adult table #524'stest_create_spi_marks_every_taxpayer_as_their_benefit_units_claimant. The shared fake-tape helper supplies bothSCOT_TXPand main'sSEINC_NUM.a22fb5daffa29d0ff28dc27d70775285202ff3f2: all 70 main-only paths matched main exactly, all three PR-only paths matched the old PR head, and both shared files preserved each branch's changes. No main or PR hunk was dropped; no conflict markers remain. The only follow-up changesuv.lock's local-project version from 1.57.4 to main's released 1.58.0, fixinguv lock --checkwithout changing any dependency pins.Ran the following under policyengine-uk 2.122.2 / policyengine-core 3.32.13, with
TESTING=1, sequentially in the foreground, one file per pytest process,-p no:cacheproviderand no xdist. All paths are underpolicyengine_uk_data/tests/.test_spi_build.pytest_spi_allowance_deductions.pytest_spi_donor_benefit_rules.pytest_spi_income_earnings_groups.pytest_claimant_or_partner.pyTotal: 73 passed, 3 skipped, 0 failed, 0 xfailed. The three skips need a built FRS/enhanced FRS. The unknown-region simulation test passed in full, rather than xfailing. A separate synthetic-H5 check used the real capability probe: it returned true,
load_spi_datasetkeptUNKNOWNwithout relabelling toSOUTH_EAST, and the Scottish flag was preserved.uv lock --checkpassed after the one-line metadata fix; the four PR Python files passruff format --check.The initial harness attempt had 15 passes and 18 setup errors because the configured temporary-directory parent was missing. Creating it fixed the harness; the final SPI run above has no errors. No source changes were needed for that rerun.
Review
Independent GPT-6.1 Sol reviews.
Round 1 (9fc0e29): REQUEST CHANGES.
private_rental_prices.UNKNOWNindex;Round 2 (3c37ef8): REQUEST CHANGES. Round 1 findings 2-4 resolved; 1 and 5 partly.
.UNKNOWN'in the message. It now uses a known-region control instead of matching text.error.name, but that cannot work:AttributeError.__init__resetsnameto None, so everyParameterNotFoundErrorcarriesname=None(checked on Python 3.13).UNKNOWN, and a failure that theSOUTH_EASThousehold shares is raised.Round 3 (c65d381): APPROVE WITH CHANGES. Round 2 findings 1, 2 and 4 resolved; 3 partly. All seven result rows were independently reproduced. The disclosure audit passed (93 assertions). Three nits remained, all fixed at 4f86cee:
economic_assumptions.pyinstalled and matchingRECORD.UNKNOWNerror) now fails 3 tests.Round 4 (
4f86ceeeb844d6fcd76f8da7e68e5d2a8062525c): APPROVE. This was the last independent Sol review before the batch refresh, as recorded by the hub; CI was green at that head. The two delta reviews below cover the refreshed head.Delta review 1 (refreshed head
4fbd8be43b7dc550d9f6c2871ddc7270fec38c01): REQUEST CHANGES. No code defect. The one blocker was that the impact had not come from a real build of this head.Delta review 2 (same head, after the real build): APPROVE. The reviewer independently:
Its one nit, stating that reason in this body, is done.
Release
Held out of the 10/8 batch, which landed on 10/7 as uk-data 1.58.0. This PR is for the next uk-data batch, on Max's go (d833). The real build and both delta reviews are complete at this head, so the PR is ready for that batch.
This PR was rescued from an interrupted session's uncommitted patch and finished here. Changes from that patch:
.-encoding test is folded into the exhaustive invariant test.axiom: n/a: SPI dataset construction, no policy rule changes
🤖 Generated with Claude Code