From 348dd493c7cba66b329a384410e4bb21de97cd4d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 09:48:35 -0400 Subject: [PATCH 1/3] Seed the calibration weight dropout calibrate_local_areas replaced 5% of the log-weights with their mean each epoch, choosing them with torch.rand_like. That draws from torch's global generator, which torch seeds differently in every process and nothing in the build seeded, so two builds of the same commit with the same inputs gave different household weights. The dropout now has its own torch.Generator, seeded from a new seed argument (default 0), and leaves the global generator alone. Co-Authored-By: Claude Opus 5.5 --- .../deterministic-calibration.fixed.md | 1 + .../tests/test_calibrate_determinism.py | 116 ++++++++++++++++++ policyengine_uk_data/utils/calibrate.py | 14 ++- 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 changelog.d/deterministic-calibration.fixed.md create mode 100644 policyengine_uk_data/tests/test_calibrate_determinism.py diff --git a/changelog.d/deterministic-calibration.fixed.md b/changelog.d/deterministic-calibration.fixed.md new file mode 100644 index 00000000..877dd757 --- /dev/null +++ b/changelog.d/deterministic-calibration.fixed.md @@ -0,0 +1 @@ +- Seed the weight dropout in `calibrate_local_areas` with its own generator (`seed`, default 0). The dropout masks were drawn from torch's global generator, which torch seeds differently in every process, so two builds of the same commit with the same inputs gave different household weights and aggregates. diff --git a/policyengine_uk_data/tests/test_calibrate_determinism.py b/policyengine_uk_data/tests/test_calibrate_determinism.py new file mode 100644 index 00000000..0d8d5287 --- /dev/null +++ b/policyengine_uk_data/tests/test_calibrate_determinism.py @@ -0,0 +1,116 @@ +"""Calibration gives the same weights for the same inputs and seed. + +Invariants of ``calibrate_local_areas``: + +- the saved weights are a function of the inputs and ``seed`` alone: not of + the process, and not of whatever has consumed torch's global generator; +- calibration does not consume the global generator; +- ``seed`` is used: different seeds draw different dropout masks. + +Before the dropout masks had their own generator, two builds of one commit +differed in every household weight, because torch seeds its global generator +differently in each process. +""" + +from __future__ import annotations + +import hashlib +import importlib.util +import subprocess +import sys +import tempfile +from pathlib import Path +from unittest.mock import patch + +import numpy as np +import pytest + +if ( + importlib.util.find_spec("torch") is None + or importlib.util.find_spec("policyengine_uk") is None +): + pytest.skip( + "torch/policyengine_uk not available in test environment", + allow_module_level=True, + ) + +import h5py +import torch + +from policyengine_uk_data.tests.test_calibrate_save import ( + _StubDataset, + _make_toy_inputs, +) +from policyengine_uk_data.utils import calibrate as calibrate_module +from policyengine_uk_data.utils.calibrate import calibrate_local_areas + + +def _toy_weights(**kwargs) -> np.ndarray: + """Area-by-household weights saved by a short toy calibration. + + 120 weights and 21 epochs, so dropout (5% per weight per epoch) acts + many times and the last save (epoch 20) reflects it. + """ + matrix_fn, national_matrix_fn = _make_toy_inputs(n_households=40, area_count=3) + dataset = _StubDataset(np.linspace(1.0, 5.0, 40)) + with tempfile.TemporaryDirectory() as folder: + with patch.object(calibrate_module, "STORAGE_FOLDER", Path(folder)): + calibrate_local_areas( + dataset=dataset, + matrix_fn=matrix_fn, + national_matrix_fn=national_matrix_fn, + area_count=3, + weight_file="weights.h5", + dataset_key="2025", + epochs=21, + verbose=False, + **kwargs, + ) + with h5py.File(Path(folder) / "weights.h5", "r") as f: + return f["2025"][:] + + +def _toy_weights_digest() -> str: + return hashlib.sha256(_toy_weights().tobytes()).hexdigest() + + +@pytest.mark.parametrize("seed", [0, 1, 2**31 - 1]) +@pytest.mark.parametrize("global_state, other_global_state", [(0, 1), (7, 12345)]) +def test_weights_depend_on_seed_not_on_the_global_generator( + seed, global_state, other_global_state +): + torch.manual_seed(global_state) + first = _toy_weights(seed=seed) + torch.manual_seed(other_global_state) + torch.rand(7) + second = _toy_weights(seed=seed) + assert np.array_equal(first, second) + + +def test_calibration_does_not_consume_the_global_generator(): + torch.manual_seed(123) + expected = torch.rand(5) + torch.manual_seed(123) + _toy_weights() + assert torch.equal(torch.rand(5), expected) + + +def test_different_seeds_give_different_weights(): + assert not np.array_equal(_toy_weights(seed=0), _toy_weights(seed=1)) + + +def test_fresh_processes_give_identical_weights(): + """The failure this guards against only shows across processes.""" + command = [ + sys.executable, + "-c", + "from policyengine_uk_data.tests.test_calibrate_determinism import " + "_toy_weights_digest; print(_toy_weights_digest())", + ] + digests = [ + subprocess.run(command, capture_output=True, text=True, check=True) + .stdout.strip() + .splitlines()[-1] + for _ in range(2) + ] + assert digests[0] == digests[1] == _toy_weights_digest() diff --git a/policyengine_uk_data/utils/calibrate.py b/policyengine_uk_data/utils/calibrate.py index a31dcc1f..c47a7682 100644 --- a/policyengine_uk_data/utils/calibrate.py +++ b/policyengine_uk_data/utils/calibrate.py @@ -14,6 +14,7 @@ DEFAULT_ZERO_WEIGHT_PRIOR_TOTAL_SHARE = 0.5 +DEFAULT_CALIBRATION_SEED = 0 def default_weight_dataset_key() -> str: @@ -221,6 +222,7 @@ def calibrate_local_areas( nested_progress=None, time_period: int | str | None = None, zero_weight_prior_total_share: float = DEFAULT_ZERO_WEIGHT_PRIOR_TOTAL_SHARE, + seed: int = DEFAULT_CALIBRATION_SEED, ): """ Generic calibration function for local areas (constituencies, local authorities, etc.) @@ -239,6 +241,8 @@ def calibrate_local_areas( area_name: Name of the area type for logging zero_weight_prior_total_share: Share of prior household mass to reserve for rows whose incoming household_weight is zero. + seed: Seed for the dropout masks. The same inputs and seed give the same + weights in every process. """ if dataset_key is None: dataset_key = default_weight_dataset_key() @@ -381,11 +385,19 @@ def pct_close(w, t=0.1, local=True, national=True): return numerator / denominator + # Dropout masks come from their own seeded generator. torch seeds its + # global generator differently in every process, so masks drawn from it + # (the earlier `torch.rand_like`) gave every build different weights. + dropout_generator = torch.Generator().manual_seed(seed) + def dropout_weights(weights, p): if p == 0: return weights # Replace p% of the weights with the mean value of the rest of them - mask = torch.rand_like(weights) < p + mask = ( + torch.rand(weights.shape, generator=dropout_generator, dtype=weights.dtype) + < p + ) mean = weights[~mask].mean() masked_weights = weights.clone() masked_weights[mask] = mean From a7650c4dfa13e3dfdae31b93069fc30423554959 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 05:14:08 -0400 Subject: [PATCH 2/3] Cover calibration backends with a shared seed and byte-identity tests --- .../deterministic-calibration.fixed.md | 2 +- .../tests/test_calibrate_determinism.py | 10 +-- .../tests/test_calibrate_l0.py | 66 ++++++++++++++++++- policyengine_uk_data/utils/calibrate.py | 7 +- policyengine_uk_data/utils/calibrate_l0.py | 51 ++++++++------ 5 files changed, 107 insertions(+), 29 deletions(-) diff --git a/changelog.d/deterministic-calibration.fixed.md b/changelog.d/deterministic-calibration.fixed.md index 877dd757..6e228f8c 100644 --- a/changelog.d/deterministic-calibration.fixed.md +++ b/changelog.d/deterministic-calibration.fixed.md @@ -1 +1 @@ -- Seed the weight dropout in `calibrate_local_areas` with its own generator (`seed`, default 0). The dropout masks were drawn from torch's global generator, which torch seeds differently in every process, so two builds of the same commit with the same inputs gave different household weights and aggregates. +- Seed calibration randomness (`seed`, default 0): dense weight dropout uses its own generator, and L0 gate initialization and sampling use an isolated CPU RNG context. Previously, unseeded torch draws gave builds of the same commit different household weights and aggregates. Fixed inputs, targets, dependency versions, thread count and platform now give identical weight bytes without consuming the caller's RNG stream. diff --git a/policyengine_uk_data/tests/test_calibrate_determinism.py b/policyengine_uk_data/tests/test_calibrate_determinism.py index 0d8d5287..9f318f64 100644 --- a/policyengine_uk_data/tests/test_calibrate_determinism.py +++ b/policyengine_uk_data/tests/test_calibrate_determinism.py @@ -2,8 +2,8 @@ Invariants of ``calibrate_local_areas``: -- the saved weights are a function of the inputs and ``seed`` alone: not of - the process, and not of whatever has consumed torch's global generator; +- with fixed inputs, targets, thread count and platform, saved weight bytes + depend on ``seed``: not on the process or torch's global generator; - calibration does not consume the global generator; - ``seed`` is used: different seeds draw different dropout masks. @@ -84,7 +84,9 @@ def test_weights_depend_on_seed_not_on_the_global_generator( torch.manual_seed(other_global_state) torch.rand(7) second = _toy_weights(seed=seed) - assert np.array_equal(first, second) + assert first.shape == second.shape + assert first.dtype == second.dtype + assert first.tobytes() == second.tobytes() def test_calibration_does_not_consume_the_global_generator(): @@ -96,7 +98,7 @@ def test_calibration_does_not_consume_the_global_generator(): def test_different_seeds_give_different_weights(): - assert not np.array_equal(_toy_weights(seed=0), _toy_weights(seed=1)) + assert _toy_weights(seed=0).tobytes() != _toy_weights(seed=1).tobytes() def test_fresh_processes_give_identical_weights(): diff --git a/policyengine_uk_data/tests/test_calibrate_l0.py b/policyengine_uk_data/tests/test_calibrate_l0.py index 356a1e3e..0edd894c 100644 --- a/policyengine_uk_data/tests/test_calibrate_l0.py +++ b/policyengine_uk_data/tests/test_calibrate_l0.py @@ -7,12 +7,15 @@ import sys import types +import h5py import numpy as np import pandas as pd import pytest +import torch from scipy import sparse as sp from policyengine_uk_data.datasets.frs_release import CURRENT_FRS_RELEASE +from policyengine_uk_data.utils.calibrate import DEFAULT_CALIBRATION_SEED from policyengine_uk_data.utils.calibrate_l0 import ( _build_sparse_calibration_matrix, calibrate_l0, @@ -147,8 +150,6 @@ def test_l0_reduces_error(self): ) # Check predictions - import torch - with torch.no_grad(): y_pred = model.forward(M, deterministic=True).numpy() @@ -186,6 +187,67 @@ def test_sparsity_with_strong_l0(self): assert sparsity > 0.1, f"Sparsity {sparsity:.1%} too low with strong L0 penalty" +def test_l0_seeded_weights_are_byte_identical_and_preserve_rng(monkeypatch, tmp_path): + """Exercise real gate initialization, sampling, optimization and persistence.""" + monkeypatch.setattr( + "policyengine_uk_data.utils.calibrate_l0.STORAGE_FOLDER", tmp_path + ) + + class Dataset: + def __init__(self): + self.household = pd.DataFrame({"household_weight": np.arange(1.0, 9.0)}) + + def copy(self): + clone = Dataset() + clone.household = self.household.copy() + return clone + + def matrix_fn(_dataset, time_period=None): + return ( + pd.DataFrame( + { + "households": np.ones(8), + "income": np.array([1.0, 3.0, 5.0, 7.0, 2.0, 4.0, 6.0, 8.0]), + } + ), + pd.DataFrame({"households": [40.0], "income": [200.0]}), + np.ones((1, 8)), + ) + + def national_matrix_fn(_dataset, time_period=None): + return pd.DataFrame({"households": np.ones(8)}), pd.Series({"households": 40.0}) + + def run_calibration(name, **kwargs): + state_before = torch.random.get_rng_state().clone() + calibrated = calibrate_l0( + dataset=Dataset(), + matrix_fn=matrix_fn, + national_matrix_fn=national_matrix_fn, + area_count=1, + weight_file=name, + dataset_key="2025", + epochs=16, + **kwargs, + ) + assert torch.equal(torch.random.get_rng_state(), state_before) + returned_weights = calibrated.household.household_weight.to_numpy() + with h5py.File(tmp_path / name, "r") as weights_file: + saved_weights = weights_file["2025"][:] + assert saved_weights.tobytes() == returned_weights.tobytes() + return saved_weights.tobytes() + + # Different ambient CPU RNG states must not alter either calibration. + with torch.random.fork_rng(devices=[]): + torch.default_generator.manual_seed(1234) + first = run_calibration("first.h5") + torch.rand(64) + repeat = run_calibration("repeat.h5", seed=DEFAULT_CALIBRATION_SEED) + different = run_calibration("different.h5", seed=DEFAULT_CALIBRATION_SEED + 1) + + assert first == repeat + assert first != different + + class TestL0CalibrationPeriods: @pytest.mark.parametrize( "dataset_key", diff --git a/policyengine_uk_data/utils/calibrate.py b/policyengine_uk_data/utils/calibrate.py index e0c3ea1c..db45bf5c 100644 --- a/policyengine_uk_data/utils/calibrate.py +++ b/policyengine_uk_data/utils/calibrate.py @@ -14,6 +14,8 @@ DEFAULT_ZERO_WEIGHT_PRIOR_TOTAL_SHARE = 0.5 +# Shared default for calibration randomness. Each call starts a fresh RNG; +# reproducibility also requires fixed inputs, targets, torch threads and platform. DEFAULT_CALIBRATION_SEED = 0 @@ -241,8 +243,9 @@ def calibrate_local_areas( area_name: Name of the area type for logging zero_weight_prior_total_share: Share of prior household mass to reserve for rows whose incoming household_weight is zero. - seed: Seed for the dropout masks. The same inputs and seed give the same - weights in every process. + seed: Seed for the dropout masks (default DEFAULT_CALIBRATION_SEED). + Fixed inputs, targets, torch thread count and platform give + byte-identical weights in every process, without using the global RNG. """ if dataset_key is None: dataset_key = default_weight_dataset_key() diff --git a/policyengine_uk_data/utils/calibrate_l0.py b/policyengine_uk_data/utils/calibrate_l0.py index 6ff8a06f..c5fcfd25 100644 --- a/policyengine_uk_data/utils/calibrate_l0.py +++ b/policyengine_uk_data/utils/calibrate_l0.py @@ -20,6 +20,7 @@ from scipy import sparse as sp from policyengine_uk_data.storage import STORAGE_FOLDER +from policyengine_uk_data.utils.calibrate import DEFAULT_CALIBRATION_SEED logger = logging.getLogger(__name__) @@ -163,6 +164,7 @@ def calibrate_l0( get_performance=None, nested_progress=None, time_period: int | str | None = None, + seed: int = DEFAULT_CALIBRATION_SEED, ): """Calibrate local area weights using L0-regularised optimisation. @@ -190,6 +192,10 @@ def calibrate_l0( nested_progress: Progress tracker for nested display. time_period: Target period to pass to matrix functions. Defaults to dataset_key when the key is a year. + seed: Seed for gate initialization and training samples. Shares the + dense calibrator's default. Identical inputs and seed give identical + weight bytes on CPU with fixed dependency versions, thread count + and platform. Returns: dataset with calibrated household_weight. @@ -239,26 +245,31 @@ def calibrate_l0( epochs, ) - # Create and fit the L0 model - model = SparseCalibrationWeights( - n_features=n_records, - init_weights=init_weights, - init_keep_prob=init_keep_prob, - device="cpu", - ) - - model.fit( - M=M, - y=y, - lambda_l0=lambda_l0, - lambda_l2=lambda_l2, - lr=lr, - epochs=epochs, - loss_type="relative", - verbose=verbose, - verbose_freq=50, - target_groups=target_groups, - ) + # l0-python draws initialization jitter and HardConcrete gates from the + # global CPU generator and does not accept a generator or seed. Isolate + # those draws so earlier random consumers cannot affect calibration and + # calibration does not change the caller's random stream. + with torch.random.fork_rng(devices=[]): + torch.default_generator.manual_seed(seed) + model = SparseCalibrationWeights( + n_features=n_records, + init_weights=init_weights, + init_keep_prob=init_keep_prob, + device="cpu", + ) + + model.fit( + M=M, + y=y, + lambda_l0=lambda_l0, + lambda_l2=lambda_l2, + lr=lr, + epochs=epochs, + loss_type="relative", + verbose=verbose, + verbose_freq=50, + target_groups=target_groups, + ) # Extract final weights with torch.no_grad(): From 60a87ff7ca8e75abea0f09bb49d37fd92901fc39 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 05:26:48 -0400 Subject: [PATCH 3/3] Prepare controlled four-build reproducibility plan for the UK hub --- jobs/adopt-ud507/BUILD_PLAN.md | 76 ++++ jobs/adopt-ud507/QUEUE_ROW.md | 7 + jobs/adopt-ud507/cached_build.py | 57 +++ jobs/adopt-ud507/compare.py | 375 +++++++++++++++++++ jobs/adopt-ud507/freeze_inputs.sh | 16 + jobs/adopt-ud507/run_build.sh | 116 ++++++ jobs/adopt-ud507/seed_http_cache.py | 56 +++ jobs/adopt-ud507/setup_build_worktree.sh | 52 +++ jobs/adopt-ud507/totals.py | 66 ++++ jobs/adopt-ud507/verify_historical_spread.py | 43 +++ 10 files changed, 864 insertions(+) create mode 100644 jobs/adopt-ud507/BUILD_PLAN.md create mode 100644 jobs/adopt-ud507/QUEUE_ROW.md create mode 100644 jobs/adopt-ud507/cached_build.py create mode 100644 jobs/adopt-ud507/compare.py create mode 100644 jobs/adopt-ud507/freeze_inputs.sh create mode 100644 jobs/adopt-ud507/run_build.sh create mode 100644 jobs/adopt-ud507/seed_http_cache.py create mode 100644 jobs/adopt-ud507/setup_build_worktree.sh create mode 100644 jobs/adopt-ud507/totals.py create mode 100644 jobs/adopt-ud507/verify_historical_spread.py diff --git a/jobs/adopt-ud507/BUILD_PLAN.md b/jobs/adopt-ud507/BUILD_PLAN.md new file mode 100644 index 00000000..04957869 --- /dev/null +++ b/jobs/adopt-ud507/BUILD_PLAN.md @@ -0,0 +1,76 @@ +# #507 reproducibility build plan + +Status: **PENDING HUB INSTALLATION; no builds or microsimulations started by this job.** This workspace cannot write `~/reviews/uk-hub`. The hub must install these artifacts in `~/reviews/uk-hub/jobs/adopt-ud507/` and add the proposed row from `QUEUE_ROW.md` to `~/reviews/uk-hub/rebuild-queue.md`. Run only after the hub replaces that row's Status with a grant containing **`GO mm/dd hh:mmZ`**, for example `**GO 10/12 12:00Z (UK hub).**` The driver requires exactly one `ud#507` queue row, checks its Status before waiting and again before launch, and rejects `NO GO`, `LAPSED`, `EXPIRED` or `REVOKED` even if another GO token is present. + +Run four fresh builds, one at a time: `branch1`, `branch2`, `main1`, `main2`. The branch uses the final reviewed head of `origin/deterministic-calibration`; resolve it once before setup and preserve it in `expected_refs.json`. Both main controls use **`2b9c1c2c64ced6e1d95fed1ed398a22627233669`**. The saved pre-resume merge was `d9d13a182a7fdaa8f38a27f0c19985639e2c5888`; use the final head including the new tests and L0 fix. + +All four use the same frozen HTTP responses and cached imputation models, Python 3.13, the committed `uv.lock` (policyengine-uk **2.122.2**, policyengine-core **3.32.13**), **512 epochs**, **one OA clone**, and **one CPU thread**. `TESTING` and `PYTHONPATH` are unset. No wrapper seeds the process-wide RNG: the branch owns calibration seed 0, while main retains its unseeded weight dropout. + +`setup_build_worktree.sh` derives a temporary copy of the requested harness at `~/reviews/uk-data-spi-reported-2026-10-01/setup_build_worktree.sh`. Its required SHA256 is `3207a17bdfc1e441cba58ba34c063e2413ba7fc2242dcacff25c338c3fcfb1de`. The only functional change removes that harness's automatic `cherry-pick -x 348dd49`: the branch already contains #507, and applying it to main would invalidate the controls. It also adjusts the success message. Setup aborts if the source harness changes, the destination exists, the requested ref changes, the branch lacks the original #507 commit, or main's dropout is seeded. + +Frozen models come from the harness's `prereqs/*.pkl`, except `income_spi_2022_23.pkl`, taken from the 7 October `uk-data-combined-1008` build to match #544's updated metadata. Frozen HTTP responses come from the harness's cache, plus its one missing post-batch HMRC salary-sacrifice CSV. `seed_http_cache.py` checks that CSV against SHA256 `04e0dab6354b01c96d04f9e379f237ea2083476bd9e920c882a236cdcf88101d` and uses the combined build's saved response metadata. No live target fetches are allowed. Any missing cache response, changed model, or changed cache manifest fails the build. + +The hub installs the committed scripts and freezes the refs with these commands. Preserve the existing job's progress and scripts first; keep its other evidence files. + +```zsh +set -euo pipefail +REPO=~/PolicyEngine/policyengine-uk-data +J=~/reviews/uk-hub/jobs/adopt-ud507 +git -C "$REPO" fetch origin deterministic-calibration main +BRANCH_REF=$(git -C "$REPO" rev-parse origin/deterministic-calibration) +MAIN_REF=2b9c1c2c64ced6e1d95fed1ed398a22627233669 +mkdir -p "$J/previous-attempt-20261012" +if [[ -f "$J/PROGRESS.md" ]]; then cp -c "$J/PROGRESS.md" "$J/previous-attempt-20261012/PROGRESS.md"; fi +for FILE in BUILD_PLAN.md QUEUE_ROW.md setup_build_worktree.sh freeze_inputs.sh run_build.sh cached_build.py seed_http_cache.py compare.py totals.py verify_historical_spread.py; do + if [[ -f "$J/$FILE" ]]; then cp -c "$J/$FILE" "$J/previous-attempt-20261012/$FILE"; fi + git -C "$REPO" show "$BRANCH_REF:jobs/adopt-ud507/$FILE" > "$J/$FILE" +done +printf '\n- 2026-10-12: Installed #507 build artifacts at branch %s; code and tests ready, impact pending the four queued builds. Existing progress preserved.\n' "$BRANCH_REF" >> "$J/PROGRESS.md" +python3 - "$J" "$BRANCH_REF" "$MAIN_REF" <<'PY' +import json +import sys +from pathlib import Path +job, branch, main = sys.argv[1:] +Path(job, "expected_refs.json").write_text(json.dumps({"branch": branch, "main": main}, indent=2) + "\n") +PY +``` + +After installing the queue row and receiving GO, use these exact setup and build commands. `freeze_inputs.sh` runs once and refuses to overwrite an existing frozen directory. The four worktrees must be new; cached models are copied from the single frozen snapshot into each. Setup runs the pinned harness's `uv sync --frozen --all-extras --python 3.13`. + +```zsh +zsh "$J/freeze_inputs.sh" "$REPO" +zsh "$J/setup_build_worktree.sh" ud507-branch1 "$BRANCH_REF" branch +zsh "$J/setup_build_worktree.sh" ud507-branch2 "$BRANCH_REF" branch +zsh "$J/setup_build_worktree.sh" ud507-main1 "$MAIN_REF" main +zsh "$J/setup_build_worktree.sh" ud507-main2 "$MAIN_REF" main +zsh "$J/run_build.sh" ud507-branch1 branch1 +zsh "$J/run_build.sh" ud507-branch2 branch2 +zsh "$J/run_build.sh" ud507-main1 main1 +zsh "$J/run_build.sh" ud507-main2 main2 +``` + +Before each build, the driver requires statvfs free disk above 100 GB, compressor below 30 GB, load5 below 36 on three checks 60 seconds apart, no other data build, and `reservation.py allowed uk-hub` exit 0. The hub must retain its GO and report starts and finishes under its queue rules. A task-specific monitor checks free disk every 30 seconds while the build runs; below 30 GB, or if the disk check fails, it sends TERM only to that build's captured child PID and logs the event in `disk_floor.log`. Traps stop the monitor and its own build if the driver exits early. The driver gives up after six hours of waiting without starting a build. Expect approximately 1.2 GB of retained artifacts per build; gzip logs and delete storage build copies after each build. Raw survey prerequisites remain the same shared harness inputs. + +Then run four microsimulations sequentially in the same locked environment, followed by comparison. `totals.py` checks the thread caps, compressor and engine versions before constructing a microsimulation. Re-read the hub's reservation and running-process state before this finisher stage. + +```zsh +export OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 +export VECLIB_MAXIMUM_THREADS=1 NUMEXPR_NUM_THREADS=1 +export OMP_WAIT_POLICY=PASSIVE PYTHONHASHSEED=0 +unset TESTING PYTHONPATH +W=~/PolicyEngine/_worktrees/ud507-branch1 +for LABEL in branch1 branch2 main1 main2; do + "$W/.venv/bin/python" "$J/totals.py" "$J/builds/$LABEL/enhanced_frs_2024_25.h5" "$J/impact/$LABEL.json" +done +"$W/.venv/bin/python" "$J/compare.py" +``` + +`compare.py` writes `RESULT.md` and `RESULT.json`. PASS requires exact recorded refs and committed calibration source, matching locks/settings/frozen manifests, successful builds, no target-cache misses or OBR receipts fallback, and complete nonempty weight arrays. Branch constituency and local-authority weight arrays must have identical shapes, dtypes and bytes, and branch dataset tables and decompressed long-geography content must match. Main's weight arrays must differ. All four pre-calibration FRS tables and eFRS person/benefit-unit tables must agree; other input noise fails the comparison. Aggregate JSONs must match their dataset hashes and engine versions. The report gives both builds' income tax and household net income totals and their absolute spread for 2025 and 2026. Impact remains pending these queued builds; do not present the historical measurements as the controlled experiment's result. + +The PR's rounded historical figures are verified by reading saved aggregate JSONs only: + +```zsh +python3 "$J/verify_historical_spread.py" +``` + +Sources are `~/reviews/frs-cvpay-property-income-2026-09-30/impact/efrs_{main_matched,main_matched_2,main_a,main_b}.json`. All report policyengine-uk head `44240bd8d7d7e97f2980ccc8134e76e61541966f`. Across those four outputs, 2026 income tax ranges from £312.411684864bn (`main_b`) to £313.196281856bn (`main_matched`): **£0.784596992bn**. Household net income ranges from £1756.085878784bn (`main_a`) to £1759.036964864bn (`main_b`): **£2.951086080bn**. These support the quoted roughly £0.8bn and £3bn maxima; the maxima use different pairs. The verifier asserts the shared engine head and both spreads without running a build or simulation. diff --git a/jobs/adopt-ud507/QUEUE_ROW.md b/jobs/adopt-ud507/QUEUE_ROW.md new file mode 100644 index 00000000..546e1cf9 --- /dev/null +++ b/jobs/adopt-ud507/QUEUE_ROW.md @@ -0,0 +1,7 @@ +# Proposed rebuild queue row + +Status: **PENDING HUB INSTALLATION**. The external queue is read-only in this job. The hub should append the single row below to `~/reviews/uk-hub/rebuild-queue.md`, replace `NEXT` with its next unused row number, and resolve the final reviewed branch head into the installed job's `expected_refs.json` before giving GO. Replace Status with a grant containing `GO mm/dd hh:mmZ`; remove the pending `NO GO` text. No build or microsimulation has been started. + +| # | Item | Owner | Precondition | Status | +|---|---|---|---|---| +| NEXT | ud#507 calibration reproducibility: FOUR fresh builds, one at a time (`branch1`, `branch2`, `main1`, `main2`), then four sequential aggregate microsims. Branch = final reviewed `origin/deterministic-calibration`; both main controls = `2b9c1c2c64ced6e1d95fed1ed398a22627233669`. Pinned hub setup harness with automatic #507 cherry-pick omitted, shared frozen HTTP and pkl manifests, 512 epochs, one OA clone, one CPU thread, policyengine-uk 2.122.2/core 3.32.13. Compare weight bytes and 2025/2026 income tax/household net income spreads; approximately 1.2 GB retained per build. Runbook: `~/reviews/uk-hub/jobs/adopt-ud507/BUILD_PLAN.md`; preserve the PR as WIP draft until impact is measured. | UK hub (adopt-ud507; code job prepares artifacts only) | Hub installs artifacts/refs and writes GO in this row; one build at a time; statvfs free >100 GB; compressor <30 GB; load5 <36 three checks; reservation.py allowed uk-hub exit 0; same frozen inputs; main controls retain unseeded calibration. | **PENDING HUB INSTALLATION — NO GO.** Code/tests ready; four builds and impact measurements pending. | diff --git a/jobs/adopt-ud507/cached_build.py b/jobs/adopt-ud507/cached_build.py new file mode 100644 index 00000000..fdc77525 --- /dev/null +++ b/jobs/adopt-ud507/cached_build.py @@ -0,0 +1,57 @@ +"""Run the hub's dataset command with a frozen, read-only HTTP cache. + +Unlike the original harness, a missing response terminates the build instead +of fetching a live target. No random generator is seeded by this wrapper. +Usage, from a build worktree: python cached_build.py +""" + +import hashlib +import json +import runpy +import sys +from pathlib import Path + +import requests +from requests.structures import CaseInsensitiveDict + + +def main(): + cache, http_log = Path(sys.argv[1]), Path(sys.argv[2]) + + def cached_get(url, **kwargs): + key = hashlib.sha256( + json.dumps( + [url, kwargs.get("params")], sort_keys=True, default=str + ).encode() + ).hexdigest()[:24] + meta_path, body_path = cache / f"{key}.json", cache / f"{key}.body" + if not meta_path.exists() or not body_path.exists(): + with http_log.open("a") as log: + log.write(f"MISS frozen cache {url}\n") + # Target loaders catch ordinary exceptions and silently fall back. + # SystemExit stops the build before that can change its target set. + raise SystemExit(f"Frozen target cache has no response for {url}") + meta, body = json.loads(meta_path.read_text()), body_path.read_bytes() + response = requests.Response() + response.status_code, response._content, response.url = ( + meta["status"], + body, + meta["url"], + ) + response.headers = CaseInsensitiveDict(meta["headers"]) + response.encoding, response.reason = meta["encoding"], meta["reason"] + with http_log.open("a") as log: + log.write( + f"cache {meta['status']} {len(body)}b " + f"{hashlib.sha256(body).hexdigest()} {url}\n" + ) + return response + + requests.get = cached_get + runpy.run_path( + "policyengine_uk_data/datasets/create_datasets.py", run_name="__main__" + ) + + +if __name__ == "__main__": + main() diff --git a/jobs/adopt-ud507/compare.py b/jobs/adopt-ud507/compare.py new file mode 100644 index 00000000..ecce29ed --- /dev/null +++ b/jobs/adopt-ud507/compare.py @@ -0,0 +1,375 @@ +"""Compare #507's four builds. Aggregates only: counts, totals and hashes, never records. + +PASS needs all of: +- provenance: every build exited 0; branch1/branch2 share the branch commit and the seeded calibrate.py, main1/main2 + share the main commit and the unseeded one; clean trees; the same thread settings and cached models; every target + download served from the cache (no MISS) with the same bodies in all four; no OBR receipts failure (notice 7); +- branch1 and branch2: byte-identical constituency and local authority weights, identical eFRS and FRS tables, + identical long-geography file, and identical income tax and household net income totals; +- main1 and main2: different weights (main's dropout is unseeded). +It reports main's spread in income tax and household net income, and how far the weights moved. + +Usage (with a build worktree's venv, which has h5py, pandas and tables): + ~/PolicyEngine/_worktrees/ud507-branch/.venv/bin/python compare.py +Reads builds/