From 93022733a411a04fc749665f544d1006be99084d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 25 Oct 2026 14:35:54 -0700 Subject: [PATCH 01/14] Bind the d2011 pair: UK 2.151.0 and Microcosm a6299504, pending release d2011 = A (Max, 25 Oct): the binding primary moves from #715's a133f37a / UK 2.147.2 pair to the 2.151.0 national and local-area pair built at microcosm#1396. The model pin, lock, CI, config.PRIMARY_DATASET, the certified bundle (d2011 wheel attestation), the dataset registry, the pending bridge endpoints, the preflight and the release wording move to it. The staged national's bytes (b96a8cd6) and its expected release tag are pinned. The carrier version and manifest digest, the certifying pull request and commit, and the local-area file stay None, and each fails closed. The certified-bundle check now requires the pair's exact region routing (national plus constituency and local-authority to the local-area file) and refuses a pending path. #715's pair stays registered, with its own pending identity, as d2011's fallback. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/python.yml | 2 +- data/binding-bridge.pending.json | 18 ++--- docs/METHOD.md | 24 +++--- docs/REBUILD.md | 71 +++++++++-------- docs/d2011-upstream.md | 2 +- pyproject.toml | 6 +- requirements-lock.txt | 4 +- scripts/binding_bridge.py | 2 +- scripts/compare_hb_link.py | 2 +- src/triple_lock/config.py | 9 ++- src/triple_lock/datasets.py | 125 +++++++++++++++++++++++------- src/triple_lock/engine.py | 45 +++++------ src/triple_lock/pipeline.py | 45 +++++------ tests/test_binding_bridge.py | 21 +++-- tests/test_budget_bundle.py | 38 ++++++++- tests/test_certified_preflight.py | 20 +++-- tests/test_datasets.py | 13 ++-- tests/test_microcosm_primary.py | 5 +- 18 files changed, 290 insertions(+), 162 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index ecd0558a..fdbb4583 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -25,7 +25,7 @@ jobs: python-version: "3.13" activate-environment: true - name: Install - # #715 candidate UK 2.147.2/core 3.33.3; released certification pending. Retained + # d2011's candidate UK 2.151.0/core 3.33.3; released certification pending. Retained # historical pilot receipts keep their original model/data labels; # no test needs the private Enhanced FRS file. run: | diff --git a/data/binding-bridge.pending.json b/data/binding-bridge.pending.json index 6cdf3ff9..10d62bd5 100644 --- a/data/binding-bridge.pending.json +++ b/data/binding-bridge.pending.json @@ -1,7 +1,7 @@ { "schema_version": 1, "status": "pending", - "decision": "d1983 = B, Max 2026-10-24; certified policyengine.py #715 Microcosm UK Budget pair supersedes d1727 qualification-ledger primary", + "decision": "d2011 = A, Max 2026-10-25: the certified 2.151.0 Microcosm UK Budget pair (microcosm#1396) replaces #715's a133f37a pair; d1983 = B, Max 2026-10-24, put the binding primary on Microcosm", "baseline_receipt": { "path": "data/pilot/J-bridge-certified.json", "sha256": "f5f42f5a6e20eb251dd26a283b840fdb825843e8edc9078c02fea78f14b6ac2f" @@ -22,7 +22,7 @@ "to": { "packages": { "policyengine": null, - "policyengine-uk": "2.147.2", + "policyengine-uk": "2.151.0", "policyengine-core": "3.33.3" }, "dataset": { @@ -35,7 +35,7 @@ "from": { "packages": { "policyengine": null, - "policyengine-uk": "2.147.2", + "policyengine-uk": "2.151.0", "policyengine-core": "3.33.3" }, "dataset": { @@ -46,22 +46,22 @@ "to": { "packages": { "policyengine": null, - "policyengine-uk": "2.147.2", + "policyengine-uk": "2.151.0", "policyengine-core": "3.33.3" }, "dataset": { - "name": "microcosm_uk_2024_25@a133f37a", - "sha256": "64f83900ca3a1242fb017585234844ce89f743d32e41cd2b6558057777f914aa" + "name": "microcosm_uk_2024_25@a6299504", + "sha256": "b96a8cd625b645a409c063b1de5158532708a2f0d3c3897a794a8fb0bbd5064e" } } } }, "pending_release": { - "pull_request": "https://github.com/PolicyEngine/policyengine.py/pull/715", - "reviewed_head": "e02d41bceab57756373b2dc3bcf78635b856ec81", + "pull_request": null, + "reviewed_head": null, "bundle_manifest_path": "src/policyengine/data/bundle/manifest.json", "policyengine_version": null, "bundle_manifest_sha256": null, - "gate": "draft stacked on #704; attribution 2026-10-24T22:00Z to 2026-10-25T10:00Z, then review and release" + "gate": "d2011 = A: the 2.151.0 national (microcosm#1396, a6299504) and its local-area cut certified together; fallback to #715's a133f37a pair if not certified by 2026-10-27T18:00Z" } } diff --git a/docs/METHOD.md b/docs/METHOD.md index de8023a9..7ee8a0a5 100644 --- a/docs/METHOD.md +++ b/docs/METHOD.md @@ -2,7 +2,7 @@ The committed `data/results.json` is the **certified pre-Budget release**, provisional and model-conditional under uncertainty ruling (c). Every fiscal figure is a full PolicyEngine UK run on policyengine.py **6.2.6**, UK **2.125.1**, core **3.33.0** and Enhanced FRS **1.58.0**, with static ageing below. That release, publicized in Vahid's post as provisional, uses Enhanced FRS as primary and the earlier Microcosm artifact as its uncertified paired sensitivity. The original uncertified model-v2 pilot (UK 2.120.0 / EFRS 1.56.16) is retained separately in `data/pilot`; model-v2 is merged. The completed historical bridge and rehearsal are bound by [J-completed-verification](../data/pilot/J-completed-verification.json). -Max's **d1983 = B** ruling on 24 October makes the binding successor's primary the national Microcosm UK Budget pair in [policyengine.py #715](https://github.com/PolicyEngine/policyengine.py/pull/715): cut `microcosm-uk-2024-25-national-20261023T075039Z-a133f37a`, UK **2.147.2**, core **3.33.3**. Enhanced FRS **1.58.2** becomes the paired sensitivity. The carrier release is pending; the draft PR is stacked on #704, with attribution scheduled for 24 October 22:00Z–25 October 10:00Z, then review. Certification fails closed until its exact released manifest is pinned. This supersedes d1727's qualification-ledger primary. [REBUILD.md](REBUILD.md) gives the fixed-data model bridge on Enhanced FRS 1.58.0 and the cross-family data bridge to Microcosm. The pre-Budget and binding releases use different data; bridge step (b) measures that move. Historical pre-Budget provenance stays at 6.2.6 / 1.58.0. Nothing is scaled or interpolated from another run. The code is the reference. +Max's **d1983 = B** ruling on 24 October put the binding successor's primary on the certified Microcosm UK Budget pair, superseding d1727's qualification-ledger primary. His **d2011 = A** ruling on 25 October chose that pair: the 2.151.0 national and local-area pair built at [microcosm#1396](https://github.com/PolicyEngine/microcosm/pull/1396), national cut `microcosm-uk-2024-25-national-20261025T091414Z-a6299504`, UK **2.151.0**, core **3.33.3**. If it is not certified by 27 October 18:00Z, the fallback is [policyengine.py #715](https://github.com/PolicyEngine/policyengine.py/pull/715)'s `a133f37a` / UK 2.147.2 pair. Enhanced FRS **1.58.2** is the paired sensitivity. The carrier release is pending, and certification fails closed until its exact released manifest is pinned. [REBUILD.md](REBUILD.md) gives the fixed-data model bridge on Enhanced FRS 1.58.0 and the cross-family data bridge to Microcosm. The pre-Budget and binding releases use different data; bridge step (b) measures that move. Historical pre-Budget provenance stays at 6.2.6 / 1.58.0. Nothing is scaled or interpolated from another run. The code is the reference. ## The rules (`rules.py`) @@ -23,7 +23,7 @@ A path is calendar-year CPI and earnings growth for 2027–2039, plus the statut - the calendar growth replaces `gov.economic_assumptions.yoy_growth.obr` (RPI and CPIH move by the same amount as CPI); - the statutory inputs replace the model's own (`statutory_uprating_inputs`: September CPI and May–July AWE, each at its observation date), from which policyengine-uk builds its triple lock. Without them it would use the published figures and then calendar growth plus the OBR's forecast gap. -On UK 2.147.2, dated parameter reforms passed during simulation construction rebuild the derived series, including the triple lock. The earlier 2.125.1 dict-reform route edited already-processed parameters without that rebuild. The engine uses a Scenario before data load so projected inputs follow the path, then applies explicit flat-rate amounts computed by its own rules, preserving their precision, complete horizon and earnings-path top-up. `model_horizon.py` first extends the private pension uprating, which policyengine-uk still stops at 2034; every other derived series now reaches 2039-40 by itself (the OBR series run to 2073). A one-off check when porting found the processed parameters for the central path identical with and without the old extensions on every date to 2039; a test checks that every derived series follows a path to 2039-40. +On UK 2.147.2 and 2.151.0, dated parameter reforms passed during simulation construction rebuild the derived series, including the triple lock. The earlier 2.125.1 dict-reform route edited already-processed parameters without that rebuild. The engine uses a Scenario before data load so projected inputs follow the path, then applies explicit flat-rate amounts computed by its own rules, preserving their precision, complete horizon and earnings-path top-up. `model_horizon.py` first extends the private pension uprating, which policyengine-uk still stops at 2034; every other derived series now reaches 2039-40 by itself (the OBR series run to 2073). A one-off check when porting found the processed parameters for the central path identical with and without the old extensions on every date to 2039; a test checks that every derived series follows a path to 2039-40. **What each run checks, in every year**, failing if any misses: - CPI-uprated benefit rates follow the path's CPI the year before; @@ -37,13 +37,13 @@ On UK 2.147.2, dated parameter reforms passed during simulation construction reb - the population treatment's inputs read back from the model (ages, birthdays, head flags, weights), person and benefit-unit weights are their household's, and every raked year hits its ONS growth targets to 1e-6 relative; - the additional State Pension is the pinned one under every policy, and in the data year basic + new + additional State Pension (plus the age addition when the installed model defines it) equals the reported State Pension the run counts, for every person, to £0.01; the age addition is identical under both rules in every path/year (below). -**What does not follow the path.** Dividend, property, savings and self-employment income, rents and council tax are recorded as not following it. Rents and council tax stay at their 2030 amounts from 2031, because the survey data are extended to 2030. UK 2.147.2 lacks #2557 (first released in 2.148.2): the existing `gov.benefit_uprating_cpi` parameter has uprating metadata `gov.economic_assumptions.indices.obr.lagged_cpi`, so the benefit path check uses lagged calendar CPI. The September-CPI branch applies only to later models whose benefit-index metadata identifies that statutory projection. Statutory September inputs still determine the State Pension, the engine's additional-pension pin and its earnings override independently of this benefit-index branch. +**What does not follow the path.** Dividend, property, savings and self-employment income, rents and council tax are recorded as not following it. Rents and council tax stay at their 2030 amounts from 2031, because the survey data are extended to 2030. UK 2.151.0 includes #2557 (first released in 2.148.2): `gov.benefit_uprating_cpi` has uprating metadata `gov.economic_assumptions.indices.september_cpi_uprating`, so every benefit it uprates rises each April by the path's September CPI the year before, to 0.1 point and never cut, and the benefit path check expects exactly that. April 2027 therefore uses the path's September 2026 input. UK 2.147.2 (the fallback) and the historical 2.125.1 lack #2557: their metadata is `gov.economic_assumptions.indices.obr.lagged_cpi`, and the check uses lagged calendar CPI. Any other index fails the run. Statutory September inputs still determine the State Pension, the engine's additional-pension pin and its earnings override independently of this benefit-index branch. -**Housing Benefit allowances and Savings Credit thresholds are a projection choice.** Under d1753's default, keep the installed projection and publish the alternative as a sensitivity. UK 2.147.2 lacks both the Savings Credit `maximum` parameter and #2576's `project_pension_age_allowances` and `project_savings_credit_threshold` builders (first released in 2.151.2). Its installed HB allowances and Savings Credit thresholds therefore remain on CPI. Defensive tests also cover a model with `maximum` but without linked builders: the parameter alone never enables linked derivation. The engine overrides the guarantee to follow each path's non-negative May–July earnings, while leaving those two installed projections intact. On a later model that defines the linked builders, it also refreshes their linked amounts from its exact guarantee. The foreground sensitivity command in [REBUILD.md](REBUILD.md) compares two complete central-path triple-lock/Burnham pairs on this bundle: installed CPI and the explicitly labelled guarantee-linked alternative. No fiscal comparison has run in this preparation. +**Housing Benefit allowances and Savings Credit thresholds are a projection choice.** Under d1753's default, keep the installed projection and publish the alternative as a sensitivity. UK 2.151.0, like 2.147.2, lacks both the Savings Credit `maximum` parameter and #2576's `project_pension_age_allowances` and `project_savings_credit_threshold` builders (first released in 2.151.2). Its installed HB allowances and Savings Credit thresholds therefore remain on CPI: they name lagged calendar CPI themselves, the projection they reached through `gov.benefit_uprating_cpi` on 2.147.2 ([d2011-upstream.md](d2011-upstream.md)). Defensive tests also cover a model with `maximum` but without linked builders: the parameter alone never enables linked derivation. The engine overrides the guarantee to follow each path's non-negative May–July earnings, while leaving those two installed projections intact. On a later model that defines the linked builders, it also refreshes their linked amounts from its exact guarantee. The foreground sensitivity command in [REBUILD.md](REBUILD.md) compares two complete central-path triple-lock/Burnham pairs on this bundle: installed CPI and the explicitly labelled guarantee-linked alternative. No fiscal comparison has run in this preparation. The guarantee's earnings duty ([SSAA 1992 s150A](https://www.legislation.gov.uk/ukpga/1992/5/section/150A)) does not itself prescribe future HB allowances or Savings Credit thresholds: these are separate cash amounts in [HB Regulations Schedule 3](https://www.legislation.gov.uk/uksi/2006/214/schedule/3) and [Pension Credit Regulations reg7](https://www.legislation.gov.uk/uksi/2002/1792/regulation/7). DWP describes protected HB allowances as guarantee plus maximum Savings Credit, with that uplift removed for pension-age entrants from April 2021 ([A3/2021](https://www.gov.uk/government/publications/housing-benefit-adjudication-circulars-2021/a32021-housing-benefit-person-who-have-attained-the-qualifying-age-for-state-pension-credit-amendment-regulations-2021)). Savings Credit's maximum is 60% of guarantee minus threshold ([2002 Act s3(7)](https://www.legislation.gov.uk/ukpga/2002/16/section/3)). Continuing these relationships is a documented projection convention. [HB_ALLOWANCE_REVIEW.md](HB_ALLOWANCE_REVIEW.md) preserves the historical UK 2.125.1 comparisons, whose whole-CPI headline used the old calendar-CPI benefit index. -**Take-up.** In the survey runs, Housing Benefit and council tax reduction respond only for households already receiving them: nobody newly entitled starts claiming. On policyengine-uk 2.147.2, both take-up formulas use reported claims directly in data-built simulations, including Microcosm; they ignore `claims_all_entitled_benefits` there. That flag is false for every data-built family (#1831), while hand-entered situations apply its per-family seven-benefit test. Direct take-up inputs can override the defaults. Pension-age families may make new Housing Benefit claims in law and in the household calculator, but the default data-built formulas do not add new claimants. Pension Credit take-up is the dataset's own draw (`would_claim_pc`). A pension cut can make a household eligible for Pension Credit guarantee credit, which the certified model passports only when it is received. Individual-record diagnostics are private and omitted from published outputs. The engine privately computes `concentration_top10_by_year`; selecting ten records does not prove ten nonzero contributors or adequate recoverable complements, so new publishers omit that family too. Microcosm records are constructed from survey donors, so the same linked ten-record withholding applies to both dataset families. The published file never gives a record's identifier, weight, amount or contribution. +**Take-up.** In the survey runs, Housing Benefit and council tax reduction respond only for households already receiving them: nobody newly entitled starts claiming. On policyengine-uk 2.151.0 (as on 2.147.2; the formulas are byte-identical), both take-up formulas use reported claims directly in data-built simulations, including Microcosm; they ignore `claims_all_entitled_benefits` there. That flag is false for every data-built family (#1831), while hand-entered situations apply its per-family seven-benefit test. Direct take-up inputs can override the defaults. Pension-age families may make new Housing Benefit claims in law and in the household calculator, but the default data-built formulas do not add new claimants. Pension Credit take-up is the dataset's own draw (`would_claim_pc`). A pension cut can make a household eligible for Pension Credit guarantee credit, which the certified model passports only when it is received. Individual-record diagnostics are private and omitted from published outputs. The engine privately computes `concentration_top10_by_year`; selecting ten records does not prove ten nonzero contributors or adequate recoverable complements, so new publishers omit that family too. Microcosm records are constructed from survey donors, so the same linked ten-record withholding applies to both dataset families. The published file never gives a record's identifier, weight, amount or contribution. **Council tax reduction for Pension Credit recipients.** Since policyengine-uk 2.104.2 the pensioner schemes disregard the whole income of a guarantee credit recipient (SI 2012/2885, Schedule 1, paragraph 13, and the Welsh and Scottish equivalents), and use the Pension Credit assessment for a savings-credit-only award, as the law requires. A guarantee credit recipient's council tax reduction no longer moves with the State Pension. A test checks that for the example pensioner on the guarantee under both rules, council tax reduction and Housing Benefit do not move and the change in income is exactly the change in savings credit, which pays 60% of income above its threshold (and which policyengine-uk 2.118.0 and later pay the example once the basic State Pension passes the threshold, where 2.90.2 paid none). @@ -62,14 +62,14 @@ The guarantee's earnings duty ([SSAA 1992 s150A](https://www.legislation.gov.uk/ | `total_matched` (available follow-up) | represented | sum of the age/sex anchored targets after the anchor year | survey year's | - **Represented ages.** Records top-coded at 80 get a represented age from 80 to 105 (105+): within each sex a deterministic hash of the verified dataset content and synthetic ordering key orders them and weighted midpoints take the anchor year's ONS single-age shares, so the weighted 80+ total is unchanged. Other ages are the survey's. If any age in a dataset is above 80 it is not top-coded and every age is kept. Every record also gets a fixed birthday within its year of age (`months_since_last_birthday`), drawn as policyengine-uk draws it, on the data year's weights (upstream's own input), so every record that keeps its survey age keeps policyengine-uk's birthday and type; a run fails if one's data-year type changes. Represented ages use the anchor year's ONS shares and weights. Both are pinned in every year, and anything the model derived from the survey ages before is cleared (`demography._clear_derived`), so the model's own State Pension age (`is_SP_age`, by date of birth) and every reader of it in the engine (the State Pension age band, poverty, the age bands, coverage) see the represented ages. The survey's household and benefit-unit head flags are pinned too, so ties among represented ages cannot move a head. -- **Household weights.** In the anchor year and every year before it the weights are the dataset's own, exactly (`demography.annual_weights`): the rake never moves the calibrated weights and never runs back to the survey year. The anchor is the data build's calibration year, from its source (`datasets.ageing_anchor`: 2025 for the Enhanced FRS, `frs_release.py`; Microcosm has none, so its anchor is 2024-25, the first year the projection covers). After it, household weights are raked (bounded minimum relative entropy, ratios 0.2 to 5 of the anchor year's; a convex problem with one solution, which `demography.rake_households` finds by least squares on the calibration residual (the dual's gradient) and, where that stalls on a bound, by Newton or gradient steps on the dual) so that every sex and age cell (five-year bands to 59, single ages 60 to 79, 80-84, 85-89, 90+) grows from its anchor-year survey count as the ONS 2024-based UK principal projection's does ([ONS_PROJECTION.md](ONS_PROJECTION.md); fiscal year = 75% of mid-year *y* + 25% of *y*+1). Targets are survey counts times ONS growth, not ONS levels. Person and benefit-unit weights are their household's. A target the bounds cannot reach fails the run. A property test checks that, for any growth, the raked weights equal the dataset's own through the anchor year. +- **Household weights.** In the anchor year and every year before it the weights are the dataset's own, exactly (`demography.annual_weights`): the rake never moves the calibrated weights and never runs back to the survey year. The anchor is the data build's calibration year, from its source (`datasets.ageing_anchor`: 2025 for the Enhanced FRS, `frs_release.py`; for Microcosm, its 2024-25 data year, the first year the projection covers). Microcosm's builds date their calibration targets 2025 (`calibration_diagnostics.json`; `run_config.calibration_year`), so whether 2025 should also be its anchor is open before the binding rebuild. After it, household weights are raked (bounded minimum relative entropy, ratios 0.2 to 5 of the anchor year's; a convex problem with one solution, which `demography.rake_households` finds by least squares on the calibration residual (the dual's gradient) and, where that stalls on a bound, by Newton or gradient steps on the dual) so that every sex and age cell (five-year bands to 59, single ages 60 to 79, 80-84, 85-89, 90+) grows from its anchor-year survey count as the ONS 2024-based UK principal projection's does ([ONS_PROJECTION.md](ONS_PROJECTION.md); fiscal year = 75% of mid-year *y* + 25% of *y*+1). Targets are survey counts times ONS growth, not ONS levels. Person and benefit-unit weights are their household's. A target the bounds cannot reach fails the run. A property test checks that, for any growth, the raked weights equal the dataset's own through the anchor year. - **Cohort types.** Basic for men born before 6 April 1951 and women born before 6 April 1953 (State Pension age before 6 April 2016, by the Pensions Act 1995 Schedule 4 timetable: Pensions Act 2014 s.1(2)), new from those dates, none below State Pension age, from the birth date the represented age and birthday give at 6 October of each year. A test checks they equal policyengine-uk's own `state_pension_type` on the same ages and birthday in every year (on 2.120.0; part B's oracle tests passed on 2.118.0 too). **Inputs held the same under both rules** (`engine.pinned_inputs`): - **State Pension age.** The model's own: policyengine-uk sets it from each person's date of birth by the Pensions Act 1995 timetable, including the rise from 66 to 67 for people born from 6 April 1960 (#1899), and the engine reads it through `is_SP_age` and `state_pension_age`. Ages are held, so a record's date of birth moves a year later each year: 66-year-olds are partly over State Pension age in 2026-27 and 2027-28 and below it from 2028-29. Each run records the youngest age with anyone over it and the youngest from which everyone is (`fixed_inputs.state_pension_age`). - **Pension type**, by the treatment's rule (above). Each run reads the types back from the model in every year, fails if anyone's differs, and records the counts (`held_pension_type_records`, `held_pension_type_people`). - **The State Pension accounting, one rule under every treatment.** The reported State Pension counts only for people over State Pension age in the data year: none is payable before it (Pensions Act 2014 s.2(1)(a) and s.4(1)(a); SSCBA 1992 s.44(1); a survivor's inherited pension is their own from it, Pensions Act 2014 s.7(1)(a) and s.9(1)(a)). In the Enhanced FRS 40 records report State Pension below it, most of them within a year of it; two-fifths of them also report contributory ESA, which nobody over pensionable age can get, and two-thirds report earnings. They are reporting errors, and the model already paid them none; the data year's `state_pension_reported` is pinned without them (`demography.payable_reported`), and each run records how many there are and what they report (`state_pension_accounting`). In each year the counted report is split by that year's type at the type's data-year flat rate (`demography.pension_components`), as policyengine-uk's own formulas split it: the flat-rate part, which policyengine-uk scales by the flat rate, and the rest, the additional State Pension (SERPS, S2P and, on the new State Pension, protected payments, inherited amounts included), which the engine pins at its data-year amount grown by September CPI (published to April 2026, the path's after; never cut), for people over State Pension age that year. Historical policyengine-uk 2.120.0 and 2.125.1 scaled it by the flat rates' ratio (#1941). UK 2.147.2 includes #2336: upstream now uses September CPI from the data year. The engine retains its path-specific explicit input, which takes precedence over that formula; this is no longer described as a remaining correction to upstream law. Splitting the additional pension by each year's type is what keeps cohort types from paying anything twice: split by the survey year's type, a record moved from the basic to the new State Pension would be paid the band between the two flat rates inside its new State Pension and again as additional pension (part A measured £14.2bn in 2034-35 and £16.0bn in 2039-40). Every run checks, from the model, that in the data year basic + new + additional equals the counted report for every person (to £0.01) and nobody below State Pension age is paid, and that the additional pension is the pinned one under both rules in every year; tests check the same on a synthetic survey under every treatment, and that a 5% cut in the flat rates moves only the basic and new State Pension. -- **Pension Credit guarantee.** The standard minimum guarantee rises with May–July earnings (never cut), the minimum SSAA 1992 s150A requires; UK 2.147.2 still projects it by CPI because it lacks #2576. The engine's explicit earnings-linked override remains necessary and is the same under both pension rules. +- **Pension Credit guarantee.** The standard minimum guarantee rises with May–July earnings (never cut), the minimum SSAA 1992 s150A requires; UK 2.151.0 still projects it by lagged calendar CPI because it lacks #2576. The engine's explicit earnings-linked override remains necessary and is the same under both pension rules. **Age addition with a newer model.** When the installed model defines `state_pension_age_addition`, the engine takes the legislated addition out of the payable report before the flat-rate/additional split, matching `state_pension_reported_excluding_age_addition`: the report, age and BASIC type are the data year's, the legislated parameters come from the baseline when present, and the remainder is clamped at zero. At the reviewed #2340 head this is 25p a week from age 80, never uprated, using the model's annual 6 October age convention. policyengine-uk computes the separate addition for each simulated year. Only a positive data-year report below the addition needs an input override: the engine caps that year's addition at the payable report, because upstream's full £13 would otherwise exceed it; later years retain the model formula. Basic + new + additional + age addition then equals the payable report in the data year, and the age addition is the same under both rules, so its saving is zero. It appears separately as `state_pension_age_addition` in fiscal components, household calculations, breakdowns and coverage. With no such variable, including UK 2.125.1 and 2.147.2 (#2340 remains unmerged), the original three-part calculation and output schema apply. The reviewed names, pending-release audit and additional-pension input precedence are recorded in [REBUILD.md](REBUILD.md#pending-state-pension-patches). @@ -81,11 +81,11 @@ The revised [Pensions Act 2014 s.4](https://www.legislation.gov.uk/ukpga/2014/19 **Possible matched-total follow-up.** The implemented `total_matched` option uses the exact sum of the age/sex anchored targets as its single margin: Σ_c S_c × P_c(y)/P_c(anchor), where S_c is the anchor survey count and P_c the ONS cell population. It otherwise shares represented ages, birthdays, survey-year types, bounds and native weights through the anchor. It could compare the age/sex margins at a shared aggregate target, but needs its own full runs and source-bound readback checks. That supplement was dropped from part E's closeout as a scope cut; part E reports the ONS total-only control above. No matched-total estimate is published. -**The Enhanced FRS calibration year's level.** This is a sensitivity/bridge caveat; Microcosm has no calibration year and anchors at 2024. The Enhanced FRS builder calibrates its weights in 2025 and saves them to 2024 with its own weight index (2024: 1.027, 2025: 1.039); policyengine-uk uprates them back to 2025 by its population growth (0.72%). In a simulation the 2025 weights are therefore the calibrated ones times 0.9956: relative weights exactly the calibrated ones, every 2025 total 0.44% low ([policyengine-uk-data#538](https://github.com/PolicyEngine/policyengine-uk-data/issues/538)). Static ageing keeps the simulation's own 2025 weights, so it neither adds to the gap nor closes it; the fix belongs to the data build or the model. +**The Enhanced FRS calibration year's level.** This is a sensitivity/bridge caveat; Microcosm anchors at its 2024 data year. The Enhanced FRS builder calibrates its weights in 2025 and saves them to 2024 with its own weight index (2024: 1.027, 2025: 1.039); policyengine-uk uprates them back to 2025 by its population growth (0.72%). In a simulation the 2025 weights are therefore the calibrated ones times 0.9956: relative weights exactly the calibrated ones, every 2025 total 0.44% low ([policyengine-uk-data#538](https://github.com/PolicyEngine/policyengine-uk-data/issues/538)). Static ageing keeps the simulation's own 2025 weights, so it neither adds to the gap nor closes it; the fix belongs to the data build or the model. **Private cache.** The population (represented ages, birthdays, the weights and the State Pension age mask and types of every year) is computed once per dataset, treatment, anchor, set of years and model (`demography.eligibility_fingerprint`: the installed policyengine-uk and the files its State Pension age and type come from) and cached under `.cache/demography`, owner-only; every path job shares one entry, and the coverage and history jobs, with other years, have their own. A run reads the model's own `is_SP_age` back against the cached mask in every year and fails if they differ, so a change to the model's State Pension age cannot reuse stale types. Nothing that depends on a path is cached: the additional pension follows each path's September CPI. No record id, weight or amount leaves the cache; an ageing run returns no single-record diagnostic, and a failed path, coverage or history job keeps its error output in an owner-only file in an owner-only worker directory instead of the build log (`jobs.private_inputs`). -**Programme limits.** The new State Pension has no age addition, but the basic State Pension has one, 25p a week at 80 (SSCBA 1992 s.79 and Sch 4 Pt III para 8), which both the historical UK 2.125.1 bundle and the binding UK 2.147.2 candidate do not model ([policyengine-uk#2139](https://github.com/PolicyEngine/policyengine-uk/issues/2139)): in that bundle a reported one is in the additional pension. The version-aware handling above applies when a later bundle includes #2340. UK 2.147.2's Winter Fuel Payment and Scotland's Pension Age Winter Heating Payment (`pawhp`, in `other_spending`) use the shared winter-heating amount helper with jurisdiction-specific eligibility and amounts. The Scotland formula distinguishes qualifying-benefit couples from non-benefit households and their shared amounts; the old £100-per-household limitation does not describe this wheel. Later retirees' amounts come from today's records of the same age; deaths, migration and contribution histories are not simulated, and pensioners abroad are outside the survey. A dynamic projection is the longer-term route (PolicyEngine/microcosm-dynamics#501). +**Programme limits.** The new State Pension has no age addition, but the basic State Pension has one, 25p a week at 80 (SSCBA 1992 s.79 and Sch 4 Pt III para 8), which the historical UK 2.125.1 bundle, the binding UK 2.151.0 candidate and the 2.147.2 fallback do not model ([policyengine-uk#2139](https://github.com/PolicyEngine/policyengine-uk/issues/2139)): in that bundle a reported one is in the additional pension. The version-aware handling above applies when a later bundle includes #2340. UK 2.151.0's Winter Fuel Payment and Scotland's Pension Age Winter Heating Payment (`pawhp`, in `other_spending`) use the shared winter-heating amount helper, unchanged from 2.147.2, with jurisdiction-specific eligibility and amounts. The Scotland formula distinguishes qualifying-benefit couples from non-benefit households and their shared amounts; the old £100-per-household limitation does not describe this wheel. On 2.151.0 the payment's amounts rise each April by `gov.benefit_uprating_cpi`, rounded to the nearest 5p (#2575). Later retirees' amounts come from today's records of the same age; deaths, migration and contribution histories are not simulated, and pensioners abroad are outside the survey. A dynamic projection is the longer-term route (PolicyEngine/microcosm-dynamics#501). **Outputs of each run, by year:** - gross saving (basic and new State Pension spending) and net saving (change in `gov_balance`), with the components, for the UK and for Great Britain (households in England, Scotland and Wales, as DWP's figures are; neither dataset has a household of unknown country); @@ -173,13 +173,13 @@ A scenario is one full run of the central path with its specified rates, nothing **Coverage.** One coverage job per dataset runs the central path under the triple lock, as the central run's triple-lock policy does (the same population treatment), for 2026-27 to 2030-31, 2034-35 and 2039-40. It gives each year's State Pension (by part, recipients and types, and in Great Britain by age, where every cell rests on at least ten records), Pension Credit (guarantee and savings credit, and claims), Housing Benefit and pension-age Housing Benefit (spending and claims), council tax reduction and Universal Credit, for the UK and for Great Britain. The results set Great Britain against DWP, like for like, in every year DWP's tables give. Pension-age Housing Benefit is Housing Benefit paid under the pension-age regulations (`housing_benefit_pension_age_regulations_apply`), nearest DWP's "over Pension Credit qualifying age". 2034-35 and 2039-40 have no DWP figure. -The population here is the path runs': static ageing, ONS growth after the dataset's anchor year and cohort pension types. Microcosm anchors at 2024 because it has no calibration year; Enhanced FRS anchors at 2025. Ages above 80 demonstrate that a dataset is not top-coded there and are retained. The deterministic synthetic ordering key matches the model's 64-bit-id to 32-bit wrapping without publishing identifiers. The State Pension age follows the law's timetable by date of birth, so every age of 67 and over is above it from 2028-29. DWP's figures come from its own projection of the population and of contribution records; matched by-type, by-age and post-2030 benchmarks are unavailable (part B's [AGEING_PILOT.md](AGEING_PILOT.md) lists them). +The population here is the path runs': static ageing, ONS growth after the dataset's anchor year and cohort pension types. Microcosm anchors at its 2024 data year; Enhanced FRS anchors at its 2025 calibration year. Ages above 80 demonstrate that a dataset is not top-coded there and are retained. The deterministic synthetic ordering key matches the model's 64-bit-id to 32-bit wrapping without publishing identifiers. The State Pension age follows the law's timetable by date of birth, so every age of 67 and over is above it from 2028-29. DWP's figures come from its own projection of the population and of contribution records; matched by-type, by-age and post-2030 benchmarks are unavailable (part B's [AGEING_PILOT.md](AGEING_PILOT.md) lists them). ## The certified model and the historical pilot (`datasets.py`) -For the binding rebuild, the primary registry entry is `microcosm_uk_2024_25@a133f37a`, national only. Its reviewed [#715](https://github.com/PolicyEngine/policyengine.py/pull/715) manifest head is `e02d41bceab57756373b2dc3bcf78635b856ec81`. It pins `policyengine/populace-uk-private`, revision `microcosm-uk-2024-25-national-20261023T075039Z-a133f37a`, path `microcosm_uk_2024_25.h5`, SHA-256 `64f83900ca3a1242fb017585234844ce89f743d32e41cd2b6558057777f914aa`. The model wheel is UK 2.147.2, SHA-256 `4b48ab1008b00610abc38192927ac9d4bb22037b6bbd9578926c2899e639f972`, with core 3.33.3. Its immutable [data release manifest](https://huggingface.co/datasets/policyengine/populace-uk-private/resolve/microcosm-uk-2024-25-national-20261023T075039Z-a133f37a/releases/microcosm-uk-2024-25-national/release_manifest.json) and `src/policyengine/data/bundle/manifest.json` in the future released carrier establish the pair. The released carrier version and manifest digest remain unset, so `datasets.provenance` and the publication preflight refuse certification now. A draft head whose manifest still says 6.10.1 does not re-certify the already published 6.10.1 wheel. +For the binding rebuild, the primary registry entry is `microcosm_uk_2024_25@a6299504`. It pins `policyengine/populace-uk-private`, expected revision `microcosm-uk-2024-25-national-20261025T091414Z-a6299504`, path `microcosm_uk_2024_25.h5`, SHA-256 `b96a8cd625b645a409c063b1de5158532708a2f0d3c3897a794a8fb0bbd5064e`: the bytes of the staged attempt `uk-frs-calibration-attempt-20261025T091414Z-a6299504`, built at microcosm `67ee2ddf3`. a133f37a's attempt was released under the matching tag with the same bytes; the tag is confirmed at release, and until it exists the download fails closed. The model wheel is UK 2.151.0, SHA-256 `5722f55aca3eb88e1a228e95e1a57e3bd70a4d530a5a000991565ebd8955ce35` (microcosm#1396's `uv.lock`; attested in `data/certification/d2011-model-wheel.json`), with core 3.33.3. The pair's released carrier also routes constituency and local-authority runs to its local-area file, and the registry requires exactly that routing. The released carrier version, manifest digest, certifying pull request and local-area file remain unset, so `datasets.provenance` and the publication preflight refuse certification now. #715's `microcosm_uk_2024_25@a133f37a` / UK 2.147.2 entry, national only, stays registered with its own pending identity as d2011's fallback. -The lock installs base `policyengine==6.10.1` as the available API carrier, plus exact `policyengine-uk==2.147.2` and `policyengine-core==3.33.3`; it deliberately omits `[uk]`, because the currently released carrier's extra pins its previous UK line. The engine constructs `policyengine_uk` directly and materializes its own registry-pinned dataset rather than asking that extra to select the data. This is a preparatory environment, not released-pair certification. Once #715 ships, update only the authenticated carrier version and bundle-manifest digest, including the carrier version in the lock and ready bridge config, after checking the head has not changed. +The lock installs base `policyengine==6.10.1` as the available API carrier, plus exact `policyengine-uk==2.151.0` and `policyengine-core==3.33.3`; it deliberately omits `[uk]`, because the currently released carrier's extra pins its previous UK line. The engine constructs `policyengine_uk` directly and materializes its own registry-pinned dataset rather than asking that extra to select the data. This is a preparatory environment, not released-pair certification. Once the pair ships, fill only the authenticated identities left unset in `datasets.py`, and the carrier version in the lock and ready bridge config. The paired sensitivity is Enhanced FRS 1.58.2: d1727's salary-sacrifice/uprating-table fixes and preserved household ids through cloning. Enhanced FRS 1.58.0 remains for bridge step (a). The pre-Budget release's `policyengine[uk]==6.2.6` / UK 2.125.1 / core 3.33.0 / Enhanced FRS 1.58.0 identity and historical receipt bytes remain as published; [J-certification](pilot/J-certification.md) retains their hashes. The [2.125.1-to-2.147.2 source audit](d1983-upstream.md) records the reviewed upstream guards. #2019, #2507–2509, #1913, #2336, #2458, #1831, #2518 and #2516 are present; #2557 and #2576 are absent, and #2340 remains unmerged. The behavioural HB passport preflight remains mandatory. #36's separate [2.153.4 audit](d1727-upstream.md) and fail-closed qualification-ledger implementation remain history; they do not authorize this primary. The remainder of this section records the pilot's version history, rather than the current bundle. diff --git a/docs/REBUILD.md b/docs/REBUILD.md index e8c4790f..e0455760 100644 --- a/docs/REBUILD.md +++ b/docs/REBUILD.md @@ -6,36 +6,39 @@ The pre-Budget release is committed on **main**: policyengine.py 6.2.6 / UK 2.12 | Gate | What it holds back | Until then | |---|---|---| -| **d1983 = B: policyengine.py #715** | The exact released carrier manifest for national Microcosm cut `microcosm-uk-2024-25-national-20261023T075039Z-a133f37a`, UK 2.147.2 / core 3.33.3 | #715 remains a draft stacked on #704. Its attribution is scheduled for 24 October 22:00Z–25 October 10:00Z, then review. Preflight fails closed while carrier version and released-manifest digest are unset. | +| **d2011 = A: the 2.151.0 pair (microcosm#1396)** | The exact released carrier manifest for national Microcosm cut `microcosm-uk-2024-25-national-20261025T091414Z-a6299504` and its local-area cut, UK 2.151.0 / core 3.33.3 | The release is expected around 23:00Z on Monday 26 October. If the pair is not certified by Tuesday 27 October 18:00Z, d2011 falls back to #715's `a133f37a` / UK 2.147.2 pair. Preflight fails closed while the carrier version, released-manifest digest, certifying pull request and local-area file are unset. | | **Binding inputs**: AWE on 20 October, September CPI on 21 October, OBR forecast on 28 October | Commit official source bytes and release identities before a fresh binding C2 handoff | A pre-Budget/dry-run file never authorizes the binding build. | | **d955 = (c)** | Only a passing original primary may get a model-conditional expected value, beside the scenario envelope | C2's frozen rule remains unchanged; primary failure automatically takes (a), scenarios only. No alternative form is promoted. | -## Pending #715 bundle and two-step bridge +## Pending d2011 bundle and two-step bridge -The reviewed [#715 head](https://github.com/PolicyEngine/policyengine.py/pull/715) is `e02d41bceab57756373b2dc3bcf78635b856ec81`. Check that head again before completing the pins; if its cut/model changes, re-read the new manifest and audit, and if it closes, stop. Its exact identities are: +Max ruled **d2011 = A** on 25 October: the binding primary is the certified 2.151.0 national and local-area pair built at [microcosm#1396](https://github.com/PolicyEngine/microcosm/pull/1396) (`67ee2ddf3`). d1983 = B's reason (Microcosm, clean data) holds for it. Its identities, known before the release: | Identity | Pin | |---|---| -| UK model/core | `policyengine-uk==2.147.2`, `policyengine-core==3.33.3` | -| UK wheel SHA-256 | `4b48ab1008b00610abc38192927ac9d4bb22037b6bbd9578926c2899e639f972` | -| Dataset registry name | `microcosm_uk_2024_25@a133f37a`, national only | -| HF repository / revision / path | `policyengine/populace-uk-private` / `microcosm-uk-2024-25-national-20261023T075039Z-a133f37a` / `microcosm_uk_2024_25.h5` | -| Dataset SHA-256 | `64f83900ca3a1242fb017585234844ce89f743d32e41cd2b6558057777f914aa` | -| Data release manifest | `https://huggingface.co/datasets/policyengine/populace-uk-private/resolve/microcosm-uk-2024-25-national-20261023T075039Z-a133f37a/releases/microcosm-uk-2024-25-national/release_manifest.json` | -| Source manifest URI | `hf://dataset/policyengine/populace-uk-private@microcosm-uk-2024-25-national-20261023T075039Z-a133f37a/releases/microcosm-uk-2024-25-national/release_manifest.json` | -| Released carrier manifest | Future `policyengine` release's `src/policyengine/data/bundle/manifest.json`; carrier version and digest intentionally unset | - -The draft manifest still labels itself 6.10.1, but the already released 6.10.1 wheel carries the previous UK cut and cannot certify this one. The preparatory lock uses the base carrier without `[uk]`, plus explicit UK 2.147.2/core 3.33.3. The engine constructs `policyengine_uk` directly and loads its own verified artifact; the released carrier's `[uk]` extra would impose the previous model. Publication preflight remains closed until the exact #715 release is authenticated. d1727's Enhanced FRS qualification-ledger route is superseded as the primary; its fail-closed code, [status receipt](../data/certification/d1727-qualification-status.json) and [2.153.4 audit](d1727-upstream.md) stay as history. - -1. After #715 is reviewed and released, set `datasets.BINDING_BUNDLE_VERSION` and `datasets.BINDING_BUNDLE_MANIFEST_SHA256` from the future released carrier, and the carrier version in both dependency files. Do not change the model/core/data pins merely to fit a carrier. Confirm that the released manifest exactly matches every identity above. -2. Keep the current [2.125.1-to-2.147.2 source audit](d1983-upstream.md), `check_upstream()` and the parameter/small-household checks passing. UK 2.147.2 includes #2019, #2507–2509, #1913, #2336, #2458, #1831, #2518 and #2516. It lacks #2557 (September-CPI benefit uprating, first 2.148.2) and #2576 (earnings-linked guarantee/derived HB and Savings Credit, first 2.151.2); #2340 remains unmerged. Additional pension still takes the engine's path-specific explicit CPI pin. Age-addition handling takes the absent-variable branch. - - **If d2011 certifies the 2.151.0 pair instead** (María's national cut `a6299504`, microcosm#1396), its [2.147.2-to-2.151.0 source audit](d2011-upstream.md) applies, and `check_upstream()` already accepts 2.151.0 exactly. That wheel adds #2557, so benefit uprating follows September CPI and `path_following` takes its September branch. It still lacks #2576 and #2340, so d1753's installed projections and the absent-variable age-addition branch are unchanged. The model pin, `datasets.py`, the bridge configuration's endpoints and the release wording then move to the certified release in one commit, and steps 4–5 run on it: (a) UK 2.125.1 → 2.151.0, then (b) Enhanced FRS 1.58.0 → Microcosm a6299504. +| UK model/core | `policyengine-uk==2.151.0`, `policyengine-core==3.33.3` (microcosm#1396's `uv.lock`) | +| UK wheel SHA-256 | `5722f55aca3eb88e1a228e95e1a57e3bd70a4d530a5a000991565ebd8955ce35` (`data/certification/d2011-model-wheel.json`) | +| Dataset registry name | `microcosm_uk_2024_25@a6299504` | +| HF repository / revision / path | `policyengine/populace-uk-private` / expected `microcosm-uk-2024-25-national-20261025T091414Z-a6299504` / `microcosm_uk_2024_25.h5` | +| Dataset SHA-256 | `b96a8cd625b645a409c063b1de5158532708a2f0d3c3897a794a8fb0bbd5064e`, the staged attempt `uk-frs-calibration-attempt-20261025T091414Z-a6299504` | +| Data release manifest | `releases/microcosm-uk-2024-25-national/release_manifest.json` at that revision (`datasets.MICROCOSM_RELEASE_MANIFEST`) | +| Region routing | national to the national file; constituency and local authority to the local-area file | +| Unset until the release | carrier version and manifest digest, certifying pull request and commit, local-area file (`datasets.BINDING_*`) | + +a133f37a's staged attempt was released under the tag with its timestamp and suffix, with the same bytes, so the revision and SHA-256 above are the expected ones; confirm both against the release. The preparatory lock uses the base carrier without `[uk]`, plus explicit UK 2.151.0/core 3.33.3. The engine constructs `policyengine_uk` directly and loads its own verified artifact. Publication preflight remains closed until the exact release is authenticated. d1727's Enhanced FRS qualification-ledger route is superseded as the primary; its fail-closed code, [status receipt](../data/certification/d1727-qualification-status.json) and [2.153.4 audit](d1727-upstream.md) stay as history. + +**Fallback.** #715's `microcosm_uk_2024_25@a133f37a` / UK 2.147.2 pair (`datasets.BUNDLE_715`, national only, reviewed head `e02d41bceab57756373b2dc3bcf78635b856ec81`) stays registered with its own pending identity. If d2011 falls back, `config.PRIMARY_DATASET`, the preflight's primary, the model pin and the bridge endpoints move back to it in one commit, and its [2.125.1-to-2.147.2 audit](d1983-upstream.md) applies. + +1. After the pair is released, set `datasets.BINDING_BUNDLE_VERSION`, `BINDING_BUNDLE_MANIFEST_SHA256`, `BINDING_CERTIFICATION_URL`, `BINDING_CERTIFICATION_COMMIT` and `BINDING_LOCAL_AREA_PATH` from it, and the carrier version in both dependency files. Do not change the model/core/data pins merely to fit a carrier. Confirm that the released manifest exactly matches every identity above, the revision included. +2. Keep the [2.147.2-to-2.151.0 source audit](d2011-upstream.md) (on top of the [2.125.1-to-2.147.2 audit](d1983-upstream.md)), `check_upstream()` and the parameter/small-household checks passing. UK 2.151.0 adds #2557: benefit uprating follows the previous September's CPI and `path_following` takes its September branch. It lacks #2576 (earnings-linked guarantee/derived HB and Savings Credit, first 2.151.2), and #2340 remains unmerged. Additional pension still takes the engine's path-specific explicit CPI pin. Age-addition handling takes the absent-variable branch. 3. Run certification and the behavioural HB receipt-passport preflight below before survey jobs. Synthetic checks never certify the fiscal result. -4. Copy `data/binding-bridge.pending.json` to `data/binding-bridge.json`. Fill the released carrier version in the three new endpoint package maps and `pending_release.policyengine_version`, fill `pending_release.bundle_manifest_sha256`, set status `ready`, and commit it. Keep historical baseline pins/SHA, UK/core pins and both artifact hashes fixed. Pending `--validate-only` honestly refuses #715 without launching jobs. -5. Run **(a), model step**: 6.2.6 / UK 2.125.1 / core 3.33.0 → UK 2.147.2 / core 3.33.3, holding **Enhanced FRS 1.58.0** fixed. The new intermediate pair is uncertified. Run **(b), data step**: **Enhanced FRS 1.58.0 → Microcosm a133f37a**, holding the same installed packages and engine sources fixed. Only that final endpoint must have released certification. Each endpoint runs the complete central/coverage/frozen forty-path design. Data/from must reuse exactly model/to's receipt; no dataset-family equality is assumed. +4. Copy `data/binding-bridge.pending.json` to `data/binding-bridge.json`. Fill the released carrier version in the three new endpoint package maps and `pending_release.policyengine_version`, fill `pending_release.pull_request`, `reviewed_head` and `bundle_manifest_sha256`, set status `ready`, and commit it. Keep historical baseline pins/SHA, UK/core pins and both artifact hashes fixed. Pending `--validate-only` honestly refuses without launching jobs. +5. Run **(a), model step**: 6.2.6 / UK 2.125.1 / core 3.33.0 → UK 2.151.0 / core 3.33.3, holding **Enhanced FRS 1.58.0** fixed. The new intermediate pair is uncertified. Run **(b), data step**: **Enhanced FRS 1.58.0 → Microcosm a6299504**, holding the same installed packages and engine sources fixed. Only that final endpoint must have released certification. Each endpoint runs the complete central/coverage/frozen forty-path design. Data/from must reuse exactly model/to's receipt; no dataset-family equality is assumed. 6. Review fresh accounting and GB/DWP coverage before feeding a regenerated binding C2 handoff to the full fiscal build. The primary/sensitivity config change alters its input hash: regenerate it through C2, and preserve every historical handoff/receipt byte. -**d1753 on this bundle.** UK 2.147.2 has neither Savings Credit `maximum` nor either linked projection builder. Tests also cover a `maximum`-present/builders-absent system defensively. Keep its installed **CPI** HB allowances and Savings Credit thresholds as headline. Their `gov.benefit_uprating_cpi` parameter names `gov.economic_assumptions.indices.obr.lagged_cpi` in its uprating metadata, so it follows lagged calendar CPI; #2557's September-CPI construction is absent. The engine still applies the statutory earnings-linked guarantee. Gate linked derivation on the actual builders, not merely on `maximum`. The separate foreground comparison below runs the installed-CPI and explicitly guarantee-linked conventions as complete central triple-lock/Burnham pairs; it does not change the default. +**Open before the binding rebuild: Microcosm's anchor year.** The registry anchors both Microcosm cuts' static ageing at their 2024-25 data year. Their builds date every calibration target 2025 (`calibration_diagnostics.json`; `run_config.calibration_year`: 1,259 targets for a133f37a and 1,268 for a6299504), and a133f37a's release build manifest records `household_weight_year` `{dataset_year: 2024, basis: dataset_year}`. Under the registry's own rule (the Enhanced FRS and Microcosm 5c6b3f68 entries), a build's calibration year is its anchor. Whether Microcosm's calibrated weights are its 2024 or its model-uprated 2025 weights decides which year it should be. This must be resolved, from microcosm's calibration code, before the 29 October rebuild. + +**d1753 on this bundle.** UK 2.151.0 has neither Savings Credit `maximum` nor either linked projection builder. Tests also cover a `maximum`-present/builders-absent system defensively. Keep its installed **CPI** HB allowances and Savings Credit thresholds as headline. Since #2557 moved `gov.benefit_uprating_cpi` to September CPI, those amounts name `gov.economic_assumptions.indices.obr.lagged_cpi` themselves, so they keep the lagged calendar-CPI projection they had on 2.147.2. The engine still applies the statutory earnings-linked guarantee. Gate linked derivation on the actual builders, not merely on `maximum`. The separate foreground comparison below runs the installed-CPI and explicitly guarantee-linked conventions as complete central triple-lock/Burnham pairs; it does not change the default. ```sh # Main session foreground only, after authenticating and installing the released lock. @@ -53,12 +56,12 @@ export OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 NUMEXPR_NUM_TH # raise it for each Microcosm run (one worker, ~31.8 GB historical peak, plus the coordinator). # (a) Model step on Enhanced FRS 1.58.0; intermediate remains uncertified. HEAVY_CALLER=uk-triple-lock .venv/bin/python scripts/resume_after_host_refusal.py \ - --log .cache/resume/d1983-bridge-model.jsonl -- ~/reviews/us-hub/scripts/heavy_run.sh d1983-bridge-model \ + --log .cache/resume/d2011-bridge-model.jsonl -- ~/reviews/us-hub/scripts/heavy_run.sh d2011-bridge-model \ .venv/bin/python scripts/run_model_v2_j_bridge.py --config data/binding-bridge.json --step model \ --from-receipt data/pilot/J-bridge-certified.json --workers 1 --out data/pilot/binding-bridge-model.json -# (b) Cross-family data step on exactly the same UK2.147.2/core3.33.3 environment. +# (b) Cross-family data step on exactly the same UK2.151.0/core3.33.3 environment. HEAVY_CALLER=uk-triple-lock HEAVY_CAP_MB=49152 .venv/bin/python scripts/resume_after_host_refusal.py \ - --log .cache/resume/d1983-bridge-data.jsonl -- ~/reviews/us-hub/scripts/heavy_run.sh d1983-bridge-data \ + --log .cache/resume/d2011-bridge-data.jsonl -- ~/reviews/us-hub/scripts/heavy_run.sh d2011-bridge-data \ .venv/bin/python scripts/run_model_v2_j_bridge.py --config data/binding-bridge.json --step data \ --from-receipt data/pilot/binding-bridge-model.json --workers 1 --out data/pilot/binding-bridge-data.json # Send Fleet ops the data step's measured memory: per batch, timings[].memory_observed. @@ -101,7 +104,7 @@ PY_CHECK -The current State Pension guard audit is [d1983-upstream](d1983-upstream.md). #36's parameter-consistency and age-addition preparation remains documented in [d1727-upstream](d1727-upstream.md) and its retained receipts; its 2.153.4 linked-projection findings do not describe 2.147.2. +The current State Pension guard audit is [d2011-upstream](d2011-upstream.md), on top of [d1983-upstream](d1983-upstream.md). #36's parameter-consistency and age-addition preparation remains documented in [d1727-upstream](d1727-upstream.md) and its retained receipts; its 2.153.4 linked-projection findings do not describe 2.147.2 or 2.151.0. ## Inputs to update first @@ -124,11 +127,11 @@ Each is a commit before the build. The build refuses a dirty tree, and every inp - The adapter retains **(b)** as an explicit tested input for the original primary with its frozen-screen failure retained. It is not Max's selected rebuild route. A run on today's inputs is explicitly a **dry run**, not the binding score and not permission for a fiscal expected value. The adapter consumes the original-primary 160-slot design (161 unique full runs including its identical-rates check on the committed inputs). Replacement sampling can repeat indices after an input update; use the handoff's saved distinct counts. A passing binding score adds the exact frozen 40-slot paired subsample on Enhanced FRS 1.58.2; only the dataset role changes. The original-primary ±0.5-point mean paths run independently of adequacy, with the same primary indices, innovations and zero-stratum mass; their extra baseline check may save nonzero. The scenario envelope carries the central path, OBR wedge, recorded historical last-decade replay (April 2017–2026, with its own model years) and those paired mean paths under either outcome. The historical replay retains its period label and is not treated as a future-horizon simulation or included in a common future-year range. -4. **Versions and dataset**: complete the pending #715 release and two-step bridge above. The installed packages, primary artifact and released certification must match exactly. Run the final coverage and accounting checks before the binding run. The behavioural Housing Benefit Guarantee Credit passport must still pass: the non-claimant faces the ordinary means test, the claimant receives maximum HB, and reversed receipt flags exercise all three branches. Every publication entry point runs this guard before fiscal jobs. +4. **Versions and dataset**: complete the pending d2011 release and two-step bridge above. The installed packages, primary artifact and released certification must match exactly. Run the final coverage and accounting checks before the binding run. The behavioural Housing Benefit Guarantee Credit passport must still pass: the non-claimant faces the ordinary means test, the claimant receives maximum HB, and reversed receipt flags exercise all three branches. Every publication entry point runs this guard before fiscal jobs. ## Commands -Start from `main`, with the newly released #715 carrier lock and bridge changes reviewed on the rebuild branch. The preparatory lock pins its UK 2.147.2/core 3.33.3 candidate and remains uncertified until that release exists: +Start from `main`, with the newly released d2011 carrier lock and bridge changes reviewed on the rebuild branch. The preparatory lock pins its UK 2.151.0/core 3.33.3 candidate and remains uncertified until that release exists: Set `HEAVY_CALLER=uk-triple-lock` before every command run through `heavy_run.sh`, `lockrun.sh` or `heavy_queue.zsh`. The caller identity gives this repository access to the Fleet ops reservation [below](#part-j-pre-budget-rehearsal-and-post-budget-sequence). @@ -137,7 +140,7 @@ git fetch origin main git switch -c rebuild/budget-2026 origin/main # Complete the pending-bundle and input-update sections above on this branch. # Commit each step and open a draft PR into main at the first commit. -# The following install uses the newly authenticated #715 lock. +# The following install uses the newly authenticated d2011 lock. uv venv .venv --python 3.13 uv pip install --python .venv/bin/python -r requirements-lock.txt uv pip install --python .venv/bin/python --no-deps -e . @@ -151,7 +154,7 @@ python -c "import psutil; m = psutil.virtual_memory(); print(m.available / 2**30 python -m pytest -q # only the documented stale-results assertions may fail (below) # 2. After 28 October, with the binding macro inputs committed and the -# released #715 bundle installed, both bridge steps and coverage reviewed. +# released d2011 bundle installed, both bridge steps and coverage reviewed. # Check #1927's receipt passport before scoring; real builds also run this guard. python -c 'from triple_lock.pipeline import housing_benefit_passport_preflight; housing_benefit_passport_preflight()' python -m triple_lock.ts_uncertainty --screen c2 --binding --output out/uncertainty-c2 @@ -201,7 +204,7 @@ Step 3 reads its paired draws from the file it is pointed at (`data/results.json ## Interim pre-Budget release (before the binding rebuild) -This section describes the historical certified pre-Budget release, reproduced only at its original source and lock. Its 6.2.6 / 1.58.0 Enhanced FRS primary and earlier Microcosm sensitivity remain as published under Max's recorded ruling (c). The current binding source selects different datasets and must use the #715 sequence above. Its expected value is **provisional and model-conditional**: the original primary passes the registered C2 dry run on current inputs (4 of 6 terminal years, 80% annual coverage), with the full paired Microcosm cross-check. The binding test follows the Budget inputs; failure removes the expected value. A scenarios-only interim release may use (a). The obsolete public pre-Budget (b) route is refused. +This section describes the historical certified pre-Budget release, reproduced only at its original source and lock. Its 6.2.6 / 1.58.0 Enhanced FRS primary and earlier Microcosm sensitivity remain as published under Max's recorded ruling (c). The current binding source selects different datasets and must use the d2011 sequence above. Its expected value is **provisional and model-conditional**: the original primary passes the registered C2 dry run on current inputs (4 of 6 terminal years, 80% annual coverage), with the full paired Microcosm cross-check. The binding test follows the Budget inputs; failure removes the expected value. A scenarios-only interim release may use (a). The obsolete public pre-Budget (b) route is refused. ```sh # From a clean checkout of the reviewed head, venv as above, Enhanced FRS 1.58.0 and Microcosm in .cache/datasets. @@ -245,7 +248,7 @@ For updated inputs let `U` be original-primary `unique_full_runs` (including the **Runtime at one Microcosm worker.** The only cold Microcosm path timings are the 12 October rehearsal's two jobs (556 s and 672 s, mean 614 s), on an earlier cut and model. At that rate, one worker runs the build's 494 Microcosm jobs in about **84 hours**, and the validation's 211 after a pass in about **36 hours** more. That is an estimate from two historical jobs, not a measurement on a133f37a. The binding data step runs 42 Microcosm jobs at one worker and records each batch's time and memory: use them to re-estimate, and to decide with Fleet ops whether a second Microcosm worker fits (two workers need 80 GiB available at a batch start, and each slot refill 40 GiB beyond what the other running job may still allocate of its 40 GiB). -**Capacity estimate for Fleet ops, derived without survey jobs.** The old reservation assumed 3 Enhanced FRS plus 2 Microcosm workers. Keeping role caps of 3 primary plus 2 sensitivity would now mean **3 Microcosm plus 2 Enhanced FRS** workers. Across concurrent work, filling all five reserved slots at the historical per-worker peaks (31.8 GB and 6 GB) gives **107.4 GB**, versus the old **81.6 GB**: **25.8 GB / 31.6% more**. These are reservation-wide sums. The frozen `expected_value.build` schedules primary and sensitivity phases sequentially: its three-primary/two-sensitivity worker estimate is `max(3 × 31.8, 2 × 6)` = **95.4 GB**, excluding coordinator/other-process memory. At the documented one-primary/one-sensitivity caps, that phase maximum is **31.8 GB**. These are historical-peak extrapolations, not measurements of a133f37a/2.147.2; the fresh-cut peak and net-support-array cost are unmeasured. Code admission requires **120 GiB available** for three concurrent cold Microcosm jobs (40 GiB each), compared with 80 GiB for two. Start the main session at **`--workers 1 --sensitivity-workers 1`**, with a 40 GiB admission floor, and have Fleet ops review capacity before increasing it. The bridge likewise caps Microcosm endpoints at two, uses Microcosm slots and scales its before-batch floor to 40 GiB per concurrent Microcosm job; each slot keeps 40 GiB for the one job it admits beyond what the batch's other admitted Microcosm jobs may still allocate, and its monitor keeps 8 GiB while the batch runs ([Host admission](#host-admission-memory-floors-and-the-load-limit)). +**Capacity estimate for Fleet ops, derived without survey jobs.** The old reservation assumed 3 Enhanced FRS plus 2 Microcosm workers. Keeping role caps of 3 primary plus 2 sensitivity would now mean **3 Microcosm plus 2 Enhanced FRS** workers. Across concurrent work, filling all five reserved slots at the historical per-worker peaks (31.8 GB and 6 GB) gives **107.4 GB**, versus the old **81.6 GB**: **25.8 GB / 31.6% more**. These are reservation-wide sums. The frozen `expected_value.build` schedules primary and sensitivity phases sequentially: its three-primary/two-sensitivity worker estimate is `max(3 × 31.8, 2 × 6)` = **95.4 GB**, excluding coordinator/other-process memory. At the documented one-primary/one-sensitivity caps, that phase maximum is **31.8 GB**. These are historical-peak extrapolations, not measurements of a6299504/2.151.0; the fresh-cut peak and net-support-array cost are unmeasured. Code admission requires **120 GiB available** for three concurrent cold Microcosm jobs (40 GiB each), compared with 80 GiB for two. Start the main session at **`--workers 1 --sensitivity-workers 1`**, with a 40 GiB admission floor, and have Fleet ops review capacity before increasing it. The bridge likewise caps Microcosm endpoints at two, uses Microcosm slots and scales its before-batch floor to 40 GiB per concurrent Microcosm job; each slot keeps 40 GiB for the one job it admits beyond what the batch's other admitted Microcosm jobs may still allocate, and its monitor keeps 8 GiB while the batch runs ([Host admission](#host-admission-memory-floors-and-the-load-limit)). The historical rehearsal timings at the end of this runbook used the opposite data roles and an earlier cut; they do not forecast the binding runtime. No current survey or Microcosm simulation was run for this preparation. @@ -307,7 +310,7 @@ The bridge also measures memory while each batch runs, in `timings[].memory_obse - `peak_job_footprint_gib`, the largest kernel lifetime footprint peak (`phys_footprint_peak`) of any job process, as of the last sample; - `peak_finished_job_rss_gib`, the kernel's high-water resident memory of the largest job process finished so far in the run (`getrusage`). -Resident memory excludes compressed and swapped pages, so under memory pressure it understates a job's footprint; the footprint fields count them (they are null off macOS). A slot credits each running job with its resident memory. For compressed and swapped pages that errs safe: a smaller reading leaves a larger reservation. For pages a job shares with other processes it does not: they count as held, though copying them later adds physical memory that resident size does not show. On 25 October a process importing the locked model and its libraries measured 0.252 GiB resident against a 0.225 GiB footprint; the binding data step records both measures for a loaded worker. The reservations have three further limits. On macOS `psutil` 6.1.1 reports available memory as inactive plus free pages, so a running job's own inactive pages can count as available while its resident memory is also credited as held; with one Microcosm worker, as the reservation sets, no other job in the batch is credited at all. Reservations are per process: a build and a bridge running at once could each admit a job on the same headroom, so the runbook never overlaps them. The historical rehearsal runner keeps its original admission (40 GiB in each slot, no reservations), because its receipts record its source; reproduce its Microcosm stages with one worker. The binding data step at one worker is the first measurement of a Microcosm worker on a133f37a / 2.147.2. Read its peak before raising `--workers` for the build, and re-set `HEAVY_CAP_MB` to at least 1.3 × the measured tree footprint (Fleet ops' rule: a ceiling trip closes heavy slot 2 for everyone for an hour). The limit is operational. It is in no job's cache key, recipe hash or execution snapshot, so changing it reruns nothing. +Resident memory excludes compressed and swapped pages, so under memory pressure it understates a job's footprint; the footprint fields count them (they are null off macOS). A slot credits each running job with its resident memory. For compressed and swapped pages that errs safe: a smaller reading leaves a larger reservation. For pages a job shares with other processes it does not: they count as held, though copying them later adds physical memory that resident size does not show. On 25 October a process importing the locked model and its libraries measured 0.252 GiB resident against a 0.225 GiB footprint; the binding data step records both measures for a loaded worker. The reservations have three further limits. On macOS `psutil` 6.1.1 reports available memory as inactive plus free pages, so a running job's own inactive pages can count as available while its resident memory is also credited as held; with one Microcosm worker, as the reservation sets, no other job in the batch is credited at all. Reservations are per process: a build and a bridge running at once could each admit a job on the same headroom, so the runbook never overlaps them. The historical rehearsal runner keeps its original admission (40 GiB in each slot, no reservations), because its receipts record its source; reproduce its Microcosm stages with one worker. The binding data step at one worker is the first measurement of a Microcosm worker on a6299504 / 2.151.0. Read its peak before raising `--workers` for the build, and re-set `HEAVY_CAP_MB` to at least 1.3 × the measured tree footprint (Fleet ops' rule: a ceiling trip closes heavy slot 2 for everyone for an hour). The limit is operational. It is in no job's cache key, recipe hash or execution snapshot, so changing it reruns nothing. The full build (`triple-lock-build`), standalone scenarios and `scripts/validate_ageing.py` use the default scheduler admission described in the binding-run guards above. @@ -375,7 +378,7 @@ No history fetch is needed in the checkout workflow. ## Part J: pre-Budget rehearsal and post-Budget sequence -The historical rehearsal used the pre-Budget 6.2.6 / 1.58.0 certified bundle. Reproducing that historical run uses that exact lock; the pending binding run uses d1983/#715 instead. The commands in this subsection reproduce the historical rehearsal at its original source and lock; the current foreground commands above control the binding rebuild. Its numbers are not for quoting. It never writes `data/results.json`, its dashboard copy or scenario publication files. The C2 dry handoff remains `run_kind: dry_run` with `expected_value_authorized: false`; the rehearsal adapter retains the frozen ruling (c) checks and original-primary design. A passing diagnostic screen does not authorize a binding build. +The historical rehearsal used the pre-Budget 6.2.6 / 1.58.0 certified bundle. Reproducing that historical run uses that exact lock; the pending binding run uses d2011's pair instead. The commands in this subsection reproduce the historical rehearsal at its original source and lock; the current foreground commands above control the binding rebuild. Its numbers are not for quoting. It never writes `data/results.json`, its dashboard copy or scenario publication files. The C2 dry handoff remains `run_kind: dry_run` with `expected_value_authorized: false`; the rehearsal adapter retains the frozen ruling (c) checks and original-primary design. A passing diagnostic screen does not authorize a binding build. ```sh export OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 NUMEXPR_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 @@ -406,7 +409,7 @@ The exact post-Budget order is: ``` A `ready` mark brings the start forward only; it cannot extend the window past 4 November. -1. Branch from `origin/main` and open a new draft PR into `main` at the first commit. Complete the pending #715 release, upstream re-hash, passport preflight, model bridge, data bridge and coverage review above. Commit AWE (20 October), September CPI (21 October), the OBR forecast (28 October) and newly complete backtest origins with official source bytes and release identities. Recheck the certified lock and guards after those changes. +1. Branch from `origin/main` and open a new draft PR into `main` at the first commit. Complete the pending d2011 release, upstream re-hash, passport preflight, model bridge, data bridge and coverage review above. Commit AWE (20 October), September CPI (21 October), the OBR forecast (28 October) and newly complete backtest origins with official source bytes and release identities. Recheck the certified lock and guards after those changes. 2. Run the **binding C2 re-score**, using `python -m triple_lock.ts_uncertainty --screen c2 --binding --output out/uncertainty-c2`. It is not a separate launchd label. It is the `uk-triple-lock-c2` step inside the loaded launchd job `com.maxghenis.triple-lock-2027`, which runs daily at 08:35. From **29 October 2026**, or the day after a moved Budget, that job dispatches the step as a Subfleet hard-tier build in this repository. It does so once its `budget-check` step has ended DONE or WAITING_ON_MAX. The step stops at the C2 verdict; it does not run the fiscal rebuild. The binding-input guard remains mandatory when the job invokes the command. Commit the resulting binding handoff and scores before model jobs. 3. Run `triple-lock-build --workers 1 --sensitivity-workers 1 --uncertainty-ruling c --uncertainty-handoff out/uncertainty-c2` on the certified bundle. Primary pass permits only its model-conditional expected value beside the scenario envelope; primary failure automatically uses (a), scenarios only. Run the paired four-way validation after a pass, or `--central-only` after a failure. Check accounting, coverage, source/input/provenance bindings, full pytest and dashboard checks. 4. Obtain independent review of the rebuilt fiscal aggregates, C2 outcome, coverage and limitations, resolve findings and repeat to approval. Keep the new rebuild PR draft until independent review approves; mark it ready and leave merging to the reviewer. @@ -427,7 +430,7 @@ python3 ~/reviews/fleet-ops/tools/reservation.py release uk-tl-bridge-2026-10-27 The historical pre-Budget pins and release hashes are in [J-certification](pilot/J-certification.md), and the source changes are in [J-upstream](pilot/J-upstream.md). The version bridge and all three rehearsal stages subsequently completed at execution head `b31137e`. The [completed verification receipt](../data/pilot/J-completed-verification.json) binds the completed artifacts; the [model-step bridge](../data/pilot/J-bridge-model-step.json), [certified bridge](../data/pilot/J-bridge-certified.json) and [GB coverage against DWP](../data/pilot/J-coverage-gb-dwp.json) retain only the permitted aggregate publications. They are **pre-Budget and not for quoting**. [MODEL_V2_PILOT](MODEL_V2_PILOT.md) records the numerical bridge and coverage interpretation. Rehearsal fiscal values remain private. Historical memory/runtime estimates above remain historical context. -The full build, standalone mean-path and single-scenario entry points, and real ageing validation enforce the exact certified client/model/core, primary pin and applicable certification evidence before model jobs. For the historical bundle that evidence is its released carrier manifest; the d1983 route requires #715's exact released carrier manifest and Microcosm artifact. The old d1727 qualification-ledger route remains closed and historical. Their provenance keeps that gate beside the behavioural Housing Benefit passport. Paired ageing additionally requires the source's exact certified packages, dataset SHA and manifests; the old pilot cannot supply that source. The version bridge verifies the retained pilot against its independently pinned historical content before reusing figures; an initially consistent but edited pilot is rejected, including under optimized Python. +The full build, standalone mean-path and single-scenario entry points, and real ageing validation enforce the exact certified client/model/core, primary pin and applicable certification evidence before model jobs. For the historical bundle that evidence is its released carrier manifest; the binding route requires the exact released carrier manifest of d2011's pair and its Microcosm artifact. The old d1727 qualification-ledger route remains closed and historical. Their provenance keeps that gate beside the behavioural Housing Benefit passport. Paired ageing additionally requires the source's exact certified packages, dataset SHA and manifests; the old pilot cannot supply that source. The version bridge verifies the retained pilot against its independently pinned historical content before reusing figures; an initially consistent but edited pilot is rejected, including under optimized Python. The successful unattended steps had these measured wrapper wall times. These are elapsed times for the complete step, including its own setup and cache handling; they are separate from the cold-job means used for planning. Attempts stopped by host admission have nonzero return codes and are excluded. diff --git a/docs/d2011-upstream.md b/docs/d2011-upstream.md index ad6f4a87..12db2027 100644 --- a/docs/d2011-upstream.md +++ b/docs/d2011-upstream.md @@ -4,7 +4,7 @@ Read on 25 October 2026 from the released **policyengine-uk 2.151.0** wheel, com `data/certification/d2011-model-wheel.json` attests the wheel. Its archive SHA-256 equals PyPI's release metadata, and every one of its 3,176 RECORD members was checked for digest and size. The generator first reproduced the committed `d1983-model-wheel.json` byte for byte from PyPI's 2.147.2 wheel. No survey or Microcosm records were loaded. -`model_horizon.UPSTREAM_2151` and `UPSTREAM_2151_PARAMETERS` name the 2.151.0 bytes, and `check_upstream()` accepts that exact version alongside 2.125.1, 2.147.2 and the historical 2.153.4. The 2.147.2 absence guards apply to 2.151.0 too. **This does not change the binding pair.** d1983's #715 pair (2.147.2 / a133f37a) stays pinned in `datasets.py`, `pyproject.toml` and the lock until d2011 is ruled and a certified policyengine.py release carries the chosen pair. +`model_horizon.UPSTREAM_2151` and `UPSTREAM_2151_PARAMETERS` name the 2.151.0 bytes, and `check_upstream()` accepts that exact version alongside 2.125.1, 2.147.2 and the historical 2.153.4. The 2.147.2 absence guards apply to 2.151.0 too. Max ruled **d2011 = A** on 25 October, so the binding pins now name this pair: UK 2.151.0 in `pyproject.toml` and the lock, and Microcosm a6299504 in `datasets.py` and `config.PRIMARY_DATASET`. Certification stays closed until a policyengine.py release carries the pair. #715's pair (2.147.2 / a133f37a) stays registered as d2011's fallback. ## Every changed guard diff --git a/pyproject.toml b/pyproject.toml index 271b468a..ca47c742 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,13 +18,13 @@ dependencies = [ ] [project.optional-dependencies] -# #715's candidate UK model/core, explicitly pinned while its carrier release is pending. +# d2011's pair's UK model/core (microcosm#1396's uv.lock), explicitly pinned while its carrier release is pending. # The engine constructs policyengine_uk directly and materializes its own pinned data. # Use the released carrier without [uk]: 6.10.1's extra pins the previous model. -# Once #715 is released, update the carrier version and manifest digest in datasets.py. +# Once the pair is released, update the carrier version and manifest digest in datasets.py. uk = [ "policyengine==6.10.1", - "policyengine-uk==2.147.2", + "policyengine-uk==2.151.0", "policyengine-core==3.33.3", "huggingface-hub>=1.0", "openpyxl>=3.1", diff --git a/requirements-lock.txt b/requirements-lock.txt index c9d88fdd..d1628ee9 100644 --- a/requirements-lock.txt +++ b/requirements-lock.txt @@ -1,4 +1,4 @@ -# d1983 B: #715 candidate model/core, pending exact released carrier certification. +# d2011 A: microcosm#1396's candidate model/core, pending exact released carrier certification. # Base policyengine carrier deliberately has no [uk] extra (its released UK pin differs). # Dataset and future release-manifest identity are pinned in triple_lock/datasets.py. # Historical receipts retain their original model/data identities. @@ -55,7 +55,7 @@ plotly==5.24.1 pluggy==1.6.0 policyengine==6.10.1 policyengine-core==3.33.3 -policyengine-uk==2.147.2 +policyengine-uk==2.151.0 prompt-toolkit==3.0.53 psutil==6.1.1 ptyprocess==0.7.0 diff --git a/scripts/binding_bridge.py b/scripts/binding_bridge.py index 1a612471..6d2ff2ed 100644 --- a/scripts/binding_bridge.py +++ b/scripts/binding_bridge.py @@ -36,7 +36,7 @@ def validate_endpoint(endpoint): def validate_configuration(configuration): if configuration.get("status") != "ready": - raise ValueError("binding bundle is pending: wait for policyengine.py #715 attribution, review and release; " + raise ValueError("binding bundle is pending: wait for the policyengine.py release carrying d2011's pair; " "fill its released carrier version and manifest SHA-256, then mark the bridge configuration ready") if configuration.get("schema_version") != 1 or set(configuration.get("steps", {})) != {"model", "data"}: raise ValueError("bridge configuration requires model and data steps") diff --git a/scripts/compare_hb_link.py b/scripts/compare_hb_link.py index 6d8d09f6..0c4bc8eb 100644 --- a/scripts/compare_hb_link.py +++ b/scripts/compare_hb_link.py @@ -1,7 +1,7 @@ """Full central-path comparisons of HB and Savings Credit projection conventions. Run only through the shared-host heavy_run.sh admission wrapper. On the binding -UK 2.147.2 bundle, d1753 keeps the installed lagged-calendar-CPI projections as +UK 2.151.0 bundle, d1753 keeps the installed lagged-calendar-CPI projections as the headline. The default linked alternative moves both pension-age HB cohorts and Savings Credit thresholds with the path guarantee, preserving the enacted 2026 component grown by non-negative statutory September CPI. The cpi selector diff --git a/src/triple_lock/config.py b/src/triple_lock/config.py index 6e1bec9c..941bfa9d 100644 --- a/src/triple_lock/config.py +++ b/src/triple_lock/config.py @@ -191,10 +191,11 @@ CPI_AUG_SEP_FIRST_YEAR = 1997 # Datasets (datasets.DATASETS: each pinned to a revision and a SHA-256). The -# binding primary is the exact Microcosm UK Budget cut proposed by policyengine.py #715; -# publication remains blocked until the certified carrier is released. Enhanced FRS -# 1.58.2 is the paired sensitivity (d1983 = B supersedes d1727). -PRIMARY_DATASET = "microcosm_uk_2024_25@a133f37a" +# binding primary is the Microcosm UK Budget national cut d2011 certifies (= A: +# a6299504, microcosm#1396, on policyengine-uk 2.151.0); publication remains +# blocked until the certified carrier is released. Enhanced FRS 1.58.2 is the +# paired sensitivity (d1983 = B supersedes d1727). +PRIMARY_DATASET = "microcosm_uk_2024_25@a6299504" SENSITIVITY_DATASET = "enhanced_frs_2024_25@1.58.2" # Outputs. diff --git a/src/triple_lock/datasets.py b/src/triple_lock/datasets.py index 09e2eed1..2ee1b809 100644 --- a/src/triple_lock/datasets.py +++ b/src/triple_lock/datasets.py @@ -1,8 +1,11 @@ """Pinned data for the binding Microcosm primary and Enhanced FRS sensitivity. -The #715 UK Budget candidate is Microcosm a133f37a / UK 2.147.2 / core -3.33.3, national only. Its carrier release is pending: provenance fails closed -until its released version and installed manifest digest are pinned below. +d2011 = A (Max, 25 October 2026): the binding primary is the 2.151.0 national +and local-area pair built at microcosm#1396, Microcosm a6299504 / UK 2.151.0 / +core 3.33.3. Its carrier release is pending: provenance fails closed until its +released version, installed manifest digest, certifying pull request and +local-area file are pinned below. #715's a133f37a / UK 2.147.2 pair, d1983's +first binding pair and d2011's fallback, keeps its own pending identity. Historical Enhanced FRS and Microcosm artifacts retain their original pins. Materialization checks the exact artifact SHA-256 before every use (cached while unchanged), with one shared copy in .cache/datasets per artifact. @@ -27,15 +30,13 @@ "certification_url": "https://github.com/PolicyEngine/policyengine.py/pull/555", "certification_commit": "37d25c423e9541b4ca86adbdd8687738b49ab8ec", } -# The only unknown certification identities: fill from the release carrying #715. -# The candidate head's 6.10.1 is NOT that release (the published version has older data). -BINDING_BUNDLE_VERSION = None -BINDING_BUNDLE_MANIFEST_SHA256 = None -CERTIFIED_BUNDLE = { - "bundle_package": "policyengine", "bundle_version": BINDING_BUNDLE_VERSION, - "bundle_id": f"uk-{BINDING_BUNDLE_VERSION}", "model_version": "2.147.2", "core_version": "3.33.3", +# #715's pair (d1983's first binding pair; d2011's fallback if the 2.151.0 pair is not certified by Tue 27 Oct +# 18:00Z). Its unknown identities fill from the release carrying #715; the candidate head's 6.10.1 is NOT that release. +BUNDLE_715 = { + "bundle_package": "policyengine", "bundle_version": None, + "bundle_id": "uk-None", "model_version": "2.147.2", "core_version": "3.33.3", "manifest_path": "policyengine/data/bundle/manifest.json", - "manifest_sha256": BINDING_BUNDLE_MANIFEST_SHA256, + "manifest_sha256": None, "model_wheel_sha256": "4b48ab1008b00610abc38192927ac9d4bb22037b6bbd9578926c2899e639f972", "wheels": { "policyengine-uk": { @@ -52,15 +53,63 @@ "certification_url": "https://github.com/PolicyEngine/policyengine.py/pull/715", "certification_commit": "e02d41bceab57756373b2dc3bcf78635b856ec81", } -MICROCOSM_REVISION = "microcosm-uk-2024-25-national-20261023T075039Z-a133f37a" -MICROCOSM_RELEASE_MANIFEST = { - "revision": MICROCOSM_REVISION, - "path": "releases/microcosm-uk-2024-25-national/release_manifest.json", - "uri": f"https://huggingface.co/datasets/policyengine/populace-uk-private/resolve/{MICROCOSM_REVISION}/" - "releases/microcosm-uk-2024-25-national/release_manifest.json", - "source_uri": f"hf://dataset/policyengine/populace-uk-private@{MICROCOSM_REVISION}/" - "releases/microcosm-uk-2024-25-national/release_manifest.json", + + +def microcosm_release_manifest(revision): + """Where a Microcosm UK national release tag keeps its release manifest.""" + return { + "revision": revision, + "path": "releases/microcosm-uk-2024-25-national/release_manifest.json", + "uri": f"https://huggingface.co/datasets/policyengine/populace-uk-private/resolve/{revision}/" + "releases/microcosm-uk-2024-25-national/release_manifest.json", + "source_uri": f"hf://dataset/policyengine/populace-uk-private@{revision}/" + "releases/microcosm-uk-2024-25-national/release_manifest.json", + } + + +MICROCOSM_REVISION_A133 = "microcosm-uk-2024-25-national-20261023T075039Z-a133f37a" +MICROCOSM_RELEASE_MANIFEST_A133 = microcosm_release_manifest(MICROCOSM_REVISION_A133) + +# d2011's pair. Unknown until the policyengine.py release that carries it: that release's version and installed +# manifest digest, the pull request and commit that certify it, and the local-area file its constituency and +# local-authority runs load. Each fails closed while None. +BINDING_BUNDLE_VERSION = None +BINDING_BUNDLE_MANIFEST_SHA256 = None +BINDING_CERTIFICATION_URL = None +BINDING_CERTIFICATION_COMMIT = None +BINDING_LOCAL_AREA_PATH = None +CERTIFIED_BUNDLE = { + "bundle_package": "policyengine", "bundle_version": BINDING_BUNDLE_VERSION, + "bundle_id": f"uk-{BINDING_BUNDLE_VERSION}", "model_version": "2.151.0", "core_version": "3.33.3", + "manifest_path": "policyengine/data/bundle/manifest.json", + "manifest_sha256": BINDING_BUNDLE_MANIFEST_SHA256, + "model_wheel_sha256": "5722f55aca3eb88e1a228e95e1a57e3bd70a4d530a5a000991565ebd8955ce35", + "wheels": { + "policyengine-uk": { + "manifest_path": "data/certification/d2011-model-wheel.json", + "manifest_sha256": "a67bd0a5afad28def6327f3cb2f056ef54c5e5da31eb03f4003d58d7ca5edc25", + "wheel_sha256": "5722f55aca3eb88e1a228e95e1a57e3bd70a4d530a5a000991565ebd8955ce35", + }, + "policyengine-core": { + "manifest_path": "data/certification/d1983-core-wheel.json", + "manifest_sha256": "dfbd18d2553e84b0e302ea22a637d4b66b6214623be7ec67874fbbe5fac42867", + "wheel_sha256": "5847d9938412095fe9848c2ce535838a7dc8fac9238d8fd9e6c9d0a0648e0888", + }, + }, + "certification_url": BINDING_CERTIFICATION_URL, + "certification_commit": BINDING_CERTIFICATION_COMMIT, } +# The national's release tag. a133f37a's staged attempt uk-frs-calibration-attempt-20261023T075039Z-a133f37a was +# released as the tag below, with the same file bytes; this cut's attempt is +# uk-frs-calibration-attempt-20261025T091414Z-a6299504 (staged_manifest.json: git_commit 67ee2ddf3, clean). Confirm the +# tag at release: until it exists, the download and the bundle's artifact URI both fail closed. +MICROCOSM_REVISION = "microcosm-uk-2024-25-national-20261025T091414Z-a6299504" +MICROCOSM_RELEASE_MANIFEST = microcosm_release_manifest(MICROCOSM_REVISION) +# policyengine.py routes a UK release's constituency and local-authority runs to its local-area file +# (provenance/certification.py: LOCAL_AREA_REGION_TYPES["uk"]); the national file serves national runs. +BINDING_REGION_DATASETS = {"national": {"path_template": "microcosm_uk_2024_25.h5"}, + "constituency": {"path_template": BINDING_LOCAL_AREA_PATH}, + "local_authority": {"path_template": BINDING_LOCAL_AREA_PATH}} RELEASE_MANIFEST_1_58_0 = { "revision": "fdeba38517bd86067f34c0a5d8ed8923efb3b10a", "path": "releases/1.58.0/release_manifest.json", @@ -79,20 +128,39 @@ # the file (for other policyengine-uk versions), "built_with" the policyengine-uk version its data build ran, # "ageing_anchor" the year static ageing anchors its weights to (the data build's calibration year where it has one). DATASETS = { - "microcosm_uk_2024_25@a133f37a": { + # SHA-256 of the staged attempt's file (staged_manifest.json and the Hub's LFS object, 342,348,841 bytes). + "microcosm_uk_2024_25@a6299504": { "logical_name": "microcosm_uk_2024_25", "repo_id": "policyengine/populace-uk-private", "repo_type": "dataset", "path": "microcosm_uk_2024_25.h5", "revision": MICROCOSM_REVISION, + "sha256": "b96a8cd625b645a409c063b1de5158532708a2f0d3c3897a794a8fb0bbd5064e", + "data_package": "microcosm-data", "data_version": "0.1.0", # packages/microcosm-data at 67ee2ddf3 + "built_with": "policyengine-uk 2.151.0 (policyengine-core 3.33.3)", + "build_id": MICROCOSM_REVISION, + "certified": [], # pending release; producer gates are not released bundle certification. + "bundle_certification": CERTIFIED_BUNDLE, + "release_manifest": MICROCOSM_RELEASE_MANIFEST, + "region_datasets": BINDING_REGION_DATASETS, + "ageing_anchor": {"year": 2024, "calibration_year": None, + "source": "Microcosm 2024-25 data year, anchored as a133f37a is (d1983). The build dates its " + "calibration targets 2025 (build_record.json run_config.calibration_year); " + "whether that year should be the anchor is open before the binding rebuild"}, + }, + "microcosm_uk_2024_25@a133f37a": { + "logical_name": "microcosm_uk_2024_25", + "repo_id": "policyengine/populace-uk-private", "repo_type": "dataset", + "path": "microcosm_uk_2024_25.h5", "revision": MICROCOSM_REVISION_A133, "sha256": "64f83900ca3a1242fb017585234844ce89f743d32e41cd2b6558057777f914aa", "data_package": "microcosm-data", "data_version": "0.1.0", "built_with": "policyengine-uk 2.147.2 (policyengine-core 3.33.3)", - "build_id": MICROCOSM_REVISION, + "build_id": MICROCOSM_REVISION_A133, "certified": [], # #715 is draft; producer gates are not released bundle certification. - "bundle_certification": CERTIFIED_BUNDLE, - "release_manifest": MICROCOSM_RELEASE_MANIFEST, + "bundle_certification": BUNDLE_715, + "release_manifest": MICROCOSM_RELEASE_MANIFEST_A133, "ageing_anchor": {"year": 2024, "calibration_year": None, - "source": "Microcosm 2024-25 data year; no calibration year is declared for this cut; " - "use the first ONS projection fiscal year, 2024-25"}, + "source": "Microcosm 2024-25 data year; use the first ONS projection fiscal year, 2024-25. " + "The build dates its calibration targets 2025 (calibration_diagnostics.json, " + "run_config.calibration_year)"}, }, "enhanced_frs_2024_25@1.58.2": { "logical_name": "enhanced_frs_2024_25", @@ -177,7 +245,7 @@ }, } UNCERTIFIED = ("uncertified: the exact released policyengine.py bundle/model/core and artifact are required; " - "the binding Microcosm UK Budget release from #715 is pending") + "the release carrying d2011's binding Microcosm UK Budget pair is pending") class DatasetMismatch(RuntimeError): @@ -282,6 +350,9 @@ def model_versions(): def _certified_by_installed_bundle(spec, versions): """Fail closed unless released-manifest or ledger evidence certifies the exact pair.""" certification = spec.get("bundle_certification") + regions = spec.get("region_datasets", {"national": {"path_template": spec["path"]}}) + if not all(isinstance(template, dict) and template.get("path_template") for template in regions.values()): + return False # a pending file (BINDING_LOCAL_AREA_PATH) certifies nothing if (certification is None or not certification.get("bundle_version") or not certification.get("manifest_sha256") and "ledger_source_path" not in certification or any(versions.get(key) != certification[key] @@ -314,7 +385,7 @@ def _certified_by_installed_bundle(spec, versions): or artifact["build_id"] != spec["build_id"] or release["release_manifest_uri"] != spec["release_manifest"]["uri"] or release["source_manifest_uri"] != spec["release_manifest"]["source_uri"] - or release["region_datasets"] != {"national": {"path_template": spec["path"]}}): + or release["region_datasets"] != regions): return False matches = (release["bundle_id"] == certification["bundle_id"] and release["certification"]["certified_for_model_version"] == versions["model_version"] diff --git a/src/triple_lock/engine.py b/src/triple_lock/engine.py index 89d7e402..a130e033 100644 --- a/src/triple_lock/engine.py +++ b/src/triple_lock/engine.py @@ -1,23 +1,23 @@ """Full PolicyEngine UK runs of the two rules on a growth path, each in its own process, cached by input. Every fiscal and household figure in the results comes from a job here; nothing -is scaled from another run. The binding primary is policyengine.py's certified -UK Budget pair proposed by #715: Microcosm national cut -microcosm-uk-2024-25-national-20261023T075039Z-a133f37a, policyengine-uk -2.147.2 and policyengine-core 3.33.3. Publication fails closed until its +is scaled from another run. The binding primary is the certified UK Budget +pair d2011 chose (microcosm#1396): Microcosm national cut +microcosm-uk-2024-25-national-20261025T091414Z-a6299504, policyengine-uk +2.151.0 and policyengine-core 3.33.3. Publication fails closed until its policyengine.py carrier is released. Enhanced FRS 1.58.2 is the paired sensitivity. All datasets have exact revision and SHA-256 pins (datasets.py). The provisional pre-Budget release used Enhanced FRS; bridge step (b) measures the move to Microcosm on the same model. Historical receipts remain intact. -This bundle predates #2557 (September-CPI benefit uprating, first in 2.148.2) -and #2576 (earnings-linked guarantee and derived HB/Savings Credit, first in -2.151.2). Benefit rates use the installed lagged-calendar-CPI branch. The -engine still earnings-links the Pension Credit guarantee; HB pension-age -allowances and Savings Credit thresholds retain their installed CPI projection -under d1753's default. The age addition (#2340) is absent. The explicit -additional-State-Pension September-CPI pin takes precedence over #2336's -formula, which is present in 2.147.2. +This model has #2557 (September-CPI benefit uprating, first in 2.148.2) and +predates #2576 (earnings-linked guarantee and derived HB/Savings Credit, first +in 2.151.2). Benefit rates follow the previous September's CPI. The engine +still earnings-links the Pension Credit guarantee; HB pension-age allowances +and Savings Credit thresholds name lagged calendar CPI and retain that +installed projection under d1753's default. The age addition (#2340) is +absent. The explicit additional-State-Pension September-CPI pin takes +precedence over #2336's formula, which is present in 2.151.0. How a path enters the model --------------------------- @@ -26,7 +26,7 @@ amount as CPI), and its statutory inputs for 2026-2038 (September CPI and May-July AWE, each at its observation date) replace the model's ``statutory_uprating_inputs``, in a Scenario applied before the data load. -On 2.147.2, dated reforms passed during construction also rebuild the derived +On 2.147.2 and 2.151.0, dated reforms passed during construction also rebuild the derived series, including the triple lock. The engine uses the before-data Scenario so projected inputs follow the path, then sets explicit flat-rate amounts from its own rules to preserve their precision, horizon and earnings-path @@ -35,8 +35,9 @@ records, and fails unless, in every year 2027-28 to 2039-40: * benefit uprating follows the index the installed model names: on 2.147.2, - the previous calendar year's CPI; on models with #2557, the previous - September CPI (the Pension Credit guarantee follows May-July earnings); + the previous calendar year's CPI; on models with #2557 (2.151.0), the + previous September CPI (the Pension Credit guarantee follows May-July + earnings); * CPI-indexed thresholds (the NI lower earnings limit) grow by the path's calendar CPI the same year; * employment income grows by the path's earnings the same year; @@ -51,9 +52,9 @@ (reported benefits, consumption) and private pension income (the previous year's RPI, capped at 5%). Not moving: dividend, property and savings income, self-employment income, rents and council tax, whose growth ``not_moving`` -records on every run. On 2.147.2, April 2027's benefit uprating uses the -installed 2026 calendar-CPI forecast; subsequent upratings use the path's -previous calendar year. Additional State Pension uses September CPI throughout. +records on every run. On 2.151.0, April 2027's benefit uprating uses the +path's September 2026 CPI input; subsequent upratings use the path's previous +September. Additional State Pension uses September CPI throughout. The State Pension flat rates are set from each rule applied to the path's statutory inputs (to 0.1 point as published, by rules.round_rate), or from a @@ -82,7 +83,7 @@ State Pension age that year. Split by each year's type, as policyengine-uk's own flat-rate parts are, it never pays the band between the two flat rates twice; historical policyengine-uk 2.125.1 scales it by the flat rates' ratio - (#1941), while 2.147.2 uses September CPI from the data year (#2336). The + (#1941), while 2.147.2 and 2.151.0 use September CPI from the data year (#2336). The engine's explicit path input takes precedence over either formula. Every run checks the data year's identity basic + new + additional (+ age addition when defined) = the counted report, person by person (state_pension_accounting). The age @@ -90,7 +91,7 @@ * the Pension Credit standard minimum guarantee rises with the path's May-July earnings growth (config.PENSION_CREDIT_GUARANTEE; SSAA 1992 s150A). Its Savings Credit threshold and pension-age HB allowances retain their installed - CPI projection on 2.147.2 (d1753). They follow the applied guarantee only on + CPI projection on 2.147.2 and 2.151.0 (d1753). They follow the applied guarantee only on models that actually provide #2576's linked builders. A Scenario simulation builds a second, default-path simulation as its @@ -671,7 +672,7 @@ def set_flat_rates(sim, levels, extra=None): The macro Scenario has already processed its parameter tree. A subsequent guarantee override updates Savings Credit or pension-age HB amounts only when the installed model has the corresponding linked projection builder. - UK 2.147.2 has neither: its installed CPI projections stay in force under + UK 2.147.2 and 2.151.0 have neither: their installed CPI projections stay in force under d1753. A maximum parameter alone does not establish either derivation. Explicit dependent amounts in ``extra`` win for projection sensitivities. """ @@ -755,7 +756,7 @@ def pinned_inputs(sim, years, sep_cpi, demography=None, anchor=None, retyped_lev does. So basic + new + additional (+ age addition) equals the payable reported pension in the data year for every person, whatever the type, and a cut in the flat rates moves only the flat-rate parts. Historical policyengine-uk 2.125.1 uprates it by the flat rates' ratio (#1941); - 2.147.2 contains #2336's September-CPI formula. This explicit input still wins + 2.147.2 and 2.151.0 contain #2336's September-CPI formula. This explicit input still wins over that formula, so the selected path controls the additional pension. Splitting it by the survey year's type instead would pay the band between the two flat rates twice for everyone a cohort type moves from basic to new. diff --git a/src/triple_lock/pipeline.py b/src/triple_lock/pipeline.py index 4eaf81c7..66b4aa0c 100644 --- a/src/triple_lock/pipeline.py +++ b/src/triple_lock/pipeline.py @@ -127,15 +127,14 @@ def method_limitations(): for i, text in enumerate(wording): if text.startswith("In every run April 2027"): wording[i] = ( - "On policyengine-uk 2.147.2, April 2027 benefit uprating uses the installed calendar-2026 " - "CPI forecast; later years use the path's previous calendar-year CPI. This bundle predates " - "#2557's September-CPI benefit uprating. Additional State Pension follows September CPI.") + "On policyengine-uk 2.151.0, benefit uprating follows the previous September's CPI (#2557), to " + "0.1 point and never cut: April 2027 uses the path's September 2026 CPI input and later years the " + "path's September CPI the year before. Additional State Pension follows September CPI.") elif text.startswith("The population is aged statically"): wording[i] = ( "The population is aged statically: each record represents a person of its age in every year. " - "The Microcosm primary has no published calibration year, so its verified data-year anchor is " - "2024-25; its own weights are retained through that year and then follow ONS 2024-based " - "age/sex growth. Enhanced FRS 1.58.2 anchors in its 2025-26 calibration year. Uncapped ages " + "The Microcosm primary is anchored at its 2024-25 data year: its own weights are retained " + "through that year and then follow ONS 2024-based age/sex growth. Enhanced FRS 1.58.2 anchors in its 2025-26 calibration year. Uncapped ages " "are retained, including genuine ages of 80; only an entirely top-coded 80+ tail gets " "represented ages of 80 to 105. State Pension types follow birth cohorts. Deaths, migration " "and contribution histories are not simulated; pensioners abroad are outside both datasets.") @@ -144,18 +143,19 @@ def method_limitations(): "Microcosm records are built from survey donors. The same minimum of ten contributing " "records and complementary withholding applies to the primary and Enhanced FRS sensitivity.") elif text.startswith("policyengine-uk pays Scotland"): - wording[i] = "policyengine-uk 2.147.2 has no 25p State Pension age addition at 80 (#2340 is unmerged)." + wording[i] = "policyengine-uk 2.151.0 has no 25p State Pension age addition at 80 (#2340 is unmerged)." elif text.startswith("The primary model/data pair"): wording[i] = ( - "The binding primary is policyengine.py's UK Budget pair proposed in #715: Microcosm national " - "cut microcosm-uk-2024-25-national-20261023T075039Z-a133f37a, policyengine-uk 2.147.2 and " + "The binding primary is the UK Budget pair d2011 certifies (microcosm#1396): Microcosm national " + "cut microcosm-uk-2024-25-national-20261025T091414Z-a6299504, policyengine-uk 2.151.0 and " "policyengine-core 3.33.3. Publication fails closed until its certified policyengine.py release " - "exists. Enhanced FRS 1.58.2 is the paired sensitivity. This model predates September-CPI " - "benefit uprating (#2557) and linked HB/Savings Credit derivation (#2576): those retain the " - "installed CPI projection (d1753); the Pension Credit guarantee still follows May-July earnings.") + "exists. Enhanced FRS 1.58.2 is the paired sensitivity. This model has September-CPI benefit " + "uprating (#2557) but predates linked HB/Savings Credit derivation (#2576): the Savings Credit " + "thresholds and pension-age HB allowances retain the installed lagged calendar-CPI projection " + "(d1753); the Pension Credit guarantee still follows May-July earnings.") wording.append( "The pre-Budget release used Enhanced FRS and was described as provisional in Vahid's post. " - "The binding release uses Microcosm; bridge step (b), on the same policyengine-uk 2.147.2 model, " + "The binding release uses Microcosm; bridge step (b), on the same policyengine-uk 2.151.0 model, " "measures that change of data.") return wording @@ -182,7 +182,7 @@ class UncertifiedBundle(RuntimeError): def certified_bundle_preflight(log=print): - """Require #715's exact released UK Budget pair without loading any dataset. + """Require d2011's exact released UK Budget pair without loading any dataset. The candidate head is not a released certificate. Dataset provenance checks the installed release manifest digest and exact model/core/artifact identity; @@ -193,15 +193,16 @@ def certified_bundle_preflight(log=print): expected = {"policyengine": certification["bundle_version"], "policyengine-uk": certification["model_version"], "policyengine-core": certification["core_version"]} - primary = "microcosm_uk_2024_25@a133f37a" - remedy = ("The fiscal rebuild requires the certified released policyengine.py UK Budget bundle from #715 " - "(https://github.com/PolicyEngine/policyengine.py/pull/715), UK 2.147.2 / core 3.33.3 / " - "Microcosm a133f37a. #715 is draft and its carrier release is pending. After release, pin its " - "version and released manifest digest in datasets.py, update the carrier dependency version, " - "and install the committed lock. The candidate head and producer gates cannot certify a release.") + primary = "microcosm_uk_2024_25@a6299504" + remedy = ("The fiscal rebuild requires the certified released policyengine.py UK Budget bundle carrying " + "d2011's pair (microcosm#1396): UK 2.151.0 / core 3.33.3 / Microcosm a6299504. Its carrier " + "release is pending. After release, pin its version, released manifest digest, certifying pull " + "request and local-area file in datasets.py, update the carrier dependency version, and install " + "the committed lock. A candidate head and producer gates cannot certify a release.") if PRIMARY_DATASET != primary: raise UncertifiedBundle(remedy) - if not expected["policyengine"] or not certification["manifest_sha256"]: + if (not expected["policyengine"] or not certification["manifest_sha256"] + or not certification["certification_url"] or not certification["certification_commit"]): raise UncertifiedBundle(remedy) installed = package_versions() if any(installed.get(name) != version for name, version in expected.items()): @@ -209,7 +210,7 @@ def certified_bundle_preflight(log=print): provenance = datasets.provenance(PRIMARY_DATASET) if provenance.get("dataset") != primary or provenance.get("certified") is not True: raise UncertifiedBundle(remedy + " The installed released manifest does not certify the pinned pair.") - log(f"Preflight: exact policyengine {expected['policyengine']} UK Budget bundle and Microcosm a133f37a certified") + log(f"Preflight: exact policyengine {expected['policyengine']} UK Budget bundle and Microcosm a6299504 certified") return {"certified_bundle": {"passed": True, "packages": expected, "primary": provenance}} diff --git a/tests/test_binding_bridge.py b/tests/test_binding_bridge.py index 5139db6c..ef76ea3e 100644 --- a/tests/test_binding_bridge.py +++ b/tests/test_binding_bridge.py @@ -19,7 +19,7 @@ def ready_configuration(): config = json.loads(PENDING.read_text()) config["status"] = "ready" - new_packages = {"policyengine": "9.0.0", "policyengine-uk": "2.147.2", "policyengine-core": "3.33.3"} + new_packages = {"policyengine": "9.0.0", "policyengine-uk": "2.151.0", "policyengine-core": "3.33.3"} config["steps"]["model"]["to"]["packages"] = dict(new_packages) config["steps"]["data"]["from"]["packages"] = dict(new_packages) config["steps"]["data"]["to"]["packages"] = dict(new_packages) @@ -51,20 +51,25 @@ def fake_coverage(value): def test_pending_bundle_cannot_be_run(): - with pytest.raises(ValueError, match="bundle is pending:.*#715"): + with pytest.raises(ValueError, match="bundle is pending:.*d2011"): binding_bridge.validate_configuration(json.loads(PENDING.read_text())) -def test_pending_bridge_pins_d1983_model_and_cross_family_data_steps(): +def test_pending_bridge_pins_d2011_model_and_cross_family_data_steps(): configuration = json.loads(PENDING.read_text()) model, data = (configuration["steps"][name] for name in ("model", "data")) assert model["from"]["dataset"] == model["to"]["dataset"] == data["from"]["dataset"] assert model["to"] == data["from"] assert data["to"]["dataset"] == { - "name": "microcosm_uk_2024_25@a133f37a", - "sha256": "64f83900ca3a1242fb017585234844ce89f743d32e41cd2b6558057777f914aa"} + "name": "microcosm_uk_2024_25@a6299504", + "sha256": "b96a8cd625b645a409c063b1de5158532708a2f0d3c3897a794a8fb0bbd5064e"} assert data["from"]["packages"] == data["to"]["packages"] == { - "policyengine": None, "policyengine-uk": "2.147.2", "policyengine-core": "3.33.3"} + "policyengine": None, "policyengine-uk": "2.151.0", "policyengine-core": "3.33.3"} + # Each pin is the registry's: the bridge measures exactly the binding pair. + from triple_lock import datasets + assert data["to"]["dataset"]["sha256"] == datasets.DATASETS[data["to"]["dataset"]["name"]]["sha256"] + assert data["to"]["packages"]["policyengine-uk"] == datasets.CERTIFIED_BUNDLE["model_version"] + assert configuration["pending_release"]["pull_request"] is None @pytest.mark.parametrize("name", ["unregistered@cut", "microcosm_uk_2024_25@", "enhanced_frs_2024_25@ "]) @@ -198,7 +203,7 @@ def test_stub_engine_runs_separate_model_and_data_steps_on_the_same_paired_desig data = run_step("data", "data/pilot/model.json", "data/pilot/data.json") assert len(model_jobs) == len(calls) == 42 assert all(arg["dataset"].endswith("1.58.0") for _, (_, arg) in model_jobs) - assert all(arg["dataset"] == "microcosm_uk_2024_25@a133f37a" for _, (_, arg) in calls) + assert all(arg["dataset"] == "microcosm_uk_2024_25@a6299504" for _, (_, arg) in calls) assert model["provenance"]["dataset"]["certified"] is False assert data["provenance"]["dataset"]["certified"] is True assert model["provenance"]["host_admission"]["minimum_available_gib"] == 40 @@ -411,7 +416,7 @@ def launch(jobs, *, admission, stop, slot_prefix, **kwargs): return [{"synthetic": True}] * len(jobs) monkeypatch.setattr(bridge.jobs, "run_jobs", launch) - batch = [(str(i), ("path", {"dataset": "microcosm_uk_2024_25@a133f37a"})) for i in range(2)] + batch = [(str(i), ("path", {"dataset": "microcosm_uk_2024_25@a6299504"})) for i in range(2)] assert len(bridge.run_batch(batch, 2, tmp_path / "cache")) == 2 # The batch's 80 GiB cold floor is checked before it starts (main); each slot admits one cold job. assert checked and all(row["minimum_gib"] == bridge.COLD_JOB_MINIMUM_GIB for row in checked) diff --git a/tests/test_budget_bundle.py b/tests/test_budget_bundle.py index fae15a36..1bc0d006 100644 --- a/tests/test_budget_bundle.py +++ b/tests/test_budget_bundle.py @@ -13,11 +13,11 @@ def test_binding_roles_and_pending_preflight_do_not_read_model_or_data(monkeypatch): - assert PRIMARY_DATASET == "microcosm_uk_2024_25@a133f37a" + assert PRIMARY_DATASET == "microcosm_uk_2024_25@a6299504" assert SENSITIVITY_DATASET == "enhanced_frs_2024_25@1.58.2" monkeypatch.setattr(datasets, "materialize", lambda *a, **kw: pytest.fail("no data")) monkeypatch.setattr(pipeline, "package_versions", lambda: pytest.fail("no model import")) - with pytest.raises(pipeline.UncertifiedBundle, match="#715.*pending"): + with pytest.raises(pipeline.UncertifiedBundle, match="d2011.*pending"): pipeline.certified_bundle_preflight() @@ -32,6 +32,19 @@ def released(tmp_path, monkeypatch): cert = {**datasets.CERTIFIED_BUNDLE, "bundle_version": version, "bundle_id": f"uk-{version}"} spec = deepcopy(datasets.DATASETS[PRIMARY_DATASET]) spec["bundle_certification"] = cert + # The skeleton is #715's a133f37a candidate; point its pair fields at the binding spec, with an invented + # local-area file in place of the pending one. + local = {"path_template": "invented_local_area.h5"} + spec["region_datasets"] = {**spec["region_datasets"], "constituency": local, "local_authority": local} + release["model_package"]["version"] = cert["model_version"] + release["certification"]["certified_for_model_version"] = cert["model_version"] + release["build_id"] = spec["build_id"] + release["certified_data_artifact"].update( + build_id=spec["build_id"], sha256=spec["sha256"], + uri=f"hf://{spec['repo_id']}/{spec['path']}@{spec['revision']}") + release["release_manifest_uri"] = spec["release_manifest"]["uri"] + release["source_manifest_uri"] = spec["release_manifest"]["source_uri"] + release["region_datasets"] = deepcopy(spec["region_datasets"]) path = tmp_path / "manifest.json" distributions = {"policyengine": SimpleNamespace(version=version, locate_file=lambda _: path)} cert["wheels"] = {} @@ -105,6 +118,8 @@ def test_released_bundle_rejects_different_artifact_identity(released, field, va (None, "source_manifest_uri", "hf://other"), (None, "release_manifest_uri", "https://example.com/other"), (None, "region_datasets", {"constituency": {"path_template": "dense.h5"}}), + # National only: the binding pair's constituency and local-authority runs must route to its local-area file. + (None, "region_datasets", {"national": {"path_template": "microcosm_uk_2024_25.h5"}}), ]) def test_structural_pair_checks_even_with_authenticated_manifest(released, location, key, value): spec, versions, manifest, write, _ = released @@ -114,6 +129,25 @@ def test_structural_pair_checks_even_with_authenticated_manifest(released, locat assert not datasets._certified_by_installed_bundle(spec, versions) +def test_the_pending_local_area_file_cannot_certify(released): + spec, versions, manifest, write, _ = released + assert datasets._certified_by_installed_bundle(spec, versions) + spec["region_datasets"] = deepcopy(datasets.BINDING_REGION_DATASETS) + assert datasets.BINDING_LOCAL_AREA_PATH is None + assert not datasets._certified_by_installed_bundle(spec, versions) + manifest["data_releases"]["uk"]["region_datasets"] = deepcopy(datasets.BINDING_REGION_DATASETS) + write() + assert not datasets._certified_by_installed_bundle(spec, versions) + + +def test_the_715_fallback_keeps_its_national_only_pair(released): + fallback = datasets.DATASETS["microcosm_uk_2024_25@a133f37a"] + assert "region_datasets" not in fallback + assert fallback["bundle_certification"] is datasets.BUNDLE_715 + assert (datasets.BUNDLE_715["model_version"], datasets.BUNDLE_715["bundle_version"]) == ("2.147.2", None) + assert fallback["release_manifest"] == datasets.MICROCOSM_RELEASE_MANIFEST_A133 + + def test_changed_installed_manifest_is_rejected(released): spec, versions, _, _, path = released path.write_text("{}") diff --git a/tests/test_certified_preflight.py b/tests/test_certified_preflight.py index 787d3baf..42baadf0 100644 --- a/tests/test_certified_preflight.py +++ b/tests/test_certified_preflight.py @@ -12,8 +12,8 @@ from triple_lock import datasets, pipeline -PINS = {"policyengine": "99.0.0", "policyengine-uk": "2.147.2", "policyengine-core": "3.33.3"} -PRIMARY = "microcosm_uk_2024_25@a133f37a" +PINS = {"policyengine": "99.0.0", "policyengine-uk": "2.151.0", "policyengine-core": "3.33.3"} +PRIMARY = "microcosm_uk_2024_25@a6299504" @pytest.fixture @@ -21,6 +21,8 @@ def certified(monkeypatch): # Fictional released carrier: production pins remain pending. monkeypatch.setitem(datasets.CERTIFIED_BUNDLE, "bundle_version", PINS["policyengine"]) monkeypatch.setitem(datasets.CERTIFIED_BUNDLE, "manifest_sha256", "a" * 64) + monkeypatch.setitem(datasets.CERTIFIED_BUNDLE, "certification_url", "https://example.invalid/pull/1") + monkeypatch.setitem(datasets.CERTIFIED_BUNDLE, "certification_commit", "b" * 40) provenance = {"dataset": PRIMARY, "certified": True, "runtime_dataset_sha256": datasets.DATASETS[PRIMARY]["sha256"], "certification_bundle": dict(datasets.CERTIFIED_BUNDLE), @@ -58,7 +60,7 @@ def test_certified_preflight_refuses_wrong_or_missing_runtime_version(certified, def test_certified_preflight_refuses_other_primary_before_provenance(certified, monkeypatch, primary): monkeypatch.setattr(pipeline, "PRIMARY_DATASET", primary) monkeypatch.setattr(datasets, "provenance", lambda *a: pytest.fail("wrong primary is refused first")) - with pytest.raises(pipeline.UncertifiedBundle, match="Microcosm a133f37a"): + with pytest.raises(pipeline.UncertifiedBundle, match="Microcosm a6299504"): pipeline.certified_bundle_preflight(log=lambda _: None) @@ -66,7 +68,7 @@ def test_certified_preflight_refuses_other_primary_before_provenance(certified, def test_certified_preflight_requires_true_manifest_certification(certified, certification): _, provenance = certified provenance["certified"] = certification - with pytest.raises(pipeline.UncertifiedBundle, match="#715|released manifest"): + with pytest.raises(pipeline.UncertifiedBundle, match="d2011|released manifest"): pipeline.certified_bundle_preflight(log=lambda _: None) @@ -77,6 +79,14 @@ def test_certified_preflight_refuses_mismatched_provenance_primary(certified): pipeline.certified_bundle_preflight(log=lambda _: None) +@pytest.mark.parametrize("field", ["certification_url", "certification_commit"]) +def test_certified_preflight_refuses_a_release_without_its_certifying_pull_request(certified, monkeypatch, field): + monkeypatch.setitem(datasets.CERTIFIED_BUNDLE, field, None) + monkeypatch.setattr(datasets, "provenance", lambda *a: pytest.fail("an unnamed certifier is refused first")) + with pytest.raises(pipeline.UncertifiedBundle, match="certifying pull"): + pipeline.certified_bundle_preflight(log=lambda _: pytest.fail("no pass log")) + + def test_certified_preflight_refuses_tampered_installed_release_manifest(tmp_path, monkeypatch): """Exercise the real dataset certification helper, without a model import or dataset load.""" manifest = tmp_path / "manifest.json" @@ -90,7 +100,7 @@ def test_certified_preflight_refuses_tampered_installed_release_manifest(tmp_pat monkeypatch.setitem(sys.modules, "policyengine_uk.system", None) monkeypatch.setattr(datasets, "materialize", lambda *a, **kw: pytest.fail("preflight reads no survey bytes")) assert datasets.provenance(PRIMARY)["certified"] is False - with pytest.raises(pipeline.UncertifiedBundle, match="#715|released manifest"): + with pytest.raises(pipeline.UncertifiedBundle, match="d2011|released manifest"): pipeline.certified_bundle_preflight(log=lambda _: None) diff --git a/tests/test_datasets.py b/tests/test_datasets.py index 26e13187..b7dec6f1 100644 --- a/tests/test_datasets.py +++ b/tests/test_datasets.py @@ -61,13 +61,14 @@ def test_materialize_refuses_a_file_that_does_not_match_its_pin(tmp_path): def test_provenance_records_pending_exact_candidate_without_claiming_certification(): pytest.importorskip("policyengine_uk") p = datasets.provenance(PRIMARY_DATASET) - assert p["model_version"] == "2.147.2" == engine.package_versions()["policyengine-uk"] + assert p["model_version"] == "2.151.0" == engine.package_versions()["policyengine-uk"] assert p["core_version"] == "3.33.3" assert p["bundle_version"] == importlib.metadata.version("policyengine") - assert p["certified"] is False and "#715" in p["certification"] + assert p["certified"] is False and "d2011" in p["certification"] assert "certification_bundle" not in p assert p["runtime_dataset"] == "microcosm_uk_2024_25" - assert p["runtime_dataset_sha256"] == "64f83900ca3a1242fb017585234844ce89f743d32e41cd2b6558057777f914aa" + assert p["runtime_dataset_sha256"] == "b96a8cd625b645a409c063b1de5158532708a2f0d3c3897a794a8fb0bbd5064e" + assert p["dataset"] == "microcosm_uk_2024_25@a6299504" assert p["release_manifest"] == datasets.MICROCOSM_RELEASE_MANIFEST assert p["data_version"] == "0.1.0" assert p["data_build_id"] == datasets.MICROCOSM_REVISION @@ -117,11 +118,11 @@ def test_the_pins_agree(): pyproject = tomllib.loads((REPO / "pyproject.toml").read_text()) dependencies = pyproject["project"]["optional-dependencies"]["uk"] assert "policyengine==6.10.1" in dependencies - assert "policyengine-uk==2.147.2" in dependencies + assert "policyengine-uk==2.151.0" in dependencies assert "policyengine-core==3.33.3" in dependencies assert not any(d.startswith("policyengine[uk]") for d in dependencies) lock = (REPO / "requirements-lock.txt").read_text().splitlines() - pins = {"policyengine": "6.10.1", "policyengine-uk": "2.147.2", "policyengine-core": "3.33.3"} + pins = {"policyengine": "6.10.1", "policyengine-uk": "2.151.0", "policyengine-core": "3.33.3"} for package, version in pins.items(): assert f"{package}=={version}" in lock try: @@ -133,7 +134,7 @@ def test_the_pins_agree(): bundled = [Requirement(r) for r in importlib.metadata.requires("policyengine")] uk_pins = {r.name: str(r.specifier) for r in bundled if r.marker and r.marker.evaluate({"extra": "uk"}) and r.name in pins} - assert uk_pins != {"policyengine-uk": "==2.147.2", "policyengine-core": "==3.33.3"} + assert uk_pins != {"policyengine-uk": "==2.151.0", "policyengine-core": "==3.33.3"} # The released extra differs: installing it would conflict with the candidate model pin. diff --git a/tests/test_microcosm_primary.py b/tests/test_microcosm_primary.py index 3cc3d1f0..375957e1 100644 --- a/tests/test_microcosm_primary.py +++ b/tests/test_microcosm_primary.py @@ -9,7 +9,7 @@ def test_binding_roles_and_current_wording_preserve_frozen_pipeline_constants(): - assert config.PRIMARY_DATASET == "microcosm_uk_2024_25@a133f37a" + assert config.PRIMARY_DATASET == "microcosm_uk_2024_25@a6299504" assert config.SENSITIVITY_DATASET == "enhanced_frs_2024_25@1.58.2" assert datasets.resolve() == config.PRIMARY_DATASET assert datasets.ageing_anchor()["year"] == 2024 @@ -20,7 +20,8 @@ def test_binding_roles_and_current_wording_preserve_frozen_pipeline_constants(): assert "Microcosm is an uncertified sensitivity" in " ".join(pipeline.METHOD_LIMITATIONS) current = " ".join(pipeline.method_limitations()) assert "Microcosm is an uncertified sensitivity" not in current - for wanted in ("#715", "2.147.2", "3.33.3", "1.58.2", "#2557", "#2576", "d1753", "Vahid", "step (b)"): + for wanted in ("d2011", "a6299504", "2.151.0", "3.33.3", "1.58.2", "#2557", "#2576", "d1753", "Vahid", + "step (b)"): assert wanted in current # Preserve exactly the historical imports/constants and redactor that C2 protects. import cold_source_correspondence as cold From dede8463c12a2a72266fc8da994449a3904dc428 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 25 Oct 2026 14:40:18 -0700 Subject: [PATCH 02/14] Anchor both Microcosm cuts' ageing at their 2025 calibration year Cherry-picks #40 (4edb1c0) onto the bind, and applies the same correction to d2011's a6299504. Each build solves its household weights at 2025 and writes them at the 2024 data year divided by policyengine-uk's own 2024-to-2025 projection (build_record.json export readback weight_year: dataset 2024, solve 2025, factor 1.0072; microcosm weight_year.py, #796 A19). Its calibration_diagnostics.json dates every target 2025 (1,259 for a133f37a, 1,268 for a6299504). So the model's 2025 weights are the solved ones, and the registry's rule (a build's calibration year is its anchor) puts both anchors at 2025. a133f37a's entry had said no calibration year was declared. A new test holds that rule for every registry entry. No binding Microcosm run exists yet, so no result moves. Co-Authored-By: Claude Opus 5.5 --- docs/CALIBRATION_ANCHOR.md | 2 +- docs/METHOD.md | 6 +++--- docs/REBUILD.md | 2 +- src/triple_lock/datasets.py | 22 ++++++++++++++-------- src/triple_lock/demography.py | 4 ++-- src/triple_lock/engine.py | 4 ++-- src/triple_lock/pipeline.py | 5 +++-- tests/test_datasets.py | 11 ++++++++++- tests/test_microcosm_primary.py | 12 +++++++----- 9 files changed, 43 insertions(+), 25 deletions(-) diff --git a/docs/CALIBRATION_ANCHOR.md b/docs/CALIBRATION_ANCHOR.md index a8ffb9fd..b77039b8 100644 --- a/docs/CALIBRATION_ANCHOR.md +++ b/docs/CALIBRATION_ANCHOR.md @@ -1,6 +1,6 @@ # Calibration anchor and certified-input bridge -> **Integration (part D).** The anchor is now part of each dataset's registry entry (`datasets.ageing_anchor`: 2025 for Enhanced FRS 1.56.16 and 1.57.4, read from `frs_release.py` at each tag; Microcosm has no published calibration year, so its anchor is 2024, the first year the ONS projection covers). The rake keeps the dataset's own weights in the anchor year and every year before it (`demography.annual_weights`). The level difference described below, runtime 2025 weights = calibrated weights × 1.0072 × 1.027 / 1.039 = 0.9956, is reported upstream as policyengine-uk-data#538. Re-checked on policyengine-uk 2.120.0: every 2025 weight is its 2024 weight × 1.0072. +> **Integration (part D).** The anchor is now part of each dataset's registry entry (`datasets.ageing_anchor`: 2025 for Enhanced FRS 1.56.16 and 1.57.4, read from `frs_release.py` at each tag; Microcosm has no published calibration year, so its anchor is 2024, the first year the ONS projection covers). *Superseded for Microcosm national cuts from a133f37a: their builds solve the weights at 2025 and write them at the data year divided by the model's own projection, so they anchor at 2025 ([METHOD.md](METHOD.md)).* The rake keeps the dataset's own weights in the anchor year and every year before it (`demography.annual_weights`). The level difference described below, runtime 2025 weights = calibrated weights × 1.0072 × 1.027 / 1.039 = 0.9956, is reported upstream as policyengine-uk-data#538. Re-checked on policyengine-uk 2.120.0: every 2025 weight is its 2024 weight × 1.0072. The pilot uses **2025**, the calibration year configured in the exact source release for its certified Enhanced FRS. It anchors to the runtime's native 2025 weights. Those weights do not restore the builder's original calibrated weights, so preserved Housing Benefit and Pension Credit calibration remains an acceptance gate. diff --git a/docs/METHOD.md b/docs/METHOD.md index 7ee8a0a5..36d9195f 100644 --- a/docs/METHOD.md +++ b/docs/METHOD.md @@ -62,7 +62,7 @@ The guarantee's earnings duty ([SSAA 1992 s150A](https://www.legislation.gov.uk/ | `total_matched` (available follow-up) | represented | sum of the age/sex anchored targets after the anchor year | survey year's | - **Represented ages.** Records top-coded at 80 get a represented age from 80 to 105 (105+): within each sex a deterministic hash of the verified dataset content and synthetic ordering key orders them and weighted midpoints take the anchor year's ONS single-age shares, so the weighted 80+ total is unchanged. Other ages are the survey's. If any age in a dataset is above 80 it is not top-coded and every age is kept. Every record also gets a fixed birthday within its year of age (`months_since_last_birthday`), drawn as policyengine-uk draws it, on the data year's weights (upstream's own input), so every record that keeps its survey age keeps policyengine-uk's birthday and type; a run fails if one's data-year type changes. Represented ages use the anchor year's ONS shares and weights. Both are pinned in every year, and anything the model derived from the survey ages before is cleared (`demography._clear_derived`), so the model's own State Pension age (`is_SP_age`, by date of birth) and every reader of it in the engine (the State Pension age band, poverty, the age bands, coverage) see the represented ages. The survey's household and benefit-unit head flags are pinned too, so ties among represented ages cannot move a head. -- **Household weights.** In the anchor year and every year before it the weights are the dataset's own, exactly (`demography.annual_weights`): the rake never moves the calibrated weights and never runs back to the survey year. The anchor is the data build's calibration year, from its source (`datasets.ageing_anchor`: 2025 for the Enhanced FRS, `frs_release.py`; for Microcosm, its 2024-25 data year, the first year the projection covers). Microcosm's builds date their calibration targets 2025 (`calibration_diagnostics.json`; `run_config.calibration_year`), so whether 2025 should also be its anchor is open before the binding rebuild. After it, household weights are raked (bounded minimum relative entropy, ratios 0.2 to 5 of the anchor year's; a convex problem with one solution, which `demography.rake_households` finds by least squares on the calibration residual (the dual's gradient) and, where that stalls on a bound, by Newton or gradient steps on the dual) so that every sex and age cell (five-year bands to 59, single ages 60 to 79, 80-84, 85-89, 90+) grows from its anchor-year survey count as the ONS 2024-based UK principal projection's does ([ONS_PROJECTION.md](ONS_PROJECTION.md); fiscal year = 75% of mid-year *y* + 25% of *y*+1). Targets are survey counts times ONS growth, not ONS levels. Person and benefit-unit weights are their household's. A target the bounds cannot reach fails the run. A property test checks that, for any growth, the raked weights equal the dataset's own through the anchor year. +- **Household weights.** In the anchor year and every year before it the weights are the dataset's own, exactly (`demography.annual_weights`): the rake never moves the calibrated weights and never runs back to the survey year. The anchor is the data build's calibration year, from its source (`datasets.ageing_anchor`: 2025 for the Enhanced FRS, `frs_release.py`; 2025 for the Microcosm primary, whose build solves its household weights at 2025 and writes them divided by policyengine-uk's own 2024-to-2025 projection, so the model's 2025 weights are the solved ones). After it, household weights are raked (bounded minimum relative entropy, ratios 0.2 to 5 of the anchor year's; a convex problem with one solution, which `demography.rake_households` finds by least squares on the calibration residual (the dual's gradient) and, where that stalls on a bound, by Newton or gradient steps on the dual) so that every sex and age cell (five-year bands to 59, single ages 60 to 79, 80-84, 85-89, 90+) grows from its anchor-year survey count as the ONS 2024-based UK principal projection's does ([ONS_PROJECTION.md](ONS_PROJECTION.md); fiscal year = 75% of mid-year *y* + 25% of *y*+1). Targets are survey counts times ONS growth, not ONS levels. Person and benefit-unit weights are their household's. A target the bounds cannot reach fails the run. A property test checks that, for any growth, the raked weights equal the dataset's own through the anchor year. - **Cohort types.** Basic for men born before 6 April 1951 and women born before 6 April 1953 (State Pension age before 6 April 2016, by the Pensions Act 1995 Schedule 4 timetable: Pensions Act 2014 s.1(2)), new from those dates, none below State Pension age, from the birth date the represented age and birthday give at 6 October of each year. A test checks they equal policyengine-uk's own `state_pension_type` on the same ages and birthday in every year (on 2.120.0; part B's oracle tests passed on 2.118.0 too). **Inputs held the same under both rules** (`engine.pinned_inputs`): @@ -81,7 +81,7 @@ The revised [Pensions Act 2014 s.4](https://www.legislation.gov.uk/ukpga/2014/19 **Possible matched-total follow-up.** The implemented `total_matched` option uses the exact sum of the age/sex anchored targets as its single margin: Σ_c S_c × P_c(y)/P_c(anchor), where S_c is the anchor survey count and P_c the ONS cell population. It otherwise shares represented ages, birthdays, survey-year types, bounds and native weights through the anchor. It could compare the age/sex margins at a shared aggregate target, but needs its own full runs and source-bound readback checks. That supplement was dropped from part E's closeout as a scope cut; part E reports the ONS total-only control above. No matched-total estimate is published. -**The Enhanced FRS calibration year's level.** This is a sensitivity/bridge caveat; Microcosm anchors at its 2024 data year. The Enhanced FRS builder calibrates its weights in 2025 and saves them to 2024 with its own weight index (2024: 1.027, 2025: 1.039); policyengine-uk uprates them back to 2025 by its population growth (0.72%). In a simulation the 2025 weights are therefore the calibrated ones times 0.9956: relative weights exactly the calibrated ones, every 2025 total 0.44% low ([policyengine-uk-data#538](https://github.com/PolicyEngine/policyengine-uk-data/issues/538)). Static ageing keeps the simulation's own 2025 weights, so it neither adds to the gap nor closes it; the fix belongs to the data build or the model. +**The Enhanced FRS calibration year's level.** This is a sensitivity/bridge caveat. Microcosm's build divides its solved weights by the model's own projection factor (1.0072 from 2024 to 2025), so its 2025 weights are exactly the solved ones. The Enhanced FRS builder calibrates its weights in 2025 and saves them to 2024 with its own weight index (2024: 1.027, 2025: 1.039); policyengine-uk uprates them back to 2025 by its population growth (0.72%). In a simulation the 2025 weights are therefore the calibrated ones times 0.9956: relative weights exactly the calibrated ones, every 2025 total 0.44% low ([policyengine-uk-data#538](https://github.com/PolicyEngine/policyengine-uk-data/issues/538)). Static ageing keeps the simulation's own 2025 weights, so it neither adds to the gap nor closes it; the fix belongs to the data build or the model. **Private cache.** The population (represented ages, birthdays, the weights and the State Pension age mask and types of every year) is computed once per dataset, treatment, anchor, set of years and model (`demography.eligibility_fingerprint`: the installed policyengine-uk and the files its State Pension age and type come from) and cached under `.cache/demography`, owner-only; every path job shares one entry, and the coverage and history jobs, with other years, have their own. A run reads the model's own `is_SP_age` back against the cached mask in every year and fails if they differ, so a change to the model's State Pension age cannot reuse stale types. Nothing that depends on a path is cached: the additional pension follows each path's September CPI. No record id, weight or amount leaves the cache; an ageing run returns no single-record diagnostic, and a failed path, coverage or history job keeps its error output in an owner-only file in an owner-only worker directory instead of the build log (`jobs.private_inputs`). @@ -173,7 +173,7 @@ A scenario is one full run of the central path with its specified rates, nothing **Coverage.** One coverage job per dataset runs the central path under the triple lock, as the central run's triple-lock policy does (the same population treatment), for 2026-27 to 2030-31, 2034-35 and 2039-40. It gives each year's State Pension (by part, recipients and types, and in Great Britain by age, where every cell rests on at least ten records), Pension Credit (guarantee and savings credit, and claims), Housing Benefit and pension-age Housing Benefit (spending and claims), council tax reduction and Universal Credit, for the UK and for Great Britain. The results set Great Britain against DWP, like for like, in every year DWP's tables give. Pension-age Housing Benefit is Housing Benefit paid under the pension-age regulations (`housing_benefit_pension_age_regulations_apply`), nearest DWP's "over Pension Credit qualifying age". 2034-35 and 2039-40 have no DWP figure. -The population here is the path runs': static ageing, ONS growth after the dataset's anchor year and cohort pension types. Microcosm anchors at its 2024 data year; Enhanced FRS anchors at its 2025 calibration year. Ages above 80 demonstrate that a dataset is not top-coded there and are retained. The deterministic synthetic ordering key matches the model's 64-bit-id to 32-bit wrapping without publishing identifiers. The State Pension age follows the law's timetable by date of birth, so every age of 67 and over is above it from 2028-29. DWP's figures come from its own projection of the population and of contribution records; matched by-type, by-age and post-2030 benchmarks are unavailable (part B's [AGEING_PILOT.md](AGEING_PILOT.md) lists them). +The population here is the path runs': static ageing, ONS growth after the dataset's anchor year and cohort pension types. Both the Microcosm primary and Enhanced FRS anchor at their 2025 calibration year. Ages above 80 demonstrate that a dataset is not top-coded there and are retained. The deterministic synthetic ordering key matches the model's 64-bit-id to 32-bit wrapping without publishing identifiers. The State Pension age follows the law's timetable by date of birth, so every age of 67 and over is above it from 2028-29. DWP's figures come from its own projection of the population and of contribution records; matched by-type, by-age and post-2030 benchmarks are unavailable (part B's [AGEING_PILOT.md](AGEING_PILOT.md) lists them). ## The certified model and the historical pilot (`datasets.py`) diff --git a/docs/REBUILD.md b/docs/REBUILD.md index e0455760..fa122060 100644 --- a/docs/REBUILD.md +++ b/docs/REBUILD.md @@ -36,7 +36,7 @@ a133f37a's staged attempt was released under the tag with its timestamp and suff 5. Run **(a), model step**: 6.2.6 / UK 2.125.1 / core 3.33.0 → UK 2.151.0 / core 3.33.3, holding **Enhanced FRS 1.58.0** fixed. The new intermediate pair is uncertified. Run **(b), data step**: **Enhanced FRS 1.58.0 → Microcosm a6299504**, holding the same installed packages and engine sources fixed. Only that final endpoint must have released certification. Each endpoint runs the complete central/coverage/frozen forty-path design. Data/from must reuse exactly model/to's receipt; no dataset-family equality is assumed. 6. Review fresh accounting and GB/DWP coverage before feeding a regenerated binding C2 handoff to the full fiscal build. The primary/sensitivity config change alters its input hash: regenerate it through C2, and preserve every historical handoff/receipt byte. -**Open before the binding rebuild: Microcosm's anchor year.** The registry anchors both Microcosm cuts' static ageing at their 2024-25 data year. Their builds date every calibration target 2025 (`calibration_diagnostics.json`; `run_config.calibration_year`: 1,259 targets for a133f37a and 1,268 for a6299504), and a133f37a's release build manifest records `household_weight_year` `{dataset_year: 2024, basis: dataset_year}`. Under the registry's own rule (the Enhanced FRS and Microcosm 5c6b3f68 entries), a build's calibration year is its anchor. Whether Microcosm's calibrated weights are its 2024 or its model-uprated 2025 weights decides which year it should be. This must be resolved, from microcosm's calibration code, before the 29 October rebuild. +**Microcosm's anchor year.** Both Microcosm cuts anchor static ageing at 2025, their calibration year (#40). Each build solves its household weights at 2025 and writes them at the 2024 data year divided by policyengine-uk's own projection factor (1.0072), so the model's 2025 weights are the solved ones (`build_record.json`'s export readback `weight_year`; microcosm's `weight_year.py`, microcosm#796 A19). a133f37a's entry had said it declared no calibration year. **d1753 on this bundle.** UK 2.151.0 has neither Savings Credit `maximum` nor either linked projection builder. Tests also cover a `maximum`-present/builders-absent system defensively. Keep its installed **CPI** HB allowances and Savings Credit thresholds as headline. Since #2557 moved `gov.benefit_uprating_cpi` to September CPI, those amounts name `gov.economic_assumptions.indices.obr.lagged_cpi` themselves, so they keep the lagged calendar-CPI projection they had on 2.147.2. The engine still applies the statutory earnings-linked guarantee. Gate linked derivation on the actual builders, not merely on `maximum`. The separate foreground comparison below runs the installed-CPI and explicitly guarantee-linked conventions as complete central triple-lock/Burnham pairs; it does not change the default. diff --git a/src/triple_lock/datasets.py b/src/triple_lock/datasets.py index 2ee1b809..8c946d8e 100644 --- a/src/triple_lock/datasets.py +++ b/src/triple_lock/datasets.py @@ -141,10 +141,12 @@ def microcosm_release_manifest(revision): "bundle_certification": CERTIFIED_BUNDLE, "release_manifest": MICROCOSM_RELEASE_MANIFEST, "region_datasets": BINDING_REGION_DATASETS, - "ageing_anchor": {"year": 2024, "calibration_year": None, - "source": "Microcosm 2024-25 data year, anchored as a133f37a is (d1983). The build dates its " - "calibration targets 2025 (build_record.json run_config.calibration_year); " - "whether that year should be the anchor is open before the binding rebuild"}, + # Solved at 2025 and written divided by the model's 2024-to-2025 projection, as a133f37a's are. + "ageing_anchor": {"year": 2025, "calibration_year": 2025, + "source": "uk-frs-calibration-attempt-20261025T091414Z-a6299504: build_record.json " + "uk_national_export_readback weight_year (dataset_year 2024, solve_year 2025, " + "engine_projection_factor 1.0072); calibration_diagnostics.json dates all 1,268 " + "targets 2025"}, }, "microcosm_uk_2024_25@a133f37a": { "logical_name": "microcosm_uk_2024_25", @@ -157,10 +159,14 @@ def microcosm_release_manifest(revision): "certified": [], # #715 is draft; producer gates are not released bundle certification. "bundle_certification": BUNDLE_715, "release_manifest": MICROCOSM_RELEASE_MANIFEST_A133, - "ageing_anchor": {"year": 2024, "calibration_year": None, - "source": "Microcosm 2024-25 data year; use the first ONS projection fiscal year, 2024-25. " - "The build dates its calibration targets 2025 (calibration_diagnostics.json, " - "run_config.calibration_year)"}, + # The build solves its household weights at 2025 and writes them divided by policyengine-uk's own + # 2024-to-2025 projection of household_weight, so the model's 2025 weights are the solved ones + # (microcosm weight_year.py, microcosm#796 A19), as the Enhanced FRS's are its calibrated ones. + "ageing_anchor": {"year": 2025, "calibration_year": 2025, + "source": "microcosm-uk-2024-25-national-20261023T075039Z-a133f37a: build_record.json " + "uk_national_export_readback weight_year (dataset_year 2024, solve_year 2025, " + "engine_projection_factor 1.0072); calibration_diagnostics.json dates all 1,259 " + "targets 2025"}, }, "enhanced_frs_2024_25@1.58.2": { "logical_name": "enhanced_frs_2024_25", diff --git a/src/triple_lock/demography.py b/src/triple_lock/demography.py index 614cdec2..e1b3bce2 100644 --- a/src/triple_lock/demography.py +++ b/src/triple_lock/demography.py @@ -475,8 +475,8 @@ def _save_private(path, arrays): def resolve_anchor(sim, anchor=None): """The year the treatment anchors its weights to: ``anchor`` if given, else the dataset's (datasets.ageing_anchor, recorded on the simulation by engine._managed), else a ``calibration_year`` the dataset - object declares. Microcosm has no calibration year, so its registry supplies - the verified data-year anchor explicitly. Fails without one: a guessed year + object declares. Microcosm's dataset object declares none, so its registry + supplies the verified anchor explicitly. Fails without one: a guessed year could rake calibrated weights.""" if anchor is None: anchor = (getattr(sim, "triple_lock_provenance", None) or {}).get("ageing_anchor", {}).get("year") diff --git a/src/triple_lock/engine.py b/src/triple_lock/engine.py index a130e033..73457e88 100644 --- a/src/triple_lock/engine.py +++ b/src/triple_lock/engine.py @@ -64,8 +64,8 @@ * the population is the run's treatment (config.DEMOGRAPHY_MODES; the model-v2 treatment ``both`` unless the spec names another): represented ages for an entirely top-coded 80+ tail (uncapped ages are retained), a fixed - birthday, ONS-projection weights after the verified anchor year (2024 for - Microcosm without a published calibration year; 2025 for Enhanced FRS), and + birthday, ONS-projection weights after the verified anchor year (the + calibration year: 2025 for the Microcosm primary and for Enhanced FRS), and State Pension types by cohort (demography.py), pinned on every simulation of the run (pinned_inputs); * the State Pension age is the model's own, by date of birth (the Pensions diff --git a/src/triple_lock/pipeline.py b/src/triple_lock/pipeline.py index 66b4aa0c..ac450687 100644 --- a/src/triple_lock/pipeline.py +++ b/src/triple_lock/pipeline.py @@ -133,8 +133,9 @@ def method_limitations(): elif text.startswith("The population is aged statically"): wording[i] = ( "The population is aged statically: each record represents a person of its age in every year. " - "The Microcosm primary is anchored at its 2024-25 data year: its own weights are retained " - "through that year and then follow ONS 2024-based age/sex growth. Enhanced FRS 1.58.2 anchors in its 2025-26 calibration year. Uncapped ages " + "The Microcosm primary and Enhanced FRS 1.58.2 both anchor in their 2025-26 calibration year: " + "each dataset's own weights, the calibrated ones in that year, are retained through it and then " + "follow ONS 2024-based age/sex growth. Uncapped ages " "are retained, including genuine ages of 80; only an entirely top-coded 80+ tail gets " "represented ages of 80 to 105. State Pension types follow birth cohorts. Deaths, migration " "and contribution histories are not simulated; pensioners abroad are outside both datasets.") diff --git a/tests/test_datasets.py b/tests/test_datasets.py index b7dec6f1..d1623dfa 100644 --- a/tests/test_datasets.py +++ b/tests/test_datasets.py @@ -72,7 +72,7 @@ def test_provenance_records_pending_exact_candidate_without_claiming_certificati assert p["release_manifest"] == datasets.MICROCOSM_RELEASE_MANIFEST assert p["data_version"] == "0.1.0" assert p["data_build_id"] == datasets.MICROCOSM_REVISION - assert p["ageing_anchor"]["calibration_year"] is None + assert p["ageing_anchor"]["year"] == p["ageing_anchor"]["calibration_year"] == 2025 @pytest.mark.parametrize("name", ["enhanced_frs_2024_25@1.56.16", SENSITIVITY_DATASET]) @@ -155,3 +155,12 @@ def test_materialize_hashes_each_file_once_per_process(tmp_path, monkeypatch): path.write_bytes(b"changed bytes!") # a different size: a new stamp with pytest.raises(datasets.DatasetMismatch): datasets.materialize(PRIMARY_DATASET, store=tmp_path, download=_fake(content)) + + +@pytest.mark.parametrize("name", sorted(datasets.DATASETS)) +def test_a_declared_calibration_year_is_the_ageing_anchor(name): + """The rake never moves calibrated weights: where a build has a calibration year, static ageing anchors there.""" + anchor = datasets.ageing_anchor(name) + if anchor["calibration_year"] is not None: + assert anchor["year"] == anchor["calibration_year"] + assert anchor["year"] >= 2024 and anchor["source"] diff --git a/tests/test_microcosm_primary.py b/tests/test_microcosm_primary.py index 375957e1..215a723e 100644 --- a/tests/test_microcosm_primary.py +++ b/tests/test_microcosm_primary.py @@ -12,8 +12,8 @@ def test_binding_roles_and_current_wording_preserve_frozen_pipeline_constants(): assert config.PRIMARY_DATASET == "microcosm_uk_2024_25@a6299504" assert config.SENSITIVITY_DATASET == "enhanced_frs_2024_25@1.58.2" assert datasets.resolve() == config.PRIMARY_DATASET - assert datasets.ageing_anchor()["year"] == 2024 - assert datasets.ageing_anchor()["calibration_year"] is None + assert datasets.ageing_anchor()["year"] == 2025 + assert datasets.ageing_anchor()["calibration_year"] == 2025 assert datasets.ageing_anchor(config.SENSITIVITY_DATASET)["year"] == 2025 assert "enhanced_frs_2024_25@1.58.0" in datasets.DATASETS assert pipeline.sensitivity_dataset_names()[config.SENSITIVITY_DATASET] == "Enhanced FRS 1.58.2" @@ -99,13 +99,15 @@ def test_microcosm_style_ids_ages_anchor_and_state_pension_identity(tmp_path, mo sim.triple_lock_provenance = {"dataset": config.PRIMARY_DATASET, "ageing_anchor": datasets.ageing_anchor()} np.testing.assert_array_equal(sim.calculate("person_id", 2024).to_numpy(), (ids + 100).astype(np.int32)) - native = sim.calculate("household_weight", 2024).to_numpy().copy() + native = {y: sim.calculate("household_weight", y).to_numpy().copy() for y in (2024, 2025)} pinned, year = engine.pinned_inputs(sim, [2026, 2027], {y: .02 for y in range(2024, 2027)}, "both") - assert year == pinned.treatment.anchor == 2024 + assert (year, pinned.treatment.anchor) == (2024, 2025) assert pinned.treatment.uncapped_age_fallback and not pinned.treatment.represented_topcoding_applied for y in (2024, 2026, 2027): np.testing.assert_array_equal(pinned["age"][y], ages) - np.testing.assert_array_equal(pinned.treatment.weights[2024], native) + # The dataset's own weights through the anchor: the model's 2025 projection is where the build's solve sits. + for y in (2024, 2025): + np.testing.assert_array_equal(pinned.treatment.weights[y], native[y]) engine.pin(sim, pinned) assert engine.state_pension_accounting(sim, pinned)["max_identity_error_gbp"] <= .01 assert max(demography.readback(sim, pinned.treatment, [2026, 2027])["max_relative_cell_error"].values()) <= 1e-6 From 72898f61c4146b84267511d68013eacde93a1118 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 25 Oct 2026 15:22:14 -0700 Subject: [PATCH 03/14] Accept 6.11.0-form certificates, route the a133 fallback as d2011 says, and script the release fill - Read the certificate's covered core by policyengine.py's own rule (provenance/manifest.py DataCertification, provenance/receipt.py _certificate_fields): the named certified_for_core_version under core_binding "certified", else the bundle's core pin ("bundle_pin", or no binding before October 2026). 6.11.0's bundle_pin certificate has no certified_for_core_version, so a genuine release in that form was refused. - d2011's fallback is a133f37a with policyengine.py #699's area routing, not #715's national-only candidate: the entry now requires national plus constituency and local authority to one local-area file, with every release identity pending in datasets.FALLBACK_*. - scripts/fill_release_identities.py reads the UK bundle manifest at the release tag (the release workflow's version commit rewrites it after the certifying merge), checks every pinned identity, and with --apply fills the five pending ones, the carrier pins and data/binding-bridge.json. A rerun on the installed wheel's manifest checks the filled identities. - The release-manifest tests run for both pairs; new core-binding and fill tests. Stale d2011 and #715 wording updated outside the frozen C2 section. Co-Authored-By: Claude Opus 5.5 --- data/binding-bridge.pending.json | 2 +- docs/METHOD.md | 6 +- docs/REBUILD.md | 11 +- docs/d2011-upstream.md | 2 +- scripts/fill_release_identities.py | 177 +++++++++++++++++++++++ src/triple_lock/datasets.py | 60 ++++++-- src/triple_lock/pipeline.py | 2 +- tests/test_budget_bundle.py | 77 ++++++++-- tests/test_budget_model_projection.py | 2 +- tests/test_fill_release_identities.py | 200 ++++++++++++++++++++++++++ 10 files changed, 499 insertions(+), 40 deletions(-) create mode 100644 scripts/fill_release_identities.py create mode 100644 tests/test_fill_release_identities.py diff --git a/data/binding-bridge.pending.json b/data/binding-bridge.pending.json index 10d62bd5..abdaa289 100644 --- a/data/binding-bridge.pending.json +++ b/data/binding-bridge.pending.json @@ -62,6 +62,6 @@ "bundle_manifest_path": "src/policyengine/data/bundle/manifest.json", "policyengine_version": null, "bundle_manifest_sha256": null, - "gate": "d2011 = A: the 2.151.0 national (microcosm#1396, a6299504) and its local-area cut certified together; fallback to #715's a133f37a pair if not certified by 2026-10-27T18:00Z" + "gate": "d2011 = A: the 2.151.0 national (microcosm#1396, a6299504) and its local-area cut certified together; fallback to #715's a133f37a pair with policyengine.py #699's area routing if not certified by 2026-10-27T18:00Z" } } diff --git a/docs/METHOD.md b/docs/METHOD.md index 36d9195f..85642094 100644 --- a/docs/METHOD.md +++ b/docs/METHOD.md @@ -2,7 +2,7 @@ The committed `data/results.json` is the **certified pre-Budget release**, provisional and model-conditional under uncertainty ruling (c). Every fiscal figure is a full PolicyEngine UK run on policyengine.py **6.2.6**, UK **2.125.1**, core **3.33.0** and Enhanced FRS **1.58.0**, with static ageing below. That release, publicized in Vahid's post as provisional, uses Enhanced FRS as primary and the earlier Microcosm artifact as its uncertified paired sensitivity. The original uncertified model-v2 pilot (UK 2.120.0 / EFRS 1.56.16) is retained separately in `data/pilot`; model-v2 is merged. The completed historical bridge and rehearsal are bound by [J-completed-verification](../data/pilot/J-completed-verification.json). -Max's **d1983 = B** ruling on 24 October put the binding successor's primary on the certified Microcosm UK Budget pair, superseding d1727's qualification-ledger primary. His **d2011 = A** ruling on 25 October chose that pair: the 2.151.0 national and local-area pair built at [microcosm#1396](https://github.com/PolicyEngine/microcosm/pull/1396), national cut `microcosm-uk-2024-25-national-20261025T091414Z-a6299504`, UK **2.151.0**, core **3.33.3**. If it is not certified by 27 October 18:00Z, the fallback is [policyengine.py #715](https://github.com/PolicyEngine/policyengine.py/pull/715)'s `a133f37a` / UK 2.147.2 pair. Enhanced FRS **1.58.2** is the paired sensitivity. The carrier release is pending, and certification fails closed until its exact released manifest is pinned. [REBUILD.md](REBUILD.md) gives the fixed-data model bridge on Enhanced FRS 1.58.0 and the cross-family data bridge to Microcosm. The pre-Budget and binding releases use different data; bridge step (b) measures that move. Historical pre-Budget provenance stays at 6.2.6 / 1.58.0. Nothing is scaled or interpolated from another run. The code is the reference. +Max's **d1983 = B** ruling on 24 October put the binding successor's primary on the certified Microcosm UK Budget pair, superseding d1727's qualification-ledger primary. His **d2011 = A** ruling on 25 October chose that pair: the 2.151.0 national and local-area pair built at [microcosm#1396](https://github.com/PolicyEngine/microcosm/pull/1396), national cut `microcosm-uk-2024-25-national-20261025T091414Z-a6299504`, UK **2.151.0**, core **3.33.3**. If it is not certified by 27 October 18:00Z, the fallback is [policyengine.py #715](https://github.com/PolicyEngine/policyengine.py/pull/715)'s `a133f37a` / UK 2.147.2 pair with [#699](https://github.com/PolicyEngine/policyengine.py/pull/699)'s area routing. Enhanced FRS **1.58.2** is the paired sensitivity. The carrier release is pending, and certification fails closed until its exact released manifest is pinned. [REBUILD.md](REBUILD.md) gives the fixed-data model bridge on Enhanced FRS 1.58.0 and the cross-family data bridge to Microcosm. The pre-Budget and binding releases use different data; bridge step (b) measures that move. Historical pre-Budget provenance stays at 6.2.6 / 1.58.0. Nothing is scaled or interpolated from another run. The code is the reference. ## The rules (`rules.py`) @@ -177,7 +177,7 @@ The population here is the path runs': static ageing, ONS growth after the datas ## The certified model and the historical pilot (`datasets.py`) -For the binding rebuild, the primary registry entry is `microcosm_uk_2024_25@a6299504`. It pins `policyengine/populace-uk-private`, expected revision `microcosm-uk-2024-25-national-20261025T091414Z-a6299504`, path `microcosm_uk_2024_25.h5`, SHA-256 `b96a8cd625b645a409c063b1de5158532708a2f0d3c3897a794a8fb0bbd5064e`: the bytes of the staged attempt `uk-frs-calibration-attempt-20261025T091414Z-a6299504`, built at microcosm `67ee2ddf3`. a133f37a's attempt was released under the matching tag with the same bytes; the tag is confirmed at release, and until it exists the download fails closed. The model wheel is UK 2.151.0, SHA-256 `5722f55aca3eb88e1a228e95e1a57e3bd70a4d530a5a000991565ebd8955ce35` (microcosm#1396's `uv.lock`; attested in `data/certification/d2011-model-wheel.json`), with core 3.33.3. The pair's released carrier also routes constituency and local-authority runs to its local-area file, and the registry requires exactly that routing. The released carrier version, manifest digest, certifying pull request and local-area file remain unset, so `datasets.provenance` and the publication preflight refuse certification now. #715's `microcosm_uk_2024_25@a133f37a` / UK 2.147.2 entry, national only, stays registered with its own pending identity as d2011's fallback. +For the binding rebuild, the primary registry entry is `microcosm_uk_2024_25@a6299504`. It pins `policyengine/populace-uk-private`, expected revision `microcosm-uk-2024-25-national-20261025T091414Z-a6299504`, path `microcosm_uk_2024_25.h5`, SHA-256 `b96a8cd625b645a409c063b1de5158532708a2f0d3c3897a794a8fb0bbd5064e`: the bytes of the staged attempt `uk-frs-calibration-attempt-20261025T091414Z-a6299504`, built at microcosm `67ee2ddf3`. a133f37a's attempt was released under the matching tag with the same bytes; the tag is confirmed at release, and until it exists the download fails closed. The model wheel is UK 2.151.0, SHA-256 `5722f55aca3eb88e1a228e95e1a57e3bd70a4d530a5a000991565ebd8955ce35` (microcosm#1396's `uv.lock`; attested in `data/certification/d2011-model-wheel.json`), with core 3.33.3. The pair's released carrier also routes constituency and local-authority runs to its local-area file, and the registry requires exactly that routing. The certificate must cover core 3.33.3 by policyengine.py's own rule: the core it names under `core_binding` `certified`, or else the bundle's core pin (`bundle_pin`). The released carrier version, manifest digest, certifying pull request and local-area file remain unset, so `datasets.provenance` and the publication preflight refuse certification now. #715's `microcosm_uk_2024_25@a133f37a` / UK 2.147.2 entry stays registered as d2011's fallback. #715's candidate routes national only, but d2011's fallback adds #699's area routing, so the entry requires that routing and keeps its own release identities, local-area file included, pending. The lock installs base `policyengine==6.10.1` as the available API carrier, plus exact `policyengine-uk==2.151.0` and `policyengine-core==3.33.3`; it deliberately omits `[uk]`, because the currently released carrier's extra pins its previous UK line. The engine constructs `policyengine_uk` directly and materializes its own registry-pinned dataset rather than asking that extra to select the data. This is a preparatory environment, not released-pair certification. Once the pair ships, fill only the authenticated identities left unset in `datasets.py`, and the carrier version in the lock and ready bridge config. @@ -218,7 +218,7 @@ Both Enhanced FRS releases load and compute on 2.118.0 and 2.120.0, and store th | `birth_year` is the calendar year of the date of birth (2.119.0); no rule reads it any more | nothing | No | | Universal Credit counts only the claimants' income, not their dependants' (UC Regs 2013 reg 22(1); 2.120.0) | Universal Credit baseline and offset | No | -Part A's version bridge rerun on 2.120.0 is retained as aggregate pilot evidence in [data/pilot/version_bridge.json](../data/pilot/version_bridge.json), with its calculation commit and model/data versions. It is an uncertified-pair pilot, not for quoting; [MODEL_V2_PILOT.md](MODEL_V2_PILOT.md) records the publication-support audit status. The pre-Budget release subsequently used core 3.33.0; the binding successor must authenticate #715's exact released UK 2.147.2 / core 3.33.3 pair ([REBUILD.md](REBUILD.md)). +Part A's version bridge rerun on 2.120.0 is retained as aggregate pilot evidence in [data/pilot/version_bridge.json](../data/pilot/version_bridge.json), with its calculation commit and model/data versions. It is an uncertified-pair pilot, not for quoting; [MODEL_V2_PILOT.md](MODEL_V2_PILOT.md) records the publication-support audit status. The pre-Budget release subsequently used core 3.33.0; the binding successor must authenticate d2011's exact released UK 2.151.0 / core 3.33.3 pair, or on fallback a133f37a's UK 2.147.2 / core 3.33.3 pair ([REBUILD.md](REBUILD.md)). The pilot-time open issues included #1927 (the Housing Benefit guarantee credit passport keyed on receipt), #1925 (benefit rates at announced amounts), #1913 (pension-age Housing Benefit allowances by cohort), #2019 (Pension Credit earnings disregards), #1941 (the additional State Pension's uprating), #2138 (Scotland's Pension Age Winter Heating Payment from winter 2025) and #2139 (the age addition). Their certified-port status is recorded in [J-upstream](pilot/J-upstream.md); #1927 is included in 2.125.1. diff --git a/docs/REBUILD.md b/docs/REBUILD.md index fa122060..b901105c 100644 --- a/docs/REBUILD.md +++ b/docs/REBUILD.md @@ -6,7 +6,7 @@ The pre-Budget release is committed on **main**: policyengine.py 6.2.6 / UK 2.12 | Gate | What it holds back | Until then | |---|---|---| -| **d2011 = A: the 2.151.0 pair (microcosm#1396)** | The exact released carrier manifest for national Microcosm cut `microcosm-uk-2024-25-national-20261025T091414Z-a6299504` and its local-area cut, UK 2.151.0 / core 3.33.3 | The release is expected around 23:00Z on Monday 26 October. If the pair is not certified by Tuesday 27 October 18:00Z, d2011 falls back to #715's `a133f37a` / UK 2.147.2 pair. Preflight fails closed while the carrier version, released-manifest digest, certifying pull request and local-area file are unset. | +| **d2011 = A: the 2.151.0 pair (microcosm#1396)** | The exact released carrier manifest for national Microcosm cut `microcosm-uk-2024-25-national-20261025T091414Z-a6299504` and its local-area cut, UK 2.151.0 / core 3.33.3 | The release is expected around 23:00Z on Monday 26 October. If the pair is not certified by Tuesday 27 October 18:00Z, d2011 falls back to #715's `a133f37a` / UK 2.147.2 pair with policyengine.py #699's area routing. Preflight fails closed while the carrier version, released-manifest digest, certifying pull request and local-area file are unset. | | **Binding inputs**: AWE on 20 October, September CPI on 21 October, OBR forecast on 28 October | Commit official source bytes and release identities before a fresh binding C2 handoff | A pre-Budget/dry-run file never authorizes the binding build. | | **d955 = (c)** | Only a passing original primary may get a model-conditional expected value, beside the scenario envelope | C2's frozen rule remains unchanged; primary failure automatically takes (a), scenarios only. No alternative form is promoted. | @@ -23,16 +23,17 @@ Max ruled **d2011 = A** on 25 October: the binding primary is the certified 2.15 | Dataset SHA-256 | `b96a8cd625b645a409c063b1de5158532708a2f0d3c3897a794a8fb0bbd5064e`, the staged attempt `uk-frs-calibration-attempt-20261025T091414Z-a6299504` | | Data release manifest | `releases/microcosm-uk-2024-25-national/release_manifest.json` at that revision (`datasets.MICROCOSM_RELEASE_MANIFEST`) | | Region routing | national to the national file; constituency and local authority to the local-area file | -| Unset until the release | carrier version and manifest digest, certifying pull request and commit, local-area file (`datasets.BINDING_*`) | +| Unset until the release | carrier version and manifest digest, certifying pull request and merge commit, local-area file (`datasets.BINDING_*`) | +| Core coverage | the certificate's core by policyengine.py's rule (`datasets.certified_core`): the named `certified_for_core_version` under `core_binding` `certified`, else the bundle's core pin (`bundle_pin`, 6.11.0's default) | a133f37a's staged attempt was released under the tag with its timestamp and suffix, with the same bytes, so the revision and SHA-256 above are the expected ones; confirm both against the release. The preparatory lock uses the base carrier without `[uk]`, plus explicit UK 2.151.0/core 3.33.3. The engine constructs `policyengine_uk` directly and loads its own verified artifact. Publication preflight remains closed until the exact release is authenticated. d1727's Enhanced FRS qualification-ledger route is superseded as the primary; its fail-closed code, [status receipt](../data/certification/d1727-qualification-status.json) and [2.153.4 audit](d1727-upstream.md) stay as history. -**Fallback.** #715's `microcosm_uk_2024_25@a133f37a` / UK 2.147.2 pair (`datasets.BUNDLE_715`, national only, reviewed head `e02d41bceab57756373b2dc3bcf78635b856ec81`) stays registered with its own pending identity. If d2011 falls back, `config.PRIMARY_DATASET`, the preflight's primary, the model pin and the bridge endpoints move back to it in one commit, and its [2.125.1-to-2.147.2 audit](d1983-upstream.md) applies. +**Fallback.** d2011's fallback is #715's `microcosm_uk_2024_25@a133f37a` / UK 2.147.2 pair served with [policyengine.py #699](https://github.com/PolicyEngine/policyengine.py/pull/699)'s area routing: a133f37a's national file, with constituency and local-authority runs routed to the 19 October dense local-area cut `microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe` (built on 2.132.6) on an accepted model mismatch. #715's reviewed candidate head `e02d41bceab57756373b2dc3bcf78635b856ec81` routes national only, so it does not certify the fallback by itself. The registry entry (`datasets.BUNDLE_715`, `FALLBACK_REGION_DATASETS`) keeps every release identity pending in `datasets.FALLBACK_*`, the local-area file included. If d2011 falls back, fill them with `scripts/fill_release_identities.py --target fallback`, then move `config.PRIMARY_DATASET`, the preflight's primary, the model pin and the bridge endpoints back to it in one reviewed commit; its [2.125.1-to-2.147.2 audit](d1983-upstream.md) applies. -1. After the pair is released, set `datasets.BINDING_BUNDLE_VERSION`, `BINDING_BUNDLE_MANIFEST_SHA256`, `BINDING_CERTIFICATION_URL`, `BINDING_CERTIFICATION_COMMIT` and `BINDING_LOCAL_AREA_PATH` from it, and the carrier version in both dependency files. Do not change the model/core/data pins merely to fit a carrier. Confirm that the released manifest exactly matches every identity above, the revision included. +1. After the pair is released, run `scripts/fill_release_identities.py --pr --release `. It reads the UK bundle manifest at the release tag (the release workflow's version commit rewrites it after the certifying merge, so the merge commit's bytes are not the release's), compares every identity above with it, the revision included, and prints the five `datasets.BINDING_*` values: carrier version, manifest SHA-256, certifying pull request and merge commit, and local-area file. Any difference refuses. With `--apply` it writes those, the carrier version in both dependency files and the ready `data/binding-bridge.json` of step 4. Do not change the model/core/data pins merely to fit a carrier. After installing the release, rerun it without `--apply`, with `--manifest` on the installed wheel's `policyengine/data/bundle/manifest.json`: it then also checks each filled identity, so the installed bytes must hash to the pinned digest, as the preflight in step 3 requires. 2. Keep the [2.147.2-to-2.151.0 source audit](d2011-upstream.md) (on top of the [2.125.1-to-2.147.2 audit](d1983-upstream.md)), `check_upstream()` and the parameter/small-household checks passing. UK 2.151.0 adds #2557: benefit uprating follows the previous September's CPI and `path_following` takes its September branch. It lacks #2576 (earnings-linked guarantee/derived HB and Savings Credit, first 2.151.2), and #2340 remains unmerged. Additional pension still takes the engine's path-specific explicit CPI pin. Age-addition handling takes the absent-variable branch. 3. Run certification and the behavioural HB receipt-passport preflight below before survey jobs. Synthetic checks never certify the fiscal result. -4. Copy `data/binding-bridge.pending.json` to `data/binding-bridge.json`. Fill the released carrier version in the three new endpoint package maps and `pending_release.policyengine_version`, fill `pending_release.pull_request`, `reviewed_head` and `bundle_manifest_sha256`, set status `ready`, and commit it. Keep historical baseline pins/SHA, UK/core pins and both artifact hashes fixed. Pending `--validate-only` honestly refuses without launching jobs. +4. `--apply` in step 1 writes this; check it by hand. It copies `data/binding-bridge.pending.json` to `data/binding-bridge.json`, fills the released carrier version in the three new endpoint package maps and `pending_release.policyengine_version`, fills `pending_release.pull_request`, `reviewed_head` and `bundle_manifest_sha256`, and sets status `ready`. Commit it. Keep historical baseline pins/SHA, UK/core pins and both artifact hashes fixed. Pending `--validate-only` honestly refuses without launching jobs. 5. Run **(a), model step**: 6.2.6 / UK 2.125.1 / core 3.33.0 → UK 2.151.0 / core 3.33.3, holding **Enhanced FRS 1.58.0** fixed. The new intermediate pair is uncertified. Run **(b), data step**: **Enhanced FRS 1.58.0 → Microcosm a6299504**, holding the same installed packages and engine sources fixed. Only that final endpoint must have released certification. Each endpoint runs the complete central/coverage/frozen forty-path design. Data/from must reuse exactly model/to's receipt; no dataset-family equality is assumed. 6. Review fresh accounting and GB/DWP coverage before feeding a regenerated binding C2 handoff to the full fiscal build. The primary/sensitivity config change alters its input hash: regenerate it through C2, and preserve every historical handoff/receipt byte. diff --git a/docs/d2011-upstream.md b/docs/d2011-upstream.md index 12db2027..e284a7aa 100644 --- a/docs/d2011-upstream.md +++ b/docs/d2011-upstream.md @@ -1,6 +1,6 @@ # d2011 Budget-wheel upstream source audit (policyengine-uk 2.151.0) -Read on 25 October 2026 from the released **policyengine-uk 2.151.0** wheel, compared file by file with **2.147.2**, the model of #715's a133f37a pair. 2.151.0 is the model of the pair d2011 recommends: María's national Microcosm cut `a6299504`, built at microcosm#1396 (`67ee2ddf3`). That commit's `uv.lock` pins this wheel (SHA-256 `5722f55aca3eb88e1a228e95e1a57e3bd70a4d530a5a000991565ebd8955ce35`) with policyengine-core **3.33.3**, the core already attested for 2.147.2. +Read on 25 October 2026 from the released **policyengine-uk 2.151.0** wheel, compared file by file with **2.147.2**, the model of #715's a133f37a pair. 2.151.0 is the model of the pair d2011 chose: María's national Microcosm cut `a6299504`, built at microcosm#1396 (`67ee2ddf3`). That commit's `uv.lock` pins this wheel (SHA-256 `5722f55aca3eb88e1a228e95e1a57e3bd70a4d530a5a000991565ebd8955ce35`) with policyengine-core **3.33.3**, the core already attested for 2.147.2. `data/certification/d2011-model-wheel.json` attests the wheel. Its archive SHA-256 equals PyPI's release metadata, and every one of its 3,176 RECORD members was checked for digest and size. The generator first reproduced the committed `d1983-model-wheel.json` byte for byte from PyPI's 2.147.2 wheel. No survey or Microcosm records were loaded. diff --git a/scripts/fill_release_identities.py b/scripts/fill_release_identities.py new file mode 100644 index 00000000..7618aa98 --- /dev/null +++ b/scripts/fill_release_identities.py @@ -0,0 +1,177 @@ +"""Check a policyengine.py release against d2011's pinned pair, then fill the identities only it can supply. + + .venv/bin/python scripts/fill_release_identities.py --pr N --release TAG [--target fallback] [--apply] + +The UK bundle manifest is read at the release tag (the release workflow's version commit rewrites it after the +certifying merge, so the merge commit's bytes are not the release's), or from ``--manifest FILE``, such as the +installed wheel's. Every identity datasets.py already pins is compared the way +``datasets._certified_by_installed_bundle`` will compare the installed release: carrier version, model and core +versions, model wheel SHA-256, build id, release and source manifest URIs, the certified artifact, and region +routing (national to the pinned file; constituency and local authority to one local-area file). Any difference +exits 1 before anything is written. + +``--apply`` writes the target's five ``datasets`` identities (carrier version, manifest SHA-256, certifying pull +request and merge commit, local-area file). For the binding target it also writes the carrier pin in +pyproject.toml and requirements-lock.txt and data/binding-bridge.json, the pending configuration filled and +marked ready. The fallback target writes only its datasets identities: its model pin, primary and bridge +endpoints move in their own reviewed commit. Nothing is installed and no data is loaded. The installed release +must still pass check_upstream(), both preflights and the bridge's --validate-only. +""" + +import argparse +import base64 +import hashlib +import json +from pathlib import Path +import re +import subprocess +import sys + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) +REPOSITORY = "PolicyEngine/policyengine.py" +MANIFEST = "src/policyengine/data/bundle/manifest.json" +LOCAL_AREA_REGIONS = ("constituency", "local_authority") +TARGETS = {"binding": ("microcosm_uk_2024_25@a6299504", "BINDING"), + "fallback": ("microcosm_uk_2024_25@a133f37a", "FALLBACK")} +IDENTITIES = ("BUNDLE_VERSION", "BUNDLE_MANIFEST_SHA256", "CERTIFICATION_URL", "CERTIFICATION_COMMIT", + "LOCAL_AREA_PATH") + + +def github(path): + return json.loads(subprocess.run(["gh", "api", path], check=True, capture_output=True, text=True).stdout) + + +def checks(manifest, spec): + """[(label, pinned, released)] for every identity the registry already pins.""" + from triple_lock.datasets import certified_core + + certification = spec["bundle_certification"] + release = manifest["data_releases"]["uk"] + artifact = release["certified_data_artifact"] + version = manifest["policyengine_version"] + regions = release["region_datasets"] + local = {(regions.get(area) or {}).get("path_template") for area in LOCAL_AREA_REGIONS} + return [ + ("UK release carrier version", version, release["policyengine_version"]), + ("bundle id", f"uk-{version}", release["bundle_id"]), + ("core version", certification["core_version"], manifest["packages"]["policyengine-core"]["version"]), + ("core version certified for", certification["core_version"], certified_core(manifest)), + ("model version", certification["model_version"], release["model_package"]["version"]), + ("model version certified for", certification["model_version"], + release["certification"]["certified_for_model_version"]), + ("model wheel SHA-256", certification["model_wheel_sha256"], release["model_package"]["sha256"]), + ("release build id", spec["build_id"], release["build_id"]), + ("artifact build id", spec["build_id"], artifact["build_id"]), + ("release manifest URI", spec["release_manifest"]["uri"], release["release_manifest_uri"]), + ("source manifest URI", spec["release_manifest"]["source_uri"], release["source_manifest_uri"]), + ("artifact dataset", spec["logical_name"], artifact["dataset"]), + ("artifact SHA-256", spec["sha256"], artifact["sha256"]), + ("artifact URI", f"hf://{spec['repo_id']}/{spec['path']}@{spec['revision']}", artifact["uri"]), + ("artifact data package", {"name": spec["data_package"], "version": spec["data_version"]}, + artifact["data_package"]), + ("routed regions", ["constituency", "local_authority", "national"], sorted(regions)), + ("national file", {"path_template": spec["path"]}, regions.get("national")), + ("one local-area file for both area types", True, len(local) == 1 and bool(next(iter(local)))), + ] + + +def identities(raw, manifest, pull_request): + """The five values only the release supplies, from the manifest's exact bytes and the pull request. An unmerged + pull request has no certifying commit (None), so its values can be printed but never applied.""" + regions = manifest["data_releases"]["uk"]["region_datasets"] + return {"BUNDLE_VERSION": manifest["policyengine_version"], + "BUNDLE_MANIFEST_SHA256": hashlib.sha256(raw).hexdigest(), + "CERTIFICATION_URL": pull_request["html_url"], + "CERTIFICATION_COMMIT": pull_request["merge_commit_sha"] if pull_request.get("merged_at") else None, + "LOCAL_AREA_PATH": (regions.get("constituency") or {}).get("path_template")} + + +def _replace_once(text, pattern, replacement, where): + text, count = re.subn(pattern, replacement, text, flags=re.M) + if count != 1: + raise ValueError(f"{where}: expected exactly one match for {pattern!r}") + return text + + +def fill(root, prefix, values, reviewed_head): + """Write the identities; the binding target also writes its carrier pins and the ready bridge configuration.""" + missing = [name for name in IDENTITIES if not values.get(name)] + if missing: + raise ValueError(f"the release does not supply {missing}: nothing is written") + path = root / "src/triple_lock/datasets.py" + text = path.read_text() + for name in IDENTITIES: + text = _replace_once(text, rf"^{prefix}_{name} = None$", f"{prefix}_{name} = {json.dumps(values[name])}", + path) + path.write_text(text) + if prefix != "BINDING": + return + version = values["BUNDLE_VERSION"] + for path in (root / "pyproject.toml", root / "requirements-lock.txt"): + path.write_text(_replace_once(path.read_text(), r'^(\s*"?policyengine==)[0-9][^",\s]*', rf"\g<1>{version}", + path)) + configuration = json.loads((root / "data/binding-bridge.pending.json").read_text()) + for step, endpoint in (("model", "to"), ("data", "from"), ("data", "to")): + packages = configuration["steps"][step][endpoint]["packages"] + if packages["policyengine"] is not None: + raise ValueError(f"the pending {step}/{endpoint} endpoint already names a carrier") + packages["policyengine"] = version + configuration["status"] = "ready" + configuration["pending_release"].update( + pull_request=values["CERTIFICATION_URL"], reviewed_head=reviewed_head, policyengine_version=version, + bundle_manifest_sha256=values["BUNDLE_MANIFEST_SHA256"]) + (root / "data/binding-bridge.json").write_text(json.dumps(configuration, indent=2) + "\n") + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--pr", type=int, required=True, help="the merged policyengine.py PR certifying the pair") + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--release", help="the policyengine.py release tag carrying it") + source.add_argument("--manifest", type=Path, help="that release's manifest file, e.g. the installed wheel's") + parser.add_argument("--target", choices=sorted(TARGETS), default="binding") + parser.add_argument("--apply", action="store_true", help="write the identities once every check passes") + args = parser.parse_args(argv) + + from triple_lock import datasets + + name, prefix = TARGETS[args.target] + spec = datasets.DATASETS[name] + if args.manifest: + raw = args.manifest.read_bytes() + else: + raw = base64.b64decode(github(f"repos/{REPOSITORY}/contents/{MANIFEST}?ref={args.release}")["content"]) + manifest = json.loads(raw) + if args.release and manifest["policyengine_version"] != args.release: + raise SystemExit(f"release {args.release}'s manifest names policyengine {manifest['policyengine_version']}") + rows = checks(manifest, spec) + pull_request = github(f"repos/{REPOSITORY}/pulls/{args.pr}") + values = identities(raw, manifest, pull_request) + # Identities already filled (a rerun on the installed wheel's manifest) must equal what this release supplies. + rows += [(f"filled {prefix}_{key}", getattr(datasets, f"{prefix}_{key}"), value) for key, value in values.items() + if getattr(datasets, f"{prefix}_{key}") is not None] + width = max(len(label) for label, _, _ in rows) + for label, pinned, released in rows: + print(f"{'ok ' if pinned == released else 'FAIL'} {label:<{width}} {json.dumps(released)}" + + ("" if pinned == released else f" (pinned {json.dumps(pinned)})")) + release = manifest["data_releases"]["uk"] + print(json.dumps({"dataset": name, **{f"{prefix}_{key}": value for key, value in values.items()}, + "reviewed_head": pull_request["head"]["sha"], + **{key: release.get(key) for key in ("regional_release_kind", "regional_release_manifest_uri", + "regional_source_manifest_uri")}}, indent=2)) + if any(pinned != released for _, pinned, released in rows): + print(f"refused: the release differs from the pinned {name} pair", file=sys.stderr) + return 1 + if args.apply: + if any(label.startswith("filled ") for label, _, _ in rows): + raise SystemExit(f"{prefix} identities are already filled: rerun without --apply to check them") + fill(ROOT, prefix, values, pull_request["head"]["sha"]) + print("applied: review the diff; install the release; run check_upstream(), both preflights and " + "the bridge's --validate-only") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/triple_lock/datasets.py b/src/triple_lock/datasets.py index 8c946d8e..e86d6fbb 100644 --- a/src/triple_lock/datasets.py +++ b/src/triple_lock/datasets.py @@ -5,7 +5,8 @@ core 3.33.3. Its carrier release is pending: provenance fails closed until its released version, installed manifest digest, certifying pull request and local-area file are pinned below. #715's a133f37a / UK 2.147.2 pair, d1983's -first binding pair and d2011's fallback, keeps its own pending identity. +first binding pair, is d2011's fallback with policyengine.py #699's area +routing; it keeps its own pending identity the same way. Historical Enhanced FRS and Microcosm artifacts retain their original pins. Materialization checks the exact artifact SHA-256 before every use (cached while unchanged), with one shared copy in .cache/datasets per artifact. @@ -30,13 +31,22 @@ "certification_url": "https://github.com/PolicyEngine/policyengine.py/pull/555", "certification_commit": "37d25c423e9541b4ca86adbdd8687738b49ab8ec", } -# #715's pair (d1983's first binding pair; d2011's fallback if the 2.151.0 pair is not certified by Tue 27 Oct -# 18:00Z). Its unknown identities fill from the release carrying #715; the candidate head's 6.10.1 is NOT that release. +# #715's pair, d1983's first binding pair, is d2011's fallback if the 2.151.0 pair is not certified by Tue 27 Oct +# 18:00Z: a133f37a's national file with policyengine.py #699's area routing (the 19 Oct dense local-area cut +# microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe, built on 2.132.6, on an accepted model mismatch). #715's +# candidate head e02d41bc routes national only, and its 6.10.1 is NOT the release. Unknown until a fallback release: +# its version and installed manifest digest, the pull request and commit that certify it, and its local-area file. +# Each fails closed while None. +FALLBACK_BUNDLE_VERSION = None +FALLBACK_BUNDLE_MANIFEST_SHA256 = None +FALLBACK_CERTIFICATION_URL = None +FALLBACK_CERTIFICATION_COMMIT = None +FALLBACK_LOCAL_AREA_PATH = None BUNDLE_715 = { - "bundle_package": "policyengine", "bundle_version": None, - "bundle_id": "uk-None", "model_version": "2.147.2", "core_version": "3.33.3", + "bundle_package": "policyengine", "bundle_version": FALLBACK_BUNDLE_VERSION, + "bundle_id": f"uk-{FALLBACK_BUNDLE_VERSION}", "model_version": "2.147.2", "core_version": "3.33.3", "manifest_path": "policyengine/data/bundle/manifest.json", - "manifest_sha256": None, + "manifest_sha256": FALLBACK_BUNDLE_MANIFEST_SHA256, "model_wheel_sha256": "4b48ab1008b00610abc38192927ac9d4bb22037b6bbd9578926c2899e639f972", "wheels": { "policyengine-uk": { @@ -50,8 +60,8 @@ "wheel_sha256": "5847d9938412095fe9848c2ce535838a7dc8fac9238d8fd9e6c9d0a0648e0888", }, }, - "certification_url": "https://github.com/PolicyEngine/policyengine.py/pull/715", - "certification_commit": "e02d41bceab57756373b2dc3bcf78635b856ec81", + "certification_url": FALLBACK_CERTIFICATION_URL, + "certification_commit": FALLBACK_CERTIFICATION_COMMIT, } @@ -67,8 +77,18 @@ def microcosm_release_manifest(revision): } +def area_routed(local_area_path): + """A UK Budget release's region routing: the national file serves national runs, and policyengine.py routes + constituency and local-authority runs to the local-area file (provenance/certification.py: + LOCAL_AREA_REGION_TYPES["uk"]). A pending (None) file certifies nothing.""" + return {"national": {"path_template": "microcosm_uk_2024_25.h5"}, + "constituency": {"path_template": local_area_path}, + "local_authority": {"path_template": local_area_path}} + + MICROCOSM_REVISION_A133 = "microcosm-uk-2024-25-national-20261023T075039Z-a133f37a" MICROCOSM_RELEASE_MANIFEST_A133 = microcosm_release_manifest(MICROCOSM_REVISION_A133) +FALLBACK_REGION_DATASETS = area_routed(FALLBACK_LOCAL_AREA_PATH) # d2011's pair. Unknown until the policyengine.py release that carries it: that release's version and installed # manifest digest, the pull request and commit that certify it, and the local-area file its constituency and @@ -105,11 +125,7 @@ def microcosm_release_manifest(revision): # tag at release: until it exists, the download and the bundle's artifact URI both fail closed. MICROCOSM_REVISION = "microcosm-uk-2024-25-national-20261025T091414Z-a6299504" MICROCOSM_RELEASE_MANIFEST = microcosm_release_manifest(MICROCOSM_REVISION) -# policyengine.py routes a UK release's constituency and local-authority runs to its local-area file -# (provenance/certification.py: LOCAL_AREA_REGION_TYPES["uk"]); the national file serves national runs. -BINDING_REGION_DATASETS = {"national": {"path_template": "microcosm_uk_2024_25.h5"}, - "constituency": {"path_template": BINDING_LOCAL_AREA_PATH}, - "local_authority": {"path_template": BINDING_LOCAL_AREA_PATH}} +BINDING_REGION_DATASETS = area_routed(BINDING_LOCAL_AREA_PATH) RELEASE_MANIFEST_1_58_0 = { "revision": "fdeba38517bd86067f34c0a5d8ed8923efb3b10a", "path": "releases/1.58.0/release_manifest.json", @@ -159,6 +175,7 @@ def microcosm_release_manifest(revision): "certified": [], # #715 is draft; producer gates are not released bundle certification. "bundle_certification": BUNDLE_715, "release_manifest": MICROCOSM_RELEASE_MANIFEST_A133, + "region_datasets": FALLBACK_REGION_DATASETS, # The build solves its household weights at 2025 and writes them divided by policyengine-uk's own # 2024-to-2025 projection of household_weight, so the model's 2025 weights are the solved ones # (microcosm weight_year.py, microcosm#796 A19), as the Enhanced FRS's are its calibrated ones. @@ -353,6 +370,21 @@ def model_versions(): "bundle_package": "policyengine", "bundle_version": bundle_version} +def certified_core(manifest): + """The core a UK bundle's certificate covers, by policyengine.py's own rule (provenance/manifest.py + DataCertification; provenance/receipt.py _certificate_fields): core_binding "certified" names it in + certified_for_core_version, which the bundle's core pin must equal; "bundle_pin" (or no binding, before October + 2026) covers the bundle's core pin. None for anything else, which certifies nothing.""" + certificate = manifest["data_releases"]["uk"]["certification"] + pin = manifest["packages"]["policyengine-core"]["version"] + binding, named = certificate.get("core_binding"), certificate.get("certified_for_core_version") + if binding == "certified": + return named if named == pin else None + if binding in (None, "bundle_pin"): + return named or pin if named in (None, pin) else None + return None + + def _certified_by_installed_bundle(spec, versions): """Fail closed unless released-manifest or ledger evidence certifies the exact pair.""" certification = spec.get("bundle_certification") @@ -384,7 +416,7 @@ def _certified_by_installed_bundle(spec, versions): if (manifest["policyengine_version"] != versions["bundle_version"] or release["policyengine_version"] != versions["bundle_version"] or manifest["packages"]["policyengine-core"]["version"] != versions["core_version"] - or release["certification"]["certified_for_core_version"] != versions["core_version"] + or certified_core(manifest) != versions["core_version"] or release["model_package"]["version"] != versions["model_version"] or release["model_package"]["sha256"] != certification["model_wheel_sha256"] or release["build_id"] != spec["build_id"] diff --git a/src/triple_lock/pipeline.py b/src/triple_lock/pipeline.py index ac450687..e8c6c166 100644 --- a/src/triple_lock/pipeline.py +++ b/src/triple_lock/pipeline.py @@ -9,7 +9,7 @@ * ``expected_value``: the calibrated distribution of paths and the stratified sample of full runs (expected_value.py), on the certified Microcosm UK Budget primary and the paired Enhanced FRS 1.58.2 sensitivity. Publication waits for - the policyengine.py #715 carrier release. + the policyengine.py release carrying d2011's pair. * ``trajectories``: a few paths we fully understand, past years and the method backtests (trajectories.py). * ``coverage``: what each dataset holds against DWP's tables, for the UK and Great Britain, in 2026-27 to 2030-31, diff --git a/tests/test_budget_bundle.py b/tests/test_budget_bundle.py index 1bc0d006..b7a2ba18 100644 --- a/tests/test_budget_bundle.py +++ b/tests/test_budget_bundle.py @@ -21,19 +21,23 @@ def test_binding_roles_and_pending_preflight_do_not_read_model_or_data(monkeypat pipeline.certified_bundle_preflight() -@pytest.fixture -def released(tmp_path, monkeypatch): +FALLBACK_DATASET = "microcosm_uk_2024_25@a133f37a" + + +# d2011's binding pair and its fallback: each must certify once its own release identities are filled. +@pytest.fixture(params=[PRIMARY_DATASET, FALLBACK_DATASET]) +def released(request, tmp_path, monkeypatch): manifest = json.loads((REPO / "data/certification/d1983-candidate-bundle-manifest.json").read_text()) version = "99.0.0" # Fictional future release, never a production pin. release = manifest["data_releases"]["uk"] manifest["policyengine_version"] = version release["policyengine_version"] = version release["bundle_id"] = f"uk-{version}" - cert = {**datasets.CERTIFIED_BUNDLE, "bundle_version": version, "bundle_id": f"uk-{version}"} - spec = deepcopy(datasets.DATASETS[PRIMARY_DATASET]) + spec = deepcopy(datasets.DATASETS[request.param]) + cert = {**spec["bundle_certification"], "bundle_version": version, "bundle_id": f"uk-{version}"} spec["bundle_certification"] = cert - # The skeleton is #715's a133f37a candidate; point its pair fields at the binding spec, with an invented - # local-area file in place of the pending one. + # The skeleton is #715's a133f37a candidate; point its pair fields at the spec, with an invented local-area + # file in place of the pending one. local = {"path_template": "invented_local_area.h5"} spec["region_datasets"] = {**spec["region_datasets"], "constituency": local, "local_authority": local} release["model_package"]["version"] = cert["model_version"] @@ -129,23 +133,68 @@ def test_structural_pair_checks_even_with_authenticated_manifest(released, locat assert not datasets._certified_by_installed_bundle(spec, versions) +@pytest.mark.parametrize("binding,named,pin,certifies", [ + ("certified", "3.33.3", "3.33.3", True), # #715's candidate: the certificate names the core + ("bundle_pin", None, "3.33.3", True), # policyengine.py's default since 6.11.0: it covers the bundle's pin + (None, None, "3.33.3", True), # certificates from before October 2026 follow the pin the same way + ("certified", None, "3.33.3", False), + ("certified", "3.33.0", "3.33.3", False), + ("bundle_pin", "3.33.0", "3.33.3", False), + ("bundle_pin", None, "3.33.0", False), + ("open", None, "3.33.3", False), +]) +def test_the_certified_core_follows_policyengine_pys_core_binding(released, binding, named, pin, certifies): + spec, versions, manifest, write, _ = released + certificate = manifest["data_releases"]["uk"]["certification"] + for key, value in (("core_binding", binding), ("certified_for_core_version", named)): + certificate.pop(key, None) + if value is not None: + certificate[key] = value + manifest["packages"]["policyengine-core"]["version"] = pin + write() + assert datasets._certified_by_installed_bundle(spec, versions) is certifies + + def test_the_pending_local_area_file_cannot_certify(released): spec, versions, manifest, write, _ = released assert datasets._certified_by_installed_bundle(spec, versions) - spec["region_datasets"] = deepcopy(datasets.BINDING_REGION_DATASETS) - assert datasets.BINDING_LOCAL_AREA_PATH is None + pending = next(d for d in datasets.DATASETS.values() if d["build_id"] == spec["build_id"])["region_datasets"] + assert {pending[area]["path_template"] for area in ("constituency", "local_authority")} == {None} + spec["region_datasets"] = deepcopy(pending) assert not datasets._certified_by_installed_bundle(spec, versions) - manifest["data_releases"]["uk"]["region_datasets"] = deepcopy(datasets.BINDING_REGION_DATASETS) + manifest["data_releases"]["uk"]["region_datasets"] = deepcopy(pending) write() assert not datasets._certified_by_installed_bundle(spec, versions) -def test_the_715_fallback_keeps_its_national_only_pair(released): - fallback = datasets.DATASETS["microcosm_uk_2024_25@a133f37a"] - assert "region_datasets" not in fallback - assert fallback["bundle_certification"] is datasets.BUNDLE_715 - assert (datasets.BUNDLE_715["model_version"], datasets.BUNDLE_715["bundle_version"]) == ("2.147.2", None) +@pytest.mark.parametrize("name,bundle,local_area", [ + (PRIMARY_DATASET, "CERTIFIED_BUNDLE", "BINDING_LOCAL_AREA_PATH"), + # d2011's fallback is a133f37a with policyengine.py #699's area routing, not #715's national-only candidate. + (FALLBACK_DATASET, "BUNDLE_715", "FALLBACK_LOCAL_AREA_PATH"), +]) +def test_each_d2011_pair_routes_areas_and_stays_pending(name, bundle, local_area): + spec = datasets.DATASETS[name] + assert spec["bundle_certification"] is getattr(datasets, bundle) + assert spec["region_datasets"] == datasets.area_routed(getattr(datasets, local_area)) == { + "national": {"path_template": spec["path"]}, + "constituency": {"path_template": None}, "local_authority": {"path_template": None}} + prefix = local_area.removesuffix("_LOCAL_AREA_PATH") + for identity in ("BUNDLE_VERSION", "BUNDLE_MANIFEST_SHA256", "CERTIFICATION_URL", "CERTIFICATION_COMMIT"): + assert getattr(datasets, f"{prefix}_{identity}") is None + certification = spec["bundle_certification"] + assert (certification["bundle_version"], certification["manifest_sha256"], certification["certification_url"], + certification["certification_commit"]) == (None, None, None, None) + versions = {**{key: certification[key] for key in ("bundle_package", "model_version", "core_version")}, + "bundle_version": "99.0.0"} + assert not datasets._certified_by_installed_bundle(spec, versions) + + +def test_the_fallback_keeps_its_own_model_and_release_manifest(): + fallback = datasets.DATASETS[FALLBACK_DATASET] + assert (datasets.BUNDLE_715["model_version"], datasets.BUNDLE_715["core_version"]) == ("2.147.2", "3.33.3") + assert datasets.BUNDLE_715["model_wheel_sha256"] == datasets.BUNDLE_715["wheels"]["policyengine-uk"]["wheel_sha256"] assert fallback["release_manifest"] == datasets.MICROCOSM_RELEASE_MANIFEST_A133 + assert fallback["build_id"] == fallback["revision"] == datasets.MICROCOSM_REVISION_A133 def test_changed_installed_manifest_is_rejected(released): diff --git a/tests/test_budget_model_projection.py b/tests/test_budget_model_projection.py index 2feb1053..9d8f0cf7 100644 --- a/tests/test_budget_model_projection.py +++ b/tests/test_budget_model_projection.py @@ -14,7 +14,7 @@ LAGGED_CPI = "gov.economic_assumptions.indices.obr.lagged_cpi" SEPTEMBER_CPI = "gov.economic_assumptions.indices.september_cpi_uprating" -# The index gov.benefit_uprating_cpi names on each Budget wheel: #715's 2.147.2 predates #2557; d2011's recommended +# The index gov.benefit_uprating_cpi names on each Budget wheel: #715's 2.147.2 predates #2557; d2011's binding # 2.151.0 has it (and still lacks #2576 and #2340). BENEFIT_INDEX = {"2.147.2": LAGGED_CPI, "2.151.0": SEPTEMBER_CPI} DEPENDENT_PATHS = ["gov.dwp.pension_credit.guarantee_credit.minimum_guarantee.SINGLE", diff --git a/tests/test_fill_release_identities.py b/tests/test_fill_release_identities.py new file mode 100644 index 00000000..9bcb143e --- /dev/null +++ b/tests/test_fill_release_identities.py @@ -0,0 +1,200 @@ +"""The release-night fill checks every pinned identity and writes only what the release supplies.""" + +from copy import deepcopy +import hashlib +import json +import shutil + +import pytest + +from scripts import binding_bridge +from scripts import fill_release_identities as fill +from triple_lock import datasets +from triple_lock.config import REPO + +VERSION = "99.0.0" # Fictional future release, never a production pin. +LOCAL_AREA = "invented_local_area.h5" +MERGED = {"html_url": "https://github.com/PolicyEngine/policyengine.py/pull/9999", "merged_at": "2026-10-26T23:00:00Z", + "merge_commit_sha": "1" * 40, "head": {"sha": "2" * 40}} + + +def released_manifest(name): + """#715's candidate skeleton, re-pointed at the named pair's pins as its release would carry them.""" + spec = datasets.DATASETS[name] + certification = spec["bundle_certification"] + manifest = json.loads((REPO / "data/certification/d1983-candidate-bundle-manifest.json").read_text()) + release = manifest["data_releases"]["uk"] + manifest["policyengine_version"] = release["policyengine_version"] = VERSION + release["bundle_id"] = f"uk-{VERSION}" + manifest["packages"]["policyengine-core"]["version"] = certification["core_version"] + release["certification"]["certified_for_core_version"] = certification["core_version"] + release["model_package"].update(version=certification["model_version"], sha256=certification["model_wheel_sha256"]) + release["certification"]["certified_for_model_version"] = certification["model_version"] + release["build_id"] = spec["build_id"] + release["certified_data_artifact"].update( + build_id=spec["build_id"], dataset=spec["logical_name"], sha256=spec["sha256"], + uri=f"hf://{spec['repo_id']}/{spec['path']}@{spec['revision']}", + data_package={"name": spec["data_package"], "version": spec["data_version"]}) + release["release_manifest_uri"] = spec["release_manifest"]["uri"] + release["source_manifest_uri"] = spec["release_manifest"]["source_uri"] + release["region_datasets"] = datasets.area_routed(LOCAL_AREA) + return manifest + + +def failing(manifest, name): + return [label for label, pinned, released in fill.checks(manifest, datasets.DATASETS[name]) if pinned != released] + + +@pytest.mark.parametrize("target", sorted(fill.TARGETS)) +def test_a_release_carrying_the_pinned_pair_passes_every_check(target): + name, _ = fill.TARGETS[target] + assert failing(released_manifest(name), name) == [] + + +@pytest.mark.parametrize("target", sorted(fill.TARGETS)) +def test_a_bundle_pin_certificate_like_6_11_0s_passes(target): + name, _ = fill.TARGETS[target] + manifest = released_manifest(name) + certificate = manifest["data_releases"]["uk"]["certification"] + del certificate["certified_for_core_version"] + certificate["core_binding"] = "bundle_pin" + assert failing(manifest, name) == [] + + +@pytest.mark.parametrize("target", sorted(fill.TARGETS)) +@pytest.mark.parametrize("where,key,value,label", [ + ("uk", "policyengine_version", "98.0.0", "UK release carrier version"), + ("uk", "bundle_id", "uk-98.0.0", "bundle id"), + ("core", "version", "3.33.0", "core version"), + ("certification", "certified_for_core_version", "3.33.0", "core version certified for"), + ("model_package", "version", "2.153.4", "model version"), + ("certification", "certified_for_model_version", "2.153.4", "model version certified for"), + ("model_package", "sha256", "0" * 64, "model wheel SHA-256"), + ("uk", "build_id", "other-cut", "release build id"), + ("artifact", "build_id", "other-cut", "artifact build id"), + ("uk", "release_manifest_uri", "https://example.com/other", "release manifest URI"), + ("uk", "source_manifest_uri", "hf://other", "source manifest URI"), + ("artifact", "dataset", "enhanced_frs_2024_25", "artifact dataset"), + ("artifact", "sha256", "0" * 64, "artifact SHA-256"), + ("artifact", "uri", "hf://policyengine/other/microcosm_uk_2024_25.h5@x", "artifact URI"), + ("artifact", "data_package", {"name": "microcosm-data", "version": "0.2.0"}, "artifact data package"), + # #715's national-only candidate routing, or an area type left out, is not d2011's pair. + ("uk", "region_datasets", {"national": {"path_template": "microcosm_uk_2024_25.h5"}}, "routed regions"), + ("regions", "national", {"path_template": "other.h5"}, "national file"), + ("regions", "local_authority", {"path_template": "other_local_area.h5"}, "one local-area file for both area types"), + ("regions", "constituency", {"path_template": ""}, "one local-area file for both area types"), +]) +def test_each_pinned_identity_is_checked(target, where, key, value, label): + name, _ = fill.TARGETS[target] + manifest = released_manifest(name) + release = manifest["data_releases"]["uk"] + container = {"uk": release, "core": manifest["packages"]["policyengine-core"], + "certification": release["certification"], "model_package": release["model_package"], + "artifact": release["certified_data_artifact"], "regions": release["region_datasets"]}[where] + container[key] = value + assert label in failing(manifest, name) + + +def test_identities_come_from_the_exact_bytes_and_the_merge(): + manifest = released_manifest(fill.TARGETS["binding"][0]) + raw = json.dumps(manifest).encode() + assert fill.identities(raw, manifest, MERGED) == { + "BUNDLE_VERSION": VERSION, "BUNDLE_MANIFEST_SHA256": hashlib.sha256(raw).hexdigest(), + "CERTIFICATION_URL": MERGED["html_url"], "CERTIFICATION_COMMIT": MERGED["merge_commit_sha"], + "LOCAL_AREA_PATH": LOCAL_AREA} + candidate = {**MERGED, "merged_at": None} + assert fill.identities(raw, manifest, candidate)["CERTIFICATION_COMMIT"] is None + + +@pytest.fixture +def tree(tmp_path): + """A copy of the files the fill writes, so the real tree is never touched.""" + for relative in ("src/triple_lock/datasets.py", "pyproject.toml", "requirements-lock.txt", + "data/binding-bridge.pending.json"): + (tmp_path / relative).parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(REPO / relative, tmp_path / relative) + return tmp_path + + +def values(): + manifest = released_manifest(fill.TARGETS["binding"][0]) + return fill.identities(json.dumps(manifest).encode(), manifest, MERGED) + + +def assignments(text, prefix): + return {name: line.split(" = ", 1)[1] for line in text.splitlines() + for name in fill.IDENTITIES if line.startswith(f"{prefix}_{name} = ")} + + +def test_the_binding_fill_writes_every_pin_and_a_ready_bridge(tree): + filled = values() + fill.fill(tree, "BINDING", filled, MERGED["head"]["sha"]) + text = (tree / "src/triple_lock/datasets.py").read_text() + assert assignments(text, "BINDING") == {name: json.dumps(filled[name]) for name in fill.IDENTITIES} + assert set(assignments(text, "FALLBACK").values()) == {"None"} + assert f'"policyengine=={VERSION}",' in (tree / "pyproject.toml").read_text() + assert f"\npolicyengine=={VERSION}\n" in (tree / "requirements-lock.txt").read_text() + pending = json.loads((REPO / "data/binding-bridge.pending.json").read_text()) + assert json.loads((tree / "data/binding-bridge.pending.json").read_text()) == pending + ready = json.loads((tree / "data/binding-bridge.json").read_text()) + binding_bridge.validate_configuration(ready) + assert ready["pending_release"] == {**pending["pending_release"], "pull_request": MERGED["html_url"], + "reviewed_head": MERGED["head"]["sha"], "policyengine_version": VERSION, + "bundle_manifest_sha256": filled["BUNDLE_MANIFEST_SHA256"]} + # Only the three carrier pins and the status change: model, core and dataset pins stay exactly as reviewed. + expected = deepcopy(pending) + expected["status"] = "ready" + expected["pending_release"] = ready["pending_release"] + for step, endpoint in (("model", "to"), ("data", "from"), ("data", "to")): + expected["steps"][step][endpoint]["packages"]["policyengine"] = VERSION + assert ready == expected + + +def test_the_fallback_fill_writes_only_its_own_identities(tree): + before = {path: path.read_bytes() for path in tree.rglob("*") if path.is_file()} + fill.fill(tree, "FALLBACK", values(), MERGED["head"]["sha"]) + text = (tree / "src/triple_lock/datasets.py").read_text() + assert set(assignments(text, "FALLBACK").values()) != {"None"} + assert set(assignments(text, "BINDING").values()) == {"None"} + changed = {path.relative_to(tree).as_posix() for path in tree.rglob("*") + if path.is_file() and before.get(path) != path.read_bytes()} + assert changed == {"src/triple_lock/datasets.py"} + + +@pytest.mark.parametrize("missing", ["CERTIFICATION_COMMIT", "LOCAL_AREA_PATH"]) +def test_a_value_the_release_does_not_supply_writes_nothing(tree, missing): + before = {path: path.read_bytes() for path in tree.rglob("*") if path.is_file()} + with pytest.raises(ValueError, match=missing): + fill.fill(tree, "BINDING", {**values(), missing: None}, MERGED["head"]["sha"]) + assert {path: path.read_bytes() for path in tree.rglob("*") if path.is_file()} == before + + +@pytest.mark.parametrize("change,code", [(None, 0), ("artifact SHA-256", 1)]) +def test_main_refuses_a_differing_release_before_writing(tree, tmp_path, monkeypatch, change, code): + manifest = released_manifest(fill.TARGETS["binding"][0]) + if change: + manifest["data_releases"]["uk"]["certified_data_artifact"]["sha256"] = "0" * 64 + path = tmp_path / "manifest.json" + path.write_text(json.dumps(manifest)) + monkeypatch.setattr(fill, "github", lambda _: MERGED) + monkeypatch.setattr(fill, "ROOT", tree) + assert fill.main(["--pr", "9999", "--manifest", str(path), "--apply"]) == code + assert (tree / "data/binding-bridge.json").exists() is (code == 0) + + +def test_a_rerun_checks_the_identities_already_filled(tmp_path, monkeypatch, capsys): + manifest = released_manifest(fill.TARGETS["binding"][0]) + path = tmp_path / "manifest.json" + path.write_text(json.dumps(manifest)) + filled = fill.identities(path.read_bytes(), manifest, MERGED) + monkeypatch.setattr(fill, "github", lambda _: MERGED) + for key, value in filled.items(): + monkeypatch.setattr(datasets, f"BINDING_{key}", value) + assert fill.main(["--pr", "9999", "--manifest", str(path)]) == 0 + assert capsys.readouterr().out.count("ok filled BINDING_") == len(fill.IDENTITIES) + with pytest.raises(SystemExit, match="already filled"): + fill.main(["--pr", "9999", "--manifest", str(path), "--apply"]) + # Installed bytes that differ from the pinned digest refuse. + path.write_text(json.dumps(manifest, indent=1)) + assert fill.main(["--pr", "9999", "--manifest", str(path)]) == 1 + assert "FAIL filled BINDING_BUNDLE_MANIFEST_SHA256" in capsys.readouterr().out From da5efe2a95ca5ae3664804b9b30af621ac0c0791 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 25 Oct 2026 16:20:15 -0700 Subject: [PATCH 04/14] Keep the solved-weight identity to Microcosm; the Enhanced FRS's 2025 weights are calibrated x 0.9956 Review of 4edb1c0: the release wording and the a133 registry note called both datasets' 2025 weights the calibrated ones, but METHOD.md's Enhanced FRS level note (policyengine-uk-data#538) gives runtime = calibrated x 0.9956. demography's module note still said the binding Microcosm cut anchors at 2024. Co-Authored-By: Claude Opus 5.5 --- src/triple_lock/datasets.py | 3 ++- src/triple_lock/demography.py | 8 +++++--- src/triple_lock/pipeline.py | 5 +++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/triple_lock/datasets.py b/src/triple_lock/datasets.py index e86d6fbb..6bd50fe9 100644 --- a/src/triple_lock/datasets.py +++ b/src/triple_lock/datasets.py @@ -178,7 +178,8 @@ def area_routed(local_area_path): "region_datasets": FALLBACK_REGION_DATASETS, # The build solves its household weights at 2025 and writes them divided by policyengine-uk's own # 2024-to-2025 projection of household_weight, so the model's 2025 weights are the solved ones - # (microcosm weight_year.py, microcosm#796 A19), as the Enhanced FRS's are its calibrated ones. + # (microcosm weight_year.py, microcosm#796 A19). The Enhanced FRS's 2025 weights are instead its calibrated + # ones times 0.9956 (policyengine-uk-data#538; METHOD.md, "The Enhanced FRS calibration year's level"). "ageing_anchor": {"year": 2025, "calibration_year": 2025, "source": "microcosm-uk-2024-25-national-20261023T075039Z-a133f37a: build_record.json " "uk_national_export_readback weight_year (dataset_year 2024, solve_year 2025, " diff --git a/src/triple_lock/demography.py b/src/triple_lock/demography.py index e1b3bce2..b1d0cf35 100644 --- a/src/triple_lock/demography.py +++ b/src/triple_lock/demography.py @@ -18,9 +18,11 @@ calibrated weights. After it they are raked (bounded minimum relative entropy, ratios 0.2 to 5 of the anchor's) so that each age/sex cell grows from its anchor-year survey count as the ONS 2024-based projection's does - (annual_weights). The binding Microcosm cut has no published calibration - year and anchors in its data year, 2024; Enhanced FRS 1.58.2 uses its 2025 - calibration year. Person and benefit-unit weights are their household's. + (annual_weights). The Microcosm cuts and Enhanced FRS 1.58.2 anchor at + their 2025 calibration year: Microcosm's builds write the solved weights so + that the model's 2025 weights are exactly them, and the Enhanced FRS's 2025 + weights are its calibrated ones times 0.9956 (policyengine-uk-data#538). + Person and benefit-unit weights are their household's. * **State Pension types** (``types``, ``both``). In each year, basic for men born before 6 April 1951 and women born before 6 April 1953 (State Pension age before 6 April 2016: Pensions Act 2014 s.1(2) and s.4), new after, none diff --git a/src/triple_lock/pipeline.py b/src/triple_lock/pipeline.py index e8c6c166..52ae33b7 100644 --- a/src/triple_lock/pipeline.py +++ b/src/triple_lock/pipeline.py @@ -134,8 +134,9 @@ def method_limitations(): wording[i] = ( "The population is aged statically: each record represents a person of its age in every year. " "The Microcosm primary and Enhanced FRS 1.58.2 both anchor in their 2025-26 calibration year: " - "each dataset's own weights, the calibrated ones in that year, are retained through it and then " - "follow ONS 2024-based age/sex growth. Uncapped ages " + "each dataset's own runtime weights are retained through that year and then follow ONS 2024-based " + "age/sex growth. Microcosm's 2025 weights are exactly its solved ones; the Enhanced FRS's are its " + "calibrated ones times 0.9956 (policyengine-uk-data#538). Uncapped ages " "are retained, including genuine ages of 80; only an entirely top-coded 80+ tail gets " "represented ages of 80 to 105. State Pension types follow birth cohorts. Deaths, migration " "and contribution histories are not simulated; pensioners abroad are outside both datasets.") From 743ca091f11b8faa4876a6d13fc3299bd83ba224 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 25 Oct 2026 17:34:32 -0700 Subject: [PATCH 05/14] Keep the exact solved-weight identity to builds from microcosm#1095 Review of 8901d55: demography's note gave the identity to every Microcosm cut, but 5c6b3f68 (built at microcosm 64c460b) predates #1095's division by the model's projection factor, so its 2025 weights are its solved ones times 1.0072. METHOD's level note and CALIBRATION_ANCHOR's superseded note now say which builds divide, and that 5c6b3f68 also anchors at 2025 (since 95732e2). Co-Authored-By: Claude Opus 5.5 --- docs/CALIBRATION_ANCHOR.md | 2 +- docs/METHOD.md | 2 +- src/triple_lock/demography.py | 8 +++++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/CALIBRATION_ANCHOR.md b/docs/CALIBRATION_ANCHOR.md index b77039b8..e7e63b27 100644 --- a/docs/CALIBRATION_ANCHOR.md +++ b/docs/CALIBRATION_ANCHOR.md @@ -1,6 +1,6 @@ # Calibration anchor and certified-input bridge -> **Integration (part D).** The anchor is now part of each dataset's registry entry (`datasets.ageing_anchor`: 2025 for Enhanced FRS 1.56.16 and 1.57.4, read from `frs_release.py` at each tag; Microcosm has no published calibration year, so its anchor is 2024, the first year the ONS projection covers). *Superseded for Microcosm national cuts from a133f37a: their builds solve the weights at 2025 and write them at the data year divided by the model's own projection, so they anchor at 2025 ([METHOD.md](METHOD.md)).* The rake keeps the dataset's own weights in the anchor year and every year before it (`demography.annual_weights`). The level difference described below, runtime 2025 weights = calibrated weights × 1.0072 × 1.027 / 1.039 = 0.9956, is reported upstream as policyengine-uk-data#538. Re-checked on policyengine-uk 2.120.0: every 2025 weight is its 2024 weight × 1.0072. +> **Integration (part D).** The anchor is now part of each dataset's registry entry (`datasets.ageing_anchor`: 2025 for Enhanced FRS 1.56.16 and 1.57.4, read from `frs_release.py` at each tag; Microcosm has no published calibration year, so its anchor is 2024, the first year the ONS projection covers). *Superseded for Microcosm: every registered cut's targets are dated 2025, so each anchors at 2025 (5c6b3f68 since 95732e2). Builds from microcosm#1095 on, such as a133f37a, also write the solved weights at the data year divided by the model's own projection, so their 2025 weights are exactly the solved ones; 5c6b3f68's are the solved ones times 1.0072 ([METHOD.md](METHOD.md)).* The rake keeps the dataset's own weights in the anchor year and every year before it (`demography.annual_weights`). The level difference described below, runtime 2025 weights = calibrated weights × 1.0072 × 1.027 / 1.039 = 0.9956, is reported upstream as policyengine-uk-data#538. Re-checked on policyengine-uk 2.120.0: every 2025 weight is its 2024 weight × 1.0072. The pilot uses **2025**, the calibration year configured in the exact source release for its certified Enhanced FRS. It anchors to the runtime's native 2025 weights. Those weights do not restore the builder's original calibrated weights, so preserved Housing Benefit and Pension Credit calibration remains an acceptance gate. diff --git a/docs/METHOD.md b/docs/METHOD.md index 85642094..00270d0c 100644 --- a/docs/METHOD.md +++ b/docs/METHOD.md @@ -81,7 +81,7 @@ The revised [Pensions Act 2014 s.4](https://www.legislation.gov.uk/ukpga/2014/19 **Possible matched-total follow-up.** The implemented `total_matched` option uses the exact sum of the age/sex anchored targets as its single margin: Σ_c S_c × P_c(y)/P_c(anchor), where S_c is the anchor survey count and P_c the ONS cell population. It otherwise shares represented ages, birthdays, survey-year types, bounds and native weights through the anchor. It could compare the age/sex margins at a shared aggregate target, but needs its own full runs and source-bound readback checks. That supplement was dropped from part E's closeout as a scope cut; part E reports the ONS total-only control above. No matched-total estimate is published. -**The Enhanced FRS calibration year's level.** This is a sensitivity/bridge caveat. Microcosm's build divides its solved weights by the model's own projection factor (1.0072 from 2024 to 2025), so its 2025 weights are exactly the solved ones. The Enhanced FRS builder calibrates its weights in 2025 and saves them to 2024 with its own weight index (2024: 1.027, 2025: 1.039); policyengine-uk uprates them back to 2025 by its population growth (0.72%). In a simulation the 2025 weights are therefore the calibrated ones times 0.9956: relative weights exactly the calibrated ones, every 2025 total 0.44% low ([policyengine-uk-data#538](https://github.com/PolicyEngine/policyengine-uk-data/issues/538)). Static ageing keeps the simulation's own 2025 weights, so it neither adds to the gap nor closes it; the fix belongs to the data build or the model. +**The Enhanced FRS calibration year's level.** This is a sensitivity/bridge caveat. The Microcosm primary's build divides its solved weights by the model's own projection factor (1.0072 from 2024 to 2025; microcosm#1095), so its 2025 weights are exactly the solved ones. The pre-Budget cross-check cut 5c6b3f68 was built before that change and stores its solved weights undivided, so its 2025 weights are the solved ones times 1.0072: every 2025 total 0.72% high. The Enhanced FRS builder calibrates its weights in 2025 and saves them to 2024 with its own weight index (2024: 1.027, 2025: 1.039); policyengine-uk uprates them back to 2025 by its population growth (0.72%). In a simulation the 2025 weights are therefore the calibrated ones times 0.9956: relative weights exactly the calibrated ones, every 2025 total 0.44% low ([policyengine-uk-data#538](https://github.com/PolicyEngine/policyengine-uk-data/issues/538)). Static ageing keeps the simulation's own 2025 weights, so it neither adds to the gap nor closes it; the fix belongs to the data build or the model. **Private cache.** The population (represented ages, birthdays, the weights and the State Pension age mask and types of every year) is computed once per dataset, treatment, anchor, set of years and model (`demography.eligibility_fingerprint`: the installed policyengine-uk and the files its State Pension age and type come from) and cached under `.cache/demography`, owner-only; every path job shares one entry, and the coverage and history jobs, with other years, have their own. A run reads the model's own `is_SP_age` back against the cached mask in every year and fails if they differ, so a change to the model's State Pension age cannot reuse stale types. Nothing that depends on a path is cached: the additional pension follows each path's September CPI. No record id, weight or amount leaves the cache; an ageing run returns no single-record diagnostic, and a failed path, coverage or history job keeps its error output in an owner-only file in an owner-only worker directory instead of the build log (`jobs.private_inputs`). diff --git a/src/triple_lock/demography.py b/src/triple_lock/demography.py index b1d0cf35..3f61ad46 100644 --- a/src/triple_lock/demography.py +++ b/src/triple_lock/demography.py @@ -19,9 +19,11 @@ entropy, ratios 0.2 to 5 of the anchor's) so that each age/sex cell grows from its anchor-year survey count as the ONS 2024-based projection's does (annual_weights). The Microcosm cuts and Enhanced FRS 1.58.2 anchor at - their 2025 calibration year: Microcosm's builds write the solved weights so - that the model's 2025 weights are exactly them, and the Enhanced FRS's 2025 - weights are its calibrated ones times 0.9956 (policyengine-uk-data#538). + their 2025 calibration year. The Microcosm primary's build (microcosm#1095 + on) writes the solved weights so that the model's 2025 weights are exactly + them; the 5c6b3f68 cross-check predates that, so its 2025 weights are its + solved ones times 1.0072. The Enhanced FRS's 2025 weights are its + calibrated ones times 0.9956 (policyengine-uk-data#538). Person and benefit-unit weights are their household's. * **State Pension types** (``types``, ``both``). In each year, basic for men born before 6 April 1951 and women born before 6 April 1953 (State Pension From fac3d5d2b9ca8d12c7d97098b1ef8902e2ae96ed Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 25 Oct 2026 17:35:17 -0700 Subject: [PATCH 06/14] Name d2011's a6299504 among the builds that divide by the model's projection Co-Authored-By: Claude Opus 5.5 --- docs/CALIBRATION_ANCHOR.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/CALIBRATION_ANCHOR.md b/docs/CALIBRATION_ANCHOR.md index e7e63b27..fb86e45e 100644 --- a/docs/CALIBRATION_ANCHOR.md +++ b/docs/CALIBRATION_ANCHOR.md @@ -1,6 +1,6 @@ # Calibration anchor and certified-input bridge -> **Integration (part D).** The anchor is now part of each dataset's registry entry (`datasets.ageing_anchor`: 2025 for Enhanced FRS 1.56.16 and 1.57.4, read from `frs_release.py` at each tag; Microcosm has no published calibration year, so its anchor is 2024, the first year the ONS projection covers). *Superseded for Microcosm: every registered cut's targets are dated 2025, so each anchors at 2025 (5c6b3f68 since 95732e2). Builds from microcosm#1095 on, such as a133f37a, also write the solved weights at the data year divided by the model's own projection, so their 2025 weights are exactly the solved ones; 5c6b3f68's are the solved ones times 1.0072 ([METHOD.md](METHOD.md)).* The rake keeps the dataset's own weights in the anchor year and every year before it (`demography.annual_weights`). The level difference described below, runtime 2025 weights = calibrated weights × 1.0072 × 1.027 / 1.039 = 0.9956, is reported upstream as policyengine-uk-data#538. Re-checked on policyengine-uk 2.120.0: every 2025 weight is its 2024 weight × 1.0072. +> **Integration (part D).** The anchor is now part of each dataset's registry entry (`datasets.ageing_anchor`: 2025 for Enhanced FRS 1.56.16 and 1.57.4, read from `frs_release.py` at each tag; Microcosm has no published calibration year, so its anchor is 2024, the first year the ONS projection covers). *Superseded for Microcosm: every registered cut's targets are dated 2025, so each anchors at 2025 (5c6b3f68 since 95732e2). Builds from microcosm#1095 on (a133f37a, and d2011's a6299504 at microcosm#1396) also write the solved weights at the data year divided by the model's own projection, so their 2025 weights are exactly the solved ones; 5c6b3f68's are the solved ones times 1.0072 ([METHOD.md](METHOD.md)).* The rake keeps the dataset's own weights in the anchor year and every year before it (`demography.annual_weights`). The level difference described below, runtime 2025 weights = calibrated weights × 1.0072 × 1.027 / 1.039 = 0.9956, is reported upstream as policyengine-uk-data#538. Re-checked on policyengine-uk 2.120.0: every 2025 weight is its 2024 weight × 1.0072. The pilot uses **2025**, the calibration year configured in the exact source release for its certified Enhanced FRS. It anchors to the runtime's native 2025 weights. Those weights do not restore the builder's original calibrated weights, so preserved Housing Benefit and Pension Credit calibration remains an acceptance gate. From 88550bf0adb6fba45251b4ceb27deef7454f9b63 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 25 Oct 2026 18:58:41 -0700 Subject: [PATCH 07/14] Identify the local-area cut and the certifying merge; make the fill state-independent Answers the review of 72898f6 (blocking 1-3; blocking 4 was fac3d5d). Local-area cut (blocking 1). Every cut publishes microcosm_uk_2024_25_dense.h5, so the file name identified nothing: a6299504 routed to any dense cut certified. datasets.local_area_cut now pins a cut's file, tag, SHA-256, release manifest URIs and digest, the model its build ran and the basis of the routing. - The fallback's 19 October cut (ee5c2dbe, 8e10e130..., manifest b9dc7cae..., built on 2.132.6) is pinned now, on #699's accepted mismatch: the bundle must carry #699's regional_certification for exactly that mismatch and the regional manifest digest. - The binding cut is pending in four BINDING_LOCAL_AREA_* identities. Its basis is "built_with" 2.151.0, so a regional_certification block refuses. - _local_area_routed is the one check: the installed-bundle check and the fill script both call it. - A released-manifest certificate now also needs its certifying pull request and commit (non-blocking 3). Certifying merge (blocking 2). In --release mode the fill requires the pull request merged, its merge commit in the tag (compare: ahead or identical), and the manifest at the merge commit equal to the tag's apart from the version strings the release workflow rewrites. On the real releases: #704 against 6.11.0 passes, #555 against 6.11.0 fails. --apply needs --release (non-blocking 6). The fill reads the cut's own release manifest at its tag (or --regional-manifest): cut_tag, build model and core, dataset role and the microdata artifact must match. On the real 6.11.0 release the binding target refuses its ee5c2dbe cut ("local-area build model 2.132.6"), the review's failing input. State-independent tests (blocking 3). Tests now hold whether the identities are pending or filled. - The fill tests build a pending copy of the tree and patch datasets to pending (tests/released_pair.py builds the release manifest). - The pin, provenance and preflight tests branch on BINDING_BUNDLE_VERSION. Filled and installed, provenance must certify the pair. - New: a pair is wholly pending or wholly filled, and the lock satisfies the installed carrier's requirements. Also: - fill() builds and checks every file before writing any, and replaces with a function, not a template (non-blocking 1 and 10). - Tests cover the --release path: the tag read, the version check and the merge checks (non-blocking 2). - The preflight reads the bundle of whichever of datasets.D2011_PAIRS config.PRIMARY_DATASET names, and REBUILD lists every move a fallback commit makes (non-blocking 5). - REBUILD documents re-resolving the lock: at the preparatory carrier the uv command reproduces all 86 pins exactly. The pyproject, lock and CI comments are version-neutral (non-blocking 7). - Stale text fixed: the a6299504 tag exists with the pinned bytes, the runtime note, and "stricter than policyengine.py's rule" (non-blocking 8 and 9). The frozen C2 section is unchanged (d69a7b4a7429). expected_value.py, redact_records, handoffs and receipts are not touched. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/python.yml | 3 +- docs/METHOD.md | 4 +- docs/REBUILD.md | 19 +- pyproject.toml | 4 +- requirements-lock.txt | 4 +- scripts/fill_release_identities.py | 266 +++++++++++---- src/triple_lock/datasets.py | 118 +++++-- src/triple_lock/pipeline.py | 33 +- tests/released_pair.py | 76 +++++ tests/test_budget_bundle.py | 171 +++++++--- tests/test_datasets.py | 83 ++++- tests/test_fill_release_identities.py | 470 +++++++++++++++++++------- 12 files changed, 950 insertions(+), 301 deletions(-) create mode 100644 tests/released_pair.py diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index fdbb4583..d6aff791 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -25,7 +25,8 @@ jobs: python-version: "3.13" activate-environment: true - name: Install - # d2011's candidate UK 2.151.0/core 3.33.3; released certification pending. Retained + # d2011's UK 2.151.0/core 3.33.3 and the carrier the lock pins (preparatory while its + # release is pending; tests then require provenance to certify the filled pair). Retained # historical pilot receipts keep their original model/data labels; # no test needs the private Enhanced FRS file. run: | diff --git a/docs/METHOD.md b/docs/METHOD.md index 00270d0c..1db16bdb 100644 --- a/docs/METHOD.md +++ b/docs/METHOD.md @@ -177,9 +177,9 @@ The population here is the path runs': static ageing, ONS growth after the datas ## The certified model and the historical pilot (`datasets.py`) -For the binding rebuild, the primary registry entry is `microcosm_uk_2024_25@a6299504`. It pins `policyengine/populace-uk-private`, expected revision `microcosm-uk-2024-25-national-20261025T091414Z-a6299504`, path `microcosm_uk_2024_25.h5`, SHA-256 `b96a8cd625b645a409c063b1de5158532708a2f0d3c3897a794a8fb0bbd5064e`: the bytes of the staged attempt `uk-frs-calibration-attempt-20261025T091414Z-a6299504`, built at microcosm `67ee2ddf3`. a133f37a's attempt was released under the matching tag with the same bytes; the tag is confirmed at release, and until it exists the download fails closed. The model wheel is UK 2.151.0, SHA-256 `5722f55aca3eb88e1a228e95e1a57e3bd70a4d530a5a000991565ebd8955ce35` (microcosm#1396's `uv.lock`; attested in `data/certification/d2011-model-wheel.json`), with core 3.33.3. The pair's released carrier also routes constituency and local-authority runs to its local-area file, and the registry requires exactly that routing. The certificate must cover core 3.33.3 by policyengine.py's own rule: the core it names under `core_binding` `certified`, or else the bundle's core pin (`bundle_pin`). The released carrier version, manifest digest, certifying pull request and local-area file remain unset, so `datasets.provenance` and the publication preflight refuse certification now. #715's `microcosm_uk_2024_25@a133f37a` / UK 2.147.2 entry stays registered as d2011's fallback. #715's candidate routes national only, but d2011's fallback adds #699's area routing, so the entry requires that routing and keeps its own release identities, local-area file included, pending. +For the binding rebuild, the primary registry entry is `microcosm_uk_2024_25@a6299504`. It pins `policyengine/populace-uk-private`, expected revision `microcosm-uk-2024-25-national-20261025T091414Z-a6299504`, path `microcosm_uk_2024_25.h5`, SHA-256 `b96a8cd625b645a409c063b1de5158532708a2f0d3c3897a794a8fb0bbd5064e`: the bytes of the staged attempt `uk-frs-calibration-attempt-20261025T091414Z-a6299504`, built at microcosm `67ee2ddf3`. That revision exists on the Hub with those bytes (checked on 26 October), and materialization verifies them before every use. The model wheel is UK 2.151.0, SHA-256 `5722f55aca3eb88e1a228e95e1a57e3bd70a4d530a5a000991565ebd8955ce35` (microcosm#1396's `uv.lock`; attested in `data/certification/d2011-model-wheel.json`), with core 3.33.3. The pair's released carrier also routes constituency and local-authority runs to its local-area cut, and the registry requires exactly that cut: its file, tag and SHA-256 in the bundle's `datasets` entry, its release manifest's URIs (and digest, where the bundle records one), and no `regional_certification`, since both halves are on 2.151.0. The certificate must cover core 3.33.3 by a rule stricter than policyengine.py's: under `core_binding` `certified` the named core, which must equal the bundle's core pin; under `bundle_pin` the pin, which a named core must equal. The released carrier version, manifest digest, certifying pull request and commit, and the local-area cut remain unset, so `datasets.provenance` and the publication preflight refuse certification now; a released-manifest certificate also needs its certifying pull request and commit. #715's `microcosm_uk_2024_25@a133f37a` / UK 2.147.2 entry stays registered as d2011's fallback. #715's candidate routes national only, but d2011's fallback adds #699's area routing to the 19 October dense cut (`ee5c2dbe`, built on 2.132.6), so the entry pins that cut and requires #699's record of the accepted mismatch; its bundle identities stay pending. -The lock installs base `policyengine==6.10.1` as the available API carrier, plus exact `policyengine-uk==2.151.0` and `policyengine-core==3.33.3`; it deliberately omits `[uk]`, because the currently released carrier's extra pins its previous UK line. The engine constructs `policyengine_uk` directly and materializes its own registry-pinned dataset rather than asking that extra to select the data. This is a preparatory environment, not released-pair certification. Once the pair ships, fill only the authenticated identities left unset in `datasets.py`, and the carrier version in the lock and ready bridge config. +While the release is pending, the lock installs base `policyengine==6.10.1` as the available API carrier (`datasets.PREPARATORY_CARRIER`), plus exact `policyengine-uk==2.151.0` and `policyengine-core==3.33.3`; it deliberately omits `[uk]`, because that carrier's extra pins its previous UK line. The engine constructs `policyengine_uk` directly and materializes its own registry-pinned dataset rather than asking that extra to select the data. This is a preparatory environment, not released-pair certification. Once the pair ships, `scripts/fill_release_identities.py` fills only the authenticated identities left unset in `datasets.py`, and the carrier version in the lock and ready bridge config; the lock is then re-resolved for that carrier ([REBUILD.md](REBUILD.md)). The paired sensitivity is Enhanced FRS 1.58.2: d1727's salary-sacrifice/uprating-table fixes and preserved household ids through cloning. Enhanced FRS 1.58.0 remains for bridge step (a). The pre-Budget release's `policyengine[uk]==6.2.6` / UK 2.125.1 / core 3.33.0 / Enhanced FRS 1.58.0 identity and historical receipt bytes remain as published; [J-certification](pilot/J-certification.md) retains their hashes. The [2.125.1-to-2.147.2 source audit](d1983-upstream.md) records the reviewed upstream guards. #2019, #2507–2509, #1913, #2336, #2458, #1831, #2518 and #2516 are present; #2557 and #2576 are absent, and #2340 remains unmerged. The behavioural HB passport preflight remains mandatory. #36's separate [2.153.4 audit](d1727-upstream.md) and fail-closed qualification-ledger implementation remain history; they do not authorize this primary. The remainder of this section records the pilot's version history, rather than the current bundle. diff --git a/docs/REBUILD.md b/docs/REBUILD.md index b901105c..787e08cb 100644 --- a/docs/REBUILD.md +++ b/docs/REBUILD.md @@ -6,7 +6,7 @@ The pre-Budget release is committed on **main**: policyengine.py 6.2.6 / UK 2.12 | Gate | What it holds back | Until then | |---|---|---| -| **d2011 = A: the 2.151.0 pair (microcosm#1396)** | The exact released carrier manifest for national Microcosm cut `microcosm-uk-2024-25-national-20261025T091414Z-a6299504` and its local-area cut, UK 2.151.0 / core 3.33.3 | The release is expected around 23:00Z on Monday 26 October. If the pair is not certified by Tuesday 27 October 18:00Z, d2011 falls back to #715's `a133f37a` / UK 2.147.2 pair with policyengine.py #699's area routing. Preflight fails closed while the carrier version, released-manifest digest, certifying pull request and local-area file are unset. | +| **d2011 = A: the 2.151.0 pair (microcosm#1396)** | The exact released carrier manifest for national Microcosm cut `microcosm-uk-2024-25-national-20261025T091414Z-a6299504` and its local-area cut, UK 2.151.0 / core 3.33.3 | The release is expected around 23:00Z on Monday 26 October. If the pair is not certified by Tuesday 27 October 18:00Z, d2011 falls back to #715's `a133f37a` / UK 2.147.2 pair with policyengine.py #699's area routing. Preflight fails closed while the carrier version, released-manifest digest, certifying pull request and commit, and local-area cut are unset. | | **Binding inputs**: AWE on 20 October, September CPI on 21 October, OBR forecast on 28 October | Commit official source bytes and release identities before a fresh binding C2 handoff | A pre-Budget/dry-run file never authorizes the binding build. | | **d955 = (c)** | Only a passing original primary may get a model-conditional expected value, beside the scenario envelope | C2's frozen rule remains unchanged; primary failure automatically takes (a), scenarios only. No alternative form is promoted. | @@ -19,18 +19,19 @@ Max ruled **d2011 = A** on 25 October: the binding primary is the certified 2.15 | UK model/core | `policyengine-uk==2.151.0`, `policyengine-core==3.33.3` (microcosm#1396's `uv.lock`) | | UK wheel SHA-256 | `5722f55aca3eb88e1a228e95e1a57e3bd70a4d530a5a000991565ebd8955ce35` (`data/certification/d2011-model-wheel.json`) | | Dataset registry name | `microcosm_uk_2024_25@a6299504` | -| HF repository / revision / path | `policyengine/populace-uk-private` / expected `microcosm-uk-2024-25-national-20261025T091414Z-a6299504` / `microcosm_uk_2024_25.h5` | +| HF repository / revision / path | `policyengine/populace-uk-private` / `microcosm-uk-2024-25-national-20261025T091414Z-a6299504` / `microcosm_uk_2024_25.h5` | | Dataset SHA-256 | `b96a8cd625b645a409c063b1de5158532708a2f0d3c3897a794a8fb0bbd5064e`, the staged attempt `uk-frs-calibration-attempt-20261025T091414Z-a6299504` | | Data release manifest | `releases/microcosm-uk-2024-25-national/release_manifest.json` at that revision (`datasets.MICROCOSM_RELEASE_MANIFEST`) | -| Region routing | national to the national file; constituency and local authority to the local-area file | -| Unset until the release | carrier version and manifest digest, certifying pull request and merge commit, local-area file (`datasets.BINDING_*`) | -| Core coverage | the certificate's core by policyengine.py's rule (`datasets.certified_core`): the named `certified_for_core_version` under `core_binding` `certified`, else the bundle's core pin (`bundle_pin`, 6.11.0's default) | +| Region routing | national to the national file; constituency and local authority to the local-area cut's file, with the bundle's `regional_*` fields naming that cut's release manifest and no `regional_certification` (both halves are on 2.151.0) | +| Unset until the release | carrier version and manifest digest, certifying pull request and merge commit, and the 2.151.0 local-area cut: file, tag, SHA-256 and release manifest SHA-256 (`datasets.BINDING_*`) | +| Core coverage | the certificate's core by policyengine.py's rule (`datasets.certified_core`), made stricter: the named `certified_for_core_version` under `core_binding` `certified`, which must equal the bundle's core pin; else the pin (`bundle_pin`, 6.11.0's default), which a named core must equal | -a133f37a's staged attempt was released under the tag with its timestamp and suffix, with the same bytes, so the revision and SHA-256 above are the expected ones; confirm both against the release. The preparatory lock uses the base carrier without `[uk]`, plus explicit UK 2.151.0/core 3.33.3. The engine constructs `policyengine_uk` directly and loads its own verified artifact. Publication preflight remains closed until the exact release is authenticated. d1727's Enhanced FRS qualification-ledger route is superseded as the primary; its fail-closed code, [status receipt](../data/certification/d1727-qualification-status.json) and [2.153.4 audit](d1727-upstream.md) stay as history. +The revision exists on the Hub with the pinned file (LFS SHA-256 and its 342,348,841 bytes checked on 26 October); materialization still verifies the bytes before every use, and the release must name the same artifact. The preparatory lock uses the base carrier without `[uk]`, plus explicit UK 2.151.0/core 3.33.3. The engine constructs `policyengine_uk` directly and loads its own verified artifact. Publication preflight remains closed until the exact release is authenticated. d1727's Enhanced FRS qualification-ledger route is superseded as the primary; its fail-closed code, [status receipt](../data/certification/d1727-qualification-status.json) and [2.153.4 audit](d1727-upstream.md) stay as history. -**Fallback.** d2011's fallback is #715's `microcosm_uk_2024_25@a133f37a` / UK 2.147.2 pair served with [policyengine.py #699](https://github.com/PolicyEngine/policyengine.py/pull/699)'s area routing: a133f37a's national file, with constituency and local-authority runs routed to the 19 October dense local-area cut `microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe` (built on 2.132.6) on an accepted model mismatch. #715's reviewed candidate head `e02d41bceab57756373b2dc3bcf78635b856ec81` routes national only, so it does not certify the fallback by itself. The registry entry (`datasets.BUNDLE_715`, `FALLBACK_REGION_DATASETS`) keeps every release identity pending in `datasets.FALLBACK_*`, the local-area file included. If d2011 falls back, fill them with `scripts/fill_release_identities.py --target fallback`, then move `config.PRIMARY_DATASET`, the preflight's primary, the model pin and the bridge endpoints back to it in one reviewed commit; its [2.125.1-to-2.147.2 audit](d1983-upstream.md) applies. +**Fallback.** d2011's fallback is #715's `microcosm_uk_2024_25@a133f37a` / UK 2.147.2 pair served with [policyengine.py #699](https://github.com/PolicyEngine/policyengine.py/pull/699)'s area routing: a133f37a's national file, with constituency and local-authority runs routed to the 19 October dense local-area cut `microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe` (built on 2.132.6) on an accepted model mismatch. #715's reviewed candidate head `e02d41bceab57756373b2dc3bcf78635b856ec81` routes national only, so it does not certify the fallback by itself. Its local-area cut is pinned now (`datasets.FALLBACK_LOCAL_AREA`: file `microcosm_uk_2024_25_dense.h5`, SHA-256 `8e10e130…`, release manifest SHA-256 `b9dc7cae…`), and the release must carry #699's `regional_certification` for exactly that mismatch (2.147.2 certified, 2.132.6 built, area receipts uncertified) and `regional_source_manifest_sha256`. Its bundle identities stay pending in `datasets.FALLBACK_*`. If d2011 falls back, fill them with `scripts/fill_release_identities.py --target fallback --pr --release --apply`. Then, in one reviewed commit, move these to the fallback: `config.PRIMARY_DATASET` (the preflight reads the bundle of whichever of `datasets.D2011_PAIRS` it names, here `datasets.BUNDLE_715`); the model pin to `policyengine-uk==2.147.2` and the carrier pin in `pyproject.toml` and `requirements-lock.txt`, with the lock re-resolved as in step 1; CI's install comment; the bridge endpoints and dataset in `data/binding-bridge.pending.json`, and its ready copy; and the d2011 wording in METHOD.md and this runbook. Its [2.125.1-to-2.147.2 audit](d1983-upstream.md) applies. -1. After the pair is released, run `scripts/fill_release_identities.py --pr --release `. It reads the UK bundle manifest at the release tag (the release workflow's version commit rewrites it after the certifying merge, so the merge commit's bytes are not the release's), compares every identity above with it, the revision included, and prints the five `datasets.BINDING_*` values: carrier version, manifest SHA-256, certifying pull request and merge commit, and local-area file. Any difference refuses. With `--apply` it writes those, the carrier version in both dependency files and the ready `data/binding-bridge.json` of step 4. Do not change the model/core/data pins merely to fit a carrier. After installing the release, rerun it without `--apply`, with `--manifest` on the installed wheel's `policyengine/data/bundle/manifest.json`: it then also checks each filled identity, so the installed bytes must hash to the pinned digest, as the preflight in step 3 requires. +1. After the pair is released, run `HUGGING_FACE_TOKEN=… scripts/fill_release_identities.py --pr --release `. It reads the UK bundle manifest at the release tag (the release workflow's version commit rewrites it after the certifying merge, so the merge commit's bytes are not the release's) and compares every identity above with it, the revision included. It reads the local-area cut's own release manifest at the cut's tag (or `--regional-manifest FILE`): the cut's build must name that tag, have run UK 2.151.0 on core 3.33.3, and publish the file the bundle routes to, with the bundle's `datasets` entry, regional URIs and any `regional_source_manifest_sha256` matching; a `regional_certification` block refuses. It requires the certifying pull request to be merged, its merge commit to be in the tag, and the manifest at that commit to equal the tag's apart from the version strings the release workflow rewrites. It prints the eight `datasets.BINDING_*` values: carrier version, manifest SHA-256, certifying pull request and merge commit, and the local-area cut's file, tag, SHA-256 and release manifest SHA-256. Any difference refuses. With `--apply` (only with `--release`) it checks every file it writes before writing any, then writes those, the carrier version in both dependency files and the ready `data/binding-bridge.json` of step 4. Do not change the model/core/data pins merely to fit a carrier. + Then re-resolve the lock for the new carrier, keeping every other pin as a preference: save the four comment lines at the top of `requirements-lock.txt`, run `uv pip compile pyproject.toml --extra uk --extra dev --python-version 3.13 --no-header --no-annotate --upgrade-package policyengine -o requirements-lock.txt`, and put the comment lines back. At the preparatory carrier this reproduces all 86 pins exactly. Name any pin other than `policyengine` that moves in the commit; `test_the_lock_satisfies_the_installed_carrier_s_own_requirements` checks the installed carrier's requirements against the lock. After installing the lock, rerun the script without `--apply`, with `--manifest` on the installed wheel's `policyengine/data/bundle/manifest.json`: it then also checks each filled identity, so the installed bytes must hash to the pinned digest, as the preflight in step 3 requires. With the release installed, `tests/test_datasets.py` and `tests/test_budget_bundle.py` require provenance to certify the filled pair. 2. Keep the [2.147.2-to-2.151.0 source audit](d2011-upstream.md) (on top of the [2.125.1-to-2.147.2 audit](d1983-upstream.md)), `check_upstream()` and the parameter/small-household checks passing. UK 2.151.0 adds #2557: benefit uprating follows the previous September's CPI and `path_following` takes its September branch. It lacks #2576 (earnings-linked guarantee/derived HB and Savings Credit, first 2.151.2), and #2340 remains unmerged. Additional pension still takes the engine's path-specific explicit CPI pin. Age-addition handling takes the absent-variable branch. 3. Run certification and the behavioural HB receipt-passport preflight below before survey jobs. Synthetic checks never certify the fiscal result. 4. `--apply` in step 1 writes this; check it by hand. It copies `data/binding-bridge.pending.json` to `data/binding-bridge.json`, fills the released carrier version in the three new endpoint package maps and `pending_release.policyengine_version`, fills `pending_release.pull_request`, `reviewed_head` and `bundle_manifest_sha256`, and sets status `ready`. Commit it. Keep historical baseline pins/SHA, UK/core pins and both artifact hashes fixed. Pending `--validate-only` honestly refuses without launching jobs. @@ -247,7 +248,7 @@ The dataset roles have changed. Counts below use the committed original-primary For updated inputs let `U` be original-primary `unique_full_runs` (including the extra check), and `V` the distinct indices in the frozen 40-slot sensitivity. The binding build has `3U + 11` **Microcosm** jobs under either C2 outcome; Enhanced FRS adds `V + 1` after a pass, or one after failure. Validation adds `5(1 + V) + 6` Microcosm jobs after pass, or 11 after failure. Standalone mean-path scenarios have `3U` Microcosm jobs. Cache hits and replacement-draw duplicates reduce process launches. -**Runtime at one Microcosm worker.** The only cold Microcosm path timings are the 12 October rehearsal's two jobs (556 s and 672 s, mean 614 s), on an earlier cut and model. At that rate, one worker runs the build's 494 Microcosm jobs in about **84 hours**, and the validation's 211 after a pass in about **36 hours** more. That is an estimate from two historical jobs, not a measurement on a133f37a. The binding data step runs 42 Microcosm jobs at one worker and records each batch's time and memory: use them to re-estimate, and to decide with Fleet ops whether a second Microcosm worker fits (two workers need 80 GiB available at a batch start, and each slot refill 40 GiB beyond what the other running job may still allocate of its 40 GiB). +**Runtime at one Microcosm worker.** The only cold Microcosm path timings are the 12 October rehearsal's two jobs (556 s and 672 s, mean 614 s), on an earlier cut and model. At that rate, one worker runs the build's 494 Microcosm jobs in about **84 hours**, and the validation's 211 after a pass in about **36 hours** more. That is an estimate from two historical jobs, not a measurement on a6299504. The binding data step runs 42 Microcosm jobs at one worker and records each batch's time and memory: use them to re-estimate, and to decide with Fleet ops whether a second Microcosm worker fits (two workers need 80 GiB available at a batch start, and each slot refill 40 GiB beyond what the other running job may still allocate of its 40 GiB). **Capacity estimate for Fleet ops, derived without survey jobs.** The old reservation assumed 3 Enhanced FRS plus 2 Microcosm workers. Keeping role caps of 3 primary plus 2 sensitivity would now mean **3 Microcosm plus 2 Enhanced FRS** workers. Across concurrent work, filling all five reserved slots at the historical per-worker peaks (31.8 GB and 6 GB) gives **107.4 GB**, versus the old **81.6 GB**: **25.8 GB / 31.6% more**. These are reservation-wide sums. The frozen `expected_value.build` schedules primary and sensitivity phases sequentially: its three-primary/two-sensitivity worker estimate is `max(3 × 31.8, 2 × 6)` = **95.4 GB**, excluding coordinator/other-process memory. At the documented one-primary/one-sensitivity caps, that phase maximum is **31.8 GB**. These are historical-peak extrapolations, not measurements of a6299504/2.151.0; the fresh-cut peak and net-support-array cost are unmeasured. Code admission requires **120 GiB available** for three concurrent cold Microcosm jobs (40 GiB each), compared with 80 GiB for two. Start the main session at **`--workers 1 --sensitivity-workers 1`**, with a 40 GiB admission floor, and have Fleet ops review capacity before increasing it. The bridge likewise caps Microcosm endpoints at two, uses Microcosm slots and scales its before-batch floor to 40 GiB per concurrent Microcosm job; each slot keeps 40 GiB for the one job it admits beyond what the batch's other admitted Microcosm jobs may still allocate, and its monitor keeps 8 GiB while the batch runs ([Host admission](#host-admission-memory-floors-and-the-load-limit)). diff --git a/pyproject.toml b/pyproject.toml index ca47c742..561ab83b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,8 +20,8 @@ dependencies = [ [project.optional-dependencies] # d2011's pair's UK model/core (microcosm#1396's uv.lock), explicitly pinned while its carrier release is pending. # The engine constructs policyengine_uk directly and materializes its own pinned data. -# Use the released carrier without [uk]: 6.10.1's extra pins the previous model. -# Once the pair is released, update the carrier version and manifest digest in datasets.py. +# The carrier is installed without [uk]: an extra may pin another model. scripts/fill_release_identities.py sets +# its version from the release, with the identities in datasets.py; then re-resolve the lock (docs/REBUILD.md). uk = [ "policyengine==6.10.1", "policyengine-uk==2.151.0", diff --git a/requirements-lock.txt b/requirements-lock.txt index d1628ee9..3e800cfe 100644 --- a/requirements-lock.txt +++ b/requirements-lock.txt @@ -1,5 +1,5 @@ -# d2011 A: microcosm#1396's candidate model/core, pending exact released carrier certification. -# Base policyengine carrier deliberately has no [uk] extra (its released UK pin differs). +# d2011 A: microcosm#1396's model/core, with the carrier datasets.py pins (preparatory while its release is pending). +# Base policyengine carrier deliberately has no [uk] extra (an extra may pin another model). # Dataset and future release-manifest identity are pinned in triple_lock/datasets.py. # Historical receipts retain their original model/data identities. annotated-types==0.8.0 diff --git a/scripts/fill_release_identities.py b/scripts/fill_release_identities.py index 7618aa98..9978600a 100644 --- a/scripts/fill_release_identities.py +++ b/scripts/fill_release_identities.py @@ -1,27 +1,39 @@ """Check a policyengine.py release against d2011's pinned pair, then fill the identities only it can supply. .venv/bin/python scripts/fill_release_identities.py --pr N --release TAG [--target fallback] [--apply] + .venv/bin/python scripts/fill_release_identities.py --pr N --manifest FILE [--target fallback] The UK bundle manifest is read at the release tag (the release workflow's version commit rewrites it after the certifying merge, so the merge commit's bytes are not the release's), or from ``--manifest FILE``, such as the installed wheel's. Every identity datasets.py already pins is compared the way ``datasets._certified_by_installed_bundle`` will compare the installed release: carrier version, model and core -versions, model wheel SHA-256, build id, release and source manifest URIs, the certified artifact, and region -routing (national to the pinned file; constituency and local authority to one local-area file). Any difference -exits 1 before anything is written. - -``--apply`` writes the target's five ``datasets`` identities (carrier version, manifest SHA-256, certifying pull -request and merge commit, local-area file). For the binding target it also writes the carrier pin in -pyproject.toml and requirements-lock.txt and data/binding-bridge.json, the pending configuration filled and -marked ready. The fallback target writes only its datasets identities: its model pin, primary and bridge -endpoints move in their own reviewed commit. Nothing is installed and no data is loaded. The installed release -must still pass check_upstream(), both preflights and the bridge's --validate-only. +versions, model wheel SHA-256, build id, release and source manifest URIs, the certified artifact, region routing +(national to the pinned file; constituency and local authority to the local-area file) and the local-area cut. + +The local-area cut's own release manifest is read at its tag on the Hub (``HUGGING_FACE_TOKEN``), or from +``--regional-manifest FILE``. Its build must name the cut's tag and have run the cut's model (2.151.0 for the +binding pair, whose bundle must carry no ``regional_certification``; 2.132.6 for the fallback, on #699's accepted +mismatch) on core 3.33.3, and its local-area artifact must be the file the bundle routes to. The fallback's cut is +pinned already; the binding cut's file, tag, SHA-256 and manifest SHA-256 come from the release and are filled. + +With ``--release``, the certifying pull request must be merged, its merge commit must be in the tag, and the +manifest at the merge commit must equal the tag's apart from the version strings the release workflow rewrites: +the release is then exactly what the certifying review merged. Any difference exits 1 before anything is written. + +``--apply`` (with ``--release`` only) writes the target's identities: carrier version, manifest SHA-256, certifying +pull request and merge commit, and for the binding target its local-area cut. The binding target also writes the +carrier pin in pyproject.toml and requirements-lock.txt and data/binding-bridge.json, the pending configuration +filled and marked ready. The fallback target writes only its datasets identities: its model pin, primary and bridge +endpoints move in their own reviewed commit. Every file is checked before any is written. Nothing is installed and +no data is loaded. Afterwards: re-resolve the lock for the new carrier, install it, rerun this with ``--manifest`` +on the installed wheel's manifest, and run check_upstream(), both preflights and the bridge's --validate-only. """ import argparse import base64 import hashlib import json +import os from pathlib import Path import re import subprocess @@ -32,35 +44,79 @@ sys.path.insert(0, str(ROOT / "src")) REPOSITORY = "PolicyEngine/policyengine.py" MANIFEST = "src/policyengine/data/bundle/manifest.json" -LOCAL_AREA_REGIONS = ("constituency", "local_authority") TARGETS = {"binding": ("microcosm_uk_2024_25@a6299504", "BINDING"), "fallback": ("microcosm_uk_2024_25@a133f37a", "FALLBACK")} -IDENTITIES = ("BUNDLE_VERSION", "BUNDLE_MANIFEST_SHA256", "CERTIFICATION_URL", "CERTIFICATION_COMMIT", - "LOCAL_AREA_PATH") +BUNDLE_IDENTITIES = ("BUNDLE_VERSION", "BUNDLE_MANIFEST_SHA256", "CERTIFICATION_URL", "CERTIFICATION_COMMIT") +LOCAL_AREA_IDENTITIES = ("LOCAL_AREA_PATH", "LOCAL_AREA_REVISION", "LOCAL_AREA_SHA256", "LOCAL_AREA_MANIFEST_SHA256") +IDENTITIES = {"BINDING": BUNDLE_IDENTITIES + LOCAL_AREA_IDENTITIES, "FALLBACK": BUNDLE_IDENTITIES} +LOCAL_AREA_ROLE = "non_default_local_area" def github(path): return json.loads(subprocess.run(["gh", "api", path], check=True, capture_output=True, text=True).stdout) -def checks(manifest, spec): - """[(label, pinned, released)] for every identity the registry already pins.""" - from triple_lock.datasets import certified_core +def github_file(path, ref): + """A file's exact bytes at a ref of policyengine.py.""" + return base64.b64decode(github(f"repos/{REPOSITORY}/contents/{path}?ref={ref}")["content"]) + + +def hub_file(local): + """The local-area cut's release manifest, at the cut's own tag.""" + from huggingface_hub import hf_hub_download + from triple_lock.datasets import LOCAL_AREA_RELEASE_MANIFEST + + return Path(hf_hub_download(local["repo_id"], LOCAL_AREA_RELEASE_MANIFEST, revision=local["revision"], + repo_type=local["repo_type"], token=os.environ.get("HUGGING_FACE_TOKEN"))).read_bytes() + + +def local_area_entry(release, path): + """The release's one datasets entry for the local-area file ({} unless exactly one names it).""" + entries = [entry for entry in release.get("datasets", {}).values() + if isinstance(entry, dict) and entry.get("path") == path] + return entries[0] if len(entries) == 1 else {} + + +def released_local_area(release): + """The local-area file the release routes constituency runs to, with its tag and SHA-256.""" + path = (release["region_datasets"].get("constituency") or {}).get("path_template") + entry = local_area_entry(release, path) + return {"LOCAL_AREA_PATH": path, "LOCAL_AREA_REVISION": entry.get("revision"), + "LOCAL_AREA_SHA256": entry.get("sha256")} + + +def checks(manifest, spec, local, regional_raw): + """[(label, pinned, released)] for every identity the registry pins, and for the local-area cut ``local`` (the + pinned cut, or for a pending binding cut the one the release names) against its own release manifest's bytes.""" + from triple_lock.datasets import _local_area_routed, area_routed, certified_core certification = spec["bundle_certification"] release = manifest["data_releases"]["uk"] artifact = release["certified_data_artifact"] version = manifest["policyengine_version"] - regions = release["region_datasets"] - local = {(regions.get(area) or {}).get("path_template") for area in LOCAL_AREA_REGIONS} + model = certification["model_version"] + entry = local_area_entry(release, local["path"]) + regional = json.loads(regional_raw) + build = regional.get("build", {}) + cut_artifacts = [item for item in regional.get("artifacts", {}).values() + if isinstance(item, dict) and item.get("path") == local["path"]] + certificate = release.get("regional_certification") + if local["model_basis"] == "built_with": + pinned_certificate = None # both halves on the pair's own model + else: # policyengine.py #699's record of the reviewed mismatch + pinned_certificate = {"compatibility_basis": local["model_basis"], "certified_for_model_version": model, + "built_with_model_version": local["built_with_model_version"], + "regional_manifest_uri": local["source_manifest_uri"], + "regional_manifest_sha256": local["manifest_sha256"], "area_receipts": "uncertified"} + if isinstance(certificate, dict): + certificate = {key: certificate.get(key) for key in pinned_certificate} return [ ("UK release carrier version", version, release["policyengine_version"]), ("bundle id", f"uk-{version}", release["bundle_id"]), ("core version", certification["core_version"], manifest["packages"]["policyengine-core"]["version"]), ("core version certified for", certification["core_version"], certified_core(manifest)), - ("model version", certification["model_version"], release["model_package"]["version"]), - ("model version certified for", certification["model_version"], - release["certification"]["certified_for_model_version"]), + ("model version", model, release["model_package"]["version"]), + ("model version certified for", model, release["certification"]["certified_for_model_version"]), ("model wheel SHA-256", certification["model_wheel_sha256"], release["model_package"]["sha256"]), ("release build id", spec["build_id"], release["build_id"]), ("artifact build id", spec["build_id"], artifact["build_id"]), @@ -71,58 +127,114 @@ def checks(manifest, spec): ("artifact URI", f"hf://{spec['repo_id']}/{spec['path']}@{spec['revision']}", artifact["uri"]), ("artifact data package", {"name": spec["data_package"], "version": spec["data_version"]}, artifact["data_package"]), - ("routed regions", ["constituency", "local_authority", "national"], sorted(regions)), - ("national file", {"path_template": spec["path"]}, regions.get("national")), - ("one local-area file for both area types", True, len(local) == 1 and bool(next(iter(local)))), + # Exactly what the installed check compares: national to the pinned file, both area types to one file. + ("region routing", area_routed(local["path"]), release["region_datasets"]), + ("local-area datasets entry", {key: local[key] for key in ("repo_id", "repo_type", "revision", "sha256")}, + {key: entry.get(key, local["repo_type"] if key == "repo_type" else None) + for key in ("repo_id", "repo_type", "revision", "sha256")}), + ("regional release kind", "local_area", release.get("regional_release_kind")), + ("regional release manifest URI", local["release_manifest_uri"], release.get("regional_release_manifest_uri")), + ("regional source manifest URI", local["source_manifest_uri"], release.get("regional_source_manifest_uri")), + ("regional source manifest SHA-256", local["manifest_sha256"], + release.get("regional_source_manifest_sha256", None if pinned_certificate else local["manifest_sha256"])), + ("regional certification", pinned_certificate, certificate), + ("local-area release manifest SHA-256", local["manifest_sha256"], hashlib.sha256(regional_raw).hexdigest()), + ("local-area cut tag", local["revision"], build.get("cut_tag")), + ("local-area build model", local["built_with_model_version"], + build.get("built_with_model_package", {}).get("version")), + ("local-area build core", certification["core_version"], + build.get("built_with_core_package", {}).get("version")), + ("local-area dataset role", LOCAL_AREA_ROLE, regional.get("dataset_role")), + ("local-area artifact", [{"kind": "microdata", "path": local["path"], "repo_id": local["repo_id"], + "revision": local["revision"], "sha256": local["sha256"]}], + [{key: item.get(key) for key in ("kind", "path", "repo_id", "revision", "sha256")} + for item in cut_artifacts]), + # The function provenance calls, so the script and the installed check cannot disagree. + ("installed check routes to this cut", True, _local_area_routed(release, local, model)), ] -def identities(raw, manifest, pull_request): - """The five values only the release supplies, from the manifest's exact bytes and the pull request. An unmerged - pull request has no certifying commit (None), so its values can be printed but never applied.""" - regions = manifest["data_releases"]["uk"]["region_datasets"] - return {"BUNDLE_VERSION": manifest["policyengine_version"], - "BUNDLE_MANIFEST_SHA256": hashlib.sha256(raw).hexdigest(), - "CERTIFICATION_URL": pull_request["html_url"], - "CERTIFICATION_COMMIT": pull_request["merge_commit_sha"] if pull_request.get("merged_at") else None, - "LOCAL_AREA_PATH": (regions.get("constituency") or {}).get("path_template")} +def same_but_version(merged, released, old, new): + """Whether two manifests are equal once ``old`` is read as ``new`` in each string the release rewrote.""" + if isinstance(merged, dict): + return (isinstance(released, dict) and merged.keys() == released.keys() + and all(same_but_version(merged[key], released[key], old, new) for key in merged)) + if isinstance(merged, list): + return (isinstance(released, list) and len(merged) == len(released) + and all(same_but_version(a, b, old, new) for a, b in zip(merged, released))) + if isinstance(merged, str) and isinstance(released, str): + return released in (merged, merged.replace(old, new)) + return type(merged) is type(released) and merged == released + + +def certifying_checks(pull_request, tag, manifest): + """[(label, pinned, released)]: the pull request merged, into this tag, with exactly this manifest.""" + commit = pull_request.get("merge_commit_sha") if pull_request.get("merged_at") else None + rows = [("certifying pull request merged", True, commit is not None)] + if commit is None: + return rows + status = github(f"repos/{REPOSITORY}/compare/{commit}...{tag}")["status"] + merged = json.loads(github_file(MANIFEST, commit)) + new = manifest["policyengine_version"] + return rows + [ + ("merge commit is in the release", True, status in ("ahead", "identical")), + ("release manifest is the merge's, version strings aside", True, + same_but_version(merged, manifest, merged.get("policyengine_version") or new, new)), + ] + + +def identities(raw, manifest, pull_request, prefix, regional_raw): + """The values only the release supplies, from the manifest's exact bytes, the pull request and (binding) the + local-area cut it names. An unmerged pull request has no certifying commit (None), so its values can be + printed but never applied.""" + values = {"BUNDLE_VERSION": manifest["policyengine_version"], + "BUNDLE_MANIFEST_SHA256": hashlib.sha256(raw).hexdigest(), + "CERTIFICATION_URL": pull_request["html_url"], + "CERTIFICATION_COMMIT": pull_request["merge_commit_sha"] if pull_request.get("merged_at") else None} + if prefix == "BINDING": + values.update(released_local_area(manifest["data_releases"]["uk"]), + LOCAL_AREA_MANIFEST_SHA256=hashlib.sha256(regional_raw).hexdigest()) + return values def _replace_once(text, pattern, replacement, where): + """``replacement`` is a function of the match, so no value is read as a template.""" text, count = re.subn(pattern, replacement, text, flags=re.M) if count != 1: - raise ValueError(f"{where}: expected exactly one match for {pattern!r}") + raise ValueError(f"{where}: expected exactly one match for {pattern!r}: nothing is written") return text def fill(root, prefix, values, reviewed_head): - """Write the identities; the binding target also writes its carrier pins and the ready bridge configuration.""" - missing = [name for name in IDENTITIES if not values.get(name)] + """Write the identities; the binding target also writes its carrier pins and the ready bridge configuration. + Every file is built and checked first, so a refusal writes nothing.""" + missing = [name for name in IDENTITIES[prefix] if not values.get(name)] if missing: raise ValueError(f"the release does not supply {missing}: nothing is written") path = root / "src/triple_lock/datasets.py" text = path.read_text() - for name in IDENTITIES: - text = _replace_once(text, rf"^{prefix}_{name} = None$", f"{prefix}_{name} = {json.dumps(values[name])}", - path) - path.write_text(text) - if prefix != "BINDING": - return - version = values["BUNDLE_VERSION"] - for path in (root / "pyproject.toml", root / "requirements-lock.txt"): - path.write_text(_replace_once(path.read_text(), r'^(\s*"?policyengine==)[0-9][^",\s]*', rf"\g<1>{version}", - path)) - configuration = json.loads((root / "data/binding-bridge.pending.json").read_text()) - for step, endpoint in (("model", "to"), ("data", "from"), ("data", "to")): - packages = configuration["steps"][step][endpoint]["packages"] - if packages["policyengine"] is not None: - raise ValueError(f"the pending {step}/{endpoint} endpoint already names a carrier") - packages["policyengine"] = version - configuration["status"] = "ready" - configuration["pending_release"].update( - pull_request=values["CERTIFICATION_URL"], reviewed_head=reviewed_head, policyengine_version=version, - bundle_manifest_sha256=values["BUNDLE_MANIFEST_SHA256"]) - (root / "data/binding-bridge.json").write_text(json.dumps(configuration, indent=2) + "\n") + for name in IDENTITIES[prefix]: + assignment = f"{prefix}_{name} = {json.dumps(values[name])}" + text = _replace_once(text, rf"^{prefix}_{name} = None$", lambda _, line=assignment: line, path) + writes = {path: text} + if prefix == "BINDING": + version = values["BUNDLE_VERSION"] + for path in (root / "pyproject.toml", root / "requirements-lock.txt"): + writes[path] = _replace_once(path.read_text(), r'^(\s*"?policyengine==)[0-9][^",\s]*', + lambda match: match.group(1) + version, path) + configuration = json.loads((root / "data/binding-bridge.pending.json").read_text()) + for step, endpoint in (("model", "to"), ("data", "from"), ("data", "to")): + packages = configuration["steps"][step][endpoint]["packages"] + if packages["policyengine"] is not None: + raise ValueError(f"the pending {step}/{endpoint} endpoint already names a carrier: nothing is written") + packages["policyengine"] = version + configuration["status"] = "ready" + configuration["pending_release"].update( + pull_request=values["CERTIFICATION_URL"], reviewed_head=reviewed_head, policyengine_version=version, + bundle_manifest_sha256=values["BUNDLE_MANIFEST_SHA256"]) + writes[root / "data/binding-bridge.json"] = json.dumps(configuration, indent=2) + "\n" + for path, text in writes.items(): + path.write_text(text) def main(argv=None): @@ -131,24 +243,40 @@ def main(argv=None): source = parser.add_mutually_exclusive_group(required=True) source.add_argument("--release", help="the policyengine.py release tag carrying it") source.add_argument("--manifest", type=Path, help="that release's manifest file, e.g. the installed wheel's") + parser.add_argument("--regional-manifest", type=Path, + help="the local-area cut's release_manifest.json (default: read it at the cut's tag)") parser.add_argument("--target", choices=sorted(TARGETS), default="binding") - parser.add_argument("--apply", action="store_true", help="write the identities once every check passes") + parser.add_argument("--apply", action="store_true", + help="with --release: write the identities once every check passes") args = parser.parse_args(argv) + if args.apply and not args.release: + parser.error("--apply needs --release: only the tag's checks authenticate what it writes") from triple_lock import datasets name, prefix = TARGETS[args.target] spec = datasets.DATASETS[name] - if args.manifest: - raw = args.manifest.read_bytes() - else: - raw = base64.b64decode(github(f"repos/{REPOSITORY}/contents/{MANIFEST}?ref={args.release}")["content"]) + raw = args.manifest.read_bytes() if args.manifest else github_file(MANIFEST, args.release) manifest = json.loads(raw) if args.release and manifest["policyengine_version"] != args.release: raise SystemExit(f"release {args.release}'s manifest names policyengine {manifest['policyengine_version']}") - rows = checks(manifest, spec) + local = spec["local_area"] + if any(local[key] is None for key in ("path", "revision", "sha256", "manifest_sha256")): + # The binding cut, pending: check the one the release names, with its release manifest's digest. + supplied = released_local_area(manifest["data_releases"]["uk"]) + local = datasets.local_area_cut(supplied["LOCAL_AREA_PATH"], supplied["LOCAL_AREA_REVISION"], + supplied["LOCAL_AREA_SHA256"], None, local["built_with_model_version"], + local["model_basis"]) + if local["revision"] is None and not args.regional_manifest: + raise SystemExit("refused: the release names no single local-area datasets entry to read the cut from") + regional_raw = args.regional_manifest.read_bytes() if args.regional_manifest else hub_file(local) + if local["manifest_sha256"] is None: + local["manifest_sha256"] = hashlib.sha256(regional_raw).hexdigest() + rows = checks(manifest, spec, local, regional_raw) pull_request = github(f"repos/{REPOSITORY}/pulls/{args.pr}") - values = identities(raw, manifest, pull_request) + if args.release: + rows += certifying_checks(pull_request, args.release, manifest) + values = identities(raw, manifest, pull_request, prefix, regional_raw) # Identities already filled (a rerun on the installed wheel's manifest) must equal what this release supplies. rows += [(f"filled {prefix}_{key}", getattr(datasets, f"{prefix}_{key}"), value) for key, value in values.items() if getattr(datasets, f"{prefix}_{key}") is not None] @@ -156,11 +284,8 @@ def main(argv=None): for label, pinned, released in rows: print(f"{'ok ' if pinned == released else 'FAIL'} {label:<{width}} {json.dumps(released)}" + ("" if pinned == released else f" (pinned {json.dumps(pinned)})")) - release = manifest["data_releases"]["uk"] print(json.dumps({"dataset": name, **{f"{prefix}_{key}": value for key, value in values.items()}, - "reviewed_head": pull_request["head"]["sha"], - **{key: release.get(key) for key in ("regional_release_kind", "regional_release_manifest_uri", - "regional_source_manifest_uri")}}, indent=2)) + "reviewed_head": pull_request["head"]["sha"]}, indent=2)) if any(pinned != released for _, pinned, released in rows): print(f"refused: the release differs from the pinned {name} pair", file=sys.stderr) return 1 @@ -168,8 +293,9 @@ def main(argv=None): if any(label.startswith("filled ") for label, _, _ in rows): raise SystemExit(f"{prefix} identities are already filled: rerun without --apply to check them") fill(ROOT, prefix, values, pull_request["head"]["sha"]) - print("applied: review the diff; install the release; run check_upstream(), both preflights and " - "the bridge's --validate-only") + print("applied: review the diff; re-resolve requirements-lock.txt for the new carrier (docs/REBUILD.md); " + "install it; rerun this with --manifest on the installed wheel's manifest; run check_upstream(), " + "both preflights and the bridge's --validate-only") return 0 diff --git a/src/triple_lock/datasets.py b/src/triple_lock/datasets.py index 6bd50fe9..45bb5747 100644 --- a/src/triple_lock/datasets.py +++ b/src/triple_lock/datasets.py @@ -4,9 +4,10 @@ and local-area pair built at microcosm#1396, Microcosm a6299504 / UK 2.151.0 / core 3.33.3. Its carrier release is pending: provenance fails closed until its released version, installed manifest digest, certifying pull request and -local-area file are pinned below. #715's a133f37a / UK 2.147.2 pair, d1983's -first binding pair, is d2011's fallback with policyengine.py #699's area -routing; it keeps its own pending identity the same way. +commit, and local-area cut are pinned below. #715's a133f37a / UK 2.147.2 +pair, d1983's first binding pair, is d2011's fallback with policyengine.py +#699's area routing to the 19 October local-area cut, which is pinned already; +its bundle identities are pending the same way. Historical Enhanced FRS and Microcosm artifacts retain their original pins. Materialization checks the exact artifact SHA-256 before every use (cached while unchanged), with one shared copy in .cache/datasets per artifact. @@ -33,15 +34,14 @@ } # #715's pair, d1983's first binding pair, is d2011's fallback if the 2.151.0 pair is not certified by Tue 27 Oct # 18:00Z: a133f37a's national file with policyengine.py #699's area routing (the 19 Oct dense local-area cut -# microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe, built on 2.132.6, on an accepted model mismatch). #715's -# candidate head e02d41bc routes national only, and its 6.10.1 is NOT the release. Unknown until a fallback release: -# its version and installed manifest digest, the pull request and commit that certify it, and its local-area file. -# Each fails closed while None. +# microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe, built on 2.132.6, on an accepted model mismatch; pinned in +# FALLBACK_LOCAL_AREA below). #715's candidate head e02d41bc routes national only, and its 6.10.1 is NOT the release. +# Unknown until a fallback release: its version and installed manifest digest, and the pull request and commit that +# certify it. Each fails closed while None. FALLBACK_BUNDLE_VERSION = None FALLBACK_BUNDLE_MANIFEST_SHA256 = None FALLBACK_CERTIFICATION_URL = None FALLBACK_CERTIFICATION_COMMIT = None -FALLBACK_LOCAL_AREA_PATH = None BUNDLE_715 = { "bundle_package": "policyengine", "bundle_version": FALLBACK_BUNDLE_VERSION, "bundle_id": f"uk-{FALLBACK_BUNDLE_VERSION}", "model_version": "2.147.2", "core_version": "3.33.3", @@ -77,6 +77,25 @@ def microcosm_release_manifest(revision): } +LOCAL_AREA_REPO = "policyengine/populace-uk-private" +LOCAL_AREA_RELEASE_MANIFEST = "releases/microcosm-uk-2024-25-dense/release_manifest.json" + + +def local_area_cut(path, revision, sha256, manifest_sha256, built_with_model_version, model_basis): + """The local-area cut a d2011 pair's constituency and local-authority runs load: its file, release tag and + SHA-256; where the bundle names its release manifest, and the SHA-256 of those bytes; the model its build ran; + and the basis on which the bundle routes to it. "built_with": the cut's build ran the pair's own model, so the + bundle carries no regional_certification. "accepted_model_mismatch": policyengine.py #699's + regional_certification records the reviewed mismatch. Any pending (None) part certifies nothing.""" + uri = lambda form: None if revision is None else form.format(repo=LOCAL_AREA_REPO, revision=revision, + manifest=LOCAL_AREA_RELEASE_MANIFEST) + return {"path": path, "repo_id": LOCAL_AREA_REPO, "repo_type": "dataset", "revision": revision, "sha256": sha256, + "release_manifest_uri": uri("https://huggingface.co/datasets/{repo}/resolve/{revision}/{manifest}"), + "source_manifest_uri": uri("hf://dataset/{repo}@{revision}/{manifest}"), + "manifest_sha256": manifest_sha256, "built_with_model_version": built_with_model_version, + "model_basis": model_basis} + + def area_routed(local_area_path): """A UK Budget release's region routing: the national file serves national runs, and policyengine.py routes constituency and local-authority runs to the local-area file (provenance/certification.py: @@ -88,16 +107,29 @@ def area_routed(local_area_path): MICROCOSM_REVISION_A133 = "microcosm-uk-2024-25-national-20261023T075039Z-a133f37a" MICROCOSM_RELEASE_MANIFEST_A133 = microcosm_release_manifest(MICROCOSM_REVISION_A133) -FALLBACK_REGION_DATASETS = area_routed(FALLBACK_LOCAL_AREA_PATH) +# The release manifest at the cut's tag is 61,723 bytes; its build (built_with_model_package 2.132.6, core 3.33.3) +# names the tag as its cut_tag. policyengine.py 6.11.0 and #699's Budget route both route to this file. +FALLBACK_LOCAL_AREA = local_area_cut( + "microcosm_uk_2024_25_dense.h5", "microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe", + "8e10e130d0260e0b52e2e29902f0502ebdff76a14a3e57d2c876bf4cd8db5872", + "b9dc7cae358b9c965935f83721508594f9fb56a35911ffb0f9216ecb6a799561", "2.132.6", "accepted_model_mismatch") +FALLBACK_REGION_DATASETS = area_routed(FALLBACK_LOCAL_AREA["path"]) # d2011's pair. Unknown until the policyengine.py release that carries it: that release's version and installed -# manifest digest, the pull request and commit that certify it, and the local-area file its constituency and -# local-authority runs load. Each fails closed while None. +# manifest digest, the pull request and commit that certify it, and the 2.151.0 local-area cut its constituency and +# local-authority runs load (file, release tag, SHA-256, and the SHA-256 of the cut's release manifest). Each fails +# closed while None; scripts/fill_release_identities.py fills them from the release. BINDING_BUNDLE_VERSION = None BINDING_BUNDLE_MANIFEST_SHA256 = None BINDING_CERTIFICATION_URL = None BINDING_CERTIFICATION_COMMIT = None BINDING_LOCAL_AREA_PATH = None +BINDING_LOCAL_AREA_REVISION = None +BINDING_LOCAL_AREA_SHA256 = None +BINDING_LOCAL_AREA_MANIFEST_SHA256 = None +# The base carrier the preparatory lock installs while BINDING_BUNDLE_VERSION is pending (its [uk] extra pins its own +# previous model, so the lock omits it); the fill replaces it with the release in pyproject.toml and the lock. +PREPARATORY_CARRIER = "6.10.1" CERTIFIED_BUNDLE = { "bundle_package": "policyengine", "bundle_version": BINDING_BUNDLE_VERSION, "bundle_id": f"uk-{BINDING_BUNDLE_VERSION}", "model_version": "2.151.0", "core_version": "3.33.3", @@ -119,12 +151,17 @@ def area_routed(local_area_path): "certification_url": BINDING_CERTIFICATION_URL, "certification_commit": BINDING_CERTIFICATION_COMMIT, } -# The national's release tag. a133f37a's staged attempt uk-frs-calibration-attempt-20261023T075039Z-a133f37a was -# released as the tag below, with the same file bytes; this cut's attempt is -# uk-frs-calibration-attempt-20261025T091414Z-a6299504 (staged_manifest.json: git_commit 67ee2ddf3, clean). Confirm the -# tag at release: until it exists, the download and the bundle's artifact URI both fail closed. +# d2011's pairs, binding first: the only primaries certified_bundle_preflight admits. The fallback becomes the +# primary only in its own reviewed commit (docs/REBUILD.md, "Fallback"). +D2011_PAIRS = ("microcosm_uk_2024_25@a6299504", "microcosm_uk_2024_25@a133f37a") +# The national's release tag, from the staged attempt uk-frs-calibration-attempt-20261025T091414Z-a6299504 +# (staged_manifest.json: git_commit 67ee2ddf3, clean). The tag exists on the Hub with the pinned file (checked 26 +# October: LFS SHA-256 b96a8cd6..., 342,348,841 bytes); materialization still verifies the bytes before every use, and +# the bundle must name the same artifact URI. MICROCOSM_REVISION = "microcosm-uk-2024-25-national-20261025T091414Z-a6299504" MICROCOSM_RELEASE_MANIFEST = microcosm_release_manifest(MICROCOSM_REVISION) +BINDING_LOCAL_AREA = local_area_cut(BINDING_LOCAL_AREA_PATH, BINDING_LOCAL_AREA_REVISION, BINDING_LOCAL_AREA_SHA256, + BINDING_LOCAL_AREA_MANIFEST_SHA256, "2.151.0", "built_with") BINDING_REGION_DATASETS = area_routed(BINDING_LOCAL_AREA_PATH) RELEASE_MANIFEST_1_58_0 = { "revision": "fdeba38517bd86067f34c0a5d8ed8923efb3b10a", @@ -157,6 +194,7 @@ def area_routed(local_area_path): "bundle_certification": CERTIFIED_BUNDLE, "release_manifest": MICROCOSM_RELEASE_MANIFEST, "region_datasets": BINDING_REGION_DATASETS, + "local_area": BINDING_LOCAL_AREA, # Solved at 2025 and written divided by the model's 2024-to-2025 projection, as a133f37a's are. "ageing_anchor": {"year": 2025, "calibration_year": 2025, "source": "uk-frs-calibration-attempt-20261025T091414Z-a6299504: build_record.json " @@ -176,6 +214,7 @@ def area_routed(local_area_path): "bundle_certification": BUNDLE_715, "release_manifest": MICROCOSM_RELEASE_MANIFEST_A133, "region_datasets": FALLBACK_REGION_DATASETS, + "local_area": FALLBACK_LOCAL_AREA, # The build solves its household weights at 2025 and writes them divided by policyengine-uk's own # 2024-to-2025 projection of household_weight, so the model's 2025 weights are the solved ones # (microcosm weight_year.py, microcosm#796 A19). The Enhanced FRS's 2025 weights are instead its calibrated @@ -372,10 +411,12 @@ def model_versions(): def certified_core(manifest): - """The core a UK bundle's certificate covers, by policyengine.py's own rule (provenance/manifest.py - DataCertification; provenance/receipt.py _certificate_fields): core_binding "certified" names it in - certified_for_core_version, which the bundle's core pin must equal; "bundle_pin" (or no binding, before October - 2026) covers the bundle's core pin. None for anything else, which certifies nothing.""" + """The core a UK bundle's certificate covers, by policyengine.py's rule (provenance/manifest.py + DataCertification; provenance/receipt.py _certificate_fields), made stricter where that rule would fall back to + the pin. core_binding "certified" must name the core in certified_for_core_version, and the bundle's core pin + must equal it; "bundle_pin" (or no binding, before October 2026) covers the bundle's core pin, and a named core + must equal it. None for anything else (an unnamed "certified" core, a named core off the pin, an unknown + binding), which certifies nothing.""" certificate = manifest["data_releases"]["uk"]["certification"] pin = manifest["packages"]["policyengine-core"]["version"] binding, named = certificate.get("core_binding"), certificate.get("certified_for_core_version") @@ -386,14 +427,47 @@ def certified_core(manifest): return None +def _local_area_routed(release, local, model_version): + """Whether a release routes areas to exactly the pinned local-area cut, on the pinned basis. Fails closed while + any part of the cut is pending.""" + if any(local[key] is None for key in ("path", "revision", "sha256", "manifest_sha256")): + return False + entries = [entry for entry in release["datasets"].values() + if isinstance(entry, dict) and entry.get("path") == local["path"]] + if (len(entries) != 1 + or any(entries[0].get(key) != local[key] for key in ("repo_id", "revision", "sha256")) + or entries[0].get("repo_type", local["repo_type"]) != local["repo_type"] + or release.get("regional_release_kind") != "local_area" + or release.get("regional_release_manifest_uri") != local["release_manifest_uri"] + or release.get("regional_source_manifest_uri") != local["source_manifest_uri"] + or release.get("regional_source_manifest_sha256", local["manifest_sha256"]) != local["manifest_sha256"]): + return False + regional = release.get("regional_certification") + if local["model_basis"] == "built_with": + # Both halves on the pair's own model: an accepted mismatch is not this pair. + return regional is None and local["built_with_model_version"] == model_version + return (local["model_basis"] == "accepted_model_mismatch" and isinstance(regional, dict) + and release.get("regional_source_manifest_sha256") == local["manifest_sha256"] + and regional.get("compatibility_basis") == "accepted_model_mismatch" + and regional.get("certified_for_model_version") == model_version + and regional.get("built_with_model_version") == local["built_with_model_version"] + and regional.get("regional_manifest_uri") == local["source_manifest_uri"] + and regional.get("regional_manifest_sha256") == local["manifest_sha256"] + and regional.get("area_receipts") == "uncertified") + + def _certified_by_installed_bundle(spec, versions): - """Fail closed unless released-manifest or ledger evidence certifies the exact pair.""" + """Fail closed unless released-manifest or ledger evidence certifies the exact pair. A released-manifest + certificate also needs the pull request and commit that certify it, and a pair with a local-area cut needs that + exact cut.""" certification = spec.get("bundle_certification") regions = spec.get("region_datasets", {"national": {"path_template": spec["path"]}}) if not all(isinstance(template, dict) and template.get("path_template") for template in regions.values()): return False # a pending file (BINDING_LOCAL_AREA_PATH) certifies nothing if (certification is None or not certification.get("bundle_version") or not certification.get("manifest_sha256") and "ledger_source_path" not in certification + or "ledger_source_path" not in certification + and not (certification.get("certification_url") and certification.get("certification_commit")) or any(versions.get(key) != certification[key] for key in ("bundle_package", "bundle_version", "model_version", "core_version"))): return False @@ -424,7 +498,9 @@ def _certified_by_installed_bundle(spec, versions): or artifact["build_id"] != spec["build_id"] or release["release_manifest_uri"] != spec["release_manifest"]["uri"] or release["source_manifest_uri"] != spec["release_manifest"]["source_uri"] - or release["region_datasets"] != regions): + or release["region_datasets"] != regions + or "local_area" in spec + and not _local_area_routed(release, spec["local_area"], versions["model_version"])): return False matches = (release["bundle_id"] == certification["bundle_id"] and release["certification"]["certified_for_model_version"] == versions["model_version"] diff --git a/src/triple_lock/pipeline.py b/src/triple_lock/pipeline.py index 52ae33b7..09525112 100644 --- a/src/triple_lock/pipeline.py +++ b/src/triple_lock/pipeline.py @@ -186,23 +186,28 @@ class UncertifiedBundle(RuntimeError): def certified_bundle_preflight(log=print): """Require d2011's exact released UK Budget pair without loading any dataset. - The candidate head is not a released certificate. Dataset provenance checks - the installed release manifest digest and exact model/core/artifact identity; - materialization separately verifies the artifact's bytes before model jobs. - Historical bridge jobs and Enhanced FRS sensitivity use separate entry points. + The primary must be one of d2011's pairs (datasets.D2011_PAIRS), and the bundle + checked is that pair's own: the 2.151.0 pair's, or after a fallback commit moves + config.PRIMARY_DATASET, a133f37a's. The candidate head is not a released + certificate. Dataset provenance checks the installed release manifest digest + and exact model/core/artifact and local-area identity; materialization + separately verifies the artifact's bytes before model jobs. Historical bridge + jobs and Enhanced FRS sensitivity use separate entry points. """ - certification = datasets.CERTIFIED_BUNDLE + remedy = ("The fiscal rebuild requires the certified released policyengine.py UK Budget bundle carrying " + "d2011's pair (microcosm#1396): UK 2.151.0 / core 3.33.3 / Microcosm a6299504, or its fallback, " + "#715's UK 2.147.2 / Microcosm a133f37a with #699's area routing. While its carrier release is " + "pending, this refuses. After release, run scripts/fill_release_identities.py to pin its version, " + "released manifest digest, certifying pull request and commit, and local-area cut in datasets.py " + "and the carrier in the lock, then install the re-resolved lock (docs/REBUILD.md). A candidate head " + "and producer gates cannot certify a release.") + if PRIMARY_DATASET not in datasets.D2011_PAIRS: + raise UncertifiedBundle(remedy) + primary = PRIMARY_DATASET + certification = datasets.DATASETS[primary]["bundle_certification"] expected = {"policyengine": certification["bundle_version"], "policyengine-uk": certification["model_version"], "policyengine-core": certification["core_version"]} - primary = "microcosm_uk_2024_25@a6299504" - remedy = ("The fiscal rebuild requires the certified released policyengine.py UK Budget bundle carrying " - "d2011's pair (microcosm#1396): UK 2.151.0 / core 3.33.3 / Microcosm a6299504. Its carrier " - "release is pending. After release, pin its version, released manifest digest, certifying pull " - "request and local-area file in datasets.py, update the carrier dependency version, and install " - "the committed lock. A candidate head and producer gates cannot certify a release.") - if PRIMARY_DATASET != primary: - raise UncertifiedBundle(remedy) if (not expected["policyengine"] or not certification["manifest_sha256"] or not certification["certification_url"] or not certification["certification_commit"]): raise UncertifiedBundle(remedy) @@ -212,7 +217,7 @@ def certified_bundle_preflight(log=print): provenance = datasets.provenance(PRIMARY_DATASET) if provenance.get("dataset") != primary or provenance.get("certified") is not True: raise UncertifiedBundle(remedy + " The installed released manifest does not certify the pinned pair.") - log(f"Preflight: exact policyengine {expected['policyengine']} UK Budget bundle and Microcosm a6299504 certified") + log(f"Preflight: exact policyengine {expected['policyengine']} UK Budget bundle and {primary} certified") return {"certified_bundle": {"passed": True, "packages": expected, "primary": provenance}} diff --git a/tests/released_pair.py b/tests/released_pair.py new file mode 100644 index 00000000..9527864e --- /dev/null +++ b/tests/released_pair.py @@ -0,0 +1,76 @@ +"""A released UK bundle manifest carrying one of d2011's pairs, as its policyengine.py release would carry it.""" + +from copy import deepcopy +import hashlib +import json + +from triple_lock import datasets +from triple_lock.config import REPO + +VERSION = "99.0.0" # Fictional future release, never a production pin. +LOCAL_AREA_KEYS = ("path", "revision", "sha256", "manifest_sha256") +# A fictional 2.151.0 local-area cut, standing in for the binding pair's own while it is pending. +INVENTED_CUT = datasets.local_area_cut("invented_local_area.h5", "invented-local-area-cut", "c" * 64, "d" * 64, + "2.151.0", "built_with") + + +def cut(name): + """The pair's local-area cut: its pinned one, or the invented one while the binding cut is pending.""" + local = datasets.DATASETS[name]["local_area"] + return deepcopy(local if all(local[key] is not None for key in LOCAL_AREA_KEYS) else INVENTED_CUT) + + +def regional_manifest(local, core="3.33.3"): + """The fields of a local-area cut's own release manifest that the fill checks, as its build writes them. + Synthetic: the real ones are metadata of a private repository.""" + return {"build": {"cut_tag": local["revision"], + "built_with_model_package": {"name": "policyengine-uk", + "version": local["built_with_model_version"]}, + "built_with_core_package": {"name": "policyengine-core", "version": core}}, + "dataset_role": "non_default_local_area", + "artifacts": {"microcosm_uk_2024_25_dense": { + "kind": "microdata", "path": local["path"], "repo_id": local["repo_id"], + "revision": local["revision"], "sha256": local["sha256"]}}} + + +def regional_certification(local, model_version): + """policyengine.py #699's record of an accepted model mismatch between a pair and its local-area cut.""" + return {"compatibility_basis": "accepted_model_mismatch", "certified_for_model_version": model_version, + "built_with_model_version": local["built_with_model_version"], + "accepted_reason": "Synthetic fixture.", "area_receipts": "uncertified", + "regional_manifest_uri": local["source_manifest_uri"], "regional_manifest_sha256": local["manifest_sha256"], + "defaulted_inputs": [], "dropped_columns": [], "limitations": [], "evidence": []} + + +def released(name, local=None, version=VERSION): + """(manifest, local, regional_raw): #715's candidate skeleton re-pointed at the pair ``name``'s pins, routing + areas to the cut ``local`` (default: ``cut(name)``), whose manifest SHA-256 becomes the synthetic bytes'.""" + spec = datasets.DATASETS[name] + certification = spec["bundle_certification"] + local = deepcopy(local or cut(name)) + regional_raw = json.dumps(regional_manifest(local, certification["core_version"])).encode() + local["manifest_sha256"] = hashlib.sha256(regional_raw).hexdigest() + manifest = json.loads((REPO / "data/certification/d1983-candidate-bundle-manifest.json").read_text()) + release = manifest["data_releases"]["uk"] + manifest["policyengine_version"] = release["policyengine_version"] = version + release["bundle_id"] = f"uk-{version}" + manifest["packages"]["policyengine-core"]["version"] = certification["core_version"] + release["certification"]["certified_for_core_version"] = certification["core_version"] + release["model_package"].update(version=certification["model_version"], sha256=certification["model_wheel_sha256"]) + release["certification"]["certified_for_model_version"] = certification["model_version"] + release["build_id"] = spec["build_id"] + release["certified_data_artifact"].update( + build_id=spec["build_id"], dataset=spec["logical_name"], sha256=spec["sha256"], + uri=f"hf://{spec['repo_id']}/{spec['path']}@{spec['revision']}", + data_package={"name": spec["data_package"], "version": spec["data_version"]}) + release["release_manifest_uri"] = spec["release_manifest"]["uri"] + release["source_manifest_uri"] = spec["release_manifest"]["source_uri"] + release["region_datasets"] = datasets.area_routed(local["path"]) + release["datasets"]["microcosm_uk_2024_25_dense"] = {key: local[key] for key in ("path", "repo_id", "revision", + "sha256")} + release.update(regional_release_kind="local_area", regional_release_manifest_uri=local["release_manifest_uri"], + regional_source_manifest_uri=local["source_manifest_uri"]) + if local["model_basis"] == "accepted_model_mismatch": + release["regional_source_manifest_sha256"] = local["manifest_sha256"] + release["regional_certification"] = regional_certification(local, certification["model_version"]) + return manifest, local, regional_raw diff --git a/tests/test_budget_bundle.py b/tests/test_budget_bundle.py index b7a2ba18..350fdbbf 100644 --- a/tests/test_budget_bundle.py +++ b/tests/test_budget_bundle.py @@ -8,47 +8,71 @@ import pytest +import released_pair +from scripts.fill_release_identities import BUNDLE_IDENTITIES, IDENTITIES from triple_lock import certification, datasets, pipeline -from triple_lock.config import PRIMARY_DATASET, REPO, SENSITIVITY_DATASET +from triple_lock.config import PRIMARY_DATASET, SENSITIVITY_DATASET + +FALLBACK_DATASET = "microcosm_uk_2024_25@a133f37a" +PENDING = datasets.BINDING_BUNDLE_VERSION is None # before the release-night fill -def test_binding_roles_and_pending_preflight_do_not_read_model_or_data(monkeypatch): +def test_binding_roles_and_the_preflight_read_no_model_or_data(monkeypatch): assert PRIMARY_DATASET == "microcosm_uk_2024_25@a6299504" + assert datasets.D2011_PAIRS == (PRIMARY_DATASET, FALLBACK_DATASET) assert SENSITIVITY_DATASET == "enhanced_frs_2024_25@1.58.2" monkeypatch.setattr(datasets, "materialize", lambda *a, **kw: pytest.fail("no data")) - monkeypatch.setattr(pipeline, "package_versions", lambda: pytest.fail("no model import")) - with pytest.raises(pipeline.UncertifiedBundle, match="d2011.*pending"): + if PENDING: + monkeypatch.setattr(pipeline, "package_versions", lambda: pytest.fail("no model import")) + with pytest.raises(pipeline.UncertifiedBundle, match="d2011.*pending"): + pipeline.certified_bundle_preflight() + else: # filled: an install of any other carrier refuses before provenance + monkeypatch.setattr(pipeline, "package_versions", lambda: {"policyengine": "0.0.0"}) + monkeypatch.setattr(datasets, "provenance", lambda *a: pytest.fail("refused before provenance")) + with pytest.raises(pipeline.UncertifiedBundle, match="d2011"): + pipeline.certified_bundle_preflight() + + +@pytest.mark.parametrize("primary", ["enhanced_frs_2024_25@1.58.2", "microcosm_uk_2024_25@5c6b3f68"]) +def test_the_preflight_admits_only_d2011_s_pairs(monkeypatch, primary): + monkeypatch.setattr(pipeline, "PRIMARY_DATASET", primary) + monkeypatch.setattr(pipeline, "package_versions", lambda: pytest.fail("refused before any import")) + with pytest.raises(pipeline.UncertifiedBundle, match="Microcosm a6299504"): pipeline.certified_bundle_preflight() -FALLBACK_DATASET = "microcosm_uk_2024_25@a133f37a" +def test_the_fallback_preflight_reads_the_fallback_s_own_bundle(monkeypatch): + """After the fallback commit moves the primary, the preflight checks #715's pair, not the 2.151.0 one.""" + monkeypatch.setattr(pipeline, "PRIMARY_DATASET", FALLBACK_DATASET) + for key, value in (("bundle_version", "99.0.0"), ("manifest_sha256", "a" * 64), + ("certification_url", "https://example.invalid/pull/1"), ("certification_commit", "b" * 40)): + monkeypatch.setitem(datasets.BUNDLE_715, key, value) + pins = {"policyengine": "99.0.0", "policyengine-uk": "2.147.2", "policyengine-core": "3.33.3"} + monkeypatch.setattr(pipeline, "package_versions", lambda: dict(pins)) + provenance = {"dataset": FALLBACK_DATASET, "certified": True} + monkeypatch.setattr(datasets, "provenance", lambda name: dict(provenance) if name == FALLBACK_DATASET + else pytest.fail(f"provenance of {name}")) + receipt = pipeline.certified_bundle_preflight(log=lambda _: None) + assert receipt == {"certified_bundle": {"passed": True, "packages": pins, "primary": provenance}} + monkeypatch.setattr(pipeline, "package_versions", lambda: {**pins, "policyengine-uk": "2.151.0"}) + with pytest.raises(pipeline.UncertifiedBundle): + pipeline.certified_bundle_preflight(log=lambda _: None) # d2011's binding pair and its fallback: each must certify once its own release identities are filled. @pytest.fixture(params=[PRIMARY_DATASET, FALLBACK_DATASET]) def released(request, tmp_path, monkeypatch): - manifest = json.loads((REPO / "data/certification/d1983-candidate-bundle-manifest.json").read_text()) - version = "99.0.0" # Fictional future release, never a production pin. + # #715's a133f37a candidate skeleton, re-pointed at the pair and routed to its local-area cut (the binding's + # invented one while its own is pending), whose manifest SHA-256 is a synthetic manifest's. + manifest, local, _ = released_pair.released(request.param) + version = released_pair.VERSION release = manifest["data_releases"]["uk"] - manifest["policyengine_version"] = version - release["policyengine_version"] = version - release["bundle_id"] = f"uk-{version}" spec = deepcopy(datasets.DATASETS[request.param]) - cert = {**spec["bundle_certification"], "bundle_version": version, "bundle_id": f"uk-{version}"} + cert = {**spec["bundle_certification"], "bundle_version": version, "bundle_id": f"uk-{version}", + "certification_url": "https://example.invalid/pull/9999", "certification_commit": "1" * 40} spec["bundle_certification"] = cert - # The skeleton is #715's a133f37a candidate; point its pair fields at the spec, with an invented local-area - # file in place of the pending one. - local = {"path_template": "invented_local_area.h5"} - spec["region_datasets"] = {**spec["region_datasets"], "constituency": local, "local_authority": local} - release["model_package"]["version"] = cert["model_version"] - release["certification"]["certified_for_model_version"] = cert["model_version"] - release["build_id"] = spec["build_id"] - release["certified_data_artifact"].update( - build_id=spec["build_id"], sha256=spec["sha256"], - uri=f"hf://{spec['repo_id']}/{spec['path']}@{spec['revision']}") - release["release_manifest_uri"] = spec["release_manifest"]["uri"] - release["source_manifest_uri"] = spec["release_manifest"]["source_uri"] - release["region_datasets"] = deepcopy(spec["region_datasets"]) + spec["local_area"] = local + spec["region_datasets"] = datasets.area_routed(local["path"]) path = tmp_path / "manifest.json" distributions = {"policyengine": SimpleNamespace(version=version, locate_file=lambda _: path)} cert["wheels"] = {} @@ -155,11 +179,17 @@ def test_the_certified_core_follows_policyengine_pys_core_binding(released, bind assert datasets._certified_by_installed_bundle(spec, versions) is certifies -def test_the_pending_local_area_file_cannot_certify(released): - spec, versions, manifest, write, _ = released +@pytest.mark.parametrize("key", released_pair.LOCAL_AREA_KEYS) +def test_a_pending_part_of_the_local_area_cut_certifies_nothing(released, key): + spec, versions, _, _, _ = released assert datasets._certified_by_installed_bundle(spec, versions) - pending = next(d for d in datasets.DATASETS.values() if d["build_id"] == spec["build_id"])["region_datasets"] - assert {pending[area]["path_template"] for area in ("constituency", "local_authority")} == {None} + spec["local_area"] = {**spec["local_area"], key: None} + assert not datasets._certified_by_installed_bundle(spec, versions) + + +def test_a_pending_local_area_file_cannot_certify(released): + spec, versions, manifest, write, _ = released + pending = datasets.area_routed(None) spec["region_datasets"] = deepcopy(pending) assert not datasets._certified_by_installed_bundle(spec, versions) manifest["data_releases"]["uk"]["region_datasets"] = deepcopy(pending) @@ -167,26 +197,81 @@ def test_the_pending_local_area_file_cannot_certify(released): assert not datasets._certified_by_installed_bundle(spec, versions) -@pytest.mark.parametrize("name,bundle,local_area", [ - (PRIMARY_DATASET, "CERTIFIED_BUNDLE", "BINDING_LOCAL_AREA_PATH"), +@pytest.mark.parametrize("key,value", [("revision", "another-cut"), ("sha256", "0" * 64), + ("repo_id", "policyengine/other")]) +def test_another_local_area_cut_of_the_same_file_cannot_certify(released, key, value): + """Every cut publishes microcosm_uk_2024_25_dense.h5: the pinned tag and bytes, not the name, identify it.""" + spec, versions, manifest, write, _ = released + manifest["data_releases"]["uk"]["datasets"]["microcosm_uk_2024_25_dense"][key] = value + write() + assert not datasets._certified_by_installed_bundle(spec, versions) + + +def test_the_basis_of_the_area_routing_is_the_pair_s_own(released): + """The binding pair carries no #699 mismatch record; the fallback needs exactly one.""" + spec, versions, manifest, write, _ = released + release = manifest["data_releases"]["uk"] + if spec["local_area"]["model_basis"] == "built_with": + release["regional_certification"] = released_pair.regional_certification(spec["local_area"], + versions["model_version"]) + else: + del release["regional_certification"] + write() + assert not datasets._certified_by_installed_bundle(spec, versions) + + +@pytest.mark.parametrize("key", ["certification_url", "certification_commit"]) +def test_a_released_certificate_needs_its_certifying_pull_request(released, key): + spec, versions, _, _, _ = released + spec["bundle_certification"][key] = None + assert not datasets._certified_by_installed_bundle(spec, versions) + + +@pytest.mark.parametrize("name,bundle,prefix,model", [ + (PRIMARY_DATASET, "CERTIFIED_BUNDLE", "BINDING", "2.151.0"), # d2011's fallback is a133f37a with policyengine.py #699's area routing, not #715's national-only candidate. - (FALLBACK_DATASET, "BUNDLE_715", "FALLBACK_LOCAL_AREA_PATH"), + (FALLBACK_DATASET, "BUNDLE_715", "FALLBACK", "2.147.2"), ]) -def test_each_d2011_pair_routes_areas_and_stays_pending(name, bundle, local_area): +def test_each_d2011_pair_routes_areas_to_its_cut_and_is_wholly_pending_or_wholly_filled(name, bundle, prefix, model): spec = datasets.DATASETS[name] - assert spec["bundle_certification"] is getattr(datasets, bundle) - assert spec["region_datasets"] == datasets.area_routed(getattr(datasets, local_area)) == { - "national": {"path_template": spec["path"]}, - "constituency": {"path_template": None}, "local_authority": {"path_template": None}} - prefix = local_area.removesuffix("_LOCAL_AREA_PATH") - for identity in ("BUNDLE_VERSION", "BUNDLE_MANIFEST_SHA256", "CERTIFICATION_URL", "CERTIFICATION_COMMIT"): - assert getattr(datasets, f"{prefix}_{identity}") is None certification = spec["bundle_certification"] + local = spec["local_area"] + assert certification is getattr(datasets, bundle) and certification["model_version"] == model + assert spec["region_datasets"] == datasets.area_routed(local["path"]) == { + "national": {"path_template": spec["path"]}, + "constituency": {"path_template": local["path"]}, "local_authority": {"path_template": local["path"]}} + filled = [getattr(datasets, f"{prefix}_{key}") is not None for key in IDENTITIES[prefix]] + assert all(filled) or not any(filled) # a half-filled pair is a broken fill assert (certification["bundle_version"], certification["manifest_sha256"], certification["certification_url"], - certification["certification_commit"]) == (None, None, None, None) - versions = {**{key: certification[key] for key in ("bundle_package", "model_version", "core_version")}, - "bundle_version": "99.0.0"} - assert not datasets._certified_by_installed_bundle(spec, versions) + certification["certification_commit"]) == tuple(getattr(datasets, f"{prefix}_{key}") + for key in BUNDLE_IDENTITIES) + if not any(filled): + versions = {**{key: certification[key] for key in ("bundle_package", "model_version", "core_version")}, + "bundle_version": "99.0.0"} + assert not datasets._certified_by_installed_bundle(spec, versions) + + +def test_the_local_area_cuts_are_pinned_on_their_bases(): + binding, fallback = datasets.BINDING_LOCAL_AREA, datasets.FALLBACK_LOCAL_AREA + assert datasets.DATASETS[PRIMARY_DATASET]["local_area"] is binding + assert datasets.DATASETS[FALLBACK_DATASET]["local_area"] is fallback + assert (binding["path"], binding["revision"], binding["sha256"], binding["manifest_sha256"]) == ( + datasets.BINDING_LOCAL_AREA_PATH, datasets.BINDING_LOCAL_AREA_REVISION, datasets.BINDING_LOCAL_AREA_SHA256, + datasets.BINDING_LOCAL_AREA_MANIFEST_SHA256) + assert (binding["built_with_model_version"], binding["model_basis"]) == ("2.151.0", "built_with") + # The 19 October dense cut, as its release manifest at the tag records it (61,723 bytes). + assert fallback == { + "path": "microcosm_uk_2024_25_dense.h5", "repo_id": "policyengine/populace-uk-private", "repo_type": "dataset", + "revision": "microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe", + "sha256": "8e10e130d0260e0b52e2e29902f0502ebdff76a14a3e57d2c876bf4cd8db5872", + "release_manifest_uri": "https://huggingface.co/datasets/policyengine/populace-uk-private/resolve/" + "microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe/releases/" + "microcosm-uk-2024-25-dense/release_manifest.json", + "source_manifest_uri": "hf://dataset/policyengine/populace-uk-private@" + "microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe/releases/" + "microcosm-uk-2024-25-dense/release_manifest.json", + "manifest_sha256": "b9dc7cae358b9c965935f83721508594f9fb56a35911ffb0f9216ecb6a799561", + "built_with_model_version": "2.132.6", "model_basis": "accepted_model_mismatch"} def test_the_fallback_keeps_its_own_model_and_release_manifest(): diff --git a/tests/test_datasets.py b/tests/test_datasets.py index d1623dfa..c464914e 100644 --- a/tests/test_datasets.py +++ b/tests/test_datasets.py @@ -58,14 +58,27 @@ def test_materialize_refuses_a_file_that_does_not_match_its_pin(tmp_path): datasets.materialize(PRIMARY_DATASET, store=tmp_path, download=_fake(b"unused")) -def test_provenance_records_pending_exact_candidate_without_claiming_certification(): +def binding_release_installed(): + """Whether the installed carrier is the binding release datasets.py pins (never while it is pending).""" + return (datasets.BINDING_BUNDLE_VERSION is not None + and datasets.model_versions()["bundle_version"] == datasets.BINDING_BUNDLE_VERSION) + + +def test_provenance_records_the_exact_pair_and_certifies_only_its_installed_release(): + """Pending, or filled but not yet installed: nothing certifies. Filled and installed (CI installs the lock), + the release must certify the pinned pair: the fill's pins and the installed bytes agree.""" pytest.importorskip("policyengine_uk") p = datasets.provenance(PRIMARY_DATASET) + installed = binding_release_installed() assert p["model_version"] == "2.151.0" == engine.package_versions()["policyengine-uk"] assert p["core_version"] == "3.33.3" assert p["bundle_version"] == importlib.metadata.version("policyengine") - assert p["certified"] is False and "d2011" in p["certification"] - assert "certification_bundle" not in p + assert p["certified"] is installed + if installed: + assert p["certification"] == f"policyengine.py {datasets.BINDING_BUNDLE_VERSION} UK bundle certification" + assert p["certification_bundle"] == datasets.CERTIFIED_BUNDLE + else: + assert "d2011" in p["certification"] and "certification_bundle" not in p assert p["runtime_dataset"] == "microcosm_uk_2024_25" assert p["runtime_dataset_sha256"] == "b96a8cd625b645a409c063b1de5158532708a2f0d3c3897a794a8fb0bbd5064e" assert p["dataset"] == "microcosm_uk_2024_25@a6299504" @@ -98,7 +111,7 @@ def test_certification_rejects_different_artifact_bytes_and_changed_bundle_manif pytest.importorskip("policyengine_uk") spec = datasets.DATASETS[PRIMARY_DATASET] versions = datasets.model_versions() - assert not datasets._certified_by_installed_bundle(spec, versions) + assert datasets._certified_by_installed_bundle(spec, versions) is binding_release_installed() assert not datasets._certified_by_installed_bundle({**spec, "sha256": "0" * 64}, versions) bad_manifest = tmp_path / "manifest.json" bad_manifest.write_text("{}") @@ -109,33 +122,69 @@ def locate_file(self, name): assert not datasets._certified_by_installed_bundle(spec, versions) +def lock_pins(): + from packaging.utils import canonicalize_name + + from triple_lock.config import REPO + + lines = (REPO / "requirements-lock.txt").read_text().splitlines() + return {canonicalize_name(name): version for name, version in + (line.split("==", 1) for line in lines if "==" in line and not line.startswith("#"))} + + def test_the_pins_agree(): - """The project pins the certified carrier; its UK-extra pins and all three installed versions match the lock.""" + """The project pins the carrier (the preparatory one while the release is pending, the release once filled), the + pair's model and core; all three installed versions match the lock.""" import tomllib + from packaging.requirements import Requirement + from triple_lock.config import REPO + carrier = datasets.BINDING_BUNDLE_VERSION or datasets.PREPARATORY_CARRIER + pins = {"policyengine": carrier, "policyengine-uk": "2.151.0", "policyengine-core": "3.33.3"} pyproject = tomllib.loads((REPO / "pyproject.toml").read_text()) dependencies = pyproject["project"]["optional-dependencies"]["uk"] - assert "policyengine==6.10.1" in dependencies - assert "policyengine-uk==2.151.0" in dependencies - assert "policyengine-core==3.33.3" in dependencies + assert {f"{package}=={version}" for package, version in pins.items()} <= set(dependencies) assert not any(d.startswith("policyengine[uk]") for d in dependencies) - lock = (REPO / "requirements-lock.txt").read_text().splitlines() - pins = {"policyengine": "6.10.1", "policyengine-uk": "2.151.0", "policyengine-core": "3.33.3"} - for package, version in pins.items(): - assert f"{package}=={version}" in lock + assert {package: lock_pins().get(package) for package in pins} == pins try: installed = {package: importlib.metadata.version(package) for package in pins} except importlib.metadata.PackageNotFoundError: pytest.skip("certified policyengine UK bundle is not installed") + if datasets.BINDING_BUNDLE_VERSION is not None and installed["policyengine"] != carrier: + pytest.skip(f"the filled carrier {carrier} is not installed yet: re-resolve and install the lock") assert installed == pins + bundled = [Requirement(r) for r in importlib.metadata.requires("policyengine") or []] + uk_extra = {r.name: r.specifier for r in bundled + if r.marker and r.marker.evaluate({"extra": "uk"}) and r.name in pins} + if datasets.BINDING_BUNDLE_VERSION is None: + # The preparatory carrier's extra pins its previous model: installing it would conflict with the pair's. + assert {name: str(specifier) for name, specifier in uk_extra.items()} != { + "policyengine-uk": "==2.151.0", "policyengine-core": "==3.33.3"} + else: + # The release carries the pair, so whatever its extra pins must admit the pair's model and core. + assert all(specifier.contains(pins[name]) for name, specifier in uk_extra.items()) + + +def test_the_lock_satisfies_the_installed_carrier_s_own_requirements(): + """The fill bumps only the carrier pin; a re-resolved lock (docs/REBUILD.md) must still satisfy what that + carrier requires outside its extras.""" from packaging.requirements import Requirement - bundled = [Requirement(r) for r in importlib.metadata.requires("policyengine")] - uk_pins = {r.name: str(r.specifier) for r in bundled - if r.marker and r.marker.evaluate({"extra": "uk"}) and r.name in pins} - assert uk_pins != {"policyengine-uk": "==2.151.0", "policyengine-core": "==3.33.3"} - # The released extra differs: installing it would conflict with the candidate model pin. + from packaging.utils import canonicalize_name + + pins = lock_pins() + try: + installed = importlib.metadata.version("policyengine") + except importlib.metadata.PackageNotFoundError: + pytest.skip("policyengine is not installed") + if installed != pins.get("policyengine"): + pytest.skip(f"installed policyengine {installed} is not the lock's") + unmet = [str(r) for r in map(Requirement, importlib.metadata.requires("policyengine") or []) + if (r.marker is None or r.marker.evaluate({"extra": ""})) + and canonicalize_name(r.name) in pins + and not r.specifier.contains(pins[canonicalize_name(r.name)], prereleases=True)] + assert unmet == [] def test_materialize_hashes_each_file_once_per_process(tmp_path, monkeypatch): diff --git a/tests/test_fill_release_identities.py b/tests/test_fill_release_identities.py index 9bcb143e..153c4c79 100644 --- a/tests/test_fill_release_identities.py +++ b/tests/test_fill_release_identities.py @@ -1,148 +1,290 @@ """The release-night fill checks every pinned identity and writes only what the release supplies.""" +import base64 from copy import deepcopy import hashlib import json +import re import shutil import pytest +from released_pair import INVENTED_CUT, VERSION, regional_certification, released from scripts import binding_bridge from scripts import fill_release_identities as fill from triple_lock import datasets from triple_lock.config import REPO -VERSION = "99.0.0" # Fictional future release, never a production pin. -LOCAL_AREA = "invented_local_area.h5" +BINDING, FALLBACK = fill.TARGETS["binding"][0], fill.TARGETS["fallback"][0] MERGED = {"html_url": "https://github.com/PolicyEngine/policyengine.py/pull/9999", "merged_at": "2026-10-26T23:00:00Z", "merge_commit_sha": "1" * 40, "head": {"sha": "2" * 40}} +OLD = "98.0.0" # The fictional carrier the certifying merge's manifest names before the release's version commit. +ROUTED = "installed check routes to this cut" -def released_manifest(name): - """#715's candidate skeleton, re-pointed at the named pair's pins as its release would carry them.""" - spec = datasets.DATASETS[name] - certification = spec["bundle_certification"] - manifest = json.loads((REPO / "data/certification/d1983-candidate-bundle-manifest.json").read_text()) - release = manifest["data_releases"]["uk"] - manifest["policyengine_version"] = release["policyengine_version"] = VERSION - release["bundle_id"] = f"uk-{VERSION}" - manifest["packages"]["policyengine-core"]["version"] = certification["core_version"] - release["certification"]["certified_for_core_version"] = certification["core_version"] - release["model_package"].update(version=certification["model_version"], sha256=certification["model_wheel_sha256"]) - release["certification"]["certified_for_model_version"] = certification["model_version"] - release["build_id"] = spec["build_id"] - release["certified_data_artifact"].update( - build_id=spec["build_id"], dataset=spec["logical_name"], sha256=spec["sha256"], - uri=f"hf://{spec['repo_id']}/{spec['path']}@{spec['revision']}", - data_package={"name": spec["data_package"], "version": spec["data_version"]}) - release["release_manifest_uri"] = spec["release_manifest"]["uri"] - release["source_manifest_uri"] = spec["release_manifest"]["source_uri"] - release["region_datasets"] = datasets.area_routed(LOCAL_AREA) - return manifest - - -def failing(manifest, name): - return [label for label, pinned, released in fill.checks(manifest, datasets.DATASETS[name]) if pinned != released] - - -@pytest.mark.parametrize("target", sorted(fill.TARGETS)) -def test_a_release_carrying_the_pinned_pair_passes_every_check(target): - name, _ = fill.TARGETS[target] - assert failing(released_manifest(name), name) == [] - - -@pytest.mark.parametrize("target", sorted(fill.TARGETS)) -def test_a_bundle_pin_certificate_like_6_11_0s_passes(target): - name, _ = fill.TARGETS[target] - manifest = released_manifest(name) +@pytest.fixture +def pending(monkeypatch): + """datasets.py as it stands before any fill, whatever the tree holds: every release identity None.""" + for prefix, names in fill.IDENTITIES.items(): + for key in names: + monkeypatch.setattr(datasets, f"{prefix}_{key}", None) + monkeypatch.setitem(datasets.DATASETS[BINDING], "local_area", + datasets.local_area_cut(None, None, None, None, "2.151.0", "built_with")) + + +def failing(manifest, name, local, regional_raw): + return [label for label, pinned, got in fill.checks(manifest, datasets.DATASETS[name], local, regional_raw) + if pinned != got] + + +@pytest.mark.parametrize("name", [BINDING, FALLBACK]) +def test_a_release_carrying_the_pinned_pair_passes_every_check(pending, name): + manifest, local, regional_raw = released(name) + assert failing(manifest, name, local, regional_raw) == [] + + +@pytest.mark.parametrize("name", [BINDING, FALLBACK]) +def test_a_bundle_pin_certificate_like_6_11_0s_passes(pending, name): + manifest, local, regional_raw = released(name) certificate = manifest["data_releases"]["uk"]["certification"] del certificate["certified_for_core_version"] certificate["core_binding"] = "bundle_pin" - assert failing(manifest, name) == [] - - -@pytest.mark.parametrize("target", sorted(fill.TARGETS)) -@pytest.mark.parametrize("where,key,value,label", [ - ("uk", "policyengine_version", "98.0.0", "UK release carrier version"), - ("uk", "bundle_id", "uk-98.0.0", "bundle id"), - ("core", "version", "3.33.0", "core version"), - ("certification", "certified_for_core_version", "3.33.0", "core version certified for"), - ("model_package", "version", "2.153.4", "model version"), - ("certification", "certified_for_model_version", "2.153.4", "model version certified for"), - ("model_package", "sha256", "0" * 64, "model wheel SHA-256"), - ("uk", "build_id", "other-cut", "release build id"), - ("artifact", "build_id", "other-cut", "artifact build id"), - ("uk", "release_manifest_uri", "https://example.com/other", "release manifest URI"), - ("uk", "source_manifest_uri", "hf://other", "source manifest URI"), - ("artifact", "dataset", "enhanced_frs_2024_25", "artifact dataset"), - ("artifact", "sha256", "0" * 64, "artifact SHA-256"), - ("artifact", "uri", "hf://policyengine/other/microcosm_uk_2024_25.h5@x", "artifact URI"), - ("artifact", "data_package", {"name": "microcosm-data", "version": "0.2.0"}, "artifact data package"), - # #715's national-only candidate routing, or an area type left out, is not d2011's pair. - ("uk", "region_datasets", {"national": {"path_template": "microcosm_uk_2024_25.h5"}}, "routed regions"), - ("regions", "national", {"path_template": "other.h5"}, "national file"), - ("regions", "local_authority", {"path_template": "other_local_area.h5"}, "one local-area file for both area types"), - ("regions", "constituency", {"path_template": ""}, "one local-area file for both area types"), + assert failing(manifest, name, local, regional_raw) == [] + + +@pytest.mark.parametrize("name", [BINDING, FALLBACK]) +@pytest.mark.parametrize("where,key,value,labels", [ + ("uk", "policyengine_version", "98.0.0", {"UK release carrier version"}), + ("uk", "bundle_id", "uk-98.0.0", {"bundle id"}), + ("core", "version", "3.33.0", {"core version", "core version certified for"}), + ("certification", "certified_for_core_version", "3.33.0", {"core version certified for"}), + ("model_package", "version", "2.153.4", {"model version"}), + ("certification", "certified_for_model_version", "2.153.4", {"model version certified for"}), + ("model_package", "sha256", "0" * 64, {"model wheel SHA-256"}), + ("uk", "build_id", "other-cut", {"release build id"}), + ("artifact", "build_id", "other-cut", {"artifact build id"}), + ("uk", "release_manifest_uri", "https://example.com/other", {"release manifest URI"}), + ("uk", "source_manifest_uri", "hf://other", {"source manifest URI"}), + ("artifact", "dataset", "enhanced_frs_2024_25", {"artifact dataset"}), + ("artifact", "sha256", "0" * 64, {"artifact SHA-256"}), + ("artifact", "uri", "hf://policyengine/other/microcosm_uk_2024_25.h5@x", {"artifact URI"}), + ("artifact", "data_package", {"name": "microcosm-data", "version": "0.2.0"}, {"artifact data package"}), + # #715's national-only candidate routing, an area type left out or split, or an extra member, is not the pair. + ("uk", "region_datasets", {"national": {"path_template": "microcosm_uk_2024_25.h5"}}, {"region routing"}), + ("regions", "national", {"path_template": "other.h5"}, {"region routing"}), + ("regions", "local_authority", {"path_template": "other_local_area.h5"}, {"region routing"}), + ("regions", "constituency", {"path_template": ""}, {"region routing"}), + ("regions", "constituency", {"path_template": "invented_local_area.h5", "extra": 1}, {"region routing"}), + # The local-area cut as the bundle names it: the installed check refuses each one too. + ("dense", "sha256", "0" * 64, {"local-area datasets entry", ROUTED}), + ("dense", "revision", "other-cut", {"local-area datasets entry", ROUTED}), + ("dense", "repo_id", "policyengine/other", {"local-area datasets entry", ROUTED}), + ("dense", "repo_type", "model", {"local-area datasets entry", ROUTED}), + ("uk", "regional_release_kind", "national", {"regional release kind", ROUTED}), + ("uk", "regional_release_manifest_uri", "https://example.com/other", {"regional release manifest URI", ROUTED}), + ("uk", "regional_source_manifest_uri", "hf://other", {"regional source manifest URI", ROUTED}), + ("uk", "regional_source_manifest_sha256", "0" * 64, {"regional source manifest SHA-256", ROUTED}), ]) -def test_each_pinned_identity_is_checked(target, where, key, value, label): - name, _ = fill.TARGETS[target] - manifest = released_manifest(name) +def test_each_pinned_identity_is_checked(pending, name, where, key, value, labels): + manifest, local, regional_raw = released(name) release = manifest["data_releases"]["uk"] container = {"uk": release, "core": manifest["packages"]["policyengine-core"], "certification": release["certification"], "model_package": release["model_package"], - "artifact": release["certified_data_artifact"], "regions": release["region_datasets"]}[where] + "artifact": release["certified_data_artifact"], "regions": release["region_datasets"], + "dense": release["datasets"]["microcosm_uk_2024_25_dense"]}[where] container[key] = value - assert label in failing(manifest, name) + assert labels <= set(failing(manifest, name, local, regional_raw)) + + +@pytest.mark.parametrize("name", [BINDING, FALLBACK]) +@pytest.mark.parametrize("change,label", [ + (lambda m: m["build"].update(cut_tag="other-cut"), "local-area cut tag"), + (lambda m: m["build"]["built_with_model_package"].update(version="2.150.0"), "local-area build model"), + (lambda m: m["build"]["built_with_core_package"].update(version="3.33.0"), "local-area build core"), + (lambda m: m.update(dataset_role="default"), "local-area dataset role"), + (lambda m: m["artifacts"]["microcosm_uk_2024_25_dense"].update(sha256="0" * 64), "local-area artifact"), + (lambda m: m["artifacts"]["microcosm_uk_2024_25_dense"].update(kind="diagnostics"), "local-area artifact"), + (lambda m: m["artifacts"].update(copy=dict(m["artifacts"]["microcosm_uk_2024_25_dense"])), "local-area artifact"), +]) +def test_the_cut_s_own_release_manifest_is_checked(pending, name, change, label): + """Each change also changes the bytes, so the pinned manifest SHA-256 refuses as well.""" + manifest, local, regional_raw = released(name) + regional = json.loads(regional_raw) + change(regional) + assert {label, "local-area release manifest SHA-256"} <= set( + failing(manifest, name, local, json.dumps(regional).encode())) + + +def test_the_binding_pair_refuses_an_accepted_mismatch_block(pending): + """Both halves of d2011's pair are on 2.151.0, so #699's mismatch record is not that pair.""" + manifest, local, regional_raw = released(BINDING) + manifest["data_releases"]["uk"]["regional_certification"] = regional_certification(local, "2.151.0") + assert {"regional certification", ROUTED} <= set(failing(manifest, BINDING, local, regional_raw)) + + +@pytest.mark.parametrize("key,value", [ + ("compatibility_basis", "built_with"), ("certified_for_model_version", "2.151.0"), + ("built_with_model_version", "2.151.0"), ("regional_manifest_uri", "hf://other"), + ("regional_manifest_sha256", "0" * 64), ("area_receipts", "certified"), (None, None), +]) +def test_the_fallback_needs_699_s_exact_mismatch_record(pending, key, value): + manifest, local, regional_raw = released(FALLBACK) + release = manifest["data_releases"]["uk"] + if key is None: + del release["regional_certification"] + else: + release["regional_certification"][key] = value + assert {"regional certification", ROUTED} <= set(failing(manifest, FALLBACK, local, regional_raw)) -def test_identities_come_from_the_exact_bytes_and_the_merge(): - manifest = released_manifest(fill.TARGETS["binding"][0]) +def test_the_fallback_needs_the_regional_manifest_digest(pending): + manifest, local, regional_raw = released(FALLBACK) + del manifest["data_releases"]["uk"]["regional_source_manifest_sha256"] + assert {"regional source manifest SHA-256", ROUTED} <= set(failing(manifest, FALLBACK, local, regional_raw)) + + +@pytest.mark.parametrize("digest", [True, False]) +def test_the_binding_digest_is_compared_where_the_release_records_it(pending, digest): + """A 6.11.0-shaped release has no regional_source_manifest_sha256; #699's schema adds it.""" + manifest, local, regional_raw = released(BINDING) + if digest: + manifest["data_releases"]["uk"]["regional_source_manifest_sha256"] = local["manifest_sha256"] + assert failing(manifest, BINDING, local, regional_raw) == [] + if digest: + manifest["data_releases"]["uk"]["regional_source_manifest_sha256"] = "0" * 64 + assert {"regional source manifest SHA-256", ROUTED} <= set(failing(manifest, BINDING, local, regional_raw)) + + +def test_identities_come_from_the_exact_bytes_the_merge_and_the_cut(pending): + manifest, local, regional_raw = released(BINDING) raw = json.dumps(manifest).encode() - assert fill.identities(raw, manifest, MERGED) == { + assert fill.identities(raw, manifest, MERGED, "BINDING", regional_raw) == { "BUNDLE_VERSION": VERSION, "BUNDLE_MANIFEST_SHA256": hashlib.sha256(raw).hexdigest(), "CERTIFICATION_URL": MERGED["html_url"], "CERTIFICATION_COMMIT": MERGED["merge_commit_sha"], - "LOCAL_AREA_PATH": LOCAL_AREA} + "LOCAL_AREA_PATH": local["path"], "LOCAL_AREA_REVISION": local["revision"], + "LOCAL_AREA_SHA256": local["sha256"], "LOCAL_AREA_MANIFEST_SHA256": hashlib.sha256(regional_raw).hexdigest()} + assert set(fill.identities(raw, manifest, MERGED, "FALLBACK", regional_raw)) == set(fill.BUNDLE_IDENTITIES) candidate = {**MERGED, "merged_at": None} - assert fill.identities(raw, manifest, candidate)["CERTIFICATION_COMMIT"] is None + assert fill.identities(raw, manifest, candidate, "BINDING", regional_raw)["CERTIFICATION_COMMIT"] is None + + +def as_merged(manifest): + """The manifest as the certifying merge left it, before the release workflow's version commit.""" + def back(value): + if isinstance(value, dict): + return {key: back(item) for key, item in value.items()} + if isinstance(value, list): + return [back(item) for item in value] + return value.replace(VERSION, OLD) if isinstance(value, str) else value + return back(manifest) + + +def test_only_the_release_s_version_strings_may_differ_from_the_merge(pending): + manifest = released(BINDING)[0] + merged = as_merged(manifest) + assert merged["policyengine_version"] == OLD != manifest["policyengine_version"] + assert fill.same_but_version(merged, manifest, OLD, VERSION) + assert fill.same_but_version(manifest, manifest, VERSION, VERSION) + for change in (lambda m: m["data_releases"]["uk"]["certified_data_artifact"].update(sha256="0" * 64), + lambda m: m["data_releases"]["uk"].update(extra=None), + lambda m: m["data_releases"]["uk"]["region_datasets"].pop("constituency"), + lambda m: m["data_releases"]["uk"]["certification"].update(core_binding=1), + lambda m: m.update(schema_version=str(m["schema_version"]))): + changed = deepcopy(merged) + change(changed) + assert not fill.same_but_version(changed, manifest, OLD, VERSION) + + +def hub(manifest, merged=None, status="ahead", pull_request=MERGED): + """gh api, answering only the reads a --release run should make: the manifest at the tag and at the merge + commit, the pull request, and the merge commit's comparison with the tag. Any other read fails.""" + merged = as_merged(manifest) if merged is None else merged + encode = lambda value: {"content": base64.b64encode(json.dumps(value).encode()).decode()} + commit = pull_request["merge_commit_sha"] + answers = {f"repos/{fill.REPOSITORY}/contents/{fill.MANIFEST}?ref={VERSION}": encode(manifest), + f"repos/{fill.REPOSITORY}/contents/{fill.MANIFEST}?ref={commit}": encode(merged), + f"repos/{fill.REPOSITORY}/pulls/9999": pull_request, + f"repos/{fill.REPOSITORY}/compare/{commit}...{VERSION}": {"status": status}} + return answers.__getitem__ + + +@pytest.mark.parametrize("pull_request,status,merged,refusal", [ + (MERGED, "ahead", None, None), + (MERGED, "identical", None, None), + ({**MERGED, "merged_at": None}, "ahead", None, "certifying pull request merged"), + (MERGED, "diverged", None, "merge commit is in the release"), + (MERGED, "behind", None, "merge commit is in the release"), + # 6.2.6's certifier (#555) is an ancestor of every later tag, but its manifest is not the release's. + (MERGED, "ahead", {"policyengine_version": "6.2.6", "data_releases": {}}, + "release manifest is the merge's, version strings aside"), +]) +def test_the_certifying_pull_request_merged_into_the_tag_with_its_manifest(pending, monkeypatch, pull_request, status, + merged, refusal): + manifest = released(BINDING)[0] + monkeypatch.setattr(fill, "github", hub(manifest, merged, status, pull_request)) + failures = [label for label, pinned, got in fill.certifying_checks(pull_request, VERSION, manifest) + if pinned != got] + assert failures == ([refusal] if refusal else []) -@pytest.fixture -def tree(tmp_path): - """A copy of the files the fill writes, so the real tree is never touched.""" +def reset(root): + """Copy the files the fill writes, with every release identity and the carrier back at their pending values.""" for relative in ("src/triple_lock/datasets.py", "pyproject.toml", "requirements-lock.txt", "data/binding-bridge.pending.json"): - (tmp_path / relative).parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(REPO / relative, tmp_path / relative) - return tmp_path + (root / relative).parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(REPO / relative, root / relative) + path = root / "src/triple_lock/datasets.py" + text = path.read_text() + for prefix, names in fill.IDENTITIES.items(): + for key in names: + text, count = re.subn(rf"^{prefix}_{key} = .*$", f"{prefix}_{key} = None", text, flags=re.M) + assert count == 1 + path.write_text(text) + for path in (root / "pyproject.toml", root / "requirements-lock.txt"): + text, count = re.subn(r'^(\s*"?policyengine==)[0-9][^",\s]*', rf"\g<1>{datasets.PREPARATORY_CARRIER}", + path.read_text(), flags=re.M) + assert count == 1 + path.write_text(text) + return root + + +@pytest.fixture +def tree(tmp_path): + """A pending copy of the tree, so the real one is never touched and its fill state does not matter.""" + return reset(tmp_path / "tree") -def values(): - manifest = released_manifest(fill.TARGETS["binding"][0]) - return fill.identities(json.dumps(manifest).encode(), manifest, MERGED) +def values(prefix="BINDING"): + manifest, _, regional_raw = released(BINDING) + return fill.identities(json.dumps(manifest).encode(), manifest, MERGED, prefix, regional_raw) def assignments(text, prefix): return {name: line.split(" = ", 1)[1] for line in text.splitlines() - for name in fill.IDENTITIES if line.startswith(f"{prefix}_{name} = ")} + for name in fill.IDENTITIES[prefix] if line.startswith(f"{prefix}_{name} = ")} -def test_the_binding_fill_writes_every_pin_and_a_ready_bridge(tree): +def files(root): + return {path.relative_to(root).as_posix(): path.read_bytes() for path in root.rglob("*") if path.is_file()} + + +def test_the_binding_fill_writes_every_pin_and_a_ready_bridge(pending, tree): filled = values() fill.fill(tree, "BINDING", filled, MERGED["head"]["sha"]) text = (tree / "src/triple_lock/datasets.py").read_text() - assert assignments(text, "BINDING") == {name: json.dumps(filled[name]) for name in fill.IDENTITIES} + assert assignments(text, "BINDING") == {name: json.dumps(filled[name]) for name in fill.IDENTITIES["BINDING"]} assert set(assignments(text, "FALLBACK").values()) == {"None"} assert f'"policyengine=={VERSION}",' in (tree / "pyproject.toml").read_text() assert f"\npolicyengine=={VERSION}\n" in (tree / "requirements-lock.txt").read_text() - pending = json.loads((REPO / "data/binding-bridge.pending.json").read_text()) - assert json.loads((tree / "data/binding-bridge.pending.json").read_text()) == pending + pending_bridge = json.loads((REPO / "data/binding-bridge.pending.json").read_text()) + assert json.loads((tree / "data/binding-bridge.pending.json").read_text()) == pending_bridge ready = json.loads((tree / "data/binding-bridge.json").read_text()) binding_bridge.validate_configuration(ready) - assert ready["pending_release"] == {**pending["pending_release"], "pull_request": MERGED["html_url"], + assert ready["pending_release"] == {**pending_bridge["pending_release"], "pull_request": MERGED["html_url"], "reviewed_head": MERGED["head"]["sha"], "policyengine_version": VERSION, "bundle_manifest_sha256": filled["BUNDLE_MANIFEST_SHA256"]} # Only the three carrier pins and the status change: model, core and dataset pins stay exactly as reviewed. - expected = deepcopy(pending) + expected = deepcopy(pending_bridge) expected["status"] = "ready" expected["pending_release"] = ready["pending_release"] for step, endpoint in (("model", "to"), ("data", "from"), ("data", "to")): @@ -150,51 +292,139 @@ def test_the_binding_fill_writes_every_pin_and_a_ready_bridge(tree): assert ready == expected -def test_the_fallback_fill_writes_only_its_own_identities(tree): - before = {path: path.read_bytes() for path in tree.rglob("*") if path.is_file()} - fill.fill(tree, "FALLBACK", values(), MERGED["head"]["sha"]) +def test_the_fallback_fill_writes_only_its_own_identities(pending, tree): + before = files(tree) + fill.fill(tree, "FALLBACK", values("FALLBACK"), MERGED["head"]["sha"]) text = (tree / "src/triple_lock/datasets.py").read_text() - assert set(assignments(text, "FALLBACK").values()) != {"None"} + assert "None" not in set(assignments(text, "FALLBACK").values()) assert set(assignments(text, "BINDING").values()) == {"None"} - changed = {path.relative_to(tree).as_posix() for path in tree.rglob("*") - if path.is_file() and before.get(path) != path.read_bytes()} - assert changed == {"src/triple_lock/datasets.py"} + assert {path for path, raw in files(tree).items() if before.get(path) != raw} == {"src/triple_lock/datasets.py"} + +def test_a_value_is_written_as_itself_never_as_a_template(pending, tree): + """A backslash in a value is not a regex back-reference.""" + url = r"https://github.com/PolicyEngine/policyengine.py/pull/9999\1\g<0>" + fill.fill(tree, "BINDING", {**values(), "CERTIFICATION_URL": url}, MERGED["head"]["sha"]) + assert f"BINDING_CERTIFICATION_URL = {json.dumps(url)}\n" in (tree / "src/triple_lock/datasets.py").read_text() -@pytest.mark.parametrize("missing", ["CERTIFICATION_COMMIT", "LOCAL_AREA_PATH"]) -def test_a_value_the_release_does_not_supply_writes_nothing(tree, missing): - before = {path: path.read_bytes() for path in tree.rglob("*") if path.is_file()} + +@pytest.mark.parametrize("missing", ["CERTIFICATION_COMMIT", "LOCAL_AREA_REVISION", "LOCAL_AREA_MANIFEST_SHA256"]) +def test_a_value_the_release_does_not_supply_writes_nothing(pending, tree, missing): + before = files(tree) with pytest.raises(ValueError, match=missing): fill.fill(tree, "BINDING", {**values(), missing: None}, MERGED["head"]["sha"]) - assert {path: path.read_bytes() for path in tree.rglob("*") if path.is_file()} == before + assert files(tree) == before -@pytest.mark.parametrize("change,code", [(None, 0), ("artifact SHA-256", 1)]) -def test_main_refuses_a_differing_release_before_writing(tree, tmp_path, monkeypatch, change, code): - manifest = released_manifest(fill.TARGETS["binding"][0]) - if change: - manifest["data_releases"]["uk"]["certified_data_artifact"]["sha256"] = "0" * 64 - path = tmp_path / "manifest.json" - path.write_text(json.dumps(manifest)) - monkeypatch.setattr(fill, "github", lambda _: MERGED) +@pytest.mark.parametrize("break_tree", [ + # A pending endpoint that already names a carrier, found after datasets.py and both pins were built. + lambda root: (root / "data/binding-bridge.pending.json").write_text( + (root / "data/binding-bridge.pending.json").read_text().replace('"policyengine": null', '"policyengine": "1"', 1)), + # No exact carrier pin to replace, found after datasets.py was built. + lambda root: (root / "pyproject.toml").write_text( + (root / "pyproject.toml").read_text().replace("policyengine==", "policyengine>=")), +]) +def test_a_refusal_found_late_still_writes_nothing(pending, tree, break_tree): + break_tree(tree) + before = files(tree) + with pytest.raises(ValueError, match="nothing is written"): + fill.fill(tree, "BINDING", values(), MERGED["head"]["sha"]) + assert files(tree) == before + + +def run_release(monkeypatch, tree, manifest, regional_raw, *arguments, merged=None): + """main() in --release mode against the fake GitHub and Hub; the Hub read must be at the cut's own tag.""" + monkeypatch.setattr(fill, "github", hub(manifest, merged)) monkeypatch.setattr(fill, "ROOT", tree) - assert fill.main(["--pr", "9999", "--manifest", str(path), "--apply"]) == code - assert (tree / "data/binding-bridge.json").exists() is (code == 0) + cut_tag = json.loads(regional_raw)["build"]["cut_tag"] + monkeypatch.setattr(fill, "hub_file", lambda local: regional_raw if local["revision"] == cut_tag + else pytest.fail(f"read the cut's manifest at {local['revision']}")) + return fill.main(["--pr", "9999", "--release", VERSION, *arguments]) + + +def test_the_release_path_reads_the_tag_and_applies_the_binding_pair(pending, tree, monkeypatch): + manifest, local, regional_raw = released(BINDING) + assert run_release(monkeypatch, tree, manifest, regional_raw, "--apply") == 0 + filled = assignments((tree / "src/triple_lock/datasets.py").read_text(), "BINDING") + assert filled["LOCAL_AREA_REVISION"] == json.dumps(local["revision"]) + assert filled["LOCAL_AREA_MANIFEST_SHA256"] == json.dumps(local["manifest_sha256"]) + assert filled["CERTIFICATION_COMMIT"] == json.dumps(MERGED["merge_commit_sha"]) + assert (tree / "data/binding-bridge.json").exists() -def test_a_rerun_checks_the_identities_already_filled(tmp_path, monkeypatch, capsys): - manifest = released_manifest(fill.TARGETS["binding"][0]) +def test_the_release_path_applies_the_pinned_fallback_cut(pending, tree, monkeypatch): + manifest, local, regional_raw = released(FALLBACK) + monkeypatch.setitem(datasets.DATASETS[FALLBACK], "local_area", local) + assert run_release(monkeypatch, tree, manifest, regional_raw, "--target", "fallback", "--apply") == 0 + assert "None" not in assignments((tree / "src/triple_lock/datasets.py").read_text(), "FALLBACK").values() + assert not (tree / "data/binding-bridge.json").exists() + + +def test_the_release_path_refuses_a_tag_naming_another_carrier(pending, monkeypatch): + manifest = released(BINDING, version="98.5.0")[0] + monkeypatch.setattr(fill, "github", lambda path: {"content": base64.b64encode(json.dumps(manifest).encode())}) + with pytest.raises(SystemExit, match="names policyengine 98.5.0"): + fill.main(["--pr", "9999", "--release", VERSION]) + + +@pytest.mark.parametrize("change", [ + lambda manifest: manifest["data_releases"]["uk"]["certified_data_artifact"].update(sha256="0" * 64), + lambda manifest: manifest["data_releases"]["uk"].update(regional_certification={"compatibility_basis": "x"}), +]) +def test_main_refuses_a_differing_release_before_writing(pending, tree, monkeypatch, capsys, change): + manifest, _, regional_raw = released(BINDING) + change(manifest) + before = files(tree) + assert run_release(monkeypatch, tree, manifest, regional_raw, "--apply") == 1 + assert files(tree) == before + assert "refused" in capsys.readouterr().err + + +def test_main_refuses_a_release_that_is_not_the_certifying_merge(pending, tree, monkeypatch, capsys): + manifest, _, regional_raw = released(BINDING) + merged = as_merged(manifest) + merged["data_releases"]["uk"]["certified_data_artifact"]["sha256"] = "0" * 64 + before = files(tree) + assert run_release(monkeypatch, tree, manifest, regional_raw, "--apply", merged=merged) == 1 + assert files(tree) == before + assert "FAIL release manifest is the merge's" in capsys.readouterr().out + + +def test_the_binding_pair_refuses_a_local_area_cut_built_on_another_model(pending, tree, monkeypatch, capsys): + """The review's failing input: a6299504 released with a cut shaped like the 19 October one (built on 2.132.6).""" + other = datasets.local_area_cut(INVENTED_CUT["path"], "dense-cut-built-on-2.132.6", "e" * 64, None, "2.132.6", + "built_with") + manifest, _, regional_raw = released(BINDING, local=other) + assert run_release(monkeypatch, tree, manifest, regional_raw) == 1 + assert "FAIL local-area build model" in capsys.readouterr().out + manifest["data_releases"]["uk"]["regional_certification"] = regional_certification(other, "2.151.0") + assert run_release(monkeypatch, tree, manifest, regional_raw) == 1 + assert "FAIL regional certification" in capsys.readouterr().out + + +def test_apply_needs_the_release_path(pending, tmp_path): + path = tmp_path / "manifest.json" + path.write_text(json.dumps(released(BINDING)[0])) + with pytest.raises(SystemExit) as refusal: + fill.main(["--pr", "9999", "--manifest", str(path), "--apply"]) + assert refusal.value.code == 2 + + +def test_a_rerun_checks_the_identities_already_filled(pending, tmp_path, monkeypatch, capsys): + manifest, local, regional_raw = released(BINDING) path = tmp_path / "manifest.json" path.write_text(json.dumps(manifest)) - filled = fill.identities(path.read_bytes(), manifest, MERGED) - monkeypatch.setattr(fill, "github", lambda _: MERGED) + regional = tmp_path / "regional.json" + regional.write_bytes(regional_raw) + filled = fill.identities(path.read_bytes(), manifest, MERGED, "BINDING", regional_raw) + monkeypatch.setattr(fill, "github", {f"repos/{fill.REPOSITORY}/pulls/9999": MERGED}.__getitem__) for key, value in filled.items(): monkeypatch.setattr(datasets, f"BINDING_{key}", value) - assert fill.main(["--pr", "9999", "--manifest", str(path)]) == 0 - assert capsys.readouterr().out.count("ok filled BINDING_") == len(fill.IDENTITIES) - with pytest.raises(SystemExit, match="already filled"): - fill.main(["--pr", "9999", "--manifest", str(path), "--apply"]) + monkeypatch.setitem(datasets.DATASETS[BINDING], "local_area", local) + arguments = ["--pr", "9999", "--manifest", str(path), "--regional-manifest", str(regional)] + assert fill.main(arguments) == 0 + assert capsys.readouterr().out.count("ok filled BINDING_") == len(fill.IDENTITIES["BINDING"]) # Installed bytes that differ from the pinned digest refuse. path.write_text(json.dumps(manifest, indent=1)) - assert fill.main(["--pr", "9999", "--manifest", str(path)]) == 1 + assert fill.main(arguments) == 1 assert "FAIL filled BINDING_BUNDLE_MANIFEST_SHA256" in capsys.readouterr().out From aac81dfd148dfdf5c0e28ad13bfd694ba038f803 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 25 Oct 2026 22:26:02 -0700 Subject: [PATCH 08/14] Compare the release with the workflow's exact rewrite of the certifying merge The r2 review found that the certifying check read the old carrier version as the new one in every string, so a source URI that changed from @6.10.2 to @6.11.0 passed as a version rewrite. fill_release_identities.as_released now replays the release workflow as at policyengine.py 6.11.0. bump_version.py's sync_bundle_versions sets the carrier version fields and each country's bundle id. generate_bundle_artifacts.py's normalized_manifest sets each package's install requirement and the citation, and drops source. The tag's manifest bytes must equal that rewrite of the merge's manifest exactly. Nothing else may differ, so a value Python compares equal (1 and true) also refuses. Tests: - A differential test against four vendored public policyengine.py manifests (#704's merge and 6.11.0, #555's merge and 6.2.6): the replay writes both real releases byte for byte, and #555's manifest is not 6.11.0's. - The review's old-version source URI, on real and synthetic manifests. - A duplicate local-area datasets entry refuses through both the installed check and the script. - Every comparator and uniqueness mutant tried is caught. The REBUILD.md fill commands now run the script with .venv/bin/python. Co-Authored-By: Claude Opus 5.5 --- docs/REBUILD.md | 4 +- scripts/fill_release_identities.py | 65 ++- .../pr-555-merge-37d25c42.json | 339 +++++++++++++++ .../pr-704-merge-13192b52.json | 392 ++++++++++++++++++ .../release-6.11.0.json | 392 ++++++++++++++++++ .../release-6.2.6.json | 339 +++++++++++++++ tests/released_pair.py | 11 +- tests/test_budget_bundle.py | 9 + tests/test_fill_release_identities.py | 171 ++++++-- 9 files changed, 1668 insertions(+), 54 deletions(-) create mode 100644 tests/fixtures/policyengine-bundle-manifests/pr-555-merge-37d25c42.json create mode 100644 tests/fixtures/policyengine-bundle-manifests/pr-704-merge-13192b52.json create mode 100644 tests/fixtures/policyengine-bundle-manifests/release-6.11.0.json create mode 100644 tests/fixtures/policyengine-bundle-manifests/release-6.2.6.json diff --git a/docs/REBUILD.md b/docs/REBUILD.md index 787e08cb..6b7829ea 100644 --- a/docs/REBUILD.md +++ b/docs/REBUILD.md @@ -28,9 +28,9 @@ Max ruled **d2011 = A** on 25 October: the binding primary is the certified 2.15 The revision exists on the Hub with the pinned file (LFS SHA-256 and its 342,348,841 bytes checked on 26 October); materialization still verifies the bytes before every use, and the release must name the same artifact. The preparatory lock uses the base carrier without `[uk]`, plus explicit UK 2.151.0/core 3.33.3. The engine constructs `policyengine_uk` directly and loads its own verified artifact. Publication preflight remains closed until the exact release is authenticated. d1727's Enhanced FRS qualification-ledger route is superseded as the primary; its fail-closed code, [status receipt](../data/certification/d1727-qualification-status.json) and [2.153.4 audit](d1727-upstream.md) stay as history. -**Fallback.** d2011's fallback is #715's `microcosm_uk_2024_25@a133f37a` / UK 2.147.2 pair served with [policyengine.py #699](https://github.com/PolicyEngine/policyengine.py/pull/699)'s area routing: a133f37a's national file, with constituency and local-authority runs routed to the 19 October dense local-area cut `microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe` (built on 2.132.6) on an accepted model mismatch. #715's reviewed candidate head `e02d41bceab57756373b2dc3bcf78635b856ec81` routes national only, so it does not certify the fallback by itself. Its local-area cut is pinned now (`datasets.FALLBACK_LOCAL_AREA`: file `microcosm_uk_2024_25_dense.h5`, SHA-256 `8e10e130…`, release manifest SHA-256 `b9dc7cae…`), and the release must carry #699's `regional_certification` for exactly that mismatch (2.147.2 certified, 2.132.6 built, area receipts uncertified) and `regional_source_manifest_sha256`. Its bundle identities stay pending in `datasets.FALLBACK_*`. If d2011 falls back, fill them with `scripts/fill_release_identities.py --target fallback --pr --release --apply`. Then, in one reviewed commit, move these to the fallback: `config.PRIMARY_DATASET` (the preflight reads the bundle of whichever of `datasets.D2011_PAIRS` it names, here `datasets.BUNDLE_715`); the model pin to `policyengine-uk==2.147.2` and the carrier pin in `pyproject.toml` and `requirements-lock.txt`, with the lock re-resolved as in step 1; CI's install comment; the bridge endpoints and dataset in `data/binding-bridge.pending.json`, and its ready copy; and the d2011 wording in METHOD.md and this runbook. Its [2.125.1-to-2.147.2 audit](d1983-upstream.md) applies. +**Fallback.** d2011's fallback is #715's `microcosm_uk_2024_25@a133f37a` / UK 2.147.2 pair served with [policyengine.py #699](https://github.com/PolicyEngine/policyengine.py/pull/699)'s area routing: a133f37a's national file, with constituency and local-authority runs routed to the 19 October dense local-area cut `microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe` (built on 2.132.6) on an accepted model mismatch. #715's reviewed candidate head `e02d41bceab57756373b2dc3bcf78635b856ec81` routes national only, so it does not certify the fallback by itself. Its local-area cut is pinned now (`datasets.FALLBACK_LOCAL_AREA`: file `microcosm_uk_2024_25_dense.h5`, SHA-256 `8e10e130…`, release manifest SHA-256 `b9dc7cae…`), and the release must carry #699's `regional_certification` for exactly that mismatch (2.147.2 certified, 2.132.6 built, area receipts uncertified) and `regional_source_manifest_sha256`. Its bundle identities stay pending in `datasets.FALLBACK_*`. If d2011 falls back, fill them with `HUGGING_FACE_TOKEN=… .venv/bin/python scripts/fill_release_identities.py --target fallback --pr --release --apply`. Then, in one reviewed commit, move these to the fallback: `config.PRIMARY_DATASET` (the preflight reads the bundle of whichever of `datasets.D2011_PAIRS` it names, here `datasets.BUNDLE_715`); the model pin to `policyengine-uk==2.147.2` and the carrier pin in `pyproject.toml` and `requirements-lock.txt`, with the lock re-resolved as in step 1; CI's install comment; the bridge endpoints and dataset in `data/binding-bridge.pending.json`, and its ready copy; and the d2011 wording in METHOD.md and this runbook. Its [2.125.1-to-2.147.2 audit](d1983-upstream.md) applies. -1. After the pair is released, run `HUGGING_FACE_TOKEN=… scripts/fill_release_identities.py --pr --release `. It reads the UK bundle manifest at the release tag (the release workflow's version commit rewrites it after the certifying merge, so the merge commit's bytes are not the release's) and compares every identity above with it, the revision included. It reads the local-area cut's own release manifest at the cut's tag (or `--regional-manifest FILE`): the cut's build must name that tag, have run UK 2.151.0 on core 3.33.3, and publish the file the bundle routes to, with the bundle's `datasets` entry, regional URIs and any `regional_source_manifest_sha256` matching; a `regional_certification` block refuses. It requires the certifying pull request to be merged, its merge commit to be in the tag, and the manifest at that commit to equal the tag's apart from the version strings the release workflow rewrites. It prints the eight `datasets.BINDING_*` values: carrier version, manifest SHA-256, certifying pull request and merge commit, and the local-area cut's file, tag, SHA-256 and release manifest SHA-256. Any difference refuses. With `--apply` (only with `--release`) it checks every file it writes before writing any, then writes those, the carrier version in both dependency files and the ready `data/binding-bridge.json` of step 4. Do not change the model/core/data pins merely to fit a carrier. +1. After the pair is released, run `HUGGING_FACE_TOKEN=… .venv/bin/python scripts/fill_release_identities.py --pr --release `. It reads the UK bundle manifest at the release tag (the release workflow's version commit rewrites it after the certifying merge, so the merge commit's bytes are not the release's) and compares every identity above with it, the revision included. It reads the local-area cut's own release manifest at the cut's tag (or `--regional-manifest FILE`): the cut's build must name that tag, have run UK 2.151.0 on core 3.33.3, and publish the file the bundle routes to, with the bundle's `datasets` entry, regional URIs and any `regional_source_manifest_sha256` matching; a `regional_certification` block refuses. It requires the certifying pull request to be merged, its merge commit to be in the tag, and the tag's manifest to be, byte for byte, what the release workflow writes from the manifest at that commit: it rewrites the carrier version fields, each country's bundle id, the install requirements and the citation, and nothing else. It prints the eight `datasets.BINDING_*` values: carrier version, manifest SHA-256, certifying pull request and merge commit, and the local-area cut's file, tag, SHA-256 and release manifest SHA-256. Any difference refuses. With `--apply` (only with `--release`) it checks every file it writes before writing any, then writes those, the carrier version in both dependency files and the ready `data/binding-bridge.json` of step 4. Do not change the model/core/data pins merely to fit a carrier. Then re-resolve the lock for the new carrier, keeping every other pin as a preference: save the four comment lines at the top of `requirements-lock.txt`, run `uv pip compile pyproject.toml --extra uk --extra dev --python-version 3.13 --no-header --no-annotate --upgrade-package policyengine -o requirements-lock.txt`, and put the comment lines back. At the preparatory carrier this reproduces all 86 pins exactly. Name any pin other than `policyengine` that moves in the commit; `test_the_lock_satisfies_the_installed_carrier_s_own_requirements` checks the installed carrier's requirements against the lock. After installing the lock, rerun the script without `--apply`, with `--manifest` on the installed wheel's `policyengine/data/bundle/manifest.json`: it then also checks each filled identity, so the installed bytes must hash to the pinned digest, as the preflight in step 3 requires. With the release installed, `tests/test_datasets.py` and `tests/test_budget_bundle.py` require provenance to certify the filled pair. 2. Keep the [2.147.2-to-2.151.0 source audit](d2011-upstream.md) (on top of the [2.125.1-to-2.147.2 audit](d1983-upstream.md)), `check_upstream()` and the parameter/small-household checks passing. UK 2.151.0 adds #2557: benefit uprating follows the previous September's CPI and `path_following` takes its September branch. It lacks #2576 (earnings-linked guarantee/derived HB and Savings Credit, first 2.151.2), and #2340 remains unmerged. Additional pension still takes the engine's path-specific explicit CPI pin. Age-addition handling takes the absent-variable branch. 3. Run certification and the behavioural HB receipt-passport preflight below before survey jobs. Synthetic checks never certify the fiscal result. diff --git a/scripts/fill_release_identities.py b/scripts/fill_release_identities.py index 9978600a..2f04bb47 100644 --- a/scripts/fill_release_identities.py +++ b/scripts/fill_release_identities.py @@ -16,9 +16,10 @@ mismatch) on core 3.33.3, and its local-area artifact must be the file the bundle routes to. The fallback's cut is pinned already; the binding cut's file, tag, SHA-256 and manifest SHA-256 come from the release and are filled. -With ``--release``, the certifying pull request must be merged, its merge commit must be in the tag, and the -manifest at the merge commit must equal the tag's apart from the version strings the release workflow rewrites: -the release is then exactly what the certifying review merged. Any difference exits 1 before anything is written. +With ``--release``, the certifying pull request must be merged, its merge commit must be in the tag, and the tag's +manifest must be, byte for byte, what the release workflow writes from the merge commit's: the carrier version +fields, each country's bundle id, the install requirements and the citation are rewritten, and nothing else. The +release is then exactly what the certifying review merged. Any difference exits 1 before anything is written. ``--apply`` (with ``--release`` only) writes the target's identities: carrier version, manifest SHA-256, certifying pull request and merge commit, and for the binding target its local-area cut. The binding target also writes the @@ -31,6 +32,7 @@ import argparse import base64 +from copy import deepcopy import hashlib import json import os @@ -154,32 +156,49 @@ def checks(manifest, spec, local, regional_raw): ] -def same_but_version(merged, released, old, new): - """Whether two manifests are equal once ``old`` is read as ``new`` in each string the release rewrote.""" - if isinstance(merged, dict): - return (isinstance(released, dict) and merged.keys() == released.keys() - and all(same_but_version(merged[key], released[key], old, new) for key in merged)) - if isinstance(merged, list): - return (isinstance(released, list) and len(merged) == len(released) - and all(same_but_version(a, b, old, new) for a, b in zip(merged, released))) - if isinstance(merged, str) and isinstance(released, str): - return released in (merged, merged.replace(old, new)) - return type(merged) is type(released) and merged == released - - -def certifying_checks(pull_request, tag, manifest): - """[(label, pinned, released)]: the pull request merged, into this tag, with exactly this manifest.""" +def as_released(merged, version): + """The manifest the release workflow writes for ``version`` from the certifying merge's ``merged``, as at + policyengine.py 6.11.0: .github/bump_version.py's sync_bundle_versions (the carrier version fields, each country's + bundle id), then scripts/generate_bundle_artifacts.py's normalized_manifest (each package's install requirement, + the citation, no ``source``). Nothing else is rewritten. None where the workflow would fail on ``merged``.""" + if not (isinstance(merged, dict) and {"bundle_version", "policyengine_version", "packages"} <= merged.keys()): + return None + released = deepcopy(merged) + try: + released["bundle_version"] = released["policyengine_version"] = version + released["packages"]["policyengine"]["version"] = version + for country, data_release in released.get("data_releases", {}).items(): + if isinstance(data_release, dict): + data_release.update(policyengine_version=version, bundle_id=f"{country}-{version}") + for package in released["packages"].values(): + package["install_requirement"] = (f"{package['name']}=={package['version']}" + + (f"; {package['markers']}" if package.get("markers") else "")) + except (AttributeError, KeyError, TypeError): + return None + released.pop("source", None) + released["citation"] = {"title": f"PolicyEngine bundle {version}", "version": version, "type": "software-bundle", + "publisher": "PolicyEngine"} + return released + + +def manifest_text(manifest): + """The bytes the release workflow writes for a manifest.""" + return (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode() + + +def certifying_checks(pull_request, tag, raw): + """[(label, pinned, released)]: the pull request merged, into this tag, whose manifest bytes ``raw`` are exactly + what the release workflow writes from the merge's.""" commit = pull_request.get("merge_commit_sha") if pull_request.get("merged_at") else None rows = [("certifying pull request merged", True, commit is not None)] if commit is None: return rows status = github(f"repos/{REPOSITORY}/compare/{commit}...{tag}")["status"] - merged = json.loads(github_file(MANIFEST, commit)) - new = manifest["policyengine_version"] + expected = as_released(json.loads(github_file(MANIFEST, commit)), json.loads(raw)["policyengine_version"]) return rows + [ ("merge commit is in the release", True, status in ("ahead", "identical")), - ("release manifest is the merge's, version strings aside", True, - same_but_version(merged, manifest, merged.get("policyengine_version") or new, new)), + ("release manifest is the workflow's rewrite of the merge's", True, + expected is not None and manifest_text(expected) == raw), ] @@ -275,7 +294,7 @@ def main(argv=None): rows = checks(manifest, spec, local, regional_raw) pull_request = github(f"repos/{REPOSITORY}/pulls/{args.pr}") if args.release: - rows += certifying_checks(pull_request, args.release, manifest) + rows += certifying_checks(pull_request, args.release, raw) values = identities(raw, manifest, pull_request, prefix, regional_raw) # Identities already filled (a rerun on the installed wheel's manifest) must equal what this release supplies. rows += [(f"filled {prefix}_{key}", getattr(datasets, f"{prefix}_{key}"), value) for key, value in values.items() diff --git a/tests/fixtures/policyengine-bundle-manifests/pr-555-merge-37d25c42.json b/tests/fixtures/policyengine-bundle-manifests/pr-555-merge-37d25c42.json new file mode 100644 index 00000000..2e7e48a9 --- /dev/null +++ b/tests/fixtures/policyengine-bundle-manifests/pr-555-merge-37d25c42.json @@ -0,0 +1,339 @@ +{ + "bundle_version": "6.2.5", + "citation": { + "publisher": "PolicyEngine", + "title": "PolicyEngine bundle 6.2.5", + "type": "software-bundle", + "version": "6.2.5" + }, + "countries": { + "uk": { + "model_package": "policyengine-uk" + }, + "us": { + "model_package": "policyengine-us" + } + }, + "data_releases": { + "uk": { + "build_id": "policyengine-uk-data-1.58.0", + "bundle_id": "uk-6.2.5", + "certification": { + "built_with_model_git_sha": "4cbedbecca352f52dffe47e849553c2759a00711", + "built_with_model_version": "2.122.2", + "certified_by": "policyengine.py bundle certification", + "certified_for_model_version": "2.125.1", + "compatibility_basis": "legacy_compatible_model_package", + "data_build_fingerprint": "sha256:c094d7006fdd81df220090284212f16bbffb15624643bc442c4bb3e1b1e5dac8", + "data_build_id": "policyengine-uk-data-1.58.0" + }, + "certified_data_artifact": { + "build_id": "policyengine-uk-data-1.58.0", + "data_package": { + "name": "policyengine-uk-data", + "version": "1.58.0" + }, + "dataset": "enhanced_frs_2024_25", + "sha256": "5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6", + "uri": "hf://policyengine/policyengine-uk-data-private/enhanced_frs_2024_25.h5@1.58.0" + }, + "country_id": "uk", + "data_package": { + "name": "policyengine-uk-data", + "release_manifest_path": "releases/1.58.0/release_manifest.json", + "release_manifest_revision": "fdeba38517bd86067f34c0a5d8ed8923efb3b10a", + "repo_id": "policyengine/policyengine-uk-data-private", + "repo_type": "model", + "version": "1.58.0" + }, + "data_producer": "populace", + "datasets": { + "enhanced_frs_2024_25": { + "path": "enhanced_frs_2024_25.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "revision": "1.58.0", + "sha256": "5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6" + }, + "enhanced_frs_2024_25_tiny": { + "path": "enhanced_frs_2024_25_tiny.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "revision": "1.58.0", + "sha256": "a2e938b6c68952dc6fa3af998501371d9014b66263baef262d4f649fe6f19dd5" + }, + "frs_2024_25": { + "path": "frs_2024_25.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "revision": "1.58.0", + "sha256": "2ec0ea6c10bc82d030a071474c1b376780ea88053e1659d784b4253662341634" + }, + "frs_2024_25_tiny": { + "path": "frs_2024_25_tiny.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "revision": "1.58.0", + "sha256": "e91d17c4bae92067222272da33918f15027f65a58e3765c7bdff8aa119df914e" + }, + "local_authority_weights": { + "path": "local_authority_weights.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "revision": "1.58.0", + "sha256": "0f8e43877f13cea86ea7bd78ce938769d0c2746ac0b191be661c25608aa786fc" + }, + "local_geography_weights": { + "path": "local_geography_weights.csv.gz", + "repo_id": "policyengine/policyengine-uk-data-private", + "revision": "1.58.0", + "sha256": "2c4026139eb69d26af5a0ddad7e7fe9ee981b89a0968e77a5f41820091f641c2" + }, + "parliamentary_constituency_weights": { + "path": "parliamentary_constituency_weights.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "revision": "1.58.0", + "sha256": "da3d021bf6cd57f06e878094e48524a6a691207231158d9a109da73ea459e900" + } + }, + "default_dataset": "enhanced_frs_2024_25", + "default_dataset_uri": "hf://policyengine/policyengine-uk-data-private/enhanced_frs_2024_25.h5@1.58.0", + "model_package": { + "name": "policyengine-uk", + "sha256": "a0983b4cef868467578a4fd0d163a977a0da8f710dd5194ec01c75f66d512c5a", + "version": "2.125.1", + "wheel_url": "https://files.pythonhosted.org/packages/ec/3a/1d90bad4346d1a8c2715be211ed3d3d7f48917da0d90405b8257b7304091/policyengine_uk-2.125.1-py3-none-any.whl" + }, + "policyengine_version": "6.2.5", + "region_datasets": { + "national": { + "path_template": "enhanced_frs_2024_25.h5" + } + }, + "release_manifest_uri": "https://huggingface.co/policyengine/policyengine-uk-data-private/resolve/fdeba38517bd86067f34c0a5d8ed8923efb3b10a/releases/1.58.0/release_manifest.json", + "schema_version": 1, + "source_manifest_uri": "hf://model/policyengine/policyengine-uk-data-private@fdeba38517bd86067f34c0a5d8ed8923efb3b10a/releases/1.58.0/release_manifest.json", + "version": "policyengine-uk-data-1.58.0" + }, + "us": { + "build_id": "populace-us-2024-spm-20260915", + "bundle_id": "us-6.2.5", + "certification": { + "built_with_model_version": "2.2.1", + "certified_by": "policyengine.py bundle certification", + "certified_for_model_version": "2.2.1", + "compatibility_basis": "built_with_model_package", + "data_build_id": "populace-us-2024-spm-20260915" + }, + "certified_data_artifact": { + "build_id": "populace-us-2024-spm-20260915", + "data_package": { + "name": "microcosm-data", + "version": "0.1.0" + }, + "dataset": "populace_us_2024", + "sha256": "6496cc4393d4d3c6574f76eca231de5898c803b9067645591fd5c4d3e65aee84", + "uri": "hf://policyengine/populace-us/populace_us_2024.h5@populace-us-2024-spm-20260915" + }, + "country_id": "us", + "data_package": { + "name": "microcosm-data", + "release_manifest_path": "releases/populace-us-2024-spm-20260915/release_manifest.json", + "release_manifest_revision": "populace-us-2024-spm-20260915", + "repo_id": "policyengine/populace-us", + "repo_type": "dataset", + "version": "0.1.0" + }, + "data_producer": "populace", + "datasets": { + "build_manifest": { + "path": "releases/populace-us-2024-spm-20260915/build_manifest.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "f3fd734867324bca7619f5f083a8115e32104bea3948d48723b345cfd39fa03b" + }, + "calibration_diagnostics": { + "path": "releases/populace-us-2024-spm-20260915/calibration_diagnostics.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "870449b44e86b13b25bcea1a57f0e7af37f4d4db18be815eea3acdf9fe6eb40e" + }, + "parent_build_manifest": { + "path": "releases/populace-us-2024-spm-20260915/parent_build_manifest.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "1990e8fd37ce66f499b8e6600c5896701bb07015be132a12de64e321a2edc67e" + }, + "parent_release_manifest": { + "path": "releases/populace-us-2024-spm-20260915/parent_release_manifest.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "dd949ba3c4c7a56aff8442c6db5a031d2c84c3da59f5d14832f468c358604506" + }, + "populace_us_2024": { + "path": "populace_us_2024.h5", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "6496cc4393d4d3c6574f76eca231de5898c803b9067645591fd5c4d3e65aee84" + }, + "populace_us_2024_acs_local": { + "path": "populace_us_2024_acs_local.h5", + "repo_id": "policyengine/populace-us", + "repo_type": "dataset", + "revision": "populace-us-2024-buildo-acs-local-767312d60-20260923T074941Z", + "sha256": "769756c31f3ca646d12c272511744dec04c0e68870c6946dd945fbba65b6a7ec" + }, + "source_enrichment": { + "path": "releases/populace-us-2024-spm-20260915/source_enrichment.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "85329907b2b9c4299855b0970b1f24ede5819d3b16c312eb7400b4203bd71203" + }, + "source_enrichment_compatibility": { + "path": "releases/populace-us-2024-spm-20260915/source_enrichment_compatibility.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "63109acffd486453c99c476dece6b0154cc5388e5a430830a30744a2249444d1" + }, + "source_spm_independence_provenance": { + "path": "releases/populace-us-2024-spm-20260915/source_spm_independence_provenance.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "265e844178ed3856d68c1638820f3089e0e1e1363aa4677e33bebb37cbd5bff3" + }, + "source_spm_person_independence": { + "path": "releases/populace-us-2024-spm-20260915/source_spm_person_independence.csv", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "22b5968d90fecfeef7614583e493fe10cc16bda8b5be82e6f49a5bc2102d3ce5" + }, + "us_source_coverage": { + "path": "releases/populace-us-2024-spm-20260915/us_source_coverage.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "6406c8686c292015a5bc7265a42402f89935f9395a8b63510efdd4d9562e7ff5" + } + }, + "default_dataset": "populace_us_2024", + "default_dataset_uri": "hf://policyengine/populace-us/populace_us_2024.h5@populace-us-2024-spm-20260915", + "model_package": { + "name": "policyengine-us", + "sha256": "0993a6c73fcdfbe171a796aca9d302741bd8e00ab83388ad81c15a08740318c6", + "version": "2.2.1", + "wheel_url": "https://files.pythonhosted.org/packages/5e/09/86ac3968d2a18e28fce3ba1186fc21728e9f299b96a806d6f204f1e0aaed/policyengine_us-2.2.1-py3-none-any.whl" + }, + "policyengine_version": "6.2.5", + "region_datasets": { + "congressional_district": { + "path_template": "populace_us_2024_acs_local.h5" + }, + "national": { + "path_template": "populace_us_2024.h5" + }, + "state": { + "path_template": "populace_us_2024_acs_local.h5" + } + }, + "regional_release_manifest_uri": "https://huggingface.co/datasets/policyengine/populace-us/resolve/populace-us-2024-buildo-acs-local-767312d60-20260923T074941Z/releases/populace-us-2024-buildo-acs-local-767312d60-20260923T074941Z/release_manifest.json", + "regional_source_manifest_uri": "hf://dataset/policyengine/populace-us@populace-us-2024-buildo-acs-local-767312d60-20260923T074941Z/releases/populace-us-2024-buildo-acs-local-767312d60-20260923T074941Z/release_manifest.json", + "release_manifest_uri": "https://huggingface.co/datasets/policyengine/populace-us/resolve/populace-us-2024-spm-20260915/releases/populace-us-2024-spm-20260915/release_manifest.json", + "schema_version": 1, + "source_manifest_uri": "hf://dataset/policyengine/populace-us@populace-us-2024-spm-20260915/releases/populace-us-2024-spm-20260915/release_manifest.json", + "version": "populace-us-2024-spm-20260915" + } + }, + "dataset_overlays": { + "uk": { + "enhanced_frs_2023_24": { + "data_package_name": "policyengine-uk-data", + "path": "enhanced_frs_2023_24.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "repo_type": "model", + "revision": "655dd07e4bb9c777b00dac044949611f1feb824f", + "sha256": "584ae33d80ca0431254610a3f8254d132da73477d31966d6446282861ecae50d" + }, + "frs_2023_24": { + "data_package_name": "policyengine-uk-data", + "path": "frs_2023_24.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "repo_type": "model", + "revision": "655dd07e4bb9c777b00dac044949611f1feb824f", + "sha256": "df26d4d7af9d164aa2d064181b39290292d2f62bb26fee6126fc095fc06da292" + }, + "populace_uk_2023": { + "data_package_name": "populace-data", + "path": "populace_uk_2023.h5", + "repo_id": "policyengine/populace-uk-private", + "repo_type": "dataset", + "revision": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "sha256": "f17306ccb2aad7ff0130be3589b560afb2e2a12a943570911cd0c77f07934833" + } + } + }, + "extras": { + "models": [ + "policyengine-core", + "policyengine-us", + "policyengine-uk", + "spm-calculator" + ], + "uk": [ + "policyengine-core", + "policyengine-uk" + ], + "us": [ + "policyengine-core", + "policyengine-us", + "spm-calculator" + ] + }, + "measurements": { + "spm": { + "as_of": null, + "county_vintage": "2020", + "forecast_content_sha256": "3d86d5c4c0423480e6b69b75d222ffa4a7a2639e4094df5ba2504af01be17173", + "geography_id": null, + "geography_kind": "county", + "scenario": "ce_trend" + } + }, + "packages": { + "policyengine": { + "import_name": "policyengine", + "install_requirement": "policyengine==6.2.5", + "name": "policyengine", + "role": "bundle_carrier", + "version": "6.2.5" + }, + "policyengine-core": { + "import_name": "policyengine_core", + "install_requirement": "policyengine-core==3.33.0", + "name": "policyengine-core", + "role": "runtime_dependency", + "version": "3.33.0" + }, + "policyengine-uk": { + "country": "uk", + "import_name": "policyengine_uk", + "install_requirement": "policyengine-uk==2.125.1", + "name": "policyengine-uk", + "role": "country_model", + "version": "2.125.1" + }, + "policyengine-us": { + "country": "us", + "import_name": "policyengine_us", + "install_requirement": "policyengine-us==2.2.1", + "name": "policyengine-us", + "role": "country_model", + "version": "2.2.1" + }, + "spm-calculator": { + "country": "us", + "import_name": "spm_calculator", + "install_requirement": "spm-calculator==1.0.0", + "name": "spm-calculator", + "role": "runtime_dependency", + "sha256": "e354937a5e1a4045d4966ed594a528d8b02866fabaac9bb5672017004b627305", + "version": "1.0.0", + "wheel_url": "https://files.pythonhosted.org/packages/19/a0/c484f69a0ebf88a9b9fd0ac28176f8df66550f46714f1b0c5ec0b360824e/spm_calculator-1.0.0-py3-none-any.whl" + } + }, + "policyengine_version": "6.2.5", + "schema_version": 2 +} diff --git a/tests/fixtures/policyengine-bundle-manifests/pr-704-merge-13192b52.json b/tests/fixtures/policyengine-bundle-manifests/pr-704-merge-13192b52.json new file mode 100644 index 00000000..e17db3ad --- /dev/null +++ b/tests/fixtures/policyengine-bundle-manifests/pr-704-merge-13192b52.json @@ -0,0 +1,392 @@ +{ + "bundle_version": "6.10.2", + "citation": { + "publisher": "PolicyEngine", + "title": "PolicyEngine bundle 6.10.2", + "type": "software-bundle", + "version": "6.10.2" + }, + "countries": { + "uk": { + "model_package": "policyengine-uk" + }, + "us": { + "model_package": "policyengine-us" + } + }, + "data_releases": { + "uk": { + "build_id": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "bundle_id": "uk-6.10.2", + "certification": { + "built_with_model_version": "2.132.6", + "certified_by": "policyengine.py bundle certification", + "certified_for_model_version": "2.132.6", + "compatibility_basis": "built_with_model_package", + "core_binding": "bundle_pin", + "data_build_id": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71" + }, + "certified_data_artifact": { + "build_id": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "data_package": { + "name": "microcosm-data", + "version": "0.1.0" + }, + "dataset": "microcosm_uk_2024_25", + "sha256": "810ba980005a8b907cb51464279fa5e0125464b7af9a4871b7f6744c30951ece", + "uri": "hf://policyengine/populace-uk-private/microcosm_uk_2024_25.h5@microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71" + }, + "country_id": "uk", + "data_package": { + "name": "microcosm-data", + "release_manifest_path": "releases/microcosm-uk-2024-25-national/release_manifest.json", + "release_manifest_revision": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "repo_id": "policyengine/populace-uk-private", + "repo_type": "dataset", + "version": "0.1.0" + }, + "data_producer": "populace", + "datasets": { + "calibration_diagnostics": { + "path": "releases/microcosm-uk-2024-25-national/calibration_diagnostics.json", + "repo_id": "policyengine/populace-uk-private", + "revision": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "sha256": "34978e2bfde3f2641769c9c3a8f60455c59ecae5a50524b359d74388d7edc888" + }, + "microcosm_uk_2024_25": { + "path": "microcosm_uk_2024_25.h5", + "repo_id": "policyengine/populace-uk-private", + "revision": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "sha256": "810ba980005a8b907cb51464279fa5e0125464b7af9a4871b7f6744c30951ece" + }, + "microcosm_uk_2024_25_calibration": { + "path": "microcosm_uk_2024_25_calibration.npz", + "repo_id": "policyengine/populace-uk-private", + "revision": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "sha256": "104a8253bf1aebc91147c9bbb40e2603d1025317ba8c6b991383d5e4d91852c2" + }, + "microcosm_uk_2024_25_dense": { + "path": "microcosm_uk_2024_25_dense.h5", + "repo_id": "policyengine/populace-uk-private", + "repo_type": "dataset", + "revision": "microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe", + "sha256": "8e10e130d0260e0b52e2e29902f0502ebdff76a14a3e57d2c876bf4cd8db5872" + }, + "release_certification": { + "path": "releases/microcosm-uk-2024-25-national/release_certification.json", + "repo_id": "policyengine/populace-uk-private", + "revision": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "sha256": "6a68cc93595fc5a7ad451d75a902523c25659de27014f50431dd5e19af5bd959" + }, + "release_cut_gates": { + "path": "releases/microcosm-uk-2024-25-national/release_cut_gates.json", + "repo_id": "policyengine/populace-uk-private", + "revision": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "sha256": "c91c1364d9c3be98493039d4e1dd8c2256881a434c1f124dd2ed0701bfaf7751" + }, + "score_vs_enhanced_frs": { + "path": "releases/microcosm-uk-2024-25-national/score_vs_enhanced_frs.json", + "repo_id": "policyengine/populace-uk-private", + "revision": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "sha256": "ba8a675f36e7c836bf5521e5933e21bb689442734b7340e05cabd3e0cc55323d" + }, + "spine_gates": { + "path": "releases/microcosm-uk-2024-25-national/spine_gates.json", + "repo_id": "policyengine/populace-uk-private", + "revision": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "sha256": "6dbba976dd1304c07da531382cecb11c9cb69512de0ee65126b71f032a0f37d6" + }, + "terminal_gates": { + "path": "releases/microcosm-uk-2024-25-national/terminal_gates.json", + "repo_id": "policyengine/populace-uk-private", + "revision": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "sha256": "e29439d5ce0ea098d32579284ac06f26f086feba2fb8d12d1e88ac9a54840808" + } + }, + "default_dataset": "microcosm_uk_2024_25", + "default_dataset_uri": "hf://policyengine/populace-uk-private/microcosm_uk_2024_25.h5@microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "model_package": { + "name": "policyengine-uk", + "sha256": "cc54025f2a3bc54be4f8d024d1e6f11ed29fed75ca513b214f2d2963c5bf4354", + "version": "2.132.6", + "wheel_url": "https://files.pythonhosted.org/packages/11/f2/c003cd7a95cb499737a5b8af0d619ba4e759c8bc7e55a2bb5c7efa46abd1/policyengine_uk-2.132.6-py3-none-any.whl" + }, + "policyengine_version": "6.10.2", + "region_datasets": { + "constituency": { + "path_template": "microcosm_uk_2024_25_dense.h5" + }, + "local_authority": { + "path_template": "microcosm_uk_2024_25_dense.h5" + }, + "national": { + "path_template": "microcosm_uk_2024_25.h5" + } + }, + "regional_release_kind": "local_area", + "regional_release_manifest_uri": "https://huggingface.co/datasets/policyengine/populace-uk-private/resolve/microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe/releases/microcosm-uk-2024-25-dense/release_manifest.json", + "regional_source_manifest_uri": "hf://dataset/policyengine/populace-uk-private@microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe/releases/microcosm-uk-2024-25-dense/release_manifest.json", + "release_manifest_uri": "https://huggingface.co/datasets/policyengine/populace-uk-private/resolve/microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71/releases/microcosm-uk-2024-25-national/release_manifest.json", + "schema_version": 1, + "source_manifest_uri": "hf://dataset/policyengine/populace-uk-private@microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71/releases/microcosm-uk-2024-25-national/release_manifest.json", + "version": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71" + }, + "us": { + "build_id": "populace-us-2024-spm-20260915", + "bundle_id": "us-6.10.2", + "certification": { + "built_with_model_version": "2.2.1", + "certified_by": "policyengine.py bundle certification", + "certified_for_model_version": "2.2.1", + "compatibility_basis": "built_with_model_package", + "data_build_id": "populace-us-2024-spm-20260915" + }, + "certified_data_artifact": { + "build_id": "populace-us-2024-spm-20260915", + "data_package": { + "name": "microcosm-data", + "version": "0.1.0" + }, + "dataset": "populace_us_2024", + "sha256": "6496cc4393d4d3c6574f76eca231de5898c803b9067645591fd5c4d3e65aee84", + "uri": "hf://policyengine/populace-us/populace_us_2024.h5@populace-us-2024-spm-20260915" + }, + "country_id": "us", + "data_package": { + "name": "microcosm-data", + "release_manifest_path": "releases/populace-us-2024-spm-20260915/release_manifest.json", + "release_manifest_revision": "populace-us-2024-spm-20260915", + "repo_id": "policyengine/populace-us", + "repo_type": "dataset", + "version": "0.1.0" + }, + "data_producer": "populace", + "datasets": { + "build_manifest": { + "path": "releases/populace-us-2024-spm-20260915/build_manifest.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "f3fd734867324bca7619f5f083a8115e32104bea3948d48723b345cfd39fa03b" + }, + "calibration_diagnostics": { + "path": "releases/populace-us-2024-spm-20260915/calibration_diagnostics.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "870449b44e86b13b25bcea1a57f0e7af37f4d4db18be815eea3acdf9fe6eb40e" + }, + "parent_build_manifest": { + "path": "releases/populace-us-2024-spm-20260915/parent_build_manifest.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "1990e8fd37ce66f499b8e6600c5896701bb07015be132a12de64e321a2edc67e" + }, + "parent_release_manifest": { + "path": "releases/populace-us-2024-spm-20260915/parent_release_manifest.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "dd949ba3c4c7a56aff8442c6db5a031d2c84c3da59f5d14832f468c358604506" + }, + "populace_us_2024": { + "path": "populace_us_2024.h5", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "6496cc4393d4d3c6574f76eca231de5898c803b9067645591fd5c4d3e65aee84" + }, + "populace_us_2024_acs_local": { + "path": "populace_us_2024_acs_local.h5", + "repo_id": "policyengine/populace-us", + "repo_type": "dataset", + "revision": "populace-us-2024-buildo-acs-local-766b7c7-20261017T030317Z", + "sha256": "3ce781c440a7f933b1d952ff4e9d9f65f4698c64b94d2fad165af0c662838d6c" + }, + "source_enrichment": { + "path": "releases/populace-us-2024-spm-20260915/source_enrichment.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "85329907b2b9c4299855b0970b1f24ede5819d3b16c312eb7400b4203bd71203" + }, + "source_enrichment_compatibility": { + "path": "releases/populace-us-2024-spm-20260915/source_enrichment_compatibility.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "63109acffd486453c99c476dece6b0154cc5388e5a430830a30744a2249444d1" + }, + "source_spm_independence_provenance": { + "path": "releases/populace-us-2024-spm-20260915/source_spm_independence_provenance.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "265e844178ed3856d68c1638820f3089e0e1e1363aa4677e33bebb37cbd5bff3" + }, + "source_spm_person_independence": { + "path": "releases/populace-us-2024-spm-20260915/source_spm_person_independence.csv", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "22b5968d90fecfeef7614583e493fe10cc16bda8b5be82e6f49a5bc2102d3ce5" + }, + "us_source_coverage": { + "path": "releases/populace-us-2024-spm-20260915/us_source_coverage.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "6406c8686c292015a5bc7265a42402f89935f9395a8b63510efdd4d9562e7ff5" + } + }, + "default_dataset": "populace_us_2024", + "default_dataset_uri": "hf://policyengine/populace-us/populace_us_2024.h5@populace-us-2024-spm-20260915", + "model_package": { + "name": "policyengine-us", + "sha256": "0993a6c73fcdfbe171a796aca9d302741bd8e00ab83388ad81c15a08740318c6", + "version": "2.2.1", + "wheel_url": "https://files.pythonhosted.org/packages/5e/09/86ac3968d2a18e28fce3ba1186fc21728e9f299b96a806d6f204f1e0aaed/policyengine_us-2.2.1-py3-none-any.whl" + }, + "policyengine_version": "6.10.2", + "region_datasets": { + "congressional_district": { + "path_template": "populace_us_2024_acs_local.h5" + }, + "national": { + "path_template": "populace_us_2024.h5" + }, + "state": { + "path_template": "populace_us_2024_acs_local.h5" + } + }, + "regional_release_manifest_uri": "https://huggingface.co/datasets/policyengine/populace-us/resolve/populace-us-2024-buildo-acs-local-766b7c7-20261017T030317Z/releases/populace-us-2024-buildo-acs-local-766b7c7-20261017T030317Z/release_manifest.json", + "regional_source_manifest_uri": "hf://dataset/policyengine/populace-us@populace-us-2024-buildo-acs-local-766b7c7-20261017T030317Z/releases/populace-us-2024-buildo-acs-local-766b7c7-20261017T030317Z/release_manifest.json", + "release_manifest_uri": "https://huggingface.co/datasets/policyengine/populace-us/resolve/populace-us-2024-spm-20260915/releases/populace-us-2024-spm-20260915/release_manifest.json", + "schema_version": 1, + "source_manifest_uri": "hf://dataset/policyengine/populace-us@populace-us-2024-spm-20260915/releases/populace-us-2024-spm-20260915/release_manifest.json", + "version": "populace-us-2024-spm-20260915" + } + }, + "dataset_overlays": { + "uk": { + "enhanced_frs_2023_24": { + "data_package_name": "policyengine-uk-data", + "path": "enhanced_frs_2023_24.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "repo_type": "model", + "revision": "655dd07e4bb9c777b00dac044949611f1feb824f", + "sha256": "584ae33d80ca0431254610a3f8254d132da73477d31966d6446282861ecae50d" + }, + "enhanced_frs_2024_25": { + "data_package_name": "policyengine-uk-data", + "path": "enhanced_frs_2024_25.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "repo_type": "model", + "revision": "1.58.1", + "sha256": "056e80fd8e9151d78f432a25cde863340baee81304c6216a37fb575ce8563784" + }, + "enhanced_frs_2024_25_tiny": { + "data_package_name": "policyengine-uk-data", + "path": "enhanced_frs_2024_25_tiny.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "repo_type": "model", + "revision": "1.58.1", + "sha256": "d5d2bde80f1f028fec98391ea31bb5a0cf8449e3867fc3c1bfe23693b9faecf8" + }, + "frs_2023_24": { + "data_package_name": "policyengine-uk-data", + "path": "frs_2023_24.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "repo_type": "model", + "revision": "655dd07e4bb9c777b00dac044949611f1feb824f", + "sha256": "df26d4d7af9d164aa2d064181b39290292d2f62bb26fee6126fc095fc06da292" + }, + "frs_2024_25": { + "data_package_name": "policyengine-uk-data", + "path": "frs_2024_25.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "repo_type": "model", + "revision": "1.58.1", + "sha256": "97af00b2cd2739957f40dcc4de8e0383a6a91d47952c90d4b4921a6007bd2021" + }, + "frs_2024_25_tiny": { + "data_package_name": "policyengine-uk-data", + "path": "frs_2024_25_tiny.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "repo_type": "model", + "revision": "1.58.1", + "sha256": "e4d7662cd3f053620224b7ceff17266783f09407002a72633b043b844cec3f5a" + }, + "populace_uk_2023": { + "data_package_name": "populace-data", + "path": "populace_uk_2023.h5", + "repo_id": "policyengine/populace-uk-private", + "repo_type": "dataset", + "revision": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "sha256": "f17306ccb2aad7ff0130be3589b560afb2e2a12a943570911cd0c77f07934833" + } + } + }, + "extras": { + "models": [ + "policyengine-core", + "policyengine-us", + "policyengine-uk", + "spm-calculator" + ], + "uk": [ + "policyengine-core", + "policyengine-uk" + ], + "us": [ + "policyengine-core", + "policyengine-us", + "spm-calculator" + ] + }, + "measurements": { + "spm": { + "as_of": null, + "county_vintage": "2020", + "forecast_content_sha256": "3d86d5c4c0423480e6b69b75d222ffa4a7a2639e4094df5ba2504af01be17173", + "geography_id": null, + "geography_kind": "county", + "scenario": "ce_trend" + } + }, + "packages": { + "policyengine": { + "import_name": "policyengine", + "install_requirement": "policyengine==6.10.2", + "name": "policyengine", + "role": "bundle_carrier", + "version": "6.10.2" + }, + "policyengine-core": { + "import_name": "policyengine_core", + "install_requirement": "policyengine-core==3.33.3", + "name": "policyengine-core", + "role": "runtime_dependency", + "version": "3.33.3" + }, + "policyengine-uk": { + "country": "uk", + "import_name": "policyengine_uk", + "install_requirement": "policyengine-uk==2.132.6", + "name": "policyengine-uk", + "role": "country_model", + "version": "2.132.6" + }, + "policyengine-us": { + "country": "us", + "import_name": "policyengine_us", + "install_requirement": "policyengine-us==2.2.1", + "name": "policyengine-us", + "role": "country_model", + "version": "2.2.1" + }, + "spm-calculator": { + "country": "us", + "import_name": "spm_calculator", + "install_requirement": "spm-calculator==1.0.0", + "name": "spm-calculator", + "role": "runtime_dependency", + "sha256": "e354937a5e1a4045d4966ed594a528d8b02866fabaac9bb5672017004b627305", + "version": "1.0.0", + "wheel_url": "https://files.pythonhosted.org/packages/19/a0/c484f69a0ebf88a9b9fd0ac28176f8df66550f46714f1b0c5ec0b360824e/spm_calculator-1.0.0-py3-none-any.whl" + } + }, + "policyengine_version": "6.10.2", + "schema_version": 2 +} diff --git a/tests/fixtures/policyengine-bundle-manifests/release-6.11.0.json b/tests/fixtures/policyengine-bundle-manifests/release-6.11.0.json new file mode 100644 index 00000000..24783f55 --- /dev/null +++ b/tests/fixtures/policyengine-bundle-manifests/release-6.11.0.json @@ -0,0 +1,392 @@ +{ + "bundle_version": "6.11.0", + "citation": { + "publisher": "PolicyEngine", + "title": "PolicyEngine bundle 6.11.0", + "type": "software-bundle", + "version": "6.11.0" + }, + "countries": { + "uk": { + "model_package": "policyengine-uk" + }, + "us": { + "model_package": "policyengine-us" + } + }, + "data_releases": { + "uk": { + "build_id": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "bundle_id": "uk-6.11.0", + "certification": { + "built_with_model_version": "2.132.6", + "certified_by": "policyengine.py bundle certification", + "certified_for_model_version": "2.132.6", + "compatibility_basis": "built_with_model_package", + "core_binding": "bundle_pin", + "data_build_id": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71" + }, + "certified_data_artifact": { + "build_id": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "data_package": { + "name": "microcosm-data", + "version": "0.1.0" + }, + "dataset": "microcosm_uk_2024_25", + "sha256": "810ba980005a8b907cb51464279fa5e0125464b7af9a4871b7f6744c30951ece", + "uri": "hf://policyengine/populace-uk-private/microcosm_uk_2024_25.h5@microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71" + }, + "country_id": "uk", + "data_package": { + "name": "microcosm-data", + "release_manifest_path": "releases/microcosm-uk-2024-25-national/release_manifest.json", + "release_manifest_revision": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "repo_id": "policyengine/populace-uk-private", + "repo_type": "dataset", + "version": "0.1.0" + }, + "data_producer": "populace", + "datasets": { + "calibration_diagnostics": { + "path": "releases/microcosm-uk-2024-25-national/calibration_diagnostics.json", + "repo_id": "policyengine/populace-uk-private", + "revision": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "sha256": "34978e2bfde3f2641769c9c3a8f60455c59ecae5a50524b359d74388d7edc888" + }, + "microcosm_uk_2024_25": { + "path": "microcosm_uk_2024_25.h5", + "repo_id": "policyengine/populace-uk-private", + "revision": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "sha256": "810ba980005a8b907cb51464279fa5e0125464b7af9a4871b7f6744c30951ece" + }, + "microcosm_uk_2024_25_calibration": { + "path": "microcosm_uk_2024_25_calibration.npz", + "repo_id": "policyengine/populace-uk-private", + "revision": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "sha256": "104a8253bf1aebc91147c9bbb40e2603d1025317ba8c6b991383d5e4d91852c2" + }, + "microcosm_uk_2024_25_dense": { + "path": "microcosm_uk_2024_25_dense.h5", + "repo_id": "policyengine/populace-uk-private", + "repo_type": "dataset", + "revision": "microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe", + "sha256": "8e10e130d0260e0b52e2e29902f0502ebdff76a14a3e57d2c876bf4cd8db5872" + }, + "release_certification": { + "path": "releases/microcosm-uk-2024-25-national/release_certification.json", + "repo_id": "policyengine/populace-uk-private", + "revision": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "sha256": "6a68cc93595fc5a7ad451d75a902523c25659de27014f50431dd5e19af5bd959" + }, + "release_cut_gates": { + "path": "releases/microcosm-uk-2024-25-national/release_cut_gates.json", + "repo_id": "policyengine/populace-uk-private", + "revision": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "sha256": "c91c1364d9c3be98493039d4e1dd8c2256881a434c1f124dd2ed0701bfaf7751" + }, + "score_vs_enhanced_frs": { + "path": "releases/microcosm-uk-2024-25-national/score_vs_enhanced_frs.json", + "repo_id": "policyengine/populace-uk-private", + "revision": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "sha256": "ba8a675f36e7c836bf5521e5933e21bb689442734b7340e05cabd3e0cc55323d" + }, + "spine_gates": { + "path": "releases/microcosm-uk-2024-25-national/spine_gates.json", + "repo_id": "policyengine/populace-uk-private", + "revision": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "sha256": "6dbba976dd1304c07da531382cecb11c9cb69512de0ee65126b71f032a0f37d6" + }, + "terminal_gates": { + "path": "releases/microcosm-uk-2024-25-national/terminal_gates.json", + "repo_id": "policyengine/populace-uk-private", + "revision": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "sha256": "e29439d5ce0ea098d32579284ac06f26f086feba2fb8d12d1e88ac9a54840808" + } + }, + "default_dataset": "microcosm_uk_2024_25", + "default_dataset_uri": "hf://policyengine/populace-uk-private/microcosm_uk_2024_25.h5@microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71", + "model_package": { + "name": "policyengine-uk", + "sha256": "cc54025f2a3bc54be4f8d024d1e6f11ed29fed75ca513b214f2d2963c5bf4354", + "version": "2.132.6", + "wheel_url": "https://files.pythonhosted.org/packages/11/f2/c003cd7a95cb499737a5b8af0d619ba4e759c8bc7e55a2bb5c7efa46abd1/policyengine_uk-2.132.6-py3-none-any.whl" + }, + "policyengine_version": "6.11.0", + "region_datasets": { + "constituency": { + "path_template": "microcosm_uk_2024_25_dense.h5" + }, + "local_authority": { + "path_template": "microcosm_uk_2024_25_dense.h5" + }, + "national": { + "path_template": "microcosm_uk_2024_25.h5" + } + }, + "regional_release_kind": "local_area", + "regional_release_manifest_uri": "https://huggingface.co/datasets/policyengine/populace-uk-private/resolve/microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe/releases/microcosm-uk-2024-25-dense/release_manifest.json", + "regional_source_manifest_uri": "hf://dataset/policyengine/populace-uk-private@microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe/releases/microcosm-uk-2024-25-dense/release_manifest.json", + "release_manifest_uri": "https://huggingface.co/datasets/policyengine/populace-uk-private/resolve/microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71/releases/microcosm-uk-2024-25-national/release_manifest.json", + "schema_version": 1, + "source_manifest_uri": "hf://dataset/policyengine/populace-uk-private@microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71/releases/microcosm-uk-2024-25-national/release_manifest.json", + "version": "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71" + }, + "us": { + "build_id": "populace-us-2024-spm-20260915", + "bundle_id": "us-6.11.0", + "certification": { + "built_with_model_version": "2.2.1", + "certified_by": "policyengine.py bundle certification", + "certified_for_model_version": "2.2.1", + "compatibility_basis": "built_with_model_package", + "data_build_id": "populace-us-2024-spm-20260915" + }, + "certified_data_artifact": { + "build_id": "populace-us-2024-spm-20260915", + "data_package": { + "name": "microcosm-data", + "version": "0.1.0" + }, + "dataset": "populace_us_2024", + "sha256": "6496cc4393d4d3c6574f76eca231de5898c803b9067645591fd5c4d3e65aee84", + "uri": "hf://policyengine/populace-us/populace_us_2024.h5@populace-us-2024-spm-20260915" + }, + "country_id": "us", + "data_package": { + "name": "microcosm-data", + "release_manifest_path": "releases/populace-us-2024-spm-20260915/release_manifest.json", + "release_manifest_revision": "populace-us-2024-spm-20260915", + "repo_id": "policyengine/populace-us", + "repo_type": "dataset", + "version": "0.1.0" + }, + "data_producer": "populace", + "datasets": { + "build_manifest": { + "path": "releases/populace-us-2024-spm-20260915/build_manifest.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "f3fd734867324bca7619f5f083a8115e32104bea3948d48723b345cfd39fa03b" + }, + "calibration_diagnostics": { + "path": "releases/populace-us-2024-spm-20260915/calibration_diagnostics.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "870449b44e86b13b25bcea1a57f0e7af37f4d4db18be815eea3acdf9fe6eb40e" + }, + "parent_build_manifest": { + "path": "releases/populace-us-2024-spm-20260915/parent_build_manifest.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "1990e8fd37ce66f499b8e6600c5896701bb07015be132a12de64e321a2edc67e" + }, + "parent_release_manifest": { + "path": "releases/populace-us-2024-spm-20260915/parent_release_manifest.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "dd949ba3c4c7a56aff8442c6db5a031d2c84c3da59f5d14832f468c358604506" + }, + "populace_us_2024": { + "path": "populace_us_2024.h5", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "6496cc4393d4d3c6574f76eca231de5898c803b9067645591fd5c4d3e65aee84" + }, + "populace_us_2024_acs_local": { + "path": "populace_us_2024_acs_local.h5", + "repo_id": "policyengine/populace-us", + "repo_type": "dataset", + "revision": "populace-us-2024-buildo-acs-local-766b7c7-20261017T030317Z", + "sha256": "3ce781c440a7f933b1d952ff4e9d9f65f4698c64b94d2fad165af0c662838d6c" + }, + "source_enrichment": { + "path": "releases/populace-us-2024-spm-20260915/source_enrichment.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "85329907b2b9c4299855b0970b1f24ede5819d3b16c312eb7400b4203bd71203" + }, + "source_enrichment_compatibility": { + "path": "releases/populace-us-2024-spm-20260915/source_enrichment_compatibility.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "63109acffd486453c99c476dece6b0154cc5388e5a430830a30744a2249444d1" + }, + "source_spm_independence_provenance": { + "path": "releases/populace-us-2024-spm-20260915/source_spm_independence_provenance.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "265e844178ed3856d68c1638820f3089e0e1e1363aa4677e33bebb37cbd5bff3" + }, + "source_spm_person_independence": { + "path": "releases/populace-us-2024-spm-20260915/source_spm_person_independence.csv", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "22b5968d90fecfeef7614583e493fe10cc16bda8b5be82e6f49a5bc2102d3ce5" + }, + "us_source_coverage": { + "path": "releases/populace-us-2024-spm-20260915/us_source_coverage.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "6406c8686c292015a5bc7265a42402f89935f9395a8b63510efdd4d9562e7ff5" + } + }, + "default_dataset": "populace_us_2024", + "default_dataset_uri": "hf://policyengine/populace-us/populace_us_2024.h5@populace-us-2024-spm-20260915", + "model_package": { + "name": "policyengine-us", + "sha256": "0993a6c73fcdfbe171a796aca9d302741bd8e00ab83388ad81c15a08740318c6", + "version": "2.2.1", + "wheel_url": "https://files.pythonhosted.org/packages/5e/09/86ac3968d2a18e28fce3ba1186fc21728e9f299b96a806d6f204f1e0aaed/policyengine_us-2.2.1-py3-none-any.whl" + }, + "policyengine_version": "6.11.0", + "region_datasets": { + "congressional_district": { + "path_template": "populace_us_2024_acs_local.h5" + }, + "national": { + "path_template": "populace_us_2024.h5" + }, + "state": { + "path_template": "populace_us_2024_acs_local.h5" + } + }, + "regional_release_manifest_uri": "https://huggingface.co/datasets/policyengine/populace-us/resolve/populace-us-2024-buildo-acs-local-766b7c7-20261017T030317Z/releases/populace-us-2024-buildo-acs-local-766b7c7-20261017T030317Z/release_manifest.json", + "regional_source_manifest_uri": "hf://dataset/policyengine/populace-us@populace-us-2024-buildo-acs-local-766b7c7-20261017T030317Z/releases/populace-us-2024-buildo-acs-local-766b7c7-20261017T030317Z/release_manifest.json", + "release_manifest_uri": "https://huggingface.co/datasets/policyengine/populace-us/resolve/populace-us-2024-spm-20260915/releases/populace-us-2024-spm-20260915/release_manifest.json", + "schema_version": 1, + "source_manifest_uri": "hf://dataset/policyengine/populace-us@populace-us-2024-spm-20260915/releases/populace-us-2024-spm-20260915/release_manifest.json", + "version": "populace-us-2024-spm-20260915" + } + }, + "dataset_overlays": { + "uk": { + "enhanced_frs_2023_24": { + "data_package_name": "policyengine-uk-data", + "path": "enhanced_frs_2023_24.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "repo_type": "model", + "revision": "655dd07e4bb9c777b00dac044949611f1feb824f", + "sha256": "584ae33d80ca0431254610a3f8254d132da73477d31966d6446282861ecae50d" + }, + "enhanced_frs_2024_25": { + "data_package_name": "policyengine-uk-data", + "path": "enhanced_frs_2024_25.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "repo_type": "model", + "revision": "1.58.1", + "sha256": "056e80fd8e9151d78f432a25cde863340baee81304c6216a37fb575ce8563784" + }, + "enhanced_frs_2024_25_tiny": { + "data_package_name": "policyengine-uk-data", + "path": "enhanced_frs_2024_25_tiny.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "repo_type": "model", + "revision": "1.58.1", + "sha256": "d5d2bde80f1f028fec98391ea31bb5a0cf8449e3867fc3c1bfe23693b9faecf8" + }, + "frs_2023_24": { + "data_package_name": "policyengine-uk-data", + "path": "frs_2023_24.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "repo_type": "model", + "revision": "655dd07e4bb9c777b00dac044949611f1feb824f", + "sha256": "df26d4d7af9d164aa2d064181b39290292d2f62bb26fee6126fc095fc06da292" + }, + "frs_2024_25": { + "data_package_name": "policyengine-uk-data", + "path": "frs_2024_25.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "repo_type": "model", + "revision": "1.58.1", + "sha256": "97af00b2cd2739957f40dcc4de8e0383a6a91d47952c90d4b4921a6007bd2021" + }, + "frs_2024_25_tiny": { + "data_package_name": "policyengine-uk-data", + "path": "frs_2024_25_tiny.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "repo_type": "model", + "revision": "1.58.1", + "sha256": "e4d7662cd3f053620224b7ceff17266783f09407002a72633b043b844cec3f5a" + }, + "populace_uk_2023": { + "data_package_name": "populace-data", + "path": "populace_uk_2023.h5", + "repo_id": "policyengine/populace-uk-private", + "repo_type": "dataset", + "revision": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "sha256": "f17306ccb2aad7ff0130be3589b560afb2e2a12a943570911cd0c77f07934833" + } + } + }, + "extras": { + "models": [ + "policyengine-core", + "policyengine-us", + "policyengine-uk", + "spm-calculator" + ], + "uk": [ + "policyengine-core", + "policyengine-uk" + ], + "us": [ + "policyengine-core", + "policyengine-us", + "spm-calculator" + ] + }, + "measurements": { + "spm": { + "as_of": null, + "county_vintage": "2020", + "forecast_content_sha256": "3d86d5c4c0423480e6b69b75d222ffa4a7a2639e4094df5ba2504af01be17173", + "geography_id": null, + "geography_kind": "county", + "scenario": "ce_trend" + } + }, + "packages": { + "policyengine": { + "import_name": "policyengine", + "install_requirement": "policyengine==6.11.0", + "name": "policyengine", + "role": "bundle_carrier", + "version": "6.11.0" + }, + "policyengine-core": { + "import_name": "policyengine_core", + "install_requirement": "policyengine-core==3.33.3", + "name": "policyengine-core", + "role": "runtime_dependency", + "version": "3.33.3" + }, + "policyengine-uk": { + "country": "uk", + "import_name": "policyengine_uk", + "install_requirement": "policyengine-uk==2.132.6", + "name": "policyengine-uk", + "role": "country_model", + "version": "2.132.6" + }, + "policyengine-us": { + "country": "us", + "import_name": "policyengine_us", + "install_requirement": "policyengine-us==2.2.1", + "name": "policyengine-us", + "role": "country_model", + "version": "2.2.1" + }, + "spm-calculator": { + "country": "us", + "import_name": "spm_calculator", + "install_requirement": "spm-calculator==1.0.0", + "name": "spm-calculator", + "role": "runtime_dependency", + "sha256": "e354937a5e1a4045d4966ed594a528d8b02866fabaac9bb5672017004b627305", + "version": "1.0.0", + "wheel_url": "https://files.pythonhosted.org/packages/19/a0/c484f69a0ebf88a9b9fd0ac28176f8df66550f46714f1b0c5ec0b360824e/spm_calculator-1.0.0-py3-none-any.whl" + } + }, + "policyengine_version": "6.11.0", + "schema_version": 2 +} diff --git a/tests/fixtures/policyengine-bundle-manifests/release-6.2.6.json b/tests/fixtures/policyengine-bundle-manifests/release-6.2.6.json new file mode 100644 index 00000000..602e5ef1 --- /dev/null +++ b/tests/fixtures/policyengine-bundle-manifests/release-6.2.6.json @@ -0,0 +1,339 @@ +{ + "bundle_version": "6.2.6", + "citation": { + "publisher": "PolicyEngine", + "title": "PolicyEngine bundle 6.2.6", + "type": "software-bundle", + "version": "6.2.6" + }, + "countries": { + "uk": { + "model_package": "policyengine-uk" + }, + "us": { + "model_package": "policyengine-us" + } + }, + "data_releases": { + "uk": { + "build_id": "policyengine-uk-data-1.58.0", + "bundle_id": "uk-6.2.6", + "certification": { + "built_with_model_git_sha": "4cbedbecca352f52dffe47e849553c2759a00711", + "built_with_model_version": "2.122.2", + "certified_by": "policyengine.py bundle certification", + "certified_for_model_version": "2.125.1", + "compatibility_basis": "legacy_compatible_model_package", + "data_build_fingerprint": "sha256:c094d7006fdd81df220090284212f16bbffb15624643bc442c4bb3e1b1e5dac8", + "data_build_id": "policyengine-uk-data-1.58.0" + }, + "certified_data_artifact": { + "build_id": "policyengine-uk-data-1.58.0", + "data_package": { + "name": "policyengine-uk-data", + "version": "1.58.0" + }, + "dataset": "enhanced_frs_2024_25", + "sha256": "5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6", + "uri": "hf://policyengine/policyengine-uk-data-private/enhanced_frs_2024_25.h5@1.58.0" + }, + "country_id": "uk", + "data_package": { + "name": "policyengine-uk-data", + "release_manifest_path": "releases/1.58.0/release_manifest.json", + "release_manifest_revision": "fdeba38517bd86067f34c0a5d8ed8923efb3b10a", + "repo_id": "policyengine/policyengine-uk-data-private", + "repo_type": "model", + "version": "1.58.0" + }, + "data_producer": "populace", + "datasets": { + "enhanced_frs_2024_25": { + "path": "enhanced_frs_2024_25.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "revision": "1.58.0", + "sha256": "5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6" + }, + "enhanced_frs_2024_25_tiny": { + "path": "enhanced_frs_2024_25_tiny.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "revision": "1.58.0", + "sha256": "a2e938b6c68952dc6fa3af998501371d9014b66263baef262d4f649fe6f19dd5" + }, + "frs_2024_25": { + "path": "frs_2024_25.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "revision": "1.58.0", + "sha256": "2ec0ea6c10bc82d030a071474c1b376780ea88053e1659d784b4253662341634" + }, + "frs_2024_25_tiny": { + "path": "frs_2024_25_tiny.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "revision": "1.58.0", + "sha256": "e91d17c4bae92067222272da33918f15027f65a58e3765c7bdff8aa119df914e" + }, + "local_authority_weights": { + "path": "local_authority_weights.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "revision": "1.58.0", + "sha256": "0f8e43877f13cea86ea7bd78ce938769d0c2746ac0b191be661c25608aa786fc" + }, + "local_geography_weights": { + "path": "local_geography_weights.csv.gz", + "repo_id": "policyengine/policyengine-uk-data-private", + "revision": "1.58.0", + "sha256": "2c4026139eb69d26af5a0ddad7e7fe9ee981b89a0968e77a5f41820091f641c2" + }, + "parliamentary_constituency_weights": { + "path": "parliamentary_constituency_weights.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "revision": "1.58.0", + "sha256": "da3d021bf6cd57f06e878094e48524a6a691207231158d9a109da73ea459e900" + } + }, + "default_dataset": "enhanced_frs_2024_25", + "default_dataset_uri": "hf://policyengine/policyengine-uk-data-private/enhanced_frs_2024_25.h5@1.58.0", + "model_package": { + "name": "policyengine-uk", + "sha256": "a0983b4cef868467578a4fd0d163a977a0da8f710dd5194ec01c75f66d512c5a", + "version": "2.125.1", + "wheel_url": "https://files.pythonhosted.org/packages/ec/3a/1d90bad4346d1a8c2715be211ed3d3d7f48917da0d90405b8257b7304091/policyengine_uk-2.125.1-py3-none-any.whl" + }, + "policyengine_version": "6.2.6", + "region_datasets": { + "national": { + "path_template": "enhanced_frs_2024_25.h5" + } + }, + "release_manifest_uri": "https://huggingface.co/policyengine/policyengine-uk-data-private/resolve/fdeba38517bd86067f34c0a5d8ed8923efb3b10a/releases/1.58.0/release_manifest.json", + "schema_version": 1, + "source_manifest_uri": "hf://model/policyengine/policyengine-uk-data-private@fdeba38517bd86067f34c0a5d8ed8923efb3b10a/releases/1.58.0/release_manifest.json", + "version": "policyengine-uk-data-1.58.0" + }, + "us": { + "build_id": "populace-us-2024-spm-20260915", + "bundle_id": "us-6.2.6", + "certification": { + "built_with_model_version": "2.2.1", + "certified_by": "policyengine.py bundle certification", + "certified_for_model_version": "2.2.1", + "compatibility_basis": "built_with_model_package", + "data_build_id": "populace-us-2024-spm-20260915" + }, + "certified_data_artifact": { + "build_id": "populace-us-2024-spm-20260915", + "data_package": { + "name": "microcosm-data", + "version": "0.1.0" + }, + "dataset": "populace_us_2024", + "sha256": "6496cc4393d4d3c6574f76eca231de5898c803b9067645591fd5c4d3e65aee84", + "uri": "hf://policyengine/populace-us/populace_us_2024.h5@populace-us-2024-spm-20260915" + }, + "country_id": "us", + "data_package": { + "name": "microcosm-data", + "release_manifest_path": "releases/populace-us-2024-spm-20260915/release_manifest.json", + "release_manifest_revision": "populace-us-2024-spm-20260915", + "repo_id": "policyengine/populace-us", + "repo_type": "dataset", + "version": "0.1.0" + }, + "data_producer": "populace", + "datasets": { + "build_manifest": { + "path": "releases/populace-us-2024-spm-20260915/build_manifest.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "f3fd734867324bca7619f5f083a8115e32104bea3948d48723b345cfd39fa03b" + }, + "calibration_diagnostics": { + "path": "releases/populace-us-2024-spm-20260915/calibration_diagnostics.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "870449b44e86b13b25bcea1a57f0e7af37f4d4db18be815eea3acdf9fe6eb40e" + }, + "parent_build_manifest": { + "path": "releases/populace-us-2024-spm-20260915/parent_build_manifest.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "1990e8fd37ce66f499b8e6600c5896701bb07015be132a12de64e321a2edc67e" + }, + "parent_release_manifest": { + "path": "releases/populace-us-2024-spm-20260915/parent_release_manifest.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "dd949ba3c4c7a56aff8442c6db5a031d2c84c3da59f5d14832f468c358604506" + }, + "populace_us_2024": { + "path": "populace_us_2024.h5", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "6496cc4393d4d3c6574f76eca231de5898c803b9067645591fd5c4d3e65aee84" + }, + "populace_us_2024_acs_local": { + "path": "populace_us_2024_acs_local.h5", + "repo_id": "policyengine/populace-us", + "repo_type": "dataset", + "revision": "populace-us-2024-buildo-acs-local-767312d60-20260923T074941Z", + "sha256": "769756c31f3ca646d12c272511744dec04c0e68870c6946dd945fbba65b6a7ec" + }, + "source_enrichment": { + "path": "releases/populace-us-2024-spm-20260915/source_enrichment.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "85329907b2b9c4299855b0970b1f24ede5819d3b16c312eb7400b4203bd71203" + }, + "source_enrichment_compatibility": { + "path": "releases/populace-us-2024-spm-20260915/source_enrichment_compatibility.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "63109acffd486453c99c476dece6b0154cc5388e5a430830a30744a2249444d1" + }, + "source_spm_independence_provenance": { + "path": "releases/populace-us-2024-spm-20260915/source_spm_independence_provenance.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "265e844178ed3856d68c1638820f3089e0e1e1363aa4677e33bebb37cbd5bff3" + }, + "source_spm_person_independence": { + "path": "releases/populace-us-2024-spm-20260915/source_spm_person_independence.csv", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "22b5968d90fecfeef7614583e493fe10cc16bda8b5be82e6f49a5bc2102d3ce5" + }, + "us_source_coverage": { + "path": "releases/populace-us-2024-spm-20260915/us_source_coverage.json", + "repo_id": "policyengine/populace-us", + "revision": "populace-us-2024-spm-20260915", + "sha256": "6406c8686c292015a5bc7265a42402f89935f9395a8b63510efdd4d9562e7ff5" + } + }, + "default_dataset": "populace_us_2024", + "default_dataset_uri": "hf://policyengine/populace-us/populace_us_2024.h5@populace-us-2024-spm-20260915", + "model_package": { + "name": "policyengine-us", + "sha256": "0993a6c73fcdfbe171a796aca9d302741bd8e00ab83388ad81c15a08740318c6", + "version": "2.2.1", + "wheel_url": "https://files.pythonhosted.org/packages/5e/09/86ac3968d2a18e28fce3ba1186fc21728e9f299b96a806d6f204f1e0aaed/policyengine_us-2.2.1-py3-none-any.whl" + }, + "policyengine_version": "6.2.6", + "region_datasets": { + "congressional_district": { + "path_template": "populace_us_2024_acs_local.h5" + }, + "national": { + "path_template": "populace_us_2024.h5" + }, + "state": { + "path_template": "populace_us_2024_acs_local.h5" + } + }, + "regional_release_manifest_uri": "https://huggingface.co/datasets/policyengine/populace-us/resolve/populace-us-2024-buildo-acs-local-767312d60-20260923T074941Z/releases/populace-us-2024-buildo-acs-local-767312d60-20260923T074941Z/release_manifest.json", + "regional_source_manifest_uri": "hf://dataset/policyengine/populace-us@populace-us-2024-buildo-acs-local-767312d60-20260923T074941Z/releases/populace-us-2024-buildo-acs-local-767312d60-20260923T074941Z/release_manifest.json", + "release_manifest_uri": "https://huggingface.co/datasets/policyengine/populace-us/resolve/populace-us-2024-spm-20260915/releases/populace-us-2024-spm-20260915/release_manifest.json", + "schema_version": 1, + "source_manifest_uri": "hf://dataset/policyengine/populace-us@populace-us-2024-spm-20260915/releases/populace-us-2024-spm-20260915/release_manifest.json", + "version": "populace-us-2024-spm-20260915" + } + }, + "dataset_overlays": { + "uk": { + "enhanced_frs_2023_24": { + "data_package_name": "policyengine-uk-data", + "path": "enhanced_frs_2023_24.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "repo_type": "model", + "revision": "655dd07e4bb9c777b00dac044949611f1feb824f", + "sha256": "584ae33d80ca0431254610a3f8254d132da73477d31966d6446282861ecae50d" + }, + "frs_2023_24": { + "data_package_name": "policyengine-uk-data", + "path": "frs_2023_24.h5", + "repo_id": "policyengine/policyengine-uk-data-private", + "repo_type": "model", + "revision": "655dd07e4bb9c777b00dac044949611f1feb824f", + "sha256": "df26d4d7af9d164aa2d064181b39290292d2f62bb26fee6126fc095fc06da292" + }, + "populace_uk_2023": { + "data_package_name": "populace-data", + "path": "populace_uk_2023.h5", + "repo_id": "policyengine/populace-uk-private", + "repo_type": "dataset", + "revision": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "sha256": "f17306ccb2aad7ff0130be3589b560afb2e2a12a943570911cd0c77f07934833" + } + } + }, + "extras": { + "models": [ + "policyengine-core", + "policyengine-us", + "policyengine-uk", + "spm-calculator" + ], + "uk": [ + "policyengine-core", + "policyengine-uk" + ], + "us": [ + "policyengine-core", + "policyengine-us", + "spm-calculator" + ] + }, + "measurements": { + "spm": { + "as_of": null, + "county_vintage": "2020", + "forecast_content_sha256": "3d86d5c4c0423480e6b69b75d222ffa4a7a2639e4094df5ba2504af01be17173", + "geography_id": null, + "geography_kind": "county", + "scenario": "ce_trend" + } + }, + "packages": { + "policyengine": { + "import_name": "policyengine", + "install_requirement": "policyengine==6.2.6", + "name": "policyengine", + "role": "bundle_carrier", + "version": "6.2.6" + }, + "policyengine-core": { + "import_name": "policyengine_core", + "install_requirement": "policyengine-core==3.33.0", + "name": "policyengine-core", + "role": "runtime_dependency", + "version": "3.33.0" + }, + "policyengine-uk": { + "country": "uk", + "import_name": "policyengine_uk", + "install_requirement": "policyengine-uk==2.125.1", + "name": "policyengine-uk", + "role": "country_model", + "version": "2.125.1" + }, + "policyengine-us": { + "country": "us", + "import_name": "policyengine_us", + "install_requirement": "policyengine-us==2.2.1", + "name": "policyengine-us", + "role": "country_model", + "version": "2.2.1" + }, + "spm-calculator": { + "country": "us", + "import_name": "spm_calculator", + "install_requirement": "spm-calculator==1.0.0", + "name": "spm-calculator", + "role": "runtime_dependency", + "sha256": "e354937a5e1a4045d4966ed594a528d8b02866fabaac9bb5672017004b627305", + "version": "1.0.0", + "wheel_url": "https://files.pythonhosted.org/packages/19/a0/c484f69a0ebf88a9b9fd0ac28176f8df66550f46714f1b0c5ec0b360824e/spm_calculator-1.0.0-py3-none-any.whl" + } + }, + "policyengine_version": "6.2.6", + "schema_version": 2 +} diff --git a/tests/released_pair.py b/tests/released_pair.py index 9527864e..85a0b6da 100644 --- a/tests/released_pair.py +++ b/tests/released_pair.py @@ -52,9 +52,14 @@ def released(name, local=None, version=VERSION): local["manifest_sha256"] = hashlib.sha256(regional_raw).hexdigest() manifest = json.loads((REPO / "data/certification/d1983-candidate-bundle-manifest.json").read_text()) release = manifest["data_releases"]["uk"] - manifest["policyengine_version"] = release["policyengine_version"] = version - release["bundle_id"] = f"uk-{version}" - manifest["packages"]["policyengine-core"]["version"] = certification["core_version"] + # Every field the release workflow rewrites for the carrier, as it writes them. + manifest["bundle_version"] = manifest["policyengine_version"] = version + manifest["packages"]["policyengine"].update(version=version, install_requirement=f"policyengine=={version}") + manifest["citation"].update(title=f"PolicyEngine bundle {version}", version=version) + for country, data_release in manifest["data_releases"].items(): + data_release.update(policyengine_version=version, bundle_id=f"{country}-{version}") + core = certification["core_version"] + manifest["packages"]["policyengine-core"].update(version=core, install_requirement=f"policyengine-core=={core}") release["certification"]["certified_for_core_version"] = certification["core_version"] release["model_package"].update(version=certification["model_version"], sha256=certification["model_wheel_sha256"]) release["certification"]["certified_for_model_version"] = certification["model_version"] diff --git a/tests/test_budget_bundle.py b/tests/test_budget_bundle.py index 350fdbbf..8c19ee0f 100644 --- a/tests/test_budget_bundle.py +++ b/tests/test_budget_bundle.py @@ -207,6 +207,15 @@ def test_another_local_area_cut_of_the_same_file_cannot_certify(released, key, v assert not datasets._certified_by_installed_bundle(spec, versions) +def test_two_datasets_entries_for_the_local_area_file_cannot_certify(released): + """Which entry the routed file means would be ambiguous, even when one of them is the pinned cut.""" + spec, versions, manifest, write, _ = released + entries = manifest["data_releases"]["uk"]["datasets"] + entries["duplicate_dense"] = dict(entries["microcosm_uk_2024_25_dense"]) + write() + assert not datasets._certified_by_installed_bundle(spec, versions) + + def test_the_basis_of_the_area_routing_is_the_pair_s_own(released): """The binding pair carries no #699 mismatch record; the fallback needs exactly one.""" spec, versions, manifest, write, _ = released diff --git a/tests/test_fill_release_identities.py b/tests/test_fill_release_identities.py index 153c4c79..4248b909 100644 --- a/tests/test_fill_release_identities.py +++ b/tests/test_fill_release_identities.py @@ -4,6 +4,7 @@ from copy import deepcopy import hashlib import json +from pathlib import Path import re import shutil @@ -20,6 +21,16 @@ "merge_commit_sha": "1" * 40, "head": {"sha": "2" * 40}} OLD = "98.0.0" # The fictional carrier the certifying merge's manifest names before the release's version commit. ROUTED = "installed check routes to this cut" +REWRITE = "release manifest is the workflow's rewrite of the merge's" +# Real policyengine.py bundle manifests (a public repository), each at src/policyengine/data/bundle/manifest.json: +# two certifying merges and the releases cut from them. +UPSTREAM = Path(__file__).parent / "fixtures/policyengine-bundle-manifests" +UPSTREAM_SHA256 = { + "pr-704-merge-13192b52.json": "461fe51f720501eda986d09e35d779a96269aeff203d4cda17f611ee357b0a73", # 13192b524 + "release-6.11.0.json": "bccf23acde631aed4665757094c04f86e60993fb9d388e7dc958b90c876e5a80", + "pr-555-merge-37d25c42.json": "e7f94bdd88818764e4112e1b6eb74a98d196683ec29ad0a552d38062e701fe8c", # 37d25c423 + "release-6.2.6.json": "8633ad814c9c85e53c7c513f69c4c7b1500a03cd2f6ef133d6e2ea8346b2db54", +} @pytest.fixture @@ -115,6 +126,15 @@ def test_the_cut_s_own_release_manifest_is_checked(pending, name, change, label) failing(manifest, name, local, json.dumps(regional).encode())) +@pytest.mark.parametrize("name", [BINDING, FALLBACK]) +def test_two_datasets_entries_for_the_local_area_file_refuse(pending, name): + """The script and the installed check both refuse, even with both entries naming the pinned cut.""" + manifest, local, regional_raw = released(name) + entries = manifest["data_releases"]["uk"]["datasets"] + entries["duplicate_dense"] = dict(entries["microcosm_uk_2024_25_dense"]) + assert {"local-area datasets entry", ROUTED} <= set(failing(manifest, name, local, regional_raw)) + + def test_the_binding_pair_refuses_an_accepted_mismatch_block(pending): """Both halves of d2011's pair are on 2.151.0, so #699's mismatch record is not that pair.""" manifest, local, regional_raw = released(BINDING) @@ -168,38 +188,123 @@ def test_identities_come_from_the_exact_bytes_the_merge_and_the_cut(pending): assert fill.identities(raw, manifest, candidate, "BINDING", regional_raw)["CERTIFICATION_COMMIT"] is None +def written(manifest): + """A manifest's bytes as the release workflow writes them (json.dumps, indent 2, sorted keys, final newline).""" + return (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode() + + def as_merged(manifest): - """The manifest as the certifying merge left it, before the release workflow's version commit.""" - def back(value): - if isinstance(value, dict): - return {key: back(item) for key, item in value.items()} - if isinstance(value, list): - return [back(item) for item in value] - return value.replace(VERSION, OLD) if isinstance(value, str) else value - return back(manifest) + """The manifest as the certifying merge left it, before the release workflow's version commit: each field that + commit rewrites names the carrier OLD. Written out field by field, not derived from fill.as_released.""" + merged = deepcopy(manifest) + merged["bundle_version"] = merged["policyengine_version"] = OLD + merged["packages"]["policyengine"].update(version=OLD, install_requirement=f"policyengine=={OLD}") + merged["citation"].update(title=f"PolicyEngine bundle {OLD}", version=OLD) + for country, data_release in merged["data_releases"].items(): + data_release.update(policyengine_version=OLD, bundle_id=f"{country}-{OLD}") + return merged + + +def upstream(name): + raw = (UPSTREAM / name).read_bytes() + assert hashlib.sha256(raw).hexdigest() == UPSTREAM_SHA256[name] + return raw + + +@pytest.mark.parametrize("merge,release,version", [("pr-704-merge-13192b52.json", "release-6.11.0.json", "6.11.0"), + ("pr-555-merge-37d25c42.json", "release-6.2.6.json", "6.2.6")]) +def test_the_replayed_workflow_writes_each_real_release_s_exact_bytes(merge, release, version): + """Differential: the script's replay against what policyengine.py's own release workflow wrote.""" + assert fill.manifest_text(fill.as_released(json.loads(upstream(merge)), version)) == upstream(release) + + +def test_a_real_certifier_whose_manifest_is_not_the_release_s_is_refused(): + """#555 (6.2.6's certifier) is an ancestor of 6.11.0, but 6.11.0's manifest is not its rewrite.""" + merged = json.loads(upstream("pr-555-merge-37d25c42.json")) + assert fill.manifest_text(fill.as_released(merged, "6.11.0")) != upstream("release-6.11.0.json") + + +def test_a_real_release_with_an_old_version_source_uri_is_refused(): + """The review's input: a source URI naming the old carrier in the merge and the new one in the release passed + the earlier string replacement. The workflow never rewrites a source URI, so it must be the merge's exactly.""" + merged = json.loads(upstream("pr-704-merge-13192b52.json")) + release = json.loads(upstream("release-6.11.0.json")) + assert fill.manifest_text(fill.as_released(merged, "6.11.0")) == written(release) + merged["data_releases"]["us"]["source_manifest_uri"] = "hf://dataset/example/data@6.10.2/release_manifest.json" + release["data_releases"]["us"]["source_manifest_uri"] = "hf://dataset/example/data@6.11.0/release_manifest.json" + assert fill.manifest_text(fill.as_released(merged, "6.11.0")) != written(release) + + +def test_the_synthetic_release_is_the_workflow_s_rewrite_of_its_merge(pending): + for name in (BINDING, FALLBACK): + manifest = released(name)[0] + assert manifest["policyengine_version"] == VERSION and as_merged(manifest)["policyengine_version"] == OLD + assert fill.manifest_text(fill.as_released(as_merged(manifest), VERSION)) == written(manifest) + assert fill.manifest_text(fill.as_released(manifest, VERSION)) == written(manifest) + # The workflow derives the whole citation and drops ``source``, whatever the merge wrote there. + merged = as_merged(manifest) + merged["citation"]["doi"], merged["source"] = "10.5281/zenodo.1", {"pull_request": 9999} + assert fill.manifest_text(fill.as_released(merged, VERSION)) == written(manifest) + + +@pytest.mark.parametrize("in_merge,in_release", [ + (lambda m: m["data_releases"]["uk"]["certified_data_artifact"].update(sha256="0" * 64), None), + (lambda m: m["data_releases"]["uk"].update(extra=None), None), + (lambda m: m["data_releases"]["uk"]["region_datasets"].pop("constituency"), None), + (lambda m: m["data_releases"]["uk"]["certification"].update(core_binding=1), None), + (lambda m: m.update(schema_version=str(m["schema_version"])), None), + (lambda m: m["packages"]["policyengine-uk"].update(version=OLD), None), + (lambda m: m["packages"]["policyengine"].update(markers="python_version >= '3.13'"), None), + # Strings naming the carrier outside the rewritten fields: old in the merge, new in the release. + (lambda m: m["data_releases"]["uk"].update(source_manifest_uri=f"hf://dataset/example@{OLD}/manifest.json"), + lambda m: m["data_releases"]["uk"].update(source_manifest_uri=f"hf://dataset/example@{VERSION}/manifest.json")), + (lambda m: m["data_releases"]["us"].update(release_manifest_uri=f"https://example.com/{OLD}/manifest.json"), + lambda m: m["data_releases"]["us"].update(release_manifest_uri=f"https://example.com/{VERSION}/manifest.json")), + # Values Python compares equal whose bytes differ: the merge wrote 1, the release true. + (lambda m: m["data_releases"]["uk"].update(flag=1), lambda m: m["data_releases"]["uk"].update(flag=True)), +]) +def test_the_release_may_differ_from_the_merge_only_in_the_rewritten_fields(pending, in_merge, in_release): + manifest = released(BINDING)[0] + merged = as_merged(manifest) + in_merge(merged) + if in_release: + in_release(manifest) + assert fill.manifest_text(fill.as_released(merged, VERSION)) != written(manifest) -def test_only_the_release_s_version_strings_may_differ_from_the_merge(pending): +def test_the_workflow_derives_every_package_s_install_requirement(pending): + """normalized_manifest rewrites each package's requirement from its name, version and markers.""" manifest = released(BINDING)[0] + markers = "python_version >= '3.13'" + manifest["packages"]["policyengine-us"].update(markers=markers, + install_requirement=f"policyengine-us==2.2.1; {markers}") merged = as_merged(manifest) - assert merged["policyengine_version"] == OLD != manifest["policyengine_version"] - assert fill.same_but_version(merged, manifest, OLD, VERSION) - assert fill.same_but_version(manifest, manifest, VERSION, VERSION) - for change in (lambda m: m["data_releases"]["uk"]["certified_data_artifact"].update(sha256="0" * 64), - lambda m: m["data_releases"]["uk"].update(extra=None), - lambda m: m["data_releases"]["uk"]["region_datasets"].pop("constituency"), - lambda m: m["data_releases"]["uk"]["certification"].update(core_binding=1), - lambda m: m.update(schema_version=str(m["schema_version"]))): - changed = deepcopy(merged) - change(changed) - assert not fill.same_but_version(changed, manifest, OLD, VERSION) + merged["packages"]["policyengine-us"]["install_requirement"] = "policyengine-us" + merged["packages"]["policyengine-uk"]["install_requirement"] = "policyengine-uk>=2" + assert fill.manifest_text(fill.as_released(merged, VERSION)) == written(manifest) + + +@pytest.mark.parametrize("field", ["bundle_version", "policyengine_version", "packages"]) +def test_a_merge_missing_a_field_the_version_commit_needs_is_no_release(pending, field): + """sync_bundle_versions exits 1 without bundle_version, policyengine_version or packages.""" + merged = as_merged(released(BINDING)[0]) + del merged[field] + assert fill.as_released(merged, VERSION) is None + + +@pytest.mark.parametrize("merged", [None, [], {"policyengine_version": "6.2.6", "data_releases": {}}, + {"bundle_version": OLD, "policyengine_version": OLD, "packages": {}}, + {"bundle_version": OLD, "policyengine_version": OLD, + "packages": {"policyengine": {"version": OLD}}}]) +def test_a_merge_manifest_the_workflow_cannot_rewrite_is_no_release(merged): + assert fill.as_released(merged, VERSION) is None def hub(manifest, merged=None, status="ahead", pull_request=MERGED): """gh api, answering only the reads a --release run should make: the manifest at the tag and at the merge commit, the pull request, and the merge commit's comparison with the tag. Any other read fails.""" merged = as_merged(manifest) if merged is None else merged - encode = lambda value: {"content": base64.b64encode(json.dumps(value).encode()).decode()} + encode = lambda value: {"content": base64.b64encode(written(value)).decode()} commit = pull_request["merge_commit_sha"] answers = {f"repos/{fill.REPOSITORY}/contents/{fill.MANIFEST}?ref={VERSION}": encode(manifest), f"repos/{fill.REPOSITORY}/contents/{fill.MANIFEST}?ref={commit}": encode(merged), @@ -214,19 +319,33 @@ def hub(manifest, merged=None, status="ahead", pull_request=MERGED): ({**MERGED, "merged_at": None}, "ahead", None, "certifying pull request merged"), (MERGED, "diverged", None, "merge commit is in the release"), (MERGED, "behind", None, "merge commit is in the release"), - # 6.2.6's certifier (#555) is an ancestor of every later tag, but its manifest is not the release's. - (MERGED, "ahead", {"policyengine_version": "6.2.6", "data_releases": {}}, - "release manifest is the merge's, version strings aside"), + # A certifier that is an ancestor of the tag but whose manifest is not the release's (like 6.2.6's #555). + (MERGED, "ahead", {"policyengine_version": "6.2.6", "data_releases": {}}, REWRITE), ]) def test_the_certifying_pull_request_merged_into_the_tag_with_its_manifest(pending, monkeypatch, pull_request, status, merged, refusal): manifest = released(BINDING)[0] monkeypatch.setattr(fill, "github", hub(manifest, merged, status, pull_request)) - failures = [label for label, pinned, got in fill.certifying_checks(pull_request, VERSION, manifest) + failures = [label for label, pinned, got in fill.certifying_checks(pull_request, VERSION, written(manifest)) if pinned != got] assert failures == ([refusal] if refusal else []) +@pytest.mark.parametrize("merge_value,release_value", [ + (f"hf://dataset/example@{OLD}/manifest.json", f"hf://dataset/example@{VERSION}/manifest.json"), + (1, True), # equal in Python, not in the bytes +]) +def test_the_certifying_check_compares_the_tag_s_bytes(pending, monkeypatch, merge_value, release_value): + manifest = released(BINDING)[0] + merged = as_merged(manifest) + merged["data_releases"]["uk"]["source_manifest_uri"] = merge_value + manifest["data_releases"]["uk"]["source_manifest_uri"] = release_value + monkeypatch.setattr(fill, "github", hub(manifest, merged)) + failures = [label for label, pinned, got in fill.certifying_checks(MERGED, VERSION, written(manifest)) + if pinned != got] + assert failures == [REWRITE] + + def reset(root): """Copy the files the fill writes, with every release identity and the carrier back at their pending values.""" for relative in ("src/triple_lock/datasets.py", "pyproject.toml", "requirements-lock.txt", @@ -387,7 +506,7 @@ def test_main_refuses_a_release_that_is_not_the_certifying_merge(pending, tree, before = files(tree) assert run_release(monkeypatch, tree, manifest, regional_raw, "--apply", merged=merged) == 1 assert files(tree) == before - assert "FAIL release manifest is the merge's" in capsys.readouterr().out + assert f"FAIL {REWRITE}" in capsys.readouterr().out def test_the_binding_pair_refuses_a_local_area_cut_built_on_another_model(pending, tree, monkeypatch, capsys): From 6a3f00eb6dc247b8437507197f33790ff62098b1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 26 Oct 2026 04:59:10 -0700 Subject: [PATCH 09/14] Test the tag's own bytes, non-ASCII text and non-dict data releases Round 3 approved ece7c36 and raised five non-blocking items: 1. as_released's docstring said it returns None wherever the workflow would fail. It returns None only where sync_bundle_versions or normalized_manifest would fail on the merge's structure. The workflow's validation and pre-versioning checks are not replayed; the tag must equal the rewrite in any case. 2. The module docstring and REBUILD.md now also say that source is dropped. 3. Tags equal to the rewrite in content but not in bytes now refuse, both through certifying_checks and through main(). The cases are compact JSON, no final newline, unsorted keys, and unescaped non-ASCII. 4. Non-ASCII text is written escaped, as the workflow writes it, whether the merge's bytes were escaped or UTF-8. 5. Non-dict data_releases entries are left as sync_bundle_versions leaves them. All 15 comparator and uniqueness mutants are caught, including the review's four survivors. Co-Authored-By: Claude Opus 5.5 --- docs/REBUILD.md | 2 +- scripts/fill_release_identities.py | 9 ++-- tests/test_fill_release_identities.py | 73 ++++++++++++++++++++++++--- 3 files changed, 72 insertions(+), 12 deletions(-) diff --git a/docs/REBUILD.md b/docs/REBUILD.md index 6b7829ea..b8e826a4 100644 --- a/docs/REBUILD.md +++ b/docs/REBUILD.md @@ -30,7 +30,7 @@ The revision exists on the Hub with the pinned file (LFS SHA-256 and its 342,348 **Fallback.** d2011's fallback is #715's `microcosm_uk_2024_25@a133f37a` / UK 2.147.2 pair served with [policyengine.py #699](https://github.com/PolicyEngine/policyengine.py/pull/699)'s area routing: a133f37a's national file, with constituency and local-authority runs routed to the 19 October dense local-area cut `microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe` (built on 2.132.6) on an accepted model mismatch. #715's reviewed candidate head `e02d41bceab57756373b2dc3bcf78635b856ec81` routes national only, so it does not certify the fallback by itself. Its local-area cut is pinned now (`datasets.FALLBACK_LOCAL_AREA`: file `microcosm_uk_2024_25_dense.h5`, SHA-256 `8e10e130…`, release manifest SHA-256 `b9dc7cae…`), and the release must carry #699's `regional_certification` for exactly that mismatch (2.147.2 certified, 2.132.6 built, area receipts uncertified) and `regional_source_manifest_sha256`. Its bundle identities stay pending in `datasets.FALLBACK_*`. If d2011 falls back, fill them with `HUGGING_FACE_TOKEN=… .venv/bin/python scripts/fill_release_identities.py --target fallback --pr --release --apply`. Then, in one reviewed commit, move these to the fallback: `config.PRIMARY_DATASET` (the preflight reads the bundle of whichever of `datasets.D2011_PAIRS` it names, here `datasets.BUNDLE_715`); the model pin to `policyengine-uk==2.147.2` and the carrier pin in `pyproject.toml` and `requirements-lock.txt`, with the lock re-resolved as in step 1; CI's install comment; the bridge endpoints and dataset in `data/binding-bridge.pending.json`, and its ready copy; and the d2011 wording in METHOD.md and this runbook. Its [2.125.1-to-2.147.2 audit](d1983-upstream.md) applies. -1. After the pair is released, run `HUGGING_FACE_TOKEN=… .venv/bin/python scripts/fill_release_identities.py --pr --release `. It reads the UK bundle manifest at the release tag (the release workflow's version commit rewrites it after the certifying merge, so the merge commit's bytes are not the release's) and compares every identity above with it, the revision included. It reads the local-area cut's own release manifest at the cut's tag (or `--regional-manifest FILE`): the cut's build must name that tag, have run UK 2.151.0 on core 3.33.3, and publish the file the bundle routes to, with the bundle's `datasets` entry, regional URIs and any `regional_source_manifest_sha256` matching; a `regional_certification` block refuses. It requires the certifying pull request to be merged, its merge commit to be in the tag, and the tag's manifest to be, byte for byte, what the release workflow writes from the manifest at that commit: it rewrites the carrier version fields, each country's bundle id, the install requirements and the citation, and nothing else. It prints the eight `datasets.BINDING_*` values: carrier version, manifest SHA-256, certifying pull request and merge commit, and the local-area cut's file, tag, SHA-256 and release manifest SHA-256. Any difference refuses. With `--apply` (only with `--release`) it checks every file it writes before writing any, then writes those, the carrier version in both dependency files and the ready `data/binding-bridge.json` of step 4. Do not change the model/core/data pins merely to fit a carrier. +1. After the pair is released, run `HUGGING_FACE_TOKEN=… .venv/bin/python scripts/fill_release_identities.py --pr --release `. It reads the UK bundle manifest at the release tag (the release workflow's version commit rewrites it after the certifying merge, so the merge commit's bytes are not the release's) and compares every identity above with it, the revision included. It reads the local-area cut's own release manifest at the cut's tag (or `--regional-manifest FILE`): the cut's build must name that tag, have run UK 2.151.0 on core 3.33.3, and publish the file the bundle routes to, with the bundle's `datasets` entry, regional URIs and any `regional_source_manifest_sha256` matching; a `regional_certification` block refuses. It requires the certifying pull request to be merged, its merge commit to be in the tag, and the tag's manifest to be, byte for byte, what the release workflow writes from the manifest at that commit: it rewrites the carrier version fields, each country's bundle id, the install requirements and the citation, drops `source`, and changes nothing else. It prints the eight `datasets.BINDING_*` values: carrier version, manifest SHA-256, certifying pull request and merge commit, and the local-area cut's file, tag, SHA-256 and release manifest SHA-256. Any difference refuses. With `--apply` (only with `--release`) it checks every file it writes before writing any, then writes those, the carrier version in both dependency files and the ready `data/binding-bridge.json` of step 4. Do not change the model/core/data pins merely to fit a carrier. Then re-resolve the lock for the new carrier, keeping every other pin as a preference: save the four comment lines at the top of `requirements-lock.txt`, run `uv pip compile pyproject.toml --extra uk --extra dev --python-version 3.13 --no-header --no-annotate --upgrade-package policyengine -o requirements-lock.txt`, and put the comment lines back. At the preparatory carrier this reproduces all 86 pins exactly. Name any pin other than `policyengine` that moves in the commit; `test_the_lock_satisfies_the_installed_carrier_s_own_requirements` checks the installed carrier's requirements against the lock. After installing the lock, rerun the script without `--apply`, with `--manifest` on the installed wheel's `policyengine/data/bundle/manifest.json`: it then also checks each filled identity, so the installed bytes must hash to the pinned digest, as the preflight in step 3 requires. With the release installed, `tests/test_datasets.py` and `tests/test_budget_bundle.py` require provenance to certify the filled pair. 2. Keep the [2.147.2-to-2.151.0 source audit](d2011-upstream.md) (on top of the [2.125.1-to-2.147.2 audit](d1983-upstream.md)), `check_upstream()` and the parameter/small-household checks passing. UK 2.151.0 adds #2557: benefit uprating follows the previous September's CPI and `path_following` takes its September branch. It lacks #2576 (earnings-linked guarantee/derived HB and Savings Credit, first 2.151.2), and #2340 remains unmerged. Additional pension still takes the engine's path-specific explicit CPI pin. Age-addition handling takes the absent-variable branch. 3. Run certification and the behavioural HB receipt-passport preflight below before survey jobs. Synthetic checks never certify the fiscal result. diff --git a/scripts/fill_release_identities.py b/scripts/fill_release_identities.py index 2f04bb47..65416a88 100644 --- a/scripts/fill_release_identities.py +++ b/scripts/fill_release_identities.py @@ -18,8 +18,9 @@ With ``--release``, the certifying pull request must be merged, its merge commit must be in the tag, and the tag's manifest must be, byte for byte, what the release workflow writes from the merge commit's: the carrier version -fields, each country's bundle id, the install requirements and the citation are rewritten, and nothing else. The -release is then exactly what the certifying review merged. Any difference exits 1 before anything is written. +fields, each country's bundle id, the install requirements and the citation are rewritten and ``source`` is dropped; +nothing else changes. The release is then exactly what the certifying review merged. Any difference exits 1 before +anything is written. ``--apply`` (with ``--release`` only) writes the target's identities: carrier version, manifest SHA-256, certifying pull request and merge commit, and for the binding target its local-area cut. The binding target also writes the @@ -160,7 +161,9 @@ def as_released(merged, version): """The manifest the release workflow writes for ``version`` from the certifying merge's ``merged``, as at policyengine.py 6.11.0: .github/bump_version.py's sync_bundle_versions (the carrier version fields, each country's bundle id), then scripts/generate_bundle_artifacts.py's normalized_manifest (each package's install requirement, - the citation, no ``source``). Nothing else is rewritten. None where the workflow would fail on ``merged``.""" + the citation, no ``source``). Nothing else is rewritten. None where sync_bundle_versions or normalized_manifest + would fail on ``merged``'s structure; the workflow's validation and pre-versioning checks are not replayed, since + the tag must equal this rewrite in any case.""" if not (isinstance(merged, dict) and {"bundle_version", "policyengine_version", "packages"} <= merged.keys()): return None released = deepcopy(merged) diff --git a/tests/test_fill_release_identities.py b/tests/test_fill_release_identities.py index 4248b909..c77da026 100644 --- a/tests/test_fill_release_identities.py +++ b/tests/test_fill_release_identities.py @@ -235,6 +235,31 @@ def test_a_real_release_with_an_old_version_source_uri_is_refused(): assert fill.manifest_text(fill.as_released(merged, "6.11.0")) != written(release) +NON_ASCII = "caf\u00e9 \u00a3 \u4e2d \U0001f600" + + +def test_non_ascii_text_is_written_escaped_as_the_workflow_writes_it(): + """json.dumps escapes non-ASCII by default, upstream and here, whether the merge's bytes were escaped or UTF-8.""" + release = json.loads(upstream("release-6.11.0.json")) + release["data_releases"]["uk"]["note"] = NON_ASCII + for ensure_ascii in (True, False): + merged = json.loads(upstream("pr-704-merge-13192b52.json")) + merged["data_releases"]["uk"]["note"] = NON_ASCII + merged = json.loads(json.dumps(merged, ensure_ascii=ensure_ascii).encode("utf-8")) + assert fill.manifest_text(fill.as_released(merged, "6.11.0")) == written(release) + assert b"\\u00e9" in written(release) and NON_ASCII.encode("utf-8") not in written(release) + + +@pytest.mark.parametrize("entry", [None, "retired", ["uk"]]) +def test_a_non_dict_data_release_is_left_as_the_workflow_leaves_it(pending, entry): + """sync_bundle_versions rewrites only the dict entries of data_releases.""" + manifest = released(BINDING)[0] + manifest["data_releases"]["xx"] = entry + merged = as_merged({**manifest, "data_releases": {k: v for k, v in manifest["data_releases"].items() if k != "xx"}}) + merged["data_releases"]["xx"] = deepcopy(entry) + assert fill.manifest_text(fill.as_released(merged, VERSION)) == written(manifest) + + def test_the_synthetic_release_is_the_workflow_s_rewrite_of_its_merge(pending): for name in (BINDING, FALLBACK): manifest = released(name)[0] @@ -300,14 +325,15 @@ def test_a_merge_manifest_the_workflow_cannot_rewrite_is_no_release(merged): assert fill.as_released(merged, VERSION) is None -def hub(manifest, merged=None, status="ahead", pull_request=MERGED): - """gh api, answering only the reads a --release run should make: the manifest at the tag and at the merge - commit, the pull request, and the merge commit's comparison with the tag. Any other read fails.""" +def hub(manifest, merged=None, status="ahead", pull_request=MERGED, tag_bytes=written): + """gh api, answering only the reads a --release run should make: the manifest at the tag (as ``tag_bytes`` + writes it) and at the merge commit, the pull request, and the merge commit's comparison with the tag. Any other + read fails.""" merged = as_merged(manifest) if merged is None else merged - encode = lambda value: {"content": base64.b64encode(written(value)).decode()} + encode = lambda raw: {"content": base64.b64encode(raw).decode()} commit = pull_request["merge_commit_sha"] - answers = {f"repos/{fill.REPOSITORY}/contents/{fill.MANIFEST}?ref={VERSION}": encode(manifest), - f"repos/{fill.REPOSITORY}/contents/{fill.MANIFEST}?ref={commit}": encode(merged), + answers = {f"repos/{fill.REPOSITORY}/contents/{fill.MANIFEST}?ref={VERSION}": encode(tag_bytes(manifest)), + f"repos/{fill.REPOSITORY}/contents/{fill.MANIFEST}?ref={commit}": encode(written(merged)), f"repos/{fill.REPOSITORY}/pulls/9999": pull_request, f"repos/{fill.REPOSITORY}/compare/{commit}...{VERSION}": {"status": status}} return answers.__getitem__ @@ -346,6 +372,25 @@ def test_the_certifying_check_compares_the_tag_s_bytes(pending, monkeypatch, mer assert failures == [REWRITE] +# Tags equal to the rewrite in content but not in bytes. +REFORMATTED = [lambda m: json.dumps(m).encode(), lambda m: written(m)[:-1], + lambda m: json.dumps(m, indent=2, sort_keys=False).encode() + b"\n", + lambda m: (json.dumps(m, indent=2, sort_keys=True, ensure_ascii=False) + "\n").encode()] + + +@pytest.mark.parametrize("tag_bytes", REFORMATTED) +def test_the_certifying_check_refuses_a_tag_reformatted_from_the_rewrite(pending, monkeypatch, tag_bytes): + manifest = released(BINDING)[0] + manifest["data_releases"]["uk"]["note"] = NON_ASCII + merged = as_merged(manifest) + assert json.loads(tag_bytes(manifest)) == fill.as_released(merged, VERSION) + assert tag_bytes(manifest) != written(manifest) + monkeypatch.setattr(fill, "github", hub(manifest, merged)) + failures = [label for label, pinned, got in fill.certifying_checks(MERGED, VERSION, tag_bytes(manifest)) + if pinned != got] + assert failures == [REWRITE] + + def reset(root): """Copy the files the fill writes, with every release identity and the carrier back at their pending values.""" for relative in ("src/triple_lock/datasets.py", "pyproject.toml", "requirements-lock.txt", @@ -451,9 +496,9 @@ def test_a_refusal_found_late_still_writes_nothing(pending, tree, break_tree): assert files(tree) == before -def run_release(monkeypatch, tree, manifest, regional_raw, *arguments, merged=None): +def run_release(monkeypatch, tree, manifest, regional_raw, *arguments, merged=None, tag_bytes=written): """main() in --release mode against the fake GitHub and Hub; the Hub read must be at the cut's own tag.""" - monkeypatch.setattr(fill, "github", hub(manifest, merged)) + monkeypatch.setattr(fill, "github", hub(manifest, merged, tag_bytes=tag_bytes)) monkeypatch.setattr(fill, "ROOT", tree) cut_tag = json.loads(regional_raw)["build"]["cut_tag"] monkeypatch.setattr(fill, "hub_file", lambda local: regional_raw if local["revision"] == cut_tag @@ -509,6 +554,18 @@ def test_main_refuses_a_release_that_is_not_the_certifying_merge(pending, tree, assert f"FAIL {REWRITE}" in capsys.readouterr().out +@pytest.mark.parametrize("tag_bytes", REFORMATTED) +def test_main_compares_the_tag_s_own_bytes(pending, tree, monkeypatch, capsys, tag_bytes): + """The tag's bytes, not a re-serialization of them, must be the workflow's.""" + manifest, _, regional_raw = released(BINDING) + manifest["data_releases"]["uk"]["note"] = NON_ASCII + before = files(tree) + assert run_release(monkeypatch, tree, manifest, regional_raw, "--apply", tag_bytes=tag_bytes) == 1 + assert files(tree) == before + assert f"FAIL {REWRITE}" in capsys.readouterr().out + assert run_release(monkeypatch, tree, manifest, regional_raw) == 0 + + def test_the_binding_pair_refuses_a_local_area_cut_built_on_another_model(pending, tree, monkeypatch, capsys): """The review's failing input: a6299504 released with a cut shaped like the 19 October one (built on 2.132.6).""" other = datasets.local_area_cut(INVENTED_CUT["path"], "dense-cut-built-on-2.132.6", "e" * 64, None, "2.132.6", From c48aa2af3d30a96fcb5aa7b39caa2b41e3c823d1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 26 Oct 2026 19:10:23 -0700 Subject: [PATCH 10/14] Fill d2011's identities from policyengine.py 6.11.1 policyengine.py 6.11.1 (tag b14ee554, 27 October 01:30Z) carries d2011's 2.151.0 pair, certified by policyengine.py#720 (merge 610ebc42 of reviewed head 99a3e48b), before d2011's Tuesday 18:00Z deadline, so the fallback is not taken. `scripts/fill_release_identities.py --pr 720 --release 6.11.1 --apply` passed all 32 checks at the tag, including that the tag's manifest is byte for byte the release workflow's rewrite of the merge's (the release workflow files are unchanged from 6.11.0 to 6.11.1), and wrote: - datasets.BINDING_*: carrier 6.11.1, manifest SHA-256 ab60903b..., #720 and 610ebc42, and the local-area cut c9df4b86 (microcosm_uk_2024_25_dense.h5, cb3712f2..., release manifest 30be4754...); - policyengine==6.11.1 in pyproject.toml and the lock; - data/binding-bridge.json, the ready copy of the pending bridge config. The lock re-resolve (`uv pip compile ... --upgrade-package policyengine`) moved only policyengine, 6.10.1 to 6.11.1. Installed in a fresh .venv: - the fill rerun on the installed wheel's manifest passed all 37 checks; - check_upstream, both preflights and the model step's --validate-only passed; - the nine focused files gave 458 passed; - the full suite gave 2297 passed, 5 skipped and the 3 documented stale-results failures, which CI's gate accepts for a PR into main. The pending-release wording now describes the release, in: - the lock's first comment and CI's install comment; - datasets.py's docstring, the preparatory-carrier comment and the UNCERTIFIED reason (tests accept any reason starting "uncertified"); - README, which still named the superseded d1727 release; - METHOD.md, outside the frozen C2 section, whose bytes are unchanged; - REBUILD.md. The fallback's identities stay pending. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/python.yml | 4 +-- README.md | 2 +- data/binding-bridge.json | 67 ++++++++++++++++++++++++++++++++++++ docs/METHOD.md | 8 ++--- docs/REBUILD.md | 22 ++++++------ pyproject.toml | 2 +- requirements-lock.txt | 4 +-- src/triple_lock/datasets.py | 36 ++++++++++--------- 8 files changed, 107 insertions(+), 38 deletions(-) create mode 100644 data/binding-bridge.json diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index d6aff791..de5b4697 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -25,8 +25,8 @@ jobs: python-version: "3.13" activate-environment: true - name: Install - # d2011's UK 2.151.0/core 3.33.3 and the carrier the lock pins (preparatory while its - # release is pending; tests then require provenance to certify the filled pair). Retained + # d2011's UK 2.151.0/core 3.33.3 and the carrier the lock pins (policyengine.py 6.11.1, + # the pair's release; tests require provenance to certify the filled pair). Retained # historical pilot receipts keep their original model/data labels; # no test needs the private Enhanced FRS file. run: | diff --git a/README.md b/README.md index cb047918..734edfe5 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ This is a **provisional, model-conditional** release ahead of the 28 October 202 Headline, 2039-40 (Enhanced FRS, cash terms, UK): a **provisional, model-conditional expected saving of £11.1bn gross and £7.9bn net of tax and benefits**, the probability-weighted average over 50,000 simulated paths of prices and earnings centred on the OBR's forecast, estimated from 160 full runs (Monte Carlo error about ±£0.1bn). -**Data.** The published figures use the certified bundle, **policyengine.py 6.2.6** (policyengine-uk 2.125.1, policyengine-core 3.33.0) with **Enhanced FRS 1.58.0**. The same 40 paths are also run on **Microcosm's UK release** (`microcosm_uk_2024_25`, tag `microcosm-uk-2024-25-national-20261002T230158Z-5c6b3f68`, uncertified under this bundle) as a paired cross-check: Microcosm minus Enhanced FRS is +£1.12bn gross and +£0.49bn net in 2039-40. The cross-check is in `data/results.json` but not on the dashboard. This is the historical pre-Budget bundle. The binding rebuild will use the **pending d1727 release**, certifying policyengine-uk main around 26 October and **Enhanced FRS 1.58.2**, with separate model and data bridge steps before coverage and the fiscal run; see [REBUILD.md](docs/REBUILD.md). +**Data.** The published figures use the certified bundle, **policyengine.py 6.2.6** (policyengine-uk 2.125.1, policyengine-core 3.33.0) with **Enhanced FRS 1.58.0**. The same 40 paths are also run on **Microcosm's UK release** (`microcosm_uk_2024_25`, tag `microcosm-uk-2024-25-national-20261002T230158Z-5c6b3f68`, uncertified under this bundle) as a paired cross-check: Microcosm minus Enhanced FRS is +£1.12bn gross and +£0.49bn net in 2039-40. The cross-check is in `data/results.json` but not on the dashboard. This is the historical pre-Budget bundle. The binding rebuild will use d2011's pair, released in **policyengine.py 6.11.1** (policyengine-uk 2.151.0, policyengine-core 3.33.3): Microcosm's national cut `microcosm-uk-2024-25-national-20261025T091414Z-a6299504` as primary, with its local-area cut for constituency and local-authority runs, and **Enhanced FRS 1.58.2** as the paired sensitivity. Separate model and data bridge steps run before coverage and the fiscal run; see [REBUILD.md](docs/REBUILD.md). ## What the dashboard shows diff --git a/data/binding-bridge.json b/data/binding-bridge.json new file mode 100644 index 00000000..38de8681 --- /dev/null +++ b/data/binding-bridge.json @@ -0,0 +1,67 @@ +{ + "schema_version": 1, + "status": "ready", + "decision": "d2011 = A, Max 2026-10-25: the certified 2.151.0 Microcosm UK Budget pair (microcosm#1396) replaces #715's a133f37a pair; d1983 = B, Max 2026-10-24, put the binding primary on Microcosm", + "baseline_receipt": { + "path": "data/pilot/J-bridge-certified.json", + "sha256": "f5f42f5a6e20eb251dd26a283b840fdb825843e8edc9078c02fea78f14b6ac2f" + }, + "steps": { + "model": { + "from": { + "packages": { + "policyengine": "6.2.6", + "policyengine-uk": "2.125.1", + "policyengine-core": "3.33.0" + }, + "dataset": { + "name": "enhanced_frs_2024_25@1.58.0", + "sha256": "5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6" + } + }, + "to": { + "packages": { + "policyengine": "6.11.1", + "policyengine-uk": "2.151.0", + "policyengine-core": "3.33.3" + }, + "dataset": { + "name": "enhanced_frs_2024_25@1.58.0", + "sha256": "5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6" + } + } + }, + "data": { + "from": { + "packages": { + "policyengine": "6.11.1", + "policyengine-uk": "2.151.0", + "policyengine-core": "3.33.3" + }, + "dataset": { + "name": "enhanced_frs_2024_25@1.58.0", + "sha256": "5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6" + } + }, + "to": { + "packages": { + "policyengine": "6.11.1", + "policyengine-uk": "2.151.0", + "policyengine-core": "3.33.3" + }, + "dataset": { + "name": "microcosm_uk_2024_25@a6299504", + "sha256": "b96a8cd625b645a409c063b1de5158532708a2f0d3c3897a794a8fb0bbd5064e" + } + } + } + }, + "pending_release": { + "pull_request": "https://github.com/PolicyEngine/policyengine.py/pull/720", + "reviewed_head": "99a3e48b405e513a98850c64ee3564f0ca8e7553", + "bundle_manifest_path": "src/policyengine/data/bundle/manifest.json", + "policyengine_version": "6.11.1", + "bundle_manifest_sha256": "ab60903b390e7a765ea6558ec5e2d8fa5aede06b6f735fbf4e4c96109ffd1617", + "gate": "d2011 = A: the 2.151.0 national (microcosm#1396, a6299504) and its local-area cut certified together; fallback to #715's a133f37a pair with policyengine.py #699's area routing if not certified by 2026-10-27T18:00Z" + } +} diff --git a/docs/METHOD.md b/docs/METHOD.md index 1db16bdb..f442e196 100644 --- a/docs/METHOD.md +++ b/docs/METHOD.md @@ -2,7 +2,7 @@ The committed `data/results.json` is the **certified pre-Budget release**, provisional and model-conditional under uncertainty ruling (c). Every fiscal figure is a full PolicyEngine UK run on policyengine.py **6.2.6**, UK **2.125.1**, core **3.33.0** and Enhanced FRS **1.58.0**, with static ageing below. That release, publicized in Vahid's post as provisional, uses Enhanced FRS as primary and the earlier Microcosm artifact as its uncertified paired sensitivity. The original uncertified model-v2 pilot (UK 2.120.0 / EFRS 1.56.16) is retained separately in `data/pilot`; model-v2 is merged. The completed historical bridge and rehearsal are bound by [J-completed-verification](../data/pilot/J-completed-verification.json). -Max's **d1983 = B** ruling on 24 October put the binding successor's primary on the certified Microcosm UK Budget pair, superseding d1727's qualification-ledger primary. His **d2011 = A** ruling on 25 October chose that pair: the 2.151.0 national and local-area pair built at [microcosm#1396](https://github.com/PolicyEngine/microcosm/pull/1396), national cut `microcosm-uk-2024-25-national-20261025T091414Z-a6299504`, UK **2.151.0**, core **3.33.3**. If it is not certified by 27 October 18:00Z, the fallback is [policyengine.py #715](https://github.com/PolicyEngine/policyengine.py/pull/715)'s `a133f37a` / UK 2.147.2 pair with [#699](https://github.com/PolicyEngine/policyengine.py/pull/699)'s area routing. Enhanced FRS **1.58.2** is the paired sensitivity. The carrier release is pending, and certification fails closed until its exact released manifest is pinned. [REBUILD.md](REBUILD.md) gives the fixed-data model bridge on Enhanced FRS 1.58.0 and the cross-family data bridge to Microcosm. The pre-Budget and binding releases use different data; bridge step (b) measures that move. Historical pre-Budget provenance stays at 6.2.6 / 1.58.0. Nothing is scaled or interpolated from another run. The code is the reference. +Max's **d1983 = B** ruling on 24 October put the binding successor's primary on the certified Microcosm UK Budget pair, superseding d1727's qualification-ledger primary. His **d2011 = A** ruling on 25 October chose that pair: the 2.151.0 national and local-area pair built at [microcosm#1396](https://github.com/PolicyEngine/microcosm/pull/1396), national cut `microcosm-uk-2024-25-national-20261025T091414Z-a6299504`, UK **2.151.0**, core **3.33.3**. If it is not certified by 27 October 18:00Z, the fallback is [policyengine.py #715](https://github.com/PolicyEngine/policyengine.py/pull/715)'s `a133f37a` / UK 2.147.2 pair with [#699](https://github.com/PolicyEngine/policyengine.py/pull/699)'s area routing. Enhanced FRS **1.58.2** is the paired sensitivity. policyengine.py 6.11.1 released the pair on 27 October, certified by [policyengine.py #720](https://github.com/PolicyEngine/policyengine.py/pull/720), so the fallback was not taken; certification requires that release's exact manifest, which is pinned. [REBUILD.md](REBUILD.md) gives the fixed-data model bridge on Enhanced FRS 1.58.0 and the cross-family data bridge to Microcosm. The pre-Budget and binding releases use different data; bridge step (b) measures that move. Historical pre-Budget provenance stays at 6.2.6 / 1.58.0. Nothing is scaled or interpolated from another run. The code is the reference. ## The rules (`rules.py`) @@ -85,7 +85,7 @@ The revised [Pensions Act 2014 s.4](https://www.legislation.gov.uk/ukpga/2014/19 **Private cache.** The population (represented ages, birthdays, the weights and the State Pension age mask and types of every year) is computed once per dataset, treatment, anchor, set of years and model (`demography.eligibility_fingerprint`: the installed policyengine-uk and the files its State Pension age and type come from) and cached under `.cache/demography`, owner-only; every path job shares one entry, and the coverage and history jobs, with other years, have their own. A run reads the model's own `is_SP_age` back against the cached mask in every year and fails if they differ, so a change to the model's State Pension age cannot reuse stale types. Nothing that depends on a path is cached: the additional pension follows each path's September CPI. No record id, weight or amount leaves the cache; an ageing run returns no single-record diagnostic, and a failed path, coverage or history job keeps its error output in an owner-only file in an owner-only worker directory instead of the build log (`jobs.private_inputs`). -**Programme limits.** The new State Pension has no age addition, but the basic State Pension has one, 25p a week at 80 (SSCBA 1992 s.79 and Sch 4 Pt III para 8), which the historical UK 2.125.1 bundle, the binding UK 2.151.0 candidate and the 2.147.2 fallback do not model ([policyengine-uk#2139](https://github.com/PolicyEngine/policyengine-uk/issues/2139)): in that bundle a reported one is in the additional pension. The version-aware handling above applies when a later bundle includes #2340. UK 2.151.0's Winter Fuel Payment and Scotland's Pension Age Winter Heating Payment (`pawhp`, in `other_spending`) use the shared winter-heating amount helper, unchanged from 2.147.2, with jurisdiction-specific eligibility and amounts. The Scotland formula distinguishes qualifying-benefit couples from non-benefit households and their shared amounts; the old £100-per-household limitation does not describe this wheel. On 2.151.0 the payment's amounts rise each April by `gov.benefit_uprating_cpi`, rounded to the nearest 5p (#2575). Later retirees' amounts come from today's records of the same age; deaths, migration and contribution histories are not simulated, and pensioners abroad are outside the survey. A dynamic projection is the longer-term route (PolicyEngine/microcosm-dynamics#501). +**Programme limits.** The new State Pension has no age addition, but the basic State Pension has one, 25p a week at 80 (SSCBA 1992 s.79 and Sch 4 Pt III para 8), which the historical UK 2.125.1 bundle, the binding UK 2.151.0 model and the 2.147.2 fallback do not model ([policyengine-uk#2139](https://github.com/PolicyEngine/policyengine-uk/issues/2139)): in that bundle a reported one is in the additional pension. The version-aware handling above applies when a later bundle includes #2340. UK 2.151.0's Winter Fuel Payment and Scotland's Pension Age Winter Heating Payment (`pawhp`, in `other_spending`) use the shared winter-heating amount helper, unchanged from 2.147.2, with jurisdiction-specific eligibility and amounts. The Scotland formula distinguishes qualifying-benefit couples from non-benefit households and their shared amounts; the old £100-per-household limitation does not describe this wheel. On 2.151.0 the payment's amounts rise each April by `gov.benefit_uprating_cpi`, rounded to the nearest 5p (#2575). Later retirees' amounts come from today's records of the same age; deaths, migration and contribution histories are not simulated, and pensioners abroad are outside the survey. A dynamic projection is the longer-term route (PolicyEngine/microcosm-dynamics#501). **Outputs of each run, by year:** - gross saving (basic and new State Pension spending) and net saving (change in `gov_balance`), with the components, for the UK and for Great Britain (households in England, Scotland and Wales, as DWP's figures are; neither dataset has a household of unknown country); @@ -177,9 +177,9 @@ The population here is the path runs': static ageing, ONS growth after the datas ## The certified model and the historical pilot (`datasets.py`) -For the binding rebuild, the primary registry entry is `microcosm_uk_2024_25@a6299504`. It pins `policyengine/populace-uk-private`, expected revision `microcosm-uk-2024-25-national-20261025T091414Z-a6299504`, path `microcosm_uk_2024_25.h5`, SHA-256 `b96a8cd625b645a409c063b1de5158532708a2f0d3c3897a794a8fb0bbd5064e`: the bytes of the staged attempt `uk-frs-calibration-attempt-20261025T091414Z-a6299504`, built at microcosm `67ee2ddf3`. That revision exists on the Hub with those bytes (checked on 26 October), and materialization verifies them before every use. The model wheel is UK 2.151.0, SHA-256 `5722f55aca3eb88e1a228e95e1a57e3bd70a4d530a5a000991565ebd8955ce35` (microcosm#1396's `uv.lock`; attested in `data/certification/d2011-model-wheel.json`), with core 3.33.3. The pair's released carrier also routes constituency and local-authority runs to its local-area cut, and the registry requires exactly that cut: its file, tag and SHA-256 in the bundle's `datasets` entry, its release manifest's URIs (and digest, where the bundle records one), and no `regional_certification`, since both halves are on 2.151.0. The certificate must cover core 3.33.3 by a rule stricter than policyengine.py's: under `core_binding` `certified` the named core, which must equal the bundle's core pin; under `bundle_pin` the pin, which a named core must equal. The released carrier version, manifest digest, certifying pull request and commit, and the local-area cut remain unset, so `datasets.provenance` and the publication preflight refuse certification now; a released-manifest certificate also needs its certifying pull request and commit. #715's `microcosm_uk_2024_25@a133f37a` / UK 2.147.2 entry stays registered as d2011's fallback. #715's candidate routes national only, but d2011's fallback adds #699's area routing to the 19 October dense cut (`ee5c2dbe`, built on 2.132.6), so the entry pins that cut and requires #699's record of the accepted mismatch; its bundle identities stay pending. +For the binding rebuild, the primary registry entry is `microcosm_uk_2024_25@a6299504`. It pins `policyengine/populace-uk-private`, expected revision `microcosm-uk-2024-25-national-20261025T091414Z-a6299504`, path `microcosm_uk_2024_25.h5`, SHA-256 `b96a8cd625b645a409c063b1de5158532708a2f0d3c3897a794a8fb0bbd5064e`: the bytes of the staged attempt `uk-frs-calibration-attempt-20261025T091414Z-a6299504`, built at microcosm `67ee2ddf3`. That revision exists on the Hub with those bytes (checked on 26 October), and materialization verifies them before every use. The model wheel is UK 2.151.0, SHA-256 `5722f55aca3eb88e1a228e95e1a57e3bd70a4d530a5a000991565ebd8955ce35` (microcosm#1396's `uv.lock`; attested in `data/certification/d2011-model-wheel.json`), with core 3.33.3. The pair's released carrier also routes constituency and local-authority runs to its local-area cut, and the registry requires exactly that cut: its file, tag and SHA-256 in the bundle's `datasets` entry, its release manifest's URIs (and digest, where the bundle records one), and no `regional_certification`, since both halves are on 2.151.0. The certificate must cover core 3.33.3 by a rule stricter than policyengine.py's: under `core_binding` `certified` the named core, which must equal the bundle's core pin; under `bundle_pin` the pin, which a named core must equal. The released carrier is policyengine.py 6.11.1: its manifest SHA-256 `ab60903b…`, certifying pull request #720 and merge commit `610ebc42…`, and the local-area cut `microcosm-uk-2024-25-dense-20261026T105309Z-c9df4b86` (file `microcosm_uk_2024_25_dense.h5`, SHA-256 `cb3712f2…`, release manifest SHA-256 `30be4754…`) are pinned in `datasets.BINDING_*`, and `datasets.provenance` and the publication preflight certify only that installed release; a released-manifest certificate also needs its certifying pull request and commit. #715's `microcosm_uk_2024_25@a133f37a` / UK 2.147.2 entry stays registered as d2011's fallback. #715's candidate routes national only, but d2011's fallback adds #699's area routing to the 19 October dense cut (`ee5c2dbe`, built on 2.132.6), so the entry pins that cut and requires #699's record of the accepted mismatch; its bundle identities stay pending. -While the release is pending, the lock installs base `policyengine==6.10.1` as the available API carrier (`datasets.PREPARATORY_CARRIER`), plus exact `policyengine-uk==2.151.0` and `policyengine-core==3.33.3`; it deliberately omits `[uk]`, because that carrier's extra pins its previous UK line. The engine constructs `policyengine_uk` directly and materializes its own registry-pinned dataset rather than asking that extra to select the data. This is a preparatory environment, not released-pair certification. Once the pair ships, `scripts/fill_release_identities.py` fills only the authenticated identities left unset in `datasets.py`, and the carrier version in the lock and ready bridge config; the lock is then re-resolved for that carrier ([REBUILD.md](REBUILD.md)). +The lock installs base `policyengine==6.11.1`, the released carrier, plus exact `policyengine-uk==2.151.0` and `policyengine-core==3.33.3`, without `[uk]`. While the release was pending it installed `policyengine==6.10.1` (`datasets.PREPARATORY_CARRIER`), whose extra pins its previous UK line; 6.11.1's extra pins the pair's model and core, and the lock still omits it. The engine constructs `policyengine_uk` directly and materializes its own registry-pinned dataset rather than asking that extra to select the data. `scripts/fill_release_identities.py` filled only the authenticated identities left unset in `datasets.py`, and the carrier version in the lock and ready bridge config, from the 6.11.1 tag; the lock was then re-resolved for that carrier, moving only `policyengine` ([REBUILD.md](REBUILD.md)). The paired sensitivity is Enhanced FRS 1.58.2: d1727's salary-sacrifice/uprating-table fixes and preserved household ids through cloning. Enhanced FRS 1.58.0 remains for bridge step (a). The pre-Budget release's `policyengine[uk]==6.2.6` / UK 2.125.1 / core 3.33.0 / Enhanced FRS 1.58.0 identity and historical receipt bytes remain as published; [J-certification](pilot/J-certification.md) retains their hashes. The [2.125.1-to-2.147.2 source audit](d1983-upstream.md) records the reviewed upstream guards. #2019, #2507–2509, #1913, #2336, #2458, #1831, #2518 and #2516 are present; #2557 and #2576 are absent, and #2340 remains unmerged. The behavioural HB passport preflight remains mandatory. #36's separate [2.153.4 audit](d1727-upstream.md) and fail-closed qualification-ledger implementation remain history; they do not authorize this primary. The remainder of this section records the pilot's version history, rather than the current bundle. diff --git a/docs/REBUILD.md b/docs/REBUILD.md index b8e826a4..b84fde22 100644 --- a/docs/REBUILD.md +++ b/docs/REBUILD.md @@ -6,11 +6,11 @@ The pre-Budget release is committed on **main**: policyengine.py 6.2.6 / UK 2.12 | Gate | What it holds back | Until then | |---|---|---| -| **d2011 = A: the 2.151.0 pair (microcosm#1396)** | The exact released carrier manifest for national Microcosm cut `microcosm-uk-2024-25-national-20261025T091414Z-a6299504` and its local-area cut, UK 2.151.0 / core 3.33.3 | The release is expected around 23:00Z on Monday 26 October. If the pair is not certified by Tuesday 27 October 18:00Z, d2011 falls back to #715's `a133f37a` / UK 2.147.2 pair with policyengine.py #699's area routing. Preflight fails closed while the carrier version, released-manifest digest, certifying pull request and commit, and local-area cut are unset. | +| **d2011 = A: the 2.151.0 pair (microcosm#1396)** | The exact released carrier manifest for national Microcosm cut `microcosm-uk-2024-25-national-20261025T091414Z-a6299504` and its local-area cut, UK 2.151.0 / core 3.33.3 | Released: policyengine.py **6.11.1** (tag `b14ee554`, 27 October 01:30Z), certified by [#720](https://github.com/PolicyEngine/policyengine.py/pull/720) (merge `610ebc42` of reviewed head `99a3e48b`), before d2011's Tuesday 27 October 18:00Z deadline, so its fallback to #715's `a133f37a` / UK 2.147.2 pair was not taken. The eight `datasets.BINDING_*` identities are filled from the tag; preflight certifies only that exact release. | | **Binding inputs**: AWE on 20 October, September CPI on 21 October, OBR forecast on 28 October | Commit official source bytes and release identities before a fresh binding C2 handoff | A pre-Budget/dry-run file never authorizes the binding build. | | **d955 = (c)** | Only a passing original primary may get a model-conditional expected value, beside the scenario envelope | C2's frozen rule remains unchanged; primary failure automatically takes (a), scenarios only. No alternative form is promoted. | -## Pending d2011 bundle and two-step bridge +## d2011 bundle and two-step bridge Max ruled **d2011 = A** on 25 October: the binding primary is the certified 2.151.0 national and local-area pair built at [microcosm#1396](https://github.com/PolicyEngine/microcosm/pull/1396) (`67ee2ddf3`). d1983 = B's reason (Microcosm, clean data) holds for it. Its identities, known before the release: @@ -23,14 +23,14 @@ Max ruled **d2011 = A** on 25 October: the binding primary is the certified 2.15 | Dataset SHA-256 | `b96a8cd625b645a409c063b1de5158532708a2f0d3c3897a794a8fb0bbd5064e`, the staged attempt `uk-frs-calibration-attempt-20261025T091414Z-a6299504` | | Data release manifest | `releases/microcosm-uk-2024-25-national/release_manifest.json` at that revision (`datasets.MICROCOSM_RELEASE_MANIFEST`) | | Region routing | national to the national file; constituency and local authority to the local-area cut's file, with the bundle's `regional_*` fields naming that cut's release manifest and no `regional_certification` (both halves are on 2.151.0) | -| Unset until the release | carrier version and manifest digest, certifying pull request and merge commit, and the 2.151.0 local-area cut: file, tag, SHA-256 and release manifest SHA-256 (`datasets.BINDING_*`) | +| Filled from the 6.11.1 release (`datasets.BINDING_*`) | carrier `6.11.1`, manifest SHA-256 `ab60903b390e7a765ea6558ec5e2d8fa5aede06b6f735fbf4e4c96109ffd1617`, certifying pull request #720 and merge commit `610ebc42586b7358c41afe4fabec7c67adf17205`, and the 2.151.0 local-area cut: file `microcosm_uk_2024_25_dense.h5`, tag `microcosm-uk-2024-25-dense-20261026T105309Z-c9df4b86`, SHA-256 `cb3712f25b76109ad27b7b0cdf5dee78c55d3b7f55ce7bf207ef1a191da2ae9a` and release manifest SHA-256 `30be47545efd2aa320e30f8d04240cf345aec105fa1d788334770bc4e17c583e` | | Core coverage | the certificate's core by policyengine.py's rule (`datasets.certified_core`), made stricter: the named `certified_for_core_version` under `core_binding` `certified`, which must equal the bundle's core pin; else the pin (`bundle_pin`, 6.11.0's default), which a named core must equal | -The revision exists on the Hub with the pinned file (LFS SHA-256 and its 342,348,841 bytes checked on 26 October); materialization still verifies the bytes before every use, and the release must name the same artifact. The preparatory lock uses the base carrier without `[uk]`, plus explicit UK 2.151.0/core 3.33.3. The engine constructs `policyengine_uk` directly and loads its own verified artifact. Publication preflight remains closed until the exact release is authenticated. d1727's Enhanced FRS qualification-ledger route is superseded as the primary; its fail-closed code, [status receipt](../data/certification/d1727-qualification-status.json) and [2.153.4 audit](d1727-upstream.md) stay as history. +The revision exists on the Hub with the pinned file (LFS SHA-256 and its 342,348,841 bytes checked on 26 October); materialization still verifies the bytes before every use, and the release must name the same artifact. The lock uses the released base carrier `policyengine==6.11.1` without `[uk]`, plus explicit UK 2.151.0/core 3.33.3. The engine constructs `policyengine_uk` directly and loads its own verified artifact. Publication preflight certifies only the exact authenticated release. d1727's Enhanced FRS qualification-ledger route is superseded as the primary; its fail-closed code, [status receipt](../data/certification/d1727-qualification-status.json) and [2.153.4 audit](d1727-upstream.md) stay as history. -**Fallback.** d2011's fallback is #715's `microcosm_uk_2024_25@a133f37a` / UK 2.147.2 pair served with [policyengine.py #699](https://github.com/PolicyEngine/policyengine.py/pull/699)'s area routing: a133f37a's national file, with constituency and local-authority runs routed to the 19 October dense local-area cut `microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe` (built on 2.132.6) on an accepted model mismatch. #715's reviewed candidate head `e02d41bceab57756373b2dc3bcf78635b856ec81` routes national only, so it does not certify the fallback by itself. Its local-area cut is pinned now (`datasets.FALLBACK_LOCAL_AREA`: file `microcosm_uk_2024_25_dense.h5`, SHA-256 `8e10e130…`, release manifest SHA-256 `b9dc7cae…`), and the release must carry #699's `regional_certification` for exactly that mismatch (2.147.2 certified, 2.132.6 built, area receipts uncertified) and `regional_source_manifest_sha256`. Its bundle identities stay pending in `datasets.FALLBACK_*`. If d2011 falls back, fill them with `HUGGING_FACE_TOKEN=… .venv/bin/python scripts/fill_release_identities.py --target fallback --pr --release --apply`. Then, in one reviewed commit, move these to the fallback: `config.PRIMARY_DATASET` (the preflight reads the bundle of whichever of `datasets.D2011_PAIRS` it names, here `datasets.BUNDLE_715`); the model pin to `policyengine-uk==2.147.2` and the carrier pin in `pyproject.toml` and `requirements-lock.txt`, with the lock re-resolved as in step 1; CI's install comment; the bridge endpoints and dataset in `data/binding-bridge.pending.json`, and its ready copy; and the d2011 wording in METHOD.md and this runbook. Its [2.125.1-to-2.147.2 audit](d1983-upstream.md) applies. +**Fallback (not taken).** The pair shipped in 6.11.1 before d2011's deadline, so this fallback was not used; it stays documented, and its identities stay pending. d2011's fallback was #715's `microcosm_uk_2024_25@a133f37a` / UK 2.147.2 pair served with [policyengine.py #699](https://github.com/PolicyEngine/policyengine.py/pull/699)'s area routing: a133f37a's national file, with constituency and local-authority runs routed to the 19 October dense local-area cut `microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe` (built on 2.132.6) on an accepted model mismatch. #715's reviewed candidate head `e02d41bceab57756373b2dc3bcf78635b856ec81` routes national only, so it does not certify the fallback by itself. Its local-area cut is pinned now (`datasets.FALLBACK_LOCAL_AREA`: file `microcosm_uk_2024_25_dense.h5`, SHA-256 `8e10e130…`, release manifest SHA-256 `b9dc7cae…`), and the release must carry #699's `regional_certification` for exactly that mismatch (2.147.2 certified, 2.132.6 built, area receipts uncertified) and `regional_source_manifest_sha256`. Its bundle identities stay pending in `datasets.FALLBACK_*`. If d2011 falls back, fill them with `HUGGING_FACE_TOKEN=… .venv/bin/python scripts/fill_release_identities.py --target fallback --pr --release --apply`. Then, in one reviewed commit, move these to the fallback: `config.PRIMARY_DATASET` (the preflight reads the bundle of whichever of `datasets.D2011_PAIRS` it names, here `datasets.BUNDLE_715`); the model pin to `policyengine-uk==2.147.2` and the carrier pin in `pyproject.toml` and `requirements-lock.txt`, with the lock re-resolved as in step 1; CI's install comment; the bridge endpoints and dataset in `data/binding-bridge.pending.json`, and its ready copy; and the d2011 wording in METHOD.md and this runbook. Its [2.125.1-to-2.147.2 audit](d1983-upstream.md) applies. -1. After the pair is released, run `HUGGING_FACE_TOKEN=… .venv/bin/python scripts/fill_release_identities.py --pr --release `. It reads the UK bundle manifest at the release tag (the release workflow's version commit rewrites it after the certifying merge, so the merge commit's bytes are not the release's) and compares every identity above with it, the revision included. It reads the local-area cut's own release manifest at the cut's tag (or `--regional-manifest FILE`): the cut's build must name that tag, have run UK 2.151.0 on core 3.33.3, and publish the file the bundle routes to, with the bundle's `datasets` entry, regional URIs and any `regional_source_manifest_sha256` matching; a `regional_certification` block refuses. It requires the certifying pull request to be merged, its merge commit to be in the tag, and the tag's manifest to be, byte for byte, what the release workflow writes from the manifest at that commit: it rewrites the carrier version fields, each country's bundle id, the install requirements and the citation, drops `source`, and changes nothing else. It prints the eight `datasets.BINDING_*` values: carrier version, manifest SHA-256, certifying pull request and merge commit, and the local-area cut's file, tag, SHA-256 and release manifest SHA-256. Any difference refuses. With `--apply` (only with `--release`) it checks every file it writes before writing any, then writes those, the carrier version in both dependency files and the ready `data/binding-bridge.json` of step 4. Do not change the model/core/data pins merely to fit a carrier. +1. Done for 6.11.1 on 27 October (`--pr 720 --release 6.11.1`): all 32 checks passed at the tag, and all 37 on the installed wheel. After the pair is released, run `HUGGING_FACE_TOKEN=… .venv/bin/python scripts/fill_release_identities.py --pr --release `. It reads the UK bundle manifest at the release tag (the release workflow's version commit rewrites it after the certifying merge, so the merge commit's bytes are not the release's) and compares every identity above with it, the revision included. It reads the local-area cut's own release manifest at the cut's tag (or `--regional-manifest FILE`): the cut's build must name that tag, have run UK 2.151.0 on core 3.33.3, and publish the file the bundle routes to, with the bundle's `datasets` entry, regional URIs and any `regional_source_manifest_sha256` matching; a `regional_certification` block refuses. It requires the certifying pull request to be merged, its merge commit to be in the tag, and the tag's manifest to be, byte for byte, what the release workflow writes from the manifest at that commit: it rewrites the carrier version fields, each country's bundle id, the install requirements and the citation, drops `source`, and changes nothing else. It prints the eight `datasets.BINDING_*` values: carrier version, manifest SHA-256, certifying pull request and merge commit, and the local-area cut's file, tag, SHA-256 and release manifest SHA-256. Any difference refuses. With `--apply` (only with `--release`) it checks every file it writes before writing any, then writes those, the carrier version in both dependency files and the ready `data/binding-bridge.json` of step 4. Do not change the model/core/data pins merely to fit a carrier. Then re-resolve the lock for the new carrier, keeping every other pin as a preference: save the four comment lines at the top of `requirements-lock.txt`, run `uv pip compile pyproject.toml --extra uk --extra dev --python-version 3.13 --no-header --no-annotate --upgrade-package policyengine -o requirements-lock.txt`, and put the comment lines back. At the preparatory carrier this reproduces all 86 pins exactly. Name any pin other than `policyengine` that moves in the commit; `test_the_lock_satisfies_the_installed_carrier_s_own_requirements` checks the installed carrier's requirements against the lock. After installing the lock, rerun the script without `--apply`, with `--manifest` on the installed wheel's `policyengine/data/bundle/manifest.json`: it then also checks each filled identity, so the installed bytes must hash to the pinned digest, as the preflight in step 3 requires. With the release installed, `tests/test_datasets.py` and `tests/test_budget_bundle.py` require provenance to certify the filled pair. 2. Keep the [2.147.2-to-2.151.0 source audit](d2011-upstream.md) (on top of the [2.125.1-to-2.147.2 audit](d1983-upstream.md)), `check_upstream()` and the parameter/small-household checks passing. UK 2.151.0 adds #2557: benefit uprating follows the previous September's CPI and `path_following` takes its September branch. It lacks #2576 (earnings-linked guarantee/derived HB and Savings Credit, first 2.151.2), and #2340 remains unmerged. Additional pension still takes the engine's path-specific explicit CPI pin. Age-addition handling takes the absent-variable branch. 3. Run certification and the behavioural HB receipt-passport preflight below before survey jobs. Synthetic checks never certify the fiscal result. @@ -129,18 +129,18 @@ Each is a commit before the build. The build refuses a dirty tree, and every inp - The adapter retains **(b)** as an explicit tested input for the original primary with its frozen-screen failure retained. It is not Max's selected rebuild route. A run on today's inputs is explicitly a **dry run**, not the binding score and not permission for a fiscal expected value. The adapter consumes the original-primary 160-slot design (161 unique full runs including its identical-rates check on the committed inputs). Replacement sampling can repeat indices after an input update; use the handoff's saved distinct counts. A passing binding score adds the exact frozen 40-slot paired subsample on Enhanced FRS 1.58.2; only the dataset role changes. The original-primary ±0.5-point mean paths run independently of adequacy, with the same primary indices, innovations and zero-stratum mass; their extra baseline check may save nonzero. The scenario envelope carries the central path, OBR wedge, recorded historical last-decade replay (April 2017–2026, with its own model years) and those paired mean paths under either outcome. The historical replay retains its period label and is not treated as a future-horizon simulation or included in a common future-year range. -4. **Versions and dataset**: complete the pending d2011 release and two-step bridge above. The installed packages, primary artifact and released certification must match exactly. Run the final coverage and accounting checks before the binding run. The behavioural Housing Benefit Guarantee Credit passport must still pass: the non-claimant faces the ordinary means test, the claimant receives maximum HB, and reversed receipt flags exercise all three branches. Every publication entry point runs this guard before fiscal jobs. +4. **Versions and dataset**: with d2011's release filled (6.11.1), complete the two-step bridge above. The installed packages, primary artifact and released certification must match exactly. Run the final coverage and accounting checks before the binding run. The behavioural Housing Benefit Guarantee Credit passport must still pass: the non-claimant faces the ordinary means test, the claimant receives maximum HB, and reversed receipt flags exercise all three branches. Every publication entry point runs this guard before fiscal jobs. ## Commands -Start from `main`, with the newly released d2011 carrier lock and bridge changes reviewed on the rebuild branch. The preparatory lock pins its UK 2.151.0/core 3.33.3 candidate and remains uncertified until that release exists: +Start from `main`, with the newly released d2011 carrier lock and bridge changes reviewed on the rebuild branch. The lock pins the released carrier, policyengine.py 6.11.1, with UK 2.151.0/core 3.33.3: Set `HEAVY_CALLER=uk-triple-lock` before every command run through `heavy_run.sh`, `lockrun.sh` or `heavy_queue.zsh`. The caller identity gives this repository access to the Fleet ops reservation [below](#part-j-pre-budget-rehearsal-and-post-budget-sequence). ```sh git fetch origin main git switch -c rebuild/budget-2026 origin/main -# Complete the pending-bundle and input-update sections above on this branch. +# Complete the bridge and input-update sections above on this branch. # Commit each step and open a draft PR into main at the first commit. # The following install uses the newly authenticated d2011 lock. uv venv .venv --python 3.13 @@ -380,7 +380,7 @@ No history fetch is needed in the checkout workflow. ## Part J: pre-Budget rehearsal and post-Budget sequence -The historical rehearsal used the pre-Budget 6.2.6 / 1.58.0 certified bundle. Reproducing that historical run uses that exact lock; the pending binding run uses d2011's pair instead. The commands in this subsection reproduce the historical rehearsal at its original source and lock; the current foreground commands above control the binding rebuild. Its numbers are not for quoting. It never writes `data/results.json`, its dashboard copy or scenario publication files. The C2 dry handoff remains `run_kind: dry_run` with `expected_value_authorized: false`; the rehearsal adapter retains the frozen ruling (c) checks and original-primary design. A passing diagnostic screen does not authorize a binding build. +The historical rehearsal used the pre-Budget 6.2.6 / 1.58.0 certified bundle. Reproducing that historical run uses that exact lock; the binding run uses d2011's pair instead. The commands in this subsection reproduce the historical rehearsal at its original source and lock; the current foreground commands above control the binding rebuild. Its numbers are not for quoting. It never writes `data/results.json`, its dashboard copy or scenario publication files. The C2 dry handoff remains `run_kind: dry_run` with `expected_value_authorized: false`; the rehearsal adapter retains the frozen ruling (c) checks and original-primary design. A passing diagnostic screen does not authorize a binding build. ```sh export OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 NUMEXPR_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 @@ -411,7 +411,7 @@ The exact post-Budget order is: ``` A `ready` mark brings the start forward only; it cannot extend the window past 4 November. -1. Branch from `origin/main` and open a new draft PR into `main` at the first commit. Complete the pending d2011 release, upstream re-hash, passport preflight, model bridge, data bridge and coverage review above. Commit AWE (20 October), September CPI (21 October), the OBR forecast (28 October) and newly complete backtest origins with official source bytes and release identities. Recheck the certified lock and guards after those changes. +1. Branch from `origin/main` and open a new draft PR into `main` at the first commit. With d2011's release filled (6.11.1), complete the upstream re-hash, passport preflight, model bridge, data bridge and coverage review above. Commit AWE (20 October), September CPI (21 October), the OBR forecast (28 October) and newly complete backtest origins with official source bytes and release identities. Recheck the certified lock and guards after those changes. 2. Run the **binding C2 re-score**, using `python -m triple_lock.ts_uncertainty --screen c2 --binding --output out/uncertainty-c2`. It is not a separate launchd label. It is the `uk-triple-lock-c2` step inside the loaded launchd job `com.maxghenis.triple-lock-2027`, which runs daily at 08:35. From **29 October 2026**, or the day after a moved Budget, that job dispatches the step as a Subfleet hard-tier build in this repository. It does so once its `budget-check` step has ended DONE or WAITING_ON_MAX. The step stops at the C2 verdict; it does not run the fiscal rebuild. The binding-input guard remains mandatory when the job invokes the command. Commit the resulting binding handoff and scores before model jobs. 3. Run `triple-lock-build --workers 1 --sensitivity-workers 1 --uncertainty-ruling c --uncertainty-handoff out/uncertainty-c2` on the certified bundle. Primary pass permits only its model-conditional expected value beside the scenario envelope; primary failure automatically uses (a), scenarios only. Run the paired four-way validation after a pass, or `--central-only` after a failure. Check accounting, coverage, source/input/provenance bindings, full pytest and dashboard checks. 4. Obtain independent review of the rebuilt fiscal aggregates, C2 outcome, coverage and limitations, resolve findings and repeat to approval. Keep the new rebuild PR draft until independent review approves; mark it ready and leave merging to the reviewer. diff --git a/pyproject.toml b/pyproject.toml index 561ab83b..396c7278 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ dependencies = [ # The carrier is installed without [uk]: an extra may pin another model. scripts/fill_release_identities.py sets # its version from the release, with the identities in datasets.py; then re-resolve the lock (docs/REBUILD.md). uk = [ - "policyengine==6.10.1", + "policyengine==6.11.1", "policyengine-uk==2.151.0", "policyengine-core==3.33.3", "huggingface-hub>=1.0", diff --git a/requirements-lock.txt b/requirements-lock.txt index 3e800cfe..11c4b1be 100644 --- a/requirements-lock.txt +++ b/requirements-lock.txt @@ -1,4 +1,4 @@ -# d2011 A: microcosm#1396's model/core, with the carrier datasets.py pins (preparatory while its release is pending). +# d2011 A: microcosm#1396's model/core, with the carrier datasets.py pins (policyengine.py 6.11.1, the pair's release). # Base policyengine carrier deliberately has no [uk] extra (an extra may pin another model). # Dataset and future release-manifest identity are pinned in triple_lock/datasets.py. # Historical receipts retain their original model/data identities. @@ -53,7 +53,7 @@ parso==0.8.7 pexpect==4.9.0 plotly==5.24.1 pluggy==1.6.0 -policyengine==6.10.1 +policyengine==6.11.1 policyengine-core==3.33.3 policyengine-uk==2.151.0 prompt-toolkit==3.0.53 diff --git a/src/triple_lock/datasets.py b/src/triple_lock/datasets.py index 45bb5747..92040171 100644 --- a/src/triple_lock/datasets.py +++ b/src/triple_lock/datasets.py @@ -2,12 +2,13 @@ d2011 = A (Max, 25 October 2026): the binding primary is the 2.151.0 national and local-area pair built at microcosm#1396, Microcosm a6299504 / UK 2.151.0 / -core 3.33.3. Its carrier release is pending: provenance fails closed until its -released version, installed manifest digest, certifying pull request and -commit, and local-area cut are pinned below. #715's a133f37a / UK 2.147.2 -pair, d1983's first binding pair, is d2011's fallback with policyengine.py -#699's area routing to the 19 October local-area cut, which is pinned already; -its bundle identities are pending the same way. +core 3.33.3, released in policyengine.py 6.11.1 on 27 October 2026 and +certified by policyengine.py#720. Provenance requires the released version, +installed manifest digest, certifying pull request and commit, and local-area +cut pinned below. #715's a133f37a / UK 2.147.2 pair, d1983's first binding +pair, was d2011's fallback with policyengine.py #699's area routing to the +19 October local-area cut, which is pinned; the pair shipped before d2011's +deadline, so the fallback was not taken, and its bundle identities stay pending. Historical Enhanced FRS and Microcosm artifacts retain their original pins. Materialization checks the exact artifact SHA-256 before every use (cached while unchanged), with one shared copy in .cache/datasets per artifact. @@ -119,16 +120,17 @@ def area_routed(local_area_path): # manifest digest, the pull request and commit that certify it, and the 2.151.0 local-area cut its constituency and # local-authority runs load (file, release tag, SHA-256, and the SHA-256 of the cut's release manifest). Each fails # closed while None; scripts/fill_release_identities.py fills them from the release. -BINDING_BUNDLE_VERSION = None -BINDING_BUNDLE_MANIFEST_SHA256 = None -BINDING_CERTIFICATION_URL = None -BINDING_CERTIFICATION_COMMIT = None -BINDING_LOCAL_AREA_PATH = None -BINDING_LOCAL_AREA_REVISION = None -BINDING_LOCAL_AREA_SHA256 = None -BINDING_LOCAL_AREA_MANIFEST_SHA256 = None -# The base carrier the preparatory lock installs while BINDING_BUNDLE_VERSION is pending (its [uk] extra pins its own -# previous model, so the lock omits it); the fill replaces it with the release in pyproject.toml and the lock. +BINDING_BUNDLE_VERSION = "6.11.1" +BINDING_BUNDLE_MANIFEST_SHA256 = "ab60903b390e7a765ea6558ec5e2d8fa5aede06b6f735fbf4e4c96109ffd1617" +BINDING_CERTIFICATION_URL = "https://github.com/PolicyEngine/policyengine.py/pull/720" +BINDING_CERTIFICATION_COMMIT = "610ebc42586b7358c41afe4fabec7c67adf17205" +BINDING_LOCAL_AREA_PATH = "microcosm_uk_2024_25_dense.h5" +BINDING_LOCAL_AREA_REVISION = "microcosm-uk-2024-25-dense-20261026T105309Z-c9df4b86" +BINDING_LOCAL_AREA_SHA256 = "cb3712f25b76109ad27b7b0cdf5dee78c55d3b7f55ce7bf207ef1a191da2ae9a" +BINDING_LOCAL_AREA_MANIFEST_SHA256 = "30be47545efd2aa320e30f8d04240cf345aec105fa1d788334770bc4e17c583e" +# The base carrier the preparatory lock installed while BINDING_BUNDLE_VERSION was pending (its [uk] extra pins its +# own previous model, so the lock omits [uk]); the fill replaced it with the release in pyproject.toml and the lock. +# Tests restore it to rebuild the pending state. PREPARATORY_CARRIER = "6.10.1" CERTIFIED_BUNDLE = { "bundle_package": "policyengine", "bundle_version": BINDING_BUNDLE_VERSION, @@ -308,7 +310,7 @@ def area_routed(local_area_path): }, } UNCERTIFIED = ("uncertified: the exact released policyengine.py bundle/model/core and artifact are required; " - "the release carrying d2011's binding Microcosm UK Budget pair is pending") + "d2011's binding Microcosm UK Budget pair is released in policyengine.py 6.11.1") class DatasetMismatch(RuntimeError): From 945263086ba20cbaaf8f8271c176b21b0880f4de Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 26 Oct 2026 19:54:39 -0700 Subject: [PATCH 11/14] Describe the released carrier in the remaining pending wording Round 5 approved c48aa2a with one non-blocking item: four comments still called the carrier or its manifest pending or future. They now describe the release: - pyproject.toml's pin comment; - the lock's third comment; - the a6299504 entry's `certified` comment in datasets.py; - pipeline.py's module docstring. Two more strings said the same thing: - method_limitations(), whose sentence goes into the binding results file, said publication fails closed until the release exists. It now says 6.11.1 (policyengine.py#720) certifies the pair, and publication fails closed without exactly that installed bundle, as certified_bundle_preflight requires; - the preflight's refusal text now covers both states: it refuses while the pair's identities in datasets.py are pending, and the fill pins them from the release (6.11.1 for the 2.151.0 pair). It still matches the tests' "d2011.*pending" in the pending state. scripts/binding_bridge.py's docstring drops "pending". The pending config's own refusal is unchanged. Comments and wording only. The nine focused files and test_results gave 522 passed, with only the 2 documented stale-results failures, and the preflight still certifies 6.11.1. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 2 +- requirements-lock.txt | 2 +- scripts/binding_bridge.py | 2 +- src/triple_lock/datasets.py | 2 +- src/triple_lock/pipeline.py | 25 +++++++++++++------------ 5 files changed, 17 insertions(+), 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 396c7278..95602616 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ ] [project.optional-dependencies] -# d2011's pair's UK model/core (microcosm#1396's uv.lock), explicitly pinned while its carrier release is pending. +# d2011's pair's UK model/core (microcosm#1396's uv.lock), pinned explicitly beside its released carrier. # The engine constructs policyengine_uk directly and materializes its own pinned data. # The carrier is installed without [uk]: an extra may pin another model. scripts/fill_release_identities.py sets # its version from the release, with the identities in datasets.py; then re-resolve the lock (docs/REBUILD.md). diff --git a/requirements-lock.txt b/requirements-lock.txt index 11c4b1be..62711f2d 100644 --- a/requirements-lock.txt +++ b/requirements-lock.txt @@ -1,6 +1,6 @@ # d2011 A: microcosm#1396's model/core, with the carrier datasets.py pins (policyengine.py 6.11.1, the pair's release). # Base policyengine carrier deliberately has no [uk] extra (an extra may pin another model). -# Dataset and future release-manifest identity are pinned in triple_lock/datasets.py. +# Dataset and release-manifest identities are pinned in triple_lock/datasets.py. # Historical receipts retain their original model/data identities. annotated-types==0.8.0 anyio==4.15.1 diff --git a/scripts/binding_bridge.py b/scripts/binding_bridge.py index 6d2ff2ed..2bf4c0a9 100644 --- a/scripts/binding_bridge.py +++ b/scripts/binding_bridge.py @@ -1,4 +1,4 @@ -"""Pin validation and aggregate comparisons for the pending binding bridge. +"""Pin validation and aggregate comparisons for the binding bridge. An endpoint pins the installed package versions and exact dataset bytes. The model step holds Enhanced FRS fixed and is uncertified; the data step holds diff --git a/src/triple_lock/datasets.py b/src/triple_lock/datasets.py index 92040171..7ba2a2f5 100644 --- a/src/triple_lock/datasets.py +++ b/src/triple_lock/datasets.py @@ -192,7 +192,7 @@ def area_routed(local_area_path): "data_package": "microcosm-data", "data_version": "0.1.0", # packages/microcosm-data at 67ee2ddf3 "built_with": "policyengine-uk 2.151.0 (policyengine-core 3.33.3)", "build_id": MICROCOSM_REVISION, - "certified": [], # pending release; producer gates are not released bundle certification. + "certified": [], # its release certifies it via bundle_certification; producer gates do not. "bundle_certification": CERTIFIED_BUNDLE, "release_manifest": MICROCOSM_RELEASE_MANIFEST, "region_datasets": BINDING_REGION_DATASETS, diff --git a/src/triple_lock/pipeline.py b/src/triple_lock/pipeline.py index 09525112..c2866afe 100644 --- a/src/triple_lock/pipeline.py +++ b/src/triple_lock/pipeline.py @@ -8,8 +8,8 @@ redact_records. * ``expected_value``: the calibrated distribution of paths and the stratified sample of full runs (expected_value.py), on the certified Microcosm UK Budget - primary and the paired Enhanced FRS 1.58.2 sensitivity. Publication waits for - the policyengine.py release carrying d2011's pair. + primary and the paired Enhanced FRS 1.58.2 sensitivity. Publication requires + policyengine.py 6.11.1, the release carrying d2011's pair. * ``trajectories``: a few paths we fully understand, past years and the method backtests (trajectories.py). * ``coverage``: what each dataset holds against DWP's tables, for the UK and Great Britain, in 2026-27 to 2030-31, @@ -150,11 +150,12 @@ def method_limitations(): wording[i] = ( "The binding primary is the UK Budget pair d2011 certifies (microcosm#1396): Microcosm national " "cut microcosm-uk-2024-25-national-20261025T091414Z-a6299504, policyengine-uk 2.151.0 and " - "policyengine-core 3.33.3. Publication fails closed until its certified policyengine.py release " - "exists. Enhanced FRS 1.58.2 is the paired sensitivity. This model has September-CPI benefit " - "uprating (#2557) but predates linked HB/Savings Credit derivation (#2576): the Savings Credit " - "thresholds and pension-age HB allowances retain the installed lagged calendar-CPI projection " - "(d1753); the Pension Credit guarantee still follows May-July earnings.") + "policyengine-core 3.33.3, certified by its policyengine.py 6.11.1 release (policyengine.py#720); " + "publication fails closed without exactly that installed bundle. Enhanced FRS 1.58.2 is the " + "paired sensitivity. This model has September-CPI benefit uprating (#2557) but predates linked " + "HB/Savings Credit derivation (#2576): the Savings Credit thresholds and pension-age HB " + "allowances retain the installed lagged calendar-CPI projection (d1753); the Pension Credit " + "guarantee still follows May-July earnings.") wording.append( "The pre-Budget release used Enhanced FRS and was described as provisional in Vahid's post. " "The binding release uses Microcosm; bridge step (b), on the same policyengine-uk 2.151.0 model, " @@ -196,11 +197,11 @@ def certified_bundle_preflight(log=print): """ remedy = ("The fiscal rebuild requires the certified released policyengine.py UK Budget bundle carrying " "d2011's pair (microcosm#1396): UK 2.151.0 / core 3.33.3 / Microcosm a6299504, or its fallback, " - "#715's UK 2.147.2 / Microcosm a133f37a with #699's area routing. While its carrier release is " - "pending, this refuses. After release, run scripts/fill_release_identities.py to pin its version, " - "released manifest digest, certifying pull request and commit, and local-area cut in datasets.py " - "and the carrier in the lock, then install the re-resolved lock (docs/REBUILD.md). A candidate head " - "and producer gates cannot certify a release.") + "#715's UK 2.147.2 / Microcosm a133f37a with #699's area routing. While the pair's release identities " + "in datasets.py are pending, this refuses. scripts/fill_release_identities.py pins them from the " + "release (for the 2.151.0 pair, policyengine.py 6.11.1): its version, released manifest digest, " + "certifying pull request and commit, and local-area cut, and the carrier in the lock. Install the " + "re-resolved lock (docs/REBUILD.md). A candidate head and producer gates cannot certify a release.") if PRIMARY_DATASET not in datasets.D2011_PAIRS: raise UncertifiedBundle(remedy) primary = PRIMARY_DATASET From 30961ed163bce2825188b81105d710453747c24a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 26 Oct 2026 20:25:01 -0700 Subject: [PATCH 12/14] Finish the released-carrier wording outside the reviewed sites Round 6 approved 9452630 and listed four older sites that still described publication as waiting for the release. A repository-wide sweep found two more. All six now describe the release: - engine.py's module docstring and config.py's dataset comment: the pair is released in 6.11.1, and publication requires exactly that installed bundle; - the comment above datasets.BINDING_*: filled from the release, each failing closed while None; - tests/test_certified_preflight.py's fixture comment; - run_model_v2_j_bridge.binding_main's docstring: "the released bundle" for "the unreleased bundle"; - docs/RESULTS_SCHEMA.md: the binding build uses d2011's 6.11.1 pair, replacing the superseded d1727 promise. Left as they are: - historical review records; - the fallback's pending identities; - the pending bridge config's own refusal; - the runbook's conditional fill instructions. Comments, docstrings and one doc sentence only. Fifteen files, the nine focused ones plus results, scenarios, cache-key, certification, pilot-evidence and ageing-report, gave 887 passed, with only the 3 documented stale-results failures. Co-Authored-By: Claude Opus 5.5 --- docs/RESULTS_SCHEMA.md | 2 +- scripts/run_model_v2_j_bridge.py | 2 +- src/triple_lock/config.py | 6 +++--- src/triple_lock/datasets.py | 8 ++++---- src/triple_lock/engine.py | 6 +++--- tests/test_certified_preflight.py | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/RESULTS_SCHEMA.md b/docs/RESULTS_SCHEMA.md index 245316fe..41860ed8 100644 --- a/docs/RESULTS_SCHEMA.md +++ b/docs/RESULTS_SCHEMA.md @@ -118,7 +118,7 @@ For d955(c), `expected_value` exists only if the **original monthly VAR(1) bootstrap primary** passes the binding [C2 screen](METHOD.md#model-v2-statutory-uncertainty-screen-pre-registration-c2). Its label is **model-conditional**. Passing alternatives are reported in `c2_outcome` individually; they never replace the primary or get averaged. If the primary fails, the build automatically records requested ruling `c`, effective ruling `a` and the failure reason, and omits `expected_value`. `scenario_envelope` remains beside the expected value after a pass and is the output after a failure: central path, OBR-wedge run, recorded historical last-decade replay (April 2017–2026, with its own model years) and paired ±0.5-point calendar-earnings mean paths from 2031. Each entry carries its saved full-run result or paired path-set estimate and recorded years, with no probability-interval claim. The historical replay is labelled with its period and is not combined into a future-year numerical range. -`release` marks an **interim pre-Budget file**, written by `triple-lock-build --pre-budget --uncertainty-ruling a` (scenarios only) or `c` (the published provisional, model-conditional expected value). The obsolete public (b) route is refused. `stage: "pre-budget"`, `interim: true`, `recorded_ruling: "c"`, `execution_ruling`, `forecast`/`forecast_source`, `cpi_period`, `april_2027_cpi_leg` and `binding_route` identify the interim vintage and replacement command. `budget_date` comes from `C2_BUDGET_DATE`; all rendered Budget dates use this release field. Ruling (c) first validates the full C2 handoff integrity contract, checks byte-identical primary and paired mean-path designs, and runs the full 40-slot Microcosm cross-check. `release.provisional: true`, `release.c2_dry_run`, `uncertainty_ruling`, `uncertainty_screen` and `expected_value.provenance` retain `run_kind: "dry_run"` and `expected_value_authorized: false` with the provisional labels. A pre-Budget file cannot authorize the binding route. A binding build requires the post-Budget inputs and handoff, writes no `release` block and removes the banner; primary failure omits expected value and retains scenarios. The historical pre-Budget file remains certified at 6.2.6 / 1.58.0. The pending d1727 binding bundle will certify EFRS 1.58.2 after the separate model and data bridge steps in REBUILD.md. +`release` marks an **interim pre-Budget file**, written by `triple-lock-build --pre-budget --uncertainty-ruling a` (scenarios only) or `c` (the published provisional, model-conditional expected value). The obsolete public (b) route is refused. `stage: "pre-budget"`, `interim: true`, `recorded_ruling: "c"`, `execution_ruling`, `forecast`/`forecast_source`, `cpi_period`, `april_2027_cpi_leg` and `binding_route` identify the interim vintage and replacement command. `budget_date` comes from `C2_BUDGET_DATE`; all rendered Budget dates use this release field. Ruling (c) first validates the full C2 handoff integrity contract, checks byte-identical primary and paired mean-path designs, and runs the full 40-slot Microcosm cross-check. `release.provisional: true`, `release.c2_dry_run`, `uncertainty_ruling`, `uncertainty_screen` and `expected_value.provenance` retain `run_kind: "dry_run"` and `expected_value_authorized: false` with the provisional labels. A pre-Budget file cannot authorize the binding route. A binding build requires the post-Budget inputs and handoff, writes no `release` block and removes the banner; primary failure omits expected value and retains scenarios. The historical pre-Budget file remains certified at 6.2.6 / 1.58.0. The binding build uses d2011's pair, certified by its release, policyengine.py 6.11.1 (Microcosm a6299504 primary, with Enhanced FRS 1.58.2 as the paired sensitivity), after the separate model and data bridge steps in REBUILD.md. `uncertainty_screen.rule_sha` is the committed C2 pre-registration `65343e2ee43a359f056ce5a027739509d32ab49f`; the handoff must match it before execution. Its `run_kind` is `binding`; a handoff labelled `dry_run` records a diagnostic run on pre-Budget inputs and cannot authorize an expected value. The one exception is the provisional pre-Budget (c) file above, which records the dry run's screen with `expected_value_authorized: false` and labels its expected value provisional. `expected_value_authorized` is true only for a binding primary pass; `authorization` records a dry run, a binding pass or the automatic fallback. `scoring_head` records the source commit used for scoring as provenance only; execution never requires that historic object. `rule_section_sha256` binds the exact frozen section bytes to the committed SHA256, and `scoring_inputs_sha256` binds the score table to the handoff's current code/data file hashes. Both survive a shallow clone or squash merge. `c1_failure` discloses that all five forms failed the original frozen C1 screen on test A with published earnings (1/6 terminal coverage, from the 2021 furlough base effect), the post-scoring rule change and the retained C1 record. `c2_outcome` retains the original-primary pass/fail and each alternative's result and failures. `c2_scores` carries both suspended and published summary tables, past-years percentiles, origins and legal-regime metadata beside that outcome; `score_table_sha256` identifies the complete handoff table, which also retains per-origin rows. The tables record legal exclusions and denominators. The rule-section hash, source snapshot and binding inputs support validation of the outcome and detect accidental changes; these hashes are audit evidence, not protection against jointly rewriting scores and their hashes. Suspended scoring, including its 5th–95th percentile past-years check, determines C2. Published-earnings scores, including their past-years percentile, remain sensitivities. Screen, rule SHA, C1 disclosure, C2 outcome and its quantitative sensitivity are carried in both results and provenance even when expected value is absent. diff --git a/scripts/run_model_v2_j_bridge.py b/scripts/run_model_v2_j_bridge.py index 4b8ee6df..e2522e0b 100644 --- a/scripts/run_model_v2_j_bridge.py +++ b/scripts/run_model_v2_j_bridge.py @@ -589,7 +589,7 @@ def binding_main(args): Model/to compares against the exact committed 6.2.6 receipt. Data/to must reuse model/to, so the intermediate endpoint cannot silently be substituted. - The unreleased bundle is deliberately not guessed or installed here. + The released bundle is deliberately not guessed or installed here: it must be the installed lock. """ workspace = Path.cwd().resolve() destination = args.out.resolve() diff --git a/src/triple_lock/config.py b/src/triple_lock/config.py index 941bfa9d..fa07951f 100644 --- a/src/triple_lock/config.py +++ b/src/triple_lock/config.py @@ -192,9 +192,9 @@ # Datasets (datasets.DATASETS: each pinned to a revision and a SHA-256). The # binding primary is the Microcosm UK Budget national cut d2011 certifies (= A: -# a6299504, microcosm#1396, on policyengine-uk 2.151.0); publication remains -# blocked until the certified carrier is released. Enhanced FRS 1.58.2 is the -# paired sensitivity (d1983 = B supersedes d1727). +# a6299504, microcosm#1396, on policyengine-uk 2.151.0), released in +# policyengine.py 6.11.1; publication requires exactly that installed bundle. +# Enhanced FRS 1.58.2 is the paired sensitivity (d1983 = B supersedes d1727). PRIMARY_DATASET = "microcosm_uk_2024_25@a6299504" SENSITIVITY_DATASET = "enhanced_frs_2024_25@1.58.2" diff --git a/src/triple_lock/datasets.py b/src/triple_lock/datasets.py index 7ba2a2f5..6302c7be 100644 --- a/src/triple_lock/datasets.py +++ b/src/triple_lock/datasets.py @@ -116,10 +116,10 @@ def area_routed(local_area_path): "b9dc7cae358b9c965935f83721508594f9fb56a35911ffb0f9216ecb6a799561", "2.132.6", "accepted_model_mismatch") FALLBACK_REGION_DATASETS = area_routed(FALLBACK_LOCAL_AREA["path"]) -# d2011's pair. Unknown until the policyengine.py release that carries it: that release's version and installed -# manifest digest, the pull request and commit that certify it, and the 2.151.0 local-area cut its constituency and -# local-authority runs load (file, release tag, SHA-256, and the SHA-256 of the cut's release manifest). Each fails -# closed while None; scripts/fill_release_identities.py fills them from the release. +# d2011's pair, as only the policyengine.py release that carries it (6.11.1) could supply them: that release's +# version and installed manifest digest, the pull request and commit that certify it, and the 2.151.0 local-area cut +# its constituency and local-authority runs load (file, release tag, SHA-256, and the SHA-256 of the cut's release +# manifest). scripts/fill_release_identities.py filled them from the release; each fails closed while None. BINDING_BUNDLE_VERSION = "6.11.1" BINDING_BUNDLE_MANIFEST_SHA256 = "ab60903b390e7a765ea6558ec5e2d8fa5aede06b6f735fbf4e4c96109ffd1617" BINDING_CERTIFICATION_URL = "https://github.com/PolicyEngine/policyengine.py/pull/720" diff --git a/src/triple_lock/engine.py b/src/triple_lock/engine.py index 73457e88..8fda5341 100644 --- a/src/triple_lock/engine.py +++ b/src/triple_lock/engine.py @@ -4,9 +4,9 @@ is scaled from another run. The binding primary is the certified UK Budget pair d2011 chose (microcosm#1396): Microcosm national cut microcosm-uk-2024-25-national-20261025T091414Z-a6299504, policyengine-uk -2.151.0 and policyengine-core 3.33.3. Publication fails closed until its -policyengine.py carrier is released. Enhanced FRS 1.58.2 is the paired -sensitivity. All datasets have exact revision and SHA-256 pins (datasets.py). +2.151.0 and policyengine-core 3.33.3, released in policyengine.py 6.11.1. +Publication fails closed without exactly that installed bundle. Enhanced FRS +1.58.2 is the paired sensitivity. All datasets have exact revision and SHA-256 pins (datasets.py). The provisional pre-Budget release used Enhanced FRS; bridge step (b) measures the move to Microcosm on the same model. Historical receipts remain intact. diff --git a/tests/test_certified_preflight.py b/tests/test_certified_preflight.py index 42baadf0..84c16efc 100644 --- a/tests/test_certified_preflight.py +++ b/tests/test_certified_preflight.py @@ -18,7 +18,7 @@ @pytest.fixture def certified(monkeypatch): - # Fictional released carrier: production pins remain pending. + # A fictional released carrier, whatever the production pins hold. monkeypatch.setitem(datasets.CERTIFIED_BUNDLE, "bundle_version", PINS["policyengine"]) monkeypatch.setitem(datasets.CERTIFIED_BUNDLE, "manifest_sha256", "a" * 64) monkeypatch.setitem(datasets.CERTIFIED_BUNDLE, "certification_url", "https://example.invalid/pull/1") From f08f85dbb1037f9615b63d45a8b51df48dd5b583 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 26 Oct 2026 21:04:57 -0700 Subject: [PATCH 13/14] Make the tampered-manifest test reach the provenance refusal Round 7 approved 30961ed with two pre-existing P3 items: - test_certified_preflight_refuses_tampered_installed_release_manifest installed carrier 99.0.0 against the pinned one, so the preflight refused on versions before reading provenance. The test's broad "d2011|released manifest" match accepted that. In the pending state it refused even earlier, on the unset identities. The test now pins a fictional released carrier, installed exactly, so only the tampered manifest can refuse. It requires the preflight's "installed released manifest does not certify the pinned pair" refusal. A mutant that drops the preflight's `certified is not True` check now fails the test; the old test passed it. The test passes in both the filled and the pending state. - docs/d2011-upstream.md said certification stays closed until a release carries the pair. It now says 6.11.1 did, and certification requires exactly that installed bundle. The nine focused files gave 458 passed. Co-Authored-By: Claude Opus 5.5 --- docs/d2011-upstream.md | 2 +- tests/test_certified_preflight.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/d2011-upstream.md b/docs/d2011-upstream.md index 7c2dbc12..63c70e50 100644 --- a/docs/d2011-upstream.md +++ b/docs/d2011-upstream.md @@ -4,7 +4,7 @@ Read on 25 October 2026 from the released **policyengine-uk 2.151.0** wheel, com `data/certification/d2011-model-wheel.json` attests the wheel. Its archive SHA-256 equals PyPI's release metadata, and every one of its 3,176 RECORD members was checked for digest and size. The generator first reproduced the committed `d1983-model-wheel.json` byte for byte from PyPI's 2.147.2 wheel. No survey or Microcosm records were loaded. -`model_horizon.UPSTREAM_2151` and `UPSTREAM_2151_PARAMETERS` name the 2.151.0 bytes, and `check_upstream()` accepts that exact version alongside 2.125.1, 2.147.2 and the historical 2.153.4. The 2.147.2 absence guards apply to 2.151.0 too. Max ruled **d2011 = A** on 25 October, so the binding pins now name this pair: UK 2.151.0 in `pyproject.toml` and the lock, and Microcosm a6299504 in `datasets.py` and `config.PRIMARY_DATASET`. Certification stays closed until a policyengine.py release carries the pair. #715's pair (2.147.2 / a133f37a) stays registered as d2011's fallback. +`model_horizon.UPSTREAM_2151` and `UPSTREAM_2151_PARAMETERS` name the 2.151.0 bytes, and `check_upstream()` accepts that exact version alongside 2.125.1, 2.147.2 and the historical 2.153.4. The 2.147.2 absence guards apply to 2.151.0 too. Max ruled **d2011 = A** on 25 October, so the binding pins now name this pair: UK 2.151.0 in `pyproject.toml` and the lock, and Microcosm a6299504 in `datasets.py` and `config.PRIMARY_DATASET`. Certification stayed closed until a policyengine.py release carried the pair: policyengine.py 6.11.1 did on 27 October ([#720](https://github.com/PolicyEngine/policyengine.py/pull/720)), and certification requires exactly that installed bundle. #715's pair (2.147.2 / a133f37a) stays registered as d2011's fallback. ## Every changed guard diff --git a/tests/test_certified_preflight.py b/tests/test_certified_preflight.py index 84c16efc..f29fc77c 100644 --- a/tests/test_certified_preflight.py +++ b/tests/test_certified_preflight.py @@ -88,9 +88,13 @@ def test_certified_preflight_refuses_a_release_without_its_certifying_pull_reque def test_certified_preflight_refuses_tampered_installed_release_manifest(tmp_path, monkeypatch): - """Exercise the real dataset certification helper, without a model import or dataset load.""" + """Exercise the real dataset certification helper, without a model import or dataset load. Every pinned identity + and installed version matches, pending or filled alike, so only the installed manifest's bytes can refuse.""" manifest = tmp_path / "manifest.json" manifest.write_text("{}") + for key, value in (("bundle_version", PINS["policyengine"]), ("manifest_sha256", "a" * 64), + ("certification_url", "https://example.invalid/pull/1"), ("certification_commit", "b" * 40)): + monkeypatch.setitem(datasets.CERTIFIED_BUNDLE, key, value) versions = {key: datasets.CERTIFIED_BUNDLE[key] for key in ("bundle_package", "bundle_version", "model_version", "core_version")} monkeypatch.setattr(pipeline, "PRIMARY_DATASET", PRIMARY) @@ -100,7 +104,7 @@ def test_certified_preflight_refuses_tampered_installed_release_manifest(tmp_pat monkeypatch.setitem(sys.modules, "policyengine_uk.system", None) monkeypatch.setattr(datasets, "materialize", lambda *a, **kw: pytest.fail("preflight reads no survey bytes")) assert datasets.provenance(PRIMARY)["certified"] is False - with pytest.raises(pipeline.UncertifiedBundle, match="d2011|released manifest"): + with pytest.raises(pipeline.UncertifiedBundle, match="installed released manifest does not certify the pinned pair"): pipeline.certified_bundle_preflight(log=lambda _: None) From 61363c135df95fcde936a14c80f8b446cff0007a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 26 Oct 2026 21:30:35 -0700 Subject: [PATCH 14/14] Bind the installed manifest's bytes, not just its content, in tests Round 8 approved f08f85d with two non-blocking test items: - No test exercised the installed manifest's digest. Every tampered manifest was "{}", which also fails the structural checks. A mutant deleting `_certified_by_installed_bundle`'s SHA-256 comparison survived all 153 tests in the three provenance files. test_an_installed_manifest_with_the_released_content_but_other_bytes_is_rejected rewrites the released fixture's manifest with the same parsed content but other bytes: indented, key-sorted, or with a trailing newline. It requires certification first, and refusal after. It runs for both d2011 pairs, filled or pending, through tests/released_pair.py. The mutant now fails 6 tests. - The tampered-manifest preflight test's docstring said only the manifest bytes could refuse. While the cut is pending, the pending local-area cut refuses inside provenance first. The docstring now says so. Tests only. The nine focused files gave 464 passed; with the binding identities reset to pending, test_budget_bundle and test_certified_preflight gave 137 passed. Co-Authored-By: Claude Opus 5.5 --- tests/test_budget_bundle.py | 17 +++++++++++++++++ tests/test_certified_preflight.py | 5 +++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/test_budget_bundle.py b/tests/test_budget_bundle.py index 8c19ee0f..ee246031 100644 --- a/tests/test_budget_bundle.py +++ b/tests/test_budget_bundle.py @@ -295,3 +295,20 @@ def test_changed_installed_manifest_is_rejected(released): spec, versions, _, _, path = released path.write_text("{}") assert not datasets._certified_by_installed_bundle(spec, versions) + + +@pytest.mark.parametrize("rewrite", [ + lambda raw: json.dumps(json.loads(raw), indent=1).encode(), + lambda raw: json.dumps(json.loads(raw), sort_keys=True).encode(), + lambda raw: raw + b"\n", +], ids=["indented", "sorted", "trailing-newline"]) +def test_an_installed_manifest_with_the_released_content_but_other_bytes_is_rejected(released, rewrite): + """The pinned digest binds the installed manifest's bytes: the same content, written differently, passes every + structural check but must not certify.""" + spec, versions, _, _, path = released + raw = path.read_bytes() + assert datasets._certified_by_installed_bundle(spec, versions) + changed = rewrite(raw) + assert changed != raw and json.loads(changed) == json.loads(raw) + path.write_bytes(changed) + assert not datasets._certified_by_installed_bundle(spec, versions) diff --git a/tests/test_certified_preflight.py b/tests/test_certified_preflight.py index f29fc77c..642c27f0 100644 --- a/tests/test_certified_preflight.py +++ b/tests/test_certified_preflight.py @@ -88,8 +88,9 @@ def test_certified_preflight_refuses_a_release_without_its_certifying_pull_reque def test_certified_preflight_refuses_tampered_installed_release_manifest(tmp_path, monkeypatch): - """Exercise the real dataset certification helper, without a model import or dataset load. Every pinned identity - and installed version matches, pending or filled alike, so only the installed manifest's bytes can refuse.""" + """Exercise the real dataset certification helper, without a model import or dataset load. The carrier's pinned + identities and installed versions match, pending or filled alike, so the preflight reaches provenance: once the + pair is filled the tampered manifest bytes refuse there, and while its local-area cut is pending the cut does.""" manifest = tmp_path / "manifest.json" manifest.write_text("{}") for key, value in (("bundle_version", PINS["policyengine"]), ("manifest_sha256", "a" * 64),