Skip to content

Port the UK stochastic layer as declarative source stages - #709

Merged
juaristi22 merged 6 commits into
mainfrom
uk-stochastic-680
Aug 18, 2026
Merged

Port the UK stochastic layer as declarative source stages#709
juaristi22 merged 6 commits into
mainfrom
uk-stochastic-680

Conversation

@juaristi22

Copy link
Copy Markdown
Collaborator

Workstream E4 of the UK migration (#145, master epic #665). Closes #680. Stacked on #703 (E3); base flips to main when #703 merges.

What

Ports the incumbent stochastic layer as four declarative source stages appended after frs_education_grant_split, replacing the incumbent's sequential RNG streams with identity-keyed draws (blake2b over seed:variable:entity_id, the US _stable_unit_draws arithmetic) — the standing RNG signed-difference class; parity is weighted-distributional only, as UKDS disclosure rules require anyway:

  • frs_take_up (benunit) — reported-receipt anchored take-up for child benefit / pension credit / universal credit (the incumbent assign_takeup_with_reported_anchors semantics exactly: anchors certain, int(rate·n) unweighted full-population target, adjusted residual, overshoot accepted; anchors fail loud on a missing *_reported source column), plain Bernoulli take-up for opts-out/TFC/the three childcare programs, and the clipped-normal extended-childcare-hours draw (inverse-CDF).
  • frs_person_draws (person) — marriage allowance, age-banded SCP (0.97 under 6 / 0.85 at 6+), and the persisted private-school uniform.
  • frs_household_draws (household) — TV ownership, TV-licence evasion, first-time-buyer, property purchase.
  • frs_brma (household) — LHA_category materialized through the rules-engine adapter (consumed, never persisted), benunit-level inverse-CDF sampling from the committed VOA count table, household collapse by identity-keyed uniform member pick.

Supporting: the country-agnostic kernel microcosm/build/stochastic_assignment.py + six generic operation kinds (assign_binary_with_anchored_residual, assign_binary_from_banded_rates, assign_uniform_draw, assign_clipped_normal, aggregate_person_to_benunit, sample_categorical_from_count_table); the cited rate contract build/uk/take_up_contract.json (status vocabulary incl. frozen_by_adjudication for UC 0.55 per U8/uk-data#452 and fitted_offline with receipts; loader refuses unreviewed rates; year≤build-year selection semantics); build/uk/brma_rent_counts.json (60 region×LHA-category cells, 200 BRMAs, 1,257,744 observations — count-weighted sampling ≡ the incumbent's uniform row-sampling incl. the unfiltered 2019+2020 pooling; regenerator tools/build_uk_brma_count_table.py; chronicle provenance registration pending); the delivered take-up gate (take_up_signal added to the shared vocabulary; uk_take_up_signal release-blocking at ±0.05 weighted share vs contract targets — the universal-take-up constant-column landmine catcher) and uk_brma_enum_domain (release-blocking, engine enum domain); the U1 pin fix (rules_engine.version now real via importlib.metadata, was "unknown"); driver sidecar declared_seeds + stochastic_contract_sha256; the household_is_ provenance-flag convention helper (flags themselves land in E7/E8); acceptance instruments tools/verify_uk_identity_stability.py (row-permutation identity at full scale) and tools/emit_uk_brma_distribution.py; and the seeded childcare fitting tool port tools/fit_uk_childcare_takeup.py (explicit default_rng, local H5 input, receipt-emitting, not in the build — U4).

UK-specificity note (required by #680)

Generic contributions: the stochastic-assignment kernel, the six operation kinds, and the take_up_signal gate name live in shared modules/vocabularies, usable by any country. UK-specific: the two build/uk/ resources (program rates and BRMA geography are UK), the four uk_runtime transforms, the gate declarations/bindings, and the childcare fitting tool (UK program semantics). The incumbent package name appears nowhere in the tree.

Signed differences

  1. RNG-stream class (standing): every unit-record draw differs from the incumbent by construction — identity-keyed blake2b (seed:variable:entity_id) replaces the sequential default_rng(100)/default_rng(0) streams. Parity is weighted-distributional only. Subsumes the extended-hours inverse-CDF and BRMA count-table variates (distributionally identical). Incumbent seeds (take-up 100, brma 0, property 0) recorded as documentation in the contract.
  2. higher_earner_tie_break dropped (adjudicated with María): no consumer in policyengine-uk (the deterministic age tie-break replaced it), absent from both parity instruments; removed from both export registers; a guard test asserts the engine still has no such variable.
  3. TV-evasion independence documented: drawn independently of household_owns_tv although the incumbent YAML phrases the rate conditionally; the reference share 0.105666 matches the independent draw at the 2023-applicable 0.1058 rate — ported as-is.
  4. Take-up target denominator documented: int(rate × all units), unweighted, full entity population — not the "share of eligible" the incumbent YAML descriptions claim; overshoot when reporters exceed the target accepted silently — ported as-is, recorded in the contract policy string; the int() floor is test-pinned.
  5. Parameter literals promoted: the property-purchase rate 0.0385 (HMRC/ONS derivation) and the extended-hours normal (15.019, 4.972, clip [0, 30]) move from code literals to cited contract entries — values unchanged.

Licensed-data acceptance (all data stayed local; disclosure-safe aggregates only)

  • Determinism: two independent full builds from the pinned 2023-24 tabs; compare_uk_h5_payload.py verdict payload-identical; shares files byte-identical; both attempts landed hash-chained Logbook rows. A first attempt failed at the configured phase on the driver's pin collector (it assumed every stage artifact was a raw-tab pin and hit frs_brma's resource artifact) — fixed with resource references split into a sidecar resource_pins key, receipted in acceptance_receipt.json.
  • Row-permutation identity at full scale (the instrument the e3 dir lacked): every E4 column recomputed from the pure derivations in original and permuted row order, un-permuted by entity id — identical, and equal to the stored artifact columns (permutation_identity_receipt.json, seed 123). The instrument's first version wrote a stub receipt without checking anything; rewritten to verify before sign-off.
  • Nonzero shares vs the committed 145-column reference: all 17 E4 columns within ±0.02 (max |Δ| 0.0088, would_claim_targeted_childcare); exact 1.0 for brma and attends_private_school_random_draw.
  • Weighted means vs contract rates: max |Δ| 0.0073 absolute across the 13 Bernoulli columns — well inside the uk_take_up_signal ±0.05 band.
  • Weighted totals vs the licensed 131-column register: 12 of 16 covered columns beyond ±2% relative (max −14.9%). Investigated, not waved through: our side matches the contract rates tightly (previous bullet), while the register was extracted from the calibrated certified artifact whose weights are heavy-tailed (weight-ratio ≈1590, ESS ≈1%), so an independently redrawn Bernoulli column's weighted total deviates from the incumbent's realization at exactly the observed 3–15% scale. Falls under the standing RNG-stream signed-difference class; per-column deltas recorded in acceptance_checks.json.
  • BRMA distribution at the sampler's native margin: benunit-level assignment vs each (region, LHA category) cell's conditional distribution — max |z| 3.66 over ~1,400 unmasked comparisons, within multinomial expectation (SDC min count 3). An earlier region-margin draft misleadingly showed double-digit z on a correct sampler and was rewritten before sign-off. brma has no weighted-register row by design; this is its acceptance instrument.
  • Build records carry rules_engine.version: 2.89.0 (the U1 pin, formerly "unknown"), declared_seeds per stage/output, stochastic_contract_sha256, and resource_pins.

Merge coordination with #706

#706 (the #630 close-out) and this PR both edit uk/gates.json and re-cut the _UK_GATE_BATTERY_* fingerprints in microcosm-data/contract.py. The entry edits are disjoint (this PR adds uk_take_up_signal/uk_brma_enum_domain + the tie-break export removal; #706 rewrites the weight-ratio/input-mass/tail-concentration parameters). Whoever lands second: union the entries, take this PR's take-up sentence + #706's weighted-integrity sentence in the policy string, and re-run the fingerprint recompute (test_gate_battery_contract_pins drives it). Recipe agreed between sessions on 2026-08-17.

Implementation notes

Codex implemented under /codex-implement-plan; one recorded deviation, reviewed and kept: the BRMA enum-domain evidence falls back to a frame-derived domain only when the injected engine exposes no _variable("brma") (engine-less test doubles); the production adapter exposes it, and the gate evaluator fails closed when neither source resolves. Post-review fixes on top: the plan-declared nonnegative_outputs for attends_private_school_random_draw restored with its lockstep constant/test; take-up anchor aggregation made fail-loud on a missing *_reported source column (with test); the BRMA household collapse rewritten from an O(households×benunits) scan to a groupby (identical semantics). The licensed run then surfaced three defects PR CI could not see, each fixed and test-covered: the driver's pin collector crashing on frs_brma's non-tab resource artifact (now split into sidecar resource_pins, with the synthetic driver spec mirroring the real artifact so the path stays exercised); the identity-stability instrument writing a stub receipt instead of verifying (now recomputes, permutes, and compares against stored columns, exiting nonzero on mismatch); and the BRMA distribution diagnostic comparing at a misleading region margin (now the native cell margin with per-row z).

🤖 Generated with Claude Code

juaristi22 and others added 6 commits August 18, 2026 10:19
Four stages appended after the FRS derived layer: frs_take_up
(reported-anchored benunit take-up plus the clipped-normal extended-hours
draw), frs_person_draws, frs_household_draws, and frs_brma (LHA_category
through the rules-engine adapter, count-table BRMA sampling with an
identity-keyed household collapse). Identity-keyed blake2b draws replace
the incumbent's sequential streams as a standing signed-difference class;
rates land in the cited build/uk take-up contract (UC 0.55 frozen per U8)
and the VOA BRMA count table with its regenerator. Delivers the
take_up_signal gate into the shared vocabulary plus a brma enum_domain
declaration (both release-blocking), drops the dead higher_earner_tie_break
from both export registers, pins the real policyengine-uk version into
build records, adds declared_seeds and the contract sha to the driver
sidecar, ships the household_is_ provenance-flag convention helper, the
row-permutation identity and BRMA distribution acceptance instruments, and
the seeded childcare fitting tool (offline, receipt-emitting).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The frs_brma stage declares its count table as a non-tab resource artifact;
the driver's pin collector assumed every artifact was a raw-tab pin and
failed on the first licensed run. Tab pins now skip resource entries, and
resource references land in the sidecar as resource_pins carrying the
country-package sha, fail-closed when the resource is undeclared. The
synthetic driver spec mirrors the real manifest's resource artifact so the
split stays exercised without licensed data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tool's main() wrote a stub receipt ("requires caller-supplied
transform") without checking anything. It now recomputes every E4 column
from the pure derivations in original and permuted row order, un-permutes
by entity id, compares both against each other and against the stored
artifact columns, reports missing stored columns explicitly, and exits
nonzero on any mismatch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The distribution diagnostic compared household BRMA shares per region
against the rents table's own regional margin — mixing our benunit LHA
category composition with the table's lettings-weighted composition, which
read as double-digit z-scores on a correct sampler. The comparison now
re-derives the benunit-level assignment and checks each (region, LHA
category) cell against its conditional distribution, reporting per-row z
and the max |z|, with the same SDC masking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four PR-CI failures none of which the local build-shard run could see:

- test_contract.py holds hand-mirrored copies of the gate-battery pins that
  microcosm-data cannot import from microcosm-build. E4 re-cut the triple in
  contract.py but not the mirrors, and did not add its two entries to the
  fixture's entry table, so the lockstep test and the end-to-end release
  report both failed. Mirrors re-cut against the live producer
  (test_gate_battery_contract_pins drives it), both entries added in
  manifest order with fixture details mirroring what each gate emits.
- test_uk_brma_resource pinned the regenerator against a CSV path inside a
  local session scratch directory, so it could only ever pass on the machine
  that staged it. The staged input is now located via UK_LHA_RENTS_CSV and
  the regeneration check skips when it is absent; the committed resource's
  sha256, row count, and cell/BRMA counts stay pinned in CI by the
  source-facts test. The regenerator tool takes the same override.
- The driver sidecar test called metadata.version("policyengine-uk")
  directly, which raises in the engine-hermetic lane. It now resolves the
  expected value the way the driver does, so it holds with or without the
  engine installed and still fails on the "unknown" the U1 pin fix removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@juaristi22
juaristi22 marked this pull request as ready for review August 18, 2026 08:49

@vahid-ahmadi vahid-ahmadi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked the same way as E2/E3 — kernel and every transform against the incumbent's code, rates against the incumbent's parameter files — and this is the cleanest of the three ports so far.

Verified faithful: assign_binary_with_anchored_residual reproduces assign_takeup_with_reported_anchors exactly (int(rate·n) full-population target, max(0, …) residual, adjusted rate over non-anchored rows, silent overshoot — all four quirks preserved and the int() floor test-pinned); the benunit anchor aggregation matches the incumbent's _reported_benunit_mask (any member with a positive reported amount); every rate in take_up_contract.json matches the incumbent YAMLs including the 2023-applicable selections (TFC at 0.586 with the 2024-04-06 0.88 correctly not selected, evasion at 0.1058, FTB at 0.384, property purchase 0.0385 with the HMRC/ONS derivation carried over); SCP banding, the clipped-normal parameters, and the BRMA count-table sampling (≡ the incumbent's uniform row-sampling over the pooled observations) all line up; the household collapse is a uniform member pick, same as the incumbent's sample(n=1) modulo the standing RNG class. The signed differences are drawn exactly where they should be — 3 and 4 in particular document incumbent quirks (evasion drawn independently despite the conditional YAML phrasing; the unweighted full-population take-up denominator) instead of silently "fixing" them, which is the right call for a parity-first port. higher_earner_tie_break's removal with an engine guard test is well-warranted. All the PR's test files pass for me with the uk extra.

The acceptance section deserves a specific word: reporting that the permutation-identity instrument's first version wrote a stub receipt without checking anything, and that an earlier BRMA margin draft produced misleading z-scores on a correct sampler — both rewritten before sign-off — is exactly the receipts culture working. An instrument that has never failed is an instrument you haven't tested; documenting the discarded drafts is what makes the surviving receipts credible.

Three observations, none blocking:

  • stable_identity_uniforms is a per-id Python loop over blake2b. Fine at FRS scale (~36k persons × ~17 columns), but this kernel is country-agnostic and E7's clone stack — and eventually populace-scale consumers — will multiply it into tens of millions of hash calls per build. Worth a note in the module (or an issue) that the loop is the known hot spot with a straightforward vectorization (hash into a bytes array, np.frombuffer), so the first person who hits it at scale isn't re-deriving the fix under pressure.
  • The uk_take_up_signal gate's target for the three anchored columns is the raw contract rate, but the assignment targets an unweighted count with anchor overshoot accepted. If reporters are weight-skewed (plausible for pension credit), the weighted share can drift from the rate legitimately — today's margin is comfortable (max |Δ| 0.0073 against a 0.05 band), but when this gate eventually fails on an anchored column, the first diagnostic question will be "regression or weight-skew?". One sentence in the entry notes distinguishing the two would save that future debugging session.
  • The #706 merge-coordination recipe is clear and the entry edits are indeed disjoint — whoever lands second, the fingerprint recompute test makes the union mechanical. No concern.

@juaristi22
juaristi22 merged commit 07e1752 into main Aug 18, 2026
4 checks passed
juaristi22 added a commit that referenced this pull request Aug 18, 2026
…on the rebased battery

Two review threads, one commit.

vahid-ahmadi asked whether the shared input-mass relative_tolerance, minted
at the worst surviving drift, leaves the fence too loose for the other 127
columns given that its edge column looks explained rather than noisy. The
full comparison surface is now recorded in the receipts doc: 128 columns at
median 0.24 / p90 0.63 against a 4.5218 fence, a smooth decay with no natural
cut point, and the exclusions-versus-tolerance tradeoff (a 5-10x tightening
needs 7 to 30 receipted columns, not the three-to-five the review sketched;
conversely only four columns sit above 1.5, so the register would not bloat
for a modest gain). Today exactly one top drift carries a documented
explanation (#703's head-to-head receipt, expiring naturally at the E10
recomposition), and excluding it alone buys 1.48x. The tolerance therefore
stays at the measured edge, with the limitation recorded in the entry notes
rather than silent, and the tightening path tracked as follow-up work gated
on evidence per column.

The #709 rebase also aged the confirmation receipt: two new release-blocking
entries (uk_take_up_signal, uk_brma_enum_domain) meant m5's 14-gate report no
longer covered the battery. Re-run as m6 on the rebased branch: all 16 gates
green (13 passed, the three parity gates evidence_absent, none failed), and
m6's artifact is payload-identical to m5's — the rebase moved policy only,
not one byte of built data. The build record is re-cut from m6 with the
16-gate signed report embedded.

Refs #706, #709, #703, #630.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
juaristi22 added a commit that referenced this pull request Aug 18, 2026
Both from vahid's review of #709, neither a defect:

- stable_identity_uniforms is a per-id Python loop. Measured ~1.8M ids/s:
  negligible at FRS spine scale (0.35s for 36k x 17 columns), ~15s for a
  country-agnostic caller at 1.6M x 17. The docstring now records the
  measurement and the vectorization path, with the warning that the key
  string and digest size must stay byte-identical or every committed draw
  moves.
- The take-up gate compares a weighted share against a rate the assignment
  targets unweighted, so weight-skewed reporters can drift it without any
  regression. The triage step (check the unweighted share first) is in the
  gate's own docstring, where whoever reads the failure will land, rather
  than in the gates.json notes where it would move the battery fingerprints
  for a comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Port the UK stochastic layer: take-up anchoring, identity-keyed draws, LHA/BRMA, provenance flags

2 participants