From fa682e2929f2e54a0cbb0fa444211d1450dbc237 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:54:33 +0400 Subject: [PATCH 01/25] Add checked-in region reference files for the US and the UK Each file lists every region of one country with its code, label, type, parent, US state, the value that selects its households, and the v1 identifier it replaces. The UK file lists 1,016 regions: the United Kingdom, its four nations (Northern Ireland as country/northern_ireland, replacing country/ni), 650 constituencies and 361 local authorities by ONS code (constituency/E14001063 replaces constituency/Aldershot), each under its nation. The 361 include Ards and North Down (N09000011), which the Public API's own list now carries. The US file lists the United States, 51 states, 436 congressional districts with unchanged codes, and 333 places. The constituency and local-authority rows come from the Public API's own lookup files; the US states, districts and places, and every filter value, match the region registry of PolicyEngine.py 6.7.5. A loader validates each file: unique codes and v1 identifiers, codes that start with their type, and parents that lead to the national region. Nothing reads the files yet. Co-Authored-By: Claude Opus 5.5 --- policyengine_api/data/v2/catalog/errors.py | 7 + .../data/v2/catalog/extraction.py | 25 +- .../v2/catalog/region_reference_files/uk.csv | 1017 +++++++++++++++++ .../v2/catalog/region_reference_files/us.csv | 822 +++++++++++++ .../data/v2/catalog/region_references.py | 251 ++++ tests/unit/v2/test_region_references.py | 318 ++++++ 6 files changed, 2420 insertions(+), 20 deletions(-) create mode 100644 policyengine_api/data/v2/catalog/errors.py create mode 100644 policyengine_api/data/v2/catalog/region_reference_files/uk.csv create mode 100644 policyengine_api/data/v2/catalog/region_reference_files/us.csv create mode 100644 policyengine_api/data/v2/catalog/region_references.py create mode 100644 tests/unit/v2/test_region_references.py diff --git a/policyengine_api/data/v2/catalog/errors.py b/policyengine_api/data/v2/catalog/errors.py new file mode 100644 index 000000000..53be90f51 --- /dev/null +++ b/policyengine_api/data/v2/catalog/errors.py @@ -0,0 +1,7 @@ +"""The error raised when catalog sources cannot be normalized.""" + +from __future__ import annotations + + +class CatalogExtractionError(RuntimeError): + """Raised before database access when source metadata cannot be normalized.""" diff --git a/policyengine_api/data/v2/catalog/extraction.py b/policyengine_api/data/v2/catalog/extraction.py index 75b0b213f..99ac8086b 100644 --- a/policyengine_api/data/v2/catalog/extraction.py +++ b/policyengine_api/data/v2/catalog/extraction.py @@ -14,6 +14,7 @@ from urllib.parse import urlsplit from uuid import NAMESPACE_URL, UUID, uuid5 +from policyengine_api.data.v2.catalog.errors import CatalogExtractionError from policyengine_api.data.v2.catalog.records import ( CountryCatalog, DatasetRecord, @@ -27,6 +28,10 @@ RegionRecord, VariableRecord, ) +from policyengine_api.data.v2.catalog.region_references import ( + MAX_REGION_FILTER_VALUE_LENGTH, + SUPPORTED_REGION_TYPES, +) if TYPE_CHECKING: from policyengine_api.json_types import JSONValue @@ -46,26 +51,10 @@ "us": tuple(range(2035, 2021, -1)), "uk": tuple(range(2024, 2031)), } -SUPPORTED_REGION_TYPES = frozenset( - { - "national", - "country", - "state", - "congressional_district", - "constituency", - "local_authority", - "city", - "place", - } -) PLACEHOLDER_VERSIONS = frozenset({"", "0", "0.0", "0.0.0", "unknown"}) DATASET_YEAR_PATTERN = re.compile(r"(?:19|20)\d{2}") -class CatalogExtractionError(RuntimeError): - """Raised before database access when source metadata cannot be normalized.""" - - def _identifier(*parts: object) -> UUID: value = "/".join(str(part) for part in parts) return uuid5(NAMESPACE_URL, f"https://api.policyengine.org/v2/catalog/{value}") @@ -99,10 +88,6 @@ def _optional_text( return value -# The catalog's region filter_value column holds at most this many characters. -MAX_REGION_FILTER_VALUE_LENGTH = 255 - - def _serialize_region_filter_value(value: object, *, region_code: str) -> str: """Keep scalar text or encode flat membership values in the existing column. diff --git a/policyengine_api/data/v2/catalog/region_reference_files/uk.csv b/policyengine_api/data/v2/catalog/region_reference_files/uk.csv new file mode 100644 index 000000000..e20f450de --- /dev/null +++ b/policyengine_api/data/v2/catalog/region_reference_files/uk.csv @@ -0,0 +1,1017 @@ +code,label,region_type,parent_code,state_code,state_name,filter_value,v1_code +uk,United Kingdom,national,,,,,uk +country/england,England,country,uk,,,"[""NORTH_EAST"",""NORTH_WEST"",""YORKSHIRE"",""EAST_MIDLANDS"",""WEST_MIDLANDS"",""EAST_OF_ENGLAND"",""LONDON"",""SOUTH_EAST"",""SOUTH_WEST""]",country/england +country/scotland,Scotland,country,uk,,,SCOTLAND,country/scotland +country/wales,Wales,country,uk,,,WALES,country/wales +country/northern_ireland,Northern Ireland,country,uk,,,NORTHERN_IRELAND,country/ni +constituency/E14001063,Aldershot,constituency,country/england,,,E14001063,constituency/Aldershot +constituency/E14001064,Aldridge-Brownhills,constituency,country/england,,,E14001064,constituency/Aldridge-Brownhills +constituency/E14001065,Altrincham and Sale West,constituency,country/england,,,E14001065,constituency/Altrincham and Sale West +constituency/E14001066,Amber Valley,constituency,country/england,,,E14001066,constituency/Amber Valley +constituency/E14001067,Arundel and South Downs,constituency,country/england,,,E14001067,constituency/Arundel and South Downs +constituency/E14001068,Ashfield,constituency,country/england,,,E14001068,constituency/Ashfield +constituency/E14001069,Ashford,constituency,country/england,,,E14001069,constituency/Ashford +constituency/E14001070,Ashton-under-Lyne,constituency,country/england,,,E14001070,constituency/Ashton-under-Lyne +constituency/E14001071,Aylesbury,constituency,country/england,,,E14001071,constituency/Aylesbury +constituency/E14001072,Banbury,constituency,country/england,,,E14001072,constituency/Banbury +constituency/E14001073,Barking,constituency,country/england,,,E14001073,constituency/Barking +constituency/E14001074,Barnsley North,constituency,country/england,,,E14001074,constituency/Barnsley North +constituency/E14001075,Barnsley South,constituency,country/england,,,E14001075,constituency/Barnsley South +constituency/E14001076,Barrow and Furness,constituency,country/england,,,E14001076,constituency/Barrow and Furness +constituency/E14001077,Basildon and Billericay,constituency,country/england,,,E14001077,constituency/Basildon and Billericay +constituency/E14001078,Basingstoke,constituency,country/england,,,E14001078,constituency/Basingstoke +constituency/E14001079,Bassetlaw,constituency,country/england,,,E14001079,constituency/Bassetlaw +constituency/E14001080,Bath,constituency,country/england,,,E14001080,constituency/Bath +constituency/E14001081,Battersea,constituency,country/england,,,E14001081,constituency/Battersea +constituency/E14001082,Beaconsfield,constituency,country/england,,,E14001082,constituency/Beaconsfield +constituency/E14001083,Beckenham and Penge,constituency,country/england,,,E14001083,constituency/Beckenham and Penge +constituency/E14001084,Bedford,constituency,country/england,,,E14001084,constituency/Bedford +constituency/E14001085,Bermondsey and Old Southwark,constituency,country/england,,,E14001085,constituency/Bermondsey and Old Southwark +constituency/E14001086,Bethnal Green and Stepney,constituency,country/england,,,E14001086,constituency/Bethnal Green and Stepney +constituency/E14001087,Beverley and Holderness,constituency,country/england,,,E14001087,constituency/Beverley and Holderness +constituency/E14001088,Bexhill and Battle,constituency,country/england,,,E14001088,constituency/Bexhill and Battle +constituency/E14001089,Bexleyheath and Crayford,constituency,country/england,,,E14001089,constituency/Bexleyheath and Crayford +constituency/E14001090,Bicester and Woodstock,constituency,country/england,,,E14001090,constituency/Bicester and Woodstock +constituency/E14001091,Birkenhead,constituency,country/england,,,E14001091,constituency/Birkenhead +constituency/E14001092,Birmingham Edgbaston,constituency,country/england,,,E14001092,constituency/Birmingham Edgbaston +constituency/E14001093,Birmingham Erdington,constituency,country/england,,,E14001093,constituency/Birmingham Erdington +constituency/E14001094,Birmingham Hall Green and Moseley,constituency,country/england,,,E14001094,constituency/Birmingham Hall Green and Moseley +constituency/E14001095,Birmingham Hodge Hill and Solihull North,constituency,country/england,,,E14001095,constituency/Birmingham Hodge Hill and Solihull North +constituency/E14001096,Birmingham Ladywood,constituency,country/england,,,E14001096,constituency/Birmingham Ladywood +constituency/E14001097,Birmingham Northfield,constituency,country/england,,,E14001097,constituency/Birmingham Northfield +constituency/E14001098,Birmingham Perry Barr,constituency,country/england,,,E14001098,constituency/Birmingham Perry Barr +constituency/E14001099,Birmingham Selly Oak,constituency,country/england,,,E14001099,constituency/Birmingham Selly Oak +constituency/E14001100,Birmingham Yardley,constituency,country/england,,,E14001100,constituency/Birmingham Yardley +constituency/E14001101,Bishop Auckland,constituency,country/england,,,E14001101,constituency/Bishop Auckland +constituency/E14001102,Blackburn,constituency,country/england,,,E14001102,constituency/Blackburn +constituency/E14001103,Blackley and Middleton South,constituency,country/england,,,E14001103,constituency/Blackley and Middleton South +constituency/E14001104,Blackpool North and Fleetwood,constituency,country/england,,,E14001104,constituency/Blackpool North and Fleetwood +constituency/E14001105,Blackpool South,constituency,country/england,,,E14001105,constituency/Blackpool South +constituency/E14001106,Blaydon and Consett,constituency,country/england,,,E14001106,constituency/Blaydon and Consett +constituency/E14001107,Blyth and Ashington,constituency,country/england,,,E14001107,constituency/Blyth and Ashington +constituency/E14001108,Bognor Regis and Littlehampton,constituency,country/england,,,E14001108,constituency/Bognor Regis and Littlehampton +constituency/E14001109,Bolsover,constituency,country/england,,,E14001109,constituency/Bolsover +constituency/E14001110,Bolton North East,constituency,country/england,,,E14001110,constituency/Bolton North East +constituency/E14001111,Bolton South and Walkden,constituency,country/england,,,E14001111,constituency/Bolton South and Walkden +constituency/E14001112,Bolton West,constituency,country/england,,,E14001112,constituency/Bolton West +constituency/E14001113,Bootle,constituency,country/england,,,E14001113,constituency/Bootle +constituency/E14001114,Boston and Skegness,constituency,country/england,,,E14001114,constituency/Boston and Skegness +constituency/E14001115,Bournemouth East,constituency,country/england,,,E14001115,constituency/Bournemouth East +constituency/E14001116,Bournemouth West,constituency,country/england,,,E14001116,constituency/Bournemouth West +constituency/E14001117,Bracknell,constituency,country/england,,,E14001117,constituency/Bracknell +constituency/E14001118,Bradford East,constituency,country/england,,,E14001118,constituency/Bradford East +constituency/E14001119,Bradford South,constituency,country/england,,,E14001119,constituency/Bradford South +constituency/E14001120,Bradford West,constituency,country/england,,,E14001120,constituency/Bradford West +constituency/E14001121,Braintree,constituency,country/england,,,E14001121,constituency/Braintree +constituency/E14001122,Brent East,constituency,country/england,,,E14001122,constituency/Brent East +constituency/E14001123,Brent West,constituency,country/england,,,E14001123,constituency/Brent West +constituency/E14001124,Brentford and Isleworth,constituency,country/england,,,E14001124,constituency/Brentford and Isleworth +constituency/E14001125,Brentwood and Ongar,constituency,country/england,,,E14001125,constituency/Brentwood and Ongar +constituency/E14001126,Bridgwater,constituency,country/england,,,E14001126,constituency/Bridgwater +constituency/E14001127,Bridlington and The Wolds,constituency,country/england,,,E14001127,constituency/Bridlington and The Wolds +constituency/E14001128,Brigg and Immingham,constituency,country/england,,,E14001128,constituency/Brigg and Immingham +constituency/E14001129,Brighton Kemptown and Peacehaven,constituency,country/england,,,E14001129,constituency/Brighton Kemptown and Peacehaven +constituency/E14001130,Brighton Pavilion,constituency,country/england,,,E14001130,constituency/Brighton Pavilion +constituency/E14001131,Bristol Central,constituency,country/england,,,E14001131,constituency/Bristol Central +constituency/E14001132,Bristol East,constituency,country/england,,,E14001132,constituency/Bristol East +constituency/E14001133,Bristol North East,constituency,country/england,,,E14001133,constituency/Bristol North East +constituency/E14001134,Bristol North West,constituency,country/england,,,E14001134,constituency/Bristol North West +constituency/E14001135,Bristol South,constituency,country/england,,,E14001135,constituency/Bristol South +constituency/E14001136,Broadland and Fakenham,constituency,country/england,,,E14001136,constituency/Broadland and Fakenham +constituency/E14001137,Bromley and Biggin Hill,constituency,country/england,,,E14001137,constituency/Bromley and Biggin Hill +constituency/E14001138,Bromsgrove,constituency,country/england,,,E14001138,constituency/Bromsgrove +constituency/E14001139,Broxbourne,constituency,country/england,,,E14001139,constituency/Broxbourne +constituency/E14001140,Broxtowe,constituency,country/england,,,E14001140,constituency/Broxtowe +constituency/E14001141,Buckingham and Bletchley,constituency,country/england,,,E14001141,constituency/Buckingham and Bletchley +constituency/E14001142,Burnley,constituency,country/england,,,E14001142,constituency/Burnley +constituency/E14001143,Burton and Uttoxeter,constituency,country/england,,,E14001143,constituency/Burton and Uttoxeter +constituency/E14001144,Bury North,constituency,country/england,,,E14001144,constituency/Bury North +constituency/E14001145,Bury South,constituency,country/england,,,E14001145,constituency/Bury South +constituency/E14001146,Bury St Edmunds and Stowmarket,constituency,country/england,,,E14001146,constituency/Bury St Edmunds and Stowmarket +constituency/E14001147,Calder Valley,constituency,country/england,,,E14001147,constituency/Calder Valley +constituency/E14001148,Camborne and Redruth,constituency,country/england,,,E14001148,constituency/Camborne and Redruth +constituency/E14001149,Cambridge,constituency,country/england,,,E14001149,constituency/Cambridge +constituency/E14001150,Cannock Chase,constituency,country/england,,,E14001150,constituency/Cannock Chase +constituency/E14001151,Canterbury,constituency,country/england,,,E14001151,constituency/Canterbury +constituency/E14001152,Carlisle,constituency,country/england,,,E14001152,constituency/Carlisle +constituency/E14001153,Carshalton and Wallington,constituency,country/england,,,E14001153,constituency/Carshalton and Wallington +constituency/E14001154,Castle Point,constituency,country/england,,,E14001154,constituency/Castle Point +constituency/E14001155,Central Devon,constituency,country/england,,,E14001155,constituency/Central Devon +constituency/E14001156,Central Suffolk and North Ipswich,constituency,country/england,,,E14001156,constituency/Central Suffolk and North Ipswich +constituency/E14001157,Chatham and Aylesford,constituency,country/england,,,E14001157,constituency/Chatham and Aylesford +constituency/E14001158,Cheadle,constituency,country/england,,,E14001158,constituency/Cheadle +constituency/E14001159,Chelmsford,constituency,country/england,,,E14001159,constituency/Chelmsford +constituency/E14001160,Chelsea and Fulham,constituency,country/england,,,E14001160,constituency/Chelsea and Fulham +constituency/E14001161,Cheltenham,constituency,country/england,,,E14001161,constituency/Cheltenham +constituency/E14001162,Chesham and Amersham,constituency,country/england,,,E14001162,constituency/Chesham and Amersham +constituency/E14001163,Chester North and Neston,constituency,country/england,,,E14001163,constituency/Chester North and Neston +constituency/E14001164,Chester South and Eddisbury,constituency,country/england,,,E14001164,constituency/Chester South and Eddisbury +constituency/E14001165,Chesterfield,constituency,country/england,,,E14001165,constituency/Chesterfield +constituency/E14001166,Chichester,constituency,country/england,,,E14001166,constituency/Chichester +constituency/E14001167,Chingford and Woodford Green,constituency,country/england,,,E14001167,constituency/Chingford and Woodford Green +constituency/E14001168,Chippenham,constituency,country/england,,,E14001168,constituency/Chippenham +constituency/E14001169,Chipping Barnet,constituency,country/england,,,E14001169,constituency/Chipping Barnet +constituency/E14001170,Chorley,constituency,country/england,,,E14001170,constituency/Chorley +constituency/E14001171,Christchurch,constituency,country/england,,,E14001171,constituency/Christchurch +constituency/E14001172,Cities of London and Westminster,constituency,country/england,,,E14001172,constituency/Cities of London and Westminster +constituency/E14001173,City of Durham,constituency,country/england,,,E14001173,constituency/City of Durham +constituency/E14001174,Clacton,constituency,country/england,,,E14001174,constituency/Clacton +constituency/E14001175,Clapham and Brixton Hill,constituency,country/england,,,E14001175,constituency/Clapham and Brixton Hill +constituency/E14001176,Colchester,constituency,country/england,,,E14001176,constituency/Colchester +constituency/E14001177,Colne Valley,constituency,country/england,,,E14001177,constituency/Colne Valley +constituency/E14001178,Congleton,constituency,country/england,,,E14001178,constituency/Congleton +constituency/E14001179,Corby and East Northamptonshire,constituency,country/england,,,E14001179,constituency/Corby and East Northamptonshire +constituency/E14001180,Coventry East,constituency,country/england,,,E14001180,constituency/Coventry East +constituency/E14001181,Coventry North West,constituency,country/england,,,E14001181,constituency/Coventry North West +constituency/E14001182,Coventry South,constituency,country/england,,,E14001182,constituency/Coventry South +constituency/E14001183,Cramlington and Killingworth,constituency,country/england,,,E14001183,constituency/Cramlington and Killingworth +constituency/E14001184,Crawley,constituency,country/england,,,E14001184,constituency/Crawley +constituency/E14001185,Crewe and Nantwich,constituency,country/england,,,E14001185,constituency/Crewe and Nantwich +constituency/E14001186,Croydon East,constituency,country/england,,,E14001186,constituency/Croydon East +constituency/E14001187,Croydon South,constituency,country/england,,,E14001187,constituency/Croydon South +constituency/E14001188,Croydon West,constituency,country/england,,,E14001188,constituency/Croydon West +constituency/E14001189,Dagenham and Rainham,constituency,country/england,,,E14001189,constituency/Dagenham and Rainham +constituency/E14001190,Darlington,constituency,country/england,,,E14001190,constituency/Darlington +constituency/E14001191,Dartford,constituency,country/england,,,E14001191,constituency/Dartford +constituency/E14001192,Daventry,constituency,country/england,,,E14001192,constituency/Daventry +constituency/E14001193,Derby North,constituency,country/england,,,E14001193,constituency/Derby North +constituency/E14001194,Derby South,constituency,country/england,,,E14001194,constituency/Derby South +constituency/E14001195,Derbyshire Dales,constituency,country/england,,,E14001195,constituency/Derbyshire Dales +constituency/E14001196,Dewsbury and Batley,constituency,country/england,,,E14001196,constituency/Dewsbury and Batley +constituency/E14001197,Didcot and Wantage,constituency,country/england,,,E14001197,constituency/Didcot and Wantage +constituency/E14001198,Doncaster Central,constituency,country/england,,,E14001198,constituency/Doncaster Central +constituency/E14001199,Doncaster East and the Isle of Axholme,constituency,country/england,,,E14001199,constituency/Doncaster East and the Isle of Axholme +constituency/E14001200,Doncaster North,constituency,country/england,,,E14001200,constituency/Doncaster North +constituency/E14001201,Dorking and Horley,constituency,country/england,,,E14001201,constituency/Dorking and Horley +constituency/E14001202,Dover and Deal,constituency,country/england,,,E14001202,constituency/Dover and Deal +constituency/E14001203,Droitwich and Evesham,constituency,country/england,,,E14001203,constituency/Droitwich and Evesham +constituency/E14001204,Dudley,constituency,country/england,,,E14001204,constituency/Dudley +constituency/E14001205,Dulwich and West Norwood,constituency,country/england,,,E14001205,constituency/Dulwich and West Norwood +constituency/E14001206,Dunstable and Leighton Buzzard,constituency,country/england,,,E14001206,constituency/Dunstable and Leighton Buzzard +constituency/E14001207,Ealing Central and Acton,constituency,country/england,,,E14001207,constituency/Ealing Central and Acton +constituency/E14001208,Ealing North,constituency,country/england,,,E14001208,constituency/Ealing North +constituency/E14001209,Ealing Southall,constituency,country/england,,,E14001209,constituency/Ealing Southall +constituency/E14001210,Earley and Woodley,constituency,country/england,,,E14001210,constituency/Earley and Woodley +constituency/E14001211,Easington,constituency,country/england,,,E14001211,constituency/Easington +constituency/E14001212,East Grinstead and Uckfield,constituency,country/england,,,E14001212,constituency/East Grinstead and Uckfield +constituency/E14001213,East Ham,constituency,country/england,,,E14001213,constituency/East Ham +constituency/E14001214,East Hampshire,constituency,country/england,,,E14001214,constituency/East Hampshire +constituency/E14001215,East Surrey,constituency,country/england,,,E14001215,constituency/East Surrey +constituency/E14001216,East Thanet,constituency,country/england,,,E14001216,constituency/East Thanet +constituency/E14001217,East Wiltshire,constituency,country/england,,,E14001217,constituency/East Wiltshire +constituency/E14001218,East Worthing and Shoreham,constituency,country/england,,,E14001218,constituency/East Worthing and Shoreham +constituency/E14001219,Eastbourne,constituency,country/england,,,E14001219,constituency/Eastbourne +constituency/E14001220,Eastleigh,constituency,country/england,,,E14001220,constituency/Eastleigh +constituency/E14001221,Edmonton and Winchmore Hill,constituency,country/england,,,E14001221,constituency/Edmonton and Winchmore Hill +constituency/E14001222,Ellesmere Port and Bromborough,constituency,country/england,,,E14001222,constituency/Ellesmere Port and Bromborough +constituency/E14001223,Eltham and Chislehurst,constituency,country/england,,,E14001223,constituency/Eltham and Chislehurst +constituency/E14001224,Ely and East Cambridgeshire,constituency,country/england,,,E14001224,constituency/Ely and East Cambridgeshire +constituency/E14001225,Enfield North,constituency,country/england,,,E14001225,constituency/Enfield North +constituency/E14001226,Epping Forest,constituency,country/england,,,E14001226,constituency/Epping Forest +constituency/E14001227,Epsom and Ewell,constituency,country/england,,,E14001227,constituency/Epsom and Ewell +constituency/E14001228,Erewash,constituency,country/england,,,E14001228,constituency/Erewash +constituency/E14001229,Erith and Thamesmead,constituency,country/england,,,E14001229,constituency/Erith and Thamesmead +constituency/E14001230,Esher and Walton,constituency,country/england,,,E14001230,constituency/Esher and Walton +constituency/E14001231,Exeter,constituency,country/england,,,E14001231,constituency/Exeter +constituency/E14001232,Exmouth and Exeter East,constituency,country/england,,,E14001232,constituency/Exmouth and Exeter East +constituency/E14001233,Fareham and Waterlooville,constituency,country/england,,,E14001233,constituency/Fareham and Waterlooville +constituency/E14001234,Farnham and Bordon,constituency,country/england,,,E14001234,constituency/Farnham and Bordon +constituency/E14001235,Faversham and Mid Kent,constituency,country/england,,,E14001235,constituency/Faversham and Mid Kent +constituency/E14001236,Feltham and Heston,constituency,country/england,,,E14001236,constituency/Feltham and Heston +constituency/E14001237,Filton and Bradley Stoke,constituency,country/england,,,E14001237,constituency/Filton and Bradley Stoke +constituency/E14001238,Finchley and Golders Green,constituency,country/england,,,E14001238,constituency/Finchley and Golders Green +constituency/E14001239,Folkestone and Hythe,constituency,country/england,,,E14001239,constituency/Folkestone and Hythe +constituency/E14001240,Forest of Dean,constituency,country/england,,,E14001240,constituency/Forest of Dean +constituency/E14001241,Frome and East Somerset,constituency,country/england,,,E14001241,constituency/Frome and East Somerset +constituency/E14001242,Fylde,constituency,country/england,,,E14001242,constituency/Fylde +constituency/E14001243,Gainsborough,constituency,country/england,,,E14001243,constituency/Gainsborough +constituency/E14001244,Gateshead Central and Whickham,constituency,country/england,,,E14001244,constituency/Gateshead Central and Whickham +constituency/E14001245,Gedling,constituency,country/england,,,E14001245,constituency/Gedling +constituency/E14001246,Gillingham and Rainham,constituency,country/england,,,E14001246,constituency/Gillingham and Rainham +constituency/E14001247,Glastonbury and Somerton,constituency,country/england,,,E14001247,constituency/Glastonbury and Somerton +constituency/E14001248,Gloucester,constituency,country/england,,,E14001248,constituency/Gloucester +constituency/E14001249,Godalming and Ash,constituency,country/england,,,E14001249,constituency/Godalming and Ash +constituency/E14001250,Goole and Pocklington,constituency,country/england,,,E14001250,constituency/Goole and Pocklington +constituency/E14001251,Gorton and Denton,constituency,country/england,,,E14001251,constituency/Gorton and Denton +constituency/E14001252,Gosport,constituency,country/england,,,E14001252,constituency/Gosport +constituency/E14001253,Grantham and Bourne,constituency,country/england,,,E14001253,constituency/Grantham and Bourne +constituency/E14001254,Gravesham,constituency,country/england,,,E14001254,constituency/Gravesham +constituency/E14001255,Great Grimsby and Cleethorpes,constituency,country/england,,,E14001255,constituency/Great Grimsby and Cleethorpes +constituency/E14001256,Great Yarmouth,constituency,country/england,,,E14001256,constituency/Great Yarmouth +constituency/E14001257,Greenwich and Woolwich,constituency,country/england,,,E14001257,constituency/Greenwich and Woolwich +constituency/E14001258,Guildford,constituency,country/england,,,E14001258,constituency/Guildford +constituency/E14001259,Hackney North and Stoke Newington,constituency,country/england,,,E14001259,constituency/Hackney North and Stoke Newington +constituency/E14001260,Hackney South and Shoreditch,constituency,country/england,,,E14001260,constituency/Hackney South and Shoreditch +constituency/E14001261,Halesowen,constituency,country/england,,,E14001261,constituency/Halesowen +constituency/E14001262,Halifax,constituency,country/england,,,E14001262,constituency/Halifax +constituency/E14001263,Hamble Valley,constituency,country/england,,,E14001263,constituency/Hamble Valley +constituency/E14001264,Hammersmith and Chiswick,constituency,country/england,,,E14001264,constituency/Hammersmith and Chiswick +constituency/E14001265,Hampstead and Highgate,constituency,country/england,,,E14001265,constituency/Hampstead and Highgate +constituency/E14001266,"Harborough, Oadby and Wigston",constituency,country/england,,,E14001266,"constituency/Harborough, Oadby and Wigston" +constituency/E14001267,Harlow,constituency,country/england,,,E14001267,constituency/Harlow +constituency/E14001268,Harpenden and Berkhamsted,constituency,country/england,,,E14001268,constituency/Harpenden and Berkhamsted +constituency/E14001269,Harrogate and Knaresborough,constituency,country/england,,,E14001269,constituency/Harrogate and Knaresborough +constituency/E14001270,Harrow East,constituency,country/england,,,E14001270,constituency/Harrow East +constituency/E14001271,Harrow West,constituency,country/england,,,E14001271,constituency/Harrow West +constituency/E14001272,Hartlepool,constituency,country/england,,,E14001272,constituency/Hartlepool +constituency/E14001273,Harwich and North Essex,constituency,country/england,,,E14001273,constituency/Harwich and North Essex +constituency/E14001274,Hastings and Rye,constituency,country/england,,,E14001274,constituency/Hastings and Rye +constituency/E14001275,Havant,constituency,country/england,,,E14001275,constituency/Havant +constituency/E14001276,Hayes and Harlington,constituency,country/england,,,E14001276,constituency/Hayes and Harlington +constituency/E14001277,Hazel Grove,constituency,country/england,,,E14001277,constituency/Hazel Grove +constituency/E14001278,Hemel Hempstead,constituency,country/england,,,E14001278,constituency/Hemel Hempstead +constituency/E14001279,Hendon,constituency,country/england,,,E14001279,constituency/Hendon +constituency/E14001280,Henley and Thame,constituency,country/england,,,E14001280,constituency/Henley and Thame +constituency/E14001281,Hereford and South Herefordshire,constituency,country/england,,,E14001281,constituency/Hereford and South Herefordshire +constituency/E14001282,Herne Bay and Sandwich,constituency,country/england,,,E14001282,constituency/Herne Bay and Sandwich +constituency/E14001283,Hertford and Stortford,constituency,country/england,,,E14001283,constituency/Hertford and Stortford +constituency/E14001284,Hertsmere,constituency,country/england,,,E14001284,constituency/Hertsmere +constituency/E14001285,Hexham,constituency,country/england,,,E14001285,constituency/Hexham +constituency/E14001286,Heywood and Middleton North,constituency,country/england,,,E14001286,constituency/Heywood and Middleton North +constituency/E14001287,High Peak,constituency,country/england,,,E14001287,constituency/High Peak +constituency/E14001288,Hinckley and Bosworth,constituency,country/england,,,E14001288,constituency/Hinckley and Bosworth +constituency/E14001289,Hitchin,constituency,country/england,,,E14001289,constituency/Hitchin +constituency/E14001290,Holborn and St Pancras,constituency,country/england,,,E14001290,constituency/Holborn and St Pancras +constituency/E14001291,Honiton and Sidmouth,constituency,country/england,,,E14001291,constituency/Honiton and Sidmouth +constituency/E14001292,Hornchurch and Upminster,constituency,country/england,,,E14001292,constituency/Hornchurch and Upminster +constituency/E14001293,Hornsey and Friern Barnet,constituency,country/england,,,E14001293,constituency/Hornsey and Friern Barnet +constituency/E14001294,Horsham,constituency,country/england,,,E14001294,constituency/Horsham +constituency/E14001295,Houghton and Sunderland South,constituency,country/england,,,E14001295,constituency/Houghton and Sunderland South +constituency/E14001296,Hove and Portslade,constituency,country/england,,,E14001296,constituency/Hove and Portslade +constituency/E14001297,Huddersfield,constituency,country/england,,,E14001297,constituency/Huddersfield +constituency/E14001298,Huntingdon,constituency,country/england,,,E14001298,constituency/Huntingdon +constituency/E14001299,Hyndburn,constituency,country/england,,,E14001299,constituency/Hyndburn +constituency/E14001300,Ilford North,constituency,country/england,,,E14001300,constituency/Ilford North +constituency/E14001301,Ilford South,constituency,country/england,,,E14001301,constituency/Ilford South +constituency/E14001302,Ipswich,constituency,country/england,,,E14001302,constituency/Ipswich +constituency/E14001303,Isle of Wight East,constituency,country/england,,,E14001303,constituency/Isle of Wight East +constituency/E14001304,Isle of Wight West,constituency,country/england,,,E14001304,constituency/Isle of Wight West +constituency/E14001305,Islington North,constituency,country/england,,,E14001305,constituency/Islington North +constituency/E14001306,Islington South and Finsbury,constituency,country/england,,,E14001306,constituency/Islington South and Finsbury +constituency/E14001307,Jarrow and Gateshead East,constituency,country/england,,,E14001307,constituency/Jarrow and Gateshead East +constituency/E14001308,Keighley and Ilkley,constituency,country/england,,,E14001308,constituency/Keighley and Ilkley +constituency/E14001309,Kenilworth and Southam,constituency,country/england,,,E14001309,constituency/Kenilworth and Southam +constituency/E14001310,Kensington and Bayswater,constituency,country/england,,,E14001310,constituency/Kensington and Bayswater +constituency/E14001311,Kettering,constituency,country/england,,,E14001311,constituency/Kettering +constituency/E14001312,Kingston and Surbiton,constituency,country/england,,,E14001312,constituency/Kingston and Surbiton +constituency/E14001313,Kingston upon Hull East,constituency,country/england,,,E14001313,constituency/Kingston upon Hull East +constituency/E14001314,Kingston upon Hull North and Cottingham,constituency,country/england,,,E14001314,constituency/Kingston upon Hull North and Cottingham +constituency/E14001315,Kingston upon Hull West and Haltemprice,constituency,country/england,,,E14001315,constituency/Kingston upon Hull West and Haltemprice +constituency/E14001316,Kingswinford and South Staffordshire,constituency,country/england,,,E14001316,constituency/Kingswinford and South Staffordshire +constituency/E14001317,Knowsley,constituency,country/england,,,E14001317,constituency/Knowsley +constituency/E14001318,Lancaster and Wyre,constituency,country/england,,,E14001318,constituency/Lancaster and Wyre +constituency/E14001319,Leeds Central and Headingley,constituency,country/england,,,E14001319,constituency/Leeds Central and Headingley +constituency/E14001320,Leeds East,constituency,country/england,,,E14001320,constituency/Leeds East +constituency/E14001321,Leeds North East,constituency,country/england,,,E14001321,constituency/Leeds North East +constituency/E14001322,Leeds North West,constituency,country/england,,,E14001322,constituency/Leeds North West +constituency/E14001323,Leeds South,constituency,country/england,,,E14001323,constituency/Leeds South +constituency/E14001324,Leeds South West and Morley,constituency,country/england,,,E14001324,constituency/Leeds South West and Morley +constituency/E14001325,Leeds West and Pudsey,constituency,country/england,,,E14001325,constituency/Leeds West and Pudsey +constituency/E14001326,Leicester East,constituency,country/england,,,E14001326,constituency/Leicester East +constituency/E14001327,Leicester South,constituency,country/england,,,E14001327,constituency/Leicester South +constituency/E14001328,Leicester West,constituency,country/england,,,E14001328,constituency/Leicester West +constituency/E14001329,Leigh and Atherton,constituency,country/england,,,E14001329,constituency/Leigh and Atherton +constituency/E14001330,Lewes,constituency,country/england,,,E14001330,constituency/Lewes +constituency/E14001331,Lewisham East,constituency,country/england,,,E14001331,constituency/Lewisham East +constituency/E14001332,Lewisham North,constituency,country/england,,,E14001332,constituency/Lewisham North +constituency/E14001333,Lewisham West and East Dulwich,constituency,country/england,,,E14001333,constituency/Lewisham West and East Dulwich +constituency/E14001334,Leyton and Wanstead,constituency,country/england,,,E14001334,constituency/Leyton and Wanstead +constituency/E14001335,Lichfield,constituency,country/england,,,E14001335,constituency/Lichfield +constituency/E14001336,Lincoln,constituency,country/england,,,E14001336,constituency/Lincoln +constituency/E14001337,Liverpool Garston,constituency,country/england,,,E14001337,constituency/Liverpool Garston +constituency/E14001338,Liverpool Riverside,constituency,country/england,,,E14001338,constituency/Liverpool Riverside +constituency/E14001339,Liverpool Walton,constituency,country/england,,,E14001339,constituency/Liverpool Walton +constituency/E14001340,Liverpool Wavertree,constituency,country/england,,,E14001340,constituency/Liverpool Wavertree +constituency/E14001341,Liverpool West Derby,constituency,country/england,,,E14001341,constituency/Liverpool West Derby +constituency/E14001342,Loughborough,constituency,country/england,,,E14001342,constituency/Loughborough +constituency/E14001343,Louth and Horncastle,constituency,country/england,,,E14001343,constituency/Louth and Horncastle +constituency/E14001344,Lowestoft,constituency,country/england,,,E14001344,constituency/Lowestoft +constituency/E14001345,Luton North,constituency,country/england,,,E14001345,constituency/Luton North +constituency/E14001346,Luton South and South Bedfordshire,constituency,country/england,,,E14001346,constituency/Luton South and South Bedfordshire +constituency/E14001347,Macclesfield,constituency,country/england,,,E14001347,constituency/Macclesfield +constituency/E14001348,Maidenhead,constituency,country/england,,,E14001348,constituency/Maidenhead +constituency/E14001349,Maidstone and Malling,constituency,country/england,,,E14001349,constituency/Maidstone and Malling +constituency/E14001350,Makerfield,constituency,country/england,,,E14001350,constituency/Makerfield +constituency/E14001351,Maldon,constituency,country/england,,,E14001351,constituency/Maldon +constituency/E14001352,Manchester Central,constituency,country/england,,,E14001352,constituency/Manchester Central +constituency/E14001353,Manchester Rusholme,constituency,country/england,,,E14001353,constituency/Manchester Rusholme +constituency/E14001354,Manchester Withington,constituency,country/england,,,E14001354,constituency/Manchester Withington +constituency/E14001355,Mansfield,constituency,country/england,,,E14001355,constituency/Mansfield +constituency/E14001356,Melksham and Devizes,constituency,country/england,,,E14001356,constituency/Melksham and Devizes +constituency/E14001357,Melton and Syston,constituency,country/england,,,E14001357,constituency/Melton and Syston +constituency/E14001358,Meriden and Solihull East,constituency,country/england,,,E14001358,constituency/Meriden and Solihull East +constituency/E14001359,Mid Bedfordshire,constituency,country/england,,,E14001359,constituency/Mid Bedfordshire +constituency/E14001360,Mid Buckinghamshire,constituency,country/england,,,E14001360,constituency/Mid Buckinghamshire +constituency/E14001361,Mid Cheshire,constituency,country/england,,,E14001361,constituency/Mid Cheshire +constituency/E14001362,Mid Derbyshire,constituency,country/england,,,E14001362,constituency/Mid Derbyshire +constituency/E14001363,Mid Dorset and North Poole,constituency,country/england,,,E14001363,constituency/Mid Dorset and North Poole +constituency/E14001364,Mid Leicestershire,constituency,country/england,,,E14001364,constituency/Mid Leicestershire +constituency/E14001365,Mid Norfolk,constituency,country/england,,,E14001365,constituency/Mid Norfolk +constituency/E14001366,Mid Sussex,constituency,country/england,,,E14001366,constituency/Mid Sussex +constituency/E14001367,Middlesbrough and Thornaby East,constituency,country/england,,,E14001367,constituency/Middlesbrough and Thornaby East +constituency/E14001368,Middlesbrough South and East Cleveland,constituency,country/england,,,E14001368,constituency/Middlesbrough South and East Cleveland +constituency/E14001369,Milton Keynes Central,constituency,country/england,,,E14001369,constituency/Milton Keynes Central +constituency/E14001370,Milton Keynes North,constituency,country/england,,,E14001370,constituency/Milton Keynes North +constituency/E14001371,Mitcham and Morden,constituency,country/england,,,E14001371,constituency/Mitcham and Morden +constituency/E14001372,Morecambe and Lunesdale,constituency,country/england,,,E14001372,constituency/Morecambe and Lunesdale +constituency/E14001373,New Forest East,constituency,country/england,,,E14001373,constituency/New Forest East +constituency/E14001374,New Forest West,constituency,country/england,,,E14001374,constituency/New Forest West +constituency/E14001375,Newark,constituency,country/england,,,E14001375,constituency/Newark +constituency/E14001376,Newbury,constituency,country/england,,,E14001376,constituency/Newbury +constituency/E14001377,Newcastle upon Tyne Central and West,constituency,country/england,,,E14001377,constituency/Newcastle upon Tyne Central and West +constituency/E14001378,Newcastle upon Tyne East and Wallsend,constituency,country/england,,,E14001378,constituency/Newcastle upon Tyne East and Wallsend +constituency/E14001379,Newcastle upon Tyne North,constituency,country/england,,,E14001379,constituency/Newcastle upon Tyne North +constituency/E14001380,Newcastle-under-Lyme,constituency,country/england,,,E14001380,constituency/Newcastle-under-Lyme +constituency/E14001381,Newton Abbot,constituency,country/england,,,E14001381,constituency/Newton Abbot +constituency/E14001382,Newton Aycliffe and Spennymoor,constituency,country/england,,,E14001382,constituency/Newton Aycliffe and Spennymoor +constituency/E14001383,Normanton and Hemsworth,constituency,country/england,,,E14001383,constituency/Normanton and Hemsworth +constituency/E14001384,North Bedfordshire,constituency,country/england,,,E14001384,constituency/North Bedfordshire +constituency/E14001385,North Cornwall,constituency,country/england,,,E14001385,constituency/North Cornwall +constituency/E14001386,North Cotswolds,constituency,country/england,,,E14001386,constituency/North Cotswolds +constituency/E14001387,North Devon,constituency,country/england,,,E14001387,constituency/North Devon +constituency/E14001388,North Dorset,constituency,country/england,,,E14001388,constituency/North Dorset +constituency/E14001389,North Durham,constituency,country/england,,,E14001389,constituency/North Durham +constituency/E14001390,North East Cambridgeshire,constituency,country/england,,,E14001390,constituency/North East Cambridgeshire +constituency/E14001391,North East Derbyshire,constituency,country/england,,,E14001391,constituency/North East Derbyshire +constituency/E14001392,North East Hampshire,constituency,country/england,,,E14001392,constituency/North East Hampshire +constituency/E14001393,North East Hertfordshire,constituency,country/england,,,E14001393,constituency/North East Hertfordshire +constituency/E14001394,North East Somerset and Hanham,constituency,country/england,,,E14001394,constituency/North East Somerset and Hanham +constituency/E14001395,North Herefordshire,constituency,country/england,,,E14001395,constituency/North Herefordshire +constituency/E14001396,North Norfolk,constituency,country/england,,,E14001396,constituency/North Norfolk +constituency/E14001397,North Northumberland,constituency,country/england,,,E14001397,constituency/North Northumberland +constituency/E14001398,North Shropshire,constituency,country/england,,,E14001398,constituency/North Shropshire +constituency/E14001399,North Somerset,constituency,country/england,,,E14001399,constituency/North Somerset +constituency/E14001400,North Warwickshire and Bedworth,constituency,country/england,,,E14001400,constituency/North Warwickshire and Bedworth +constituency/E14001401,North West Cambridgeshire,constituency,country/england,,,E14001401,constituency/North West Cambridgeshire +constituency/E14001402,North West Essex,constituency,country/england,,,E14001402,constituency/North West Essex +constituency/E14001403,North West Hampshire,constituency,country/england,,,E14001403,constituency/North West Hampshire +constituency/E14001404,North West Leicestershire,constituency,country/england,,,E14001404,constituency/North West Leicestershire +constituency/E14001405,North West Norfolk,constituency,country/england,,,E14001405,constituency/North West Norfolk +constituency/E14001406,Northampton North,constituency,country/england,,,E14001406,constituency/Northampton North +constituency/E14001407,Northampton South,constituency,country/england,,,E14001407,constituency/Northampton South +constituency/E14001408,Norwich North,constituency,country/england,,,E14001408,constituency/Norwich North +constituency/E14001409,Norwich South,constituency,country/england,,,E14001409,constituency/Norwich South +constituency/E14001410,Nottingham East,constituency,country/england,,,E14001410,constituency/Nottingham East +constituency/E14001411,Nottingham North and Kimberley,constituency,country/england,,,E14001411,constituency/Nottingham North and Kimberley +constituency/E14001412,Nottingham South,constituency,country/england,,,E14001412,constituency/Nottingham South +constituency/E14001413,Nuneaton,constituency,country/england,,,E14001413,constituency/Nuneaton +constituency/E14001414,Old Bexley and Sidcup,constituency,country/england,,,E14001414,constituency/Old Bexley and Sidcup +constituency/E14001415,Oldham East and Saddleworth,constituency,country/england,,,E14001415,constituency/Oldham East and Saddleworth +constituency/E14001416,"Oldham West, Chadderton and Royton",constituency,country/england,,,E14001416,"constituency/Oldham West, Chadderton and Royton" +constituency/E14001417,Orpington,constituency,country/england,,,E14001417,constituency/Orpington +constituency/E14001418,Ossett and Denby Dale,constituency,country/england,,,E14001418,constituency/Ossett and Denby Dale +constituency/E14001419,Oxford East,constituency,country/england,,,E14001419,constituency/Oxford East +constituency/E14001420,Oxford West and Abingdon,constituency,country/england,,,E14001420,constituency/Oxford West and Abingdon +constituency/E14001421,Peckham,constituency,country/england,,,E14001421,constituency/Peckham +constituency/E14001422,Pendle and Clitheroe,constituency,country/england,,,E14001422,constituency/Pendle and Clitheroe +constituency/E14001423,Penistone and Stocksbridge,constituency,country/england,,,E14001423,constituency/Penistone and Stocksbridge +constituency/E14001424,Penrith and Solway,constituency,country/england,,,E14001424,constituency/Penrith and Solway +constituency/E14001425,Peterborough,constituency,country/england,,,E14001425,constituency/Peterborough +constituency/E14001426,Plymouth Moor View,constituency,country/england,,,E14001426,constituency/Plymouth Moor View +constituency/E14001427,Plymouth Sutton and Devonport,constituency,country/england,,,E14001427,constituency/Plymouth Sutton and Devonport +constituency/E14001428,"Pontefract, Castleford and Knottingley",constituency,country/england,,,E14001428,"constituency/Pontefract, Castleford and Knottingley" +constituency/E14001429,Poole,constituency,country/england,,,E14001429,constituency/Poole +constituency/E14001430,Poplar and Limehouse,constituency,country/england,,,E14001430,constituency/Poplar and Limehouse +constituency/E14001431,Portsmouth North,constituency,country/england,,,E14001431,constituency/Portsmouth North +constituency/E14001432,Portsmouth South,constituency,country/england,,,E14001432,constituency/Portsmouth South +constituency/E14001433,Preston,constituency,country/england,,,E14001433,constituency/Preston +constituency/E14001434,Putney,constituency,country/england,,,E14001434,constituency/Putney +constituency/E14001435,Queen's Park and Maida Vale,constituency,country/england,,,E14001435,constituency/Queen's Park and Maida Vale +constituency/E14001436,Rawmarsh and Conisbrough,constituency,country/england,,,E14001436,constituency/Rawmarsh and Conisbrough +constituency/E14001437,Rayleigh and Wickford,constituency,country/england,,,E14001437,constituency/Rayleigh and Wickford +constituency/E14001438,Reading Central,constituency,country/england,,,E14001438,constituency/Reading Central +constituency/E14001439,Reading West and Mid Berkshire,constituency,country/england,,,E14001439,constituency/Reading West and Mid Berkshire +constituency/E14001440,Redcar,constituency,country/england,,,E14001440,constituency/Redcar +constituency/E14001441,Redditch,constituency,country/england,,,E14001441,constituency/Redditch +constituency/E14001442,Reigate,constituency,country/england,,,E14001442,constituency/Reigate +constituency/E14001443,Ribble Valley,constituency,country/england,,,E14001443,constituency/Ribble Valley +constituency/E14001444,Richmond and Northallerton,constituency,country/england,,,E14001444,constituency/Richmond and Northallerton +constituency/E14001445,Richmond Park,constituency,country/england,,,E14001445,constituency/Richmond Park +constituency/E14001446,Rochdale,constituency,country/england,,,E14001446,constituency/Rochdale +constituency/E14001447,Rochester and Strood,constituency,country/england,,,E14001447,constituency/Rochester and Strood +constituency/E14001448,Romford,constituency,country/england,,,E14001448,constituency/Romford +constituency/E14001449,Romsey and Southampton North,constituency,country/england,,,E14001449,constituency/Romsey and Southampton North +constituency/E14001450,Rossendale and Darwen,constituency,country/england,,,E14001450,constituency/Rossendale and Darwen +constituency/E14001451,Rother Valley,constituency,country/england,,,E14001451,constituency/Rother Valley +constituency/E14001452,Rotherham,constituency,country/england,,,E14001452,constituency/Rotherham +constituency/E14001453,Rugby,constituency,country/england,,,E14001453,constituency/Rugby +constituency/E14001454,"Ruislip, Northwood and Pinner",constituency,country/england,,,E14001454,"constituency/Ruislip, Northwood and Pinner" +constituency/E14001455,Runcorn and Helsby,constituency,country/england,,,E14001455,constituency/Runcorn and Helsby +constituency/E14001456,Runnymede and Weybridge,constituency,country/england,,,E14001456,constituency/Runnymede and Weybridge +constituency/E14001457,Rushcliffe,constituency,country/england,,,E14001457,constituency/Rushcliffe +constituency/E14001458,Rutland and Stamford,constituency,country/england,,,E14001458,constituency/Rutland and Stamford +constituency/E14001459,Salford,constituency,country/england,,,E14001459,constituency/Salford +constituency/E14001460,Salisbury,constituency,country/england,,,E14001460,constituency/Salisbury +constituency/E14001461,Scarborough and Whitby,constituency,country/england,,,E14001461,constituency/Scarborough and Whitby +constituency/E14001462,Scunthorpe,constituency,country/england,,,E14001462,constituency/Scunthorpe +constituency/E14001463,Sefton Central,constituency,country/england,,,E14001463,constituency/Sefton Central +constituency/E14001464,Selby,constituency,country/england,,,E14001464,constituency/Selby +constituency/E14001465,Sevenoaks,constituency,country/england,,,E14001465,constituency/Sevenoaks +constituency/E14001466,Sheffield Brightside and Hillsborough,constituency,country/england,,,E14001466,constituency/Sheffield Brightside and Hillsborough +constituency/E14001467,Sheffield Central,constituency,country/england,,,E14001467,constituency/Sheffield Central +constituency/E14001468,Sheffield Hallam,constituency,country/england,,,E14001468,constituency/Sheffield Hallam +constituency/E14001469,Sheffield Heeley,constituency,country/england,,,E14001469,constituency/Sheffield Heeley +constituency/E14001470,Sheffield South East,constituency,country/england,,,E14001470,constituency/Sheffield South East +constituency/E14001471,Sherwood Forest,constituency,country/england,,,E14001471,constituency/Sherwood Forest +constituency/E14001472,Shipley,constituency,country/england,,,E14001472,constituency/Shipley +constituency/E14001473,Shrewsbury,constituency,country/england,,,E14001473,constituency/Shrewsbury +constituency/E14001474,Sittingbourne and Sheppey,constituency,country/england,,,E14001474,constituency/Sittingbourne and Sheppey +constituency/E14001475,Skipton and Ripon,constituency,country/england,,,E14001475,constituency/Skipton and Ripon +constituency/E14001476,Sleaford and North Hykeham,constituency,country/england,,,E14001476,constituency/Sleaford and North Hykeham +constituency/E14001477,Slough,constituency,country/england,,,E14001477,constituency/Slough +constituency/E14001478,Smethwick,constituency,country/england,,,E14001478,constituency/Smethwick +constituency/E14001479,Solihull West and Shirley,constituency,country/england,,,E14001479,constituency/Solihull West and Shirley +constituency/E14001480,South Basildon and East Thurrock,constituency,country/england,,,E14001480,constituency/South Basildon and East Thurrock +constituency/E14001481,South Cambridgeshire,constituency,country/england,,,E14001481,constituency/South Cambridgeshire +constituency/E14001482,South Cotswolds,constituency,country/england,,,E14001482,constituency/South Cotswolds +constituency/E14001483,South Derbyshire,constituency,country/england,,,E14001483,constituency/South Derbyshire +constituency/E14001484,South Devon,constituency,country/england,,,E14001484,constituency/South Devon +constituency/E14001485,South Dorset,constituency,country/england,,,E14001485,constituency/South Dorset +constituency/E14001486,South East Cornwall,constituency,country/england,,,E14001486,constituency/South East Cornwall +constituency/E14001487,South Holland and The Deepings,constituency,country/england,,,E14001487,constituency/South Holland and The Deepings +constituency/E14001488,South Leicestershire,constituency,country/england,,,E14001488,constituency/South Leicestershire +constituency/E14001489,South Norfolk,constituency,country/england,,,E14001489,constituency/South Norfolk +constituency/E14001490,South Northamptonshire,constituency,country/england,,,E14001490,constituency/South Northamptonshire +constituency/E14001491,South Ribble,constituency,country/england,,,E14001491,constituency/South Ribble +constituency/E14001492,South Shields,constituency,country/england,,,E14001492,constituency/South Shields +constituency/E14001493,South Shropshire,constituency,country/england,,,E14001493,constituency/South Shropshire +constituency/E14001494,South Suffolk,constituency,country/england,,,E14001494,constituency/South Suffolk +constituency/E14001495,South West Devon,constituency,country/england,,,E14001495,constituency/South West Devon +constituency/E14001496,South West Hertfordshire,constituency,country/england,,,E14001496,constituency/South West Hertfordshire +constituency/E14001497,South West Norfolk,constituency,country/england,,,E14001497,constituency/South West Norfolk +constituency/E14001498,South West Wiltshire,constituency,country/england,,,E14001498,constituency/South West Wiltshire +constituency/E14001499,Southampton Itchen,constituency,country/england,,,E14001499,constituency/Southampton Itchen +constituency/E14001500,Southampton Test,constituency,country/england,,,E14001500,constituency/Southampton Test +constituency/E14001501,Southend East and Rochford,constituency,country/england,,,E14001501,constituency/Southend East and Rochford +constituency/E14001502,Southend West and Leigh,constituency,country/england,,,E14001502,constituency/Southend West and Leigh +constituency/E14001503,Southgate and Wood Green,constituency,country/england,,,E14001503,constituency/Southgate and Wood Green +constituency/E14001504,Southport,constituency,country/england,,,E14001504,constituency/Southport +constituency/E14001505,Spelthorne,constituency,country/england,,,E14001505,constituency/Spelthorne +constituency/E14001506,Spen Valley,constituency,country/england,,,E14001506,constituency/Spen Valley +constituency/E14001507,St Albans,constituency,country/england,,,E14001507,constituency/St Albans +constituency/E14001508,St Austell and Newquay,constituency,country/england,,,E14001508,constituency/St Austell and Newquay +constituency/E14001509,St Helens North,constituency,country/england,,,E14001509,constituency/St Helens North +constituency/E14001510,St Helens South and Whiston,constituency,country/england,,,E14001510,constituency/St Helens South and Whiston +constituency/E14001511,St Ives,constituency,country/england,,,E14001511,constituency/St Ives +constituency/E14001512,St Neots and Mid Cambridgeshire,constituency,country/england,,,E14001512,constituency/St Neots and Mid Cambridgeshire +constituency/E14001513,Stafford,constituency,country/england,,,E14001513,constituency/Stafford +constituency/E14001514,Staffordshire Moorlands,constituency,country/england,,,E14001514,constituency/Staffordshire Moorlands +constituency/E14001515,Stalybridge and Hyde,constituency,country/england,,,E14001515,constituency/Stalybridge and Hyde +constituency/E14001516,Stevenage,constituency,country/england,,,E14001516,constituency/Stevenage +constituency/E14001517,Stockport,constituency,country/england,,,E14001517,constituency/Stockport +constituency/E14001518,Stockton North,constituency,country/england,,,E14001518,constituency/Stockton North +constituency/E14001519,Stockton West,constituency,country/england,,,E14001519,constituency/Stockton West +constituency/E14001520,Stoke-on-Trent Central,constituency,country/england,,,E14001520,constituency/Stoke-on-Trent Central +constituency/E14001521,Stoke-on-Trent North,constituency,country/england,,,E14001521,constituency/Stoke-on-Trent North +constituency/E14001522,Stoke-on-Trent South,constituency,country/england,,,E14001522,constituency/Stoke-on-Trent South +constituency/E14001523,"Stone, Great Wyrley and Penkridge",constituency,country/england,,,E14001523,"constituency/Stone, Great Wyrley and Penkridge" +constituency/E14001524,Stourbridge,constituency,country/england,,,E14001524,constituency/Stourbridge +constituency/E14001525,Stratford and Bow,constituency,country/england,,,E14001525,constituency/Stratford and Bow +constituency/E14001526,Stratford-on-Avon,constituency,country/england,,,E14001526,constituency/Stratford-on-Avon +constituency/E14001527,Streatham and Croydon North,constituency,country/england,,,E14001527,constituency/Streatham and Croydon North +constituency/E14001528,Stretford and Urmston,constituency,country/england,,,E14001528,constituency/Stretford and Urmston +constituency/E14001529,Stroud,constituency,country/england,,,E14001529,constituency/Stroud +constituency/E14001530,Suffolk Coastal,constituency,country/england,,,E14001530,constituency/Suffolk Coastal +constituency/E14001531,Sunderland Central,constituency,country/england,,,E14001531,constituency/Sunderland Central +constituency/E14001532,Surrey Heath,constituency,country/england,,,E14001532,constituency/Surrey Heath +constituency/E14001533,Sussex Weald,constituency,country/england,,,E14001533,constituency/Sussex Weald +constituency/E14001534,Sutton and Cheam,constituency,country/england,,,E14001534,constituency/Sutton and Cheam +constituency/E14001535,Sutton Coldfield,constituency,country/england,,,E14001535,constituency/Sutton Coldfield +constituency/E14001536,Swindon North,constituency,country/england,,,E14001536,constituency/Swindon North +constituency/E14001537,Swindon South,constituency,country/england,,,E14001537,constituency/Swindon South +constituency/E14001538,Tamworth,constituency,country/england,,,E14001538,constituency/Tamworth +constituency/E14001539,Tatton,constituency,country/england,,,E14001539,constituency/Tatton +constituency/E14001540,Taunton and Wellington,constituency,country/england,,,E14001540,constituency/Taunton and Wellington +constituency/E14001541,Telford,constituency,country/england,,,E14001541,constituency/Telford +constituency/E14001542,Tewkesbury,constituency,country/england,,,E14001542,constituency/Tewkesbury +constituency/E14001543,The Wrekin,constituency,country/england,,,E14001543,constituency/The Wrekin +constituency/E14001544,Thirsk and Malton,constituency,country/england,,,E14001544,constituency/Thirsk and Malton +constituency/E14001545,Thornbury and Yate,constituency,country/england,,,E14001545,constituency/Thornbury and Yate +constituency/E14001546,Thurrock,constituency,country/england,,,E14001546,constituency/Thurrock +constituency/E14001547,Tipton and Wednesbury,constituency,country/england,,,E14001547,constituency/Tipton and Wednesbury +constituency/E14001548,Tiverton and Minehead,constituency,country/england,,,E14001548,constituency/Tiverton and Minehead +constituency/E14001549,Tonbridge,constituency,country/england,,,E14001549,constituency/Tonbridge +constituency/E14001550,Tooting,constituency,country/england,,,E14001550,constituency/Tooting +constituency/E14001551,Torbay,constituency,country/england,,,E14001551,constituency/Torbay +constituency/E14001552,Torridge and Tavistock,constituency,country/england,,,E14001552,constituency/Torridge and Tavistock +constituency/E14001553,Tottenham,constituency,country/england,,,E14001553,constituency/Tottenham +constituency/E14001554,Truro and Falmouth,constituency,country/england,,,E14001554,constituency/Truro and Falmouth +constituency/E14001555,Tunbridge Wells,constituency,country/england,,,E14001555,constituency/Tunbridge Wells +constituency/E14001556,Twickenham,constituency,country/england,,,E14001556,constituency/Twickenham +constituency/E14001557,Tynemouth,constituency,country/england,,,E14001557,constituency/Tynemouth +constituency/E14001558,Uxbridge and South Ruislip,constituency,country/england,,,E14001558,constituency/Uxbridge and South Ruislip +constituency/E14001559,Vauxhall and Camberwell Green,constituency,country/england,,,E14001559,constituency/Vauxhall and Camberwell Green +constituency/E14001560,Wakefield and Rothwell,constituency,country/england,,,E14001560,constituency/Wakefield and Rothwell +constituency/E14001561,Wallasey,constituency,country/england,,,E14001561,constituency/Wallasey +constituency/E14001562,Walsall and Bloxwich,constituency,country/england,,,E14001562,constituency/Walsall and Bloxwich +constituency/E14001563,Walthamstow,constituency,country/england,,,E14001563,constituency/Walthamstow +constituency/E14001564,Warrington North,constituency,country/england,,,E14001564,constituency/Warrington North +constituency/E14001565,Warrington South,constituency,country/england,,,E14001565,constituency/Warrington South +constituency/E14001566,Warwick and Leamington,constituency,country/england,,,E14001566,constituency/Warwick and Leamington +constituency/E14001567,Washington and Gateshead South,constituency,country/england,,,E14001567,constituency/Washington and Gateshead South +constituency/E14001568,Watford,constituency,country/england,,,E14001568,constituency/Watford +constituency/E14001569,Waveney Valley,constituency,country/england,,,E14001569,constituency/Waveney Valley +constituency/E14001570,Weald of Kent,constituency,country/england,,,E14001570,constituency/Weald of Kent +constituency/E14001571,Wellingborough and Rushden,constituency,country/england,,,E14001571,constituency/Wellingborough and Rushden +constituency/E14001572,Wells and Mendip Hills,constituency,country/england,,,E14001572,constituency/Wells and Mendip Hills +constituency/E14001573,Welwyn Hatfield,constituency,country/england,,,E14001573,constituency/Welwyn Hatfield +constituency/E14001574,West Bromwich,constituency,country/england,,,E14001574,constituency/West Bromwich +constituency/E14001575,West Dorset,constituency,country/england,,,E14001575,constituency/West Dorset +constituency/E14001576,West Ham and Beckton,constituency,country/england,,,E14001576,constituency/West Ham and Beckton +constituency/E14001577,West Lancashire,constituency,country/england,,,E14001577,constituency/West Lancashire +constituency/E14001578,West Suffolk,constituency,country/england,,,E14001578,constituency/West Suffolk +constituency/E14001579,West Worcestershire,constituency,country/england,,,E14001579,constituency/West Worcestershire +constituency/E14001580,Westmorland and Lonsdale,constituency,country/england,,,E14001580,constituency/Westmorland and Lonsdale +constituency/E14001581,Weston-super-Mare,constituency,country/england,,,E14001581,constituency/Weston-super-Mare +constituency/E14001582,Wetherby and Easingwold,constituency,country/england,,,E14001582,constituency/Wetherby and Easingwold +constituency/E14001583,Whitehaven and Workington,constituency,country/england,,,E14001583,constituency/Whitehaven and Workington +constituency/E14001584,Widnes and Halewood,constituency,country/england,,,E14001584,constituency/Widnes and Halewood +constituency/E14001585,Wigan,constituency,country/england,,,E14001585,constituency/Wigan +constituency/E14001586,Wimbledon,constituency,country/england,,,E14001586,constituency/Wimbledon +constituency/E14001587,Winchester,constituency,country/england,,,E14001587,constituency/Winchester +constituency/E14001588,Windsor,constituency,country/england,,,E14001588,constituency/Windsor +constituency/E14001589,Wirral West,constituency,country/england,,,E14001589,constituency/Wirral West +constituency/E14001590,Witham,constituency,country/england,,,E14001590,constituency/Witham +constituency/E14001591,Witney,constituency,country/england,,,E14001591,constituency/Witney +constituency/E14001592,Woking,constituency,country/england,,,E14001592,constituency/Woking +constituency/E14001593,Wokingham,constituency,country/england,,,E14001593,constituency/Wokingham +constituency/E14001594,Wolverhampton North East,constituency,country/england,,,E14001594,constituency/Wolverhampton North East +constituency/E14001595,Wolverhampton South East,constituency,country/england,,,E14001595,constituency/Wolverhampton South East +constituency/E14001596,Wolverhampton West,constituency,country/england,,,E14001596,constituency/Wolverhampton West +constituency/E14001597,Worcester,constituency,country/england,,,E14001597,constituency/Worcester +constituency/E14001598,Worsley and Eccles,constituency,country/england,,,E14001598,constituency/Worsley and Eccles +constituency/E14001599,Worthing West,constituency,country/england,,,E14001599,constituency/Worthing West +constituency/E14001600,Wycombe,constituency,country/england,,,E14001600,constituency/Wycombe +constituency/E14001601,Wyre Forest,constituency,country/england,,,E14001601,constituency/Wyre Forest +constituency/E14001602,Wythenshawe and Sale East,constituency,country/england,,,E14001602,constituency/Wythenshawe and Sale East +constituency/E14001603,Yeovil,constituency,country/england,,,E14001603,constituency/Yeovil +constituency/E14001604,York Central,constituency,country/england,,,E14001604,constituency/York Central +constituency/E14001605,York Outer,constituency,country/england,,,E14001605,constituency/York Outer +constituency/N05000001,Belfast East,constituency,country/northern_ireland,,,N05000001,constituency/Belfast East +constituency/N05000002,Belfast North,constituency,country/northern_ireland,,,N05000002,constituency/Belfast North +constituency/N05000003,Belfast South and Mid Down,constituency,country/northern_ireland,,,N05000003,constituency/Belfast South and Mid Down +constituency/N05000004,Belfast West,constituency,country/northern_ireland,,,N05000004,constituency/Belfast West +constituency/N05000005,East Antrim,constituency,country/northern_ireland,,,N05000005,constituency/East Antrim +constituency/N05000006,East Londonderry,constituency,country/northern_ireland,,,N05000006,constituency/East Londonderry +constituency/N05000007,Fermanagh and South Tyrone,constituency,country/northern_ireland,,,N05000007,constituency/Fermanagh and South Tyrone +constituency/N05000008,Foyle,constituency,country/northern_ireland,,,N05000008,constituency/Foyle +constituency/N05000009,Lagan Valley,constituency,country/northern_ireland,,,N05000009,constituency/Lagan Valley +constituency/N05000010,Mid Ulster,constituency,country/northern_ireland,,,N05000010,constituency/Mid Ulster +constituency/N05000011,Newry and Armagh,constituency,country/northern_ireland,,,N05000011,constituency/Newry and Armagh +constituency/N05000012,North Antrim,constituency,country/northern_ireland,,,N05000012,constituency/North Antrim +constituency/N05000013,North Down,constituency,country/northern_ireland,,,N05000013,constituency/North Down +constituency/N05000014,South Antrim,constituency,country/northern_ireland,,,N05000014,constituency/South Antrim +constituency/N05000015,South Down,constituency,country/northern_ireland,,,N05000015,constituency/South Down +constituency/N05000016,Strangford,constituency,country/northern_ireland,,,N05000016,constituency/Strangford +constituency/N05000017,Upper Bann,constituency,country/northern_ireland,,,N05000017,constituency/Upper Bann +constituency/N05000018,West Tyrone,constituency,country/northern_ireland,,,N05000018,constituency/West Tyrone +constituency/S14000021,East Renfrewshire,constituency,country/scotland,,,S14000021,constituency/East Renfrewshire +constituency/S14000027,Na h-Eileanan an Iar,constituency,country/scotland,,,S14000027,constituency/Na h-Eileanan an Iar +constituency/S14000045,Midlothian,constituency,country/scotland,,,S14000045,constituency/Midlothian +constituency/S14000048,North Ayrshire and Arran,constituency,country/scotland,,,S14000048,constituency/North Ayrshire and Arran +constituency/S14000051,Orkney and Shetland,constituency,country/scotland,,,S14000051,constituency/Orkney and Shetland +constituency/S14000060,Aberdeen North,constituency,country/scotland,,,S14000060,constituency/Aberdeen North +constituency/S14000061,Aberdeen South,constituency,country/scotland,,,S14000061,constituency/Aberdeen South +constituency/S14000062,Aberdeenshire North and Moray East,constituency,country/scotland,,,S14000062,constituency/Aberdeenshire North and Moray East +constituency/S14000063,Airdrie and Shotts,constituency,country/scotland,,,S14000063,constituency/Airdrie and Shotts +constituency/S14000064,Alloa and Grangemouth,constituency,country/scotland,,,S14000064,constituency/Alloa and Grangemouth +constituency/S14000065,Angus and Perthshire Glens,constituency,country/scotland,,,S14000065,constituency/Angus and Perthshire Glens +constituency/S14000066,Arbroath and Broughty Ferry,constituency,country/scotland,,,S14000066,constituency/Arbroath and Broughty Ferry +constituency/S14000067,"Argyll, Bute and South Lochaber",constituency,country/scotland,,,S14000067,"constituency/Argyll, Bute and South Lochaber" +constituency/S14000068,Bathgate and Linlithgow,constituency,country/scotland,,,S14000068,constituency/Bathgate and Linlithgow +constituency/S14000069,"Caithness, Sutherland and Easter Ross",constituency,country/scotland,,,S14000069,"constituency/Caithness, Sutherland and Easter Ross" +constituency/S14000070,Coatbridge and Bellshill,constituency,country/scotland,,,S14000070,constituency/Coatbridge and Bellshill +constituency/S14000071,Cowdenbeath and Kirkcaldy,constituency,country/scotland,,,S14000071,constituency/Cowdenbeath and Kirkcaldy +constituency/S14000072,Cumbernauld and Kirkintilloch,constituency,country/scotland,,,S14000072,constituency/Cumbernauld and Kirkintilloch +constituency/S14000073,Dumfries and Galloway,constituency,country/scotland,,,S14000073,constituency/Dumfries and Galloway +constituency/S14000074,"Dumfriesshire, Clydesdale and Tweeddale",constituency,country/scotland,,,S14000074,"constituency/Dumfriesshire, Clydesdale and Tweeddale" +constituency/S14000075,Dundee Central,constituency,country/scotland,,,S14000075,constituency/Dundee Central +constituency/S14000076,Dunfermline and Dollar,constituency,country/scotland,,,S14000076,constituency/Dunfermline and Dollar +constituency/S14000077,East Kilbride and Strathaven,constituency,country/scotland,,,S14000077,constituency/East Kilbride and Strathaven +constituency/S14000078,Edinburgh East and Musselburgh,constituency,country/scotland,,,S14000078,constituency/Edinburgh East and Musselburgh +constituency/S14000079,Edinburgh North and Leith,constituency,country/scotland,,,S14000079,constituency/Edinburgh North and Leith +constituency/S14000080,Edinburgh South,constituency,country/scotland,,,S14000080,constituency/Edinburgh South +constituency/S14000081,Edinburgh South West,constituency,country/scotland,,,S14000081,constituency/Edinburgh South West +constituency/S14000082,Edinburgh West,constituency,country/scotland,,,S14000082,constituency/Edinburgh West +constituency/S14000083,Falkirk,constituency,country/scotland,,,S14000083,constituency/Falkirk +constituency/S14000084,Glasgow East,constituency,country/scotland,,,S14000084,constituency/Glasgow East +constituency/S14000085,Glasgow North,constituency,country/scotland,,,S14000085,constituency/Glasgow North +constituency/S14000086,Glasgow North East,constituency,country/scotland,,,S14000086,constituency/Glasgow North East +constituency/S14000087,Glasgow South,constituency,country/scotland,,,S14000087,constituency/Glasgow South +constituency/S14000088,Glasgow South West,constituency,country/scotland,,,S14000088,constituency/Glasgow South West +constituency/S14000089,Glasgow West,constituency,country/scotland,,,S14000089,constituency/Glasgow West +constituency/S14000090,Glenrothes and Mid Fife,constituency,country/scotland,,,S14000090,constituency/Glenrothes and Mid Fife +constituency/S14000091,Gordon and Buchan,constituency,country/scotland,,,S14000091,constituency/Gordon and Buchan +constituency/S14000092,Hamilton and Clyde Valley,constituency,country/scotland,,,S14000092,constituency/Hamilton and Clyde Valley +constituency/S14000093,Inverclyde and Renfrewshire West,constituency,country/scotland,,,S14000093,constituency/Inverclyde and Renfrewshire West +constituency/S14000094,"Inverness, Skye and West Ross-shire",constituency,country/scotland,,,S14000094,"constituency/Inverness, Skye and West Ross-shire" +constituency/S14000095,Livingston,constituency,country/scotland,,,S14000095,constituency/Livingston +constituency/S14000096,Lothian East,constituency,country/scotland,,,S14000096,constituency/Lothian East +constituency/S14000097,Mid Dunbartonshire,constituency,country/scotland,,,S14000097,constituency/Mid Dunbartonshire +constituency/S14000098,"Moray West, Nairn and Strathspey",constituency,country/scotland,,,S14000098,"constituency/Moray West, Nairn and Strathspey" +constituency/S14000099,"Motherwell, Wishaw and Carluke",constituency,country/scotland,,,S14000099,"constituency/Motherwell, Wishaw and Carluke" +constituency/S14000100,North East Fife,constituency,country/scotland,,,S14000100,constituency/North East Fife +constituency/S14000101,Paisley and Renfrewshire North,constituency,country/scotland,,,S14000101,constituency/Paisley and Renfrewshire North +constituency/S14000102,Paisley and Renfrewshire South,constituency,country/scotland,,,S14000102,constituency/Paisley and Renfrewshire South +constituency/S14000103,Perth and Kinross-shire,constituency,country/scotland,,,S14000103,constituency/Perth and Kinross-shire +constituency/S14000104,Rutherglen,constituency,country/scotland,,,S14000104,constituency/Rutherglen +constituency/S14000105,Stirling and Strathallan,constituency,country/scotland,,,S14000105,constituency/Stirling and Strathallan +constituency/S14000106,West Dunbartonshire,constituency,country/scotland,,,S14000106,constituency/West Dunbartonshire +constituency/S14000107,"Ayr, Carrick and Cumnock",constituency,country/scotland,,,S14000107,"constituency/Ayr, Carrick and Cumnock" +constituency/S14000108,"Berwickshire, Roxburgh and Selkirk",constituency,country/scotland,,,S14000108,"constituency/Berwickshire, Roxburgh and Selkirk" +constituency/S14000109,Central Ayrshire,constituency,country/scotland,,,S14000109,constituency/Central Ayrshire +constituency/S14000110,Kilmarnock and Loudoun,constituency,country/scotland,,,S14000110,constituency/Kilmarnock and Loudoun +constituency/S14000111,West Aberdeenshire and Kincardine,constituency,country/scotland,,,S14000111,constituency/West Aberdeenshire and Kincardine +constituency/W07000081,Aberafan Maesteg,constituency,country/wales,,,W07000081,constituency/Aberafan Maesteg +constituency/W07000082,Alyn and Deeside,constituency,country/wales,,,W07000082,constituency/Alyn and Deeside +constituency/W07000083,Bangor Aberconwy,constituency,country/wales,,,W07000083,constituency/Bangor Aberconwy +constituency/W07000084,Blaenau Gwent and Rhymney,constituency,country/wales,,,W07000084,constituency/Blaenau Gwent and Rhymney +constituency/W07000085,"Brecon, Radnor and Cwm Tawe",constituency,country/wales,,,W07000085,"constituency/Brecon, Radnor and Cwm Tawe" +constituency/W07000086,Bridgend,constituency,country/wales,,,W07000086,constituency/Bridgend +constituency/W07000087,Caerfyrddin,constituency,country/wales,,,W07000087,constituency/Caerfyrddin +constituency/W07000088,Caerphilly,constituency,country/wales,,,W07000088,constituency/Caerphilly +constituency/W07000089,Cardiff East,constituency,country/wales,,,W07000089,constituency/Cardiff East +constituency/W07000090,Cardiff North,constituency,country/wales,,,W07000090,constituency/Cardiff North +constituency/W07000091,Cardiff South and Penarth,constituency,country/wales,,,W07000091,constituency/Cardiff South and Penarth +constituency/W07000092,Cardiff West,constituency,country/wales,,,W07000092,constituency/Cardiff West +constituency/W07000093,Ceredigion Preseli,constituency,country/wales,,,W07000093,constituency/Ceredigion Preseli +constituency/W07000094,Clwyd East,constituency,country/wales,,,W07000094,constituency/Clwyd East +constituency/W07000095,Clwyd North,constituency,country/wales,,,W07000095,constituency/Clwyd North +constituency/W07000096,Dwyfor Meirionnydd,constituency,country/wales,,,W07000096,constituency/Dwyfor Meirionnydd +constituency/W07000097,Gower,constituency,country/wales,,,W07000097,constituency/Gower +constituency/W07000098,Llanelli,constituency,country/wales,,,W07000098,constituency/Llanelli +constituency/W07000099,Merthyr Tydfil and Aberdare,constituency,country/wales,,,W07000099,constituency/Merthyr Tydfil and Aberdare +constituency/W07000100,Mid and South Pembrokeshire,constituency,country/wales,,,W07000100,constituency/Mid and South Pembrokeshire +constituency/W07000101,Monmouthshire,constituency,country/wales,,,W07000101,constituency/Monmouthshire +constituency/W07000102,Montgomeryshire and Glyndwr,constituency,country/wales,,,W07000102,constituency/Montgomeryshire and Glyndwr +constituency/W07000103,Neath and Swansea East,constituency,country/wales,,,W07000103,constituency/Neath and Swansea East +constituency/W07000104,Newport East,constituency,country/wales,,,W07000104,constituency/Newport East +constituency/W07000105,Newport West and Islwyn,constituency,country/wales,,,W07000105,constituency/Newport West and Islwyn +constituency/W07000106,Pontypridd,constituency,country/wales,,,W07000106,constituency/Pontypridd +constituency/W07000107,Rhondda and Ogmore,constituency,country/wales,,,W07000107,constituency/Rhondda and Ogmore +constituency/W07000108,Swansea West,constituency,country/wales,,,W07000108,constituency/Swansea West +constituency/W07000109,Torfaen,constituency,country/wales,,,W07000109,constituency/Torfaen +constituency/W07000110,Vale of Glamorgan,constituency,country/wales,,,W07000110,constituency/Vale of Glamorgan +constituency/W07000111,Wrexham,constituency,country/wales,,,W07000111,constituency/Wrexham +constituency/W07000112,Ynys Môn,constituency,country/wales,,,W07000112,constituency/Ynys Môn +local_authority/E06000001,Hartlepool,local_authority,country/england,,,E06000001,local_authority/Hartlepool +local_authority/E06000002,Middlesbrough,local_authority,country/england,,,E06000002,local_authority/Middlesbrough +local_authority/E06000003,Redcar and Cleveland,local_authority,country/england,,,E06000003,local_authority/Redcar and Cleveland +local_authority/E06000004,Stockton-on-Tees,local_authority,country/england,,,E06000004,local_authority/Stockton-on-Tees +local_authority/E06000005,Darlington,local_authority,country/england,,,E06000005,local_authority/Darlington +local_authority/E06000006,Halton,local_authority,country/england,,,E06000006,local_authority/Halton +local_authority/E06000007,Warrington,local_authority,country/england,,,E06000007,local_authority/Warrington +local_authority/E06000008,Blackburn with Darwen,local_authority,country/england,,,E06000008,local_authority/Blackburn with Darwen +local_authority/E06000009,Blackpool,local_authority,country/england,,,E06000009,local_authority/Blackpool +local_authority/E06000010,"Kingston upon Hull, City of",local_authority,country/england,,,E06000010,"local_authority/Kingston upon Hull, City of" +local_authority/E06000011,East Riding of Yorkshire,local_authority,country/england,,,E06000011,local_authority/East Riding of Yorkshire +local_authority/E06000012,North East Lincolnshire,local_authority,country/england,,,E06000012,local_authority/North East Lincolnshire +local_authority/E06000013,North Lincolnshire,local_authority,country/england,,,E06000013,local_authority/North Lincolnshire +local_authority/E06000014,York,local_authority,country/england,,,E06000014,local_authority/York +local_authority/E06000015,Derby,local_authority,country/england,,,E06000015,local_authority/Derby +local_authority/E06000016,Leicester,local_authority,country/england,,,E06000016,local_authority/Leicester +local_authority/E06000017,Rutland,local_authority,country/england,,,E06000017,local_authority/Rutland +local_authority/E06000018,Nottingham,local_authority,country/england,,,E06000018,local_authority/Nottingham +local_authority/E06000019,"Herefordshire, County of",local_authority,country/england,,,E06000019,"local_authority/Herefordshire, County of" +local_authority/E06000020,Telford and Wrekin,local_authority,country/england,,,E06000020,local_authority/Telford and Wrekin +local_authority/E06000021,Stoke-on-Trent,local_authority,country/england,,,E06000021,local_authority/Stoke-on-Trent +local_authority/E06000022,Bath and North East Somerset,local_authority,country/england,,,E06000022,local_authority/Bath and North East Somerset +local_authority/E06000023,"Bristol, City of",local_authority,country/england,,,E06000023,"local_authority/Bristol, City of" +local_authority/E06000024,North Somerset,local_authority,country/england,,,E06000024,local_authority/North Somerset +local_authority/E06000025,South Gloucestershire,local_authority,country/england,,,E06000025,local_authority/South Gloucestershire +local_authority/E06000026,Plymouth,local_authority,country/england,,,E06000026,local_authority/Plymouth +local_authority/E06000027,Torbay,local_authority,country/england,,,E06000027,local_authority/Torbay +local_authority/E06000030,Swindon,local_authority,country/england,,,E06000030,local_authority/Swindon +local_authority/E06000031,Peterborough,local_authority,country/england,,,E06000031,local_authority/Peterborough +local_authority/E06000032,Luton,local_authority,country/england,,,E06000032,local_authority/Luton +local_authority/E06000033,Southend-on-Sea,local_authority,country/england,,,E06000033,local_authority/Southend-on-Sea +local_authority/E06000034,Thurrock,local_authority,country/england,,,E06000034,local_authority/Thurrock +local_authority/E06000035,Medway,local_authority,country/england,,,E06000035,local_authority/Medway +local_authority/E06000036,Bracknell Forest,local_authority,country/england,,,E06000036,local_authority/Bracknell Forest +local_authority/E06000037,West Berkshire,local_authority,country/england,,,E06000037,local_authority/West Berkshire +local_authority/E06000038,Reading,local_authority,country/england,,,E06000038,local_authority/Reading +local_authority/E06000039,Slough,local_authority,country/england,,,E06000039,local_authority/Slough +local_authority/E06000040,Windsor and Maidenhead,local_authority,country/england,,,E06000040,local_authority/Windsor and Maidenhead +local_authority/E06000041,Wokingham,local_authority,country/england,,,E06000041,local_authority/Wokingham +local_authority/E06000042,Milton Keynes,local_authority,country/england,,,E06000042,local_authority/Milton Keynes +local_authority/E06000043,Brighton and Hove,local_authority,country/england,,,E06000043,local_authority/Brighton and Hove +local_authority/E06000044,Portsmouth,local_authority,country/england,,,E06000044,local_authority/Portsmouth +local_authority/E06000045,Southampton,local_authority,country/england,,,E06000045,local_authority/Southampton +local_authority/E06000046,Isle of Wight,local_authority,country/england,,,E06000046,local_authority/Isle of Wight +local_authority/E06000047,County Durham,local_authority,country/england,,,E06000047,local_authority/County Durham +local_authority/E06000049,Cheshire East,local_authority,country/england,,,E06000049,local_authority/Cheshire East +local_authority/E06000050,Cheshire West and Chester,local_authority,country/england,,,E06000050,local_authority/Cheshire West and Chester +local_authority/E06000051,Shropshire,local_authority,country/england,,,E06000051,local_authority/Shropshire +local_authority/E06000052,Cornwall,local_authority,country/england,,,E06000052,local_authority/Cornwall +local_authority/E06000053,Isles of Scilly,local_authority,country/england,,,E06000053,local_authority/Isles of Scilly +local_authority/E06000054,Wiltshire,local_authority,country/england,,,E06000054,local_authority/Wiltshire +local_authority/E06000055,Bedford,local_authority,country/england,,,E06000055,local_authority/Bedford +local_authority/E06000056,Central Bedfordshire,local_authority,country/england,,,E06000056,local_authority/Central Bedfordshire +local_authority/E06000057,Northumberland,local_authority,country/england,,,E06000057,local_authority/Northumberland +local_authority/E06000058,"Bournemouth, Christchurch and Poole",local_authority,country/england,,,E06000058,"local_authority/Bournemouth, Christchurch and Poole" +local_authority/E06000059,Dorset,local_authority,country/england,,,E06000059,local_authority/Dorset +local_authority/E06000060,Buckinghamshire,local_authority,country/england,,,E06000060,local_authority/Buckinghamshire +local_authority/E06000061,North Northamptonshire,local_authority,country/england,,,E06000061,local_authority/North Northamptonshire +local_authority/E06000062,West Northamptonshire,local_authority,country/england,,,E06000062,local_authority/West Northamptonshire +local_authority/E06000063,Cumberland,local_authority,country/england,,,E06000063,local_authority/Cumberland +local_authority/E06000064,Westmorland and Furness,local_authority,country/england,,,E06000064,local_authority/Westmorland and Furness +local_authority/E06000065,North Yorkshire,local_authority,country/england,,,E06000065,local_authority/North Yorkshire +local_authority/E06000066,Somerset,local_authority,country/england,,,E06000066,local_authority/Somerset +local_authority/E07000008,Cambridge,local_authority,country/england,,,E07000008,local_authority/Cambridge +local_authority/E07000009,East Cambridgeshire,local_authority,country/england,,,E07000009,local_authority/East Cambridgeshire +local_authority/E07000010,Fenland,local_authority,country/england,,,E07000010,local_authority/Fenland +local_authority/E07000011,Huntingdonshire,local_authority,country/england,,,E07000011,local_authority/Huntingdonshire +local_authority/E07000012,South Cambridgeshire,local_authority,country/england,,,E07000012,local_authority/South Cambridgeshire +local_authority/E07000032,Amber Valley,local_authority,country/england,,,E07000032,local_authority/Amber Valley +local_authority/E07000033,Bolsover,local_authority,country/england,,,E07000033,local_authority/Bolsover +local_authority/E07000034,Chesterfield,local_authority,country/england,,,E07000034,local_authority/Chesterfield +local_authority/E07000035,Derbyshire Dales,local_authority,country/england,,,E07000035,local_authority/Derbyshire Dales +local_authority/E07000036,Erewash,local_authority,country/england,,,E07000036,local_authority/Erewash +local_authority/E07000037,High Peak,local_authority,country/england,,,E07000037,local_authority/High Peak +local_authority/E07000038,North East Derbyshire,local_authority,country/england,,,E07000038,local_authority/North East Derbyshire +local_authority/E07000039,South Derbyshire,local_authority,country/england,,,E07000039,local_authority/South Derbyshire +local_authority/E07000040,East Devon,local_authority,country/england,,,E07000040,local_authority/East Devon +local_authority/E07000041,Exeter,local_authority,country/england,,,E07000041,local_authority/Exeter +local_authority/E07000042,Mid Devon,local_authority,country/england,,,E07000042,local_authority/Mid Devon +local_authority/E07000043,North Devon,local_authority,country/england,,,E07000043,local_authority/North Devon +local_authority/E07000044,South Hams,local_authority,country/england,,,E07000044,local_authority/South Hams +local_authority/E07000045,Teignbridge,local_authority,country/england,,,E07000045,local_authority/Teignbridge +local_authority/E07000046,Torridge,local_authority,country/england,,,E07000046,local_authority/Torridge +local_authority/E07000047,West Devon,local_authority,country/england,,,E07000047,local_authority/West Devon +local_authority/E07000061,Eastbourne,local_authority,country/england,,,E07000061,local_authority/Eastbourne +local_authority/E07000062,Hastings,local_authority,country/england,,,E07000062,local_authority/Hastings +local_authority/E07000063,Lewes,local_authority,country/england,,,E07000063,local_authority/Lewes +local_authority/E07000064,Rother,local_authority,country/england,,,E07000064,local_authority/Rother +local_authority/E07000065,Wealden,local_authority,country/england,,,E07000065,local_authority/Wealden +local_authority/E07000066,Basildon,local_authority,country/england,,,E07000066,local_authority/Basildon +local_authority/E07000067,Braintree,local_authority,country/england,,,E07000067,local_authority/Braintree +local_authority/E07000068,Brentwood,local_authority,country/england,,,E07000068,local_authority/Brentwood +local_authority/E07000069,Castle Point,local_authority,country/england,,,E07000069,local_authority/Castle Point +local_authority/E07000070,Chelmsford,local_authority,country/england,,,E07000070,local_authority/Chelmsford +local_authority/E07000071,Colchester,local_authority,country/england,,,E07000071,local_authority/Colchester +local_authority/E07000072,Epping Forest,local_authority,country/england,,,E07000072,local_authority/Epping Forest +local_authority/E07000073,Harlow,local_authority,country/england,,,E07000073,local_authority/Harlow +local_authority/E07000074,Maldon,local_authority,country/england,,,E07000074,local_authority/Maldon +local_authority/E07000075,Rochford,local_authority,country/england,,,E07000075,local_authority/Rochford +local_authority/E07000076,Tendring,local_authority,country/england,,,E07000076,local_authority/Tendring +local_authority/E07000077,Uttlesford,local_authority,country/england,,,E07000077,local_authority/Uttlesford +local_authority/E07000078,Cheltenham,local_authority,country/england,,,E07000078,local_authority/Cheltenham +local_authority/E07000079,Cotswold,local_authority,country/england,,,E07000079,local_authority/Cotswold +local_authority/E07000080,Forest of Dean,local_authority,country/england,,,E07000080,local_authority/Forest of Dean +local_authority/E07000081,Gloucester,local_authority,country/england,,,E07000081,local_authority/Gloucester +local_authority/E07000082,Stroud,local_authority,country/england,,,E07000082,local_authority/Stroud +local_authority/E07000083,Tewkesbury,local_authority,country/england,,,E07000083,local_authority/Tewkesbury +local_authority/E07000084,Basingstoke and Deane,local_authority,country/england,,,E07000084,local_authority/Basingstoke and Deane +local_authority/E07000085,East Hampshire,local_authority,country/england,,,E07000085,local_authority/East Hampshire +local_authority/E07000086,Eastleigh,local_authority,country/england,,,E07000086,local_authority/Eastleigh +local_authority/E07000087,Fareham,local_authority,country/england,,,E07000087,local_authority/Fareham +local_authority/E07000088,Gosport,local_authority,country/england,,,E07000088,local_authority/Gosport +local_authority/E07000089,Hart,local_authority,country/england,,,E07000089,local_authority/Hart +local_authority/E07000090,Havant,local_authority,country/england,,,E07000090,local_authority/Havant +local_authority/E07000091,New Forest,local_authority,country/england,,,E07000091,local_authority/New Forest +local_authority/E07000092,Rushmoor,local_authority,country/england,,,E07000092,local_authority/Rushmoor +local_authority/E07000093,Test Valley,local_authority,country/england,,,E07000093,local_authority/Test Valley +local_authority/E07000094,Winchester,local_authority,country/england,,,E07000094,local_authority/Winchester +local_authority/E07000095,Broxbourne,local_authority,country/england,,,E07000095,local_authority/Broxbourne +local_authority/E07000096,Dacorum,local_authority,country/england,,,E07000096,local_authority/Dacorum +local_authority/E07000098,Hertsmere,local_authority,country/england,,,E07000098,local_authority/Hertsmere +local_authority/E07000099,North Hertfordshire,local_authority,country/england,,,E07000099,local_authority/North Hertfordshire +local_authority/E07000102,Three Rivers,local_authority,country/england,,,E07000102,local_authority/Three Rivers +local_authority/E07000103,Watford,local_authority,country/england,,,E07000103,local_authority/Watford +local_authority/E07000105,Ashford,local_authority,country/england,,,E07000105,local_authority/Ashford +local_authority/E07000106,Canterbury,local_authority,country/england,,,E07000106,local_authority/Canterbury +local_authority/E07000107,Dartford,local_authority,country/england,,,E07000107,local_authority/Dartford +local_authority/E07000108,Dover,local_authority,country/england,,,E07000108,local_authority/Dover +local_authority/E07000109,Gravesham,local_authority,country/england,,,E07000109,local_authority/Gravesham +local_authority/E07000110,Maidstone,local_authority,country/england,,,E07000110,local_authority/Maidstone +local_authority/E07000111,Sevenoaks,local_authority,country/england,,,E07000111,local_authority/Sevenoaks +local_authority/E07000112,Folkestone and Hythe,local_authority,country/england,,,E07000112,local_authority/Folkestone and Hythe +local_authority/E07000113,Swale,local_authority,country/england,,,E07000113,local_authority/Swale +local_authority/E07000114,Thanet,local_authority,country/england,,,E07000114,local_authority/Thanet +local_authority/E07000115,Tonbridge and Malling,local_authority,country/england,,,E07000115,local_authority/Tonbridge and Malling +local_authority/E07000116,Tunbridge Wells,local_authority,country/england,,,E07000116,local_authority/Tunbridge Wells +local_authority/E07000117,Burnley,local_authority,country/england,,,E07000117,local_authority/Burnley +local_authority/E07000118,Chorley,local_authority,country/england,,,E07000118,local_authority/Chorley +local_authority/E07000119,Fylde,local_authority,country/england,,,E07000119,local_authority/Fylde +local_authority/E07000120,Hyndburn,local_authority,country/england,,,E07000120,local_authority/Hyndburn +local_authority/E07000121,Lancaster,local_authority,country/england,,,E07000121,local_authority/Lancaster +local_authority/E07000122,Pendle,local_authority,country/england,,,E07000122,local_authority/Pendle +local_authority/E07000123,Preston,local_authority,country/england,,,E07000123,local_authority/Preston +local_authority/E07000124,Ribble Valley,local_authority,country/england,,,E07000124,local_authority/Ribble Valley +local_authority/E07000125,Rossendale,local_authority,country/england,,,E07000125,local_authority/Rossendale +local_authority/E07000126,South Ribble,local_authority,country/england,,,E07000126,local_authority/South Ribble +local_authority/E07000127,West Lancashire,local_authority,country/england,,,E07000127,local_authority/West Lancashire +local_authority/E07000128,Wyre,local_authority,country/england,,,E07000128,local_authority/Wyre +local_authority/E07000129,Blaby,local_authority,country/england,,,E07000129,local_authority/Blaby +local_authority/E07000130,Charnwood,local_authority,country/england,,,E07000130,local_authority/Charnwood +local_authority/E07000131,Harborough,local_authority,country/england,,,E07000131,local_authority/Harborough +local_authority/E07000132,Hinckley and Bosworth,local_authority,country/england,,,E07000132,local_authority/Hinckley and Bosworth +local_authority/E07000133,Melton,local_authority,country/england,,,E07000133,local_authority/Melton +local_authority/E07000134,North West Leicestershire,local_authority,country/england,,,E07000134,local_authority/North West Leicestershire +local_authority/E07000135,Oadby and Wigston,local_authority,country/england,,,E07000135,local_authority/Oadby and Wigston +local_authority/E07000136,Boston,local_authority,country/england,,,E07000136,local_authority/Boston +local_authority/E07000137,East Lindsey,local_authority,country/england,,,E07000137,local_authority/East Lindsey +local_authority/E07000138,Lincoln,local_authority,country/england,,,E07000138,local_authority/Lincoln +local_authority/E07000139,North Kesteven,local_authority,country/england,,,E07000139,local_authority/North Kesteven +local_authority/E07000140,South Holland,local_authority,country/england,,,E07000140,local_authority/South Holland +local_authority/E07000141,South Kesteven,local_authority,country/england,,,E07000141,local_authority/South Kesteven +local_authority/E07000142,West Lindsey,local_authority,country/england,,,E07000142,local_authority/West Lindsey +local_authority/E07000143,Breckland,local_authority,country/england,,,E07000143,local_authority/Breckland +local_authority/E07000144,Broadland,local_authority,country/england,,,E07000144,local_authority/Broadland +local_authority/E07000145,Great Yarmouth,local_authority,country/england,,,E07000145,local_authority/Great Yarmouth +local_authority/E07000146,King's Lynn and West Norfolk,local_authority,country/england,,,E07000146,local_authority/King's Lynn and West Norfolk +local_authority/E07000147,North Norfolk,local_authority,country/england,,,E07000147,local_authority/North Norfolk +local_authority/E07000148,Norwich,local_authority,country/england,,,E07000148,local_authority/Norwich +local_authority/E07000149,South Norfolk,local_authority,country/england,,,E07000149,local_authority/South Norfolk +local_authority/E07000170,Ashfield,local_authority,country/england,,,E07000170,local_authority/Ashfield +local_authority/E07000171,Bassetlaw,local_authority,country/england,,,E07000171,local_authority/Bassetlaw +local_authority/E07000172,Broxtowe,local_authority,country/england,,,E07000172,local_authority/Broxtowe +local_authority/E07000173,Gedling,local_authority,country/england,,,E07000173,local_authority/Gedling +local_authority/E07000174,Mansfield,local_authority,country/england,,,E07000174,local_authority/Mansfield +local_authority/E07000175,Newark and Sherwood,local_authority,country/england,,,E07000175,local_authority/Newark and Sherwood +local_authority/E07000176,Rushcliffe,local_authority,country/england,,,E07000176,local_authority/Rushcliffe +local_authority/E07000177,Cherwell,local_authority,country/england,,,E07000177,local_authority/Cherwell +local_authority/E07000178,Oxford,local_authority,country/england,,,E07000178,local_authority/Oxford +local_authority/E07000179,South Oxfordshire,local_authority,country/england,,,E07000179,local_authority/South Oxfordshire +local_authority/E07000180,Vale of White Horse,local_authority,country/england,,,E07000180,local_authority/Vale of White Horse +local_authority/E07000181,West Oxfordshire,local_authority,country/england,,,E07000181,local_authority/West Oxfordshire +local_authority/E07000192,Cannock Chase,local_authority,country/england,,,E07000192,local_authority/Cannock Chase +local_authority/E07000193,East Staffordshire,local_authority,country/england,,,E07000193,local_authority/East Staffordshire +local_authority/E07000194,Lichfield,local_authority,country/england,,,E07000194,local_authority/Lichfield +local_authority/E07000195,Newcastle-under-Lyme,local_authority,country/england,,,E07000195,local_authority/Newcastle-under-Lyme +local_authority/E07000196,South Staffordshire,local_authority,country/england,,,E07000196,local_authority/South Staffordshire +local_authority/E07000197,Stafford,local_authority,country/england,,,E07000197,local_authority/Stafford +local_authority/E07000198,Staffordshire Moorlands,local_authority,country/england,,,E07000198,local_authority/Staffordshire Moorlands +local_authority/E07000199,Tamworth,local_authority,country/england,,,E07000199,local_authority/Tamworth +local_authority/E07000200,Babergh,local_authority,country/england,,,E07000200,local_authority/Babergh +local_authority/E07000202,Ipswich,local_authority,country/england,,,E07000202,local_authority/Ipswich +local_authority/E07000203,Mid Suffolk,local_authority,country/england,,,E07000203,local_authority/Mid Suffolk +local_authority/E07000207,Elmbridge,local_authority,country/england,,,E07000207,local_authority/Elmbridge +local_authority/E07000208,Epsom and Ewell,local_authority,country/england,,,E07000208,local_authority/Epsom and Ewell +local_authority/E07000209,Guildford,local_authority,country/england,,,E07000209,local_authority/Guildford +local_authority/E07000210,Mole Valley,local_authority,country/england,,,E07000210,local_authority/Mole Valley +local_authority/E07000211,Reigate and Banstead,local_authority,country/england,,,E07000211,local_authority/Reigate and Banstead +local_authority/E07000212,Runnymede,local_authority,country/england,,,E07000212,local_authority/Runnymede +local_authority/E07000213,Spelthorne,local_authority,country/england,,,E07000213,local_authority/Spelthorne +local_authority/E07000214,Surrey Heath,local_authority,country/england,,,E07000214,local_authority/Surrey Heath +local_authority/E07000215,Tandridge,local_authority,country/england,,,E07000215,local_authority/Tandridge +local_authority/E07000216,Waverley,local_authority,country/england,,,E07000216,local_authority/Waverley +local_authority/E07000217,Woking,local_authority,country/england,,,E07000217,local_authority/Woking +local_authority/E07000218,North Warwickshire,local_authority,country/england,,,E07000218,local_authority/North Warwickshire +local_authority/E07000219,Nuneaton and Bedworth,local_authority,country/england,,,E07000219,local_authority/Nuneaton and Bedworth +local_authority/E07000220,Rugby,local_authority,country/england,,,E07000220,local_authority/Rugby +local_authority/E07000221,Stratford-on-Avon,local_authority,country/england,,,E07000221,local_authority/Stratford-on-Avon +local_authority/E07000222,Warwick,local_authority,country/england,,,E07000222,local_authority/Warwick +local_authority/E07000223,Adur,local_authority,country/england,,,E07000223,local_authority/Adur +local_authority/E07000224,Arun,local_authority,country/england,,,E07000224,local_authority/Arun +local_authority/E07000225,Chichester,local_authority,country/england,,,E07000225,local_authority/Chichester +local_authority/E07000226,Crawley,local_authority,country/england,,,E07000226,local_authority/Crawley +local_authority/E07000227,Horsham,local_authority,country/england,,,E07000227,local_authority/Horsham +local_authority/E07000228,Mid Sussex,local_authority,country/england,,,E07000228,local_authority/Mid Sussex +local_authority/E07000229,Worthing,local_authority,country/england,,,E07000229,local_authority/Worthing +local_authority/E07000234,Bromsgrove,local_authority,country/england,,,E07000234,local_authority/Bromsgrove +local_authority/E07000235,Malvern Hills,local_authority,country/england,,,E07000235,local_authority/Malvern Hills +local_authority/E07000236,Redditch,local_authority,country/england,,,E07000236,local_authority/Redditch +local_authority/E07000237,Worcester,local_authority,country/england,,,E07000237,local_authority/Worcester +local_authority/E07000238,Wychavon,local_authority,country/england,,,E07000238,local_authority/Wychavon +local_authority/E07000239,Wyre Forest,local_authority,country/england,,,E07000239,local_authority/Wyre Forest +local_authority/E07000240,St Albans,local_authority,country/england,,,E07000240,local_authority/St Albans +local_authority/E07000241,Welwyn Hatfield,local_authority,country/england,,,E07000241,local_authority/Welwyn Hatfield +local_authority/E07000242,East Hertfordshire,local_authority,country/england,,,E07000242,local_authority/East Hertfordshire +local_authority/E07000243,Stevenage,local_authority,country/england,,,E07000243,local_authority/Stevenage +local_authority/E07000244,East Suffolk,local_authority,country/england,,,E07000244,local_authority/East Suffolk +local_authority/E07000245,West Suffolk,local_authority,country/england,,,E07000245,local_authority/West Suffolk +local_authority/E08000001,Bolton,local_authority,country/england,,,E08000001,local_authority/Bolton +local_authority/E08000002,Bury,local_authority,country/england,,,E08000002,local_authority/Bury +local_authority/E08000003,Manchester,local_authority,country/england,,,E08000003,local_authority/Manchester +local_authority/E08000004,Oldham,local_authority,country/england,,,E08000004,local_authority/Oldham +local_authority/E08000005,Rochdale,local_authority,country/england,,,E08000005,local_authority/Rochdale +local_authority/E08000006,Salford,local_authority,country/england,,,E08000006,local_authority/Salford +local_authority/E08000007,Stockport,local_authority,country/england,,,E08000007,local_authority/Stockport +local_authority/E08000008,Tameside,local_authority,country/england,,,E08000008,local_authority/Tameside +local_authority/E08000009,Trafford,local_authority,country/england,,,E08000009,local_authority/Trafford +local_authority/E08000010,Wigan,local_authority,country/england,,,E08000010,local_authority/Wigan +local_authority/E08000011,Knowsley,local_authority,country/england,,,E08000011,local_authority/Knowsley +local_authority/E08000012,Liverpool,local_authority,country/england,,,E08000012,local_authority/Liverpool +local_authority/E08000013,St. Helens,local_authority,country/england,,,E08000013,local_authority/St. Helens +local_authority/E08000014,Sefton,local_authority,country/england,,,E08000014,local_authority/Sefton +local_authority/E08000015,Wirral,local_authority,country/england,,,E08000015,local_authority/Wirral +local_authority/E08000016,Barnsley,local_authority,country/england,,,E08000016,local_authority/Barnsley +local_authority/E08000017,Doncaster,local_authority,country/england,,,E08000017,local_authority/Doncaster +local_authority/E08000018,Rotherham,local_authority,country/england,,,E08000018,local_authority/Rotherham +local_authority/E08000019,Sheffield,local_authority,country/england,,,E08000019,local_authority/Sheffield +local_authority/E08000021,Newcastle upon Tyne,local_authority,country/england,,,E08000021,local_authority/Newcastle upon Tyne +local_authority/E08000022,North Tyneside,local_authority,country/england,,,E08000022,local_authority/North Tyneside +local_authority/E08000023,South Tyneside,local_authority,country/england,,,E08000023,local_authority/South Tyneside +local_authority/E08000024,Sunderland,local_authority,country/england,,,E08000024,local_authority/Sunderland +local_authority/E08000025,Birmingham,local_authority,country/england,,,E08000025,local_authority/Birmingham +local_authority/E08000026,Coventry,local_authority,country/england,,,E08000026,local_authority/Coventry +local_authority/E08000027,Dudley,local_authority,country/england,,,E08000027,local_authority/Dudley +local_authority/E08000028,Sandwell,local_authority,country/england,,,E08000028,local_authority/Sandwell +local_authority/E08000029,Solihull,local_authority,country/england,,,E08000029,local_authority/Solihull +local_authority/E08000030,Walsall,local_authority,country/england,,,E08000030,local_authority/Walsall +local_authority/E08000031,Wolverhampton,local_authority,country/england,,,E08000031,local_authority/Wolverhampton +local_authority/E08000032,Bradford,local_authority,country/england,,,E08000032,local_authority/Bradford +local_authority/E08000033,Calderdale,local_authority,country/england,,,E08000033,local_authority/Calderdale +local_authority/E08000034,Kirklees,local_authority,country/england,,,E08000034,local_authority/Kirklees +local_authority/E08000035,Leeds,local_authority,country/england,,,E08000035,local_authority/Leeds +local_authority/E08000036,Wakefield,local_authority,country/england,,,E08000036,local_authority/Wakefield +local_authority/E08000037,Gateshead,local_authority,country/england,,,E08000037,local_authority/Gateshead +local_authority/E09000001,City of London,local_authority,country/england,,,E09000001,local_authority/City of London +local_authority/E09000002,Barking and Dagenham,local_authority,country/england,,,E09000002,local_authority/Barking and Dagenham +local_authority/E09000003,Barnet,local_authority,country/england,,,E09000003,local_authority/Barnet +local_authority/E09000004,Bexley,local_authority,country/england,,,E09000004,local_authority/Bexley +local_authority/E09000005,Brent,local_authority,country/england,,,E09000005,local_authority/Brent +local_authority/E09000006,Bromley,local_authority,country/england,,,E09000006,local_authority/Bromley +local_authority/E09000007,Camden,local_authority,country/england,,,E09000007,local_authority/Camden +local_authority/E09000008,Croydon,local_authority,country/england,,,E09000008,local_authority/Croydon +local_authority/E09000009,Ealing,local_authority,country/england,,,E09000009,local_authority/Ealing +local_authority/E09000010,Enfield,local_authority,country/england,,,E09000010,local_authority/Enfield +local_authority/E09000011,Greenwich,local_authority,country/england,,,E09000011,local_authority/Greenwich +local_authority/E09000012,Hackney,local_authority,country/england,,,E09000012,local_authority/Hackney +local_authority/E09000013,Hammersmith and Fulham,local_authority,country/england,,,E09000013,local_authority/Hammersmith and Fulham +local_authority/E09000014,Haringey,local_authority,country/england,,,E09000014,local_authority/Haringey +local_authority/E09000015,Harrow,local_authority,country/england,,,E09000015,local_authority/Harrow +local_authority/E09000016,Havering,local_authority,country/england,,,E09000016,local_authority/Havering +local_authority/E09000017,Hillingdon,local_authority,country/england,,,E09000017,local_authority/Hillingdon +local_authority/E09000018,Hounslow,local_authority,country/england,,,E09000018,local_authority/Hounslow +local_authority/E09000019,Islington,local_authority,country/england,,,E09000019,local_authority/Islington +local_authority/E09000020,Kensington and Chelsea,local_authority,country/england,,,E09000020,local_authority/Kensington and Chelsea +local_authority/E09000021,Kingston upon Thames,local_authority,country/england,,,E09000021,local_authority/Kingston upon Thames +local_authority/E09000022,Lambeth,local_authority,country/england,,,E09000022,local_authority/Lambeth +local_authority/E09000023,Lewisham,local_authority,country/england,,,E09000023,local_authority/Lewisham +local_authority/E09000024,Merton,local_authority,country/england,,,E09000024,local_authority/Merton +local_authority/E09000025,Newham,local_authority,country/england,,,E09000025,local_authority/Newham +local_authority/E09000026,Redbridge,local_authority,country/england,,,E09000026,local_authority/Redbridge +local_authority/E09000027,Richmond upon Thames,local_authority,country/england,,,E09000027,local_authority/Richmond upon Thames +local_authority/E09000028,Southwark,local_authority,country/england,,,E09000028,local_authority/Southwark +local_authority/E09000029,Sutton,local_authority,country/england,,,E09000029,local_authority/Sutton +local_authority/E09000030,Tower Hamlets,local_authority,country/england,,,E09000030,local_authority/Tower Hamlets +local_authority/E09000031,Waltham Forest,local_authority,country/england,,,E09000031,local_authority/Waltham Forest +local_authority/E09000032,Wandsworth,local_authority,country/england,,,E09000032,local_authority/Wandsworth +local_authority/E09000033,Westminster,local_authority,country/england,,,E09000033,local_authority/Westminster +local_authority/N09000001,Antrim and Newtownabbey,local_authority,country/northern_ireland,,,N09000001,local_authority/Antrim and Newtownabbey +local_authority/N09000002,"Armagh City, Banbridge and Craigavon",local_authority,country/northern_ireland,,,N09000002,"local_authority/Armagh City, Banbridge and Craigavon" +local_authority/N09000003,Belfast,local_authority,country/northern_ireland,,,N09000003,local_authority/Belfast +local_authority/N09000004,Causeway Coast and Glens,local_authority,country/northern_ireland,,,N09000004,local_authority/Causeway Coast and Glens +local_authority/N09000005,Derry City and Strabane,local_authority,country/northern_ireland,,,N09000005,local_authority/Derry City and Strabane +local_authority/N09000006,Fermanagh and Omagh,local_authority,country/northern_ireland,,,N09000006,local_authority/Fermanagh and Omagh +local_authority/N09000007,Lisburn and Castlereagh,local_authority,country/northern_ireland,,,N09000007,local_authority/Lisburn and Castlereagh +local_authority/N09000008,Mid and East Antrim,local_authority,country/northern_ireland,,,N09000008,local_authority/Mid and East Antrim +local_authority/N09000009,Mid Ulster,local_authority,country/northern_ireland,,,N09000009,local_authority/Mid Ulster +local_authority/N09000010,"Newry, Mourne and Down",local_authority,country/northern_ireland,,,N09000010,"local_authority/Newry, Mourne and Down" +local_authority/N09000011,Ards and North Down,local_authority,country/northern_ireland,,,N09000011,local_authority/Ards and North Down +local_authority/S12000005,Clackmannanshire,local_authority,country/scotland,,,S12000005,local_authority/Clackmannanshire +local_authority/S12000006,Dumfries and Galloway,local_authority,country/scotland,,,S12000006,local_authority/Dumfries and Galloway +local_authority/S12000008,East Ayrshire,local_authority,country/scotland,,,S12000008,local_authority/East Ayrshire +local_authority/S12000010,East Lothian,local_authority,country/scotland,,,S12000010,local_authority/East Lothian +local_authority/S12000011,East Renfrewshire,local_authority,country/scotland,,,S12000011,local_authority/East Renfrewshire +local_authority/S12000013,Na h-Eileanan Siar,local_authority,country/scotland,,,S12000013,local_authority/Na h-Eileanan Siar +local_authority/S12000014,Falkirk,local_authority,country/scotland,,,S12000014,local_authority/Falkirk +local_authority/S12000017,Highland,local_authority,country/scotland,,,S12000017,local_authority/Highland +local_authority/S12000018,Inverclyde,local_authority,country/scotland,,,S12000018,local_authority/Inverclyde +local_authority/S12000019,Midlothian,local_authority,country/scotland,,,S12000019,local_authority/Midlothian +local_authority/S12000020,Moray,local_authority,country/scotland,,,S12000020,local_authority/Moray +local_authority/S12000021,North Ayrshire,local_authority,country/scotland,,,S12000021,local_authority/North Ayrshire +local_authority/S12000023,Orkney Islands,local_authority,country/scotland,,,S12000023,local_authority/Orkney Islands +local_authority/S12000026,Scottish Borders,local_authority,country/scotland,,,S12000026,local_authority/Scottish Borders +local_authority/S12000027,Shetland Islands,local_authority,country/scotland,,,S12000027,local_authority/Shetland Islands +local_authority/S12000028,South Ayrshire,local_authority,country/scotland,,,S12000028,local_authority/South Ayrshire +local_authority/S12000029,South Lanarkshire,local_authority,country/scotland,,,S12000029,local_authority/South Lanarkshire +local_authority/S12000030,Stirling,local_authority,country/scotland,,,S12000030,local_authority/Stirling +local_authority/S12000033,Aberdeen City,local_authority,country/scotland,,,S12000033,local_authority/Aberdeen City +local_authority/S12000034,Aberdeenshire,local_authority,country/scotland,,,S12000034,local_authority/Aberdeenshire +local_authority/S12000035,Argyll and Bute,local_authority,country/scotland,,,S12000035,local_authority/Argyll and Bute +local_authority/S12000036,City of Edinburgh,local_authority,country/scotland,,,S12000036,local_authority/City of Edinburgh +local_authority/S12000038,Renfrewshire,local_authority,country/scotland,,,S12000038,local_authority/Renfrewshire +local_authority/S12000039,West Dunbartonshire,local_authority,country/scotland,,,S12000039,local_authority/West Dunbartonshire +local_authority/S12000040,West Lothian,local_authority,country/scotland,,,S12000040,local_authority/West Lothian +local_authority/S12000041,Angus,local_authority,country/scotland,,,S12000041,local_authority/Angus +local_authority/S12000042,Dundee City,local_authority,country/scotland,,,S12000042,local_authority/Dundee City +local_authority/S12000045,East Dunbartonshire,local_authority,country/scotland,,,S12000045,local_authority/East Dunbartonshire +local_authority/S12000047,Fife,local_authority,country/scotland,,,S12000047,local_authority/Fife +local_authority/S12000048,Perth and Kinross,local_authority,country/scotland,,,S12000048,local_authority/Perth and Kinross +local_authority/S12000049,Glasgow City,local_authority,country/scotland,,,S12000049,local_authority/Glasgow City +local_authority/S12000050,North Lanarkshire,local_authority,country/scotland,,,S12000050,local_authority/North Lanarkshire +local_authority/W06000001,Isle of Anglesey,local_authority,country/wales,,,W06000001,local_authority/Isle of Anglesey +local_authority/W06000002,Gwynedd,local_authority,country/wales,,,W06000002,local_authority/Gwynedd +local_authority/W06000003,Conwy,local_authority,country/wales,,,W06000003,local_authority/Conwy +local_authority/W06000004,Denbighshire,local_authority,country/wales,,,W06000004,local_authority/Denbighshire +local_authority/W06000005,Flintshire,local_authority,country/wales,,,W06000005,local_authority/Flintshire +local_authority/W06000006,Wrexham,local_authority,country/wales,,,W06000006,local_authority/Wrexham +local_authority/W06000008,Ceredigion,local_authority,country/wales,,,W06000008,local_authority/Ceredigion +local_authority/W06000009,Pembrokeshire,local_authority,country/wales,,,W06000009,local_authority/Pembrokeshire +local_authority/W06000010,Carmarthenshire,local_authority,country/wales,,,W06000010,local_authority/Carmarthenshire +local_authority/W06000011,Swansea,local_authority,country/wales,,,W06000011,local_authority/Swansea +local_authority/W06000012,Neath Port Talbot,local_authority,country/wales,,,W06000012,local_authority/Neath Port Talbot +local_authority/W06000013,Bridgend,local_authority,country/wales,,,W06000013,local_authority/Bridgend +local_authority/W06000014,Vale of Glamorgan,local_authority,country/wales,,,W06000014,local_authority/Vale of Glamorgan +local_authority/W06000015,Cardiff,local_authority,country/wales,,,W06000015,local_authority/Cardiff +local_authority/W06000016,Rhondda Cynon Taf,local_authority,country/wales,,,W06000016,local_authority/Rhondda Cynon Taf +local_authority/W06000018,Caerphilly,local_authority,country/wales,,,W06000018,local_authority/Caerphilly +local_authority/W06000019,Blaenau Gwent,local_authority,country/wales,,,W06000019,local_authority/Blaenau Gwent +local_authority/W06000020,Torfaen,local_authority,country/wales,,,W06000020,local_authority/Torfaen +local_authority/W06000021,Monmouthshire,local_authority,country/wales,,,W06000021,local_authority/Monmouthshire +local_authority/W06000022,Newport,local_authority,country/wales,,,W06000022,local_authority/Newport +local_authority/W06000023,Powys,local_authority,country/wales,,,W06000023,local_authority/Powys +local_authority/W06000024,Merthyr Tydfil,local_authority,country/wales,,,W06000024,local_authority/Merthyr Tydfil diff --git a/policyengine_api/data/v2/catalog/region_reference_files/us.csv b/policyengine_api/data/v2/catalog/region_reference_files/us.csv new file mode 100644 index 000000000..a8a3b3166 --- /dev/null +++ b/policyengine_api/data/v2/catalog/region_reference_files/us.csv @@ -0,0 +1,822 @@ +code,label,region_type,parent_code,state_code,state_name,filter_value,v1_code +us,United States,national,,,,,us +state/ak,Alaska,state,us,AK,Alaska,2,state/ak +state/al,Alabama,state,us,AL,Alabama,1,state/al +state/ar,Arkansas,state,us,AR,Arkansas,5,state/ar +state/az,Arizona,state,us,AZ,Arizona,4,state/az +state/ca,California,state,us,CA,California,6,state/ca +state/co,Colorado,state,us,CO,Colorado,8,state/co +state/ct,Connecticut,state,us,CT,Connecticut,9,state/ct +state/dc,District of Columbia,state,us,DC,District of Columbia,11,state/dc +state/de,Delaware,state,us,DE,Delaware,10,state/de +state/fl,Florida,state,us,FL,Florida,12,state/fl +state/ga,Georgia,state,us,GA,Georgia,13,state/ga +state/hi,Hawaii,state,us,HI,Hawaii,15,state/hi +state/ia,Iowa,state,us,IA,Iowa,19,state/ia +state/id,Idaho,state,us,ID,Idaho,16,state/id +state/il,Illinois,state,us,IL,Illinois,17,state/il +state/in,Indiana,state,us,IN,Indiana,18,state/in +state/ks,Kansas,state,us,KS,Kansas,20,state/ks +state/ky,Kentucky,state,us,KY,Kentucky,21,state/ky +state/la,Louisiana,state,us,LA,Louisiana,22,state/la +state/ma,Massachusetts,state,us,MA,Massachusetts,25,state/ma +state/md,Maryland,state,us,MD,Maryland,24,state/md +state/me,Maine,state,us,ME,Maine,23,state/me +state/mi,Michigan,state,us,MI,Michigan,26,state/mi +state/mn,Minnesota,state,us,MN,Minnesota,27,state/mn +state/mo,Missouri,state,us,MO,Missouri,29,state/mo +state/ms,Mississippi,state,us,MS,Mississippi,28,state/ms +state/mt,Montana,state,us,MT,Montana,30,state/mt +state/nc,North Carolina,state,us,NC,North Carolina,37,state/nc +state/nd,North Dakota,state,us,ND,North Dakota,38,state/nd +state/ne,Nebraska,state,us,NE,Nebraska,31,state/ne +state/nh,New Hampshire,state,us,NH,New Hampshire,33,state/nh +state/nj,New Jersey,state,us,NJ,New Jersey,34,state/nj +state/nm,New Mexico,state,us,NM,New Mexico,35,state/nm +state/nv,Nevada,state,us,NV,Nevada,32,state/nv +state/ny,New York,state,us,NY,New York,36,state/ny +state/oh,Ohio,state,us,OH,Ohio,39,state/oh +state/ok,Oklahoma,state,us,OK,Oklahoma,40,state/ok +state/or,Oregon,state,us,OR,Oregon,41,state/or +state/pa,Pennsylvania,state,us,PA,Pennsylvania,42,state/pa +state/ri,Rhode Island,state,us,RI,Rhode Island,44,state/ri +state/sc,South Carolina,state,us,SC,South Carolina,45,state/sc +state/sd,South Dakota,state,us,SD,South Dakota,46,state/sd +state/tn,Tennessee,state,us,TN,Tennessee,47,state/tn +state/tx,Texas,state,us,TX,Texas,48,state/tx +state/ut,Utah,state,us,UT,Utah,49,state/ut +state/va,Virginia,state,us,VA,Virginia,51,state/va +state/vt,Vermont,state,us,VT,Vermont,50,state/vt +state/wa,Washington,state,us,WA,Washington,53,state/wa +state/wi,Wisconsin,state,us,WI,Wisconsin,55,state/wi +state/wv,West Virginia,state,us,WV,West Virginia,54,state/wv +state/wy,Wyoming,state,us,WY,Wyoming,56,state/wy +congressional_district/AK-01,Alaska's at-large congressional district,congressional_district,state/ak,AK,Alaska,200,congressional_district/AK-01 +congressional_district/AL-01,Alabama's 1st congressional district,congressional_district,state/al,AL,Alabama,101,congressional_district/AL-01 +congressional_district/AL-02,Alabama's 2nd congressional district,congressional_district,state/al,AL,Alabama,102,congressional_district/AL-02 +congressional_district/AL-03,Alabama's 3rd congressional district,congressional_district,state/al,AL,Alabama,103,congressional_district/AL-03 +congressional_district/AL-04,Alabama's 4th congressional district,congressional_district,state/al,AL,Alabama,104,congressional_district/AL-04 +congressional_district/AL-05,Alabama's 5th congressional district,congressional_district,state/al,AL,Alabama,105,congressional_district/AL-05 +congressional_district/AL-06,Alabama's 6th congressional district,congressional_district,state/al,AL,Alabama,106,congressional_district/AL-06 +congressional_district/AL-07,Alabama's 7th congressional district,congressional_district,state/al,AL,Alabama,107,congressional_district/AL-07 +congressional_district/AR-01,Arkansas's 1st congressional district,congressional_district,state/ar,AR,Arkansas,501,congressional_district/AR-01 +congressional_district/AR-02,Arkansas's 2nd congressional district,congressional_district,state/ar,AR,Arkansas,502,congressional_district/AR-02 +congressional_district/AR-03,Arkansas's 3rd congressional district,congressional_district,state/ar,AR,Arkansas,503,congressional_district/AR-03 +congressional_district/AR-04,Arkansas's 4th congressional district,congressional_district,state/ar,AR,Arkansas,504,congressional_district/AR-04 +congressional_district/AZ-01,Arizona's 1st congressional district,congressional_district,state/az,AZ,Arizona,401,congressional_district/AZ-01 +congressional_district/AZ-02,Arizona's 2nd congressional district,congressional_district,state/az,AZ,Arizona,402,congressional_district/AZ-02 +congressional_district/AZ-03,Arizona's 3rd congressional district,congressional_district,state/az,AZ,Arizona,403,congressional_district/AZ-03 +congressional_district/AZ-04,Arizona's 4th congressional district,congressional_district,state/az,AZ,Arizona,404,congressional_district/AZ-04 +congressional_district/AZ-05,Arizona's 5th congressional district,congressional_district,state/az,AZ,Arizona,405,congressional_district/AZ-05 +congressional_district/AZ-06,Arizona's 6th congressional district,congressional_district,state/az,AZ,Arizona,406,congressional_district/AZ-06 +congressional_district/AZ-07,Arizona's 7th congressional district,congressional_district,state/az,AZ,Arizona,407,congressional_district/AZ-07 +congressional_district/AZ-08,Arizona's 8th congressional district,congressional_district,state/az,AZ,Arizona,408,congressional_district/AZ-08 +congressional_district/AZ-09,Arizona's 9th congressional district,congressional_district,state/az,AZ,Arizona,409,congressional_district/AZ-09 +congressional_district/CA-01,California's 1st congressional district,congressional_district,state/ca,CA,California,601,congressional_district/CA-01 +congressional_district/CA-02,California's 2nd congressional district,congressional_district,state/ca,CA,California,602,congressional_district/CA-02 +congressional_district/CA-03,California's 3rd congressional district,congressional_district,state/ca,CA,California,603,congressional_district/CA-03 +congressional_district/CA-04,California's 4th congressional district,congressional_district,state/ca,CA,California,604,congressional_district/CA-04 +congressional_district/CA-05,California's 5th congressional district,congressional_district,state/ca,CA,California,605,congressional_district/CA-05 +congressional_district/CA-06,California's 6th congressional district,congressional_district,state/ca,CA,California,606,congressional_district/CA-06 +congressional_district/CA-07,California's 7th congressional district,congressional_district,state/ca,CA,California,607,congressional_district/CA-07 +congressional_district/CA-08,California's 8th congressional district,congressional_district,state/ca,CA,California,608,congressional_district/CA-08 +congressional_district/CA-09,California's 9th congressional district,congressional_district,state/ca,CA,California,609,congressional_district/CA-09 +congressional_district/CA-10,California's 10th congressional district,congressional_district,state/ca,CA,California,610,congressional_district/CA-10 +congressional_district/CA-11,California's 11th congressional district,congressional_district,state/ca,CA,California,611,congressional_district/CA-11 +congressional_district/CA-12,California's 12th congressional district,congressional_district,state/ca,CA,California,612,congressional_district/CA-12 +congressional_district/CA-13,California's 13th congressional district,congressional_district,state/ca,CA,California,613,congressional_district/CA-13 +congressional_district/CA-14,California's 14th congressional district,congressional_district,state/ca,CA,California,614,congressional_district/CA-14 +congressional_district/CA-15,California's 15th congressional district,congressional_district,state/ca,CA,California,615,congressional_district/CA-15 +congressional_district/CA-16,California's 16th congressional district,congressional_district,state/ca,CA,California,616,congressional_district/CA-16 +congressional_district/CA-17,California's 17th congressional district,congressional_district,state/ca,CA,California,617,congressional_district/CA-17 +congressional_district/CA-18,California's 18th congressional district,congressional_district,state/ca,CA,California,618,congressional_district/CA-18 +congressional_district/CA-19,California's 19th congressional district,congressional_district,state/ca,CA,California,619,congressional_district/CA-19 +congressional_district/CA-20,California's 20th congressional district,congressional_district,state/ca,CA,California,620,congressional_district/CA-20 +congressional_district/CA-21,California's 21st congressional district,congressional_district,state/ca,CA,California,621,congressional_district/CA-21 +congressional_district/CA-22,California's 22nd congressional district,congressional_district,state/ca,CA,California,622,congressional_district/CA-22 +congressional_district/CA-23,California's 23rd congressional district,congressional_district,state/ca,CA,California,623,congressional_district/CA-23 +congressional_district/CA-24,California's 24th congressional district,congressional_district,state/ca,CA,California,624,congressional_district/CA-24 +congressional_district/CA-25,California's 25th congressional district,congressional_district,state/ca,CA,California,625,congressional_district/CA-25 +congressional_district/CA-26,California's 26th congressional district,congressional_district,state/ca,CA,California,626,congressional_district/CA-26 +congressional_district/CA-27,California's 27th congressional district,congressional_district,state/ca,CA,California,627,congressional_district/CA-27 +congressional_district/CA-28,California's 28th congressional district,congressional_district,state/ca,CA,California,628,congressional_district/CA-28 +congressional_district/CA-29,California's 29th congressional district,congressional_district,state/ca,CA,California,629,congressional_district/CA-29 +congressional_district/CA-30,California's 30th congressional district,congressional_district,state/ca,CA,California,630,congressional_district/CA-30 +congressional_district/CA-31,California's 31st congressional district,congressional_district,state/ca,CA,California,631,congressional_district/CA-31 +congressional_district/CA-32,California's 32nd congressional district,congressional_district,state/ca,CA,California,632,congressional_district/CA-32 +congressional_district/CA-33,California's 33rd congressional district,congressional_district,state/ca,CA,California,633,congressional_district/CA-33 +congressional_district/CA-34,California's 34th congressional district,congressional_district,state/ca,CA,California,634,congressional_district/CA-34 +congressional_district/CA-35,California's 35th congressional district,congressional_district,state/ca,CA,California,635,congressional_district/CA-35 +congressional_district/CA-36,California's 36th congressional district,congressional_district,state/ca,CA,California,636,congressional_district/CA-36 +congressional_district/CA-37,California's 37th congressional district,congressional_district,state/ca,CA,California,637,congressional_district/CA-37 +congressional_district/CA-38,California's 38th congressional district,congressional_district,state/ca,CA,California,638,congressional_district/CA-38 +congressional_district/CA-39,California's 39th congressional district,congressional_district,state/ca,CA,California,639,congressional_district/CA-39 +congressional_district/CA-40,California's 40th congressional district,congressional_district,state/ca,CA,California,640,congressional_district/CA-40 +congressional_district/CA-41,California's 41st congressional district,congressional_district,state/ca,CA,California,641,congressional_district/CA-41 +congressional_district/CA-42,California's 42nd congressional district,congressional_district,state/ca,CA,California,642,congressional_district/CA-42 +congressional_district/CA-43,California's 43rd congressional district,congressional_district,state/ca,CA,California,643,congressional_district/CA-43 +congressional_district/CA-44,California's 44th congressional district,congressional_district,state/ca,CA,California,644,congressional_district/CA-44 +congressional_district/CA-45,California's 45th congressional district,congressional_district,state/ca,CA,California,645,congressional_district/CA-45 +congressional_district/CA-46,California's 46th congressional district,congressional_district,state/ca,CA,California,646,congressional_district/CA-46 +congressional_district/CA-47,California's 47th congressional district,congressional_district,state/ca,CA,California,647,congressional_district/CA-47 +congressional_district/CA-48,California's 48th congressional district,congressional_district,state/ca,CA,California,648,congressional_district/CA-48 +congressional_district/CA-49,California's 49th congressional district,congressional_district,state/ca,CA,California,649,congressional_district/CA-49 +congressional_district/CA-50,California's 50th congressional district,congressional_district,state/ca,CA,California,650,congressional_district/CA-50 +congressional_district/CA-51,California's 51st congressional district,congressional_district,state/ca,CA,California,651,congressional_district/CA-51 +congressional_district/CA-52,California's 52nd congressional district,congressional_district,state/ca,CA,California,652,congressional_district/CA-52 +congressional_district/CO-01,Colorado's 1st congressional district,congressional_district,state/co,CO,Colorado,801,congressional_district/CO-01 +congressional_district/CO-02,Colorado's 2nd congressional district,congressional_district,state/co,CO,Colorado,802,congressional_district/CO-02 +congressional_district/CO-03,Colorado's 3rd congressional district,congressional_district,state/co,CO,Colorado,803,congressional_district/CO-03 +congressional_district/CO-04,Colorado's 4th congressional district,congressional_district,state/co,CO,Colorado,804,congressional_district/CO-04 +congressional_district/CO-05,Colorado's 5th congressional district,congressional_district,state/co,CO,Colorado,805,congressional_district/CO-05 +congressional_district/CO-06,Colorado's 6th congressional district,congressional_district,state/co,CO,Colorado,806,congressional_district/CO-06 +congressional_district/CO-07,Colorado's 7th congressional district,congressional_district,state/co,CO,Colorado,807,congressional_district/CO-07 +congressional_district/CO-08,Colorado's 8th congressional district,congressional_district,state/co,CO,Colorado,808,congressional_district/CO-08 +congressional_district/CT-01,Connecticut's 1st congressional district,congressional_district,state/ct,CT,Connecticut,901,congressional_district/CT-01 +congressional_district/CT-02,Connecticut's 2nd congressional district,congressional_district,state/ct,CT,Connecticut,902,congressional_district/CT-02 +congressional_district/CT-03,Connecticut's 3rd congressional district,congressional_district,state/ct,CT,Connecticut,903,congressional_district/CT-03 +congressional_district/CT-04,Connecticut's 4th congressional district,congressional_district,state/ct,CT,Connecticut,904,congressional_district/CT-04 +congressional_district/CT-05,Connecticut's 5th congressional district,congressional_district,state/ct,CT,Connecticut,905,congressional_district/CT-05 +congressional_district/DC-01,District of Columbia's at-large congressional district,congressional_district,state/dc,DC,District of Columbia,1100,congressional_district/DC-01 +congressional_district/DE-01,Delaware's at-large congressional district,congressional_district,state/de,DE,Delaware,1000,congressional_district/DE-01 +congressional_district/FL-01,Florida's 1st congressional district,congressional_district,state/fl,FL,Florida,1201,congressional_district/FL-01 +congressional_district/FL-02,Florida's 2nd congressional district,congressional_district,state/fl,FL,Florida,1202,congressional_district/FL-02 +congressional_district/FL-03,Florida's 3rd congressional district,congressional_district,state/fl,FL,Florida,1203,congressional_district/FL-03 +congressional_district/FL-04,Florida's 4th congressional district,congressional_district,state/fl,FL,Florida,1204,congressional_district/FL-04 +congressional_district/FL-05,Florida's 5th congressional district,congressional_district,state/fl,FL,Florida,1205,congressional_district/FL-05 +congressional_district/FL-06,Florida's 6th congressional district,congressional_district,state/fl,FL,Florida,1206,congressional_district/FL-06 +congressional_district/FL-07,Florida's 7th congressional district,congressional_district,state/fl,FL,Florida,1207,congressional_district/FL-07 +congressional_district/FL-08,Florida's 8th congressional district,congressional_district,state/fl,FL,Florida,1208,congressional_district/FL-08 +congressional_district/FL-09,Florida's 9th congressional district,congressional_district,state/fl,FL,Florida,1209,congressional_district/FL-09 +congressional_district/FL-10,Florida's 10th congressional district,congressional_district,state/fl,FL,Florida,1210,congressional_district/FL-10 +congressional_district/FL-11,Florida's 11th congressional district,congressional_district,state/fl,FL,Florida,1211,congressional_district/FL-11 +congressional_district/FL-12,Florida's 12th congressional district,congressional_district,state/fl,FL,Florida,1212,congressional_district/FL-12 +congressional_district/FL-13,Florida's 13th congressional district,congressional_district,state/fl,FL,Florida,1213,congressional_district/FL-13 +congressional_district/FL-14,Florida's 14th congressional district,congressional_district,state/fl,FL,Florida,1214,congressional_district/FL-14 +congressional_district/FL-15,Florida's 15th congressional district,congressional_district,state/fl,FL,Florida,1215,congressional_district/FL-15 +congressional_district/FL-16,Florida's 16th congressional district,congressional_district,state/fl,FL,Florida,1216,congressional_district/FL-16 +congressional_district/FL-17,Florida's 17th congressional district,congressional_district,state/fl,FL,Florida,1217,congressional_district/FL-17 +congressional_district/FL-18,Florida's 18th congressional district,congressional_district,state/fl,FL,Florida,1218,congressional_district/FL-18 +congressional_district/FL-19,Florida's 19th congressional district,congressional_district,state/fl,FL,Florida,1219,congressional_district/FL-19 +congressional_district/FL-20,Florida's 20th congressional district,congressional_district,state/fl,FL,Florida,1220,congressional_district/FL-20 +congressional_district/FL-21,Florida's 21st congressional district,congressional_district,state/fl,FL,Florida,1221,congressional_district/FL-21 +congressional_district/FL-22,Florida's 22nd congressional district,congressional_district,state/fl,FL,Florida,1222,congressional_district/FL-22 +congressional_district/FL-23,Florida's 23rd congressional district,congressional_district,state/fl,FL,Florida,1223,congressional_district/FL-23 +congressional_district/FL-24,Florida's 24th congressional district,congressional_district,state/fl,FL,Florida,1224,congressional_district/FL-24 +congressional_district/FL-25,Florida's 25th congressional district,congressional_district,state/fl,FL,Florida,1225,congressional_district/FL-25 +congressional_district/FL-26,Florida's 26th congressional district,congressional_district,state/fl,FL,Florida,1226,congressional_district/FL-26 +congressional_district/FL-27,Florida's 27th congressional district,congressional_district,state/fl,FL,Florida,1227,congressional_district/FL-27 +congressional_district/FL-28,Florida's 28th congressional district,congressional_district,state/fl,FL,Florida,1228,congressional_district/FL-28 +congressional_district/GA-01,Georgia's 1st congressional district,congressional_district,state/ga,GA,Georgia,1301,congressional_district/GA-01 +congressional_district/GA-02,Georgia's 2nd congressional district,congressional_district,state/ga,GA,Georgia,1302,congressional_district/GA-02 +congressional_district/GA-03,Georgia's 3rd congressional district,congressional_district,state/ga,GA,Georgia,1303,congressional_district/GA-03 +congressional_district/GA-04,Georgia's 4th congressional district,congressional_district,state/ga,GA,Georgia,1304,congressional_district/GA-04 +congressional_district/GA-05,Georgia's 5th congressional district,congressional_district,state/ga,GA,Georgia,1305,congressional_district/GA-05 +congressional_district/GA-06,Georgia's 6th congressional district,congressional_district,state/ga,GA,Georgia,1306,congressional_district/GA-06 +congressional_district/GA-07,Georgia's 7th congressional district,congressional_district,state/ga,GA,Georgia,1307,congressional_district/GA-07 +congressional_district/GA-08,Georgia's 8th congressional district,congressional_district,state/ga,GA,Georgia,1308,congressional_district/GA-08 +congressional_district/GA-09,Georgia's 9th congressional district,congressional_district,state/ga,GA,Georgia,1309,congressional_district/GA-09 +congressional_district/GA-10,Georgia's 10th congressional district,congressional_district,state/ga,GA,Georgia,1310,congressional_district/GA-10 +congressional_district/GA-11,Georgia's 11th congressional district,congressional_district,state/ga,GA,Georgia,1311,congressional_district/GA-11 +congressional_district/GA-12,Georgia's 12th congressional district,congressional_district,state/ga,GA,Georgia,1312,congressional_district/GA-12 +congressional_district/GA-13,Georgia's 13th congressional district,congressional_district,state/ga,GA,Georgia,1313,congressional_district/GA-13 +congressional_district/GA-14,Georgia's 14th congressional district,congressional_district,state/ga,GA,Georgia,1314,congressional_district/GA-14 +congressional_district/HI-01,Hawaii's 1st congressional district,congressional_district,state/hi,HI,Hawaii,1501,congressional_district/HI-01 +congressional_district/HI-02,Hawaii's 2nd congressional district,congressional_district,state/hi,HI,Hawaii,1502,congressional_district/HI-02 +congressional_district/IA-01,Iowa's 1st congressional district,congressional_district,state/ia,IA,Iowa,1901,congressional_district/IA-01 +congressional_district/IA-02,Iowa's 2nd congressional district,congressional_district,state/ia,IA,Iowa,1902,congressional_district/IA-02 +congressional_district/IA-03,Iowa's 3rd congressional district,congressional_district,state/ia,IA,Iowa,1903,congressional_district/IA-03 +congressional_district/IA-04,Iowa's 4th congressional district,congressional_district,state/ia,IA,Iowa,1904,congressional_district/IA-04 +congressional_district/ID-01,Idaho's 1st congressional district,congressional_district,state/id,ID,Idaho,1601,congressional_district/ID-01 +congressional_district/ID-02,Idaho's 2nd congressional district,congressional_district,state/id,ID,Idaho,1602,congressional_district/ID-02 +congressional_district/IL-01,Illinois's 1st congressional district,congressional_district,state/il,IL,Illinois,1701,congressional_district/IL-01 +congressional_district/IL-02,Illinois's 2nd congressional district,congressional_district,state/il,IL,Illinois,1702,congressional_district/IL-02 +congressional_district/IL-03,Illinois's 3rd congressional district,congressional_district,state/il,IL,Illinois,1703,congressional_district/IL-03 +congressional_district/IL-04,Illinois's 4th congressional district,congressional_district,state/il,IL,Illinois,1704,congressional_district/IL-04 +congressional_district/IL-05,Illinois's 5th congressional district,congressional_district,state/il,IL,Illinois,1705,congressional_district/IL-05 +congressional_district/IL-06,Illinois's 6th congressional district,congressional_district,state/il,IL,Illinois,1706,congressional_district/IL-06 +congressional_district/IL-07,Illinois's 7th congressional district,congressional_district,state/il,IL,Illinois,1707,congressional_district/IL-07 +congressional_district/IL-08,Illinois's 8th congressional district,congressional_district,state/il,IL,Illinois,1708,congressional_district/IL-08 +congressional_district/IL-09,Illinois's 9th congressional district,congressional_district,state/il,IL,Illinois,1709,congressional_district/IL-09 +congressional_district/IL-10,Illinois's 10th congressional district,congressional_district,state/il,IL,Illinois,1710,congressional_district/IL-10 +congressional_district/IL-11,Illinois's 11th congressional district,congressional_district,state/il,IL,Illinois,1711,congressional_district/IL-11 +congressional_district/IL-12,Illinois's 12th congressional district,congressional_district,state/il,IL,Illinois,1712,congressional_district/IL-12 +congressional_district/IL-13,Illinois's 13th congressional district,congressional_district,state/il,IL,Illinois,1713,congressional_district/IL-13 +congressional_district/IL-14,Illinois's 14th congressional district,congressional_district,state/il,IL,Illinois,1714,congressional_district/IL-14 +congressional_district/IL-15,Illinois's 15th congressional district,congressional_district,state/il,IL,Illinois,1715,congressional_district/IL-15 +congressional_district/IL-16,Illinois's 16th congressional district,congressional_district,state/il,IL,Illinois,1716,congressional_district/IL-16 +congressional_district/IL-17,Illinois's 17th congressional district,congressional_district,state/il,IL,Illinois,1717,congressional_district/IL-17 +congressional_district/IN-01,Indiana's 1st congressional district,congressional_district,state/in,IN,Indiana,1801,congressional_district/IN-01 +congressional_district/IN-02,Indiana's 2nd congressional district,congressional_district,state/in,IN,Indiana,1802,congressional_district/IN-02 +congressional_district/IN-03,Indiana's 3rd congressional district,congressional_district,state/in,IN,Indiana,1803,congressional_district/IN-03 +congressional_district/IN-04,Indiana's 4th congressional district,congressional_district,state/in,IN,Indiana,1804,congressional_district/IN-04 +congressional_district/IN-05,Indiana's 5th congressional district,congressional_district,state/in,IN,Indiana,1805,congressional_district/IN-05 +congressional_district/IN-06,Indiana's 6th congressional district,congressional_district,state/in,IN,Indiana,1806,congressional_district/IN-06 +congressional_district/IN-07,Indiana's 7th congressional district,congressional_district,state/in,IN,Indiana,1807,congressional_district/IN-07 +congressional_district/IN-08,Indiana's 8th congressional district,congressional_district,state/in,IN,Indiana,1808,congressional_district/IN-08 +congressional_district/IN-09,Indiana's 9th congressional district,congressional_district,state/in,IN,Indiana,1809,congressional_district/IN-09 +congressional_district/KS-01,Kansas's 1st congressional district,congressional_district,state/ks,KS,Kansas,2001,congressional_district/KS-01 +congressional_district/KS-02,Kansas's 2nd congressional district,congressional_district,state/ks,KS,Kansas,2002,congressional_district/KS-02 +congressional_district/KS-03,Kansas's 3rd congressional district,congressional_district,state/ks,KS,Kansas,2003,congressional_district/KS-03 +congressional_district/KS-04,Kansas's 4th congressional district,congressional_district,state/ks,KS,Kansas,2004,congressional_district/KS-04 +congressional_district/KY-01,Kentucky's 1st congressional district,congressional_district,state/ky,KY,Kentucky,2101,congressional_district/KY-01 +congressional_district/KY-02,Kentucky's 2nd congressional district,congressional_district,state/ky,KY,Kentucky,2102,congressional_district/KY-02 +congressional_district/KY-03,Kentucky's 3rd congressional district,congressional_district,state/ky,KY,Kentucky,2103,congressional_district/KY-03 +congressional_district/KY-04,Kentucky's 4th congressional district,congressional_district,state/ky,KY,Kentucky,2104,congressional_district/KY-04 +congressional_district/KY-05,Kentucky's 5th congressional district,congressional_district,state/ky,KY,Kentucky,2105,congressional_district/KY-05 +congressional_district/KY-06,Kentucky's 6th congressional district,congressional_district,state/ky,KY,Kentucky,2106,congressional_district/KY-06 +congressional_district/LA-01,Louisiana's 1st congressional district,congressional_district,state/la,LA,Louisiana,2201,congressional_district/LA-01 +congressional_district/LA-02,Louisiana's 2nd congressional district,congressional_district,state/la,LA,Louisiana,2202,congressional_district/LA-02 +congressional_district/LA-03,Louisiana's 3rd congressional district,congressional_district,state/la,LA,Louisiana,2203,congressional_district/LA-03 +congressional_district/LA-04,Louisiana's 4th congressional district,congressional_district,state/la,LA,Louisiana,2204,congressional_district/LA-04 +congressional_district/LA-05,Louisiana's 5th congressional district,congressional_district,state/la,LA,Louisiana,2205,congressional_district/LA-05 +congressional_district/LA-06,Louisiana's 6th congressional district,congressional_district,state/la,LA,Louisiana,2206,congressional_district/LA-06 +congressional_district/MA-01,Massachusetts's 1st congressional district,congressional_district,state/ma,MA,Massachusetts,2501,congressional_district/MA-01 +congressional_district/MA-02,Massachusetts's 2nd congressional district,congressional_district,state/ma,MA,Massachusetts,2502,congressional_district/MA-02 +congressional_district/MA-03,Massachusetts's 3rd congressional district,congressional_district,state/ma,MA,Massachusetts,2503,congressional_district/MA-03 +congressional_district/MA-04,Massachusetts's 4th congressional district,congressional_district,state/ma,MA,Massachusetts,2504,congressional_district/MA-04 +congressional_district/MA-05,Massachusetts's 5th congressional district,congressional_district,state/ma,MA,Massachusetts,2505,congressional_district/MA-05 +congressional_district/MA-06,Massachusetts's 6th congressional district,congressional_district,state/ma,MA,Massachusetts,2506,congressional_district/MA-06 +congressional_district/MA-07,Massachusetts's 7th congressional district,congressional_district,state/ma,MA,Massachusetts,2507,congressional_district/MA-07 +congressional_district/MA-08,Massachusetts's 8th congressional district,congressional_district,state/ma,MA,Massachusetts,2508,congressional_district/MA-08 +congressional_district/MA-09,Massachusetts's 9th congressional district,congressional_district,state/ma,MA,Massachusetts,2509,congressional_district/MA-09 +congressional_district/MD-01,Maryland's 1st congressional district,congressional_district,state/md,MD,Maryland,2401,congressional_district/MD-01 +congressional_district/MD-02,Maryland's 2nd congressional district,congressional_district,state/md,MD,Maryland,2402,congressional_district/MD-02 +congressional_district/MD-03,Maryland's 3rd congressional district,congressional_district,state/md,MD,Maryland,2403,congressional_district/MD-03 +congressional_district/MD-04,Maryland's 4th congressional district,congressional_district,state/md,MD,Maryland,2404,congressional_district/MD-04 +congressional_district/MD-05,Maryland's 5th congressional district,congressional_district,state/md,MD,Maryland,2405,congressional_district/MD-05 +congressional_district/MD-06,Maryland's 6th congressional district,congressional_district,state/md,MD,Maryland,2406,congressional_district/MD-06 +congressional_district/MD-07,Maryland's 7th congressional district,congressional_district,state/md,MD,Maryland,2407,congressional_district/MD-07 +congressional_district/MD-08,Maryland's 8th congressional district,congressional_district,state/md,MD,Maryland,2408,congressional_district/MD-08 +congressional_district/ME-01,Maine's 1st congressional district,congressional_district,state/me,ME,Maine,2301,congressional_district/ME-01 +congressional_district/ME-02,Maine's 2nd congressional district,congressional_district,state/me,ME,Maine,2302,congressional_district/ME-02 +congressional_district/MI-01,Michigan's 1st congressional district,congressional_district,state/mi,MI,Michigan,2601,congressional_district/MI-01 +congressional_district/MI-02,Michigan's 2nd congressional district,congressional_district,state/mi,MI,Michigan,2602,congressional_district/MI-02 +congressional_district/MI-03,Michigan's 3rd congressional district,congressional_district,state/mi,MI,Michigan,2603,congressional_district/MI-03 +congressional_district/MI-04,Michigan's 4th congressional district,congressional_district,state/mi,MI,Michigan,2604,congressional_district/MI-04 +congressional_district/MI-05,Michigan's 5th congressional district,congressional_district,state/mi,MI,Michigan,2605,congressional_district/MI-05 +congressional_district/MI-06,Michigan's 6th congressional district,congressional_district,state/mi,MI,Michigan,2606,congressional_district/MI-06 +congressional_district/MI-07,Michigan's 7th congressional district,congressional_district,state/mi,MI,Michigan,2607,congressional_district/MI-07 +congressional_district/MI-08,Michigan's 8th congressional district,congressional_district,state/mi,MI,Michigan,2608,congressional_district/MI-08 +congressional_district/MI-09,Michigan's 9th congressional district,congressional_district,state/mi,MI,Michigan,2609,congressional_district/MI-09 +congressional_district/MI-10,Michigan's 10th congressional district,congressional_district,state/mi,MI,Michigan,2610,congressional_district/MI-10 +congressional_district/MI-11,Michigan's 11th congressional district,congressional_district,state/mi,MI,Michigan,2611,congressional_district/MI-11 +congressional_district/MI-12,Michigan's 12th congressional district,congressional_district,state/mi,MI,Michigan,2612,congressional_district/MI-12 +congressional_district/MI-13,Michigan's 13th congressional district,congressional_district,state/mi,MI,Michigan,2613,congressional_district/MI-13 +congressional_district/MN-01,Minnesota's 1st congressional district,congressional_district,state/mn,MN,Minnesota,2701,congressional_district/MN-01 +congressional_district/MN-02,Minnesota's 2nd congressional district,congressional_district,state/mn,MN,Minnesota,2702,congressional_district/MN-02 +congressional_district/MN-03,Minnesota's 3rd congressional district,congressional_district,state/mn,MN,Minnesota,2703,congressional_district/MN-03 +congressional_district/MN-04,Minnesota's 4th congressional district,congressional_district,state/mn,MN,Minnesota,2704,congressional_district/MN-04 +congressional_district/MN-05,Minnesota's 5th congressional district,congressional_district,state/mn,MN,Minnesota,2705,congressional_district/MN-05 +congressional_district/MN-06,Minnesota's 6th congressional district,congressional_district,state/mn,MN,Minnesota,2706,congressional_district/MN-06 +congressional_district/MN-07,Minnesota's 7th congressional district,congressional_district,state/mn,MN,Minnesota,2707,congressional_district/MN-07 +congressional_district/MN-08,Minnesota's 8th congressional district,congressional_district,state/mn,MN,Minnesota,2708,congressional_district/MN-08 +congressional_district/MO-01,Missouri's 1st congressional district,congressional_district,state/mo,MO,Missouri,2901,congressional_district/MO-01 +congressional_district/MO-02,Missouri's 2nd congressional district,congressional_district,state/mo,MO,Missouri,2902,congressional_district/MO-02 +congressional_district/MO-03,Missouri's 3rd congressional district,congressional_district,state/mo,MO,Missouri,2903,congressional_district/MO-03 +congressional_district/MO-04,Missouri's 4th congressional district,congressional_district,state/mo,MO,Missouri,2904,congressional_district/MO-04 +congressional_district/MO-05,Missouri's 5th congressional district,congressional_district,state/mo,MO,Missouri,2905,congressional_district/MO-05 +congressional_district/MO-06,Missouri's 6th congressional district,congressional_district,state/mo,MO,Missouri,2906,congressional_district/MO-06 +congressional_district/MO-07,Missouri's 7th congressional district,congressional_district,state/mo,MO,Missouri,2907,congressional_district/MO-07 +congressional_district/MO-08,Missouri's 8th congressional district,congressional_district,state/mo,MO,Missouri,2908,congressional_district/MO-08 +congressional_district/MS-01,Mississippi's 1st congressional district,congressional_district,state/ms,MS,Mississippi,2801,congressional_district/MS-01 +congressional_district/MS-02,Mississippi's 2nd congressional district,congressional_district,state/ms,MS,Mississippi,2802,congressional_district/MS-02 +congressional_district/MS-03,Mississippi's 3rd congressional district,congressional_district,state/ms,MS,Mississippi,2803,congressional_district/MS-03 +congressional_district/MS-04,Mississippi's 4th congressional district,congressional_district,state/ms,MS,Mississippi,2804,congressional_district/MS-04 +congressional_district/MT-01,Montana's 1st congressional district,congressional_district,state/mt,MT,Montana,3001,congressional_district/MT-01 +congressional_district/MT-02,Montana's 2nd congressional district,congressional_district,state/mt,MT,Montana,3002,congressional_district/MT-02 +congressional_district/NC-01,North Carolina's 1st congressional district,congressional_district,state/nc,NC,North Carolina,3701,congressional_district/NC-01 +congressional_district/NC-02,North Carolina's 2nd congressional district,congressional_district,state/nc,NC,North Carolina,3702,congressional_district/NC-02 +congressional_district/NC-03,North Carolina's 3rd congressional district,congressional_district,state/nc,NC,North Carolina,3703,congressional_district/NC-03 +congressional_district/NC-04,North Carolina's 4th congressional district,congressional_district,state/nc,NC,North Carolina,3704,congressional_district/NC-04 +congressional_district/NC-05,North Carolina's 5th congressional district,congressional_district,state/nc,NC,North Carolina,3705,congressional_district/NC-05 +congressional_district/NC-06,North Carolina's 6th congressional district,congressional_district,state/nc,NC,North Carolina,3706,congressional_district/NC-06 +congressional_district/NC-07,North Carolina's 7th congressional district,congressional_district,state/nc,NC,North Carolina,3707,congressional_district/NC-07 +congressional_district/NC-08,North Carolina's 8th congressional district,congressional_district,state/nc,NC,North Carolina,3708,congressional_district/NC-08 +congressional_district/NC-09,North Carolina's 9th congressional district,congressional_district,state/nc,NC,North Carolina,3709,congressional_district/NC-09 +congressional_district/NC-10,North Carolina's 10th congressional district,congressional_district,state/nc,NC,North Carolina,3710,congressional_district/NC-10 +congressional_district/NC-11,North Carolina's 11th congressional district,congressional_district,state/nc,NC,North Carolina,3711,congressional_district/NC-11 +congressional_district/NC-12,North Carolina's 12th congressional district,congressional_district,state/nc,NC,North Carolina,3712,congressional_district/NC-12 +congressional_district/NC-13,North Carolina's 13th congressional district,congressional_district,state/nc,NC,North Carolina,3713,congressional_district/NC-13 +congressional_district/NC-14,North Carolina's 14th congressional district,congressional_district,state/nc,NC,North Carolina,3714,congressional_district/NC-14 +congressional_district/ND-01,North Dakota's at-large congressional district,congressional_district,state/nd,ND,North Dakota,3800,congressional_district/ND-01 +congressional_district/NE-01,Nebraska's 1st congressional district,congressional_district,state/ne,NE,Nebraska,3101,congressional_district/NE-01 +congressional_district/NE-02,Nebraska's 2nd congressional district,congressional_district,state/ne,NE,Nebraska,3102,congressional_district/NE-02 +congressional_district/NE-03,Nebraska's 3rd congressional district,congressional_district,state/ne,NE,Nebraska,3103,congressional_district/NE-03 +congressional_district/NH-01,New Hampshire's 1st congressional district,congressional_district,state/nh,NH,New Hampshire,3301,congressional_district/NH-01 +congressional_district/NH-02,New Hampshire's 2nd congressional district,congressional_district,state/nh,NH,New Hampshire,3302,congressional_district/NH-02 +congressional_district/NJ-01,New Jersey's 1st congressional district,congressional_district,state/nj,NJ,New Jersey,3401,congressional_district/NJ-01 +congressional_district/NJ-02,New Jersey's 2nd congressional district,congressional_district,state/nj,NJ,New Jersey,3402,congressional_district/NJ-02 +congressional_district/NJ-03,New Jersey's 3rd congressional district,congressional_district,state/nj,NJ,New Jersey,3403,congressional_district/NJ-03 +congressional_district/NJ-04,New Jersey's 4th congressional district,congressional_district,state/nj,NJ,New Jersey,3404,congressional_district/NJ-04 +congressional_district/NJ-05,New Jersey's 5th congressional district,congressional_district,state/nj,NJ,New Jersey,3405,congressional_district/NJ-05 +congressional_district/NJ-06,New Jersey's 6th congressional district,congressional_district,state/nj,NJ,New Jersey,3406,congressional_district/NJ-06 +congressional_district/NJ-07,New Jersey's 7th congressional district,congressional_district,state/nj,NJ,New Jersey,3407,congressional_district/NJ-07 +congressional_district/NJ-08,New Jersey's 8th congressional district,congressional_district,state/nj,NJ,New Jersey,3408,congressional_district/NJ-08 +congressional_district/NJ-09,New Jersey's 9th congressional district,congressional_district,state/nj,NJ,New Jersey,3409,congressional_district/NJ-09 +congressional_district/NJ-10,New Jersey's 10th congressional district,congressional_district,state/nj,NJ,New Jersey,3410,congressional_district/NJ-10 +congressional_district/NJ-11,New Jersey's 11th congressional district,congressional_district,state/nj,NJ,New Jersey,3411,congressional_district/NJ-11 +congressional_district/NJ-12,New Jersey's 12th congressional district,congressional_district,state/nj,NJ,New Jersey,3412,congressional_district/NJ-12 +congressional_district/NM-01,New Mexico's 1st congressional district,congressional_district,state/nm,NM,New Mexico,3501,congressional_district/NM-01 +congressional_district/NM-02,New Mexico's 2nd congressional district,congressional_district,state/nm,NM,New Mexico,3502,congressional_district/NM-02 +congressional_district/NM-03,New Mexico's 3rd congressional district,congressional_district,state/nm,NM,New Mexico,3503,congressional_district/NM-03 +congressional_district/NV-01,Nevada's 1st congressional district,congressional_district,state/nv,NV,Nevada,3201,congressional_district/NV-01 +congressional_district/NV-02,Nevada's 2nd congressional district,congressional_district,state/nv,NV,Nevada,3202,congressional_district/NV-02 +congressional_district/NV-03,Nevada's 3rd congressional district,congressional_district,state/nv,NV,Nevada,3203,congressional_district/NV-03 +congressional_district/NV-04,Nevada's 4th congressional district,congressional_district,state/nv,NV,Nevada,3204,congressional_district/NV-04 +congressional_district/NY-01,New York's 1st congressional district,congressional_district,state/ny,NY,New York,3601,congressional_district/NY-01 +congressional_district/NY-02,New York's 2nd congressional district,congressional_district,state/ny,NY,New York,3602,congressional_district/NY-02 +congressional_district/NY-03,New York's 3rd congressional district,congressional_district,state/ny,NY,New York,3603,congressional_district/NY-03 +congressional_district/NY-04,New York's 4th congressional district,congressional_district,state/ny,NY,New York,3604,congressional_district/NY-04 +congressional_district/NY-05,New York's 5th congressional district,congressional_district,state/ny,NY,New York,3605,congressional_district/NY-05 +congressional_district/NY-06,New York's 6th congressional district,congressional_district,state/ny,NY,New York,3606,congressional_district/NY-06 +congressional_district/NY-07,New York's 7th congressional district,congressional_district,state/ny,NY,New York,3607,congressional_district/NY-07 +congressional_district/NY-08,New York's 8th congressional district,congressional_district,state/ny,NY,New York,3608,congressional_district/NY-08 +congressional_district/NY-09,New York's 9th congressional district,congressional_district,state/ny,NY,New York,3609,congressional_district/NY-09 +congressional_district/NY-10,New York's 10th congressional district,congressional_district,state/ny,NY,New York,3610,congressional_district/NY-10 +congressional_district/NY-11,New York's 11th congressional district,congressional_district,state/ny,NY,New York,3611,congressional_district/NY-11 +congressional_district/NY-12,New York's 12th congressional district,congressional_district,state/ny,NY,New York,3612,congressional_district/NY-12 +congressional_district/NY-13,New York's 13th congressional district,congressional_district,state/ny,NY,New York,3613,congressional_district/NY-13 +congressional_district/NY-14,New York's 14th congressional district,congressional_district,state/ny,NY,New York,3614,congressional_district/NY-14 +congressional_district/NY-15,New York's 15th congressional district,congressional_district,state/ny,NY,New York,3615,congressional_district/NY-15 +congressional_district/NY-16,New York's 16th congressional district,congressional_district,state/ny,NY,New York,3616,congressional_district/NY-16 +congressional_district/NY-17,New York's 17th congressional district,congressional_district,state/ny,NY,New York,3617,congressional_district/NY-17 +congressional_district/NY-18,New York's 18th congressional district,congressional_district,state/ny,NY,New York,3618,congressional_district/NY-18 +congressional_district/NY-19,New York's 19th congressional district,congressional_district,state/ny,NY,New York,3619,congressional_district/NY-19 +congressional_district/NY-20,New York's 20th congressional district,congressional_district,state/ny,NY,New York,3620,congressional_district/NY-20 +congressional_district/NY-21,New York's 21st congressional district,congressional_district,state/ny,NY,New York,3621,congressional_district/NY-21 +congressional_district/NY-22,New York's 22nd congressional district,congressional_district,state/ny,NY,New York,3622,congressional_district/NY-22 +congressional_district/NY-23,New York's 23rd congressional district,congressional_district,state/ny,NY,New York,3623,congressional_district/NY-23 +congressional_district/NY-24,New York's 24th congressional district,congressional_district,state/ny,NY,New York,3624,congressional_district/NY-24 +congressional_district/NY-25,New York's 25th congressional district,congressional_district,state/ny,NY,New York,3625,congressional_district/NY-25 +congressional_district/NY-26,New York's 26th congressional district,congressional_district,state/ny,NY,New York,3626,congressional_district/NY-26 +congressional_district/OH-01,Ohio's 1st congressional district,congressional_district,state/oh,OH,Ohio,3901,congressional_district/OH-01 +congressional_district/OH-02,Ohio's 2nd congressional district,congressional_district,state/oh,OH,Ohio,3902,congressional_district/OH-02 +congressional_district/OH-03,Ohio's 3rd congressional district,congressional_district,state/oh,OH,Ohio,3903,congressional_district/OH-03 +congressional_district/OH-04,Ohio's 4th congressional district,congressional_district,state/oh,OH,Ohio,3904,congressional_district/OH-04 +congressional_district/OH-05,Ohio's 5th congressional district,congressional_district,state/oh,OH,Ohio,3905,congressional_district/OH-05 +congressional_district/OH-06,Ohio's 6th congressional district,congressional_district,state/oh,OH,Ohio,3906,congressional_district/OH-06 +congressional_district/OH-07,Ohio's 7th congressional district,congressional_district,state/oh,OH,Ohio,3907,congressional_district/OH-07 +congressional_district/OH-08,Ohio's 8th congressional district,congressional_district,state/oh,OH,Ohio,3908,congressional_district/OH-08 +congressional_district/OH-09,Ohio's 9th congressional district,congressional_district,state/oh,OH,Ohio,3909,congressional_district/OH-09 +congressional_district/OH-10,Ohio's 10th congressional district,congressional_district,state/oh,OH,Ohio,3910,congressional_district/OH-10 +congressional_district/OH-11,Ohio's 11th congressional district,congressional_district,state/oh,OH,Ohio,3911,congressional_district/OH-11 +congressional_district/OH-12,Ohio's 12th congressional district,congressional_district,state/oh,OH,Ohio,3912,congressional_district/OH-12 +congressional_district/OH-13,Ohio's 13th congressional district,congressional_district,state/oh,OH,Ohio,3913,congressional_district/OH-13 +congressional_district/OH-14,Ohio's 14th congressional district,congressional_district,state/oh,OH,Ohio,3914,congressional_district/OH-14 +congressional_district/OH-15,Ohio's 15th congressional district,congressional_district,state/oh,OH,Ohio,3915,congressional_district/OH-15 +congressional_district/OK-01,Oklahoma's 1st congressional district,congressional_district,state/ok,OK,Oklahoma,4001,congressional_district/OK-01 +congressional_district/OK-02,Oklahoma's 2nd congressional district,congressional_district,state/ok,OK,Oklahoma,4002,congressional_district/OK-02 +congressional_district/OK-03,Oklahoma's 3rd congressional district,congressional_district,state/ok,OK,Oklahoma,4003,congressional_district/OK-03 +congressional_district/OK-04,Oklahoma's 4th congressional district,congressional_district,state/ok,OK,Oklahoma,4004,congressional_district/OK-04 +congressional_district/OK-05,Oklahoma's 5th congressional district,congressional_district,state/ok,OK,Oklahoma,4005,congressional_district/OK-05 +congressional_district/OR-01,Oregon's 1st congressional district,congressional_district,state/or,OR,Oregon,4101,congressional_district/OR-01 +congressional_district/OR-02,Oregon's 2nd congressional district,congressional_district,state/or,OR,Oregon,4102,congressional_district/OR-02 +congressional_district/OR-03,Oregon's 3rd congressional district,congressional_district,state/or,OR,Oregon,4103,congressional_district/OR-03 +congressional_district/OR-04,Oregon's 4th congressional district,congressional_district,state/or,OR,Oregon,4104,congressional_district/OR-04 +congressional_district/OR-05,Oregon's 5th congressional district,congressional_district,state/or,OR,Oregon,4105,congressional_district/OR-05 +congressional_district/OR-06,Oregon's 6th congressional district,congressional_district,state/or,OR,Oregon,4106,congressional_district/OR-06 +congressional_district/PA-01,Pennsylvania's 1st congressional district,congressional_district,state/pa,PA,Pennsylvania,4201,congressional_district/PA-01 +congressional_district/PA-02,Pennsylvania's 2nd congressional district,congressional_district,state/pa,PA,Pennsylvania,4202,congressional_district/PA-02 +congressional_district/PA-03,Pennsylvania's 3rd congressional district,congressional_district,state/pa,PA,Pennsylvania,4203,congressional_district/PA-03 +congressional_district/PA-04,Pennsylvania's 4th congressional district,congressional_district,state/pa,PA,Pennsylvania,4204,congressional_district/PA-04 +congressional_district/PA-05,Pennsylvania's 5th congressional district,congressional_district,state/pa,PA,Pennsylvania,4205,congressional_district/PA-05 +congressional_district/PA-06,Pennsylvania's 6th congressional district,congressional_district,state/pa,PA,Pennsylvania,4206,congressional_district/PA-06 +congressional_district/PA-07,Pennsylvania's 7th congressional district,congressional_district,state/pa,PA,Pennsylvania,4207,congressional_district/PA-07 +congressional_district/PA-08,Pennsylvania's 8th congressional district,congressional_district,state/pa,PA,Pennsylvania,4208,congressional_district/PA-08 +congressional_district/PA-09,Pennsylvania's 9th congressional district,congressional_district,state/pa,PA,Pennsylvania,4209,congressional_district/PA-09 +congressional_district/PA-10,Pennsylvania's 10th congressional district,congressional_district,state/pa,PA,Pennsylvania,4210,congressional_district/PA-10 +congressional_district/PA-11,Pennsylvania's 11th congressional district,congressional_district,state/pa,PA,Pennsylvania,4211,congressional_district/PA-11 +congressional_district/PA-12,Pennsylvania's 12th congressional district,congressional_district,state/pa,PA,Pennsylvania,4212,congressional_district/PA-12 +congressional_district/PA-13,Pennsylvania's 13th congressional district,congressional_district,state/pa,PA,Pennsylvania,4213,congressional_district/PA-13 +congressional_district/PA-14,Pennsylvania's 14th congressional district,congressional_district,state/pa,PA,Pennsylvania,4214,congressional_district/PA-14 +congressional_district/PA-15,Pennsylvania's 15th congressional district,congressional_district,state/pa,PA,Pennsylvania,4215,congressional_district/PA-15 +congressional_district/PA-16,Pennsylvania's 16th congressional district,congressional_district,state/pa,PA,Pennsylvania,4216,congressional_district/PA-16 +congressional_district/PA-17,Pennsylvania's 17th congressional district,congressional_district,state/pa,PA,Pennsylvania,4217,congressional_district/PA-17 +congressional_district/RI-01,Rhode Island's 1st congressional district,congressional_district,state/ri,RI,Rhode Island,4401,congressional_district/RI-01 +congressional_district/RI-02,Rhode Island's 2nd congressional district,congressional_district,state/ri,RI,Rhode Island,4402,congressional_district/RI-02 +congressional_district/SC-01,South Carolina's 1st congressional district,congressional_district,state/sc,SC,South Carolina,4501,congressional_district/SC-01 +congressional_district/SC-02,South Carolina's 2nd congressional district,congressional_district,state/sc,SC,South Carolina,4502,congressional_district/SC-02 +congressional_district/SC-03,South Carolina's 3rd congressional district,congressional_district,state/sc,SC,South Carolina,4503,congressional_district/SC-03 +congressional_district/SC-04,South Carolina's 4th congressional district,congressional_district,state/sc,SC,South Carolina,4504,congressional_district/SC-04 +congressional_district/SC-05,South Carolina's 5th congressional district,congressional_district,state/sc,SC,South Carolina,4505,congressional_district/SC-05 +congressional_district/SC-06,South Carolina's 6th congressional district,congressional_district,state/sc,SC,South Carolina,4506,congressional_district/SC-06 +congressional_district/SC-07,South Carolina's 7th congressional district,congressional_district,state/sc,SC,South Carolina,4507,congressional_district/SC-07 +congressional_district/SD-01,South Dakota's at-large congressional district,congressional_district,state/sd,SD,South Dakota,4600,congressional_district/SD-01 +congressional_district/TN-01,Tennessee's 1st congressional district,congressional_district,state/tn,TN,Tennessee,4701,congressional_district/TN-01 +congressional_district/TN-02,Tennessee's 2nd congressional district,congressional_district,state/tn,TN,Tennessee,4702,congressional_district/TN-02 +congressional_district/TN-03,Tennessee's 3rd congressional district,congressional_district,state/tn,TN,Tennessee,4703,congressional_district/TN-03 +congressional_district/TN-04,Tennessee's 4th congressional district,congressional_district,state/tn,TN,Tennessee,4704,congressional_district/TN-04 +congressional_district/TN-05,Tennessee's 5th congressional district,congressional_district,state/tn,TN,Tennessee,4705,congressional_district/TN-05 +congressional_district/TN-06,Tennessee's 6th congressional district,congressional_district,state/tn,TN,Tennessee,4706,congressional_district/TN-06 +congressional_district/TN-07,Tennessee's 7th congressional district,congressional_district,state/tn,TN,Tennessee,4707,congressional_district/TN-07 +congressional_district/TN-08,Tennessee's 8th congressional district,congressional_district,state/tn,TN,Tennessee,4708,congressional_district/TN-08 +congressional_district/TN-09,Tennessee's 9th congressional district,congressional_district,state/tn,TN,Tennessee,4709,congressional_district/TN-09 +congressional_district/TX-01,Texas's 1st congressional district,congressional_district,state/tx,TX,Texas,4801,congressional_district/TX-01 +congressional_district/TX-02,Texas's 2nd congressional district,congressional_district,state/tx,TX,Texas,4802,congressional_district/TX-02 +congressional_district/TX-03,Texas's 3rd congressional district,congressional_district,state/tx,TX,Texas,4803,congressional_district/TX-03 +congressional_district/TX-04,Texas's 4th congressional district,congressional_district,state/tx,TX,Texas,4804,congressional_district/TX-04 +congressional_district/TX-05,Texas's 5th congressional district,congressional_district,state/tx,TX,Texas,4805,congressional_district/TX-05 +congressional_district/TX-06,Texas's 6th congressional district,congressional_district,state/tx,TX,Texas,4806,congressional_district/TX-06 +congressional_district/TX-07,Texas's 7th congressional district,congressional_district,state/tx,TX,Texas,4807,congressional_district/TX-07 +congressional_district/TX-08,Texas's 8th congressional district,congressional_district,state/tx,TX,Texas,4808,congressional_district/TX-08 +congressional_district/TX-09,Texas's 9th congressional district,congressional_district,state/tx,TX,Texas,4809,congressional_district/TX-09 +congressional_district/TX-10,Texas's 10th congressional district,congressional_district,state/tx,TX,Texas,4810,congressional_district/TX-10 +congressional_district/TX-11,Texas's 11th congressional district,congressional_district,state/tx,TX,Texas,4811,congressional_district/TX-11 +congressional_district/TX-12,Texas's 12th congressional district,congressional_district,state/tx,TX,Texas,4812,congressional_district/TX-12 +congressional_district/TX-13,Texas's 13th congressional district,congressional_district,state/tx,TX,Texas,4813,congressional_district/TX-13 +congressional_district/TX-14,Texas's 14th congressional district,congressional_district,state/tx,TX,Texas,4814,congressional_district/TX-14 +congressional_district/TX-15,Texas's 15th congressional district,congressional_district,state/tx,TX,Texas,4815,congressional_district/TX-15 +congressional_district/TX-16,Texas's 16th congressional district,congressional_district,state/tx,TX,Texas,4816,congressional_district/TX-16 +congressional_district/TX-17,Texas's 17th congressional district,congressional_district,state/tx,TX,Texas,4817,congressional_district/TX-17 +congressional_district/TX-18,Texas's 18th congressional district,congressional_district,state/tx,TX,Texas,4818,congressional_district/TX-18 +congressional_district/TX-19,Texas's 19th congressional district,congressional_district,state/tx,TX,Texas,4819,congressional_district/TX-19 +congressional_district/TX-20,Texas's 20th congressional district,congressional_district,state/tx,TX,Texas,4820,congressional_district/TX-20 +congressional_district/TX-21,Texas's 21st congressional district,congressional_district,state/tx,TX,Texas,4821,congressional_district/TX-21 +congressional_district/TX-22,Texas's 22nd congressional district,congressional_district,state/tx,TX,Texas,4822,congressional_district/TX-22 +congressional_district/TX-23,Texas's 23rd congressional district,congressional_district,state/tx,TX,Texas,4823,congressional_district/TX-23 +congressional_district/TX-24,Texas's 24th congressional district,congressional_district,state/tx,TX,Texas,4824,congressional_district/TX-24 +congressional_district/TX-25,Texas's 25th congressional district,congressional_district,state/tx,TX,Texas,4825,congressional_district/TX-25 +congressional_district/TX-26,Texas's 26th congressional district,congressional_district,state/tx,TX,Texas,4826,congressional_district/TX-26 +congressional_district/TX-27,Texas's 27th congressional district,congressional_district,state/tx,TX,Texas,4827,congressional_district/TX-27 +congressional_district/TX-28,Texas's 28th congressional district,congressional_district,state/tx,TX,Texas,4828,congressional_district/TX-28 +congressional_district/TX-29,Texas's 29th congressional district,congressional_district,state/tx,TX,Texas,4829,congressional_district/TX-29 +congressional_district/TX-30,Texas's 30th congressional district,congressional_district,state/tx,TX,Texas,4830,congressional_district/TX-30 +congressional_district/TX-31,Texas's 31st congressional district,congressional_district,state/tx,TX,Texas,4831,congressional_district/TX-31 +congressional_district/TX-32,Texas's 32nd congressional district,congressional_district,state/tx,TX,Texas,4832,congressional_district/TX-32 +congressional_district/TX-33,Texas's 33rd congressional district,congressional_district,state/tx,TX,Texas,4833,congressional_district/TX-33 +congressional_district/TX-34,Texas's 34th congressional district,congressional_district,state/tx,TX,Texas,4834,congressional_district/TX-34 +congressional_district/TX-35,Texas's 35th congressional district,congressional_district,state/tx,TX,Texas,4835,congressional_district/TX-35 +congressional_district/TX-36,Texas's 36th congressional district,congressional_district,state/tx,TX,Texas,4836,congressional_district/TX-36 +congressional_district/TX-37,Texas's 37th congressional district,congressional_district,state/tx,TX,Texas,4837,congressional_district/TX-37 +congressional_district/TX-38,Texas's 38th congressional district,congressional_district,state/tx,TX,Texas,4838,congressional_district/TX-38 +congressional_district/UT-01,Utah's 1st congressional district,congressional_district,state/ut,UT,Utah,4901,congressional_district/UT-01 +congressional_district/UT-02,Utah's 2nd congressional district,congressional_district,state/ut,UT,Utah,4902,congressional_district/UT-02 +congressional_district/UT-03,Utah's 3rd congressional district,congressional_district,state/ut,UT,Utah,4903,congressional_district/UT-03 +congressional_district/UT-04,Utah's 4th congressional district,congressional_district,state/ut,UT,Utah,4904,congressional_district/UT-04 +congressional_district/VA-01,Virginia's 1st congressional district,congressional_district,state/va,VA,Virginia,5101,congressional_district/VA-01 +congressional_district/VA-02,Virginia's 2nd congressional district,congressional_district,state/va,VA,Virginia,5102,congressional_district/VA-02 +congressional_district/VA-03,Virginia's 3rd congressional district,congressional_district,state/va,VA,Virginia,5103,congressional_district/VA-03 +congressional_district/VA-04,Virginia's 4th congressional district,congressional_district,state/va,VA,Virginia,5104,congressional_district/VA-04 +congressional_district/VA-05,Virginia's 5th congressional district,congressional_district,state/va,VA,Virginia,5105,congressional_district/VA-05 +congressional_district/VA-06,Virginia's 6th congressional district,congressional_district,state/va,VA,Virginia,5106,congressional_district/VA-06 +congressional_district/VA-07,Virginia's 7th congressional district,congressional_district,state/va,VA,Virginia,5107,congressional_district/VA-07 +congressional_district/VA-08,Virginia's 8th congressional district,congressional_district,state/va,VA,Virginia,5108,congressional_district/VA-08 +congressional_district/VA-09,Virginia's 9th congressional district,congressional_district,state/va,VA,Virginia,5109,congressional_district/VA-09 +congressional_district/VA-10,Virginia's 10th congressional district,congressional_district,state/va,VA,Virginia,5110,congressional_district/VA-10 +congressional_district/VA-11,Virginia's 11th congressional district,congressional_district,state/va,VA,Virginia,5111,congressional_district/VA-11 +congressional_district/VT-01,Vermont's at-large congressional district,congressional_district,state/vt,VT,Vermont,5000,congressional_district/VT-01 +congressional_district/WA-01,Washington's 1st congressional district,congressional_district,state/wa,WA,Washington,5301,congressional_district/WA-01 +congressional_district/WA-02,Washington's 2nd congressional district,congressional_district,state/wa,WA,Washington,5302,congressional_district/WA-02 +congressional_district/WA-03,Washington's 3rd congressional district,congressional_district,state/wa,WA,Washington,5303,congressional_district/WA-03 +congressional_district/WA-04,Washington's 4th congressional district,congressional_district,state/wa,WA,Washington,5304,congressional_district/WA-04 +congressional_district/WA-05,Washington's 5th congressional district,congressional_district,state/wa,WA,Washington,5305,congressional_district/WA-05 +congressional_district/WA-06,Washington's 6th congressional district,congressional_district,state/wa,WA,Washington,5306,congressional_district/WA-06 +congressional_district/WA-07,Washington's 7th congressional district,congressional_district,state/wa,WA,Washington,5307,congressional_district/WA-07 +congressional_district/WA-08,Washington's 8th congressional district,congressional_district,state/wa,WA,Washington,5308,congressional_district/WA-08 +congressional_district/WA-09,Washington's 9th congressional district,congressional_district,state/wa,WA,Washington,5309,congressional_district/WA-09 +congressional_district/WA-10,Washington's 10th congressional district,congressional_district,state/wa,WA,Washington,5310,congressional_district/WA-10 +congressional_district/WI-01,Wisconsin's 1st congressional district,congressional_district,state/wi,WI,Wisconsin,5501,congressional_district/WI-01 +congressional_district/WI-02,Wisconsin's 2nd congressional district,congressional_district,state/wi,WI,Wisconsin,5502,congressional_district/WI-02 +congressional_district/WI-03,Wisconsin's 3rd congressional district,congressional_district,state/wi,WI,Wisconsin,5503,congressional_district/WI-03 +congressional_district/WI-04,Wisconsin's 4th congressional district,congressional_district,state/wi,WI,Wisconsin,5504,congressional_district/WI-04 +congressional_district/WI-05,Wisconsin's 5th congressional district,congressional_district,state/wi,WI,Wisconsin,5505,congressional_district/WI-05 +congressional_district/WI-06,Wisconsin's 6th congressional district,congressional_district,state/wi,WI,Wisconsin,5506,congressional_district/WI-06 +congressional_district/WI-07,Wisconsin's 7th congressional district,congressional_district,state/wi,WI,Wisconsin,5507,congressional_district/WI-07 +congressional_district/WI-08,Wisconsin's 8th congressional district,congressional_district,state/wi,WI,Wisconsin,5508,congressional_district/WI-08 +congressional_district/WV-01,West Virginia's 1st congressional district,congressional_district,state/wv,WV,West Virginia,5401,congressional_district/WV-01 +congressional_district/WV-02,West Virginia's 2nd congressional district,congressional_district,state/wv,WV,West Virginia,5402,congressional_district/WV-02 +congressional_district/WY-01,Wyoming's at-large congressional district,congressional_district,state/wy,WY,Wyoming,5600,congressional_district/WY-01 +place/AK-03000,Anchorage,place,state/ak,AK,Alaska,,place/AK-03000 +place/AL-07000,Birmingham,place,state/al,AL,Alabama,,place/AL-07000 +place/AL-37000,Huntsville,place,state/al,AL,Alabama,,place/AL-37000 +place/AL-50000,Mobile,place,state/al,AL,Alabama,,place/AL-50000 +place/AL-51000,Montgomery,place,state/al,AL,Alabama,,place/AL-51000 +place/AL-77256,Tuscaloosa,place,state/al,AL,Alabama,,place/AL-77256 +place/AR-23290,Fayetteville,place,state/ar,AR,Arkansas,,place/AR-23290 +place/AR-41000,Little Rock,place,state/ar,AR,Arkansas,,place/AR-41000 +place/AZ-07940,Buckeye,place,state/az,AZ,Arizona,,place/AZ-07940 +place/AZ-12000,Chandler,place,state/az,AZ,Arizona,,place/AZ-12000 +place/AZ-27400,Gilbert,place,state/az,AZ,Arizona,,place/AZ-27400 +place/AZ-27820,Glendale,place,state/az,AZ,Arizona,,place/AZ-27820 +place/AZ-28380,Goodyear,place,state/az,AZ,Arizona,,place/AZ-28380 +place/AZ-46000,Mesa,place,state/az,AZ,Arizona,,place/AZ-46000 +place/AZ-54050,Peoria,place,state/az,AZ,Arizona,,place/AZ-54050 +place/AZ-55000,Phoenix,place,state/az,AZ,Arizona,,place/AZ-55000 +place/AZ-65000,Scottsdale,place,state/az,AZ,Arizona,,place/AZ-65000 +place/AZ-71510,Surprise,place,state/az,AZ,Arizona,,place/AZ-71510 +place/AZ-73000,Tempe,place,state/az,AZ,Arizona,,place/AZ-73000 +place/AZ-77000,Tucson,place,state/az,AZ,Arizona,,place/AZ-77000 +place/AZ-85540,Yuma,place,state/az,AZ,Arizona,,place/AZ-85540 +place/CA-02000,Anaheim,place,state/ca,CA,California,,place/CA-02000 +place/CA-02252,Antioch,place,state/ca,CA,California,,place/CA-02252 +place/CA-03526,Bakersfield,place,state/ca,CA,California,,place/CA-03526 +place/CA-06000,Berkeley,place,state/ca,CA,California,,place/CA-06000 +place/CA-08954,Burbank,place,state/ca,CA,California,,place/CA-08954 +place/CA-11194,Carlsbad,place,state/ca,CA,California,,place/CA-11194 +place/CA-13014,Chico,place,state/ca,CA,California,,place/CA-13014 +place/CA-13392,Chula Vista,place,state/ca,CA,California,,place/CA-13392 +place/CA-14218,Clovis,place,state/ca,CA,California,,place/CA-14218 +place/CA-16000,Concord,place,state/ca,CA,California,,place/CA-16000 +place/CA-16350,Corona,place,state/ca,CA,California,,place/CA-16350 +place/CA-16532,Costa Mesa,place,state/ca,CA,California,,place/CA-16532 +place/CA-19766,Downey,place,state/ca,CA,California,,place/CA-19766 +place/CA-21712,El Cajon,place,state/ca,CA,California,,place/CA-21712 +place/CA-22020,Elk Grove,place,state/ca,CA,California,,place/CA-22020 +place/CA-22230,El Monte,place,state/ca,CA,California,,place/CA-22230 +place/CA-22804,Escondido,place,state/ca,CA,California,,place/CA-22804 +place/CA-23182,Fairfield,place,state/ca,CA,California,,place/CA-23182 +place/CA-24680,Fontana,place,state/ca,CA,California,,place/CA-24680 +place/CA-26000,Fremont,place,state/ca,CA,California,,place/CA-26000 +place/CA-27000,Fresno,place,state/ca,CA,California,,place/CA-27000 +place/CA-28000,Fullerton,place,state/ca,CA,California,,place/CA-28000 +place/CA-29000,Garden Grove,place,state/ca,CA,California,,place/CA-29000 +place/CA-30000,Glendale,place,state/ca,CA,California,,place/CA-30000 +place/CA-33000,Hayward,place,state/ca,CA,California,,place/CA-33000 +place/CA-33434,Hesperia,place,state/ca,CA,California,,place/CA-33434 +place/CA-36000,Huntington Beach,place,state/ca,CA,California,,place/CA-36000 +place/CA-36546,Inglewood,place,state/ca,CA,California,,place/CA-36546 +place/CA-36770,Irvine,place,state/ca,CA,California,,place/CA-36770 +place/CA-37692,Jurupa Valley,place,state/ca,CA,California,,place/CA-37692 +place/CA-40130,Lancaster,place,state/ca,CA,California,,place/CA-40130 +place/CA-43000,Long Beach,place,state/ca,CA,California,,place/CA-43000 +place/CA-44000,Los Angeles,place,state/ca,CA,California,,place/CA-44000 +place/CA-46842,Menifee,place,state/ca,CA,California,,place/CA-46842 +place/CA-48354,Modesto,place,state/ca,CA,California,,place/CA-48354 +place/CA-49270,Moreno Valley,place,state/ca,CA,California,,place/CA-49270 +place/CA-50076,Murrieta,place,state/ca,CA,California,,place/CA-50076 +place/CA-53000,Oakland,place,state/ca,CA,California,,place/CA-53000 +place/CA-53322,Oceanside,place,state/ca,CA,California,,place/CA-53322 +place/CA-53896,Ontario,place,state/ca,CA,California,,place/CA-53896 +place/CA-53980,Orange,place,state/ca,CA,California,,place/CA-53980 +place/CA-54652,Oxnard,place,state/ca,CA,California,,place/CA-54652 +place/CA-55156,Palmdale,place,state/ca,CA,California,,place/CA-55156 +place/CA-56000,Pasadena,place,state/ca,CA,California,,place/CA-56000 +place/CA-58072,Pomona,place,state/ca,CA,California,,place/CA-58072 +place/CA-59451,Rancho Cucamonga,place,state/ca,CA,California,,place/CA-59451 +place/CA-60466,Rialto,place,state/ca,CA,California,,place/CA-60466 +place/CA-60620,Richmond,place,state/ca,CA,California,,place/CA-60620 +place/CA-62000,Riverside,place,state/ca,CA,California,,place/CA-62000 +place/CA-62938,Roseville,place,state/ca,CA,California,,place/CA-62938 +place/CA-64000,Sacramento,place,state/ca,CA,California,,place/CA-64000 +place/CA-64224,Salinas,place,state/ca,CA,California,,place/CA-64224 +place/CA-65000,San Bernardino,place,state/ca,CA,California,,place/CA-65000 +place/CA-65042,Ventura,place,state/ca,CA,California,,place/CA-65042 +place/CA-66000,San Diego,place,state/ca,CA,California,,place/CA-66000 +place/CA-67000,San Francisco,place,state/ca,CA,California,,place/CA-67000 +place/CA-68000,San Jose,place,state/ca,CA,California,,place/CA-68000 +place/CA-68252,San Mateo,place,state/ca,CA,California,,place/CA-68252 +place/CA-69000,Santa Ana,place,state/ca,CA,California,,place/CA-69000 +place/CA-69084,Santa Clara,place,state/ca,CA,California,,place/CA-69084 +place/CA-69088,Santa Clarita,place,state/ca,CA,California,,place/CA-69088 +place/CA-69196,Santa Maria,place,state/ca,CA,California,,place/CA-69196 +place/CA-70098,Santa Rosa,place,state/ca,CA,California,,place/CA-70098 +place/CA-72016,Simi Valley,place,state/ca,CA,California,,place/CA-72016 +place/CA-75000,Stockton,place,state/ca,CA,California,,place/CA-75000 +place/CA-77000,Sunnyvale,place,state/ca,CA,California,,place/CA-77000 +place/CA-78120,Temecula,place,state/ca,CA,California,,place/CA-78120 +place/CA-78582,Thousand Oaks,place,state/ca,CA,California,,place/CA-78582 +place/CA-80000,Torrance,place,state/ca,CA,California,,place/CA-80000 +place/CA-81554,Vacaville,place,state/ca,CA,California,,place/CA-81554 +place/CA-81666,Vallejo,place,state/ca,CA,California,,place/CA-81666 +place/CA-82590,Victorville,place,state/ca,CA,California,,place/CA-82590 +place/CA-82954,Visalia,place,state/ca,CA,California,,place/CA-82954 +place/CA-84200,West Covina,place,state/ca,CA,California,,place/CA-84200 +place/CO-03455,Arvada,place,state/co,CO,Colorado,,place/CO-03455 +place/CO-04000,Aurora,place,state/co,CO,Colorado,,place/CO-04000 +place/CO-07850,Boulder,place,state/co,CO,Colorado,,place/CO-07850 +place/CO-12815,Centennial,place,state/co,CO,Colorado,,place/CO-12815 +place/CO-16000,Colorado Springs,place,state/co,CO,Colorado,,place/CO-16000 +place/CO-20000,Denver,place,state/co,CO,Colorado,,place/CO-20000 +place/CO-27425,Fort Collins,place,state/co,CO,Colorado,,place/CO-27425 +place/CO-32155,Greeley,place,state/co,CO,Colorado,,place/CO-32155 +place/CO-43000,Lakewood,place,state/co,CO,Colorado,,place/CO-43000 +place/CO-62000,Pueblo,place,state/co,CO,Colorado,,place/CO-62000 +place/CO-77290,Thornton,place,state/co,CO,Colorado,,place/CO-77290 +place/CO-83835,Westminster,place,state/co,CO,Colorado,,place/CO-83835 +place/CT-08000,Bridgeport,place,state/ct,CT,Connecticut,,place/CT-08000 +place/CT-37000,Hartford,place,state/ct,CT,Connecticut,,place/CT-37000 +place/CT-52000,New Haven,place,state/ct,CT,Connecticut,,place/CT-52000 +place/CT-73000,Stamford,place,state/ct,CT,Connecticut,,place/CT-73000 +place/CT-80000,Waterbury,place,state/ct,CT,Connecticut,,place/CT-80000 +place/DC-50000,Washington,place,state/dc,DC,District of Columbia,,place/DC-50000 +place/FL-10275,Cape Coral,place,state/fl,FL,Florida,,place/FL-10275 +place/FL-12875,Clearwater,place,state/fl,FL,Florida,,place/FL-12875 +place/FL-14400,Coral Springs,place,state/fl,FL,Florida,,place/FL-14400 +place/FL-16475,Davie,place,state/fl,FL,Florida,,place/FL-16475 +place/FL-24000,Fort Lauderdale,place,state/fl,FL,Florida,,place/FL-24000 +place/FL-25175,Gainesville,place,state/fl,FL,Florida,,place/FL-25175 +place/FL-30000,Hialeah,place,state/fl,FL,Florida,,place/FL-30000 +place/FL-32000,Hollywood,place,state/fl,FL,Florida,,place/FL-32000 +place/FL-35000,Jacksonville,place,state/fl,FL,Florida,,place/FL-35000 +place/FL-38250,Lakeland,place,state/fl,FL,Florida,,place/FL-38250 +place/FL-45000,Miami,place,state/fl,FL,Florida,,place/FL-45000 +place/FL-45060,Miami Gardens,place,state/fl,FL,Florida,,place/FL-45060 +place/FL-45975,Miramar,place,state/fl,FL,Florida,,place/FL-45975 +place/FL-53000,Orlando,place,state/fl,FL,Florida,,place/FL-53000 +place/FL-54000,Palm Bay,place,state/fl,FL,Florida,,place/FL-54000 +place/FL-54200,Palm Coast,place,state/fl,FL,Florida,,place/FL-54200 +place/FL-55775,Pembroke Pines,place,state/fl,FL,Florida,,place/FL-55775 +place/FL-58050,Pompano Beach,place,state/fl,FL,Florida,,place/FL-58050 +place/FL-58715,Port St. Lucie,place,state/fl,FL,Florida,,place/FL-58715 +place/FL-63000,St. Petersburg,place,state/fl,FL,Florida,,place/FL-63000 +place/FL-70600,Tallahassee,place,state/fl,FL,Florida,,place/FL-70600 +place/FL-71000,Tampa,place,state/fl,FL,Florida,,place/FL-71000 +place/FL-76600,West Palm Beach,place,state/fl,FL,Florida,,place/FL-76600 +place/GA-03440,Athens-Clarke County,place,state/ga,GA,Georgia,,place/GA-03440 +place/GA-04000,Atlanta,place,state/ga,GA,Georgia,,place/GA-04000 +place/GA-04204,Augusta-Richmond County,place,state/ga,GA,Georgia,,place/GA-04204 +place/GA-19000,Columbus,place,state/ga,GA,Georgia,,place/GA-19000 +place/GA-49008,Macon-Bibb County,place,state/ga,GA,Georgia,,place/GA-49008 +place/GA-68516,Sandy Springs,place,state/ga,GA,Georgia,,place/GA-68516 +place/GA-69000,Savannah,place,state/ga,GA,Georgia,,place/GA-69000 +place/GA-72122,South Fulton,place,state/ga,GA,Georgia,,place/GA-72122 +place/HI-71550,Urban Honolulu,place,state/hi,HI,Hawaii,,place/HI-71550 +place/IA-12000,Cedar Rapids,place,state/ia,IA,Iowa,,place/IA-12000 +place/IA-19000,Davenport,place,state/ia,IA,Iowa,,place/IA-19000 +place/IA-21000,Des Moines,place,state/ia,IA,Iowa,,place/IA-21000 +place/ID-08830,Boise City,place,state/id,ID,Idaho,,place/ID-08830 +place/ID-52120,Meridian,place,state/id,ID,Idaho,,place/ID-52120 +place/ID-56260,Nampa,place,state/id,ID,Idaho,,place/ID-56260 +place/IL-03012,Aurora,place,state/il,IL,Illinois,,place/IL-03012 +place/IL-14000,Chicago,place,state/il,IL,Illinois,,place/IL-14000 +place/IL-23074,Elgin,place,state/il,IL,Illinois,,place/IL-23074 +place/IL-38570,Joliet,place,state/il,IL,Illinois,,place/IL-38570 +place/IL-51622,Naperville,place,state/il,IL,Illinois,,place/IL-51622 +place/IL-59000,Peoria,place,state/il,IL,Illinois,,place/IL-59000 +place/IL-65000,Rockford,place,state/il,IL,Illinois,,place/IL-65000 +place/IL-72000,Springfield,place,state/il,IL,Illinois,,place/IL-72000 +place/IN-10342,Carmel,place,state/in,IN,Indiana,,place/IN-10342 +place/IN-22000,Evansville,place,state/in,IN,Indiana,,place/IN-22000 +place/IN-23278,Fishers,place,state/in,IN,Indiana,,place/IN-23278 +place/IN-25000,Fort Wayne,place,state/in,IN,Indiana,,place/IN-25000 +place/IN-36003,Indianapolis,place,state/in,IN,Indiana,,place/IN-36003 +place/IN-71000,South Bend,place,state/in,IN,Indiana,,place/IN-71000 +place/KS-36000,Kansas City,place,state/ks,KS,Kansas,,place/KS-36000 +place/KS-52575,Olathe,place,state/ks,KS,Kansas,,place/KS-52575 +place/KS-53775,Overland Park,place,state/ks,KS,Kansas,,place/KS-53775 +place/KS-71000,Topeka,place,state/ks,KS,Kansas,,place/KS-71000 +place/KS-79000,Wichita,place,state/ks,KS,Kansas,,place/KS-79000 +place/KY-46027,Lexington-Fayette,place,state/ky,KY,Kentucky,,place/KY-46027 +place/KY-48006,Louisville/Jefferson County,place,state/ky,KY,Kentucky,,place/KY-48006 +place/LA-05000,Baton Rouge,place,state/la,LA,Louisiana,,place/LA-05000 +place/LA-40735,Lafayette,place,state/la,LA,Louisiana,,place/LA-40735 +place/LA-55000,New Orleans,place,state/la,LA,Louisiana,,place/LA-55000 +place/LA-70000,Shreveport,place,state/la,LA,Louisiana,,place/LA-70000 +place/MA-07000,Boston,place,state/ma,MA,Massachusetts,,place/MA-07000 +place/MA-09000,Brockton,place,state/ma,MA,Massachusetts,,place/MA-09000 +place/MA-11000,Cambridge,place,state/ma,MA,Massachusetts,,place/MA-11000 +place/MA-37000,Lowell,place,state/ma,MA,Massachusetts,,place/MA-37000 +place/MA-37490,Lynn,place,state/ma,MA,Massachusetts,,place/MA-37490 +place/MA-45000,New Bedford,place,state/ma,MA,Massachusetts,,place/MA-45000 +place/MA-55745,Quincy,place,state/ma,MA,Massachusetts,,place/MA-55745 +place/MA-67000,Springfield,place,state/ma,MA,Massachusetts,,place/MA-67000 +place/MA-82000,Worcester,place,state/ma,MA,Massachusetts,,place/MA-82000 +place/MD-04000,Baltimore,place,state/md,MD,Maryland,,place/MD-04000 +place/MI-03000,Ann Arbor,place,state/mi,MI,Michigan,,place/MI-03000 +place/MI-21000,Dearborn,place,state/mi,MI,Michigan,,place/MI-21000 +place/MI-22000,Detroit,place,state/mi,MI,Michigan,,place/MI-22000 +place/MI-34000,Grand Rapids,place,state/mi,MI,Michigan,,place/MI-34000 +place/MI-46000,Lansing,place,state/mi,MI,Michigan,,place/MI-46000 +place/MI-76460,Sterling Heights,place,state/mi,MI,Michigan,,place/MI-76460 +place/MI-84000,Warren,place,state/mi,MI,Michigan,,place/MI-84000 +place/MN-43000,Minneapolis,place,state/mn,MN,Minnesota,,place/MN-43000 +place/MN-54880,Rochester,place,state/mn,MN,Minnesota,,place/MN-54880 +place/MN-58000,St. Paul,place,state/mn,MN,Minnesota,,place/MN-58000 +place/MO-15670,Columbia,place,state/mo,MO,Missouri,,place/MO-15670 +place/MO-35000,Independence,place,state/mo,MO,Missouri,,place/MO-35000 +place/MO-38000,Kansas City,place,state/mo,MO,Missouri,,place/MO-38000 +place/MO-41348,Lee's Summit,place,state/mo,MO,Missouri,,place/MO-41348 +place/MO-65000,St. Louis,place,state/mo,MO,Missouri,,place/MO-65000 +place/MO-70000,Springfield,place,state/mo,MO,Missouri,,place/MO-70000 +place/MS-36000,Jackson,place,state/ms,MS,Mississippi,,place/MS-36000 +place/MT-06550,Billings,place,state/mt,MT,Montana,,place/MT-06550 +place/NC-10740,Cary,place,state/nc,NC,North Carolina,,place/NC-10740 +place/NC-12000,Charlotte,place,state/nc,NC,North Carolina,,place/NC-12000 +place/NC-14100,Concord,place,state/nc,NC,North Carolina,,place/NC-14100 +place/NC-19000,Durham,place,state/nc,NC,North Carolina,,place/NC-19000 +place/NC-22920,Fayetteville,place,state/nc,NC,North Carolina,,place/NC-22920 +place/NC-28000,Greensboro,place,state/nc,NC,North Carolina,,place/NC-28000 +place/NC-31400,High Point,place,state/nc,NC,North Carolina,,place/NC-31400 +place/NC-55000,Raleigh,place,state/nc,NC,North Carolina,,place/NC-55000 +place/NC-74440,Wilmington,place,state/nc,NC,North Carolina,,place/NC-74440 +place/NC-75000,Winston-Salem,place,state/nc,NC,North Carolina,,place/NC-75000 +place/ND-25700,Fargo,place,state/nd,ND,North Dakota,,place/ND-25700 +place/NE-28000,Lincoln,place,state/ne,NE,Nebraska,,place/NE-28000 +place/NE-37000,Omaha,place,state/ne,NE,Nebraska,,place/NE-37000 +place/NH-45140,Manchester,place,state/nh,NH,New Hampshire,,place/NH-45140 +place/NJ-21000,Elizabeth,place,state/nj,NJ,New Jersey,,place/NJ-21000 +place/NJ-36000,Jersey City,place,state/nj,NJ,New Jersey,,place/NJ-36000 +place/NJ-51000,Newark,place,state/nj,NJ,New Jersey,,place/NJ-51000 +place/NJ-57000,Paterson,place,state/nj,NJ,New Jersey,,place/NJ-57000 +place/NM-02000,Albuquerque,place,state/nm,NM,New Mexico,,place/NM-02000 +place/NM-39380,Las Cruces,place,state/nm,NM,New Mexico,,place/NM-39380 +place/NM-63460,Rio Rancho,place,state/nm,NM,New Mexico,,place/NM-63460 +place/NV-31900,Henderson,place,state/nv,NV,Nevada,,place/NV-31900 +place/NV-40000,Las Vegas,place,state/nv,NV,Nevada,,place/NV-40000 +place/NV-51800,North Las Vegas,place,state/nv,NV,Nevada,,place/NV-51800 +place/NV-60600,Reno,place,state/nv,NV,Nevada,,place/NV-60600 +place/NV-68400,Sparks,place,state/nv,NV,Nevada,,place/NV-68400 +place/NY-01000,Albany,place,state/ny,NY,New York,,place/NY-01000 +place/NY-11000,Buffalo,place,state/ny,NY,New York,,place/NY-11000 +place/NY-51000,New York City,place,state/ny,NY,New York,,place/NY-51000 +place/NY-63000,Rochester,place,state/ny,NY,New York,,place/NY-63000 +place/NY-73000,Syracuse,place,state/ny,NY,New York,,place/NY-73000 +place/NY-84000,Yonkers,place,state/ny,NY,New York,,place/NY-84000 +place/OH-01000,Akron,place,state/oh,OH,Ohio,,place/OH-01000 +place/OH-15000,Cincinnati,place,state/oh,OH,Ohio,,place/OH-15000 +place/OH-16000,Cleveland,place,state/oh,OH,Ohio,,place/OH-16000 +place/OH-18000,Columbus,place,state/oh,OH,Ohio,,place/OH-18000 +place/OH-21000,Dayton,place,state/oh,OH,Ohio,,place/OH-21000 +place/OH-77000,Toledo,place,state/oh,OH,Ohio,,place/OH-77000 +place/OK-09050,Broken Arrow,place,state/ok,OK,Oklahoma,,place/OK-09050 +place/OK-52500,Norman,place,state/ok,OK,Oklahoma,,place/OK-52500 +place/OK-55000,Oklahoma City,place,state/ok,OK,Oklahoma,,place/OK-55000 +place/OK-75000,Tulsa,place,state/ok,OK,Oklahoma,,place/OK-75000 +place/OR-05800,Bend,place,state/or,OR,Oregon,,place/OR-05800 +place/OR-23850,Eugene,place,state/or,OR,Oregon,,place/OR-23850 +place/OR-31250,Gresham,place,state/or,OR,Oregon,,place/OR-31250 +place/OR-34100,Hillsboro,place,state/or,OR,Oregon,,place/OR-34100 +place/OR-59000,Portland,place,state/or,OR,Oregon,,place/OR-59000 +place/OR-64900,Salem,place,state/or,OR,Oregon,,place/OR-64900 +place/PA-02000,Allentown,place,state/pa,PA,Pennsylvania,,place/PA-02000 +place/PA-60000,Philadelphia,place,state/pa,PA,Pennsylvania,,place/PA-60000 +place/PA-61000,Pittsburgh,place,state/pa,PA,Pennsylvania,,place/PA-61000 +place/RI-59000,Providence,place,state/ri,RI,Rhode Island,,place/RI-59000 +place/SC-13330,Charleston,place,state/sc,SC,South Carolina,,place/SC-13330 +place/SC-16000,Columbia,place,state/sc,SC,South Carolina,,place/SC-16000 +place/SC-50875,North Charleston,place,state/sc,SC,South Carolina,,place/SC-50875 +place/SD-59020,Sioux Falls,place,state/sd,SD,South Dakota,,place/SD-59020 +place/TN-14000,Chattanooga,place,state/tn,TN,Tennessee,,place/TN-14000 +place/TN-15160,Clarksville,place,state/tn,TN,Tennessee,,place/TN-15160 +place/TN-40000,Knoxville,place,state/tn,TN,Tennessee,,place/TN-40000 +place/TN-48000,Memphis,place,state/tn,TN,Tennessee,,place/TN-48000 +place/TN-51560,Murfreesboro,place,state/tn,TN,Tennessee,,place/TN-51560 +place/TN-52006,Nashville-Davidson,place,state/tn,TN,Tennessee,,place/TN-52006 +place/TX-01000,Abilene,place,state/tx,TX,Texas,,place/TX-01000 +place/TX-01924,Allen,place,state/tx,TX,Texas,,place/TX-01924 +place/TX-03000,Amarillo,place,state/tx,TX,Texas,,place/TX-03000 +place/TX-04000,Arlington,place,state/tx,TX,Texas,,place/TX-04000 +place/TX-05000,Austin,place,state/tx,TX,Texas,,place/TX-05000 +place/TX-07000,Beaumont,place,state/tx,TX,Texas,,place/TX-07000 +place/TX-10768,Brownsville,place,state/tx,TX,Texas,,place/TX-10768 +place/TX-13024,Carrollton,place,state/tx,TX,Texas,,place/TX-13024 +place/TX-15976,College Station,place,state/tx,TX,Texas,,place/TX-15976 +place/TX-16432,Conroe,place,state/tx,TX,Texas,,place/TX-16432 +place/TX-17000,Corpus Christi,place,state/tx,TX,Texas,,place/TX-17000 +place/TX-19000,Dallas,place,state/tx,TX,Texas,,place/TX-19000 +place/TX-19972,Denton,place,state/tx,TX,Texas,,place/TX-19972 +place/TX-22660,Edinburg,place,state/tx,TX,Texas,,place/TX-22660 +place/TX-24000,El Paso,place,state/tx,TX,Texas,,place/TX-24000 +place/TX-27000,Fort Worth,place,state/tx,TX,Texas,,place/TX-27000 +place/TX-27684,Frisco,place,state/tx,TX,Texas,,place/TX-27684 +place/TX-29000,Garland,place,state/tx,TX,Texas,,place/TX-29000 +place/TX-30464,Grand Prairie,place,state/tx,TX,Texas,,place/TX-30464 +place/TX-35000,Houston,place,state/tx,TX,Texas,,place/TX-35000 +place/TX-37000,Irving,place,state/tx,TX,Texas,,place/TX-37000 +place/TX-39148,Killeen,place,state/tx,TX,Texas,,place/TX-39148 +place/TX-41464,Laredo,place,state/tx,TX,Texas,,place/TX-41464 +place/TX-41980,League City,place,state/tx,TX,Texas,,place/TX-41980 +place/TX-42508,Lewisville,place,state/tx,TX,Texas,,place/TX-42508 +place/TX-45000,Lubbock,place,state/tx,TX,Texas,,place/TX-45000 +place/TX-45384,McAllen,place,state/tx,TX,Texas,,place/TX-45384 +place/TX-45744,McKinney,place,state/tx,TX,Texas,,place/TX-45744 +place/TX-47892,Mesquite,place,state/tx,TX,Texas,,place/TX-47892 +place/TX-48072,Midland,place,state/tx,TX,Texas,,place/TX-48072 +place/TX-50820,New Braunfels,place,state/tx,TX,Texas,,place/TX-50820 +place/TX-53388,Odessa,place,state/tx,TX,Texas,,place/TX-53388 +place/TX-56000,Pasadena,place,state/tx,TX,Texas,,place/TX-56000 +place/TX-56348,Pearland,place,state/tx,TX,Texas,,place/TX-56348 +place/TX-58016,Plano,place,state/tx,TX,Texas,,place/TX-58016 +place/TX-61796,Richardson,place,state/tx,TX,Texas,,place/TX-61796 +place/TX-63500,Round Rock,place,state/tx,TX,Texas,,place/TX-63500 +place/TX-65000,San Antonio,place,state/tx,TX,Texas,,place/TX-65000 +place/TX-70808,Sugar Land,place,state/tx,TX,Texas,,place/TX-70808 +place/TX-74144,Tyler,place,state/tx,TX,Texas,,place/TX-74144 +place/TX-76000,Waco,place,state/tx,TX,Texas,,place/TX-76000 +place/TX-79000,Wichita Falls,place,state/tx,TX,Texas,,place/TX-79000 +place/UT-62470,Provo,place,state/ut,UT,Utah,,place/UT-62470 +place/UT-65330,St. George,place,state/ut,UT,Utah,,place/UT-65330 +place/UT-67000,Salt Lake City,place,state/ut,UT,Utah,,place/UT-67000 +place/UT-82950,West Jordan,place,state/ut,UT,Utah,,place/UT-82950 +place/UT-83470,West Valley City,place,state/ut,UT,Utah,,place/UT-83470 +place/VA-01000,Alexandria,place,state/va,VA,Virginia,,place/VA-01000 +place/VA-16000,Chesapeake,place,state/va,VA,Virginia,,place/VA-16000 +place/VA-35000,Hampton,place,state/va,VA,Virginia,,place/VA-35000 +place/VA-56000,Newport News,place,state/va,VA,Virginia,,place/VA-56000 +place/VA-57000,Norfolk,place,state/va,VA,Virginia,,place/VA-57000 +place/VA-67000,Richmond,place,state/va,VA,Virginia,,place/VA-67000 +place/VA-76432,Suffolk,place,state/va,VA,Virginia,,place/VA-76432 +place/VA-82000,Virginia Beach,place,state/va,VA,Virginia,,place/VA-82000 +place/WA-05210,Bellevue,place,state/wa,WA,Washington,,place/WA-05210 +place/WA-22640,Everett,place,state/wa,WA,Washington,,place/WA-22640 +place/WA-35415,Kent,place,state/wa,WA,Washington,,place/WA-35415 +place/WA-57745,Renton,place,state/wa,WA,Washington,,place/WA-57745 +place/WA-63000,Seattle,place,state/wa,WA,Washington,,place/WA-63000 +place/WA-67000,Spokane,place,state/wa,WA,Washington,,place/WA-67000 +place/WA-67167,Spokane Valley,place,state/wa,WA,Washington,,place/WA-67167 +place/WA-70000,Tacoma,place,state/wa,WA,Washington,,place/WA-70000 +place/WA-74060,Vancouver,place,state/wa,WA,Washington,,place/WA-74060 +place/WI-31000,Green Bay,place,state/wi,WI,Wisconsin,,place/WI-31000 +place/WI-48000,Madison,place,state/wi,WI,Wisconsin,,place/WI-48000 +place/WI-53000,Milwaukee,place,state/wi,WI,Wisconsin,,place/WI-53000 diff --git a/policyengine_api/data/v2/catalog/region_references.py b/policyengine_api/data/v2/catalog/region_references.py new file mode 100644 index 000000000..ce37a5908 --- /dev/null +++ b/policyengine_api/data/v2/catalog/region_references.py @@ -0,0 +1,251 @@ +"""The Public API's checked-in region reference files, one per country. + +Each file lists every region of one country: its code, label, type, parent, +US state, the value that selects its households in its dataset, and the v1 +identifier it replaces. Catalog publication reads regions from these files, +never from PolicyEngine.py's region registry. +""" + +from __future__ import annotations + +import csv +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +from policyengine_api.data.v2.catalog.errors import CatalogExtractionError + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping + +REGION_REFERENCE_DIRECTORY = Path(__file__).parent / "region_reference_files" +REGION_REFERENCE_FIELDS = ( + "code", + "label", + "region_type", + "parent_code", + "state_code", + "state_name", + "filter_value", + "v1_code", +) +SUPPORTED_REGION_TYPES = frozenset( + { + "national", + "country", + "state", + "congressional_district", + "constituency", + "local_authority", + "city", + "place", + } +) +# The catalog's region columns hold at most these many characters. +MAX_REGION_CODE_LENGTH = 255 +MAX_REGION_LABEL_LENGTH = 255 +MAX_REGION_STATE_CODE_LENGTH = 16 +MAX_REGION_STATE_NAME_LENGTH = 128 +MAX_REGION_FILTER_VALUE_LENGTH = 255 + + +@dataclass(frozen=True, slots=True) +class RegionReference: + """One region as its country's reference file lists it. + + ``filter_value`` is the text that selects the region's households: a FIPS + number or district GEOID for US states and districts, the stored region + names of a UK nation, or the ONS code of a UK constituency or local + authority (its row in the weight matrix). The national region and regions + that cannot run yet have none. ``v1_code`` is the v1 region identifier this + region replaces, if v1 had one. + """ + + code: str + label: str + region_type: str + parent_code: str | None + state_code: str | None + state_name: str | None + filter_value: str | None + v1_code: str | None + + +def _text( + row: Mapping[str, str | None], + field: str, + *, + code: str, + maximum: int, + required: bool, +) -> str | None: + value = row.get(field) or "" + if value != value.strip(): + msg = f"region {code!r} {field} has surrounding whitespace" + raise CatalogExtractionError(msg) + if not value: + if required: + msg = f"region {code!r} {field} must be a non-empty string" + raise CatalogExtractionError(msg) + return None + if len(value) > maximum: + msg = f"region {code!r} {field} exceeds {maximum} characters" + raise CatalogExtractionError(msg) + return value + + +def _reference(country_id: str, row: Mapping[str, str | None]) -> RegionReference: + code = row.get("code") or "" + if not code or code != code.strip() or len(code) > MAX_REGION_CODE_LENGTH: + msg = f"{country_id} region reference file has an invalid code {code!r}" + raise CatalogExtractionError(msg) + region_type = row.get("region_type") or "" + if region_type not in SUPPORTED_REGION_TYPES: + msg = f"region {code!r} has unsupported type {region_type!r}" + raise CatalogExtractionError(msg) + if region_type != "national" and not code.startswith(f"{region_type}/"): + msg = f"region {code!r} does not start with its type {region_type + '/'!r}" + raise CatalogExtractionError(msg) + return RegionReference( + code=code, + label=_text( + row, "label", code=code, maximum=MAX_REGION_LABEL_LENGTH, required=True + ) + or "", + region_type=region_type, + parent_code=_text( + row, + "parent_code", + code=code, + maximum=MAX_REGION_CODE_LENGTH, + required=False, + ), + state_code=_text( + row, + "state_code", + code=code, + maximum=MAX_REGION_STATE_CODE_LENGTH, + required=False, + ), + state_name=_text( + row, + "state_name", + code=code, + maximum=MAX_REGION_STATE_NAME_LENGTH, + required=False, + ), + filter_value=_text( + row, + "filter_value", + code=code, + maximum=MAX_REGION_FILTER_VALUE_LENGTH, + required=False, + ), + v1_code=_text( + row, "v1_code", code=code, maximum=MAX_REGION_CODE_LENGTH, required=False + ), + ) + + +def _repeated(values: Iterable[str | None]) -> list[str]: + return sorted( + value + for value, count in Counter(value for value in values if value).items() + if count > 1 + ) + + +def _check_parents_present( + country_id: str, + references: tuple[RegionReference, ...], +) -> None: + """Require one parentless national region and a parent for every other region.""" + if not any(reference.code == country_id for reference in references): + msg = ( + f"{country_id} region reference file has no national region {country_id!r}" + ) + raise CatalogExtractionError(msg) + for reference in references: + if reference.region_type == "national": + if reference.code != country_id: + msg = f"national region {reference.code!r} must be named {country_id!r}" + raise CatalogExtractionError(msg) + if reference.parent_code is not None: + msg = f"national region {reference.code!r} must have no parent" + raise CatalogExtractionError(msg) + elif reference.parent_code is None: + msg = f"region {reference.code!r} has no parent" + raise CatalogExtractionError(msg) + + +def _check_ancestry( + country_id: str, + references: tuple[RegionReference, ...], +) -> None: + """Require every region's parents to exist and lead to the national region.""" + parents = {reference.code: reference.parent_code for reference in references} + for reference in references: + visited = {reference.code} + ancestor = reference.parent_code + while ancestor is not None and ancestor != country_id: + if ancestor not in parents: + msg = ( + f"region {reference.code!r} names an unknown parent " + f"{reference.parent_code!r}" + ) + raise CatalogExtractionError(msg) + if ancestor in visited: + msg = f"region {reference.code!r} has a parent cycle" + raise CatalogExtractionError(msg) + visited.add(ancestor) + ancestor = parents[ancestor] + + +def parse_region_references( + country_id: str, + rows: Iterable[Mapping[str, str | None]], +) -> tuple[RegionReference, ...]: + """Validate one country's reference rows and return them in file order. + + Codes and v1 identifiers must be unique, every region's code must start + with its type, and every region must descend from the national region. + """ + references = tuple(_reference(country_id, row) for row in rows) + repeated_codes = _repeated(reference.code for reference in references) + if repeated_codes: + msg = ( + f"{country_id} region reference file repeats region codes: {repeated_codes}" + ) + raise CatalogExtractionError(msg) + repeated_v1_codes = _repeated(reference.v1_code for reference in references) + if repeated_v1_codes: + msg = ( + f"{country_id} region reference file repeats v1 identifiers: " + f"{repeated_v1_codes}" + ) + raise CatalogExtractionError(msg) + _check_parents_present(country_id, references) + _check_ancestry(country_id, references) + return references + + +def load_region_references( + country_id: str, + *, + directory: Path = REGION_REFERENCE_DIRECTORY, +) -> tuple[RegionReference, ...]: + """Read and validate the checked-in reference file of one country.""" + path = directory / f"{country_id}.csv" + if not path.is_file(): + msg = f"{country_id} has no region reference file" + raise CatalogExtractionError(msg) + with path.open(encoding="utf-8", newline="") as handle: + reader = csv.DictReader(handle) + if tuple(reader.fieldnames or ()) != REGION_REFERENCE_FIELDS: + msg = ( + f"{country_id} region reference file columns must be " + f"{', '.join(REGION_REFERENCE_FIELDS)}" + ) + raise CatalogExtractionError(msg) + return parse_region_references(country_id, reader) diff --git a/tests/unit/v2/test_region_references.py b/tests/unit/v2/test_region_references.py new file mode 100644 index 000000000..dd97be4b4 --- /dev/null +++ b/tests/unit/v2/test_region_references.py @@ -0,0 +1,318 @@ +"""The checked-in region reference files and the rules their loader enforces.""" + +from __future__ import annotations + +import csv +import json +from collections import Counter +from pathlib import Path + +import pytest + +from policyengine_api.data.congressional_districts import ( + AT_LARGE_STATES, + CONGRESSIONAL_DISTRICTS, + STATE_CODE_TO_NAME, + build_congressional_district_metadata, +) +from policyengine_api.data.v2.catalog.errors import CatalogExtractionError +from policyengine_api.data.v2.catalog.region_references import ( + REGION_REFERENCE_FIELDS, + RegionReference, + load_region_references, + parse_region_references, +) + +V1_DATA_DIRECTORY = Path(__file__).parents[3] / "policyengine_api" / "data" +NATION_BY_ONS_PREFIX = { + "E": "country/england", + "S": "country/scotland", + "W": "country/wales", + "N": "country/northern_ireland", +} +ENGLISH_ITL1_REGIONS = [ + "NORTH_EAST", + "NORTH_WEST", + "YORKSHIRE", + "EAST_MIDLANDS", + "WEST_MIDLANDS", + "EAST_OF_ENGLAND", + "LONDON", + "SOUTH_EAST", + "SOUTH_WEST", +] + + +def _by_code(references: tuple[RegionReference, ...]) -> dict[str, RegionReference]: + return {reference.code: reference for reference in references} + + +def _v1_lookup_rows(filename: str) -> list[dict[str, str]]: + with (V1_DATA_DIRECTORY / filename).open(encoding="utf-8", newline="") as handle: + return list(csv.DictReader(handle)) + + +def _row(code: str, region_type: str, **fields: str) -> dict[str, str]: + """Return one reference-file row; unspecified columns are empty.""" + row = dict.fromkeys(REGION_REFERENCE_FIELDS, "") + row.update(code=code, label=code.title(), region_type=region_type, v1_code=code) + row.update(fields) + return row + + +def _small_us_rows() -> list[dict[str, str]]: + return [ + _row("us", "national"), + _row("state/ca", "state", parent_code="us", filter_value="6"), + _row( + "congressional_district/CA-01", + "congressional_district", + parent_code="state/ca", + ), + ] + + +def test_each_country_lists_every_region_once_with_the_expected_counts() -> None: + us = load_region_references("us") + uk = load_region_references("uk") + + assert Counter(reference.region_type for reference in us) == { + "national": 1, + "state": 51, + "congressional_district": 436, + "place": 333, + } + assert Counter(reference.region_type for reference in uk) == { + "national": 1, + "country": 4, + "constituency": 650, + "local_authority": 361, + } + assert len(uk) == 1_016 + for references in (us, uk): + codes = [reference.code for reference in references] + v1_codes = [reference.v1_code for reference in references] + assert len(set(codes)) == len(codes) + assert len(set(v1_codes)) == len(v1_codes) + + +def test_national_regions_and_the_v1_identifiers_they_replace() -> None: + us = _by_code(load_region_references("us")) + uk = _by_code(load_region_references("uk")) + + assert us["us"].label == "United States" + assert uk["uk"].label == "United Kingdom" + assert us["us"].parent_code is None + assert uk["uk"].parent_code is None + assert uk["country/northern_ireland"].v1_code == "country/ni" + assert uk["country/northern_ireland"].label == "Northern Ireland" + assert "country/ni" not in uk + assert uk["constituency/E14001063"].label == "Aldershot" + assert uk["constituency/E14001063"].v1_code == "constituency/Aldershot" + assert uk["local_authority/E07000110"].label == "Maidstone" + assert uk["local_authority/E07000110"].v1_code == "local_authority/Maidstone" + assert uk["local_authority/N09000011"].label == "Ards and North Down" + assert uk["local_authority/N09000011"].parent_code == "country/northern_ireland" + assert uk["local_authority/N09000011"].v1_code == ( + "local_authority/Ards and North Down" + ) + assert us["state/ca"].v1_code == "state/ca" + assert us["congressional_district/CA-01"].v1_code == "congressional_district/CA-01" + + +def test_uk_constituencies_and_local_authorities_use_ons_codes_from_v1_lookups() -> ( + None +): + uk = load_region_references("uk") + for region_type, filename in ( + ("constituency", "constituencies_2024.csv"), + ("local_authority", "local_authorities_2021.csv"), + ): + expected = { + f"{region_type}/{row['code']}": ( + row["name"], + f"{region_type}/{row['name']}", + row["code"], + NATION_BY_ONS_PREFIX[row["code"][0]], + ) + for row in _v1_lookup_rows(filename) + } + actual = { + reference.code: ( + reference.label, + reference.v1_code, + reference.filter_value, + reference.parent_code, + ) + for reference in uk + if reference.region_type == region_type + } + assert actual == expected + + +def test_uk_nations_select_their_stored_regions() -> None: + uk = _by_code(load_region_references("uk")) + + assert json.loads(uk["country/england"].filter_value or "") == ( + ENGLISH_ITL1_REGIONS + ) + assert uk["country/scotland"].filter_value == "SCOTLAND" + assert uk["country/wales"].filter_value == "WALES" + assert uk["country/northern_ireland"].filter_value == "NORTHERN_IRELAND" + assert all(uk[code].parent_code == "uk" for code in NATION_BY_ONS_PREFIX.values()) + + +def test_us_states_and_districts_match_v1_and_select_by_fips_and_geoid() -> None: + us = load_region_references("us") + states = { + reference.state_code: reference + for reference in us + if reference.region_type == "state" + } + districts = { + reference.code: reference + for reference in us + if reference.region_type == "congressional_district" + } + + assert {code: state.label for code, state in states.items()} == (STATE_CODE_TO_NAME) + assert all(state.code == f"state/{code.lower()}" for code, state in states.items()) + assert all(state.parent_code == "us" for state in states.values()) + assert states["CA"].filter_value == "6" + assert states["DC"].filter_value == "11" + + v1_districts = { + entry["name"]: entry for entry in build_congressional_district_metadata() + } + assert set(districts) == set(v1_districts) + for district in CONGRESSIONAL_DISTRICTS: + code = f"congressional_district/{district.state_code}-{district.number:02d}" + reference = districts[code] + number = 0 if district.state_code in AT_LARGE_STATES else district.number + state_fips = int(states[district.state_code].filter_value or "") + assert reference.label == v1_districts[code]["label"] + assert reference.parent_code == f"state/{district.state_code.lower()}" + assert reference.state_code == district.state_code + assert reference.state_name == STATE_CODE_TO_NAME[district.state_code] + assert reference.filter_value == str(state_fips * 100 + number) + assert districts["congressional_district/CA-01"].filter_value == "601" + assert districts["congressional_district/AK-01"].filter_value == "200" + assert districts["congressional_district/DC-01"].filter_value == "1100" + + +def test_us_places_are_listed_under_their_state_without_a_filter() -> None: + places = [ + reference + for reference in load_region_references("us") + if reference.region_type == "place" + ] + + assert places + assert all(place.filter_value is None for place in places) + assert all( + place.parent_code == f"state/{(place.state_code or '').lower()}" + for place in places + ) + assert all(place.v1_code == place.code for place in places) + + +def test_parses_a_valid_minimal_file() -> None: + references = parse_region_references("us", _small_us_rows()) + + assert [reference.code for reference in references] == [ + "us", + "state/ca", + "congressional_district/CA-01", + ] + assert references[1] == RegionReference( + code="state/ca", + label="State/Ca", + region_type="state", + parent_code="us", + state_code=None, + state_name=None, + filter_value="6", + v1_code="state/ca", + ) + + +@pytest.mark.parametrize( + ("change", "message"), + [ + ( + lambda rows: rows.append(_row("state/ca", "state", parent_code="us")), + r"^us region reference file repeats region codes: \['state/ca'\]$", + ), + ( + lambda rows: rows[2].update(v1_code="state/ca"), + r"^us region reference file repeats v1 identifiers: \['state/ca'\]$", + ), + ( + lambda rows: rows[2].update(parent_code="state/ny"), + r"^region 'congressional_district/CA-01' names an unknown parent " + r"'state/ny'$", + ), + ( + lambda rows: rows.pop(0), + r"^us region reference file has no national region 'us'$", + ), + ( + lambda rows: rows[1].update(region_type="place"), + r"^region 'state/ca' does not start with its type 'place/'$", + ), + ( + lambda rows: rows.append(_row("county/06037", "county", parent_code="us")), + r"^region 'county/06037' has unsupported type 'county'$", + ), + ( + lambda rows: rows[1].update(parent_code="congressional_district/CA-01"), + r"^region 'state/ca' has a parent cycle$", + ), + ( + lambda rows: rows[0].update(parent_code="state/ca"), + r"^national region 'us' must have no parent$", + ), + ( + lambda rows: rows[1].update(parent_code=""), + r"^region 'state/ca' has no parent$", + ), + ( + lambda rows: rows[1].update(filter_value="x" * 256), + r"^region 'state/ca' filter_value exceeds 255 characters$", + ), + ( + lambda rows: rows[1].update(label=" California"), + r"^region 'state/ca' label has surrounding whitespace$", + ), + ( + lambda rows: rows[1].update(label=""), + r"^region 'state/ca' label must be a non-empty string$", + ), + ], +) +def test_rejects_an_inconsistent_reference_file(change, message: str) -> None: + rows = _small_us_rows() + change(rows) + + with pytest.raises(CatalogExtractionError, match=message): + parse_region_references("us", rows) + + +def test_rejects_a_file_whose_columns_differ(tmp_path: Path) -> None: + (tmp_path / "us.csv").write_text( + "code,label,region_type\nus,United States,national\n", encoding="utf-8" + ) + + with pytest.raises( + CatalogExtractionError, + match=r"^us region reference file columns must be ", + ): + load_region_references("us", directory=tmp_path) + + +def test_rejects_a_country_without_a_reference_file(tmp_path: Path) -> None: + with pytest.raises( + CatalogExtractionError, + match=r"^ca has no region reference file$", + ): + load_region_references("ca", directory=tmp_path) From f50890fa2d01fbc3a92356acd616fc6732852eb5 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:07:14 +0400 Subject: [PATCH 02/25] Publish catalog regions from the reference files and the certified data release Catalog extraction no longer reads PolicyEngine.py's region registry. It takes every region from the checked-in reference file of its country and gives it a dataset and filter from a scoping rule per country and region type: - US national, and UK national: the release's national dataset, no filter. - US states and congressional districts: the release's state and district dataset, a row filter on state_fips or congressional_district_geoid. - UK nations: the national dataset, a row filter on the stored region. - UK constituencies and local authorities: the national dataset, a weight_replacement filter whose field names the release's weight matrix (parliamentary_constituency_weights, local_authority_weights) and whose value is the region's ONS code. The matrix must come from the same release as the dataset, since weights attach to households by position. Extraction fails if the national region would not use the reviewed dataset, a dataset role names no certified dataset or a per-region template, a filtered region has no filter value or a malformed list, or a runnable region sits under one that is left out. Region types without a rule (US places) are left out and counted in the publication evidence. The installed 6.7.5 catalog now publishes 1,503 regions: 488 US and 1,015 UK. Co-Authored-By: Claude Opus 5.5 --- ...n-1f-123-region-reference-files.changed.md | 1 + .../data/v2/catalog/extraction.py | 307 ++++------------- .../data/v2/catalog/publication.py | 5 + .../data/v2/catalog/publication_types.py | 9 + policyengine_api/data/v2/catalog/records.py | 16 +- .../data/v2/catalog/region_scoping.py | 313 ++++++++++++++++++ policyengine_api/data/v2/models/metadata.py | 7 +- .../services/v2/metadata/types.py | 8 +- tests/fixtures/v2_catalog.py | 226 ++++++++----- .../integration/test_v2_catalog_installed.py | 36 +- .../test_v2_catalog_publication.py | 10 +- tests/unit/v2/test_catalog_extraction.py | 227 +++++++------ .../v2/test_catalog_extraction_failures.py | 198 ++++++++--- tests/unit/v2/test_catalog_publication.py | 4 + 14 files changed, 868 insertions(+), 499 deletions(-) create mode 100644 changelog.d/v2plan-1f-123-region-reference-files.changed.md create mode 100644 policyengine_api/data/v2/catalog/region_scoping.py diff --git a/changelog.d/v2plan-1f-123-region-reference-files.changed.md b/changelog.d/v2plan-1f-123-region-reference-files.changed.md new file mode 100644 index 000000000..589a50194 --- /dev/null +++ b/changelog.d/v2plan-1f-123-region-reference-files.changed.md @@ -0,0 +1 @@ +Catalog publication reads regions from the Public API's checked-in reference files, one per country, instead of PolicyEngine.py's region registry. Each region takes its dataset from the certified data release and its filter from a scoping rule: a row filter on state_fips, congressional_district_geoid or the UK region column, or a weight_replacement filter naming the release's constituency or local-authority weight matrix and the region's ONS code. The UK now publishes 1,015 regions, with constituencies and local authorities by ONS code and Northern Ireland as country/northern_ireland; the national regions are "United States" and "United Kingdom". US places are listed in the reference file but left out of the catalog until they can run, and the publication evidence counts them. diff --git a/policyengine_api/data/v2/catalog/extraction.py b/policyengine_api/data/v2/catalog/extraction.py index 99ac8086b..76895175e 100644 --- a/policyengine_api/data/v2/catalog/extraction.py +++ b/policyengine_api/data/v2/catalog/extraction.py @@ -2,16 +2,13 @@ from __future__ import annotations -import json import math import re -from collections import Counter from collections.abc import Callable, Mapping, Sequence from datetime import UTC, date, datetime, timedelta from enum import Enum from importlib import metadata as importlib_metadata -from typing import TYPE_CHECKING, Any, NamedTuple, Protocol -from urllib.parse import urlsplit +from typing import TYPE_CHECKING, Any from uuid import NAMESPACE_URL, UUID, uuid5 from policyengine_api.data.v2.catalog.errors import CatalogExtractionError @@ -22,6 +19,7 @@ ModelRecord, ModelVersionRecord, NormalizedCatalog, + OmittedRegionSummary, ParameterNodeRecord, ParameterRecord, ParameterValueRecord, @@ -29,8 +27,12 @@ VariableRecord, ) from policyengine_api.data.v2.catalog.region_references import ( - MAX_REGION_FILTER_VALUE_LENGTH, - SUPPORTED_REGION_TYPES, + RegionReference, + load_region_references, +) +from policyengine_api.data.v2.catalog.region_scoping import ( + ScopedRegion, + scope_regions, ) if TYPE_CHECKING: @@ -88,40 +90,6 @@ def _optional_text( return value -def _serialize_region_filter_value(value: object, *, region_code: str) -> str: - """Keep scalar text or encode flat membership values in the existing column. - - Since PolicyEngine 6.2.7, the England filter names all nine English regions. The - catalog's existing text column can retain that complete list as a JSON - array without changing its schema or the representation of scalar filters. - """ - scalar_types = (str, int, float, bool) - if isinstance(value, scalar_types): - serialized = str(value) - elif ( - isinstance(value, (list, tuple)) - and value - and all(isinstance(item, scalar_types) for item in value) - ): - try: - serialized = json.dumps( - value, ensure_ascii=False, separators=(",", ":"), allow_nan=False - ) - except ValueError as error: - msg = f"region {region_code!r} has an unsupported filter value" - raise CatalogExtractionError(msg) from error - else: - msg = f"region {region_code!r} has an unsupported filter value" - raise CatalogExtractionError(msg) - if len(serialized) > MAX_REGION_FILTER_VALUE_LENGTH: - msg = ( - f"region {region_code!r} filter value exceeds " - f"{MAX_REGION_FILTER_VALUE_LENGTH} characters" - ) - raise CatalogExtractionError(msg) - return serialized - - def _type_name(value: object) -> str | None: if value is None: return None @@ -221,17 +189,6 @@ def _optional_aware_datetime(value: object, *, field_name: str) -> datetime | No return _aware_datetime(value, field_name=field_name) -def _dataset_name_from_path(path: str) -> str: - without_revision = path.split("@", maxsplit=1)[0] - parsed_path = urlsplit(without_revision).path or without_revision - filename = parsed_path.rstrip("/").rsplit("/", maxsplit=1)[-1] - for suffix in (".hdf5", ".parquet", ".csv", ".h5"): - if filename.endswith(suffix): - filename = filename[: -len(suffix)] - break - return _required_text(filename, field_name="dataset name", maximum=255) - - def _dataset_year(name: str) -> int: matches = DATASET_YEAR_PATTERN.findall(name) if not matches: @@ -477,18 +434,6 @@ def _public_named_records( return tuple(normalized) -class _RegionDatasetSelection(NamedTuple): - """The logical input datasets a country's regions select. - - Names every selected dataset, each region code's dataset, and how many - regions of each type fall back to the reviewed default dataset. - """ - - dataset_names: set[str] - region_dataset_names: dict[str, str] - fallback_counts: Counter[str] - - def _verified_model_name(country_id: str, source_model: object) -> str: """Return the public model's name once it matches the country's model.""" expected_model_name = f"policyengine-{country_id}" @@ -580,184 +525,39 @@ def _normalize_parameter_nodes( return tuple(normalized_nodes) -def _verified_default_dataset( - country_id: str, - release_manifest: object, -) -> tuple[str, object]: - """Return the reviewed default dataset and the manifest's URI for it. - - The manifest's default dataset must be the reviewed one. - """ +def _verified_default_dataset(country_id: str, release_manifest: object) -> str: + """Return the reviewed default dataset once the manifest names it as its default.""" reviewed_default = REVIEWED_DEFAULT_DATASETS[country_id] manifest_default = getattr(release_manifest, "default_dataset", None) if manifest_default != reviewed_default: msg = f"{country_id} default dataset does not match the reviewed selection" raise CatalogExtractionError(msg) - return reviewed_default, getattr(release_manifest, "default_dataset_uri", None) - - -def _verified_source_regions(country_id: str, source: object) -> tuple[Any, ...]: - """Return the country's public regions once their codes are unique and complete. - - The registry must belong to the country, and its codes must be unique and - include the national region. - """ - registry = getattr(source, "region_registry", None) - if registry is None or getattr(registry, "country_id", None) != country_id: - msg = f"{country_id} public region registry is absent" - raise CatalogExtractionError(msg) - - source_regions = tuple(registry) - region_codes = [getattr(region, "code", None) for region in source_regions] - duplicate_region_codes = sorted( - code for code, count in Counter(region_codes).items() if count > 1 - ) - if duplicate_region_codes: - msg = f"duplicate region natural keys: {duplicate_region_codes[:3]}" - raise CatalogExtractionError(msg) - if country_id not in region_codes: - msg = f"{country_id} national region is absent" - raise CatalogExtractionError(msg) - return source_regions - - -def _select_region_datasets( - country_id: str, - source_regions: tuple[Any, ...], - *, - reviewed_default: str, - default_dataset_uri: object, -) -> _RegionDatasetSelection: - """Choose each region's logical input dataset. - - A subnational region with a dataset path uses that dataset: US states and - districts, and the UK constituencies and local authorities that .py routes - to its local-area release. Every other region, such as a UK country that - filters the national file, uses the reviewed default, and the national - region may only name it. A US subnational region without a path is - counted as a fallback. - """ - selected_dataset_names: set[str] = {reviewed_default} - region_dataset_names: dict[str, str] = {} - fallback_counts: Counter[str] = Counter() - for region in source_regions: - code = _required_text( - getattr(region, "code", None), - field_name="region code", - maximum=255, - ) - region_type = _required_text( - getattr(region, "region_type", None), - field_name=f"region {code} type", - maximum=64, - ) - if region_type not in SUPPORTED_REGION_TYPES: - msg = f"region {code!r} has unsupported type {region_type!r}" - raise CatalogExtractionError(msg) - dataset_path = getattr(region, "dataset_path", None) - if region_type != "national" and dataset_path: - dataset_name = _dataset_name_from_path(dataset_path) - selected_dataset_names.add(dataset_name) - else: - dataset_name = reviewed_default - if country_id == "us" and region_type != "national" and not dataset_path: - fallback_counts[region_type] += 1 - if region_type == "national" and dataset_path not in { - None, - default_dataset_uri, - }: - msg = f"{country_id} national region does not use the reviewed dataset" - raise CatalogExtractionError(msg) - region_dataset_names[code] = dataset_name - return _RegionDatasetSelection( - dataset_names=selected_dataset_names, - region_dataset_names=region_dataset_names, - fallback_counts=fallback_counts, - ) - - -def _region_filter(strategy: object, code: str) -> tuple[str, str, str]: - """Return a filtered region's strategy type, filter field and filter value.""" - strategy_type = _required_text( - getattr(strategy, "strategy_type", None), - field_name=f"region {code} filter strategy", - maximum=64, - ) - filter_field = _required_text( - getattr(strategy, "variable_name", None), - field_name=f"region {code} filter field", - maximum=128, - ) - filter_value = _serialize_region_filter_value( - getattr(strategy, "variable_value", None), region_code=code - ) - additional_filters = getattr(strategy, "additional_filters", {}) - if additional_filters: - msg = f"region {code!r} has unsupported additional filters" - raise CatalogExtractionError(msg) - return strategy_type, filter_field, filter_value - - -class _SourceRegion(Protocol): - """The attributes of a PolicyEngine.py region that _normalize_region reads directly. - - It reads every other region attribute through getattr, with a default. - """ - - @property - def code(self) -> object: ... - - @property - def region_type(self) -> object: ... + return reviewed_default def _normalize_region( - source_region: _SourceRegion, + scoped_region: ScopedRegion, *, model_version_id: UUID, - region_dataset_ids: Mapping[str, UUID], + dataset_ids: Mapping[str, UUID], ) -> RegionRecord: - """Normalize one public region, with its filter and default dataset.""" - code = str(source_region.code) - strategy = getattr(source_region, "scoping_strategy", None) - requires_filter = bool(getattr(source_region, "requires_filter", False)) - if requires_filter: - strategy_type, filter_field, filter_value = _region_filter(strategy, code) - else: - strategy_type = None - filter_field = None - filter_value = None - + """Build one region's record from its reference, dataset and filter.""" + reference = scoped_region.reference + requires_filter = scoped_region.filter_strategy is not None return RegionRecord( - id=_identifier("region", model_version_id, code), + id=_identifier("region", model_version_id, reference.code), model_version_id=model_version_id, - default_dataset_id=region_dataset_ids[code], - code=code, - label=_required_text( - getattr(source_region, "label", None), - field_name=f"region {code} label", - maximum=255, - ), - region_type=str(source_region.region_type), + default_dataset_id=dataset_ids[scoped_region.dataset_name], + code=reference.code, + label=reference.label, + region_type=reference.region_type, requires_filter=requires_filter, - filter_field=filter_field, - filter_value=filter_value, - filter_strategy=strategy_type, - parent_code=_optional_text( - getattr(source_region, "parent_code", None), - field_name=f"region {code} parent_code", - maximum=255, - ), - state_code=_optional_text( - getattr(source_region, "state_code", None), - field_name=f"region {code} state_code", - maximum=16, - ), - state_name=_optional_text( - getattr(source_region, "state_name", None), - field_name=f"region {code} state_name", - maximum=128, - ), + filter_field=scoped_region.filter_field, + filter_value=reference.filter_value if requires_filter else None, + filter_strategy=scoped_region.filter_strategy, + parent_code=reference.parent_code, + state_code=reference.state_code, + state_name=reference.state_name, ) @@ -767,6 +567,7 @@ def _normalize_country( source: object, policyengine_version: str, expected_country_package_version: str, + region_references: tuple[RegionReference, ...], ) -> CountryCatalog: source_model = getattr(source, "model", None) model_name = _verified_model_name(country_id, source_model) @@ -799,15 +600,12 @@ def _normalize_country( ) ) - reviewed_default, default_dataset_uri = _verified_default_dataset( - country_id, release_manifest - ) - source_regions = _verified_source_regions(country_id, source) - dataset_selection = _select_region_datasets( + reviewed_default = _verified_default_dataset(country_id, release_manifest) + scoping = scope_regions( country_id, - source_regions, + region_references, + release_manifest, reviewed_default=reviewed_default, - default_dataset_uri=default_dataset_uri, ) datasets = tuple( @@ -818,20 +616,16 @@ def _normalize_country( description=f"PolicyEngine.py logical input dataset {name}", year=_dataset_year(name), ) - for name in sorted(dataset_selection.dataset_names) + for name in sorted(scoping.dataset_names) ) dataset_ids = {dataset.name: dataset.id for dataset in datasets} - region_dataset_ids = { - code: dataset_ids[name] - for code, name in dataset_selection.region_dataset_names.items() - } regions = [ _normalize_region( - source_region, + scoped_region, model_version_id=model_version_id, - region_dataset_ids=region_dataset_ids, + dataset_ids=dataset_ids, ) - for source_region in source_regions + for scoped_region in scoping.regions ] return CountryCatalog( @@ -863,7 +657,11 @@ def _normalize_country( regions=tuple(sorted(regions, key=lambda record: record.code)), fallback_summaries=tuple( FallbackSummary(region_type=region_type, count=count) - for region_type, count in sorted(dataset_selection.fallback_counts.items()) + for region_type, count in scoping.fallback_counts.items() + ), + omitted_region_summaries=tuple( + OmittedRegionSummary(region_type=region_type, count=count) + for region_type, count in scoping.omitted_counts.items() ), ) @@ -873,9 +671,14 @@ def extract_catalog( bundle: Mapping[str, Any], policyengine_version: str, models: Mapping[str, object], + region_references: Mapping[str, Sequence[RegionReference]], installed_version: Callable[[str], str] = importlib_metadata.version, ) -> NormalizedCatalog: - """Validate and normalize supplied PolicyEngine.py public objects.""" + """Validate and normalize supplied PolicyEngine.py public objects. + + Regions come from the supplied reference files, scoped by each country's + certified data release. + """ dependencies = _verify_bundle( bundle=bundle, policyengine_version=policyengine_version, @@ -885,6 +688,12 @@ def extract_catalog( if missing: msg = f"PolicyEngine.py public models are absent for: {', '.join(missing)}" raise CatalogExtractionError(msg) + missing = [ + country for country in SUPPORTED_COUNTRIES if country not in region_references + ] + if missing: + msg = f"region references are absent for: {', '.join(missing)}" + raise CatalogExtractionError(msg) package_map = bundle["packages"] countries = tuple( @@ -895,6 +704,7 @@ def extract_catalog( expected_country_package_version=package_map[f"policyengine-{country_id}"][ "version" ], + region_references=tuple(region_references[country_id]), ) for country_id in SUPPORTED_COUNTRIES ) @@ -906,7 +716,10 @@ def extract_catalog( def extract_installed_catalog() -> NormalizedCatalog: - """Load and normalize the installed PolicyEngine.py certified catalog.""" + """Load and normalize the installed PolicyEngine.py certified catalog. + + Regions come from the Public API's checked-in reference files. + """ import policyengine from policyengine.bundle import ( get_current_bundle, @@ -920,4 +733,8 @@ def extract_installed_catalog() -> NormalizedCatalog: "us": policyengine.us.model, "uk": policyengine.uk.model, }, + region_references={ + country_id: load_region_references(country_id) + for country_id in SUPPORTED_COUNTRIES + }, ) diff --git a/policyengine_api/data/v2/catalog/publication.py b/policyengine_api/data/v2/catalog/publication.py index 5e629fb05..88da73310 100644 --- a/policyengine_api/data/v2/catalog/publication.py +++ b/policyengine_api/data/v2/catalog/publication.py @@ -137,6 +137,11 @@ def publish_catalog( entity_counts=catalog.entity_counts(), fallback_summaries=fallback_summaries, elapsed_seconds=time.monotonic() - started, + omitted_region_summaries=tuple( + (country.country_id, summary.region_type, summary.count) + for country in catalog.countries + for summary in country.omitted_region_summaries + ), ) diff --git a/policyengine_api/data/v2/catalog/publication_types.py b/policyengine_api/data/v2/catalog/publication_types.py index 884bcef07..51865f7c7 100644 --- a/policyengine_api/data/v2/catalog/publication_types.py +++ b/policyengine_api/data/v2/catalog/publication_types.py @@ -18,6 +18,7 @@ class PublicationEvidence: entity_counts: dict[str, int] fallback_summaries: tuple[tuple[str, str, int], ...] elapsed_seconds: float + omitted_region_summaries: tuple[tuple[str, str, int], ...] = () def as_dict(self) -> dict[str, object]: """Return the evidence as the JSON object printed after a publication.""" @@ -34,5 +35,13 @@ def as_dict(self) -> dict[str, object]: } for country_id, region_type, count in self.fallback_summaries ], + "omitted_region_summaries": [ + { + "country_id": country_id, + "region_type": region_type, + "count": count, + } + for country_id, region_type, count in self.omitted_region_summaries + ], "elapsed_seconds": round(self.elapsed_seconds, 3), } diff --git a/policyengine_api/data/v2/catalog/records.py b/policyengine_api/data/v2/catalog/records.py index afccc4add..5be00f616 100644 --- a/policyengine_api/data/v2/catalog/records.py +++ b/policyengine_api/data/v2/catalog/records.py @@ -128,9 +128,20 @@ class RegionRecord: @dataclass(frozen=True, slots=True) class FallbackSummary: - """How many US sub-national regions of one type fell back to the default dataset. + """How many sub-national regions of one type fell back to the default dataset. - These regions had no dataset path of their own. + The certified data release names no dataset for their type. + """ + + region_type: str + count: int + + +@dataclass(frozen=True, slots=True) +class OmittedRegionSummary: + """How many reference regions of one type publication left out. + + No scoping rule can select their households yet, such as US places. """ region_type: str @@ -166,6 +177,7 @@ class CountryCatalog: datasets: tuple[DatasetRecord, ...] regions: tuple[RegionRecord, ...] fallback_summaries: tuple[FallbackSummary, ...] + omitted_region_summaries: tuple[OmittedRegionSummary, ...] = () def parameter_value_batches( self, diff --git a/policyengine_api/data/v2/catalog/region_scoping.py b/policyengine_api/data/v2/catalog/region_scoping.py new file mode 100644 index 000000000..d3ea6629d --- /dev/null +++ b/policyengine_api/data/v2/catalog/region_scoping.py @@ -0,0 +1,313 @@ +"""Give each reference region its dataset and filter from the certified data release. + +A scoping rule per country and region type names the release's dataset role +(a key of the release manifest's ``region_datasets``) and how the region +selects its households: a row filter on one column of that dataset, or a +weight-replacement filter whose field names the release's weight-matrix +artifact and whose value is the region's row code in it. Region types without +a rule cannot run yet, so publication leaves them out and counts them. +""" + +from __future__ import annotations + +import json +from collections import Counter +from collections.abc import Mapping +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from policyengine_api.data.v2.catalog.errors import CatalogExtractionError + +if TYPE_CHECKING: + from policyengine_api.data.v2.catalog.region_references import RegionReference + +ROW_FILTER = "row_filter" +WEIGHT_REPLACEMENT = "weight_replacement" +NATIONAL_DATASET_ROLE = "national" + + +@dataclass(frozen=True, slots=True) +class RegionScopingRule: + """How regions of one type find their dataset and select their households. + + ``filter_field`` is the dataset column of a row filter, or the release's + weight-matrix artifact name of a weight-replacement filter. + """ + + dataset_role: str + filter_strategy: str | None = None + filter_field: str | None = None + + +REGION_SCOPING_RULES: Mapping[tuple[str, str], RegionScopingRule] = { + ("us", "national"): RegionScopingRule(NATIONAL_DATASET_ROLE), + ("us", "state"): RegionScopingRule("state", ROW_FILTER, "state_fips"), + ("us", "congressional_district"): RegionScopingRule( + "congressional_district", ROW_FILTER, "congressional_district_geoid" + ), + ("uk", "national"): RegionScopingRule(NATIONAL_DATASET_ROLE), + ("uk", "country"): RegionScopingRule(NATIONAL_DATASET_ROLE, ROW_FILTER, "region"), + ("uk", "constituency"): RegionScopingRule( + NATIONAL_DATASET_ROLE, + WEIGHT_REPLACEMENT, + "parliamentary_constituency_weights", + ), + ("uk", "local_authority"): RegionScopingRule( + NATIONAL_DATASET_ROLE, WEIGHT_REPLACEMENT, "local_authority_weights" + ), +} + + +@dataclass(frozen=True, slots=True) +class CertifiedArtifact: + """One artifact of a country's certified data release, pinned to a revision.""" + + name: str + repo_id: str + repo_type: str + path: str + revision: str + sha256: str | None + + +@dataclass(frozen=True, slots=True) +class ScopedRegion: + """A reference region with its dataset and the filter selecting its households.""" + + reference: RegionReference + dataset_name: str + filter_strategy: str | None + filter_field: str | None + + +@dataclass(frozen=True, slots=True) +class RegionScoping: + """Every runnable region of one country, and what the others fell back to. + + ``fallback_counts`` counts, by region type, regions whose dataset role the + release does not name, so they use the default dataset; ``omitted_counts`` + counts the regions left out because their type cannot run yet. + """ + + regions: tuple[ScopedRegion, ...] + dataset_names: frozenset[str] + certified_artifacts: Mapping[str, CertifiedArtifact] + fallback_counts: Mapping[str, int] + omitted_counts: Mapping[str, int] + + +def _manifest_text(value: object, *, description: str) -> str: + if not isinstance(value, str) or not value: + msg = f"{description} must be a non-empty string" + raise CatalogExtractionError(msg) + return value + + +def certified_artifacts( + country_id: str, + release_manifest: object, +) -> dict[str, CertifiedArtifact]: + """Return the release manifest's artifacts, with the data package's defaults. + + An artifact without its own repository, repository type or revision is in + the data package's repository at the release revision. + """ + data_package = getattr(release_manifest, "data_package", None) + default_repo_id = getattr(data_package, "repo_id", None) + default_repo_type = getattr(data_package, "repo_type", None) or "model" + default_revision = getattr( + data_package, "release_manifest_revision", None + ) or getattr(data_package, "version", None) + datasets = getattr(release_manifest, "datasets", None) + if not isinstance(datasets, Mapping): + msg = f"{country_id} release manifest has no dataset map" + raise CatalogExtractionError(msg) + artifacts: dict[str, CertifiedArtifact] = {} + for name, reference in datasets.items(): + description = f"{country_id} release artifact {name}" + artifacts[name] = CertifiedArtifact( + name=_manifest_text(name, description=f"{country_id} release artifact"), + repo_id=_manifest_text( + getattr(reference, "repo_id", None) or default_repo_id, + description=f"{description} repository", + ), + repo_type=_manifest_text( + getattr(reference, "repo_type", None) or default_repo_type, + description=f"{description} repository type", + ), + path=_manifest_text( + getattr(reference, "path", None), description=f"{description} path" + ), + revision=_manifest_text( + getattr(reference, "revision", None) or default_revision, + description=f"{description} revision", + ), + sha256=getattr(reference, "sha256", None), + ) + return artifacts + + +def _role_datasets( + country_id: str, + release_manifest: object, + artifacts: Mapping[str, CertifiedArtifact], +) -> dict[str, str]: + """Map each dataset role the release names to its certified dataset's name.""" + region_datasets = getattr(release_manifest, "region_datasets", None) + if not isinstance(region_datasets, Mapping): + msg = f"{country_id} release manifest has no region dataset map" + raise CatalogExtractionError(msg) + names_by_path = {artifact.path: name for name, artifact in artifacts.items()} + role_datasets: dict[str, str] = {} + for role, template in region_datasets.items(): + path = _manifest_text( + getattr(template, "path_template", None), + description=f"{country_id} {role} region dataset", + ) + if "{" in path: + msg = ( + f"{country_id} {role} regions name a per-region dataset template " + f"{path!r}, which the catalog does not support" + ) + raise CatalogExtractionError(msg) + if path not in names_by_path: + msg = ( + f"{country_id} release names no certified dataset at path {path!r} " + f"for {role} regions" + ) + raise CatalogExtractionError(msg) + role_datasets[role] = names_by_path[path] + return role_datasets + + +def _checked_filter_value(reference: RegionReference, rule: RegionScopingRule) -> None: + """Require a filter value exactly when the rule filters, and valid list values.""" + value = reference.filter_value + if rule.filter_strategy is None: + if value is not None: + msg = ( + f"region {reference.code!r} has a filter value, but a national region " + "selects every household" + ) + raise CatalogExtractionError(msg) + return + if value is None: + msg = f"region {reference.code!r} has no filter value" + raise CatalogExtractionError(msg) + if value.startswith(("[", "{")): + try: + members = json.loads(value, parse_constant=_reject_constant) + except ValueError: + members = None + if not ( + isinstance(members, list) + and members + and all(isinstance(member, (str, int, float, bool)) for member in members) + ): + msg = f"region {reference.code!r} has an unsupported filter value" + raise CatalogExtractionError(msg) + + +def _reject_constant(constant: str) -> object: + msg = f"{constant} is not a filter value" + raise ValueError(msg) + + +def _checked_weight_matrix( + country_id: str, + reference: RegionReference, + matrix_name: str, + dataset_name: str, + artifacts: Mapping[str, CertifiedArtifact], +) -> None: + """Require the weight matrix from the same release as the dataset it reweights. + + Weights attach to households by position, so only a matrix built with the + region's dataset lines up with its households. + """ + matrix = artifacts.get(matrix_name) + if matrix is None: + msg = ( + f"region {reference.code!r} reweights households with {matrix_name}, " + f"which the {country_id} release does not certify" + ) + raise CatalogExtractionError(msg) + dataset = artifacts[dataset_name] + if (matrix.repo_id, matrix.revision) != (dataset.repo_id, dataset.revision): + msg = ( + f"region {reference.code!r} reweights households with {matrix_name}, " + f"which is not certified in the release of {dataset_name}" + ) + raise CatalogExtractionError(msg) + + +def scope_regions( + country_id: str, + references: tuple[RegionReference, ...], + release_manifest: object, + *, + reviewed_default: str, +) -> RegionScoping: + """Give every runnable reference region its certified dataset and filter. + + The national region must use the reviewed default dataset. A region whose + type has no scoping rule is left out, and a runnable region may not sit + under a region that is left out. + """ + artifacts = certified_artifacts(country_id, release_manifest) + if reviewed_default not in artifacts: + msg = f"{country_id} release does not certify {reviewed_default}" + raise CatalogExtractionError(msg) + role_datasets = _role_datasets(country_id, release_manifest, artifacts) + if role_datasets.get(NATIONAL_DATASET_ROLE, reviewed_default) != reviewed_default: + msg = f"{country_id} national region does not use the reviewed dataset" + raise CatalogExtractionError(msg) + + omitted = { + reference.code: reference.region_type + for reference in references + if (country_id, reference.region_type) not in REGION_SCOPING_RULES + } + fallback_counts: Counter[str] = Counter() + regions: list[ScopedRegion] = [] + for reference in references: + if reference.code in omitted: + continue + if reference.parent_code in omitted: + msg = ( + f"region {reference.code!r} cannot be published because its parent " + f"{reference.parent_code!r} is left out" + ) + raise CatalogExtractionError(msg) + rule = REGION_SCOPING_RULES[country_id, reference.region_type] + _checked_filter_value(reference, rule) + dataset_name = role_datasets.get(rule.dataset_role) + if dataset_name is None: + dataset_name = reviewed_default + if rule.dataset_role != NATIONAL_DATASET_ROLE: + fallback_counts[reference.region_type] += 1 + if rule.filter_strategy == WEIGHT_REPLACEMENT: + _checked_weight_matrix( + country_id, + reference, + rule.filter_field or "", + dataset_name, + artifacts, + ) + regions.append( + ScopedRegion( + reference=reference, + dataset_name=dataset_name, + filter_strategy=rule.filter_strategy, + filter_field=rule.filter_field, + ) + ) + return RegionScoping( + regions=tuple(regions), + dataset_names=frozenset( + {reviewed_default, *(region.dataset_name for region in regions)} + ), + certified_artifacts=artifacts, + fallback_counts=dict(sorted(fallback_counts.items())), + omitted_counts=dict(sorted(Counter(omitted.values()).items())), + ) diff --git a/policyengine_api/data/v2/models/metadata.py b/policyengine_api/data/v2/models/metadata.py index 8d91864df..324ad521b 100644 --- a/policyengine_api/data/v2/models/metadata.py +++ b/policyengine_api/data/v2/models/metadata.py @@ -111,8 +111,11 @@ class Region(TimestampedModel, table=True): """A region in one model version, unique by code within that version. Its default dataset must belong to the same version. Filtered regions - (``requires_filter``) also name the variable and value that select their - households. + (``requires_filter``) also name how they select their households: a + ``row_filter`` keeps the households whose ``filter_field`` column matches + ``filter_value``; a ``weight_replacement`` filter reweights every household + with row ``filter_value`` of the weight matrix ``filter_field`` names in the + certified data release. """ __tablename__ = "regions" diff --git a/policyengine_api/services/v2/metadata/types.py b/policyengine_api/services/v2/metadata/types.py index 7f41e1575..97ec2ebeb 100644 --- a/policyengine_api/services/v2/metadata/types.py +++ b/policyengine_api/services/v2/metadata/types.py @@ -136,8 +136,12 @@ class MetadataDataset(StrictResponseModel): class MetadataRegion(StrictResponseModel): """A region the model can simulate, such as a country, state or constituency. - When requires_filter is true, the region is simulated by filtering its - default dataset on filter_field and filter_value. + When requires_filter is true, filter_strategy says how the region selects + households of its default dataset. With "row_filter", it keeps the + households whose filter_field column equals filter_value (or is in it, + when filter_value is a JSON list). With "weight_replacement", it keeps + every household and gives each the weight in row filter_value of the + weight matrix that filter_field names in the certified data release. """ id: UUID diff --git a/tests/fixtures/v2_catalog.py b/tests/fixtures/v2_catalog.py index 77f5cbd20..e3de5fc62 100644 --- a/tests/fixtures/v2_catalog.py +++ b/tests/fixtures/v2_catalog.py @@ -2,12 +2,12 @@ from __future__ import annotations -from dataclasses import dataclass from datetime import UTC, datetime from types import SimpleNamespace from typing import TYPE_CHECKING from policyengine_api.data.v2.catalog.extraction import extract_catalog +from policyengine_api.data.v2.catalog.region_references import RegionReference if TYPE_CHECKING: from policyengine_api.data.v2.catalog.records import NormalizedCatalog @@ -20,49 +20,139 @@ } -class RegionRegistry(list): - """Small iterable with the public registry's country identifier.""" +def region_references() -> dict[str, tuple[RegionReference, ...]]: + """Return small US and UK reference files. - def __init__(self, country_id: str, regions: list[SimpleNamespace]): - super().__init__(regions) - self.country_id = country_id + The US place has no filter rule, so publication leaves it out. + """ + return { + "us": ( + RegionReference( + code="us", + label="United States", + region_type="national", + parent_code=None, + state_code=None, + state_name=None, + filter_value=None, + v1_code="us", + ), + RegionReference( + code="state/ca", + label="California", + region_type="state", + parent_code="us", + state_code="CA", + state_name="California", + filter_value="6", + v1_code="state/ca", + ), + RegionReference( + code="congressional_district/CA-01", + label="California's 1st congressional district", + region_type="congressional_district", + parent_code="state/ca", + state_code="CA", + state_name="California", + filter_value="601", + v1_code="congressional_district/CA-01", + ), + RegionReference( + code="place/CA-44000", + label="Los Angeles", + region_type="place", + parent_code="state/ca", + state_code="CA", + state_name="California", + filter_value=None, + v1_code="place/CA-44000", + ), + ), + "uk": ( + RegionReference( + code="uk", + label="United Kingdom", + region_type="national", + parent_code=None, + state_code=None, + state_name=None, + filter_value=None, + v1_code="uk", + ), + RegionReference( + code="country/england", + label="England", + region_type="country", + parent_code="uk", + state_code=None, + state_name=None, + filter_value='["NORTH_EAST","LONDON"]', + v1_code="country/england", + ), + RegionReference( + code="constituency/E14001063", + label="Aldershot", + region_type="constituency", + parent_code="country/england", + state_code=None, + state_name=None, + filter_value="E14001063", + v1_code="constituency/Aldershot", + ), + ), + } -def _strategy(field: str, value: str | int) -> SimpleNamespace: +def _certified_artifact(path: str, *, revision: str = "fixture") -> SimpleNamespace: return SimpleNamespace( - strategy_type="row_filter", - variable_name=field, - variable_value=value, - additional_filters={}, + path=path, + revision=revision, + sha256="0" * 64, + repo_id=None, + repo_type=None, ) -@dataclass(frozen=True, kw_only=True) -class _Region: - """One public region; ``source`` gives the mutable object the registry holds.""" - - code: str - label: str - region_type: str - parent_code: str | None = None - dataset_path: str | None = None - strategy: SimpleNamespace | None = None - state_code: str | None = None - state_name: str | None = None - - def source(self) -> SimpleNamespace: - """Return the region as a registry entry; it needs a filter if scoped.""" - return SimpleNamespace( - code=self.code, - label=self.label, - region_type=self.region_type, - parent_code=self.parent_code, - dataset_path=self.dataset_path, - scoping_strategy=self.strategy, - requires_filter=self.strategy is not None, - state_code=self.state_code, - state_name=self.state_name, +def _release_contents(country_id: str) -> tuple[dict, dict, SimpleNamespace]: + """Return a country's certified datasets, region datasets and data package.""" + if country_id == "us": + datasets = { + "populace_us_2024": _certified_artifact("populace_us_2024.h5"), + "populace_us_2024_acs_local": _certified_artifact( + "populace_us_2024_acs_local.h5" + ), + } + region_datasets = { + "national": SimpleNamespace(path_template="populace_us_2024.h5"), + "state": SimpleNamespace(path_template="populace_us_2024_acs_local.h5"), + "congressional_district": SimpleNamespace( + path_template="populace_us_2024_acs_local.h5" + ), + } + data_package = SimpleNamespace( + repo_id="policyengine/populace-us", + repo_type="dataset", + version="0.1.0", + release_manifest_revision="fixture", ) + return datasets, region_datasets, data_package + datasets = { + "microcosm_uk_2024_25": _certified_artifact("microcosm_uk_2024_25.h5"), + "parliamentary_constituency_weights": _certified_artifact( + "parliamentary_constituency_weights.h5" + ), + "local_authority_weights": _certified_artifact("local_authority_weights.h5"), + } + region_datasets = { + "national": SimpleNamespace(path_template="microcosm_uk_2024_25.h5"), + } + data_package = SimpleNamespace( + repo_id="example-org/example-uk-data", + repo_type="model", + version="1.0.0", + release_manifest_revision="fixture", + ) + return datasets, region_datasets, data_package def bundle(*, policyengine_version: str = POLICYENGINE_VERSION) -> dict: @@ -88,7 +178,6 @@ def _model_source( country_id: str, country_package_version: str, policyengine_version: str, - regions: list[SimpleNamespace], ) -> SimpleNamespace: model_name = f"policyengine-{country_id}" default_dataset = { @@ -141,13 +230,16 @@ def _model_source( name=model_name, version=country_package_version, ) + datasets, region_datasets, data_package = _release_contents(country_id) version_holder.release_manifest = SimpleNamespace( country_id=country_id, policyengine_version=policyengine_version, default_dataset=default_dataset, default_dataset_uri=default_uri, + datasets=datasets, + region_datasets=region_datasets, + data_package=data_package, ) - version_holder.region_registry = RegionRegistry(country_id, regions) version_holder.variables = [variable] version_holder.parameter_nodes = [parameter_node] version_holder.parameters = [parameter] @@ -162,60 +254,13 @@ def source_models( policyengine_version: str = POLICYENGINE_VERSION, ) -> dict[str, SimpleNamespace]: """Return US and UK public-model fixtures.""" - us_default_uri = "hf://policyengine/us/populace_us_2024.h5@fixture" - uk_default_uri = "hf://policyengine/uk/microcosm_uk_2024_25.h5@fixture" return { - "us": _model_source( - country_id="us", - country_package_version=DEPENDENCY_VERSIONS["policyengine-us"], - policyengine_version=policyengine_version, - regions=[ - _Region( - code="us", - label="United States", - region_type="national", - dataset_path=us_default_uri, - ).source(), - _Region( - code="state/ca", - label="California", - region_type="state", - parent_code="us", - dataset_path="hf://policyengine/us/populace_us_ca_2024.h5@fixture", - strategy=_strategy("state_fips", 6), - state_code="CA", - state_name="California", - ).source(), - _Region( - code="place/CA-44000", - label="Los Angeles", - region_type="place", - parent_code="state/ca", - state_code="CA", - state_name="California", - ).source(), - ], - ), - "uk": _model_source( - country_id="uk", - country_package_version=DEPENDENCY_VERSIONS["policyengine-uk"], + country_id: _model_source( + country_id=country_id, + country_package_version=DEPENDENCY_VERSIONS[f"policyengine-{country_id}"], policyengine_version=policyengine_version, - regions=[ - _Region( - code="uk", - label="United Kingdom", - region_type="national", - dataset_path=uk_default_uri, - ).source(), - _Region( - code="country/england", - label="England", - region_type="country", - parent_code="uk", - strategy=_strategy("country", "ENGLAND"), - ).source(), - ], - ), + ) + for country_id in ("us", "uk") } @@ -234,4 +279,5 @@ def normalized_catalog( policyengine_version=policyengine_version, models=source_models(policyengine_version=policyengine_version), installed_version=installed_version, + region_references=region_references(), ) diff --git a/tests/integration/test_v2_catalog_installed.py b/tests/integration/test_v2_catalog_installed.py index 0f9106e06..778b72d07 100644 --- a/tests/integration/test_v2_catalog_installed.py +++ b/tests/integration/test_v2_catalog_installed.py @@ -4,6 +4,7 @@ import json import os +from collections import Counter from importlib import metadata as importlib_metadata import pytest @@ -49,8 +50,8 @@ def test_installed_policyengine_catalog_is_complete_and_bounded() -> None: "parameter_nodes": 29_307, "parameters": 104_172, "parameter_values": 1_194_360, - "datasets": 4, - "regions": 1_837, + "datasets": 3, + "regions": 1_503, } for country_id in ("us", "uk"): @@ -121,7 +122,8 @@ def test_installed_policyengine_catalog_is_complete_and_bounded() -> None: # Since 6.7.5, US state and congressional-district regions use the # ACS-local dataset again, on the PolicyEngine/microcosm#1263 rebuild # (PolicyEngine/policyengine.py#649, ending #604's national-file filter). - # The national region uses the national dataset, and places fall back to it. + # The national region uses the national dataset. Regions come from the + # checked-in reference files; US places cannot run yet and are left out. us_catalog = catalog.country("us") us_dataset_names = {dataset.id: dataset.name for dataset in us_catalog.datasets} region_type_datasets: dict[str, set[str]] = {} @@ -133,11 +135,29 @@ def test_installed_policyengine_catalog_is_complete_and_bounded() -> None: "national": {"populace_us_2024"}, "state": {"populace_us_2024_acs_local"}, "congressional_district": {"populace_us_2024_acs_local"}, - "place": {"populace_us_2024"}, } + assert us_catalog.fallback_summaries == () assert [ (summary.region_type, summary.count) - for summary in us_catalog.fallback_summaries - ] == [ - ("place", 333), - ] + for summary in us_catalog.omitted_region_summaries + ] == [("place", 333)] + + uk_catalog = catalog.country("uk") + uk_types = Counter(region.region_type for region in uk_catalog.regions) + assert uk_types == { + "national": 1, + "country": 4, + "constituency": 650, + "local_authority": 360, + } + uk_regions = {region.code: region for region in uk_catalog.regions} + aldershot = uk_regions["constituency/E14001063"] + assert ( + aldershot.filter_strategy, + aldershot.filter_field, + aldershot.filter_value, + ) == ("weight_replacement", "parliamentary_constituency_weights", "E14001063") + assert uk_regions["uk"].label == "United Kingdom" + assert {region.code: region.label for region in us_catalog.regions}["us"] == ( + "United States" + ) diff --git a/tests/integration/test_v2_catalog_publication.py b/tests/integration/test_v2_catalog_publication.py index 25bc9178f..c894c8b45 100644 --- a/tests/integration/test_v2_catalog_publication.py +++ b/tests/integration/test_v2_catalog_publication.py @@ -121,6 +121,7 @@ def test_publish_and_republish_preserve_complete_catalog( second = publish_catalog(publication_engine, catalog) assert first.as_dict()["outcome"] == "ok" + assert first.omitted_region_summaries == (("us", "place", 1),) assert second.policyengine_version == first.policyengine_version assert ( _counts(publication_engine) @@ -134,7 +135,7 @@ def test_publish_and_republish_preserve_complete_catalog( "parameter_values": 4, "datasets": 3, "dataset_versions": 0, - "regions": 5, + "regions": 6, "simulations": 0, "reports": 0, "report_runs": 0, @@ -163,9 +164,10 @@ def test_publish_and_republish_preserve_complete_catalog( storage_path is None and not output for _, storage_path, output in datasets ) assert dict(region_defaults) == { + "congressional_district/CA-01": "populace_us_2024_acs_local", + "constituency/E14001063": "microcosm_uk_2024_25", "country/england": "microcosm_uk_2024_25", - "place/CA-44000": "populace_us_2024", - "state/ca": "populace_us_ca_2024", + "state/ca": "populace_us_2024_acs_local", "uk": "microcosm_uk_2024_25", "us": "populace_us_2024", } @@ -335,7 +337,7 @@ def test_new_version_is_additive_and_concurrent_retries_serialize( "parameter_values": 8, "datasets": 6, "dataset_versions": 0, - "regions": 10, + "regions": 12, "simulations": 0, "reports": 0, "report_runs": 0, diff --git a/tests/unit/v2/test_catalog_extraction.py b/tests/unit/v2/test_catalog_extraction.py index f88319cd8..8bd5cf519 100644 --- a/tests/unit/v2/test_catalog_extraction.py +++ b/tests/unit/v2/test_catalog_extraction.py @@ -3,7 +3,7 @@ from __future__ import annotations import ast -import json +from dataclasses import replace from datetime import UTC, datetime from importlib.metadata import PackageNotFoundError from pathlib import Path @@ -22,6 +22,7 @@ bundle, installed_version, normalized_catalog, + region_references, source_models, ) @@ -39,7 +40,7 @@ def test_extracts_canonical_policyengine_catalog_and_dataset_defaults() -> None: assert {dataset.name for dataset in us.datasets} == { "populace_us_2024", - "populace_us_ca_2024", + "populace_us_2024_acs_local", } assert {dataset.name for dataset in uk.datasets} == {"microcosm_uk_2024_25"} assert all( @@ -52,74 +53,104 @@ def test_extracts_canonical_policyengine_catalog_and_dataset_defaults() -> None: region.code: us_datasets[region.default_dataset_id] for region in us.regions } assert us_defaults == { - "place/CA-44000": "populace_us_2024", - "state/ca": "populace_us_ca_2024", + "congressional_district/CA-01": "populace_us_2024_acs_local", + "state/ca": "populace_us_2024_acs_local", "us": "populace_us_2024", } + assert us.fallback_summaries == () assert [ - (summary.region_type, summary.count) for summary in us.fallback_summaries + (summary.region_type, summary.count) for summary in us.omitted_region_summaries ] == [("place", 1)] + assert uk.omitted_region_summaries == () assert all(region.default_dataset_id == uk.datasets[0].id for region in uk.regions) -UK_DENSE_URI = "hf://policyengine/uk/microcosm_uk_2024_25_dense.h5@fixture" - +def test_regions_take_labels_from_references_and_filters_from_scoping_rules() -> None: + catalog = normalized_catalog() + regions = { + region.code: ( + region.label, + region.region_type, + region.parent_code, + region.requires_filter, + region.filter_strategy, + region.filter_field, + region.filter_value, + region.state_code, + region.state_name, + ) + for country_id in ("us", "uk") + for region in catalog.country(country_id).regions + } -def _uk_area_region(code: str, region_type: str, filter_field: str) -> SimpleNamespace: - """Return a UK area region routed to the local-area dataset, as .py 6.10 does.""" - return SimpleNamespace( - code=code, - label=code.split("/", maxsplit=1)[1], - region_type=region_type, - parent_code="uk", - dataset_path=UK_DENSE_URI, - scoping_strategy=SimpleNamespace( - strategy_type="row_filter", - variable_name=filter_field, - variable_value=code.split("/", maxsplit=1)[1], - additional_filters={}, + assert regions == { + "us": ("United States", "national", None, False, None, None, None, None, None), + "state/ca": ( + "California", + "state", + "us", + True, + "row_filter", + "state_fips", + "6", + "CA", + "California", ), - requires_filter=True, - state_code=None, - state_name=None, - ) + "congressional_district/CA-01": ( + "California's 1st congressional district", + "congressional_district", + "state/ca", + True, + "row_filter", + "congressional_district_geoid", + "601", + "CA", + "California", + ), + "uk": ("United Kingdom", "national", None, False, None, None, None, None, None), + "country/england": ( + "England", + "country", + "uk", + True, + "row_filter", + "region", + '["NORTH_EAST","LONDON"]', + None, + None, + ), + "constituency/E14001063": ( + "Aldershot", + "constituency", + "country/england", + True, + "weight_replacement", + "parliamentary_constituency_weights", + "E14001063", + None, + None, + ), + } -def test_uk_area_regions_use_the_dataset_their_path_names() -> None: +def test_sub_national_regions_without_a_release_dataset_use_the_default() -> None: models = source_models() - models["uk"].region_registry.extend( - [ - _uk_area_region( - "constituency/E14001063", "constituency", "constituency_code_oa" - ), - _uk_area_region( - "local_authority/E07000092", "local_authority", "la_code_oa" - ), - ] - ) + del models["us"].release_manifest.region_datasets["state"] + del models["us"].release_manifest.region_datasets["congressional_district"] - uk = extract_catalog( + us = extract_catalog( + region_references=region_references(), bundle=bundle(), policyengine_version=POLICYENGINE_VERSION, models=models, installed_version=installed_version, - ).country("uk") + ).country("us") - assert {dataset.name for dataset in uk.datasets} == { - "microcosm_uk_2024_25", - "microcosm_uk_2024_25_dense", - } - uk_datasets = {dataset.id: dataset.name for dataset in uk.datasets} - assert { - region.code: uk_datasets[region.default_dataset_id] for region in uk.regions - } == { - "uk": "microcosm_uk_2024_25", - "country/england": "microcosm_uk_2024_25", - "constituency/E14001063": "microcosm_uk_2024_25_dense", - "local_authority/E07000092": "microcosm_uk_2024_25_dense", - } - # A UK country filters the national dataset by design; it is no fallback. - assert uk.fallback_summaries == () + assert {dataset.name for dataset in us.datasets} == {"populace_us_2024"} + assert all(region.default_dataset_id == us.datasets[0].id for region in us.regions) + assert [ + (summary.region_type, summary.count) for summary in us.fallback_summaries + ] == [("congressional_district", 1), ("state", 1)] def test_durable_ids_are_deterministic_and_stable_at_the_intended_scope() -> None: @@ -179,62 +210,56 @@ def item(self): assert normalize_json_value(float("-inf")) == "-Infinity" -@pytest.mark.parametrize("value", [("NORTH_EAST", "LONDON"), ["NORTH_EAST", "LONDON"]]) -def test_serializes_region_membership_filters_in_the_existing_text_field( - value: object, -) -> None: - models = source_models() - models["uk"].region_registry[1].scoping_strategy.variable_value = value - - catalog = extract_catalog( - bundle=bundle(), - policyengine_version=POLICYENGINE_VERSION, - models=models, - installed_version=installed_version, - ) - england = next( - region - for region in catalog.country("uk").regions - if region.code == "country/england" +@pytest.mark.parametrize("value", ['["NORTH_EAST","LONDON"]', "ENGLAND", "6"]) +def test_keeps_reference_filter_values_as_text(value: str) -> None: + references = region_references() + england = references["uk"][1] + references["uk"] = ( + references["uk"][0], + replace(england, filter_value=value), + references["uk"][2], ) - assert isinstance(england.filter_value, str) - assert json.loads(england.filter_value) == ["NORTH_EAST", "LONDON"] - - -@pytest.mark.parametrize("value", ["ENGLAND", 6, True, 1.5]) -def test_preserves_existing_scalar_region_filter_serialization(value: object) -> None: - models = source_models() - models["uk"].region_registry[1].scoping_strategy.variable_value = value - catalog = extract_catalog( + region_references=references, bundle=bundle(), policyengine_version=POLICYENGINE_VERSION, - models=models, + models=source_models(), installed_version=installed_version, ) - england = next( + england_record = next( region for region in catalog.country("uk").regions if region.code == "country/england" ) - assert england.filter_value == str(value) + assert england_record.filter_value == value @pytest.mark.parametrize( "value", - [[], [["LONDON"]], [None], [object()], [float("nan")], ["x" * 256], "x" * 256], + ["[]", '[["LONDON"]]', "[null]", '[{"a": 1}]', "[NaN]", "[", '{"a": 1}'], ) -def test_rejects_invalid_or_oversized_region_membership_values(value: object) -> None: - models = source_models() - models["uk"].region_registry[1].scoping_strategy.variable_value = value +def test_rejects_filter_value_lists_that_are_not_flat_json_scalars( + value: str, +) -> None: + references = region_references() + england = references["uk"][1] + references["uk"] = ( + references["uk"][0], + replace(england, filter_value=value), + references["uk"][2], + ) - with pytest.raises(CatalogExtractionError, match="filter value"): + with pytest.raises( + CatalogExtractionError, + match=r"^region 'country/england' has an unsupported filter value$", + ): extract_catalog( + region_references=references, bundle=bundle(), policyengine_version=POLICYENGINE_VERSION, - models=models, + models=source_models(), installed_version=installed_version, ) @@ -250,6 +275,7 @@ def test_rejects_missing_or_mismatched_manifest_dependencies() -> None: del missing["packages"]["policyengine-core"] with pytest.raises(CatalogExtractionError, match="omits policyengine-core"): extract_catalog( + region_references=region_references(), bundle=missing, policyengine_version=POLICYENGINE_VERSION, models=source_models(), @@ -258,6 +284,7 @@ def test_rejects_missing_or_mismatched_manifest_dependencies() -> None: with pytest.raises(CatalogExtractionError, match="does not match"): extract_catalog( + region_references=region_references(), bundle=bundle(), policyengine_version=POLICYENGINE_VERSION, models=source_models(), @@ -271,6 +298,7 @@ def absent(_name: str) -> str: with pytest.raises(CatalogExtractionError, match="is absent"): extract_catalog( + region_references=region_references(), bundle=bundle(), policyengine_version=POLICYENGINE_VERSION, models=source_models(), @@ -282,6 +310,7 @@ def absent(_name: str) -> str: def test_rejects_placeholder_policyengine_versions(version: str) -> None: with pytest.raises(CatalogExtractionError, match="placeholder"): extract_catalog( + region_references=region_references(), bundle=bundle(policyengine_version=version), policyengine_version=version, models=source_models(policyengine_version=version), @@ -294,6 +323,7 @@ def test_rejects_incomplete_public_model_catalogs() -> None: del models["uk"] with pytest.raises(CatalogExtractionError, match="absent for: uk"): extract_catalog( + region_references=region_references(), bundle=bundle(), policyengine_version=POLICYENGINE_VERSION, models=models, @@ -304,6 +334,7 @@ def test_rejects_incomplete_public_model_catalogs() -> None: models["us"].release_manifest.default_dataset = "unreviewed_us_2025" with pytest.raises(CatalogExtractionError, match="reviewed selection"): extract_catalog( + region_references=region_references(), bundle=bundle(), policyengine_version=POLICYENGINE_VERSION, models=models, @@ -321,6 +352,7 @@ def test_rejects_inconsistent_country_model_identity(identity_source: str) -> No with pytest.raises(CatalogExtractionError, match="identity does not match"): extract_catalog( + region_references=region_references(), bundle=bundle(), policyengine_version=POLICYENGINE_VERSION, models=models, @@ -335,6 +367,7 @@ def test_ignores_only_the_unnamed_structural_parameter_root() -> None: ) catalog = extract_catalog( + region_references=region_references(), bundle=bundle(), policyengine_version=POLICYENGINE_VERSION, models=models, @@ -352,6 +385,7 @@ def test_uses_and_validates_public_name_indexed_mappings() -> None: models["uk"].variables_by_name["wrong_name"] = variable with pytest.raises(CatalogExtractionError, match="does not match record name"): extract_catalog( + region_references=region_references(), bundle=bundle(), policyengine_version=POLICYENGINE_VERSION, models=models, @@ -359,33 +393,25 @@ def test_uses_and_validates_public_name_indexed_mappings() -> None: ) -def test_rejects_duplicate_keys_invalid_intervals_and_unknown_region_types() -> None: +def test_rejects_unindexed_mappings_and_invalid_parameter_intervals() -> None: duplicate = source_models() duplicate["us"].parameters_by_name = [] with pytest.raises(CatalogExtractionError, match="name-indexed mapping"): extract_catalog( + region_references=region_references(), bundle=bundle(), policyengine_version=POLICYENGINE_VERSION, models=duplicate, installed_version=installed_version, ) - unknown_region = source_models() - unknown_region["us"].region_registry[1].region_type = "province" - with pytest.raises(CatalogExtractionError, match="unsupported type"): - extract_catalog( - bundle=bundle(), - policyengine_version=POLICYENGINE_VERSION, - models=unknown_region, - installed_version=installed_version, - ) - invalid_interval = source_models() invalid_interval["us"].parameters_by_name["gov.example.rate"].parameter_values[ 0 ].end_date = datetime(2026, 1, 1, tzinfo=UTC) with pytest.raises(CatalogExtractionError, match="canonical inclusive intervals"): extract_catalog( + region_references=region_references(), bundle=bundle(), policyengine_version=POLICYENGINE_VERSION, models=invalid_interval, @@ -399,6 +425,7 @@ def test_rejects_parameter_values_that_are_not_oldest_to_newest() -> None: parameter.parameter_values.reverse() with pytest.raises(CatalogExtractionError, match="oldest to newest"): extract_catalog( + region_references=region_references(), bundle=bundle(), policyengine_version=POLICYENGINE_VERSION, models=models, @@ -419,6 +446,7 @@ def test_collapses_equal_parameter_values_at_the_same_effective_date() -> None: ) catalog = extract_catalog( + region_references=region_references(), bundle=bundle(), policyengine_version=POLICYENGINE_VERSION, models=models, @@ -446,6 +474,7 @@ def test_rejects_conflicting_parameter_values_at_the_same_effective_date() -> No with pytest.raises(CatalogExtractionError, match="conflicting values"): extract_catalog( + region_references=region_references(), bundle=bundle(), policyengine_version=POLICYENGINE_VERSION, models=models, @@ -467,6 +496,7 @@ def test_rejects_repeated_parameter_values_with_inconsistent_intervals() -> None with pytest.raises(CatalogExtractionError, match="inconsistent intervals"): extract_catalog( + region_references=region_references(), bundle=bundle(), policyengine_version=POLICYENGINE_VERSION, models=models, @@ -491,6 +521,7 @@ def test_preserves_consecutive_day_parameter_value_intervals() -> None: ] catalog = extract_catalog( + region_references=region_references(), bundle=bundle(), policyengine_version=POLICYENGINE_VERSION, models=models, diff --git a/tests/unit/v2/test_catalog_extraction_failures.py b/tests/unit/v2/test_catalog_extraction_failures.py index fa46c3f80..20dce3fe6 100644 --- a/tests/unit/v2/test_catalog_extraction_failures.py +++ b/tests/unit/v2/test_catalog_extraction_failures.py @@ -3,9 +3,10 @@ from __future__ import annotations import math +from dataclasses import replace from datetime import date from enum import Enum -from typing import TYPE_CHECKING +from types import SimpleNamespace import pytest @@ -14,19 +15,19 @@ extract_catalog, normalize_json_value, ) +from policyengine_api.data.v2.catalog.region_references import RegionReference from tests.fixtures.v2_catalog import ( POLICYENGINE_VERSION, bundle, installed_version, + region_references, source_models, ) -if TYPE_CHECKING: - from types import SimpleNamespace - def _extract(models: dict[str, SimpleNamespace]) -> None: extract_catalog( + region_references=region_references(), bundle=bundle(), policyengine_version=POLICYENGINE_VERSION, models=models, @@ -46,97 +47,198 @@ def _break_manifest_version(models: dict[str, SimpleNamespace]) -> None: models["us"].release_manifest.policyengine_version = "9.9.9" -def _remove_region_registry(models: dict[str, SimpleNamespace]) -> None: - models["us"].region_registry = None +@pytest.mark.parametrize( + ("break_models", "message"), + [ + ( + _break_package_version, + "^us public model does not match the PolicyEngine.py manifest$", + ), + ( + _break_manifest_country, + "^us public model has an inconsistent release manifest$", + ), + ( + _break_manifest_version, + "^us public model was not certified by the installed PolicyEngine.py " + "version$", + ), + ], +) +def test_inconsistent_public_models_fail_with_a_specific_message( + break_models, + message: str, +) -> None: + models = source_models() + break_models(models) + with pytest.raises(CatalogExtractionError, match=message): + _extract(models) -def _mislabel_region_registry(models: dict[str, SimpleNamespace]) -> None: - models["us"].region_registry.country_id = "uk" +References = dict[str, tuple[RegionReference, ...]] -def _duplicate_region_codes(models: dict[str, SimpleNamespace]) -> None: - registry = models["us"].region_registry - registry.append(registry[1]) +def _replace_reference( + references: References, country_id: str, code: str, **changes: str | None +) -> None: + references[country_id] = tuple( + replace(reference, **changes) if reference.code == code else reference + for reference in references[country_id] + ) -def _remove_national_region(models: dict[str, SimpleNamespace]) -> None: - registry = models["us"].region_registry - del registry[0] + +def _point_national_region_at_another_dataset( + models: dict[str, SimpleNamespace], _references: References +) -> None: + models["uk"].release_manifest.region_datasets[ + "national" + ].path_template = "frs_2024_25.h5" + models["uk"].release_manifest.datasets["frs_2024_25"] = SimpleNamespace( + path="frs_2024_25.h5", revision="fixture", repo_id=None, repo_type=None + ) -def _give_national_region_another_dataset(models: dict[str, SimpleNamespace]) -> None: - models["uk"].region_registry[0].dataset_path = "hf://policyengine/uk/other.h5" +def _name_an_uncertified_region_dataset( + models: dict[str, SimpleNamespace], _references: References +) -> None: + models["us"].release_manifest.region_datasets["state"].path_template = "missing.h5" -def _give_filter_an_unsupported_value(models: dict[str, SimpleNamespace]) -> None: - models["us"].region_registry[1].scoping_strategy.variable_value = {"state": "CA"} +def _name_a_per_region_dataset_template( + models: dict[str, SimpleNamespace], _references: References +) -> None: + models["us"].release_manifest.region_datasets[ + "state" + ].path_template = "populace_{state_code}.h5" -def _give_filter_additional_filters(models: dict[str, SimpleNamespace]) -> None: - models["uk"].region_registry[1].scoping_strategy.additional_filters = { - "region": "LONDON" - } +def _remove_the_weight_matrix( + models: dict[str, SimpleNamespace], _references: References +) -> None: + del models["uk"].release_manifest.datasets["parliamentary_constituency_weights"] + + +def _take_the_weight_matrix_from_another_release( + models: dict[str, SimpleNamespace], _references: References +) -> None: + matrix = models["uk"].release_manifest.datasets[ + "parliamentary_constituency_weights" + ] + matrix.revision = "another-release" + + +def _drop_a_filter_value( + _models: dict[str, SimpleNamespace], references: References +) -> None: + _replace_reference(references, "us", "state/ca", filter_value=None) + + +def _give_the_national_region_a_filter_value( + _models: dict[str, SimpleNamespace], references: References +) -> None: + _replace_reference(references, "us", "us", filter_value="0") + + +def _place_a_district_under_a_place( + _models: dict[str, SimpleNamespace], references: References +) -> None: + _replace_reference( + references, + "us", + "congressional_district/CA-01", + parent_code="place/CA-44000", + ) + + +def _remove_a_country_from_the_references( + _models: dict[str, SimpleNamespace], references: References +) -> None: + del references["uk"] @pytest.mark.parametrize( - ("break_models", "message"), + ("break_sources", "message"), [ ( - _break_package_version, - "^us public model does not match the PolicyEngine.py manifest$", + _point_national_region_at_another_dataset, + "^uk national region does not use the reviewed dataset$", ), ( - _break_manifest_country, - "^us public model has an inconsistent release manifest$", + _name_an_uncertified_region_dataset, + "^us release names no certified dataset at path 'missing.h5' for state " + "regions$", ), ( - _break_manifest_version, - "^us public model was not certified by the installed PolicyEngine.py " - "version$", + _name_a_per_region_dataset_template, + r"^us state regions name a per-region dataset template " + r"'populace_\{state_code\}.h5', which the catalog does not support$", ), - (_remove_region_registry, "^us public region registry is absent$"), - (_mislabel_region_registry, "^us public region registry is absent$"), ( - _duplicate_region_codes, - r"^duplicate region natural keys: \['state/ca'\]$", + _remove_the_weight_matrix, + "^region 'constituency/E14001063' reweights households with " + "parliamentary_constituency_weights, which the uk release does not " + "certify$", ), - (_remove_national_region, "^us national region is absent$"), ( - _give_national_region_another_dataset, - "^uk national region does not use the reviewed dataset$", + _take_the_weight_matrix_from_another_release, + "^region 'constituency/E14001063' reweights households with " + "parliamentary_constituency_weights, which is not certified in the " + "release of microcosm_uk_2024_25$", ), + (_drop_a_filter_value, "^region 'state/ca' has no filter value$"), ( - _give_filter_an_unsupported_value, - "^region 'state/ca' has an unsupported filter value$", + _give_the_national_region_a_filter_value, + "^region 'us' has a filter value, but a national region selects every " + "household$", ), ( - _give_filter_additional_filters, - "^region 'country/england' has unsupported additional filters$", + _place_a_district_under_a_place, + "^region 'congressional_district/CA-01' cannot be published because its " + "parent 'place/CA-44000' is left out$", + ), + ( + _remove_a_country_from_the_references, + "^region references are absent for: uk$", ), ], ) -def test_inconsistent_public_models_fail_with_a_specific_message( - break_models, +def test_inconsistent_regions_fail_with_a_specific_message( + break_sources, message: str, ) -> None: models = source_models() - break_models(models) + references = region_references() + break_sources(models, references) with pytest.raises(CatalogExtractionError, match=message): - _extract(models) + extract_catalog( + region_references=references, + bundle=bundle(), + policyengine_version=POLICYENGINE_VERSION, + models=models, + installed_version=installed_version, + ) -def test_national_region_without_a_dataset_path_uses_the_reviewed_dataset() -> None: +def test_national_region_without_a_release_dataset_uses_the_reviewed_dataset() -> None: models = source_models() - models["us"].region_registry[0].dataset_path = None + del models["us"].release_manifest.region_datasets["national"] - extract_catalog( + catalog = extract_catalog( + region_references=region_references(), bundle=bundle(), policyengine_version=POLICYENGINE_VERSION, models=models, installed_version=installed_version, ) + us = catalog.country("us") + national = next(region for region in us.regions if region.code == "us") + datasets = {dataset.id: dataset.name for dataset in us.datasets} + assert datasets[national.default_dataset_id] == "populace_us_2024" + assert us.fallback_summaries == () + class _ScalarWrapper: """A numpy-like scalar whose item() returns a plain Python value.""" diff --git a/tests/unit/v2/test_catalog_publication.py b/tests/unit/v2/test_catalog_publication.py index a3cbe0d65..7bb733ec0 100644 --- a/tests/unit/v2/test_catalog_publication.py +++ b/tests/unit/v2/test_catalog_publication.py @@ -251,6 +251,7 @@ def test_completion_evidence_contains_only_reviewed_non_secret_fields() -> None: entity_counts={"parameters": 12}, fallback_summaries=(("us", "state", 3),), elapsed_seconds=2.3456, + omitted_region_summaries=(("us", "place", 333),), ).as_dict() assert evidence == { @@ -261,6 +262,9 @@ def test_completion_evidence_contains_only_reviewed_non_secret_fields() -> None: "fallback_summaries": [ {"country_id": "us", "region_type": "state", "count": 3} ], + "omitted_region_summaries": [ + {"country_id": "us", "region_type": "place", "count": 333} + ], "elapsed_seconds": 2.346, } assert ( From 6cf90bcfd756e46c59629ddf24978c169c4c6a88 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:31:09 +0400 Subject: [PATCH 03/25] Refuse to publish regions whose filter column or weight-matrix row is missing Before it publishes a catalog version that is not stored yet, the publication command checks each region against the certified artifact its filter reads, and stops with a clear error if: - a row filter's column (state_fips, congressional_district_geoid, region) is not a household column of the region's dataset; or - a weight matrix's row count differs from its row lookup, or the lookup has no row for a constituency's or local authority's ONS code. Compute finds a region's matrix row in the same lookup files (constituencies_2024.csv, local_authorities_2021.csv). The artifacts are read from Hugging Face at their pinned revisions with ranged requests, so only HDF5 metadata and the column-name arrays are downloaded: about a minute for the 9.8 GB US file. Pandas fixed and table formats, one frame per year, and policyengine-core files are understood. A read failure names only the artifact and the error type. Versions already stored are not read again, since publication only compares them. The seed workflow's publication step receives the organization secret PE_UK_PRIVATE_HF_READ_TOKEN as HUGGING_FACE_TOKEN, because the UK data repository is private. The release workflow passes it through its existing `secrets: inherit`. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/seed-v2-database.yml | 3 + ...n-1f-123-region-reference-files.changed.md | 2 +- .../uk-private-hugging-face-credential.md | 5 + docs/migration/stage-9-v2-metadata.md | 60 +++-- .../v2/catalog/certified_data_inspection.py | 171 ++++++++++++++ .../data/v2/catalog/extraction.py | 2 + .../data/v2/catalog/initialization.py | 56 ++++- .../data/v2/catalog/publication.py | 17 ++ policyengine_api/data/v2/catalog/records.py | 18 ++ .../v2/catalog/region_data_verification.py | 193 ++++++++++++++++ .../data/v2/catalog/region_scoping.py | 31 ++- .../test_v2_catalog_publication.py | 14 ++ tests/unit/v2/test_catalog_initialization.py | 103 +++++++++ .../unit/v2/test_certified_data_inspection.py | 218 ++++++++++++++++++ tests/unit/v2/test_metadata_deployment.py | 9 + .../unit/v2/test_region_data_verification.py | 208 +++++++++++++++++ 16 files changed, 1078 insertions(+), 32 deletions(-) create mode 100644 policyengine_api/data/v2/catalog/certified_data_inspection.py create mode 100644 policyengine_api/data/v2/catalog/region_data_verification.py create mode 100644 tests/unit/v2/test_certified_data_inspection.py create mode 100644 tests/unit/v2/test_region_data_verification.py diff --git a/.github/workflows/seed-v2-database.yml b/.github/workflows/seed-v2-database.yml index 634352775..61af8978c 100644 --- a/.github/workflows/seed-v2-database.yml +++ b/.github/workflows/seed-v2-database.yml @@ -66,3 +66,6 @@ jobs: run: uv run python scripts/publish_v2_metadata_catalog.py env: V2_DATA_WRITE_DATABASE_URL: ${{ secrets.V2_DATA_WRITE_DATABASE_URL }} + # Reads the certified UK data release, whose repository is private, + # to check each new catalog version's region filters. + HUGGING_FACE_TOKEN: ${{ secrets.PE_UK_PRIVATE_HF_READ_TOKEN }} diff --git a/changelog.d/v2plan-1f-123-region-reference-files.changed.md b/changelog.d/v2plan-1f-123-region-reference-files.changed.md index 589a50194..3b2933649 100644 --- a/changelog.d/v2plan-1f-123-region-reference-files.changed.md +++ b/changelog.d/v2plan-1f-123-region-reference-files.changed.md @@ -1 +1 @@ -Catalog publication reads regions from the Public API's checked-in reference files, one per country, instead of PolicyEngine.py's region registry. Each region takes its dataset from the certified data release and its filter from a scoping rule: a row filter on state_fips, congressional_district_geoid or the UK region column, or a weight_replacement filter naming the release's constituency or local-authority weight matrix and the region's ONS code. The UK now publishes 1,015 regions, with constituencies and local authorities by ONS code and Northern Ireland as country/northern_ireland; the national regions are "United States" and "United Kingdom". US places are listed in the reference file but left out of the catalog until they can run, and the publication evidence counts them. +Catalog publication reads regions from the Public API's checked-in reference files, one per country, instead of PolicyEngine.py's region registry. Each region takes its dataset from the certified data release and its filter from a scoping rule: a row filter on state_fips, congressional_district_geoid or the UK region column, or a weight_replacement filter naming the release's constituency or local-authority weight matrix and the region's ONS code. The UK now publishes 1,015 regions, with constituencies and local authorities by ONS code and Northern Ireland as country/northern_ireland; the national regions are "United States" and "United Kingdom". US places are listed in the reference file but left out of the catalog until they can run, and the publication evidence counts them. Before it publishes a catalog version that is not stored yet, the publication command reads the HDF5 metadata of the certified artifacts the filters use, from Hugging Face at their pinned revisions, and stops if a row filter's column is missing from its dataset or a weight matrix has no row for a region's code; the seed workflow's publication step receives the UK data read token as HUGGING_FACE_TOKEN for this. diff --git a/docs/engineering/uk-private-hugging-face-credential.md b/docs/engineering/uk-private-hugging-face-credential.md index 1bc1c6134..6d02b014e 100644 --- a/docs/engineering/uk-private-hugging-face-credential.md +++ b/docs/engineering/uk-private-hugging-face-credential.md @@ -51,6 +51,11 @@ Cloud Run candidate resolver verifies that the exact deployed revision binds `HUGGING_FACE_TOKEN` to `pe-uk-private-hf-read-token:latest`, without printing the credential. +The v2 catalog publication step of `.github/workflows/seed-v2-database.yml` +also receives the organization secret as `HUGGING_FACE_TOKEN`, step-scoped. It +reads the HDF5 metadata of the certified UK dataset and weight matrices to +check each new catalog version's region filters, and nothing else. + The simulation-entry Cloud Run service does not receive this credential. It submits requests to Modal and does not download model data. diff --git a/docs/migration/stage-9-v2-metadata.md b/docs/migration/stage-9-v2-metadata.md index 8ee9dc4b4..b6f0bd994 100644 --- a/docs/migration/stage-9-v2-metadata.md +++ b/docs/migration/stage-9-v2-metadata.md @@ -64,8 +64,12 @@ must execute in this order: Alembic chain, confirms all heads are current, and detects ungenerated model changes. 3. Remove the migration URL from the command environment. -4. Supply only `V2_DATA_WRITE_DATABASE_URL` and the same target identity, then - run `uv run python scripts/publish_v2_metadata_catalog.py`. +4. Supply only `V2_DATA_WRITE_DATABASE_URL`, the same target identity and + `HUGGING_FACE_TOKEN` (from the organization secret + `PE_UK_PRIVATE_HF_READ_TOKEN`), then run + `uv run python scripts/publish_v2_metadata_catalog.py`. Before publishing a + catalog version that is not stored yet, the command reads the certified data + artifacts its regions' filters use and stops if one cannot select a region. 5. Require the command to finish successfully and retain its non-secret JSON evidence before creating the candidate revision. 6. Deploy the Cloud Run candidate with the target identity and @@ -101,20 +105,42 @@ does not reorder, deduplicate, or repair package output. A value superseded on the immediately following day therefore has equal start and end dates and is a valid one-day interval. -The US national region uses `populace_us_2024`. A US state, -congressional-district, or place region uses its PolicyEngine.py regional -alternative when one is available. Until a later reviewed change removes the -fallback, a subnational region without such an alternative uses -`populace_us_2024`. The command emits one summary warning containing only the -affected region types and counts. The UK national and country regions use -`microcosm_uk_2024_25`, the default certified by PolicyEngine.py 6.10.0; a -country filters that national file. UK constituency and local-authority -regions use the dataset PolicyEngine.py routes them to, -`microcosm_uk_2024_25_dense`, because the national file has no area codes. - -Review the fallback summary on every new PolicyEngine.py release. A changed -count may identify an upstream catalog change that needs a reviewed dataset -selection even when publication otherwise succeeds. +Regions come from the Public API's checked-in reference files, one per country +(`policyengine_api/data/v2/catalog/region_reference_files/`), not from +PolicyEngine.py's region registry. Each row lists a region's code, label, type, +parent, US state, the value that selects its households, and the v1 identifier +it replaces. A scoping rule per country and region type gives each region its +dataset from the certified data release and its filter: + +| Regions | Dataset | Filter | +| --- | --- | --- | +| US national | the release's national dataset (`populace_us_2024`) | none | +| US states and congressional districts | the release's state and district dataset | `row_filter` on `state_fips` or `congressional_district_geoid` | +| UK national | `enhanced_frs_2024_25` | none | +| UK nations | `enhanced_frs_2024_25` | `row_filter` on `region` | +| UK constituencies and local authorities | `enhanced_frs_2024_25` | `weight_replacement`: field `parliamentary_constituency_weights` or `local_authority_weights`, value the ONS code | + +A weight matrix must come from the same release as the dataset it reweights. +If the release names no dataset for a sub-national type, its regions use the +default dataset and the command emits one summary warning with the affected +region types and counts. Region types without a rule, such as US places until +the data can select them, are left out and counted in the evidence's +`omitted_region_summaries`. + +Before a new catalog version is published, the command reads the HDF5 metadata +of each artifact the filters use, with ranged requests at the pinned revision: +publication stops if a row filter's column is missing from its dataset's +household columns, or if a weight matrix's row count differs from its row +lookup (`constituencies_2024.csv`, `local_authorities_2021.csv`) or the lookup +lacks a region's code. + +Review the fallback and omitted-region summaries on every new PolicyEngine.py +release. A changed count may identify an upstream catalog change that needs a +reviewed dataset selection even when publication otherwise succeeds. + +A catalog version is immutable: republishing a stored PolicyEngine.py version +whose regions differ from the stored ones fails. Region reference changes +therefore reach the catalog with the next PolicyEngine.py version. ## Validation evidence and retry @@ -123,7 +149,7 @@ Successful JSON evidence contains: - the canonical PolicyEngine.py version; - the dependency versions derived from its packaged manifest; - catalog entity counts; -- US fallback counts; +- fallback counts and omitted-region counts by country and region type; - elapsed publication time; and - a success outcome. diff --git a/policyengine_api/data/v2/catalog/certified_data_inspection.py b/policyengine_api/data/v2/catalog/certified_data_inspection.py new file mode 100644 index 000000000..d0a1ebed5 --- /dev/null +++ b/policyengine_api/data/v2/catalog/certified_data_inspection.py @@ -0,0 +1,171 @@ +"""Read household column names and weight-matrix sizes from certified HDF5 files. + +The artifacts are read from Hugging Face at their pinned revisions through +ranged requests, so only the HDF5 metadata and the small column-name arrays +are downloaded, never the households themselves. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, BinaryIO, Protocol, TypeVar +from urllib.parse import quote + +import h5py +from huggingface_hub import HfFileSystem + +if TYPE_CHECKING: + from collections.abc import Callable + from contextlib import AbstractContextManager + + from policyengine_api.data.v2.catalog.records import CertifiedArtifact + +HOUSEHOLD_TABLE = "household" +# PyTables names the columns a table stores together "values_block_"; their +# real names live only in pickled attributes. +VALUE_BLOCK_PREFIX = "values_block_" +TABLE_INDEX_COLUMN = "index" + +_Result = TypeVar("_Result") + + +class CertifiedDataReadError(RuntimeError): + """Raised when a certified artifact does not have the structure expected.""" + + +# HDF5 metadata is read in many small, scattered pieces; caching whole blocks of +# this size keeps the number of ranged requests low. +READ_BLOCK_BYTES = 1 << 20 + + +class ArtifactFileSystem(Protocol): + """Opens a file in a Hugging Face repository at a revision for reading.""" + + def open( + self, path: str, mode: str, *, block_size: int, cache_type: str + ) -> AbstractContextManager[BinaryIO]: + """Return a readable, seekable binary file that caches blocks it reads.""" + ... + + +def huggingface_path(artifact: CertifiedArtifact) -> str: + """Return the Hugging Face file system path of an artifact at its revision.""" + prefix = "datasets/" if artifact.repo_type == "dataset" else "" + revision = quote(artifact.revision, safe="") + return f"{prefix}{artifact.repo_id}@{revision}/{artifact.path}" + + +def _text(value: object) -> str: + return value.decode("utf-8") if isinstance(value, bytes) else str(value) + + +def _is_frame(group: h5py.Group) -> bool: + return "axis0" in group or "table" in group + + +def _frame_columns(group: h5py.Group) -> frozenset[str]: + """Return the column names of one pandas data frame stored in HDF5.""" + if "axis0" in group: + return frozenset(_text(value) for value in group["axis0"][()]) + names = group["table"].dtype.names or () + if any(name.startswith(VALUE_BLOCK_PREFIX) for name in names): + msg = ( + f"household table {group.name.lstrip('/')} stores columns in value " + "blocks, whose names cannot be read without unpickling" + ) + raise CertifiedDataReadError(msg) + return frozenset(name for name in names if name != TABLE_INDEX_COLUMN) + + +def household_columns_in_hdf5(h5: h5py.File) -> frozenset[str]: + """Return the household column names of a dataset file. + + A pandas file stores the household frame directly, or one frame per year + (then only columns every year has count). A policyengine-core file stores + one top-level entry per variable. + """ + if HOUSEHOLD_TABLE not in h5: + return frozenset(h5.keys()) + household = h5[HOUSEHOLD_TABLE] + if not isinstance(household, h5py.Group): + msg = "household entry is not an HDF5 group" + raise CertifiedDataReadError(msg) + if _is_frame(household): + return _frame_columns(household) + frames = [ + entry + for entry in household.values() + if isinstance(entry, h5py.Group) and _is_frame(entry) + ] + if not frames: + msg = "household group holds no data frame" + raise CertifiedDataReadError(msg) + return frozenset.intersection(*(_frame_columns(frame) for frame in frames)) + + +def weight_matrix_rows_in_hdf5(h5: h5py.File) -> int: + """Return the number of region rows of a weight matrix. + + The file holds one two-dimensional array per year, of regions by + households, and every year must have the same regions. + """ + row_counts: set[int] = set() + for name, entry in h5.items(): + if not isinstance(entry, h5py.Dataset): + continue + if len(entry.shape) != 2: # noqa: PLR2004 # regions by households + msg = f"the weight matrix entry {name} is not two-dimensional" + raise CertifiedDataReadError(msg) + row_counts.add(entry.shape[0]) + if not row_counts: + msg = "the weight matrix has no year datasets" + raise CertifiedDataReadError(msg) + if len(row_counts) > 1: + msg = ( + "the weight matrix years disagree on the number of regions: " + f"{sorted(row_counts)}" + ) + raise CertifiedDataReadError(msg) + return row_counts.pop() + + +def _hugging_face_file_system(token: str | None) -> ArtifactFileSystem: + return HfFileSystem(token=token) + + +class HuggingFaceDataInspector: + """Reads certified artifacts from Hugging Face with an optional read token.""" + + def __init__( + self, + *, + token: str | None, + file_system_factory: Callable[[str | None], ArtifactFileSystem] = ( + _hugging_face_file_system + ), + ) -> None: + """Build the file system once; a token of None reads public data only.""" + self._file_system = file_system_factory(token) + + def _read( + self, + artifact: CertifiedArtifact, + read: Callable[[h5py.File], _Result], + ) -> _Result: + with ( + self._file_system.open( + huggingface_path(artifact), + "rb", + block_size=READ_BLOCK_BYTES, + cache_type="blockcache", + ) as handle, + h5py.File(handle, "r") as h5, + ): + return read(h5) + + def household_columns(self, artifact: CertifiedArtifact) -> frozenset[str]: + """Return the household column names of a dataset artifact.""" + return self._read(artifact, household_columns_in_hdf5) + + def weight_matrix_row_count(self, artifact: CertifiedArtifact) -> int: + """Return how many region rows a weight-matrix artifact holds.""" + return self._read(artifact, weight_matrix_rows_in_hdf5) diff --git a/policyengine_api/data/v2/catalog/extraction.py b/policyengine_api/data/v2/catalog/extraction.py index 76895175e..410340cea 100644 --- a/policyengine_api/data/v2/catalog/extraction.py +++ b/policyengine_api/data/v2/catalog/extraction.py @@ -32,6 +32,7 @@ ) from policyengine_api.data.v2.catalog.region_scoping import ( ScopedRegion, + filtered_artifacts, scope_regions, ) @@ -663,6 +664,7 @@ def _normalize_country( OmittedRegionSummary(region_type=region_type, count=count) for region_type, count in scoping.omitted_counts.items() ), + certified_artifacts=filtered_artifacts(scoping), ) diff --git a/policyengine_api/data/v2/catalog/initialization.py b/policyengine_api/data/v2/catalog/initialization.py index 10efdda6f..e62098bf9 100644 --- a/policyengine_api/data/v2/catalog/initialization.py +++ b/policyengine_api/data/v2/catalog/initialization.py @@ -3,12 +3,16 @@ from __future__ import annotations import json +import os import sys from typing import TYPE_CHECKING from sqlalchemy import Engine, create_engine from sqlalchemy.pool import NullPool +from policyengine_api.data.v2.catalog.certified_data_inspection import ( + HuggingFaceDataInspector, +) from policyengine_api.data.v2.catalog.extraction import ( CatalogExtractionError, extract_installed_catalog, @@ -17,6 +21,10 @@ CatalogPublicationError, PublicationEvidence, publish_catalog, + unpublished_countries, +) +from policyengine_api.data.v2.catalog.region_data_verification import ( + verify_region_data, ) from policyengine_api.data.v2.settings import ( V2ConfigurationError, @@ -25,16 +33,56 @@ ) if TYPE_CHECKING: - from collections.abc import Callable, Mapping + from collections.abc import Callable, Mapping, Sequence from policyengine_api.data.v2.catalog.records import NormalizedCatalog +# The environment variable holding the Hugging Face read token; PolicyEngine +# Core's data downloads read the same variable. +HUGGING_FACE_TOKEN_VARIABLE = "HUGGING_FACE_TOKEN" # noqa: S105 # an environment variable name, not a secret + + def build_data_write_engine(settings: V2DatabaseSettings) -> Engine: """Build an isolated, unpooled engine for one initialization attempt.""" return create_engine(settings.connection.url, poolclass=NullPool) +def verify_installed_region_data( + catalog: NormalizedCatalog, + countries: Sequence[str], + environ: Mapping[str, str] | None = None, +) -> None: + """Check the named countries' regions against their certified artifacts. + + The artifacts are read from Hugging Face with ``HUGGING_FACE_TOKEN``, + which the UK's private data repository needs. + """ + token = (os.environ if environ is None else environ).get( + HUGGING_FACE_TOKEN_VARIABLE + ) + verify_region_data( + catalog, + HuggingFaceDataInspector(token=token or None), + countries=countries, + ) + + +def verify_unpublished_region_data( + engine: Engine, + catalog: NormalizedCatalog, + environ: Mapping[str, str] | None = None, +) -> None: + """Check the regions of countries whose catalog version is not stored yet. + + Publication writes nothing for the other countries and only compares them + with what is stored, so their artifacts are not read. + """ + verify_installed_region_data( + catalog, unpublished_countries(engine, catalog), environ + ) + + def initialize_catalog( environ: Mapping[str, str] | None = None, *, @@ -43,12 +91,16 @@ def initialize_catalog( publisher: Callable[[Engine, NormalizedCatalog], PublicationEvidence] = ( publish_catalog ), + region_data_verifier: Callable[ + [Engine, NormalizedCatalog, Mapping[str, str] | None], None + ] = verify_unpublished_region_data, ) -> PublicationEvidence: - """Load only row-write settings, extract fully, then publish explicitly.""" + """Load only row-write settings, extract fully, verify, then publish explicitly.""" settings = load_v2_data_write_database_settings(environ) catalog = extractor() engine = engine_builder(settings) try: + region_data_verifier(engine, catalog, environ) return publisher(engine, catalog) finally: engine.dispose() diff --git a/policyengine_api/data/v2/catalog/publication.py b/policyengine_api/data/v2/catalog/publication.py index 88da73310..04256af4f 100644 --- a/policyengine_api/data/v2/catalog/publication.py +++ b/policyengine_api/data/v2/catalog/publication.py @@ -56,6 +56,7 @@ "CatalogPublicationError", "PublicationEvidence", "publish_catalog", + "unpublished_countries", ] @@ -87,6 +88,22 @@ def _protected_row_counts(connection: Connection) -> tuple[int, int, int, int]: ) +def unpublished_countries( + engine: Engine, catalog: NormalizedCatalog +) -> tuple[str, ...]: + """Return the countries whose catalog version is not stored yet, in catalog order. + + A read outside publication's transaction: a version another publisher adds + meanwhile is still compared with the staged one when this one publishes. + """ + with engine.connect() as connection: + return tuple( + country.country_id + for country in catalog.countries + if not version_exists(connection, country) + ) + + def publish_catalog( engine: Engine, catalog: NormalizedCatalog, diff --git a/policyengine_api/data/v2/catalog/records.py b/policyengine_api/data/v2/catalog/records.py index 5be00f616..0b22334fd 100644 --- a/policyengine_api/data/v2/catalog/records.py +++ b/policyengine_api/data/v2/catalog/records.py @@ -126,6 +126,21 @@ class RegionRecord: state_name: str | None +@dataclass(frozen=True, slots=True) +class CertifiedArtifact: + """One artifact of a country's certified data release, pinned to a revision. + + ``repo_type`` is the Hugging Face repository type, ``model`` or ``dataset``. + """ + + name: str + repo_id: str + repo_type: str + path: str + revision: str + sha256: str | None + + @dataclass(frozen=True, slots=True) class FallbackSummary: """How many sub-national regions of one type fell back to the default dataset. @@ -178,6 +193,9 @@ class CountryCatalog: regions: tuple[RegionRecord, ...] fallback_summaries: tuple[FallbackSummary, ...] omitted_region_summaries: tuple[OmittedRegionSummary, ...] = () + # The release artifacts the regions' filters read: the datasets of row + # filters and the weight matrices of weight-replacement filters. + certified_artifacts: tuple[CertifiedArtifact, ...] = () def parameter_value_batches( self, diff --git a/policyengine_api/data/v2/catalog/region_data_verification.py b/policyengine_api/data/v2/catalog/region_data_verification.py new file mode 100644 index 000000000..803698467 --- /dev/null +++ b/policyengine_api/data/v2/catalog/region_data_verification.py @@ -0,0 +1,193 @@ +"""Refuse to publish regions whose households the certified data cannot select. + +A row-filtered region needs its filter column among the household columns of +its dataset. A weight-replacement region needs a row in its weight matrix: the +matrix's rows follow the order of the row lookup that compute uses to find a +region's row, so the lookup must list exactly as many codes as the matrix has +rows and include the region's code. +""" + +from __future__ import annotations + +import csv +from collections import defaultdict +from functools import partial +from pathlib import Path +from typing import TYPE_CHECKING, Protocol, TypeVar + +from policyengine_api.data.v2.catalog.errors import CatalogExtractionError +from policyengine_api.data.v2.catalog.region_scoping import ( + ROW_FILTER, + WEIGHT_REPLACEMENT, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping, Sequence + + from policyengine_api.data.v2.catalog.records import ( + CertifiedArtifact, + CountryCatalog, + NormalizedCatalog, + RegionRecord, + ) + +# The Public API's copies of the row lookups compute reads to find a region's +# row in each UK weight matrix (the files compute downloads under the same names). +_DATA_DIRECTORY = Path(__file__).parents[2] +WEIGHT_MATRIX_ROW_LOOKUPS: Mapping[str, Path] = { + "parliamentary_constituency_weights": _DATA_DIRECTORY / "constituencies_2024.csv", + "local_authority_weights": _DATA_DIRECTORY / "local_authorities_2021.csv", +} +# Region codes named in an error message, at most. +MAX_LISTED_REGIONS = 3 + +_Result = TypeVar("_Result") + + +class RegionDataVerificationError(CatalogExtractionError): + """Raised when the certified data cannot select a region's households.""" + + +class CertifiedDataInspector(Protocol): + """Reads what verification needs from one certified artifact.""" + + def household_columns(self, artifact: CertifiedArtifact) -> frozenset[str]: + """Return the household column names of a dataset artifact.""" + ... + + def weight_matrix_row_count(self, artifact: CertifiedArtifact) -> int: + """Return how many region rows a weight-matrix artifact holds.""" + ... + + +def load_weight_matrix_row_codes(path: Path) -> tuple[str, ...]: + """Return a row lookup's region codes in matrix row order.""" + with path.open(encoding="utf-8", newline="") as handle: + return tuple(row["code"] for row in csv.DictReader(handle)) + + +def _listed(codes: Sequence[str]) -> str: + shown = ", ".join(codes[:MAX_LISTED_REGIONS]) + more = len(codes) - MAX_LISTED_REGIONS + return f"{shown} and {more} more" if more > 0 else shown + + +def _noun(count: int) -> str: + return "region" if count == 1 else "regions" + + +def _read(artifact: CertifiedArtifact, read: Callable[[], _Result]) -> _Result: + """Run one read of an artifact, reporting a failure without its error text.""" + try: + return read() + except RegionDataVerificationError: + raise + except Exception as error: + # Only the artifact's name and the error type: the error text may hold + # its location, and command output names no dataset release. + msg = ( + f"could not read the certified artifact {artifact.name}: " + f"{type(error).__name__}" + ) + raise RegionDataVerificationError(msg) from error + + +def _verify_row_filters( + country: CountryCatalog, + artifacts: Mapping[str, CertifiedArtifact], + inspector: CertifiedDataInspector, +) -> None: + dataset_names = {dataset.id: dataset.name for dataset in country.datasets} + regions_by_column: dict[tuple[str, str], list[str]] = defaultdict(list) + for region in country.regions: + if region.filter_strategy == ROW_FILTER and region.filter_field: + dataset_name = dataset_names[region.default_dataset_id] + regions_by_column[dataset_name, region.filter_field].append(region.code) + columns_by_dataset: dict[str, frozenset[str]] = {} + for (dataset_name, column), codes in sorted(regions_by_column.items()): + if dataset_name not in columns_by_dataset: + artifact = artifacts[dataset_name] + columns_by_dataset[dataset_name] = _read( + artifact, + partial(inspector.household_columns, artifact), + ) + if column not in columns_by_dataset[dataset_name]: + msg = ( + f"the certified dataset {dataset_name} has no household column " + f"{column}, which {len(codes)} {country.country_id} " + f"{_noun(len(codes))} {'filters' if len(codes) == 1 else 'filter'} " + f"on ({_listed(codes)})" + ) + raise RegionDataVerificationError(msg) + + +def _weight_replacement_regions( + regions: Sequence[RegionRecord], +) -> dict[str, list[RegionRecord]]: + regions_by_matrix: dict[str, list[RegionRecord]] = defaultdict(list) + for region in regions: + if region.filter_strategy == WEIGHT_REPLACEMENT and region.filter_field: + regions_by_matrix[region.filter_field].append(region) + return regions_by_matrix + + +def _verify_weight_matrices( + country: CountryCatalog, + artifacts: Mapping[str, CertifiedArtifact], + inspector: CertifiedDataInspector, + weight_matrix_row_codes: Mapping[str, Sequence[str]], +) -> None: + for matrix_name, regions in sorted( + _weight_replacement_regions(country.regions).items() + ): + row_codes = weight_matrix_row_codes.get(matrix_name) + if row_codes is None: + msg = f"no row lookup names the regions of the weight matrix {matrix_name}" + raise RegionDataVerificationError(msg) + artifact = artifacts[matrix_name] + row_count = _read( + artifact, + partial(inspector.weight_matrix_row_count, artifact), + ) + if row_count != len(row_codes): + msg = ( + f"the weight matrix {matrix_name} has {row_count} region rows, but its " + f"row lookup lists {len(row_codes)} codes" + ) + raise RegionDataVerificationError(msg) + known_codes = set(row_codes) + missing = [ + region.code for region in regions if region.filter_value not in known_codes + ] + if missing: + msg = ( + f"the weight matrix {matrix_name} has no row for {len(missing)} " + f"{country.country_id} {_noun(len(missing))} ({_listed(missing)})" + ) + raise RegionDataVerificationError(msg) + + +def verify_region_data( + catalog: NormalizedCatalog, + inspector: CertifiedDataInspector, + *, + countries: Sequence[str], + weight_matrix_row_codes: Mapping[str, Sequence[str]] | None = None, +) -> None: + """Check the regions of the named countries against their certified artifacts. + + Each artifact is read at most once. Without explicit row codes, the row + lookups in ``WEIGHT_MATRIX_ROW_LOOKUPS`` are read. + """ + if weight_matrix_row_codes is None: + weight_matrix_row_codes = { + name: load_weight_matrix_row_codes(path) + for name, path in WEIGHT_MATRIX_ROW_LOOKUPS.items() + } + for country_id in countries: + country = catalog.country(country_id) + artifacts = { + artifact.name: artifact for artifact in country.certified_artifacts + } + _verify_row_filters(country, artifacts, inspector) + _verify_weight_matrices(country, artifacts, inspector, weight_matrix_row_codes) diff --git a/policyengine_api/data/v2/catalog/region_scoping.py b/policyengine_api/data/v2/catalog/region_scoping.py index d3ea6629d..541973bc6 100644 --- a/policyengine_api/data/v2/catalog/region_scoping.py +++ b/policyengine_api/data/v2/catalog/region_scoping.py @@ -17,6 +17,7 @@ from typing import TYPE_CHECKING from policyengine_api.data.v2.catalog.errors import CatalogExtractionError +from policyengine_api.data.v2.catalog.records import CertifiedArtifact if TYPE_CHECKING: from policyengine_api.data.v2.catalog.region_references import RegionReference @@ -58,18 +59,6 @@ class RegionScopingRule: } -@dataclass(frozen=True, slots=True) -class CertifiedArtifact: - """One artifact of a country's certified data release, pinned to a revision.""" - - name: str - repo_id: str - repo_type: str - path: str - revision: str - sha256: str | None - - @dataclass(frozen=True, slots=True) class ScopedRegion: """A reference region with its dataset and the filter selecting its households.""" @@ -311,3 +300,21 @@ def scope_regions( fallback_counts=dict(sorted(fallback_counts.items())), omitted_counts=dict(sorted(Counter(omitted.values()).items())), ) + + +def filtered_artifacts(scoping: RegionScoping) -> tuple[CertifiedArtifact, ...]: + """Return, by name, the artifacts the regions' filters read. + + Those are the datasets of row-filtered regions and the weight matrices of + weight-replacement regions. + """ + names = { + region.dataset_name + for region in scoping.regions + if region.filter_strategy == ROW_FILTER + } | { + region.filter_field + for region in scoping.regions + if region.filter_strategy == WEIGHT_REPLACEMENT and region.filter_field + } + return tuple(scoping.certified_artifacts[name] for name in sorted(names)) diff --git a/tests/integration/test_v2_catalog_publication.py b/tests/integration/test_v2_catalog_publication.py index c894c8b45..011b3d29c 100644 --- a/tests/integration/test_v2_catalog_publication.py +++ b/tests/integration/test_v2_catalog_publication.py @@ -18,6 +18,7 @@ from policyengine_api.data.v2.catalog.publication import ( CatalogPublicationError, publish_catalog, + unpublished_countries, ) from policyengine_api.data.v2.models import ( Dataset, @@ -173,6 +174,19 @@ def test_publish_and_republish_preserve_complete_catalog( } +def test_lists_only_countries_whose_catalog_version_is_not_stored( + publication_engine: Engine, +) -> None: + catalog = normalized_catalog() + newer = normalized_catalog(policyengine_version="4.21.0") + + assert unpublished_countries(publication_engine, catalog) == ("us", "uk") + publish_catalog(publication_engine, catalog) + + assert unpublished_countries(publication_engine, catalog) == () + assert unpublished_countries(publication_engine, newer) == ("us", "uk") + + @pytest.mark.parametrize( "failure_point", ["during_copy", "after_reconciliation", "after_validation"], diff --git a/tests/unit/v2/test_catalog_initialization.py b/tests/unit/v2/test_catalog_initialization.py index e77681fcd..c998c3006 100644 --- a/tests/unit/v2/test_catalog_initialization.py +++ b/tests/unit/v2/test_catalog_initialization.py @@ -9,6 +9,9 @@ from policyengine_api.data.v2.catalog import initialization from policyengine_api.data.v2.catalog.publication import PublicationEvidence +from policyengine_api.data.v2.catalog.region_data_verification import ( + RegionDataVerificationError, +) from policyengine_api.data.v2.settings import ( V2_DATA_WRITE_DATABASE_URL, V2_SUPABASE_ENVIRONMENT, @@ -65,22 +68,122 @@ def publish(selected_engine, catalog): observed["version"] = catalog.policyengine_version return _evidence() + def verify(selected_engine, catalog, environ): + assert "engine" not in observed + observed["verified"] = ( + selected_engine, + catalog.policyengine_version, + environ, + ) + result = initialization.initialize_catalog( ENVIRONMENT, extractor=normalized_catalog, engine_builder=build, publisher=publish, + region_data_verifier=verify, ) assert result == _evidence() assert observed == { "username": "data-writer", + "verified": (engine, POLICYENGINE_VERSION, ENVIRONMENT), "engine": engine, "version": POLICYENGINE_VERSION, } assert engine.disposed +def test_region_data_failure_publishes_nothing_and_disposes_engine() -> None: + engine = FakeEngine() + + def reject(_engine, _catalog, _environ): + msg = "the certified dataset d has no household column c" + raise RegionDataVerificationError(msg) + + def refuse_publication(_engine, _catalog): + msg = "publication must not start" + raise AssertionError(msg) + + with pytest.raises(RegionDataVerificationError, match="no household column"): + initialization.initialize_catalog( + ENVIRONMENT, + extractor=normalized_catalog, + engine_builder=lambda _settings: engine, + publisher=refuse_publication, + region_data_verifier=reject, + ) + assert engine.disposed + + +def test_installed_verification_reads_artifacts_with_the_hugging_face_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + observed = {} + + class RecordingInspector: + def __init__(self, *, token): + observed["token"] = token + + def record(catalog, inspector, *, countries): + observed["call"] = (catalog.policyengine_version, type(inspector), countries) + + monkeypatch.setattr(initialization, "HuggingFaceDataInspector", RecordingInspector) + monkeypatch.setattr(initialization, "verify_region_data", record) + + initialization.verify_installed_region_data( + normalized_catalog(), ("uk",), {"HUGGING_FACE_TOKEN": "read-token"} + ) + assert observed == { + "token": "read-token", + "call": (POLICYENGINE_VERSION, RecordingInspector, ("uk",)), + } + + initialization.verify_installed_region_data(normalized_catalog(), ("uk",), {}) + assert observed["token"] is None + + +def test_default_verification_checks_only_countries_not_yet_published( + monkeypatch: pytest.MonkeyPatch, +) -> None: + engine = FakeEngine() + catalog = normalized_catalog() + observed = {} + + def find_unpublished(selected_engine, selected_catalog): + observed["found"] = (selected_engine, selected_catalog) + return ("uk",) + + def verify(selected_catalog, countries, environ): + observed["verified"] = (selected_catalog, countries, environ) + + monkeypatch.setattr(initialization, "unpublished_countries", find_unpublished) + monkeypatch.setattr(initialization, "verify_installed_region_data", verify) + + initialization.verify_unpublished_region_data(engine, catalog, ENVIRONMENT) + + assert observed == { + "found": (engine, catalog), + "verified": (catalog, ("uk",), ENVIRONMENT), + } + + +def test_main_reports_a_region_data_failure_with_its_message( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + def fail(): + msg = "the weight matrix m has no row for 1 uk region (constituency/x)" + raise RegionDataVerificationError(msg) + + monkeypatch.setattr(initialization, "initialize_catalog", fail) + + assert initialization.main() == 1 + failure = capsys.readouterr() + assert '"type": "RegionDataVerificationError"' in failure.err + assert "has no row for 1 uk region" in failure.err + + def test_extraction_failure_opens_no_engine() -> None: def fail_extraction(): msg = "extraction failed" diff --git a/tests/unit/v2/test_certified_data_inspection.py b/tests/unit/v2/test_certified_data_inspection.py new file mode 100644 index 000000000..6ff69191e --- /dev/null +++ b/tests/unit/v2/test_certified_data_inspection.py @@ -0,0 +1,218 @@ +"""Reading column names and weight-matrix sizes from certified HDF5 artifacts.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import h5py +import numpy as np +import pandas as pd +import pytest + +from policyengine_api.data.v2.catalog.certified_data_inspection import ( + CertifiedDataReadError, + HuggingFaceDataInspector, + household_columns_in_hdf5, + huggingface_path, + weight_matrix_rows_in_hdf5, +) +from policyengine_api.data.v2.catalog.records import CertifiedArtifact + +if TYPE_CHECKING: + from pathlib import Path + +HOUSEHOLDS = pd.DataFrame( + { + "household_id": [1, 2], + "household_weight": [1.5, 2.5], + "state_fips": [6, 36], + } +) + + +def _artifact( + *, + name: str = "populace_us_2024_acs_local", + repo_type: str = "dataset", + revision: str = "populace-us-2024-buildo-acs-local-766b7c7-20261017T030317Z", +) -> CertifiedArtifact: + return CertifiedArtifact( + name=name, + repo_id="policyengine/populace-us", + repo_type=repo_type, + path=f"{name}.h5", + revision=revision, + sha256=None, + ) + + +def _columns(path: Path) -> frozenset[str]: + with h5py.File(path, "r") as h5: + return household_columns_in_hdf5(h5) + + +def _rows(path: Path) -> int: + with h5py.File(path, "r") as h5: + return weight_matrix_rows_in_hdf5(h5) + + +def test_reads_columns_of_a_fixed_format_household_frame(tmp_path: Path) -> None: + path = tmp_path / "fixed.h5" + HOUSEHOLDS.to_hdf(path, key="household", format="fixed") + + assert _columns(path) == {"household_id", "household_weight", "state_fips"} + + +def test_reads_data_columns_of_a_table_format_household_frame(tmp_path: Path) -> None: + path = tmp_path / "table.h5" + frame = HOUSEHOLDS.assign(region=pd.Categorical(["LONDON", "WALES"])) + frame.to_hdf(path, key="household", format="table", data_columns=True) + + assert _columns(path) == { + "household_id", + "household_weight", + "state_fips", + "region", + } + + +# PyTables warns that year keys are not Python identifiers; policyengine-uk's +# multi-year files use the same keys. +@pytest.mark.filterwarnings("ignore:object name is not a valid Python identifier") +def test_keeps_only_columns_every_year_of_a_multi_year_file_has( + tmp_path: Path, +) -> None: + path = tmp_path / "years.h5" + HOUSEHOLDS.to_hdf(path, key="household/2024", format="table", data_columns=True) + HOUSEHOLDS.drop(columns="state_fips").to_hdf( + path, key="household/2025", format="table", data_columns=True + ) + + assert _columns(path) == {"household_id", "household_weight"} + + +def test_reads_variable_names_of_a_core_format_file(tmp_path: Path) -> None: + path = tmp_path / "core.h5" + with h5py.File(path, "w") as h5: + h5.create_dataset("state_fips/2024", data=np.array([6, 36])) + h5.create_dataset("household_weight/2024", data=np.array([1.5, 2.5])) + + assert _columns(path) == {"state_fips", "household_weight"} + + +def test_refuses_a_table_whose_columns_are_stored_in_value_blocks( + tmp_path: Path, +) -> None: + path = tmp_path / "blocks.h5" + HOUSEHOLDS.to_hdf(path, key="household", format="table") + + with pytest.raises( + CertifiedDataReadError, + match=r"^household table household stores columns in value blocks, whose " + r"names cannot be read without unpickling$", + ): + _columns(path) + + +def test_refuses_a_household_group_that_holds_no_frame(tmp_path: Path) -> None: + path = tmp_path / "empty.h5" + with h5py.File(path, "w") as h5: + h5.create_group("household") + + with pytest.raises( + CertifiedDataReadError, match=r"^household group holds no data frame$" + ): + _columns(path) + + +def test_counts_the_region_rows_of_a_weight_matrix(tmp_path: Path) -> None: + path = tmp_path / "matrix.h5" + with h5py.File(path, "w") as h5: + h5.create_dataset("2024", data=np.ones((3, 5))) + h5.create_dataset("2025", data=np.ones((3, 5))) + + assert _rows(path) == 3 + + +@pytest.mark.parametrize( + ("shapes", "message"), + [ + ({}, r"^the weight matrix has no year datasets$"), + ({"2024": (3,)}, r"^the weight matrix entry 2024 is not two-dimensional$"), + ( + {"2024": (3, 5), "2025": (4, 5)}, + r"^the weight matrix years disagree on the number of regions: \[3, 4\]$", + ), + ], +) +def test_refuses_an_inconsistent_weight_matrix( + tmp_path: Path, + shapes: dict[str, tuple[int, ...]], + message: str, +) -> None: + path = tmp_path / "matrix.h5" + with h5py.File(path, "w") as h5: + for year, shape in shapes.items(): + h5.create_dataset(year, data=np.ones(shape)) + + with pytest.raises(CertifiedDataReadError, match=message): + _rows(path) + + +def test_names_dataset_and_model_repository_files_at_their_revision() -> None: + assert huggingface_path(_artifact()) == ( + "datasets/policyengine/populace-us@" + "populace-us-2024-buildo-acs-local-766b7c7-20261017T030317Z/" + "populace_us_2024_acs_local.h5" + ) + assert huggingface_path( + _artifact(name="enhanced_frs_2024_25", repo_type="model", revision="1.58.1") + ) == ("policyengine/populace-us@1.58.1/enhanced_frs_2024_25.h5") + assert huggingface_path(_artifact(revision="refs/pr/1")).startswith( + "datasets/policyengine/populace-us@refs%2Fpr%2F1/" + ) + + +class FakeFileSystem: + """Serves local files for Hugging Face paths and records how it was built.""" + + def __init__(self, files: dict[str, Path]) -> None: + self.files = files + self.opened: list[str] = [] + + def open(self, path: str, mode: str, *, block_size: int, cache_type: str): + assert (mode, block_size, cache_type) == ("rb", 1 << 20, "blockcache") + self.opened.append(path) + return self.files[path].open("rb") + + +def test_inspector_reads_artifacts_through_an_authenticated_file_system( + tmp_path: Path, +) -> None: + dataset_path = tmp_path / "dataset.h5" + HOUSEHOLDS.to_hdf(dataset_path, key="household", format="fixed") + matrix_path = tmp_path / "matrix.h5" + with h5py.File(matrix_path, "w") as h5: + h5.create_dataset("2025", data=np.ones((2, 2))) + dataset = _artifact() + matrix = _artifact(name="parliamentary_constituency_weights", repo_type="model") + file_system = FakeFileSystem( + { + huggingface_path(dataset): dataset_path, + huggingface_path(matrix): matrix_path, + } + ) + tokens: list[str | None] = [] + + def build_file_system(token: str | None) -> FakeFileSystem: + tokens.append(token) + return file_system + + inspector = HuggingFaceDataInspector( + token="read-token", file_system_factory=build_file_system + ) + + assert "state_fips" in inspector.household_columns(dataset) + assert inspector.weight_matrix_row_count(matrix) == 2 + assert tokens == ["read-token"] + assert file_system.opened == [huggingface_path(dataset), huggingface_path(matrix)] diff --git a/tests/unit/v2/test_metadata_deployment.py b/tests/unit/v2/test_metadata_deployment.py index c752b46c0..a5e51e864 100644 --- a/tests/unit/v2/test_metadata_deployment.py +++ b/tests/unit/v2/test_metadata_deployment.py @@ -171,3 +171,12 @@ def test_stage_9_deployment_shell_script_is_syntax_valid() -> None: ) assert result.returncode == 0, result.stderr + + +def test_only_catalog_publication_receives_the_hugging_face_read_token() -> None: + workflow = _read(".github/workflows/seed-v2-database.yml") + publication = _step(workflow, "Seed and validate the v2 metadata catalog") + token = "HUGGING_FACE_TOKEN: ${{ secrets.PE_UK_PRIVATE_HF_READ_TOKEN }}" + + assert token in publication + assert workflow.count("HUGGING_FACE_TOKEN") == 1 diff --git a/tests/unit/v2/test_region_data_verification.py b/tests/unit/v2/test_region_data_verification.py new file mode 100644 index 000000000..b5a4d89b7 --- /dev/null +++ b/tests/unit/v2/test_region_data_verification.py @@ -0,0 +1,208 @@ +"""Publication refuses regions the certified data cannot select.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from policyengine_api.data.v2.catalog.region_data_verification import ( + WEIGHT_MATRIX_ROW_LOOKUPS, + RegionDataVerificationError, + load_weight_matrix_row_codes, + verify_region_data, +) +from tests.fixtures.v2_catalog import normalized_catalog + +if TYPE_CHECKING: + from policyengine_api.data.v2.catalog.records import CertifiedArtifact + +ALL_COLUMNS = { + "populace_us_2024_acs_local": frozenset( + {"state_fips", "congressional_district_geoid", "household_weight"} + ), + "microcosm_uk_2024_25": frozenset({"region", "household_weight"}), +} +CONSTITUENCY_ROW_CODES = ("E14001063", "E14001064") + + +class FakeInspector: + """Answers from fixed columns and matrix sizes, and records each artifact read.""" + + def __init__( + self, + *, + columns: dict[str, frozenset[str]] | None = None, + matrix_rows: dict[str, int] | None = None, + failure: Exception | None = None, + ) -> None: + self.columns = ALL_COLUMNS if columns is None else columns + self.matrix_rows = ( + {"parliamentary_constituency_weights": len(CONSTITUENCY_ROW_CODES)} + if matrix_rows is None + else matrix_rows + ) + self.failure = failure + self.reads: list[tuple[str, str]] = [] + + def household_columns(self, artifact: CertifiedArtifact) -> frozenset[str]: + self.reads.append(("columns", artifact.name)) + if self.failure is not None: + raise self.failure + return self.columns[artifact.name] + + def weight_matrix_row_count(self, artifact: CertifiedArtifact) -> int: + self.reads.append(("rows", artifact.name)) + if self.failure is not None: + raise self.failure + return self.matrix_rows[artifact.name] + + +def _verify( + inspector: FakeInspector, + *, + countries: tuple[str, ...] = ("us", "uk"), + row_codes: tuple[str, ...] = CONSTITUENCY_ROW_CODES, +) -> None: + verify_region_data( + normalized_catalog(), + inspector, + countries=countries, + weight_matrix_row_codes={"parliamentary_constituency_weights": row_codes}, + ) + + +def test_catalog_records_only_the_artifacts_its_filters_read() -> None: + catalog = normalized_catalog() + + assert [ + (artifact.name, artifact.repo_id, artifact.repo_type, artifact.revision) + for artifact in catalog.country("us").certified_artifacts + ] == [ + ( + "populace_us_2024_acs_local", + "policyengine/populace-us", + "dataset", + "fixture", + ) + ] + assert [ + (artifact.name, artifact.path, artifact.repo_type) + for artifact in catalog.country("uk").certified_artifacts + ] == [ + ("microcosm_uk_2024_25", "microcosm_uk_2024_25.h5", "model"), + ( + "parliamentary_constituency_weights", + "parliamentary_constituency_weights.h5", + "model", + ), + ] + + +def test_accepts_regions_whose_columns_and_matrix_rows_exist() -> None: + inspector = FakeInspector() + + _verify(inspector) + + assert sorted(inspector.reads) == [ + ("columns", "microcosm_uk_2024_25"), + ("columns", "populace_us_2024_acs_local"), + ("rows", "parliamentary_constituency_weights"), + ] + + +def test_reads_only_the_artifacts_of_the_countries_it_verifies() -> None: + inspector = FakeInspector() + + _verify(inspector, countries=("us",)) + + assert inspector.reads == [("columns", "populace_us_2024_acs_local")] + + +def test_verifies_nothing_when_every_version_is_already_published() -> None: + inspector = FakeInspector(failure=AssertionError("must not read")) + + _verify(inspector, countries=()) + + assert inspector.reads == [] + + +def test_rejects_a_filter_column_missing_from_the_dataset() -> None: + inspector = FakeInspector( + columns={ + **ALL_COLUMNS, + "populace_us_2024_acs_local": frozenset({"state_fips"}), + } + ) + + with pytest.raises( + RegionDataVerificationError, + match=r"^the certified dataset populace_us_2024_acs_local has no household " + r"column congressional_district_geoid, which 1 us region filters on " + r"\(congressional_district/CA-01\)$", + ): + _verify(inspector) + + +def test_rejects_a_region_code_missing_from_the_weight_matrix() -> None: + with pytest.raises( + RegionDataVerificationError, + match=r"^the weight matrix parliamentary_constituency_weights has no row for " + r"1 uk region \(constituency/E14001063\)$", + ): + _verify(FakeInspector(), row_codes=("E14001064", "E14001065")) + + +def test_rejects_a_weight_matrix_whose_rows_differ_from_its_lookup() -> None: + inspector = FakeInspector(matrix_rows={"parliamentary_constituency_weights": 3}) + + with pytest.raises( + RegionDataVerificationError, + match=r"^the weight matrix parliamentary_constituency_weights has 3 region " + r"rows, but its row lookup lists 2 codes$", + ): + _verify(inspector) + + +def test_rejects_a_weight_matrix_without_a_row_lookup() -> None: + with pytest.raises( + RegionDataVerificationError, + match=r"^no row lookup names the regions of the weight matrix " + r"parliamentary_constituency_weights$", + ): + verify_region_data( + normalized_catalog(), + FakeInspector(), + countries=("uk",), + weight_matrix_row_codes={}, + ) + + +def test_reports_an_unreadable_artifact_without_its_error_text() -> None: + inspector = FakeInspector( + failure=PermissionError("403 for https://example.test/?token=do-not-print") + ) + + with pytest.raises(RegionDataVerificationError) as raised: + _verify(inspector, countries=("us",)) + + assert str(raised.value) == ( + "could not read the certified artifact populace_us_2024_acs_local: " + "PermissionError" + ) + assert "do-not-print" not in str(raised.value) + assert "fixture" not in str(raised.value) + + +def test_row_lookups_list_every_reference_code_in_matrix_order() -> None: + constituencies = load_weight_matrix_row_codes( + WEIGHT_MATRIX_ROW_LOOKUPS["parliamentary_constituency_weights"] + ) + local_authorities = load_weight_matrix_row_codes( + WEIGHT_MATRIX_ROW_LOOKUPS["local_authority_weights"] + ) + + assert len(constituencies) == len(set(constituencies)) == 650 + assert len(local_authorities) == len(set(local_authorities)) == 361 + assert constituencies[0] == "E14001063" + assert local_authorities[0] == "E06000001" From 47902c1295d1ba973d6d49de9a6cb3e4a7d7a648 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:40:02 +0400 Subject: [PATCH 04/25] Keep a stored catalog version's regions when the reference files change A catalog version is identified by its country and PolicyEngine.py version and never changes once published, but its regions now come from the Public API's reference files. Republishing a stored version compared every table, regions included, so a reference file change under an unchanged PolicyEngine.py version (6.7.5 today, whose stored version has the 826 regions of PolicyEngine.py's registry) made the seed job fail. For a stored version, publication now compares every table except regions exactly as before, keeps the stored region rows unchanged, and logs kept_published_catalog_regions with the country, the version and the counts of staged region rows the stored version lacks and of stored rows not staged. A version not yet stored still publishes, and is checked against, the reference-file regions. Region file changes therefore take effect in the next catalog version that is published. Co-Authored-By: Claude Opus 5.5 --- ...n-1f-123-region-reference-files.changed.md | 2 +- docs/migration/stage-9-v2-metadata.md | 15 ++- .../data/v2/catalog/publication.py | 36 ++++- .../v2/catalog/publication_reconciliation.py | 55 ++++++-- .../test_v2_catalog_publication.py | 125 ++++++++++++++++++ 5 files changed, 214 insertions(+), 19 deletions(-) diff --git a/changelog.d/v2plan-1f-123-region-reference-files.changed.md b/changelog.d/v2plan-1f-123-region-reference-files.changed.md index 3b2933649..0b762cece 100644 --- a/changelog.d/v2plan-1f-123-region-reference-files.changed.md +++ b/changelog.d/v2plan-1f-123-region-reference-files.changed.md @@ -1 +1 @@ -Catalog publication reads regions from the Public API's checked-in reference files, one per country, instead of PolicyEngine.py's region registry. Each region takes its dataset from the certified data release and its filter from a scoping rule: a row filter on state_fips, congressional_district_geoid or the UK region column, or a weight_replacement filter naming the release's constituency or local-authority weight matrix and the region's ONS code. The UK now publishes 1,015 regions, with constituencies and local authorities by ONS code and Northern Ireland as country/northern_ireland; the national regions are "United States" and "United Kingdom". US places are listed in the reference file but left out of the catalog until they can run, and the publication evidence counts them. Before it publishes a catalog version that is not stored yet, the publication command reads the HDF5 metadata of the certified artifacts the filters use, from Hugging Face at their pinned revisions, and stops if a row filter's column is missing from its dataset or a weight matrix has no row for a region's code; the seed workflow's publication step receives the UK data read token as HUGGING_FACE_TOKEN for this. +Catalog publication reads regions from the Public API's checked-in reference files, one per country, instead of PolicyEngine.py's region registry. Each region takes its dataset from the certified data release and its filter from a scoping rule: a row filter on state_fips, congressional_district_geoid or the UK region column, or a weight_replacement filter naming the release's constituency or local-authority weight matrix and the region's ONS code. The UK now publishes 1,015 regions, with constituencies and local authorities by ONS code and Northern Ireland as country/northern_ireland; the national regions are "United States" and "United Kingdom". US places are listed in the reference file but left out of the catalog until they can run, and the publication evidence counts them. Before it publishes a catalog version that is not stored yet, the publication command reads the HDF5 metadata of the certified artifacts the filters use, from Hugging Face at their pinned revisions, and stops if a row filter's column is missing from its dataset or a weight matrix has no row for a region's code; the seed workflow's publication step receives the UK data read token as HUGGING_FACE_TOKEN for this. A catalog version that is already stored keeps the regions it was published with, so region file changes take effect in the next catalog version; republishing it logs the difference in region rows instead of failing, and every other table must still match. diff --git a/docs/migration/stage-9-v2-metadata.md b/docs/migration/stage-9-v2-metadata.md index b6f0bd994..b5ef519db 100644 --- a/docs/migration/stage-9-v2-metadata.md +++ b/docs/migration/stage-9-v2-metadata.md @@ -138,9 +138,14 @@ Review the fallback and omitted-region summaries on every new PolicyEngine.py release. A changed count may identify an upstream catalog change that needs a reviewed dataset selection even when publication otherwise succeeds. -A catalog version is immutable: republishing a stored PolicyEngine.py version -whose regions differ from the stored ones fails. Region reference changes -therefore reach the catalog with the next PolicyEngine.py version. +A catalog version is identified by its country and PolicyEngine.py version and +never changes once published. When the command republishes a stored version +whose staged regions differ from the stored ones, it keeps the stored regions +unchanged, logs `kept_published_catalog_regions` with the country, the version +and the counts of staged region rows the stored version lacks and of stored +rows not staged, and continues. Every other table must still match exactly. +Region reference changes therefore take effect in the next catalog version +that is published. ## Validation evidence and retry @@ -169,7 +174,9 @@ A failed attempt rolls back all catalog changes. Retry the same artifact only after correcting the external failure. A matching retry preserves identifiers, content, and row counts. Different normalized content under the same PolicyEngine.py version fails for operator review; it is never silently -replaced. A later PolicyEngine.py version adds a catalog version without +replaced. Regions are the exception: they come from the Public API's reference +files, so a stored version keeps the regions it was published with (see +"Source and regional dataset behavior"). A later PolicyEngine.py version adds a catalog version without deleting or modifying earlier versioned rows, including their logical datasets, regions, model descriptions, current-law IDs, and time-period options. diff --git a/policyengine_api/data/v2/catalog/publication.py b/policyengine_api/data/v2/catalog/publication.py index 04256af4f..d58ddd665 100644 --- a/policyengine_api/data/v2/catalog/publication.py +++ b/policyengine_api/data/v2/catalog/publication.py @@ -12,6 +12,7 @@ from policyengine_api.data.v2.catalog.publication_reconciliation import ( assert_canonical_value_uniqueness, assert_country_matches, + count_region_differences, publish_new_country, version_exists, ) @@ -34,7 +35,10 @@ if TYPE_CHECKING: from collections.abc import Callable - from policyengine_api.data.v2.catalog.records import NormalizedCatalog + from policyengine_api.data.v2.catalog.records import ( + CountryCatalog, + NormalizedCatalog, + ) # Stable application-defined PostgreSQL lock ID shared by all v2 catalog publishers. PUBLICATION_ADVISORY_LOCK_KEY = 8_629_020_026_090_001 @@ -121,10 +125,14 @@ def publish_catalog( if checkpoint is not None: checkpoint("after_copy", connection) + # A stored catalog version never changes. Its regions stay as they + # were published even when the reference files differ now; every + # other table must still match exactly. existing: set[str] = set() for country in catalog.countries: if version_exists(connection, country): - assert_country_matches(connection, country) + assert_country_matches(connection, country, include_regions=False) + _log_kept_regions(connection, country) existing.add(country.country_id) for country in catalog.countries: @@ -134,7 +142,11 @@ def publish_catalog( checkpoint("after_reconciliation", connection) for country in catalog.countries: - assert_country_matches(connection, country) + assert_country_matches( + connection, + country, + include_regions=country.country_id not in existing, + ) assert_canonical_value_uniqueness(connection) if _protected_row_counts(connection) != before: msg = "publication changed simulation, report, or dataset-version rows" @@ -162,6 +174,24 @@ def publish_catalog( ) +def _log_kept_regions(connection: Connection, country: CountryCatalog) -> None: + """Log, with counts only, how a stored version's regions differ from the staged.""" + staged_not_stored, stored_not_staged = count_region_differences(connection, country) + if not staged_not_stored and not stored_not_staged: + return + LOGGER.warning( + "kept_published_catalog_regions", + extra={ + "policyengine_attributes": { + "country_id": country.country_id, + "policyengine_version": country.model_version.version, + "staged_region_rows_not_stored": staged_not_stored, + "stored_region_rows_not_staged": stored_not_staged, + } + }, + ) + + def _log_fallback_warning( fallback_summaries: tuple[tuple[str, str, int], ...], ) -> None: diff --git a/policyengine_api/data/v2/catalog/publication_reconciliation.py b/policyengine_api/data/v2/catalog/publication_reconciliation.py index 0977676c6..f8e378235 100644 --- a/policyengine_api/data/v2/catalog/publication_reconciliation.py +++ b/policyengine_api/data/v2/catalog/publication_reconciliation.py @@ -220,6 +220,22 @@ def _comparison_pairs( ) ) + return ( + (staged_model_version, actual_model_version), + (staged_variables, actual_variables), + (staged_parameter_nodes, actual_parameter_nodes), + (staged_parameters, actual_parameters), + (staged_parameter_values, actual_parameter_values), + (staged_datasets, actual_datasets), + ) + + +def _region_comparison_pair(country: CountryCatalog) -> tuple[Select, Select]: + """Return the staged and stored region rows of one country's catalog version.""" + selected_version = sa.and_( + MODELS.c.name == country.model.name, + MODEL_VERSIONS.c.version == country.model_version.version, + ) staged_regions = sa.select( STAGE_CATALOG_REGIONS.c.code, STAGE_CATALOG_REGIONS.c.label, @@ -256,15 +272,7 @@ def _comparison_pairs( .where(selected_version) ) - return ( - (staged_model_version, actual_model_version), - (staged_variables, actual_variables), - (staged_parameter_nodes, actual_parameter_nodes), - (staged_parameters, actual_parameters), - (staged_parameter_values, actual_parameter_values), - (staged_datasets, actual_datasets), - (staged_regions, actual_regions), - ) + return staged_regions, actual_regions def _sets_differ( @@ -284,18 +292,43 @@ def _sets_differ( def assert_country_matches( connection: Connection, country: CountryCatalog, + *, + include_regions: bool = True, ) -> None: """Raise CatalogPublicationError if the stored version differs from the staged one. Rows are compared as sets in both directions, so a missing row and an extra - row both fail. + row both fail. Without ``include_regions``, region rows are not compared: + they come from the Public API's reference files, which may change while + the PolicyEngine.py version stays the same. """ - for staged, actual in _comparison_pairs(country): + pairs = _comparison_pairs(country) + if include_regions: + pairs = (*pairs, _region_comparison_pair(country)) + for staged, actual in pairs: if _sets_differ(connection, staged, actual): msg = f"persisted {country.country_id} catalog differs from PolicyEngine.py" raise CatalogPublicationError(msg) +def count_region_differences( + connection: Connection, + country: CountryCatalog, +) -> tuple[int, int]: + """Count staged region rows the stored version lacks, and stored rows not staged.""" + staged, actual = _region_comparison_pair(country) + staged_not_stored = sa.except_(staged, actual).subquery() + stored_not_staged = sa.except_(actual, staged).subquery() + return ( + connection.execute( + sa.select(sa.func.count()).select_from(staged_not_stored) + ).scalar_one(), + connection.execute( + sa.select(sa.func.count()).select_from(stored_not_staged) + ).scalar_one(), + ) + + INSERT_MODEL = ( postgresql_insert(MODELS) .from_select( diff --git a/tests/integration/test_v2_catalog_publication.py b/tests/integration/test_v2_catalog_publication.py index 011b3d29c..92d572bba 100644 --- a/tests/integration/test_v2_catalog_publication.py +++ b/tests/integration/test_v2_catalog_publication.py @@ -15,6 +15,7 @@ from sqlalchemy.pool import NullPool from sqlmodel import Session, select +from policyengine_api.data.v2.catalog import publication as publication_module from policyengine_api.data.v2.catalog.publication import ( CatalogPublicationError, publish_catalog, @@ -232,6 +233,130 @@ def test_same_version_with_changed_content_fails_without_mutation( assert _counts(publication_engine) == before_counts +def _region_rows(engine: Engine) -> list[tuple[object, ...]]: + with engine.connect() as connection: + return [ + tuple(row) + for row in connection.execute( + text( + """ + SELECT id, code, label, region_type, requires_filter, + filter_field, filter_value, filter_strategy, + parent_code, state_code, state_name, default_dataset_id + FROM regions + ORDER BY id + """ + ) + ) + ] + + +def _with_changed_us_regions(catalog): + """Relabel state/ca and drop the district, as a reference-file change would.""" + us = catalog.country("us") + regions = tuple( + replace(region, label="Changed California") + if region.code == "state/ca" + else region + for region in us.regions + if region.code != "congressional_district/CA-01" + ) + return replace( + catalog, + countries=(replace(us, regions=regions), catalog.country("uk")), + ) + + +def test_stored_version_keeps_its_published_regions_and_logs_the_difference( + publication_engine: Engine, + monkeypatch: pytest.MonkeyPatch, +) -> None: + catalog = normalized_catalog() + publish_catalog(publication_engine, catalog) + before_regions = _region_rows(publication_engine) + before_ids = _identifiers(publication_engine) + before_counts = _counts(publication_engine) + # Alembic's logging configuration disables existing loggers, so the + # warnings are recorded where they are emitted. + warnings: list[tuple[str, dict]] = [] + monkeypatch.setattr( + publication_module.LOGGER, + "warning", + lambda message, *args, **kwargs: warnings.append((message, kwargs)), + ) + + evidence = publish_catalog(publication_engine, _with_changed_us_regions(catalog)) + + assert evidence.as_dict()["outcome"] == "ok" + assert _region_rows(publication_engine) == before_regions + assert _identifiers(publication_engine) == before_ids + assert _counts(publication_engine) == before_counts + notices = [ + keywords["extra"] + for message, keywords in warnings + if message == "kept_published_catalog_regions" + ] + assert [notice["policyengine_attributes"] for notice in notices] == [ + { + "country_id": "us", + "policyengine_version": catalog.policyengine_version, + "staged_region_rows_not_stored": 1, + "stored_region_rows_not_staged": 2, + } + ] + + +def test_stored_version_with_changed_regions_still_fails_on_other_tables( + publication_engine: Engine, +) -> None: + catalog = normalized_catalog() + publish_catalog(publication_engine, catalog) + before_regions = _region_rows(publication_engine) + changed = _with_changed_us_regions(catalog) + us = changed.country("us") + changed = replace( + changed, + countries=( + replace(us, variables=(replace(us.variables[0], label="Changed"),)), + changed.country("uk"), + ), + ) + + with pytest.raises(CatalogPublicationError, match="differs"): + publish_catalog(publication_engine, changed) + + assert _region_rows(publication_engine) == before_regions + + +def test_new_version_publishes_the_reference_file_regions( + publication_engine: Engine, +) -> None: + publish_catalog(publication_engine, normalized_catalog()) + newer = _with_changed_us_regions(normalized_catalog(policyengine_version="4.21.0")) + + publish_catalog(publication_engine, newer) + + with publication_engine.connect() as connection: + newer_us_regions = dict( + connection.execute( + text( + """ + SELECT region.code, region.label + FROM regions AS region + JOIN tax_benefit_model_versions AS version + ON version.id = region.tax_benefit_model_version_id + JOIN tax_benefit_models AS model ON model.id = version.model_id + WHERE version.version = '4.21.0' AND model.name = 'policyengine-us' + """ + ) + ).all() + ) + assert newer_us_regions == { + "state/ca": "Changed California", + "us": "United States", + } + + def test_republish_preserves_existing_run_and_dataset_version_records( publication_engine: Engine, ) -> None: From 5670b182ee61b2bbee7ebdb897871c7b7fa2db7d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:40:43 +0400 Subject: [PATCH 05/25] Pin the UK catalog fixture's placeholder repository in the artifact test The artifact test now checks that the UK release fixture's artifacts name the placeholder repository example-org/example-uk-data. Co-Authored-By: Claude Opus 5.5 --- tests/unit/v2/test_region_data_verification.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/unit/v2/test_region_data_verification.py b/tests/unit/v2/test_region_data_verification.py index b5a4d89b7..74e67a5cb 100644 --- a/tests/unit/v2/test_region_data_verification.py +++ b/tests/unit/v2/test_region_data_verification.py @@ -87,12 +87,18 @@ def test_catalog_records_only_the_artifacts_its_filters_read() -> None: ) ] assert [ - (artifact.name, artifact.path, artifact.repo_type) + (artifact.name, artifact.repo_id, artifact.path, artifact.repo_type) for artifact in catalog.country("uk").certified_artifacts ] == [ - ("microcosm_uk_2024_25", "microcosm_uk_2024_25.h5", "model"), + ( + "microcosm_uk_2024_25", + "example-org/example-uk-data", + "microcosm_uk_2024_25.h5", + "model", + ), ( "parliamentary_constituency_weights", + "example-org/example-uk-data", "parliamentary_constituency_weights.h5", "model", ), From 15d84e2618818fba626f5336a92d3914a133ed3a Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:54:10 +0400 Subject: [PATCH 06/25] Depend directly on h5py and huggingface-hub Catalog publication's data reader imports both, which the project had only through PolicyEngine.py and PolicyEngine Core. They are now direct dependencies, constrained to keep the locked versions (h5py 3.16.0, huggingface-hub 1.12.2). uv.lock, refreshed with `uv lock` (uv 0.13.4, as the release workflow uses), changes only the project's dependency metadata. A test requires every third-party package the reader imports to be a direct dependency. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 3 ++ .../unit/v2/test_certified_data_inspection.py | 40 +++++++++++++++++-- uv.lock | 4 ++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ef1bee9d6..9285d2383 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,10 @@ dependencies = [ "google-cloud-logging>=3,<4", "google-cloud-secret-manager>=2,<3", "gunicorn", + # Catalog publication reads certified HDF5 artifacts from Hugging Face. + "h5py>=3.16,<4", "httpx>=0.27.0", + "huggingface-hub>=1.12,<2", "markupsafe>=3,<4", "microdf_python>=1.0.0", "openai", diff --git a/tests/unit/v2/test_certified_data_inspection.py b/tests/unit/v2/test_certified_data_inspection.py index 6ff69191e..d159fb820 100644 --- a/tests/unit/v2/test_certified_data_inspection.py +++ b/tests/unit/v2/test_certified_data_inspection.py @@ -2,12 +2,17 @@ from __future__ import annotations -from typing import TYPE_CHECKING +import ast +import importlib.metadata +import tomllib +from pathlib import Path import h5py import numpy as np import pandas as pd import pytest +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name from policyengine_api.data.v2.catalog.certified_data_inspection import ( CertifiedDataReadError, @@ -18,9 +23,6 @@ ) from policyengine_api.data.v2.catalog.records import CertifiedArtifact -if TYPE_CHECKING: - from pathlib import Path - HOUSEHOLDS = pd.DataFrame( { "household_id": [1, 2], @@ -216,3 +218,33 @@ def build_file_system(token: str | None) -> FakeFileSystem: assert inspector.weight_matrix_row_count(matrix) == 2 assert tokens == ["read-token"] assert file_system.opened == [huggingface_path(dataset), huggingface_path(matrix)] + + +def test_imports_only_packages_the_project_depends_on_directly() -> None: + repo = Path(__file__).parents[3] + module = repo / "policyengine_api/data/v2/catalog/certified_data_inspection.py" + tree = ast.parse(module.read_text(encoding="utf-8")) + top_level_imports = { + alias.name.split(".")[0] + for node in ast.walk(tree) + if isinstance(node, ast.Import) + for alias in node.names + } | { + (node.module or "").split(".")[0] + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and node.level == 0 + } + distributions = importlib.metadata.packages_distributions() + third_party = { + canonicalize_name(distributions[name][0]) + for name in top_level_imports + if name in distributions and name != "policyengine_api" + } + project = tomllib.loads((repo / "pyproject.toml").read_text(encoding="utf-8")) + direct = { + canonicalize_name(Requirement(requirement).name) + for requirement in project["project"]["dependencies"] + } + + assert third_party == {"h5py", "huggingface-hub"} + assert third_party <= direct diff --git a/uv.lock b/uv.lock index 094aec459..14d4b7346 100644 --- a/uv.lock +++ b/uv.lock @@ -2786,7 +2786,9 @@ dependencies = [ { name = "google-cloud-logging" }, { name = "google-cloud-secret-manager" }, { name = "gunicorn" }, + { name = "h5py" }, { name = "httpx" }, + { name = "huggingface-hub" }, { name = "markupsafe" }, { name = "microdf-python" }, { name = "openai" }, @@ -2840,7 +2842,9 @@ requires-dist = [ { name = "google-cloud-logging", specifier = ">=3,<4" }, { name = "google-cloud-secret-manager", specifier = ">=2,<3" }, { name = "gunicorn" }, + { name = "h5py", specifier = ">=3.16,<4" }, { name = "httpx", specifier = ">=0.27.0" }, + { name = "huggingface-hub", specifier = ">=1.12,<2" }, { name = "markupsafe", specifier = ">=3,<4" }, { name = "microdf-python", specifier = ">=1.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.15,<2" }, From 4abc8a087c9356939b6af0d3234fffd31c514305 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:17:00 +0400 Subject: [PATCH 07/25] Print the kept-regions warning's country, version and counts The seed command configures no logging handler, so Python's last-resort handler printed only the warning's bare event name and dropped the structured attributes. The message now carries the country, the PolicyEngine.py version and both region-row counts; the attributes stay for structured handlers. Co-Authored-By: Claude Opus 5.5 --- docs/migration/stage-9-v2-metadata.md | 6 +-- .../data/v2/catalog/publication.py | 9 +++- .../test_v2_catalog_publication.py | 13 +++-- tests/unit/v2/test_catalog_publication.py | 50 +++++++++++++++++++ 4 files changed, 70 insertions(+), 8 deletions(-) diff --git a/docs/migration/stage-9-v2-metadata.md b/docs/migration/stage-9-v2-metadata.md index b5ef519db..d8deb827f 100644 --- a/docs/migration/stage-9-v2-metadata.md +++ b/docs/migration/stage-9-v2-metadata.md @@ -141,9 +141,9 @@ reviewed dataset selection even when publication otherwise succeeds. A catalog version is identified by its country and PolicyEngine.py version and never changes once published. When the command republishes a stored version whose staged regions differ from the stored ones, it keeps the stored regions -unchanged, logs `kept_published_catalog_regions` with the country, the version -and the counts of staged region rows the stored version lacks and of stored -rows not staged, and continues. Every other table must still match exactly. +unchanged, prints a warning with the country, the version and the counts of +staged region rows the stored version lacks and of stored rows not staged, and +continues. Every other table must still match exactly. Region reference changes therefore take effect in the next catalog version that is published. diff --git a/policyengine_api/data/v2/catalog/publication.py b/policyengine_api/data/v2/catalog/publication.py index d58ddd665..108a41af0 100644 --- a/policyengine_api/data/v2/catalog/publication.py +++ b/policyengine_api/data/v2/catalog/publication.py @@ -179,8 +179,15 @@ def _log_kept_regions(connection: Connection, country: CountryCatalog) -> None: staged_not_stored, stored_not_staged = count_region_differences(connection, country) if not staged_not_stored and not stored_not_staged: return + # The seed command configures no logging handler, so the details are in the + # message itself; the attributes serve structured handlers. LOGGER.warning( - "kept_published_catalog_regions", + "kept the published regions of the stored %s catalog version %s: %d staged " + "region rows are not stored and %d stored region rows are not staged", + country.country_id, + country.model_version.version, + staged_not_stored, + stored_not_staged, extra={ "policyengine_attributes": { "country_id": country.country_id, diff --git a/tests/integration/test_v2_catalog_publication.py b/tests/integration/test_v2_catalog_publication.py index 92d572bba..87d88a326 100644 --- a/tests/integration/test_v2_catalog_publication.py +++ b/tests/integration/test_v2_catalog_publication.py @@ -282,7 +282,7 @@ def test_stored_version_keeps_its_published_regions_and_logs_the_difference( monkeypatch.setattr( publication_module.LOGGER, "warning", - lambda message, *args, **kwargs: warnings.append((message, kwargs)), + lambda message, *args, **kwargs: warnings.append((message % args, kwargs)), ) evidence = publish_catalog(publication_engine, _with_changed_us_regions(catalog)) @@ -292,11 +292,16 @@ def test_stored_version_keeps_its_published_regions_and_logs_the_difference( assert _identifiers(publication_engine) == before_ids assert _counts(publication_engine) == before_counts notices = [ - keywords["extra"] + (message, keywords["extra"]) for message, keywords in warnings - if message == "kept_published_catalog_regions" + if message.startswith("kept the published regions") ] - assert [notice["policyengine_attributes"] for notice in notices] == [ + assert [message for message, _extra in notices] == [ + "kept the published regions of the stored us catalog version " + f"{catalog.policyengine_version}: 1 staged region rows are not stored and " + "2 stored region rows are not staged" + ] + assert [extra["policyengine_attributes"] for _message, extra in notices] == [ { "country_id": "us", "policyengine_version": catalog.policyengine_version, diff --git a/tests/unit/v2/test_catalog_publication.py b/tests/unit/v2/test_catalog_publication.py index 7bb733ec0..abd519d06 100644 --- a/tests/unit/v2/test_catalog_publication.py +++ b/tests/unit/v2/test_catalog_publication.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from io import StringIO from pathlib import Path from unittest.mock import patch @@ -297,3 +298,52 @@ def test_fallback_summary_emits_one_non_secret_warning() -> None: rendered_message = message_template % (logged_summaries,) assert "regional dataset fallback summary" in rendered_message assert "postgresql://" not in rendered_message + + +def _kept_region_records( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + differences: tuple[int, int], +) -> list[logging.LogRecord]: + monkeypatch.setattr( + publication, + "count_region_differences", + lambda _connection, _country: differences, + ) + # An earlier test's Alembic logging setup may have disabled existing loggers. + monkeypatch.setattr(publication.LOGGER, "disabled", False) + with caplog.at_level(logging.WARNING, logger=publication.LOGGER.name): + publication._log_kept_regions(object(), normalized_catalog().country("us")) + return [ + record for record in caplog.records if record.name == publication.LOGGER.name + ] + + +def test_kept_region_warning_prints_country_version_and_counts_without_logging_setup( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + [record] = _kept_region_records(monkeypatch, caplog, (1, 2)) + + # The seed command configures no logging, so Python's last-resort handler + # prints the record; this is the text the job output shows. + assert logging.lastResort is not None + assert logging.lastResort.format(record) == ( + "kept the published regions of the stored us catalog version 5.0.4: " + "1 staged region rows are not stored and 2 stored region rows are not " + "staged" + ) + assert record.levelno == logging.WARNING + assert record.policyengine_attributes == { + "country_id": "us", + "policyengine_version": "5.0.4", + "staged_region_rows_not_stored": 1, + "stored_region_rows_not_staged": 2, + } + + +def test_matching_kept_regions_log_nothing( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + assert _kept_region_records(monkeypatch, caplog, (0, 0)) == [] From 103cf3bd9a8f2b758f087bab297ec44e92afc537 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:21:23 +0400 Subject: [PATCH 08/25] Name the table that differs when a stored catalog version does not match Republishing a stored catalog version with different content failed with "persisted catalog differs from PolicyEngine.py", even when the difference came from the Public API's own selections, such as a dataset. The error now names the country, the version and the first table that differs (model version, variables, parameter nodes, parameters, parameter values, datasets or regions), and still no row data. Co-Authored-By: Claude Opus 5.5 --- docs/migration/stage-9-v2-metadata.md | 4 +-- .../v2/catalog/publication_reconciliation.py | 32 ++++++++++------- .../test_v2_catalog_publication.py | 34 ++++++++++++++++++- tests/unit/v2/test_catalog_publication.py | 14 ++++++-- 4 files changed, 66 insertions(+), 18 deletions(-) diff --git a/docs/migration/stage-9-v2-metadata.md b/docs/migration/stage-9-v2-metadata.md index d8deb827f..6568ad9e3 100644 --- a/docs/migration/stage-9-v2-metadata.md +++ b/docs/migration/stage-9-v2-metadata.md @@ -173,8 +173,8 @@ confirms existing simulation and report record counts are unchanged. A failed attempt rolls back all catalog changes. Retry the same artifact only after correcting the external failure. A matching retry preserves identifiers, content, and row counts. Different normalized content under the same -PolicyEngine.py version fails for operator review; it is never silently -replaced. Regions are the exception: they come from the Public API's reference +PolicyEngine.py version fails for operator review, with an error naming the +first table that differs; it is never silently replaced. Regions are the exception: they come from the Public API's reference files, so a stored version keeps the regions it was published with (see "Source and regional dataset behavior"). A later PolicyEngine.py version adds a catalog version without deleting or modifying earlier versioned rows, including their logical datasets, diff --git a/policyengine_api/data/v2/catalog/publication_reconciliation.py b/policyengine_api/data/v2/catalog/publication_reconciliation.py index f8e378235..1debc3d98 100644 --- a/policyengine_api/data/v2/catalog/publication_reconciliation.py +++ b/policyengine_api/data/v2/catalog/publication_reconciliation.py @@ -78,7 +78,8 @@ def _versioned_table_from(table: sa.Table) -> sa.Join: def _comparison_pairs( country: CountryCatalog, -) -> tuple[tuple[Select, Select], ...]: +) -> tuple[tuple[str, Select, Select], ...]: + """Return each compared table's name with its staged and stored rows.""" staged_country = STAGE_CATALOG_MODELS.c.country_id == country.country_id selected_version = sa.and_( MODELS.c.name == country.model.name, @@ -221,12 +222,12 @@ def _comparison_pairs( ) return ( - (staged_model_version, actual_model_version), - (staged_variables, actual_variables), - (staged_parameter_nodes, actual_parameter_nodes), - (staged_parameters, actual_parameters), - (staged_parameter_values, actual_parameter_values), - (staged_datasets, actual_datasets), + ("model version", staged_model_version, actual_model_version), + ("variables", staged_variables, actual_variables), + ("parameter nodes", staged_parameter_nodes, actual_parameter_nodes), + ("parameters", staged_parameters, actual_parameters), + ("parameter values", staged_parameter_values, actual_parameter_values), + ("datasets", staged_datasets, actual_datasets), ) @@ -298,16 +299,21 @@ def assert_country_matches( """Raise CatalogPublicationError if the stored version differs from the staged one. Rows are compared as sets in both directions, so a missing row and an extra - row both fail. Without ``include_regions``, region rows are not compared: - they come from the Public API's reference files, which may change while - the PolicyEngine.py version stays the same. + row both fail; the error names the first table that differs, never its + rows. Without ``include_regions``, region rows are not compared: they come + from the Public API's reference files, which may change while the + PolicyEngine.py version stays the same. """ pairs = _comparison_pairs(country) if include_regions: - pairs = (*pairs, _region_comparison_pair(country)) - for staged, actual in pairs: + pairs = (*pairs, ("regions", *_region_comparison_pair(country))) + for table_name, staged, actual in pairs: if _sets_differ(connection, staged, actual): - msg = f"persisted {country.country_id} catalog differs from PolicyEngine.py" + msg = ( + f"the stored {country.country_id} catalog version " + f"{country.model_version.version} differs from the staged catalog " + f"in its {table_name}" + ) raise CatalogPublicationError(msg) diff --git a/tests/integration/test_v2_catalog_publication.py b/tests/integration/test_v2_catalog_publication.py index 87d88a326..db16da1c8 100644 --- a/tests/integration/test_v2_catalog_publication.py +++ b/tests/integration/test_v2_catalog_publication.py @@ -226,13 +226,45 @@ def test_same_version_with_changed_content_fails_without_mutation( countries=(changed_us, catalog.country("uk")), ) - with pytest.raises(CatalogPublicationError, match="differs"): + with pytest.raises( + CatalogPublicationError, + match=r"^the stored us catalog version 5\.0\.4 differs from the staged " + r"catalog in its variables$", + ): publish_catalog(publication_engine, changed) assert _identifiers(publication_engine) == before_ids assert _counts(publication_engine) == before_counts +def test_stored_version_with_a_different_dataset_names_the_datasets( + publication_engine: Engine, +) -> None: + catalog = normalized_catalog() + publish_catalog(publication_engine, catalog) + uk = catalog.country("uk") + changed = replace( + catalog, + countries=( + catalog.country("us"), + replace( + uk, + datasets=tuple( + replace(dataset, description="Another dataset selection") + for dataset in uk.datasets + ), + ), + ), + ) + + with pytest.raises( + CatalogPublicationError, + match=r"^the stored uk catalog version 5\.0\.4 differs from the staged " + r"catalog in its datasets$", + ): + publish_catalog(publication_engine, changed) + + def _region_rows(engine: Engine) -> list[tuple[object, ...]]: with engine.connect() as connection: return [ diff --git a/tests/unit/v2/test_catalog_publication.py b/tests/unit/v2/test_catalog_publication.py index abd519d06..911948618 100644 --- a/tests/unit/v2/test_catalog_publication.py +++ b/tests/unit/v2/test_catalog_publication.py @@ -215,10 +215,20 @@ def test_publication_uses_mapped_tables_and_sqlalchemy_statements() -> None: catalog = normalized_catalog() comparisons = publication_reconciliation._comparison_pairs(catalog.country("us")) + assert [table_name for table_name, _staged, _stored in comparisons] == [ + "model version", + "variables", + "parameter nodes", + "parameters", + "parameter values", + "datasets", + ] + statements = [ + statement for _table_name, *pair in comparisons for statement in pair + ] + list(publication_reconciliation._region_comparison_pair(catalog.country("us"))) assert all( isinstance(statement, sa.sql.Select) and not isinstance(statement, TextClause) - for pair in comparisons - for statement in pair + for statement in statements ) class Result: From 089713dc397d8f96f880fd4de663175682a2188b Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:26:16 +0400 Subject: [PATCH 09/25] Report a certified artifact's structure error with its own message Verification reduced every read failure to the artifact's name and the error type, so a structure error such as "household group holds no data frame" reached the operator only as "CertifiedDataReadError". That error, which names only HDF5 entries and counts, now keeps its message; any other failure is still reduced to the name and type, since its text may hold a location. The error class moves to the catalog's errors module so verification can recognize it. Co-Authored-By: Claude Opus 5.5 --- .../data/v2/catalog/certified_data_inspection.py | 6 ++---- policyengine_api/data/v2/catalog/errors.py | 9 ++++++++- .../data/v2/catalog/region_data_verification.py | 15 +++++++++++++-- tests/unit/v2/test_region_data_verification.py | 14 ++++++++++++++ 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/policyengine_api/data/v2/catalog/certified_data_inspection.py b/policyengine_api/data/v2/catalog/certified_data_inspection.py index d0a1ebed5..80fd29ba3 100644 --- a/policyengine_api/data/v2/catalog/certified_data_inspection.py +++ b/policyengine_api/data/v2/catalog/certified_data_inspection.py @@ -13,6 +13,8 @@ import h5py from huggingface_hub import HfFileSystem +from policyengine_api.data.v2.catalog.errors import CertifiedDataReadError + if TYPE_CHECKING: from collections.abc import Callable from contextlib import AbstractContextManager @@ -28,10 +30,6 @@ _Result = TypeVar("_Result") -class CertifiedDataReadError(RuntimeError): - """Raised when a certified artifact does not have the structure expected.""" - - # HDF5 metadata is read in many small, scattered pieces; caching whole blocks of # this size keeps the number of ranged requests low. READ_BLOCK_BYTES = 1 << 20 diff --git a/policyengine_api/data/v2/catalog/errors.py b/policyengine_api/data/v2/catalog/errors.py index 53be90f51..e33762b81 100644 --- a/policyengine_api/data/v2/catalog/errors.py +++ b/policyengine_api/data/v2/catalog/errors.py @@ -1,7 +1,14 @@ -"""The error raised when catalog sources cannot be normalized.""" +"""Errors raised while catalog sources are read and normalized.""" from __future__ import annotations class CatalogExtractionError(RuntimeError): """Raised before database access when source metadata cannot be normalized.""" + + +class CertifiedDataReadError(RuntimeError): + """Raised when a certified artifact does not have the structure expected. + + Its message names only HDF5 entries and counts, so it is safe to print. + """ diff --git a/policyengine_api/data/v2/catalog/region_data_verification.py b/policyengine_api/data/v2/catalog/region_data_verification.py index 803698467..e3f1a8489 100644 --- a/policyengine_api/data/v2/catalog/region_data_verification.py +++ b/policyengine_api/data/v2/catalog/region_data_verification.py @@ -15,7 +15,10 @@ from pathlib import Path from typing import TYPE_CHECKING, Protocol, TypeVar -from policyengine_api.data.v2.catalog.errors import CatalogExtractionError +from policyengine_api.data.v2.catalog.errors import ( + CatalogExtractionError, + CertifiedDataReadError, +) from policyengine_api.data.v2.catalog.region_scoping import ( ROW_FILTER, WEIGHT_REPLACEMENT, @@ -77,11 +80,19 @@ def _noun(count: int) -> str: def _read(artifact: CertifiedArtifact, read: Callable[[], _Result]) -> _Result: - """Run one read of an artifact, reporting a failure without its error text.""" + """Run one read of an artifact, reporting a failure safely. + + A structure error keeps its own message; any other failure is reduced to + the artifact's name and the error type, since its text may hold a location. + """ try: return read() except RegionDataVerificationError: raise + except CertifiedDataReadError as error: + # Its message names only HDF5 entries and counts. + msg = f"the certified artifact {artifact.name} cannot be checked: {error}" + raise RegionDataVerificationError(msg) from error except Exception as error: # Only the artifact's name and the error type: the error text may hold # its location, and command output names no dataset release. diff --git a/tests/unit/v2/test_region_data_verification.py b/tests/unit/v2/test_region_data_verification.py index 74e67a5cb..0735d39b0 100644 --- a/tests/unit/v2/test_region_data_verification.py +++ b/tests/unit/v2/test_region_data_verification.py @@ -6,6 +6,7 @@ import pytest +from policyengine_api.data.v2.catalog.errors import CertifiedDataReadError from policyengine_api.data.v2.catalog.region_data_verification import ( WEIGHT_MATRIX_ROW_LOOKUPS, RegionDataVerificationError, @@ -212,3 +213,16 @@ def test_row_lookups_list_every_reference_code_in_matrix_order() -> None: assert len(local_authorities) == len(set(local_authorities)) == 361 assert constituencies[0] == "E14001063" assert local_authorities[0] == "E06000001" + + +def test_reports_an_artifact_structure_error_with_its_own_message() -> None: + inspector = FakeInspector( + failure=CertifiedDataReadError("household group holds no data frame") + ) + + with pytest.raises( + RegionDataVerificationError, + match=r"^the certified artifact populace_us_2024_acs_local cannot be " + r"checked: household group holds no data frame$", + ): + _verify(inspector, countries=("us",)) From c27bd92880d7d391a778db8af55a117eebd45d84 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:26:58 +0400 Subject: [PATCH 10/25] Check the v2 revision before reading which catalog versions are stored The region data check reads stored catalog versions before publication checks the database revision, so on a database without the v2 schema the command failed with "catalog initialization failed unexpectedly" instead of the specific revision error. unpublished_countries now requires the expected Alembic revision first, as publication does. Co-Authored-By: Claude Opus 5.5 --- policyengine_api/data/v2/catalog/publication.py | 7 +++++-- tests/integration/test_v2_catalog_publication.py | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/policyengine_api/data/v2/catalog/publication.py b/policyengine_api/data/v2/catalog/publication.py index 108a41af0..12b6ef059 100644 --- a/policyengine_api/data/v2/catalog/publication.py +++ b/policyengine_api/data/v2/catalog/publication.py @@ -97,10 +97,13 @@ def unpublished_countries( ) -> tuple[str, ...]: """Return the countries whose catalog version is not stored yet, in catalog order. - A read outside publication's transaction: a version another publisher adds - meanwhile is still compared with the staged one when this one publishes. + It first requires the expected v2 revision, as publication does, so a + missing or older schema is reported as such. A read outside publication's + transaction: a version another publisher adds meanwhile is still compared + with the staged one when this one publishes. """ with engine.connect() as connection: + _verify_expected_revision(connection) return tuple( country.country_id for country in catalog.countries diff --git a/tests/integration/test_v2_catalog_publication.py b/tests/integration/test_v2_catalog_publication.py index db16da1c8..e89323431 100644 --- a/tests/integration/test_v2_catalog_publication.py +++ b/tests/integration/test_v2_catalog_publication.py @@ -175,6 +175,20 @@ def test_publish_and_republish_preserve_complete_catalog( } +def test_unpublished_country_check_reports_a_missing_schema_specifically() -> None: + # The same server's maintenance database has no v2 schema at all. + schema_less = make_url(_disposable_url()).set(database="postgres") + engine = create_engine(schema_less, poolclass=NullPool) + try: + with pytest.raises( + CatalogPublicationError, + match=r"^the v2 Alembic revision table is absent$", + ): + unpublished_countries(engine, normalized_catalog()) + finally: + engine.dispose() + + def test_lists_only_countries_whose_catalog_version_is_not_stored( publication_engine: Engine, ) -> None: From 052ef12d00961d43364512456b97951640ed8931 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:27:26 +0400 Subject: [PATCH 11/25] State exactly what the lookup copies and the read token cover The UK credential document said the catalog publication step reads only UK artifacts, but the same authenticated file system also reads the public US dataset. The weight-matrix lookup comment and the catalog document now say that the certified data release lists no lookup file, so the check uses the Public API's own copies, and that on 2026-10-22 both copies were byte-identical to the files compute downloads. Co-Authored-By: Claude Opus 5.5 --- docs/engineering/uk-private-hugging-face-credential.md | 9 ++++++--- docs/migration/stage-9-v2-metadata.md | 4 +++- .../data/v2/catalog/region_data_verification.py | 10 +++++++--- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/engineering/uk-private-hugging-face-credential.md b/docs/engineering/uk-private-hugging-face-credential.md index 6d02b014e..afbefac48 100644 --- a/docs/engineering/uk-private-hugging-face-credential.md +++ b/docs/engineering/uk-private-hugging-face-credential.md @@ -52,9 +52,12 @@ Cloud Run candidate resolver verifies that the exact deployed revision binds the credential. The v2 catalog publication step of `.github/workflows/seed-v2-database.yml` -also receives the organization secret as `HUGGING_FACE_TOKEN`, step-scoped. It -reads the HDF5 metadata of the certified UK dataset and weight matrices to -check each new catalog version's region filters, and nothing else. +also receives the organization secret as `HUGGING_FACE_TOKEN`, step-scoped. +Before it publishes a new catalog version, the step reads, through one +authenticated Hugging Face file system, the HDF5 metadata of the certified +artifacts that version's region filters use: the UK dataset and weight +matrices in the private UK data repository, and the public US dataset. It +downloads no household data. The simulation-entry Cloud Run service does not receive this credential. It submits requests to Modal and does not download model data. diff --git a/docs/migration/stage-9-v2-metadata.md b/docs/migration/stage-9-v2-metadata.md index 6568ad9e3..91792d6ff 100644 --- a/docs/migration/stage-9-v2-metadata.md +++ b/docs/migration/stage-9-v2-metadata.md @@ -132,7 +132,9 @@ of each artifact the filters use, with ranged requests at the pinned revision: publication stops if a row filter's column is missing from its dataset's household columns, or if a weight matrix's row count differs from its row lookup (`constituencies_2024.csv`, `local_authorities_2021.csv`) or the lookup -lacks a region's code. +lacks a region's code. The certified data release lists no lookup file, so these +are the Public API's own copies of the lookups compute reads; on 2026-10-22 both +were byte-identical to the files compute downloads. Review the fallback and omitted-region summaries on every new PolicyEngine.py release. A changed count may identify an upstream catalog change that needs a diff --git a/policyengine_api/data/v2/catalog/region_data_verification.py b/policyengine_api/data/v2/catalog/region_data_verification.py index e3f1a8489..a2ad6515a 100644 --- a/policyengine_api/data/v2/catalog/region_data_verification.py +++ b/policyengine_api/data/v2/catalog/region_data_verification.py @@ -4,7 +4,8 @@ its dataset. A weight-replacement region needs a row in its weight matrix: the matrix's rows follow the order of the row lookup that compute uses to find a region's row, so the lookup must list exactly as many codes as the matrix has -rows and include the region's code. +rows and include the region's code. The release certifies no lookup file, so +the Public API's own copies of the lookups stand in for it. """ from __future__ import annotations @@ -34,8 +35,11 @@ RegionRecord, ) -# The Public API's copies of the row lookups compute reads to find a region's -# row in each UK weight matrix (the files compute downloads under the same names). +# The Public API's own copies of the row lookups compute reads to find a +# region's row in each UK weight matrix. The certified data release lists no +# lookup file, so the matrix check compares each matrix with these copies. On +# 2026-10-22 both were byte-identical to the files compute downloads under the +# same names (last changed 2025-04-29). _DATA_DIRECTORY = Path(__file__).parents[2] WEIGHT_MATRIX_ROW_LOOKUPS: Mapping[str, Path] = { "parliamentary_constituency_weights": _DATA_DIRECTORY / "constituencies_2024.csv", From a9160d92bc6128a823eba5f3dbe1c83217c5623d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:27:35 +0400 Subject: [PATCH 12/25] Name the weight matrix's dimension count instead of suppressing the lint Co-Authored-By: Claude Opus 5.5 --- policyengine_api/data/v2/catalog/certified_data_inspection.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/policyengine_api/data/v2/catalog/certified_data_inspection.py b/policyengine_api/data/v2/catalog/certified_data_inspection.py index 80fd29ba3..b1ce180b8 100644 --- a/policyengine_api/data/v2/catalog/certified_data_inspection.py +++ b/policyengine_api/data/v2/catalog/certified_data_inspection.py @@ -26,6 +26,8 @@ # real names live only in pickled attributes. VALUE_BLOCK_PREFIX = "values_block_" TABLE_INDEX_COLUMN = "index" +# Each year of a weight matrix is one array of regions by households. +WEIGHT_MATRIX_DIMENSIONS = 2 _Result = TypeVar("_Result") @@ -110,7 +112,7 @@ def weight_matrix_rows_in_hdf5(h5: h5py.File) -> int: for name, entry in h5.items(): if not isinstance(entry, h5py.Dataset): continue - if len(entry.shape) != 2: # noqa: PLR2004 # regions by households + if len(entry.shape) != WEIGHT_MATRIX_DIMENSIONS: msg = f"the weight matrix entry {name} is not two-dimensional" raise CertifiedDataReadError(msg) row_counts.add(entry.shape[0]) From d96b11520f09421237826a5069f39178ecc01ce6 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:21:05 +0400 Subject: [PATCH 13/25] Report the stored regions in the evidence when a version keeps them When a stored catalog version kept its region rows, the publication evidence still counted the staged regions and reported the staged fallbacks and omissions, so the record kept before a release described regions that were never published. The evidence now counts the stored region rows for such a country, leaves its staged fallback and omitted summaries out, and lists it under kept_regions with the stored and staged region counts. Co-Authored-By: Claude Opus 5.5 --- ...n-1f-123-region-reference-files.changed.md | 2 +- docs/migration/stage-9-v2-metadata.md | 9 ++- .../data/v2/catalog/publication.py | 79 ++++++++++++++++--- .../v2/catalog/publication_reconciliation.py | 8 ++ .../data/v2/catalog/publication_types.py | 12 +++ .../test_v2_catalog_publication.py | 16 ++++ tests/unit/v2/test_catalog_publication.py | 14 +++- 7 files changed, 123 insertions(+), 17 deletions(-) diff --git a/changelog.d/v2plan-1f-123-region-reference-files.changed.md b/changelog.d/v2plan-1f-123-region-reference-files.changed.md index 0b762cece..8434bbdb6 100644 --- a/changelog.d/v2plan-1f-123-region-reference-files.changed.md +++ b/changelog.d/v2plan-1f-123-region-reference-files.changed.md @@ -1 +1 @@ -Catalog publication reads regions from the Public API's checked-in reference files, one per country, instead of PolicyEngine.py's region registry. Each region takes its dataset from the certified data release and its filter from a scoping rule: a row filter on state_fips, congressional_district_geoid or the UK region column, or a weight_replacement filter naming the release's constituency or local-authority weight matrix and the region's ONS code. The UK now publishes 1,015 regions, with constituencies and local authorities by ONS code and Northern Ireland as country/northern_ireland; the national regions are "United States" and "United Kingdom". US places are listed in the reference file but left out of the catalog until they can run, and the publication evidence counts them. Before it publishes a catalog version that is not stored yet, the publication command reads the HDF5 metadata of the certified artifacts the filters use, from Hugging Face at their pinned revisions, and stops if a row filter's column is missing from its dataset or a weight matrix has no row for a region's code; the seed workflow's publication step receives the UK data read token as HUGGING_FACE_TOKEN for this. A catalog version that is already stored keeps the regions it was published with, so region file changes take effect in the next catalog version; republishing it logs the difference in region rows instead of failing, and every other table must still match. +Catalog publication reads regions from the Public API's checked-in reference files, one per country, instead of PolicyEngine.py's region registry. Each region takes its dataset from the certified data release and its filter from a scoping rule: a row filter on state_fips, congressional_district_geoid or the UK region column, or a weight_replacement filter naming the release's constituency or local-authority weight matrix and the region's ONS code. The UK now publishes 1,015 regions, with constituencies and local authorities by ONS code and Northern Ireland as country/northern_ireland; the national regions are "United States" and "United Kingdom". US places are listed in the reference file but left out of the catalog until they can run, and the publication evidence counts them. Before it publishes a catalog version that is not stored yet, the publication command reads the HDF5 metadata of the certified artifacts the filters use, from Hugging Face at their pinned revisions, and stops if a row filter's column is missing from its dataset or a weight matrix has no row for a region's code; the seed workflow's publication step receives the UK data read token as HUGGING_FACE_TOKEN for this. A catalog version that is already stored keeps the regions it was published with, so region file changes take effect in the next catalog version; republishing it logs the difference in region rows instead of failing, its evidence counts the stored regions and lists the kept version under kept_regions, and every other table must still match. diff --git a/docs/migration/stage-9-v2-metadata.md b/docs/migration/stage-9-v2-metadata.md index 91792d6ff..0b231ac12 100644 --- a/docs/migration/stage-9-v2-metadata.md +++ b/docs/migration/stage-9-v2-metadata.md @@ -145,7 +145,10 @@ never changes once published. When the command republishes a stored version whose staged regions differ from the stored ones, it keeps the stored regions unchanged, prints a warning with the country, the version and the counts of staged region rows the stored version lacks and of stored rows not staged, and -continues. Every other table must still match exactly. +continues. Its evidence then describes what is stored: the region count is the +stored one, `kept_regions` lists the country, version, stored and staged region +counts, and the fallback and omitted-region summaries leave that country out, +since they describe staged regions that were not published. Every other table must still match exactly. Region reference changes therefore take effect in the next catalog version that is published. @@ -156,7 +159,9 @@ Successful JSON evidence contains: - the canonical PolicyEngine.py version; - the dependency versions derived from its packaged manifest; - catalog entity counts; -- fallback counts and omitted-region counts by country and region type; +- fallback counts and omitted-region counts by country and region type, and + the stored and staged region counts of any stored version whose regions were + kept; - elapsed publication time; and - a success outcome. diff --git a/policyengine_api/data/v2/catalog/publication.py b/policyengine_api/data/v2/catalog/publication.py index 12b6ef059..a8951d6b5 100644 --- a/policyengine_api/data/v2/catalog/publication.py +++ b/policyengine_api/data/v2/catalog/publication.py @@ -13,6 +13,7 @@ assert_canonical_value_uniqueness, assert_country_matches, count_region_differences, + count_stored_regions, publish_new_country, version_exists, ) @@ -128,15 +129,7 @@ def publish_catalog( if checkpoint is not None: checkpoint("after_copy", connection) - # A stored catalog version never changes. Its regions stay as they - # were published even when the reference files differ now; every - # other table must still match exactly. - existing: set[str] = set() - for country in catalog.countries: - if version_exists(connection, country): - assert_country_matches(connection, country, include_regions=False) - _log_kept_regions(connection, country) - existing.add(country.country_id) + existing, kept_region_summaries = _check_stored_versions(connection, catalog) for country in catalog.countries: if country.country_id not in existing: @@ -157,31 +150,85 @@ def publish_catalog( if checkpoint is not None: checkpoint("after_validation", connection) + return _evidence(catalog, kept_region_summaries, started=started) + + +def _check_stored_versions( + connection: Connection, + catalog: NormalizedCatalog, +) -> tuple[set[str], tuple[tuple[str, str, int, int], ...]]: + """Compare each stored catalog version and return its countries and kept regions. + + A stored catalog version never changes. Its regions stay as they were + published even when the reference files differ now; every other table + must still match exactly. + """ + existing: set[str] = set() + kept_region_summaries: list[tuple[str, str, int, int]] = [] + for country in catalog.countries: + if version_exists(connection, country): + assert_country_matches(connection, country, include_regions=False) + kept = _kept_region_summary(connection, country) + if kept is not None: + kept_region_summaries.append(kept) + existing.add(country.country_id) + return existing, tuple(kept_region_summaries) + + +def _evidence( + catalog: NormalizedCatalog, + kept_region_summaries: tuple[tuple[str, str, int, int], ...], + *, + started: float, +) -> PublicationEvidence: + """Describe what is stored after publication. + + Every table of a stored version matches the staged catalog except regions + that were kept: for those countries the evidence counts the stored region + rows and reports none of the staged fallbacks or omissions, which describe + rows that were not published. + """ + kept_countries = {country_id for country_id, *_counts in kept_region_summaries} + entity_counts = catalog.entity_counts() + entity_counts["regions"] += sum( + stored - staged for _country, _version, stored, staged in kept_region_summaries + ) fallback_summaries = tuple( (country.country_id, summary.region_type, summary.count) for country in catalog.countries + if country.country_id not in kept_countries for summary in country.fallback_summaries ) _log_fallback_warning(fallback_summaries) return PublicationEvidence( policyengine_version=catalog.policyengine_version, dependency_versions=catalog.dependency_versions, - entity_counts=catalog.entity_counts(), + entity_counts=entity_counts, fallback_summaries=fallback_summaries, elapsed_seconds=time.monotonic() - started, omitted_region_summaries=tuple( (country.country_id, summary.region_type, summary.count) for country in catalog.countries + if country.country_id not in kept_countries for summary in country.omitted_region_summaries ), + kept_region_summaries=kept_region_summaries, ) -def _log_kept_regions(connection: Connection, country: CountryCatalog) -> None: - """Log, with counts only, how a stored version's regions differ from the staged.""" +def _kept_region_summary( + connection: Connection, + country: CountryCatalog, +) -> tuple[str, str, int, int] | None: + """Return and log how a stored version's kept regions differ from the staged. + + The summary is the country, the version and the stored and staged region + counts; there is none when the regions are the same. The log names counts + only. + """ staged_not_stored, stored_not_staged = count_region_differences(connection, country) if not staged_not_stored and not stored_not_staged: - return + return None # The seed command configures no logging handler, so the details are in the # message itself; the attributes serve structured handlers. LOGGER.warning( @@ -200,6 +247,12 @@ def _log_kept_regions(connection: Connection, country: CountryCatalog) -> None: } }, ) + return ( + country.country_id, + country.model_version.version, + count_stored_regions(connection, country), + len(country.regions), + ) def _log_fallback_warning( diff --git a/policyengine_api/data/v2/catalog/publication_reconciliation.py b/policyengine_api/data/v2/catalog/publication_reconciliation.py index 1debc3d98..8a0c9c0aa 100644 --- a/policyengine_api/data/v2/catalog/publication_reconciliation.py +++ b/policyengine_api/data/v2/catalog/publication_reconciliation.py @@ -317,6 +317,14 @@ def assert_country_matches( raise CatalogPublicationError(msg) +def count_stored_regions(connection: Connection, country: CountryCatalog) -> int: + """Count the region rows stored for one country's catalog version.""" + _staged, actual = _region_comparison_pair(country) + return connection.execute( + sa.select(sa.func.count()).select_from(actual.subquery()) + ).scalar_one() + + def count_region_differences( connection: Connection, country: CountryCatalog, diff --git a/policyengine_api/data/v2/catalog/publication_types.py b/policyengine_api/data/v2/catalog/publication_types.py index 51865f7c7..515232560 100644 --- a/policyengine_api/data/v2/catalog/publication_types.py +++ b/policyengine_api/data/v2/catalog/publication_types.py @@ -19,6 +19,9 @@ class PublicationEvidence: fallback_summaries: tuple[tuple[str, str, int], ...] elapsed_seconds: float omitted_region_summaries: tuple[tuple[str, str, int], ...] = () + # Stored catalog versions whose region rows were kept although the staged + # regions differ: country, version, stored and staged region counts. + kept_region_summaries: tuple[tuple[str, str, int, int], ...] = () def as_dict(self) -> dict[str, object]: """Return the evidence as the JSON object printed after a publication.""" @@ -43,5 +46,14 @@ def as_dict(self) -> dict[str, object]: } for country_id, region_type, count in self.omitted_region_summaries ], + "kept_regions": [ + { + "country_id": country_id, + "policyengine_version": version, + "stored_regions": stored, + "staged_regions": staged, + } + for country_id, version, stored, staged in self.kept_region_summaries + ], "elapsed_seconds": round(self.elapsed_seconds, 3), } diff --git a/tests/integration/test_v2_catalog_publication.py b/tests/integration/test_v2_catalog_publication.py index e89323431..70a275895 100644 --- a/tests/integration/test_v2_catalog_publication.py +++ b/tests/integration/test_v2_catalog_publication.py @@ -337,6 +337,22 @@ def test_stored_version_keeps_its_published_regions_and_logs_the_difference( assert _region_rows(publication_engine) == before_regions assert _identifiers(publication_engine) == before_ids assert _counts(publication_engine) == before_counts + # The evidence describes the stored rows, not the staged regions. + assert evidence.entity_counts == { + "models": before_counts["tax_benefit_models"], + "model_versions": before_counts["tax_benefit_model_versions"], + "variables": before_counts["variables"], + "parameter_nodes": before_counts["parameter_nodes"], + "parameters": before_counts["parameters"], + "parameter_values": before_counts["parameter_values"], + "datasets": before_counts["datasets"], + "regions": before_counts["regions"], + } + assert evidence.kept_region_summaries == ( + ("us", catalog.policyengine_version, 3, 2), + ) + assert evidence.omitted_region_summaries == () + assert evidence.fallback_summaries == () notices = [ (message, keywords["extra"]) for message, keywords in warnings diff --git a/tests/unit/v2/test_catalog_publication.py b/tests/unit/v2/test_catalog_publication.py index 911948618..2341003dc 100644 --- a/tests/unit/v2/test_catalog_publication.py +++ b/tests/unit/v2/test_catalog_publication.py @@ -263,6 +263,7 @@ def test_completion_evidence_contains_only_reviewed_non_secret_fields() -> None: fallback_summaries=(("us", "state", 3),), elapsed_seconds=2.3456, omitted_region_summaries=(("us", "place", 333),), + kept_region_summaries=(("uk", "6.7.5", 5, 1_015),), ).as_dict() assert evidence == { @@ -276,6 +277,14 @@ def test_completion_evidence_contains_only_reviewed_non_secret_fields() -> None: "omitted_region_summaries": [ {"country_id": "us", "region_type": "place", "count": 333} ], + "kept_regions": [ + { + "country_id": "uk", + "policyengine_version": "6.7.5", + "stored_regions": 5, + "staged_regions": 1_015, + } + ], "elapsed_seconds": 2.346, } assert ( @@ -320,10 +329,13 @@ def _kept_region_records( "count_region_differences", lambda _connection, _country: differences, ) + monkeypatch.setattr(publication, "count_stored_regions", lambda *_args: 4) # An earlier test's Alembic logging setup may have disabled existing loggers. monkeypatch.setattr(publication.LOGGER, "disabled", False) + us = normalized_catalog().country("us") with caplog.at_level(logging.WARNING, logger=publication.LOGGER.name): - publication._log_kept_regions(object(), normalized_catalog().country("us")) + summary = publication._kept_region_summary(object(), us) + assert summary == (None if differences == (0, 0) else ("us", "5.0.4", 4, 3)) return [ record for record in caplog.records if record.name == publication.LOGGER.name ] From 2a01b4c0c248de74d6415f7e80426ae313cb9b5a Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:21:29 +0400 Subject: [PATCH 14/25] Name the missing ancestor when a region's parent chain breaks higher up When a region's parent existed but a region further up its chain did not, the reference file check named the region's direct parent as unknown. It now names the missing code and the region that names it as parent. Co-Authored-By: Claude Opus 5.5 --- .../data/v2/catalog/region_references.py | 18 +++++++++++++----- tests/unit/v2/test_region_references.py | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/policyengine_api/data/v2/catalog/region_references.py b/policyengine_api/data/v2/catalog/region_references.py index ce37a5908..966d24612 100644 --- a/policyengine_api/data/v2/catalog/region_references.py +++ b/policyengine_api/data/v2/catalog/region_references.py @@ -187,19 +187,27 @@ def _check_ancestry( parents = {reference.code: reference.parent_code for reference in references} for reference in references: visited = {reference.code} + child = reference.code ancestor = reference.parent_code while ancestor is not None and ancestor != country_id: if ancestor not in parents: - msg = ( - f"region {reference.code!r} names an unknown parent " - f"{reference.parent_code!r}" + raise CatalogExtractionError( + _unknown_ancestor_message(reference.code, child, ancestor) ) - raise CatalogExtractionError(msg) if ancestor in visited: msg = f"region {reference.code!r} has a parent cycle" raise CatalogExtractionError(msg) visited.add(ancestor) - ancestor = parents[ancestor] + child, ancestor = ancestor, parents[ancestor] + + +def _unknown_ancestor_message(code: str, child: str, ancestor: str) -> str: + """Name the missing region and, if it is further up, the region it parents.""" + if child == code: + return f"region {code!r} names an unknown parent {ancestor!r}" + return ( + f"region {code!r} descends from {child!r}, whose parent {ancestor!r} is unknown" + ) def parse_region_references( diff --git a/tests/unit/v2/test_region_references.py b/tests/unit/v2/test_region_references.py index dd97be4b4..c8c17d9aa 100644 --- a/tests/unit/v2/test_region_references.py +++ b/tests/unit/v2/test_region_references.py @@ -298,6 +298,25 @@ def test_rejects_an_inconsistent_reference_file(change, message: str) -> None: parse_region_references("us", rows) +def test_names_the_missing_ancestor_further_up_a_regions_chain() -> None: + rows = [ + _row("us", "national"), + _row( + "congressional_district/CA-01", + "congressional_district", + parent_code="state/ca", + ), + _row("state/ca", "state", parent_code="state/zz"), + ] + + with pytest.raises( + CatalogExtractionError, + match=r"^region 'congressional_district/CA-01' descends from 'state/ca', " + r"whose parent 'state/zz' is unknown$", + ): + parse_region_references("us", rows) + + def test_rejects_a_file_whose_columns_differ(tmp_path: Path) -> None: (tmp_path / "us.csv").write_text( "code,label,region_type\nus,United States,national\n", encoding="utf-8" From 765c69ba4ea912d0c84df3d5a57b39473884709f Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:22:06 +0400 Subject: [PATCH 15/25] Require the Hugging Face token before checking the UK's private data Without HUGGING_FACE_TOKEN, the UK artifact reads failed with "could not read the certified artifact enhanced_frs_2024_25: FileNotFoundError", which gave no sign of the cause. The installed check now stops before any read when a country whose certified data repository is private (the UK) needs checking and the token is unset, with a message naming the variable, never its value. The public US data is still read without it. Co-Authored-By: Claude Opus 5.5 --- docs/migration/stage-9-v2-metadata.md | 2 ++ .../data/v2/catalog/initialization.py | 17 +++++++++-- tests/unit/v2/test_catalog_initialization.py | 29 +++++++++++++++++-- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/docs/migration/stage-9-v2-metadata.md b/docs/migration/stage-9-v2-metadata.md index 0b231ac12..adb8e59b9 100644 --- a/docs/migration/stage-9-v2-metadata.md +++ b/docs/migration/stage-9-v2-metadata.md @@ -70,6 +70,8 @@ must execute in this order: `uv run python scripts/publish_v2_metadata_catalog.py`. Before publishing a catalog version that is not stored yet, the command reads the certified data artifacts its regions' filters use and stops if one cannot select a region. + It stops before reading anything if a UK version needs checking and + `HUGGING_FACE_TOKEN` is unset, because the UK data repository is private. 5. Require the command to finish successfully and retain its non-secret JSON evidence before creating the candidate revision. 6. Deploy the Cloud Run candidate with the target identity and diff --git a/policyengine_api/data/v2/catalog/initialization.py b/policyengine_api/data/v2/catalog/initialization.py index e62098bf9..ce5c00585 100644 --- a/policyengine_api/data/v2/catalog/initialization.py +++ b/policyengine_api/data/v2/catalog/initialization.py @@ -24,6 +24,7 @@ unpublished_countries, ) from policyengine_api.data.v2.catalog.region_data_verification import ( + RegionDataVerificationError, verify_region_data, ) from policyengine_api.data.v2.settings import ( @@ -41,6 +42,8 @@ # The environment variable holding the Hugging Face read token; PolicyEngine # Core's data downloads read the same variable. HUGGING_FACE_TOKEN_VARIABLE = "HUGGING_FACE_TOKEN" # noqa: S105 # an environment variable name, not a secret +# Countries whose certified data repository needs the read token. +PRIVATE_DATA_COUNTRIES = frozenset({"uk"}) def build_data_write_engine(settings: V2DatabaseSettings) -> Engine: @@ -55,12 +58,22 @@ def verify_installed_region_data( ) -> None: """Check the named countries' regions against their certified artifacts. - The artifacts are read from Hugging Face with ``HUGGING_FACE_TOKEN``, - which the UK's private data repository needs. + The artifacts are read from Hugging Face with ``HUGGING_FACE_TOKEN``. + Countries whose certified data repository is private need it, so their + check fails before any read when it is unset. """ token = (os.environ if environ is None else environ).get( HUGGING_FACE_TOKEN_VARIABLE ) + needing_token = [ + country for country in countries if country in PRIVATE_DATA_COUNTRIES + ] + if needing_token and not token: + msg = ( + f"{HUGGING_FACE_TOKEN_VARIABLE} must be set to read the certified " + f"{', '.join(needing_token)} data, whose repository is private" + ) + raise RegionDataVerificationError(msg) verify_region_data( catalog, HuggingFaceDataInspector(token=token or None), diff --git a/tests/unit/v2/test_catalog_initialization.py b/tests/unit/v2/test_catalog_initialization.py index c998c3006..1b57b0166 100644 --- a/tests/unit/v2/test_catalog_initialization.py +++ b/tests/unit/v2/test_catalog_initialization.py @@ -139,8 +139,33 @@ def record(catalog, inspector, *, countries): "call": (POLICYENGINE_VERSION, RecordingInspector, ("uk",)), } - initialization.verify_installed_region_data(normalized_catalog(), ("uk",), {}) - assert observed["token"] is None + initialization.verify_installed_region_data(normalized_catalog(), ("us",), {}) + assert observed == { + "token": None, + "call": (POLICYENGINE_VERSION, RecordingInspector, ("us",)), + } + + +@pytest.mark.parametrize("environ", [{}, {"HUGGING_FACE_TOKEN": ""}]) +def test_uk_verification_requires_the_hugging_face_token_before_reading( + monkeypatch: pytest.MonkeyPatch, + environ: dict[str, str], +) -> None: + def refuse(*_args, **_kwargs): + msg = "no artifact may be read without the token" + raise AssertionError(msg) + + monkeypatch.setattr(initialization, "HuggingFaceDataInspector", refuse) + monkeypatch.setattr(initialization, "verify_region_data", refuse) + + with pytest.raises( + RegionDataVerificationError, + match=r"^HUGGING_FACE_TOKEN must be set to read the certified uk data, " + r"whose repository is private$", + ): + initialization.verify_installed_region_data( + normalized_catalog(), ("us", "uk"), environ + ) def test_default_verification_checks_only_countries_not_yet_published( From 1a5beffee3d2fe75dc678d088a7a4f98004ee06e Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:25:05 +0400 Subject: [PATCH 16/25] Test the remaining failure paths of region references, scoping and reading Adds tests for errors that had none: an invalid region code and a national region not named after its country; a release manifest without a dataset map or a region-dataset map, without the reviewed default dataset, with a region dataset without a path, and with an artifact without a path, revision or repository; a household entry that is not an HDF5 group. Also covers a weight matrix with a non-array entry, the default Hugging Face file system and its token, a verification error the inspector raises, and the default row lookups. Co-Authored-By: Claude Opus 5.5 --- .../v2/test_catalog_extraction_failures.py | 72 +++++++++++++++++++ .../unit/v2/test_certified_data_inspection.py | 21 ++++++ .../unit/v2/test_region_data_verification.py | 15 ++++ tests/unit/v2/test_region_references.py | 16 +++++ 4 files changed, 124 insertions(+) diff --git a/tests/unit/v2/test_catalog_extraction_failures.py b/tests/unit/v2/test_catalog_extraction_failures.py index 20dce3fe6..eeea7625d 100644 --- a/tests/unit/v2/test_catalog_extraction_failures.py +++ b/tests/unit/v2/test_catalog_extraction_failures.py @@ -157,9 +157,81 @@ def _remove_a_country_from_the_references( del references["uk"] +def _remove_the_dataset_map( + models: dict[str, SimpleNamespace], _references: References +) -> None: + models["us"].release_manifest.datasets = None + + +def _remove_the_reviewed_default_dataset( + models: dict[str, SimpleNamespace], _references: References +) -> None: + del models["us"].release_manifest.datasets["populace_us_2024"] + + +def _remove_the_region_dataset_map( + models: dict[str, SimpleNamespace], _references: References +) -> None: + models["us"].release_manifest.region_datasets = None + + +def _remove_a_region_dataset_path( + models: dict[str, SimpleNamespace], _references: References +) -> None: + models["us"].release_manifest.region_datasets["state"].path_template = None + + +def _remove_an_artifact_path( + models: dict[str, SimpleNamespace], _references: References +) -> None: + models["us"].release_manifest.datasets["populace_us_2024"].path = None + + +def _remove_every_artifact_revision( + models: dict[str, SimpleNamespace], _references: References +) -> None: + manifest = models["us"].release_manifest + manifest.datasets["populace_us_2024"].revision = None + manifest.data_package.release_manifest_revision = None + manifest.data_package.version = None + + +def _remove_every_artifact_repository( + models: dict[str, SimpleNamespace], _references: References +) -> None: + models["us"].release_manifest.data_package.repo_id = None + + @pytest.mark.parametrize( ("break_sources", "message"), [ + (_remove_the_dataset_map, "^us release manifest has no dataset map$"), + ( + _remove_the_reviewed_default_dataset, + "^us release does not certify populace_us_2024$", + ), + ( + _remove_the_region_dataset_map, + "^us release manifest has no region dataset map$", + ), + ( + _remove_a_region_dataset_path, + "^us state region dataset must be a non-empty string$", + ), + ( + _remove_an_artifact_path, + "^us release artifact populace_us_2024 path must be a non-empty string$", + ), + ( + _remove_every_artifact_revision, + "^us release artifact populace_us_2024 revision must be a non-empty " + "string$", + ), + ( + _remove_every_artifact_repository, + "^us release artifact populace_us_2024 repository must be a non-empty " + "string$", + ), ( _point_national_region_at_another_dataset, "^uk national region does not use the reviewed dataset$", diff --git a/tests/unit/v2/test_certified_data_inspection.py b/tests/unit/v2/test_certified_data_inspection.py index d159fb820..be0be2c7e 100644 --- a/tests/unit/v2/test_certified_data_inspection.py +++ b/tests/unit/v2/test_certified_data_inspection.py @@ -11,6 +11,7 @@ import numpy as np import pandas as pd import pytest +from huggingface_hub import HfFileSystem from packaging.requirements import Requirement from packaging.utils import canonicalize_name @@ -127,15 +128,35 @@ def test_refuses_a_household_group_that_holds_no_frame(tmp_path: Path) -> None: _columns(path) +def test_refuses_a_household_entry_that_is_not_a_group(tmp_path: Path) -> None: + path = tmp_path / "flat.h5" + with h5py.File(path, "w") as h5: + h5.create_dataset("household", data=np.array([1, 2])) + + with pytest.raises( + CertifiedDataReadError, match=r"^household entry is not an HDF5 group$" + ): + _columns(path) + + def test_counts_the_region_rows_of_a_weight_matrix(tmp_path: Path) -> None: path = tmp_path / "matrix.h5" with h5py.File(path, "w") as h5: h5.create_dataset("2024", data=np.ones((3, 5))) h5.create_dataset("2025", data=np.ones((3, 5))) + h5.create_group("metadata").create_dataset("note", data=np.ones(1)) assert _rows(path) == 3 +def test_default_file_system_is_hugging_face_with_the_token() -> None: + inspector = HuggingFaceDataInspector(token="read-token") + + file_system = inspector._file_system + assert isinstance(file_system, HfFileSystem) + assert file_system.token == "read-token" + + @pytest.mark.parametrize( ("shapes", "message"), [ diff --git a/tests/unit/v2/test_region_data_verification.py b/tests/unit/v2/test_region_data_verification.py index 0735d39b0..d2d1cfa2d 100644 --- a/tests/unit/v2/test_region_data_verification.py +++ b/tests/unit/v2/test_region_data_verification.py @@ -226,3 +226,18 @@ def test_reports_an_artifact_structure_error_with_its_own_message() -> None: r"checked: household group holds no data frame$", ): _verify(inspector, countries=("us",)) + + +def test_passes_on_a_verification_error_the_inspector_raises() -> None: + inspector = FakeInspector(failure=RegionDataVerificationError("already explained")) + + with pytest.raises(RegionDataVerificationError, match=r"^already explained$"): + _verify(inspector, countries=("us",)) + + +def test_reads_the_checked_in_row_lookups_by_default() -> None: + inspector = FakeInspector(matrix_rows={"parliamentary_constituency_weights": 650}) + + verify_region_data(normalized_catalog(), inspector, countries=("uk",)) + + assert ("rows", "parliamentary_constituency_weights") in inspector.reads diff --git a/tests/unit/v2/test_region_references.py b/tests/unit/v2/test_region_references.py index c8c17d9aa..e2493a2d5 100644 --- a/tests/unit/v2/test_region_references.py +++ b/tests/unit/v2/test_region_references.py @@ -288,6 +288,22 @@ def test_parses_a_valid_minimal_file() -> None: lambda rows: rows[1].update(label=""), r"^region 'state/ca' label must be a non-empty string$", ), + ( + lambda rows: rows[1].update(code=""), + r"^us region reference file has an invalid code ''$", + ), + ( + lambda rows: rows[1].update(code="state/ca "), + r"^us region reference file has an invalid code 'state/ca '$", + ), + ( + lambda rows: rows[1].update(code="state/" + "c" * 250), + r"^us region reference file has an invalid code 'state/c+'$", + ), + ( + lambda rows: rows.append(_row("usa", "national")), + r"^national region 'usa' must be named 'us'$", + ), ], ) def test_rejects_an_inconsistent_reference_file(change, message: str) -> None: From dc263673217f7250a419ae0b3aa55dbc4bbe75b7 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:25:21 +0400 Subject: [PATCH 17/25] Describe the lookup check without dating private storage The lookup comment and the catalog document now say only that on 2026-10-22 both copies were checked to be identical in content to the lookups the calculation workers download. Co-Authored-By: Claude Opus 5.5 --- docs/migration/stage-9-v2-metadata.md | 3 ++- policyengine_api/data/v2/catalog/region_data_verification.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/migration/stage-9-v2-metadata.md b/docs/migration/stage-9-v2-metadata.md index adb8e59b9..2a2295a48 100644 --- a/docs/migration/stage-9-v2-metadata.md +++ b/docs/migration/stage-9-v2-metadata.md @@ -136,7 +136,8 @@ household columns, or if a weight matrix's row count differs from its row lookup (`constituencies_2024.csv`, `local_authorities_2021.csv`) or the lookup lacks a region's code. The certified data release lists no lookup file, so these are the Public API's own copies of the lookups compute reads; on 2026-10-22 both -were byte-identical to the files compute downloads. +copies were checked to be identical in content to the lookups the calculation +workers download. Review the fallback and omitted-region summaries on every new PolicyEngine.py release. A changed count may identify an upstream catalog change that needs a diff --git a/policyengine_api/data/v2/catalog/region_data_verification.py b/policyengine_api/data/v2/catalog/region_data_verification.py index a2ad6515a..38493a306 100644 --- a/policyengine_api/data/v2/catalog/region_data_verification.py +++ b/policyengine_api/data/v2/catalog/region_data_verification.py @@ -38,8 +38,8 @@ # The Public API's own copies of the row lookups compute reads to find a # region's row in each UK weight matrix. The certified data release lists no # lookup file, so the matrix check compares each matrix with these copies. On -# 2026-10-22 both were byte-identical to the files compute downloads under the -# same names (last changed 2025-04-29). +# 2026-10-22 both copies were checked to be identical in content to the lookups +# the calculation workers download. _DATA_DIRECTORY = Path(__file__).parents[2] WEIGHT_MATRIX_ROW_LOOKUPS: Mapping[str, Path] = { "parliamentary_constituency_weights": _DATA_DIRECTORY / "constituencies_2024.csv", From d466c99b868629412460047bd1dae0dd7a9a4146 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:25:45 +0400 Subject: [PATCH 18/25] Describe exactly what the artifact reads download The data reader's docstring and the UK credential document said only HDF5 metadata and column names are downloaded. Reads go through a cache of 1 MB blocks, so they download the blocks holding that metadata, which can include neighbouring bytes; the household arrays are never read. Co-Authored-By: Claude Opus 5.5 --- docs/engineering/uk-private-hugging-face-credential.md | 2 +- .../data/v2/catalog/certified_data_inspection.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/engineering/uk-private-hugging-face-credential.md b/docs/engineering/uk-private-hugging-face-credential.md index afbefac48..57d5621dd 100644 --- a/docs/engineering/uk-private-hugging-face-credential.md +++ b/docs/engineering/uk-private-hugging-face-credential.md @@ -57,7 +57,7 @@ Before it publishes a new catalog version, the step reads, through one authenticated Hugging Face file system, the HDF5 metadata of the certified artifacts that version's region filters use: the UK dataset and weight matrices in the private UK data repository, and the public US dataset. It -downloads no household data. +downloads only the 1 MB blocks that hold that metadata, not whole files. The simulation-entry Cloud Run service does not receive this credential. It submits requests to Modal and does not download model data. diff --git a/policyengine_api/data/v2/catalog/certified_data_inspection.py b/policyengine_api/data/v2/catalog/certified_data_inspection.py index b1ce180b8..e18d42c58 100644 --- a/policyengine_api/data/v2/catalog/certified_data_inspection.py +++ b/policyengine_api/data/v2/catalog/certified_data_inspection.py @@ -1,8 +1,9 @@ """Read household column names and weight-matrix sizes from certified HDF5 files. The artifacts are read from Hugging Face at their pinned revisions through -ranged requests, so only the HDF5 metadata and the small column-name arrays -are downloaded, never the households themselves. +ranged requests. Only the 1 MB blocks holding the HDF5 metadata and the +column-name arrays are downloaded, a small part of each file; the household +arrays themselves are never read. """ from __future__ import annotations From b120d44de1e59f9dd46a795a293bff381127bb13 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:02:54 +0400 Subject: [PATCH 19/25] Ignore the coverage.py data and report files `make test` runs coverage, which writes .coverage (an SQLite database with local paths) and coverage.xml at the repository root; neither was ignored, so a broad `git add` could commit them. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index d69167e9d..18fe946f3 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,8 @@ gha-creds-*.json ## vscode settings /.vscode *.db.init.lock + +# coverage.py data and reports written by `make test` +.coverage +.coverage.* +coverage.xml From 00c558cd9cbbf3a7316f754031c706e83868bb3f Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:03:08 +0400 Subject: [PATCH 20/25] Rename the read-token variable constant so it needs no lint suppression The constant holding the environment variable's name is now HUGGING_FACE_CREDENTIAL_VARIABLE, which the hardcoded-password check does not flag, so its suppression is removed. The environment variable stays HUGGING_FACE_TOKEN. Co-Authored-By: Claude Opus 5.5 --- policyengine_api/data/v2/catalog/initialization.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/policyengine_api/data/v2/catalog/initialization.py b/policyengine_api/data/v2/catalog/initialization.py index ce5c00585..2bde74d25 100644 --- a/policyengine_api/data/v2/catalog/initialization.py +++ b/policyengine_api/data/v2/catalog/initialization.py @@ -41,7 +41,7 @@ # The environment variable holding the Hugging Face read token; PolicyEngine # Core's data downloads read the same variable. -HUGGING_FACE_TOKEN_VARIABLE = "HUGGING_FACE_TOKEN" # noqa: S105 # an environment variable name, not a secret +HUGGING_FACE_CREDENTIAL_VARIABLE = "HUGGING_FACE_TOKEN" # Countries whose certified data repository needs the read token. PRIVATE_DATA_COUNTRIES = frozenset({"uk"}) @@ -63,14 +63,14 @@ def verify_installed_region_data( check fails before any read when it is unset. """ token = (os.environ if environ is None else environ).get( - HUGGING_FACE_TOKEN_VARIABLE + HUGGING_FACE_CREDENTIAL_VARIABLE ) needing_token = [ country for country in countries if country in PRIVATE_DATA_COUNTRIES ] if needing_token and not token: msg = ( - f"{HUGGING_FACE_TOKEN_VARIABLE} must be set to read the certified " + f"{HUGGING_FACE_CREDENTIAL_VARIABLE} must be set to read the certified " f"{', '.join(needing_token)} data, whose repository is private" ) raise RegionDataVerificationError(msg) From 637b6284e30b3ffed95dad32c32380e2d4c5f054 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:05:55 +0400 Subject: [PATCH 21/25] Give region data and token failures error classes that match when they occur RegionDataVerificationError inherited from CatalogExtractionError, which is raised before any database access, although verification runs after the stored catalog versions are read and reads certified data over the network. It is now its own error, documented as such, and the command still prints its message. A missing HUGGING_FACE_TOKEN is now reported as the existing V2ConfigurationError, the error for absent configuration. Co-Authored-By: Claude Opus 5.5 --- .../data/v2/catalog/initialization.py | 5 ++-- .../v2/catalog/region_data_verification.py | 14 ++++++---- tests/unit/v2/test_catalog_initialization.py | 28 ++++++++++++++++++- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/policyengine_api/data/v2/catalog/initialization.py b/policyengine_api/data/v2/catalog/initialization.py index 2bde74d25..978e40e47 100644 --- a/policyengine_api/data/v2/catalog/initialization.py +++ b/policyengine_api/data/v2/catalog/initialization.py @@ -60,7 +60,7 @@ def verify_installed_region_data( The artifacts are read from Hugging Face with ``HUGGING_FACE_TOKEN``. Countries whose certified data repository is private need it, so their - check fails before any read when it is unset. + check fails with a configuration error before any read when it is unset. """ token = (os.environ if environ is None else environ).get( HUGGING_FACE_CREDENTIAL_VARIABLE @@ -73,7 +73,7 @@ def verify_installed_region_data( f"{HUGGING_FACE_CREDENTIAL_VARIABLE} must be set to read the certified " f"{', '.join(needing_token)} data, whose repository is private" ) - raise RegionDataVerificationError(msg) + raise V2ConfigurationError(msg) verify_region_data( catalog, HuggingFaceDataInspector(token=token or None), @@ -123,6 +123,7 @@ def _error_payload(error: Exception) -> dict[str, object]: safe_errors = ( V2ConfigurationError, CatalogExtractionError, + RegionDataVerificationError, CatalogPublicationError, ) message = ( diff --git a/policyengine_api/data/v2/catalog/region_data_verification.py b/policyengine_api/data/v2/catalog/region_data_verification.py index 38493a306..de2910a69 100644 --- a/policyengine_api/data/v2/catalog/region_data_verification.py +++ b/policyengine_api/data/v2/catalog/region_data_verification.py @@ -16,10 +16,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Protocol, TypeVar -from policyengine_api.data.v2.catalog.errors import ( - CatalogExtractionError, - CertifiedDataReadError, -) +from policyengine_api.data.v2.catalog.errors import CertifiedDataReadError from policyengine_api.data.v2.catalog.region_scoping import ( ROW_FILTER, WEIGHT_REPLACEMENT, @@ -51,8 +48,13 @@ _Result = TypeVar("_Result") -class RegionDataVerificationError(CatalogExtractionError): - """Raised when the certified data cannot select a region's households.""" +class RegionDataVerificationError(RuntimeError): + """Raised when certified data cannot select a region's households. + + Also raised when an artifact cannot be read for that check. Verification + runs after the stored catalog versions are read and before anything is + published; its messages name only artifacts, columns, codes and counts. + """ class CertifiedDataInspector(Protocol): diff --git a/tests/unit/v2/test_catalog_initialization.py b/tests/unit/v2/test_catalog_initialization.py index 1b57b0166..1870d431d 100644 --- a/tests/unit/v2/test_catalog_initialization.py +++ b/tests/unit/v2/test_catalog_initialization.py @@ -8,6 +8,7 @@ import pytest from policyengine_api.data.v2.catalog import initialization +from policyengine_api.data.v2.catalog.errors import CatalogExtractionError from policyengine_api.data.v2.catalog.publication import PublicationEvidence from policyengine_api.data.v2.catalog.region_data_verification import ( RegionDataVerificationError, @@ -16,6 +17,7 @@ V2_DATA_WRITE_DATABASE_URL, V2_SUPABASE_ENVIRONMENT, V2_SUPABASE_PROJECT_REF, + V2ConfigurationError, ) from tests.fixtures.v2_catalog import ( DEPENDENCY_VERSIONS, @@ -159,7 +161,7 @@ def refuse(*_args, **_kwargs): monkeypatch.setattr(initialization, "verify_region_data", refuse) with pytest.raises( - RegionDataVerificationError, + V2ConfigurationError, match=r"^HUGGING_FACE_TOKEN must be set to read the certified uk data, " r"whose repository is private$", ): @@ -270,3 +272,27 @@ def test_initializer_is_not_imported_by_application_startup_modules() -> None: module.startswith("policyengine_api.data.v2.catalog.initialization") for module in imported ) + + +def test_region_data_errors_are_not_reported_as_extraction_errors() -> None: + # Extraction errors are raised before any database access; verification + # runs after reading the stored versions and reads certified data over the + # network. + assert not issubclass(RegionDataVerificationError, CatalogExtractionError) + + +def test_main_reports_a_missing_hugging_face_token_with_its_message( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + def verify_without_token(): + initialization.verify_installed_region_data(normalized_catalog(), ("uk",), {}) + + monkeypatch.setattr(initialization, "initialize_catalog", verify_without_token) + + assert initialization.main() == 1 + failure = capsys.readouterr() + assert '"type": "V2ConfigurationError"' in failure.err + assert "HUGGING_FACE_TOKEN must be set to read the certified uk data" in ( + failure.err + ) From 50c7dfce76a0060ddd2e8eb42ed9d5aa7a9f68cd Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:07:30 +0400 Subject: [PATCH 22/25] Type the release manifest scoping reads with a small Protocol Region scoping typed the PolicyEngine.py release manifest as object and read its dataset map, region-dataset map and data package with getattr. A runtime-checkable Protocol now states those three attributes; a manifest without them fails with a message naming them, and their values are still checked where they are read. Co-Authored-By: Claude Opus 5.5 --- .../data/v2/catalog/region_scoping.py | 42 ++++++++++++++++--- .../v2/test_catalog_extraction_failures.py | 10 +++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/policyengine_api/data/v2/catalog/region_scoping.py b/policyengine_api/data/v2/catalog/region_scoping.py index 541973bc6..2b43987cf 100644 --- a/policyengine_api/data/v2/catalog/region_scoping.py +++ b/policyengine_api/data/v2/catalog/region_scoping.py @@ -14,7 +14,7 @@ from collections import Counter from collections.abc import Mapping from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Protocol, runtime_checkable from policyengine_api.data.v2.catalog.errors import CatalogExtractionError from policyengine_api.data.v2.catalog.records import CertifiedArtifact @@ -92,22 +92,46 @@ def _manifest_text(value: object, *, description: str) -> str: return value +@runtime_checkable +class ReleaseManifest(Protocol): + """What scoping reads from a PolicyEngine.py country release manifest. + + The manifest is an untyped public object, so each value is checked where + it is read. + """ + + @property + def datasets(self) -> object: + """The certified artifacts by name.""" + ... + + @property + def region_datasets(self) -> object: + """The dataset path of each region type.""" + ... + + @property + def data_package(self) -> object: + """The repository and revision artifacts default to.""" + ... + + def certified_artifacts( country_id: str, - release_manifest: object, + release_manifest: ReleaseManifest, ) -> dict[str, CertifiedArtifact]: """Return the release manifest's artifacts, with the data package's defaults. An artifact without its own repository, repository type or revision is in the data package's repository at the release revision. """ - data_package = getattr(release_manifest, "data_package", None) + data_package = release_manifest.data_package default_repo_id = getattr(data_package, "repo_id", None) default_repo_type = getattr(data_package, "repo_type", None) or "model" default_revision = getattr( data_package, "release_manifest_revision", None ) or getattr(data_package, "version", None) - datasets = getattr(release_manifest, "datasets", None) + datasets = release_manifest.datasets if not isinstance(datasets, Mapping): msg = f"{country_id} release manifest has no dataset map" raise CatalogExtractionError(msg) @@ -138,11 +162,11 @@ def certified_artifacts( def _role_datasets( country_id: str, - release_manifest: object, + release_manifest: ReleaseManifest, artifacts: Mapping[str, CertifiedArtifact], ) -> dict[str, str]: """Map each dataset role the release names to its certified dataset's name.""" - region_datasets = getattr(release_manifest, "region_datasets", None) + region_datasets = release_manifest.region_datasets if not isinstance(region_datasets, Mapping): msg = f"{country_id} release manifest has no region dataset map" raise CatalogExtractionError(msg) @@ -243,6 +267,12 @@ def scope_regions( type has no scoping rule is left out, and a runnable region may not sit under a region that is left out. """ + if not isinstance(release_manifest, ReleaseManifest): + msg = ( + f"{country_id} release manifest lacks datasets, region_datasets or " + "data_package" + ) + raise CatalogExtractionError(msg) artifacts = certified_artifacts(country_id, release_manifest) if reviewed_default not in artifacts: msg = f"{country_id} release does not certify {reviewed_default}" diff --git a/tests/unit/v2/test_catalog_extraction_failures.py b/tests/unit/v2/test_catalog_extraction_failures.py index eeea7625d..74c9f4459 100644 --- a/tests/unit/v2/test_catalog_extraction_failures.py +++ b/tests/unit/v2/test_catalog_extraction_failures.py @@ -196,6 +196,12 @@ def _remove_every_artifact_revision( manifest.data_package.version = None +def _drop_the_region_dataset_attribute( + models: dict[str, SimpleNamespace], _references: References +) -> None: + del models["us"].release_manifest.region_datasets + + def _remove_every_artifact_repository( models: dict[str, SimpleNamespace], _references: References ) -> None: @@ -206,6 +212,10 @@ def _remove_every_artifact_repository( ("break_sources", "message"), [ (_remove_the_dataset_map, "^us release manifest has no dataset map$"), + ( + _drop_the_region_dataset_attribute, + "^us release manifest lacks datasets, region_datasets or data_package$", + ), ( _remove_the_reviewed_default_dataset, "^us release does not certify populace_us_2024$", From 59f9ccc56038eab42b5d0aaf3cf434c356b96547 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:02:34 +0400 Subject: [PATCH 23/25] Row-filter UK constituencies and local authorities on the dataset the release routes them to The Public API now pins PolicyEngine.py 6.10.0. Its UK release certifies no constituency or local-authority weight matrix; it routes both region types to a local-area dataset (microcosm_uk_2024_25_dense) whose households carry constituency_code_oa and la_code_oa. UK constituencies and local authorities are now scoped like US states and districts: a row filter on that column, with the region's ONS code, on the dataset the release routes their type to. Because the national file has no area codes, they never fall back to it; a release that routes no dataset to their type stops publication, naming the region. The weight_replacement scoping, the weight-matrix artifact checks and the row-count reading of weight matrices are removed. No release the Public API can publish uses them: the pinned 6.10.0 release has none, stored 6.7.5 versions keep their regions without being scoped again, and the Public API's local-authority list now has 361 rows while 6.7.5's matrix had 360, so that check would be wrong for 6.7.5 anyway. The installed 6.10.0 catalog now extracts 1,504 regions (488 US, 1,016 UK) and 4 datasets. Co-Authored-By: Claude Opus 5.5 --- ...n-1f-123-region-reference-files.changed.md | 2 +- .../uk-private-hugging-face-credential.md | 4 +- docs/migration/stage-9-v2-metadata.md | 31 +++-- .../v2/catalog/certified_data_inspection.py | 34 +----- policyengine_api/data/v2/catalog/records.py | 3 +- .../v2/catalog/region_data_verification.py | 90 +-------------- .../data/v2/catalog/region_references.py | 2 +- .../data/v2/catalog/region_scoping.py | 83 +++++--------- policyengine_api/data/v2/models/metadata.py | 4 +- .../services/v2/metadata/types.py | 4 +- tests/fixtures/v2_catalog.py | 11 +- .../integration/test_v2_catalog_installed.py | 76 +++++++------ .../test_v2_catalog_publication.py | 6 +- tests/unit/v2/test_catalog_extraction.py | 18 ++- .../v2/test_catalog_extraction_failures.py | 26 +---- tests/unit/v2/test_catalog_initialization.py | 7 +- .../unit/v2/test_certified_data_inspection.py | 65 +++-------- .../unit/v2/test_region_data_verification.py | 107 ++++-------------- 18 files changed, 169 insertions(+), 404 deletions(-) diff --git a/changelog.d/v2plan-1f-123-region-reference-files.changed.md b/changelog.d/v2plan-1f-123-region-reference-files.changed.md index 8434bbdb6..2ec0371e0 100644 --- a/changelog.d/v2plan-1f-123-region-reference-files.changed.md +++ b/changelog.d/v2plan-1f-123-region-reference-files.changed.md @@ -1 +1 @@ -Catalog publication reads regions from the Public API's checked-in reference files, one per country, instead of PolicyEngine.py's region registry. Each region takes its dataset from the certified data release and its filter from a scoping rule: a row filter on state_fips, congressional_district_geoid or the UK region column, or a weight_replacement filter naming the release's constituency or local-authority weight matrix and the region's ONS code. The UK now publishes 1,015 regions, with constituencies and local authorities by ONS code and Northern Ireland as country/northern_ireland; the national regions are "United States" and "United Kingdom". US places are listed in the reference file but left out of the catalog until they can run, and the publication evidence counts them. Before it publishes a catalog version that is not stored yet, the publication command reads the HDF5 metadata of the certified artifacts the filters use, from Hugging Face at their pinned revisions, and stops if a row filter's column is missing from its dataset or a weight matrix has no row for a region's code; the seed workflow's publication step receives the UK data read token as HUGGING_FACE_TOKEN for this. A catalog version that is already stored keeps the regions it was published with, so region file changes take effect in the next catalog version; republishing it logs the difference in region rows instead of failing, its evidence counts the stored regions and lists the kept version under kept_regions, and every other table must still match. +Catalog publication reads regions from the Public API's checked-in reference files, one per country, instead of PolicyEngine.py's region registry. Each region takes its dataset from the certified data release and a row filter from a scoping rule: state_fips or congressional_district_geoid for US states and districts, the UK region column for UK nations, and constituency_code_oa or la_code_oa on the local-area dataset the release routes UK constituencies and local authorities to. The UK now publishes 1,016 regions, with 650 constituencies and 361 local authorities (including Ards and North Down) by ONS code and Northern Ireland as country/northern_ireland; the national regions are "United States" and "United Kingdom". US places are listed in the reference file but left out of the catalog until they can run, and the publication evidence counts them. Before it publishes a catalog version that is not stored yet, the publication command reads the HDF5 metadata of the certified datasets the filters use, from Hugging Face at their pinned revisions, and stops if a filter's column is missing; the seed workflow's publication step receives the UK data read token as HUGGING_FACE_TOKEN for this. A catalog version that is already stored keeps the regions it was published with, so region file changes take effect in the next catalog version; republishing it logs the difference in region rows instead of failing, its evidence counts the stored regions and lists the kept version under kept_regions, and every other table must still match. diff --git a/docs/engineering/uk-private-hugging-face-credential.md b/docs/engineering/uk-private-hugging-face-credential.md index 57d5621dd..295be325c 100644 --- a/docs/engineering/uk-private-hugging-face-credential.md +++ b/docs/engineering/uk-private-hugging-face-credential.md @@ -55,8 +55,8 @@ The v2 catalog publication step of `.github/workflows/seed-v2-database.yml` also receives the organization secret as `HUGGING_FACE_TOKEN`, step-scoped. Before it publishes a new catalog version, the step reads, through one authenticated Hugging Face file system, the HDF5 metadata of the certified -artifacts that version's region filters use: the UK dataset and weight -matrices in the private UK data repository, and the public US dataset. It +datasets that version's region filters use: the UK national and local-area +datasets in the private UK data repository, and the public US dataset. It downloads only the 1 MB blocks that hold that metadata, not whole files. The simulation-entry Cloud Run service does not receive this credential. It diff --git a/docs/migration/stage-9-v2-metadata.md b/docs/migration/stage-9-v2-metadata.md index 2a2295a48..61e37b49d 100644 --- a/docs/migration/stage-9-v2-metadata.md +++ b/docs/migration/stage-9-v2-metadata.md @@ -118,26 +118,23 @@ dataset from the certified data release and its filter: | --- | --- | --- | | US national | the release's national dataset (`populace_us_2024`) | none | | US states and congressional districts | the release's state and district dataset | `row_filter` on `state_fips` or `congressional_district_geoid` | -| UK national | `enhanced_frs_2024_25` | none | -| UK nations | `enhanced_frs_2024_25` | `row_filter` on `region` | -| UK constituencies and local authorities | `enhanced_frs_2024_25` | `weight_replacement`: field `parliamentary_constituency_weights` or `local_authority_weights`, value the ONS code | - -A weight matrix must come from the same release as the dataset it reweights. -If the release names no dataset for a sub-national type, its regions use the -default dataset and the command emits one summary warning with the affected -region types and counts. Region types without a rule, such as US places until -the data can select them, are left out and counted in the evidence's +| UK national | the release's national dataset (`microcosm_uk_2024_25`) | none | +| UK nations | the release's national dataset | `row_filter` on `region` | +| UK constituencies and local authorities | the release's local-area dataset (`microcosm_uk_2024_25_dense`), which carries their codes | `row_filter` on `constituency_code_oa` or `la_code_oa`, value the ONS code | + +If the release names no dataset for a US state or district, those regions use +the default dataset and the command emits one summary warning with the affected +region types and counts. A UK constituency or local authority cannot fall back, +since the national file has no area codes: a release that routes no dataset to +their type stops publication. Region types without a rule, such as US places +until the data can select them, are left out and counted in the evidence's `omitted_region_summaries`. Before a new catalog version is published, the command reads the HDF5 metadata -of each artifact the filters use, with ranged requests at the pinned revision: -publication stops if a row filter's column is missing from its dataset's -household columns, or if a weight matrix's row count differs from its row -lookup (`constituencies_2024.csv`, `local_authorities_2021.csv`) or the lookup -lacks a region's code. The certified data release lists no lookup file, so these -are the Public API's own copies of the lookups compute reads; on 2026-10-22 both -copies were checked to be identical in content to the lookups the calculation -workers download. +of each dataset the row filters use, with ranged requests at the pinned +revision, and stops if a filter's column is missing from its dataset's +household columns. It does not check that a column holds rows for every +region's value. Review the fallback and omitted-region summaries on every new PolicyEngine.py release. A changed count may identify an upstream catalog change that needs a diff --git a/policyengine_api/data/v2/catalog/certified_data_inspection.py b/policyengine_api/data/v2/catalog/certified_data_inspection.py index e18d42c58..58f0164a4 100644 --- a/policyengine_api/data/v2/catalog/certified_data_inspection.py +++ b/policyengine_api/data/v2/catalog/certified_data_inspection.py @@ -1,4 +1,4 @@ -"""Read household column names and weight-matrix sizes from certified HDF5 files. +"""Read household column names from certified HDF5 dataset files. The artifacts are read from Hugging Face at their pinned revisions through ranged requests. Only the 1 MB blocks holding the HDF5 metadata and the @@ -27,8 +27,6 @@ # real names live only in pickled attributes. VALUE_BLOCK_PREFIX = "values_block_" TABLE_INDEX_COLUMN = "index" -# Each year of a weight matrix is one array of regions by households. -WEIGHT_MATRIX_DIMENSIONS = 2 _Result = TypeVar("_Result") @@ -103,32 +101,6 @@ def household_columns_in_hdf5(h5: h5py.File) -> frozenset[str]: return frozenset.intersection(*(_frame_columns(frame) for frame in frames)) -def weight_matrix_rows_in_hdf5(h5: h5py.File) -> int: - """Return the number of region rows of a weight matrix. - - The file holds one two-dimensional array per year, of regions by - households, and every year must have the same regions. - """ - row_counts: set[int] = set() - for name, entry in h5.items(): - if not isinstance(entry, h5py.Dataset): - continue - if len(entry.shape) != WEIGHT_MATRIX_DIMENSIONS: - msg = f"the weight matrix entry {name} is not two-dimensional" - raise CertifiedDataReadError(msg) - row_counts.add(entry.shape[0]) - if not row_counts: - msg = "the weight matrix has no year datasets" - raise CertifiedDataReadError(msg) - if len(row_counts) > 1: - msg = ( - "the weight matrix years disagree on the number of regions: " - f"{sorted(row_counts)}" - ) - raise CertifiedDataReadError(msg) - return row_counts.pop() - - def _hugging_face_file_system(token: str | None) -> ArtifactFileSystem: return HfFileSystem(token=token) @@ -166,7 +138,3 @@ def _read( def household_columns(self, artifact: CertifiedArtifact) -> frozenset[str]: """Return the household column names of a dataset artifact.""" return self._read(artifact, household_columns_in_hdf5) - - def weight_matrix_row_count(self, artifact: CertifiedArtifact) -> int: - """Return how many region rows a weight-matrix artifact holds.""" - return self._read(artifact, weight_matrix_rows_in_hdf5) diff --git a/policyengine_api/data/v2/catalog/records.py b/policyengine_api/data/v2/catalog/records.py index 0b22334fd..f05233202 100644 --- a/policyengine_api/data/v2/catalog/records.py +++ b/policyengine_api/data/v2/catalog/records.py @@ -193,8 +193,7 @@ class CountryCatalog: regions: tuple[RegionRecord, ...] fallback_summaries: tuple[FallbackSummary, ...] omitted_region_summaries: tuple[OmittedRegionSummary, ...] = () - # The release artifacts the regions' filters read: the datasets of row - # filters and the weight matrices of weight-replacement filters. + # The release datasets the regions' row filters read. certified_artifacts: tuple[CertifiedArtifact, ...] = () def parameter_value_batches( diff --git a/policyengine_api/data/v2/catalog/region_data_verification.py b/policyengine_api/data/v2/catalog/region_data_verification.py index de2910a69..e7eda396a 100644 --- a/policyengine_api/data/v2/catalog/region_data_verification.py +++ b/policyengine_api/data/v2/catalog/region_data_verification.py @@ -1,26 +1,17 @@ """Refuse to publish regions whose households the certified data cannot select. A row-filtered region needs its filter column among the household columns of -its dataset. A weight-replacement region needs a row in its weight matrix: the -matrix's rows follow the order of the row lookup that compute uses to find a -region's row, so the lookup must list exactly as many codes as the matrix has -rows and include the region's code. The release certifies no lookup file, so -the Public API's own copies of the lookups stand in for it. +its dataset. """ from __future__ import annotations -import csv from collections import defaultdict from functools import partial -from pathlib import Path from typing import TYPE_CHECKING, Protocol, TypeVar from policyengine_api.data.v2.catalog.errors import CertifiedDataReadError -from policyengine_api.data.v2.catalog.region_scoping import ( - ROW_FILTER, - WEIGHT_REPLACEMENT, -) +from policyengine_api.data.v2.catalog.region_scoping import ROW_FILTER if TYPE_CHECKING: from collections.abc import Callable, Mapping, Sequence @@ -29,19 +20,8 @@ CertifiedArtifact, CountryCatalog, NormalizedCatalog, - RegionRecord, ) -# The Public API's own copies of the row lookups compute reads to find a -# region's row in each UK weight matrix. The certified data release lists no -# lookup file, so the matrix check compares each matrix with these copies. On -# 2026-10-22 both copies were checked to be identical in content to the lookups -# the calculation workers download. -_DATA_DIRECTORY = Path(__file__).parents[2] -WEIGHT_MATRIX_ROW_LOOKUPS: Mapping[str, Path] = { - "parliamentary_constituency_weights": _DATA_DIRECTORY / "constituencies_2024.csv", - "local_authority_weights": _DATA_DIRECTORY / "local_authorities_2021.csv", -} # Region codes named in an error message, at most. MAX_LISTED_REGIONS = 3 @@ -64,16 +44,6 @@ def household_columns(self, artifact: CertifiedArtifact) -> frozenset[str]: """Return the household column names of a dataset artifact.""" ... - def weight_matrix_row_count(self, artifact: CertifiedArtifact) -> int: - """Return how many region rows a weight-matrix artifact holds.""" - ... - - -def load_weight_matrix_row_codes(path: Path) -> tuple[str, ...]: - """Return a row lookup's region codes in matrix row order.""" - with path.open(encoding="utf-8", newline="") as handle: - return tuple(row["code"] for row in csv.DictReader(handle)) - def _listed(codes: Sequence[str]) -> str: shown = ", ".join(codes[:MAX_LISTED_REGIONS]) @@ -138,73 +108,19 @@ def _verify_row_filters( raise RegionDataVerificationError(msg) -def _weight_replacement_regions( - regions: Sequence[RegionRecord], -) -> dict[str, list[RegionRecord]]: - regions_by_matrix: dict[str, list[RegionRecord]] = defaultdict(list) - for region in regions: - if region.filter_strategy == WEIGHT_REPLACEMENT and region.filter_field: - regions_by_matrix[region.filter_field].append(region) - return regions_by_matrix - - -def _verify_weight_matrices( - country: CountryCatalog, - artifacts: Mapping[str, CertifiedArtifact], - inspector: CertifiedDataInspector, - weight_matrix_row_codes: Mapping[str, Sequence[str]], -) -> None: - for matrix_name, regions in sorted( - _weight_replacement_regions(country.regions).items() - ): - row_codes = weight_matrix_row_codes.get(matrix_name) - if row_codes is None: - msg = f"no row lookup names the regions of the weight matrix {matrix_name}" - raise RegionDataVerificationError(msg) - artifact = artifacts[matrix_name] - row_count = _read( - artifact, - partial(inspector.weight_matrix_row_count, artifact), - ) - if row_count != len(row_codes): - msg = ( - f"the weight matrix {matrix_name} has {row_count} region rows, but its " - f"row lookup lists {len(row_codes)} codes" - ) - raise RegionDataVerificationError(msg) - known_codes = set(row_codes) - missing = [ - region.code for region in regions if region.filter_value not in known_codes - ] - if missing: - msg = ( - f"the weight matrix {matrix_name} has no row for {len(missing)} " - f"{country.country_id} {_noun(len(missing))} ({_listed(missing)})" - ) - raise RegionDataVerificationError(msg) - - def verify_region_data( catalog: NormalizedCatalog, inspector: CertifiedDataInspector, *, countries: Sequence[str], - weight_matrix_row_codes: Mapping[str, Sequence[str]] | None = None, ) -> None: """Check the regions of the named countries against their certified artifacts. - Each artifact is read at most once. Without explicit row codes, the row - lookups in ``WEIGHT_MATRIX_ROW_LOOKUPS`` are read. + Each artifact is read at most once. """ - if weight_matrix_row_codes is None: - weight_matrix_row_codes = { - name: load_weight_matrix_row_codes(path) - for name, path in WEIGHT_MATRIX_ROW_LOOKUPS.items() - } for country_id in countries: country = catalog.country(country_id) artifacts = { artifact.name: artifact for artifact in country.certified_artifacts } _verify_row_filters(country, artifacts, inspector) - _verify_weight_matrices(country, artifacts, inspector, weight_matrix_row_codes) diff --git a/policyengine_api/data/v2/catalog/region_references.py b/policyengine_api/data/v2/catalog/region_references.py index 966d24612..421e73622 100644 --- a/policyengine_api/data/v2/catalog/region_references.py +++ b/policyengine_api/data/v2/catalog/region_references.py @@ -57,7 +57,7 @@ class RegionReference: ``filter_value`` is the text that selects the region's households: a FIPS number or district GEOID for US states and districts, the stored region names of a UK nation, or the ONS code of a UK constituency or local - authority (its row in the weight matrix). The national region and regions + authority (its area code in the local-area dataset). The national region and regions that cannot run yet have none. ``v1_code`` is the v1 region identifier this region replaces, if v1 had one. """ diff --git a/policyengine_api/data/v2/catalog/region_scoping.py b/policyengine_api/data/v2/catalog/region_scoping.py index 2b43987cf..3432dcb4c 100644 --- a/policyengine_api/data/v2/catalog/region_scoping.py +++ b/policyengine_api/data/v2/catalog/region_scoping.py @@ -1,11 +1,9 @@ """Give each reference region its dataset and filter from the certified data release. A scoping rule per country and region type names the release's dataset role -(a key of the release manifest's ``region_datasets``) and how the region -selects its households: a row filter on one column of that dataset, or a -weight-replacement filter whose field names the release's weight-matrix -artifact and whose value is the region's row code in it. Region types without -a rule cannot run yet, so publication leaves them out and counts them. +(a key of the release manifest's ``region_datasets``) and the household column +whose value selects the region's households in that dataset. Region types +without a rule cannot run yet, so publication leaves them out and counts them. """ from __future__ import annotations @@ -23,7 +21,6 @@ from policyengine_api.data.v2.catalog.region_references import RegionReference ROW_FILTER = "row_filter" -WEIGHT_REPLACEMENT = "weight_replacement" NATIONAL_DATASET_ROLE = "national" @@ -31,13 +28,16 @@ class RegionScopingRule: """How regions of one type find their dataset and select their households. - ``filter_field`` is the dataset column of a row filter, or the release's - weight-matrix artifact name of a weight-replacement filter. + ``filter_field`` is the household column a row filter compares with the + region's filter value. When the release names no dataset for the role, + the regions use the default dataset, unless ``requires_release_dataset``: + then only the routed dataset carries the column, and scoping fails. """ dataset_role: str filter_strategy: str | None = None filter_field: str | None = None + requires_release_dataset: bool = False REGION_SCOPING_RULES: Mapping[tuple[str, str], RegionScopingRule] = { @@ -48,13 +48,19 @@ class RegionScopingRule: ), ("uk", "national"): RegionScopingRule(NATIONAL_DATASET_ROLE), ("uk", "country"): RegionScopingRule(NATIONAL_DATASET_ROLE, ROW_FILTER, "region"), + # The UK national file has no area codes; the release routes constituencies + # and local authorities to a local-area dataset that carries them. ("uk", "constituency"): RegionScopingRule( - NATIONAL_DATASET_ROLE, - WEIGHT_REPLACEMENT, - "parliamentary_constituency_weights", + "constituency", + ROW_FILTER, + "constituency_code_oa", + requires_release_dataset=True, ), ("uk", "local_authority"): RegionScopingRule( - NATIONAL_DATASET_ROLE, WEIGHT_REPLACEMENT, "local_authority_weights" + "local_authority", + ROW_FILTER, + "la_code_oa", + requires_release_dataset=True, ), } @@ -226,34 +232,6 @@ def _reject_constant(constant: str) -> object: raise ValueError(msg) -def _checked_weight_matrix( - country_id: str, - reference: RegionReference, - matrix_name: str, - dataset_name: str, - artifacts: Mapping[str, CertifiedArtifact], -) -> None: - """Require the weight matrix from the same release as the dataset it reweights. - - Weights attach to households by position, so only a matrix built with the - region's dataset lines up with its households. - """ - matrix = artifacts.get(matrix_name) - if matrix is None: - msg = ( - f"region {reference.code!r} reweights households with {matrix_name}, " - f"which the {country_id} release does not certify" - ) - raise CatalogExtractionError(msg) - dataset = artifacts[dataset_name] - if (matrix.repo_id, matrix.revision) != (dataset.repo_id, dataset.revision): - msg = ( - f"region {reference.code!r} reweights households with {matrix_name}, " - f"which is not certified in the release of {dataset_name}" - ) - raise CatalogExtractionError(msg) - - def scope_regions( country_id: str, references: tuple[RegionReference, ...], @@ -302,17 +280,16 @@ def scope_regions( _checked_filter_value(reference, rule) dataset_name = role_datasets.get(rule.dataset_role) if dataset_name is None: + if rule.requires_release_dataset: + msg = ( + f"the {country_id} release routes no dataset to " + f"{rule.dataset_role} regions, so region {reference.code!r} " + "cannot select its households" + ) + raise CatalogExtractionError(msg) dataset_name = reviewed_default if rule.dataset_role != NATIONAL_DATASET_ROLE: fallback_counts[reference.region_type] += 1 - if rule.filter_strategy == WEIGHT_REPLACEMENT: - _checked_weight_matrix( - country_id, - reference, - rule.filter_field or "", - dataset_name, - artifacts, - ) regions.append( ScopedRegion( reference=reference, @@ -333,18 +310,10 @@ def scope_regions( def filtered_artifacts(scoping: RegionScoping) -> tuple[CertifiedArtifact, ...]: - """Return, by name, the artifacts the regions' filters read. - - Those are the datasets of row-filtered regions and the weight matrices of - weight-replacement regions. - """ + """Return, by name, the datasets the regions' row filters read.""" names = { region.dataset_name for region in scoping.regions if region.filter_strategy == ROW_FILTER - } | { - region.filter_field - for region in scoping.regions - if region.filter_strategy == WEIGHT_REPLACEMENT and region.filter_field } return tuple(scoping.certified_artifacts[name] for name in sorted(names)) diff --git a/policyengine_api/data/v2/models/metadata.py b/policyengine_api/data/v2/models/metadata.py index 324ad521b..37f04f7be 100644 --- a/policyengine_api/data/v2/models/metadata.py +++ b/policyengine_api/data/v2/models/metadata.py @@ -113,9 +113,7 @@ class Region(TimestampedModel, table=True): Its default dataset must belong to the same version. Filtered regions (``requires_filter``) also name how they select their households: a ``row_filter`` keeps the households whose ``filter_field`` column matches - ``filter_value``; a ``weight_replacement`` filter reweights every household - with row ``filter_value`` of the weight matrix ``filter_field`` names in the - certified data release. + ``filter_value``. """ __tablename__ = "regions" diff --git a/policyengine_api/services/v2/metadata/types.py b/policyengine_api/services/v2/metadata/types.py index 97ec2ebeb..a7336d7e9 100644 --- a/policyengine_api/services/v2/metadata/types.py +++ b/policyengine_api/services/v2/metadata/types.py @@ -139,9 +139,7 @@ class MetadataRegion(StrictResponseModel): When requires_filter is true, filter_strategy says how the region selects households of its default dataset. With "row_filter", it keeps the households whose filter_field column equals filter_value (or is in it, - when filter_value is a JSON list). With "weight_replacement", it keeps - every household and gives each the weight in row filter_value of the - weight matrix that filter_field names in the certified data release. + when filter_value is a JSON list). """ id: UUID diff --git a/tests/fixtures/v2_catalog.py b/tests/fixtures/v2_catalog.py index e3de5fc62..60c44ff81 100644 --- a/tests/fixtures/v2_catalog.py +++ b/tests/fixtures/v2_catalog.py @@ -138,13 +138,18 @@ def _release_contents(country_id: str) -> tuple[dict, dict, SimpleNamespace]: return datasets, region_datasets, data_package datasets = { "microcosm_uk_2024_25": _certified_artifact("microcosm_uk_2024_25.h5"), - "parliamentary_constituency_weights": _certified_artifact( - "parliamentary_constituency_weights.h5" + "microcosm_uk_2024_25_dense": _certified_artifact( + "microcosm_uk_2024_25_dense.h5" ), - "local_authority_weights": _certified_artifact("local_authority_weights.h5"), } + # As in PolicyEngine.py 6.10.0, constituencies and local authorities are + # routed to the local-area dataset, which carries their codes. region_datasets = { "national": SimpleNamespace(path_template="microcosm_uk_2024_25.h5"), + "constituency": SimpleNamespace(path_template="microcosm_uk_2024_25_dense.h5"), + "local_authority": SimpleNamespace( + path_template="microcosm_uk_2024_25_dense.h5" + ), } data_package = SimpleNamespace( repo_id="example-org/example-uk-data", diff --git a/tests/integration/test_v2_catalog_installed.py b/tests/integration/test_v2_catalog_installed.py index 778b72d07..b705d68d0 100644 --- a/tests/integration/test_v2_catalog_installed.py +++ b/tests/integration/test_v2_catalog_installed.py @@ -6,12 +6,16 @@ import os from collections import Counter from importlib import metadata as importlib_metadata +from typing import TYPE_CHECKING import pytest from policyengine.bundle import get_current_bundle from policyengine_api.data.v2.catalog.extraction import extract_installed_catalog +if TYPE_CHECKING: + from policyengine_api.data.v2.catalog.records import CountryCatalog + pytestmark = pytest.mark.skipif( os.environ.get("RUN_V2_CATALOG_COMPATIBILITY") != "1", reason=("loads the complete public catalog; set RUN_V2_CATALOG_COMPATIBILITY=1"), @@ -39,10 +43,9 @@ def test_installed_policyengine_catalog_is_complete_and_bounded() -> None: ) # Counts for the 6.10.0 bundle's models: US 2.2.1 and UK 2.132.6. Keep # this exact assertion so later package changes require explicit review. - # Since 6.10.0 the UK registry also lists its 650 constituencies and 361 - # local authorities, because the bundle routes them to a local-area - # release, so regions grow by 1,011 from 6.7.5's 826 and the catalog - # adds that release's dataset. + # Regions come from the checked-in reference files: 488 US (places are + # left out) and 1,016 UK, whose constituencies and local authorities use + # the local-area dataset the release routes them to. assert catalog.entity_counts() == { "models": 2, "model_versions": 2, @@ -50,8 +53,8 @@ def test_installed_policyengine_catalog_is_complete_and_bounded() -> None: "parameter_nodes": 29_307, "parameters": 104_172, "parameter_values": 1_194_360, - "datasets": 3, - "regions": 1_503, + "datasets": 4, + "regions": 1_504, } for country_id in ("us", "uk"): @@ -74,18 +77,18 @@ def test_installed_policyengine_catalog_is_complete_and_bounded() -> None: total_values += len(batch) assert total_values == country.entity_counts()["parameter_values"] - assert {dataset.name for dataset in catalog.country("us").datasets} == { - "populace_us_2024", - "populace_us_2024_acs_local", - } - assert {dataset.name for dataset in catalog.country("uk").datasets} == { + _check_uk_regions(catalog.country("uk")) + _check_us_regions(catalog.country("us")) + + +def _check_uk_regions(uk_catalog: CountryCatalog) -> None: + assert {dataset.name for dataset in uk_catalog.datasets} == { "microcosm_uk_2024_25", "microcosm_uk_2024_25_dense", } # The national file has no area codes, so UK constituencies and local # authorities use the local-area dataset .py routes them to, while the # UK countries filter the national file. - uk_catalog = catalog.country("uk") uk_dataset_names = {dataset.id: dataset.name for dataset in uk_catalog.datasets} uk_region_type_datasets: dict[str, set[str]] = {} for region in uk_catalog.regions: @@ -100,9 +103,7 @@ def test_installed_policyengine_catalog_is_complete_and_bounded() -> None: } assert uk_catalog.fallback_summaries == () england = next( - region - for region in catalog.country("uk").regions - if region.code == "country/england" + region for region in uk_catalog.regions if region.code == "country/england" ) assert england.filter_field == "region" assert england.filter_strategy == "row_filter" @@ -119,12 +120,39 @@ def test_installed_policyengine_catalog_is_complete_and_bounded() -> None: "SOUTH_WEST", ] + uk_types = Counter(region.region_type for region in uk_catalog.regions) + assert uk_types == { + "national": 1, + "country": 4, + "constituency": 650, + "local_authority": 361, + } + uk_regions = {region.code: region for region in uk_catalog.regions} + aldershot = uk_regions["constituency/E14001063"] + assert ( + aldershot.filter_strategy, + aldershot.filter_field, + aldershot.filter_value, + ) == ("row_filter", "constituency_code_oa", "E14001063") + ards = uk_regions["local_authority/N09000011"] + assert (ards.label, ards.filter_field, ards.filter_value) == ( + "Ards and North Down", + "la_code_oa", + "N09000011", + ) + assert uk_regions["uk"].label == "United Kingdom" + + +def _check_us_regions(us_catalog: CountryCatalog) -> None: + assert {dataset.name for dataset in us_catalog.datasets} == { + "populace_us_2024", + "populace_us_2024_acs_local", + } # Since 6.7.5, US state and congressional-district regions use the # ACS-local dataset again, on the PolicyEngine/microcosm#1263 rebuild # (PolicyEngine/policyengine.py#649, ending #604's national-file filter). # The national region uses the national dataset. Regions come from the # checked-in reference files; US places cannot run yet and are left out. - us_catalog = catalog.country("us") us_dataset_names = {dataset.id: dataset.name for dataset in us_catalog.datasets} region_type_datasets: dict[str, set[str]] = {} for region in us_catalog.regions: @@ -142,22 +170,6 @@ def test_installed_policyengine_catalog_is_complete_and_bounded() -> None: for summary in us_catalog.omitted_region_summaries ] == [("place", 333)] - uk_catalog = catalog.country("uk") - uk_types = Counter(region.region_type for region in uk_catalog.regions) - assert uk_types == { - "national": 1, - "country": 4, - "constituency": 650, - "local_authority": 360, - } - uk_regions = {region.code: region for region in uk_catalog.regions} - aldershot = uk_regions["constituency/E14001063"] - assert ( - aldershot.filter_strategy, - aldershot.filter_field, - aldershot.filter_value, - ) == ("weight_replacement", "parliamentary_constituency_weights", "E14001063") - assert uk_regions["uk"].label == "United Kingdom" assert {region.code: region.label for region in us_catalog.regions}["us"] == ( "United States" ) diff --git a/tests/integration/test_v2_catalog_publication.py b/tests/integration/test_v2_catalog_publication.py index 70a275895..71808bc6e 100644 --- a/tests/integration/test_v2_catalog_publication.py +++ b/tests/integration/test_v2_catalog_publication.py @@ -135,7 +135,7 @@ def test_publish_and_republish_preserve_complete_catalog( "parameter_nodes": 2, "parameters": 2, "parameter_values": 4, - "datasets": 3, + "datasets": 4, "dataset_versions": 0, "regions": 6, "simulations": 0, @@ -167,7 +167,7 @@ def test_publish_and_republish_preserve_complete_catalog( ) assert dict(region_defaults) == { "congressional_district/CA-01": "populace_us_2024_acs_local", - "constituency/E14001063": "microcosm_uk_2024_25", + "constituency/E14001063": "microcosm_uk_2024_25_dense", "country/england": "microcosm_uk_2024_25", "state/ca": "populace_us_2024_acs_local", "uk": "microcosm_uk_2024_25", @@ -541,7 +541,7 @@ def test_new_version_is_additive_and_concurrent_retries_serialize( "parameter_nodes": 4, "parameters": 4, "parameter_values": 8, - "datasets": 6, + "datasets": 8, "dataset_versions": 0, "regions": 12, "simulations": 0, diff --git a/tests/unit/v2/test_catalog_extraction.py b/tests/unit/v2/test_catalog_extraction.py index 8bd5cf519..8b7ecec7f 100644 --- a/tests/unit/v2/test_catalog_extraction.py +++ b/tests/unit/v2/test_catalog_extraction.py @@ -42,7 +42,10 @@ def test_extracts_canonical_policyengine_catalog_and_dataset_defaults() -> None: "populace_us_2024", "populace_us_2024_acs_local", } - assert {dataset.name for dataset in uk.datasets} == {"microcosm_uk_2024_25"} + assert {dataset.name for dataset in uk.datasets} == { + "microcosm_uk_2024_25", + "microcosm_uk_2024_25_dense", + } assert all( not dataset.is_output_dataset for dataset in (*us.datasets, *uk.datasets) ) @@ -62,7 +65,14 @@ def test_extracts_canonical_policyengine_catalog_and_dataset_defaults() -> None: (summary.region_type, summary.count) for summary in us.omitted_region_summaries ] == [("place", 1)] assert uk.omitted_region_summaries == () - assert all(region.default_dataset_id == uk.datasets[0].id for region in uk.regions) + uk_datasets = {dataset.id: dataset.name for dataset in uk.datasets} + assert { + region.code: uk_datasets[region.default_dataset_id] for region in uk.regions + } == { + "uk": "microcosm_uk_2024_25", + "country/england": "microcosm_uk_2024_25", + "constituency/E14001063": "microcosm_uk_2024_25_dense", + } def test_regions_take_labels_from_references_and_filters_from_scoping_rules() -> None: @@ -124,8 +134,8 @@ def test_regions_take_labels_from_references_and_filters_from_scoping_rules() -> "constituency", "country/england", True, - "weight_replacement", - "parliamentary_constituency_weights", + "row_filter", + "constituency_code_oa", "E14001063", None, None, diff --git a/tests/unit/v2/test_catalog_extraction_failures.py b/tests/unit/v2/test_catalog_extraction_failures.py index 74c9f4459..c0a314458 100644 --- a/tests/unit/v2/test_catalog_extraction_failures.py +++ b/tests/unit/v2/test_catalog_extraction_failures.py @@ -113,19 +113,10 @@ def _name_a_per_region_dataset_template( ].path_template = "populace_{state_code}.h5" -def _remove_the_weight_matrix( +def _stop_routing_constituencies( models: dict[str, SimpleNamespace], _references: References ) -> None: - del models["uk"].release_manifest.datasets["parliamentary_constituency_weights"] - - -def _take_the_weight_matrix_from_another_release( - models: dict[str, SimpleNamespace], _references: References -) -> None: - matrix = models["uk"].release_manifest.datasets[ - "parliamentary_constituency_weights" - ] - matrix.revision = "another-release" + del models["uk"].release_manifest.region_datasets["constituency"] def _drop_a_filter_value( @@ -257,16 +248,9 @@ def _remove_every_artifact_repository( r"'populace_\{state_code\}.h5', which the catalog does not support$", ), ( - _remove_the_weight_matrix, - "^region 'constituency/E14001063' reweights households with " - "parliamentary_constituency_weights, which the uk release does not " - "certify$", - ), - ( - _take_the_weight_matrix_from_another_release, - "^region 'constituency/E14001063' reweights households with " - "parliamentary_constituency_weights, which is not certified in the " - "release of microcosm_uk_2024_25$", + _stop_routing_constituencies, + "^the uk release routes no dataset to constituency regions, so region " + "'constituency/E14001063' cannot select its households$", ), (_drop_a_filter_value, "^region 'state/ca' has no filter value$"), ( diff --git a/tests/unit/v2/test_catalog_initialization.py b/tests/unit/v2/test_catalog_initialization.py index 1870d431d..d4d6c22c7 100644 --- a/tests/unit/v2/test_catalog_initialization.py +++ b/tests/unit/v2/test_catalog_initialization.py @@ -200,7 +200,10 @@ def test_main_reports_a_region_data_failure_with_its_message( capsys: pytest.CaptureFixture[str], ) -> None: def fail(): - msg = "the weight matrix m has no row for 1 uk region (constituency/x)" + msg = ( + "the certified dataset d has no household column c, which 1 uk region " + "filters on (constituency/x)" + ) raise RegionDataVerificationError(msg) monkeypatch.setattr(initialization, "initialize_catalog", fail) @@ -208,7 +211,7 @@ def fail(): assert initialization.main() == 1 failure = capsys.readouterr() assert '"type": "RegionDataVerificationError"' in failure.err - assert "has no row for 1 uk region" in failure.err + assert "which 1 uk region filters on" in failure.err def test_extraction_failure_opens_no_engine() -> None: diff --git a/tests/unit/v2/test_certified_data_inspection.py b/tests/unit/v2/test_certified_data_inspection.py index be0be2c7e..a0b375909 100644 --- a/tests/unit/v2/test_certified_data_inspection.py +++ b/tests/unit/v2/test_certified_data_inspection.py @@ -1,4 +1,4 @@ -"""Reading column names and weight-matrix sizes from certified HDF5 artifacts.""" +"""Reading household column names from certified HDF5 datasets.""" from __future__ import annotations @@ -20,7 +20,6 @@ HuggingFaceDataInspector, household_columns_in_hdf5, huggingface_path, - weight_matrix_rows_in_hdf5, ) from policyengine_api.data.v2.catalog.records import CertifiedArtifact @@ -54,11 +53,6 @@ def _columns(path: Path) -> frozenset[str]: return household_columns_in_hdf5(h5) -def _rows(path: Path) -> int: - with h5py.File(path, "r") as h5: - return weight_matrix_rows_in_hdf5(h5) - - def test_reads_columns_of_a_fixed_format_household_frame(tmp_path: Path) -> None: path = tmp_path / "fixed.h5" HOUSEHOLDS.to_hdf(path, key="household", format="fixed") @@ -139,16 +133,6 @@ def test_refuses_a_household_entry_that_is_not_a_group(tmp_path: Path) -> None: _columns(path) -def test_counts_the_region_rows_of_a_weight_matrix(tmp_path: Path) -> None: - path = tmp_path / "matrix.h5" - with h5py.File(path, "w") as h5: - h5.create_dataset("2024", data=np.ones((3, 5))) - h5.create_dataset("2025", data=np.ones((3, 5))) - h5.create_group("metadata").create_dataset("note", data=np.ones(1)) - - assert _rows(path) == 3 - - def test_default_file_system_is_hugging_face_with_the_token() -> None: inspector = HuggingFaceDataInspector(token="read-token") @@ -157,31 +141,6 @@ def test_default_file_system_is_hugging_face_with_the_token() -> None: assert file_system.token == "read-token" -@pytest.mark.parametrize( - ("shapes", "message"), - [ - ({}, r"^the weight matrix has no year datasets$"), - ({"2024": (3,)}, r"^the weight matrix entry 2024 is not two-dimensional$"), - ( - {"2024": (3, 5), "2025": (4, 5)}, - r"^the weight matrix years disagree on the number of regions: \[3, 4\]$", - ), - ], -) -def test_refuses_an_inconsistent_weight_matrix( - tmp_path: Path, - shapes: dict[str, tuple[int, ...]], - message: str, -) -> None: - path = tmp_path / "matrix.h5" - with h5py.File(path, "w") as h5: - for year, shape in shapes.items(): - h5.create_dataset(year, data=np.ones(shape)) - - with pytest.raises(CertifiedDataReadError, match=message): - _rows(path) - - def test_names_dataset_and_model_repository_files_at_their_revision() -> None: assert huggingface_path(_artifact()) == ( "datasets/policyengine/populace-us@" @@ -189,8 +148,8 @@ def test_names_dataset_and_model_repository_files_at_their_revision() -> None: "populace_us_2024_acs_local.h5" ) assert huggingface_path( - _artifact(name="enhanced_frs_2024_25", repo_type="model", revision="1.58.1") - ) == ("policyengine/populace-us@1.58.1/enhanced_frs_2024_25.h5") + _artifact(name="microcosm_uk_2024_25", repo_type="model", revision="1.0.0") + ) == ("policyengine/populace-us@1.0.0/microcosm_uk_2024_25.h5") assert huggingface_path(_artifact(revision="refs/pr/1")).startswith( "datasets/policyengine/populace-us@refs%2Fpr%2F1/" ) @@ -214,15 +173,16 @@ def test_inspector_reads_artifacts_through_an_authenticated_file_system( ) -> None: dataset_path = tmp_path / "dataset.h5" HOUSEHOLDS.to_hdf(dataset_path, key="household", format="fixed") - matrix_path = tmp_path / "matrix.h5" - with h5py.File(matrix_path, "w") as h5: - h5.create_dataset("2025", data=np.ones((2, 2))) + local_area_path = tmp_path / "local_area.h5" + HOUSEHOLDS.assign(la_code_oa=["N09000011", "E07000110"]).to_hdf( + local_area_path, key="household", format="table", data_columns=True + ) dataset = _artifact() - matrix = _artifact(name="parliamentary_constituency_weights", repo_type="model") + local_area = _artifact(name="microcosm_uk_2024_25_dense", repo_type="dataset") file_system = FakeFileSystem( { huggingface_path(dataset): dataset_path, - huggingface_path(matrix): matrix_path, + huggingface_path(local_area): local_area_path, } ) tokens: list[str | None] = [] @@ -236,9 +196,12 @@ def build_file_system(token: str | None) -> FakeFileSystem: ) assert "state_fips" in inspector.household_columns(dataset) - assert inspector.weight_matrix_row_count(matrix) == 2 + assert "la_code_oa" in inspector.household_columns(local_area) assert tokens == ["read-token"] - assert file_system.opened == [huggingface_path(dataset), huggingface_path(matrix)] + assert file_system.opened == [ + huggingface_path(dataset), + huggingface_path(local_area), + ] def test_imports_only_packages_the_project_depends_on_directly() -> None: diff --git a/tests/unit/v2/test_region_data_verification.py b/tests/unit/v2/test_region_data_verification.py index d2d1cfa2d..11c719a41 100644 --- a/tests/unit/v2/test_region_data_verification.py +++ b/tests/unit/v2/test_region_data_verification.py @@ -8,9 +8,7 @@ from policyengine_api.data.v2.catalog.errors import CertifiedDataReadError from policyengine_api.data.v2.catalog.region_data_verification import ( - WEIGHT_MATRIX_ROW_LOOKUPS, RegionDataVerificationError, - load_weight_matrix_row_codes, verify_region_data, ) from tests.fixtures.v2_catalog import normalized_catalog @@ -23,54 +21,38 @@ {"state_fips", "congressional_district_geoid", "household_weight"} ), "microcosm_uk_2024_25": frozenset({"region", "household_weight"}), + "microcosm_uk_2024_25_dense": frozenset( + {"constituency_code_oa", "la_code_oa", "household_weight"} + ), } -CONSTITUENCY_ROW_CODES = ("E14001063", "E14001064") class FakeInspector: - """Answers from fixed columns and matrix sizes, and records each artifact read.""" + """Answers from fixed columns, and records each artifact read.""" def __init__( self, *, columns: dict[str, frozenset[str]] | None = None, - matrix_rows: dict[str, int] | None = None, failure: Exception | None = None, ) -> None: self.columns = ALL_COLUMNS if columns is None else columns - self.matrix_rows = ( - {"parliamentary_constituency_weights": len(CONSTITUENCY_ROW_CODES)} - if matrix_rows is None - else matrix_rows - ) self.failure = failure - self.reads: list[tuple[str, str]] = [] + self.reads: list[str] = [] def household_columns(self, artifact: CertifiedArtifact) -> frozenset[str]: - self.reads.append(("columns", artifact.name)) + self.reads.append(artifact.name) if self.failure is not None: raise self.failure return self.columns[artifact.name] - def weight_matrix_row_count(self, artifact: CertifiedArtifact) -> int: - self.reads.append(("rows", artifact.name)) - if self.failure is not None: - raise self.failure - return self.matrix_rows[artifact.name] - def _verify( inspector: FakeInspector, *, countries: tuple[str, ...] = ("us", "uk"), - row_codes: tuple[str, ...] = CONSTITUENCY_ROW_CODES, ) -> None: - verify_region_data( - normalized_catalog(), - inspector, - countries=countries, - weight_matrix_row_codes={"parliamentary_constituency_weights": row_codes}, - ) + verify_region_data(normalized_catalog(), inspector, countries=countries) def test_catalog_records_only_the_artifacts_its_filters_read() -> None: @@ -98,23 +80,23 @@ def test_catalog_records_only_the_artifacts_its_filters_read() -> None: "model", ), ( - "parliamentary_constituency_weights", + "microcosm_uk_2024_25_dense", "example-org/example-uk-data", - "parliamentary_constituency_weights.h5", + "microcosm_uk_2024_25_dense.h5", "model", ), ] -def test_accepts_regions_whose_columns_and_matrix_rows_exist() -> None: +def test_accepts_regions_whose_filter_columns_exist() -> None: inspector = FakeInspector() _verify(inspector) assert sorted(inspector.reads) == [ - ("columns", "microcosm_uk_2024_25"), - ("columns", "populace_us_2024_acs_local"), - ("rows", "parliamentary_constituency_weights"), + "microcosm_uk_2024_25", + "microcosm_uk_2024_25_dense", + "populace_us_2024_acs_local", ] @@ -123,7 +105,7 @@ def test_reads_only_the_artifacts_of_the_countries_it_verifies() -> None: _verify(inspector, countries=("us",)) - assert inspector.reads == [("columns", "populace_us_2024_acs_local")] + assert inspector.reads == ["populace_us_2024_acs_local"] def test_verifies_nothing_when_every_version_is_already_published() -> None: @@ -151,38 +133,21 @@ def test_rejects_a_filter_column_missing_from_the_dataset() -> None: _verify(inspector) -def test_rejects_a_region_code_missing_from_the_weight_matrix() -> None: - with pytest.raises( - RegionDataVerificationError, - match=r"^the weight matrix parliamentary_constituency_weights has no row for " - r"1 uk region \(constituency/E14001063\)$", - ): - _verify(FakeInspector(), row_codes=("E14001064", "E14001065")) - - -def test_rejects_a_weight_matrix_whose_rows_differ_from_its_lookup() -> None: - inspector = FakeInspector(matrix_rows={"parliamentary_constituency_weights": 3}) - - with pytest.raises( - RegionDataVerificationError, - match=r"^the weight matrix parliamentary_constituency_weights has 3 region " - r"rows, but its row lookup lists 2 codes$", - ): - _verify(inspector) - +def test_rejects_a_local_area_dataset_without_the_area_code_column() -> None: + inspector = FakeInspector( + columns={ + **ALL_COLUMNS, + "microcosm_uk_2024_25_dense": frozenset({"la_code_oa"}), + } + ) -def test_rejects_a_weight_matrix_without_a_row_lookup() -> None: with pytest.raises( RegionDataVerificationError, - match=r"^no row lookup names the regions of the weight matrix " - r"parliamentary_constituency_weights$", + match=r"^the certified dataset microcosm_uk_2024_25_dense has no household " + r"column constituency_code_oa, which 1 uk region filters on " + r"\(constituency/E14001063\)$", ): - verify_region_data( - normalized_catalog(), - FakeInspector(), - countries=("uk",), - weight_matrix_row_codes={}, - ) + _verify(inspector, countries=("uk",)) def test_reports_an_unreadable_artifact_without_its_error_text() -> None: @@ -201,20 +166,6 @@ def test_reports_an_unreadable_artifact_without_its_error_text() -> None: assert "fixture" not in str(raised.value) -def test_row_lookups_list_every_reference_code_in_matrix_order() -> None: - constituencies = load_weight_matrix_row_codes( - WEIGHT_MATRIX_ROW_LOOKUPS["parliamentary_constituency_weights"] - ) - local_authorities = load_weight_matrix_row_codes( - WEIGHT_MATRIX_ROW_LOOKUPS["local_authority_weights"] - ) - - assert len(constituencies) == len(set(constituencies)) == 650 - assert len(local_authorities) == len(set(local_authorities)) == 361 - assert constituencies[0] == "E14001063" - assert local_authorities[0] == "E06000001" - - def test_reports_an_artifact_structure_error_with_its_own_message() -> None: inspector = FakeInspector( failure=CertifiedDataReadError("household group holds no data frame") @@ -233,11 +184,3 @@ def test_passes_on_a_verification_error_the_inspector_raises() -> None: with pytest.raises(RegionDataVerificationError, match=r"^already explained$"): _verify(inspector, countries=("us",)) - - -def test_reads_the_checked_in_row_lookups_by_default() -> None: - inspector = FakeInspector(matrix_rows={"parliamentary_constituency_weights": 650}) - - verify_region_data(normalized_catalog(), inspector, countries=("uk",)) - - assert ("rows", "parliamentary_constituency_weights") in inspector.reads From cbb1656cf40aa59494c1ed793356bf1f3279b141 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:43:47 +0400 Subject: [PATCH 24/25] Cover UK local authorities and the UK's lack of fallback at unit level The catalog fixture's UK reference file now also lists Northern Ireland and Ards and North Down (local_authority/N09000011). The unit tests assert that the local authority is row-filtered on la_code_oa with its ONS code in the local-area dataset the release routes it to, that the UK records no fallback, and that a release routing no dataset to local authorities stops publication naming the region, as for constituencies. These replace, at unit level, master's registry-based test of the UK area regions' dataset. Co-Authored-By: Claude Opus 5.5 --- tests/fixtures/v2_catalog.py | 20 ++++++++++++++ .../test_v2_catalog_publication.py | 6 +++-- tests/unit/v2/test_catalog_extraction.py | 27 +++++++++++++++++++ .../v2/test_catalog_extraction_failures.py | 11 ++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/tests/fixtures/v2_catalog.py b/tests/fixtures/v2_catalog.py index 60c44ff81..61e569f95 100644 --- a/tests/fixtures/v2_catalog.py +++ b/tests/fixtures/v2_catalog.py @@ -99,6 +99,26 @@ def region_references() -> dict[str, tuple[RegionReference, ...]]: filter_value="E14001063", v1_code="constituency/Aldershot", ), + RegionReference( + code="country/northern_ireland", + label="Northern Ireland", + region_type="country", + parent_code="uk", + state_code=None, + state_name=None, + filter_value="NORTHERN_IRELAND", + v1_code="country/ni", + ), + RegionReference( + code="local_authority/N09000011", + label="Ards and North Down", + region_type="local_authority", + parent_code="country/northern_ireland", + state_code=None, + state_name=None, + filter_value="N09000011", + v1_code="local_authority/Ards and North Down", + ), ), } diff --git a/tests/integration/test_v2_catalog_publication.py b/tests/integration/test_v2_catalog_publication.py index 71808bc6e..edef7b93e 100644 --- a/tests/integration/test_v2_catalog_publication.py +++ b/tests/integration/test_v2_catalog_publication.py @@ -137,7 +137,7 @@ def test_publish_and_republish_preserve_complete_catalog( "parameter_values": 4, "datasets": 4, "dataset_versions": 0, - "regions": 6, + "regions": 8, "simulations": 0, "reports": 0, "report_runs": 0, @@ -169,6 +169,8 @@ def test_publish_and_republish_preserve_complete_catalog( "congressional_district/CA-01": "populace_us_2024_acs_local", "constituency/E14001063": "microcosm_uk_2024_25_dense", "country/england": "microcosm_uk_2024_25", + "country/northern_ireland": "microcosm_uk_2024_25", + "local_authority/N09000011": "microcosm_uk_2024_25_dense", "state/ca": "populace_us_2024_acs_local", "uk": "microcosm_uk_2024_25", "us": "populace_us_2024", @@ -543,7 +545,7 @@ def test_new_version_is_additive_and_concurrent_retries_serialize( "parameter_values": 8, "datasets": 8, "dataset_versions": 0, - "regions": 12, + "regions": 16, "simulations": 0, "reports": 0, "report_runs": 0, diff --git a/tests/unit/v2/test_catalog_extraction.py b/tests/unit/v2/test_catalog_extraction.py index 8b7ecec7f..bc6d45eca 100644 --- a/tests/unit/v2/test_catalog_extraction.py +++ b/tests/unit/v2/test_catalog_extraction.py @@ -71,8 +71,13 @@ def test_extracts_canonical_policyengine_catalog_and_dataset_defaults() -> None: } == { "uk": "microcosm_uk_2024_25", "country/england": "microcosm_uk_2024_25", + "country/northern_ireland": "microcosm_uk_2024_25", "constituency/E14001063": "microcosm_uk_2024_25_dense", + "local_authority/N09000011": "microcosm_uk_2024_25_dense", } + # UK nations filter the national dataset by design, and constituencies and + # local authorities use the dataset the release routes them to. + assert uk.fallback_summaries == () def test_regions_take_labels_from_references_and_filters_from_scoping_rules() -> None: @@ -140,6 +145,28 @@ def test_regions_take_labels_from_references_and_filters_from_scoping_rules() -> None, None, ), + "country/northern_ireland": ( + "Northern Ireland", + "country", + "uk", + True, + "row_filter", + "region", + "NORTHERN_IRELAND", + None, + None, + ), + "local_authority/N09000011": ( + "Ards and North Down", + "local_authority", + "country/northern_ireland", + True, + "row_filter", + "la_code_oa", + "N09000011", + None, + None, + ), } diff --git a/tests/unit/v2/test_catalog_extraction_failures.py b/tests/unit/v2/test_catalog_extraction_failures.py index c0a314458..70f5f954c 100644 --- a/tests/unit/v2/test_catalog_extraction_failures.py +++ b/tests/unit/v2/test_catalog_extraction_failures.py @@ -119,6 +119,12 @@ def _stop_routing_constituencies( del models["uk"].release_manifest.region_datasets["constituency"] +def _stop_routing_local_authorities( + models: dict[str, SimpleNamespace], _references: References +) -> None: + del models["uk"].release_manifest.region_datasets["local_authority"] + + def _drop_a_filter_value( _models: dict[str, SimpleNamespace], references: References ) -> None: @@ -252,6 +258,11 @@ def _remove_every_artifact_repository( "^the uk release routes no dataset to constituency regions, so region " "'constituency/E14001063' cannot select its households$", ), + ( + _stop_routing_local_authorities, + "^the uk release routes no dataset to local_authority regions, so region " + "'local_authority/N09000011' cannot select its households$", + ), (_drop_a_filter_value, "^region 'state/ca' has no filter value$"), ( _give_the_national_region_a_filter_value, From d2f6c4ccbc524d4029652f995c0354613d8e2dab Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:44:13 +0400 Subject: [PATCH 25/25] Rewrap the Stage 9 region paragraphs like the surrounding text Co-Authored-By: Claude Opus 5.5 --- docs/migration/stage-9-v2-metadata.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/migration/stage-9-v2-metadata.md b/docs/migration/stage-9-v2-metadata.md index 61e37b49d..3825ad81c 100644 --- a/docs/migration/stage-9-v2-metadata.md +++ b/docs/migration/stage-9-v2-metadata.md @@ -148,9 +148,9 @@ staged region rows the stored version lacks and of stored rows not staged, and continues. Its evidence then describes what is stored: the region count is the stored one, `kept_regions` lists the country, version, stored and staged region counts, and the fallback and omitted-region summaries leave that country out, -since they describe staged regions that were not published. Every other table must still match exactly. -Region reference changes therefore take effect in the next catalog version -that is published. +since they describe staged regions that were not published. Every other table +must still match exactly. Region reference changes therefore take effect in the +next catalog version that is published. ## Validation evidence and retry @@ -181,9 +181,10 @@ A failed attempt rolls back all catalog changes. Retry the same artifact only after correcting the external failure. A matching retry preserves identifiers, content, and row counts. Different normalized content under the same PolicyEngine.py version fails for operator review, with an error naming the -first table that differs; it is never silently replaced. Regions are the exception: they come from the Public API's reference -files, so a stored version keeps the regions it was published with (see -"Source and regional dataset behavior"). A later PolicyEngine.py version adds a catalog version without +first table that differs; it is never silently replaced. Regions are the +exception: they come from the Public API's reference files, so a stored version +keeps the regions it was published with (see "Source and regional dataset +behavior"). A later PolicyEngine.py version adds a catalog version without deleting or modifying earlier versioned rows, including their logical datasets, regions, model descriptions, current-law IDs, and time-period options.