Skip to content

Calibrate the QRF sign gates out of fold, starting with the LCFS chain - #1391

Draft
juaristi22 wants to merge 17 commits into
uk-excise-quantities-1359from
qrf-gate-calibration
Draft

juaristi22 wants to merge 17 commits into
uk-excise-quantities-1359from
qrf-gate-calibration

Conversation

@juaristi22

Copy link
Copy Markdown
Collaborator

Follows Vahid's review of #1381 (#1359).

Microcosm's regime-gated QRF decides whether each recipient row is zero or positive with a boosted classifier, the sign gate. The gate is calibrated only on the donors it was fitted to, so on every other row it draws rare outcomes too rarely.

This PR adds an opt-in that calibrates each gate out of fold, and turns it on for the LCFS consumption chain only. Every other UK stage and every US fit keep today's gate, and their draws do not move.

Stacked on #1381, which is stacked on #1383. This PR gets no CI until both merge. Merge them without deleting their branches, or this PR closes with them. The tests below ran locally on the stack.

The problem

Addressing Vahid's review of #1381 showed that the excise shares the LCFS chain draws lose incidence on the frame. Tobacco sat on 8.2% of frame households against 10.3% of donors, and betting and gaming on 2.0% against 3.6%.

Held-out tests trace it to the gate, not to the frame.

  • A row is drawn positive when its keyed uniform falls under the gate's probability, so the share of positive draws is the gate's mean probability.

  • The gate is a default HistGradientBoostingClassifier, fitted and calibrated on the same donors: 100 rounds, 31 leaves, 20 rows a leaf, and no early stopping below 10,000 rows. It memorises a rare class's donors, so on rows it was not fitted to, its mean probability of that class falls short of the class's rate.

  • Out of fold on the 4,995 LCFS 2024-25 donors, the current gates' mean probability over the true rate is:

    • education 0.42, solid fuels 0.11 and liquid fuels 0.81;
    • tobacco 0.73, gambling stakes 0.82 and betting and gaming 0.88.

    For education, solid fuels and gambling stakes, the held-out log loss is worse than a constant at the class rate.

On the frame, rare chain targets lose incidence against the donor. In a stage-only run on #1333's test spine, at prior weights:

  • solid fuels: 0.33% of households, against 2.47%;
  • liquid fuels: 3.09%, against 4.54%;
  • education: 4.31%, against 5.83%;
  • tobacco: 8.16%, against 10.34%;
  • betting and gaming: 1.96%, against 3.58%.

How the QRF model changes

An opt-in on RegimeGatedQRF. RegimeGatedQRF(gate_calibration=...) takes a name from the new GateCalibration constants.

  • in_sample, the default, is the gate as before, byte for byte.
  • cross_fitted works in four steps:
    1. It cuts the training rows into five folds, stratified by class.
    2. For each fold, it fits a gate the same way, with the same classifier and weights, on the other four folds, and that gate scores the fold it left out.
    3. It fits one weighted calibration on the pooled out-of-fold scores: a slope and a per-class offset on the gates' log-probabilities. For two classes this is Platt scaling; for three-sign gates, a shared temperature with class offsets. At the fit, the calibrated out-of-fold probabilities' weighted mean equals each class's weighted rate.
    4. At draw time, each row's probability is the mean of the five mapped fold gates.

What does not change.

  • The fold assignment and the fold seeds derive from the one seed the in-sample gate takes, so the fit's random stream advances by the same draw. Every magnitude forest, every bootstrap and every keyed uniform is identical; only which side of zero a row falls on moves.
  • Every draw path takes the cross-fitted gate unchanged: predict, predict_from_uniforms, keyed fit_draw_next and conditional_uniforms. predict_positive_from_uniforms bypasses the gate, as before.
  • A class with fewer rows than folds cannot appear in every fold, so its target keeps the in-sample gate.
  • QRFChainState records the calibration, but writes it into a checkpoint's model_config only when it is cross-fitted. So in-sample checkpoints are byte-identical, the US banks and PUF chain included. Resuming under a different calibration is refused.

Why this form. I compared the candidates out of fold on the LCFS donors, over one five-fold pass. The figure is the mean over the seven rare targets of |log(mean probability / rate)|:

  • current gate: 0.578;
  • regularisation alone (early stopping, 25 rounds, or small trees): 0.09 to 0.12;
  • a logit shift that matches the out-of-fold mean to the rate: 0.117, or 0.028 on an early-stopped gate;
  • one calibration map applied to a gate fitted on every row: 0.066. It overshoots the rarest targets.
  • the cross-fitted fold ensemble: 0.005, with the lowest log loss on every target, common ones included.

It is written in qrf.py, not through sklearn's CalibratedClassifierCV. sklearn's stratified splits, and HistGradientBoostingClassifier's own early stopping, fail on a class with too few rows.

Where it runs: only the LCFS chain

  • lcfs_consumption's fit_weighted_qrf_chain declares gate_calibration: cross_fitted, which the schema allows as an enum on that operation. impute_lcfs_consumption passes it to the model.
  • Nothing else opts in. The other UK stages, the LCFS road-fuel redraw and prepayment flag, and every US fit keep the in-sample gate.
  • A control run of the LCFS stage, with the new qrf.py and no declaration, reproduces Write policyengine-uk's alcohol, tobacco and gambling duty inputs from the LCFS stage #1381's stage run byte for byte.

Results on the frame

This is a stage-only run of lcfs_consumption on #1333's test spine (78,440 households, policyengine-uk 2.151.0), the control against this head. All eight LCFS stage-health gates pass in both.

Rare targets. Share of households with the spending, at prior weights, control → head, with LCFS donors in brackets:

  • solid fuels: 0.33% → 2.25% (2.47%);
  • liquid fuels: 3.09% → 3.88% (4.54%);
  • education: 4.31% → 6.65% (5.83%);
  • tobacco: 8.16% → 11.16% (10.34%);
  • gambling stakes: 14.63% → 16.70% (17.03%);
  • National Lottery: 10.68% → 10.85% (11.42%);
  • betting and gaming: 1.96% → 2.63% (3.58%).

Common targets. The in-sample gates also under-drew the zeros of the near-universal divisions. With those zeros drawn at their rate:

  • Incidence moves towards the donor's:
    • food: 99.67% → 99.05%, against 99.15%;
    • recreation: 98.98% → 97.10%, against 97.27%;
    • miscellaneous: 98.43% → 97.30%, against 97.31%.
  • The twelve divisions' prior-weighted total falls 1.4%. The seven divisions held at the shared frame-to-donor ratio fall 1.42% with it, and restaurants and hotels falls 4.2%.
  • On-trade alcohol: 30.37% → 29.25% (31.01%).

Excise inputs. They keep their national levels; who carries them changes:

  • Tobacco duty is about 1.8 times tobacco spending, from 2.2, on 11.2% of households.
  • The other gambling duties are 5.5 times betting and gaming stakes, from 6.4, on 2.6%.
  • Lottery Duty is 0.61 times National Lottery stakes, from 0.56.
  • Alcohol duty is 0.45 times alcohol spending, from 0.41.

The stage notes carry the new figures.

Columns and cost.

  • 42 of the stage's 46 written columns move: all twenty chain targets and 22 of the 24 excise inputs.
  • The stage takes 264 s against 110 s, since each gate is now five.

Read before reviewing

  • Re-pins. Any byte edit of qrf.py moves these, so they are re-pinned:

    • the fit.qrf graph-parity pins (its draws are positive-only and do not move);
    • the spec-loader golden vector;
    • the US seed protocol and seed map digests, with the regenerated F0 coverage report. The second commit does this and cites 1734b9e;
    • the US bundle hash in the multispine pool tool test.

    Only CI's engine-us and engine-workflow tiers check the US ones.

  • What remains is the chain's own. The chain conditions each target on its own earlier draws, not on a donor's spending. Out of fold, with the chain's earlier draws as features:

    • liquid fuels are drawn at 0.75 of their rate;
    • the National Lottery at 0.88;
    • betting and gaming at 0.91.

    On the frame, tobacco now sits above the donor. No gate calibration reaches that.

  • Write policyengine-uk's alcohol, tobacco and gambling duty inputs from the LCFS stage #1381's receipts and changelog record the in-sample figures as of Write policyengine-uk's alcohol, tobacco and gambling duty inputs from the LCFS stage #1381; this PR's changelog records the change.

Should the other gated fits move too?

This PR turns the calibration on only where it was measured. Every other gated fit keeps the in-sample gate, and is likely to under-draw its rare classes on recipients in the same way:

  • UK:
    • etb_services, etb_vat and nts_bus_travel;
    • spi_housing_shell;
    • spi_income, stages 1 and 2, and through them spi_rank_transport;
    • uc_reporter_redraw, was_wealth and salary_sacrifice;
    • the LCFS road-fuel redraw's petrol share and the prepayment flag.
  • US:
    • puf_qrf_chain and acs_transfer;
    • about twenty predict() fits, among them child support, childcare, disability benefits, retirement contributions and distributions, SCF wealth and auto loans, and the SIPP fits.

Do we want to measure each of these with the same held-out harness and opt in the ones that need it, stage by stage? A global change of the default would do more than move every gated draw:

  • it would move the US exact-draw pin in test_spec_engine_seeds.py;
  • it would let resumed UK graph builds reuse stage outputs from the old gate, because UK stage hashes do not include qrf.py.

Opting in per stage avoids both, because a stage's declaration changes its hash.

Tests

Run locally on the stack, targeted to the touched surfaces, with JUnit counts and no failures or skips:

  • microcosm-fit: 149, 10 of them new;
  • graph parity pins, H parity and the executor: 105;
  • the spec loader, 10; inventory coverage and seeds, 24; and spec_engine_coverage.py --check;
  • engine-free UK and spec, 531:
    • the LCFS stage, 133, with one new test;
    • stage health and source stages;
    • coverage and the UK graph;
    • re-emission, country spec and bundles;
  • the engine tier, 64: H2 UK spine parity on the regenerated fixture, the engine excise inputs and engine coverage;
  • the coverage manifest's --check and ci_test_plan verify.

Not run here, because they need the US engine: engine_contract/us/test_spec_engine_coverage_tool.py and engine_workflow/us/test_us_multispine_pool_tool.py. The wheels job did not run either.

🤖 Generated with Claude Code

juaristi22 and others added 17 commits October 24, 2026 09:58
microcosm#1359, step A1. The chain draws two shares after its twenty
targets (the eighteen spend targets and the two heating fuels): tobacco's share of the off-trade alcohol and tobacco that
make up P602, and on-trade alcohol's share of restaurants and hotels
(P611). The derive step declares them and computes them on the donor from
the adult diary lines (LCF 2024-25 Volume F), with refunds as zero, a zero
denominator as zero and the drinks past P611's one-decimal rounding held
at one. A household's tobacco, off-trade and on-trade alcohol spending is
its share of the total it drew, so the parts sum to the totals and no
total, the VAT base included, moves; appended last, the shares leave the
twenty draws bit-identical (tested against the chain without them).

The shares and the spending they give are scratch, on no table and outside
the support clip. A report-only excise_shares receipt sets each share on
the frame beside the donor's, in bands and withheld shares only. The spec,
schema and source_stages.json declare the shares; the coverage manifest's
source hash and the H2 fixture tool's synthetic donor follow. The H2
fixture itself is regenerated once, at the end of the PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ared

microcosm#1359, step A2. The step gives each household its part of each
national excise quantity in proportion to its spending at prior weights:
alcohol volumes by its off- and on-trade alcohol spending (in proportion
to the two, or each channel's published share of the volume to that
channel's spenders), tobacco clearances by its tobacco spending. Each
policyengine-uk input it writes names its published row, unit, share of
the row and, for hectolitres of alcohol, the strength (with the engine
parameter it mirrors) that turns them into litres of the product. The
fiscal year is the one policyengine-uk charges excise over, so no bridge
is needed; weekly inputs are the annual quantity over the engine's 52
weeks. Outputs that share a row must take all of it.

The one-factor scaling level_road_fuel used is now _scale_to_level, shared
by both and unchanged for road fuel. The excise_quantities stage-health
check recomputes every level from the vendored rows through the stage's
own declaration and holds the frame totals, factors, incidence, basis,
share range, share support and the published total's residual beside
its lines to the receipt. Nothing declares the step yet: its facts wait
for PolicyEngine/chronicle#381, so its tests read synthetic rows
(test_support/microcosm_build/uk_excise_facts.py), never publisher ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
microcosm#1359, step A3. The export-surface gate checks a column only
against the enhanced-FRS reference, so an engine test holds the excise
step to policyengine-uk 2.145.0: every input it can write is a household,
yearly, float input with no formula; the engine's 52 weeks are the step's;
each declared strength equals the engine parameter it names; and alcohol
and tobacco duty are positive, proportional to the quantities and in
household tax for 2024, 2025 and 2026.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
microcosm#1359. Chronicle applies the publisher's scale, as its
hydrocarbon-oils package does: OBR's thousand hectolitres of alcohol,
million sticks and thousand kilograms arrive as litres of pure alcohol,
sticks and kilograms, with the published unit kept only as a dimension
(PolicyEngine/chronicle#381, branch uk-excise-facts-381). The step
declared the published units and scaled by 1e5 and 1e6, which would have
put the alcohol volumes 1e5 too high. It now declares the stored units
(litre_of_pure_alcohol, litre, stick, kilogram, scaled by one or a
thousand) and refuses a row whose own unit is not the declared one, so a
value can no longer be read in the wrong scale. The synthetic facts move
to the stored units with the same levels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
microcosm#1359. María ruled on 2026-10-23 to keep spending weights for the
alcohol volume: no on-trade share of volume can be archived, since MESAS
is a dashboard of NielsenIQ and Circana data with no fixed file. The rule
sets no level, but a pound buys the same volume in a pub as in a shop,
where a unit costs about three times more. The rule's comment records the
measured size on the LCFS 2024-25 diary against a quarter of the volume
sold on-trade: a pub pound carries about 1.6 times its volume and a shop
pound about 0.8 times, about a tenth of the duty sits with other
households, and the bottom three income deciles bear about 7 % too little
of it and the top decile about 7 % too much.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
microcosm#1359, step C1. A third share follows the two excise shares in
the chain: gambling stakes over recreation (P609), from the seven COICOP
09.4.3 stake lines (football pools, bingo, other lotteries, bookmaker and
tote betting with gaming machines, the Irish Lottery, National Lottery
scratchcards and draws; LCF 2024-25 Volume F, Part 2 rows 1631-1649).
Like the others it is computed on the donor from the weekly lines, clipped
at one because the tab stores P609 to one decimal place, appended last so
no earlier draw moves, and scratch: a household's stakes are its share of
the recreation it drew, so the total and the VAT base keep their draw.
The synthetic donors and the H2 fixture tool carry stake lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…m#1359)

level_excise_quantities now also writes:
- beer_draught_share and cider_draught_share, each the clearances under
  draught relief (alone or with small producer relief) over all of that
  product's clearances, in litres of pure alcohol;
- policyengine-uk's thirteen gambling duty bases
  (PolicyEngine/policyengine-uk#2590, from 2.151.0). A single-duty base is
  HMRC's receipts for the duty, or Machine Games Duty's liabilities for the
  band, over the engine's rate, so the engine charges back the published
  receipts. General Betting Duty's base, its receipts over the fixed-odds
  rate, is split over its five inputs by the Gambling Commission's gross
  gambling yield for Great Britain. Every base goes to households by their
  drawn gambling stakes at prior weights, the alcohol and tobacco rule.

A gambling series may pin dimensions of its own, such as the cash
accounting basis. The vendored resource is renamed excise_facts.json: it
carries HMRC's clearances, receipts and liabilities and the Commission's
yield. The stage-health gate checks the receipt's draught shares against
the declared rows. The engine test checks every new input and rate
against policyengine-uk, and that gambling duty is linear in the bases
and reaches household tax; it needs policyengine-uk 2.151.0 or later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…microcosm#1359)

The feed moves from abf0921 to 200bf6d55, Chronicle main after
PolicyEngine/chronicle#384 (1,502,291 rows, up 2,990; the schema digest
is unchanged). It brings HMRC's alcohol and tobacco clearances, betting
and gaming duty receipts and Machine Games Duty liabilities, the
Gambling Commission's industry statistics for Great Britain and OBR's
excise lines. It also replaces the June 2026 Consumer Trends edition
with September's.

- excise_facts.json vendors the 98 FY2024-25 rows the LCFS excise step
  reads. Every other vendored resource carries the same rows under the
  new feed identity, except ons_household_expenditure_facts.json, whose
  93 rows take the September values.
- Five OBR rows join the national contract, held out of the objective:
  beer and cider, wine and spirits duties accrued, and tobacco and
  betting and gaming duties cash. Each is measured at FY2024-25
  (measurement_period 2024, require_matching_fact_period, a
  matched_period hold), and diagnose_uk_consumption_taxes.py measures
  them at that period. The exclusions expire on 2027-01-08 and the hold
  on 2026-12-11.
- The September revisions move three compiled values. Calendar-2024
  household road fuel (07.2.2, bound) goes from GBP 37.025bn to
  GBP 35.460bn; the measured calendar-2025 electricity and gas rows also
  move.
- The local surface is byte-identical but for the membership's feed
  label.

The national references and parity were regenerated from a cut of the
pinned artifact without its constituency, MSOA and county rows. The
local ones came from a cut to the constituency, local-authority and
non-small-area statistical-scope rows. docs/uk-chronicle-feed-repin.md
records the entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…icrocosm#1359)

The lcfs_consumption stage now declares level_excise_quantities on the
vendored excise_facts.json. It reads HMRC's FY2024-25 clearances of beer,
cider, wine, spirits and other fermented products at the engine's
strengths, the beer and cider draught shares, the four tobacco products
and the thirteen gambling duty bases. Each is shared out by the drawn
spending at prior weights, with alcohol volume following spending.

- The stage writes 24 more household outputs, all nonnegative, after its
  existing ones and in declared order. The schema, the allowed operation
  kinds and the graph's typed cells know the step, and the export
  allow-list (code and gates.json) carries the inputs.
- The stage-health gate uk_stage_lcfs_consumption_excise_quantities
  recomputes every level from the vendored rows. It joins the spine gate
  scope and the microcosm-data contract mirrors, whose digests are
  re-pinned.
- consumption_drift reports each quantity and base at design and final
  weights.
- The stage notes document the spending rule's lean towards on-trade
  drinkers and the gambling mapping.

The engine checks of the gambling inputs need policyengine-uk 2.151.0 or
later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-pin (microcosm#1359)

lcfs_consumption declares 24 more outputs, which flow into the inputs of
the stages after it. Every stage that declares a re-vendored resource
moves its stage_contract_sha256. The synthetic LCFS donor carries the
alcohol, tobacco and gambling diary lines the shares read. The sources
are unchanged, and H2 parity passes on policyengine-uk 2.151.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
experiments/1359-excise-quantities-receipts.md gives:
- the national figures the step levels to;
- a stage-only run on a real spine against microcosm#1383's head;
- the re-pin's value moves;
- the tests.

docs/evidence/uk-excise-quantities-1359/ holds the scripts and the
sanitized outputs: aggregates only, with no column digests or donor
extremes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…crocosm#1359)

Vahid's review: every gambling base followed one stakes total, so National
Lottery players carried remote gaming and betting duty. The chain now draws
two more shares after the gambling stakes, appended so every earlier draw
stays bit-identical:
- the National Lottery's share of the stakes (c94316, c94319);
- the betting and gaming share of the rest (c94311, c94312, c94314).

Lottery Duty's base follows the National Lottery stakes and the other twelve
bases follow the betting and gaming stakes. Society lotteries (c94313) and
the Irish Lottery (c94315) pay no UK gambling duty and carry no base. Each
share rests on 30 or more donors.

The stage notes also state the known biases the review asked for:
- each duty as a multiple of the spending that shares it out;
- incidence against published prevalence, and where the frame loses it;
- vaping inside other tobacco;
- the national draught shares;
- the overstated remote UK horse-racing base.

A stage-only run on a real spine leaves 33 of the 46 columns byte-identical
to the restacked head. Only the 13 gambling inputs move, and all eight LCFS
gates pass. source_stages.json, the coverage manifest and the H2 fixture
re-derive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ocosm#1359)

Vahid's review: nothing noticed if a rate or strength change moved the
duty the levels give. A new engine test runs the committed declaration's
national levels through policyengine-uk as one household. Every duty is
linear in its inputs, so this is the frame's duty at prior weights. The test
pins each OBR row's gap at FY2024-25:
- beer and cider +8.1%, wine +2.6%, spirits +2.2%;
- tobacco +3.0%;
- betting and gaming +0.1%.

The proportionality tests also check that household_net_income loses the
duties.

The five measure exclusions now state those figures. Betting and gaming
gets a reason of its own: its bases are receipts over the engine's rates,
so the row is an identity, measured for weight drift and not as evidence.
Its gap is Machine Games Duty's liabilities against its cash receipts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The receipts answer Vahid's review with aggregates only:
- the engine's duty on the national levels against OBR's FY2024-25 rows,
  with betting and gaming shown to be an identity;
- each duty as a multiple of the spending that shares it out, and the share
  of households carrying it, against ONS and Gambling Commission prevalence;
- where the frame loses incidence against the donor: in the share draws, not
  the parent totals;
- the gambling split's stage run, and how it concentrates the betting and
  gaming duties on the diary's incidence.

The changelog names household_net_income, which falls by the three duties
(about £24.8bn at FY2024-25), and states the multiples.

docs/evidence/uk-excise-quantities-1359/ gains the reconciliation and
incidence scripts and their outputs, and the split run with its comparison.
Every donor share in them rests on 30 or more donors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sm#1359)

A sign gate is a default HistGradientBoostingClassifier calibrated only on
its own training rows. It memorises a rare class's training rows, so on
every other row, every recipient included, its mean probability of that
class falls short of the class's rate. That mean is the share of rows
drawn into the class. On held-out LCFS 2024-25 donors, out of fold, the
lcfs_consumption chain's gates gave education 0.42 of its rate and solid
fuels 0.11.

RegimeGatedQRF now takes a gate_calibration, named by GateCalibration.
in_sample, the default, is the gate as before. cross_fitted fits a gate on
each of five folds of the training rows, stratified by class. One weighted
calibration of a slope and per-class offsets (Platt scaling, for two
classes) is fitted on the gates' out-of-fold log-probabilities and maps
each of them, and a row's probability is the mean of the mapped gates. On
the same donors it puts every chain target's out-of-fold mean probability
at its rate (education 0.997, solid fuels 1.003) and lowers every target's
log loss. Regularising the gate alone, a single calibrated gate, and
shifting the mean all left a gap.

- The fold assignment and the fold seeds derive from the seed the
  in-sample gate takes, so the fit RNG advances by the same draw and every
  forest and draw uniform is unchanged.
- A class with fewer rows than folds keeps the in-sample gate.
- QRFChainState carries the calibration. It writes it into model_config
  only when it is cross-fitted, so in-sample checkpoints serialize as
  before, and a resume under another calibration is refused.

qrf.py's bytes move the fit.qrf graph-parity pins, re-pinned with
tools/graph_parity_repin.py; their draws are positive-only, so no gate is
fitted and they do not move. They also move the spec-loader golden vector.
The seed protocol re-pin follows in its own commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…alibration (microcosm#1359)

microcosm.fit.qrf is an attested kernel module, and the opt-in
cross-fitted gate calibration changes its bytes. So the seed protocol's
content digest and the compiled seed stream map digest legitimately move,
as they did in 1734b9e. The US spec is unchanged, and the base matched
the old pins.

- The committed F0 coverage report is regenerated from the re-pinned
  inventory, and tools/spec_engine_coverage.py --check passes.
- The US bundle hash that the multispine pool tool test pins moves with
  them, to the value the regenerated report carries.

Only CI's engine-us and engine-workflow tiers check these here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lcfs_consumption chain declares gate_calibration: cross_fitted on its
fit_weighted_qrf_chain, so each gate draws a spending at its donor rate on
frame households, which the gates were never fitted to. The in-sample
gates drew rare spending there too rarely: on held-out donors, solid fuels
about a tenth as often as they occur and education under half as often.
The forests and the keyed uniforms are unchanged.

A stage-only run on microcosm#1333's test spine (78,440 households, prior
weights) passes every LCFS stage-health gate. The share of households with
each spending moves as follows, with the LCFS donor's share in brackets:

- solid fuels 0.33% -> 2.25% (2.47%);
- liquid fuels 3.09% -> 3.88% (4.54%);
- education 4.31% -> 6.65% (5.83%);
- tobacco 8.16% -> 11.16% (10.34%);
- gambling stakes 14.63% -> 16.70% (17.03%);
- betting and gaming 1.96% -> 2.63% (3.58%).

The near-universal divisions' zeros are now drawn at their rate too, so
their incidence moves towards the donor's. The twelve divisions'
prior-weighted total falls 1.4%, and the seven divisions held at the
shared frame-to-donor ratio fall with it.

The excise inputs keep their national levels, but the multiples of
spending change. Tobacco duty is about 1.8 times tobacco spending, down
from 2.2, and the other gambling duties are 5.5 times betting and gaming
stakes, down from 6.4. Lottery Duty is 0.61 times National Lottery stakes,
up from 0.56, and alcohol duty 0.45 times alcohol spending, up from 0.41.
The stage notes carry these figures and the cross-fitting.

The derived files follow the declaration: source_stages.json, the
coverage manifest's source digest, the schema's enum on the operation and
the charter-H2 fixture. The H2 fixture changes only in the stage's notes
and the new parameter. A control with the new qrf.py and no declaration
reproduces the stage's earlier run byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@juaristi22
juaristi22 force-pushed the uk-excise-quantities-1359 branch from 03427be to e80a424 Compare October 24, 2026 17:17

@MaxGhenis MaxGhenis 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.

Thanks María. This is a clear diagnosis, and calibrating the gate out of fold is the right fix. I'd land it after the Budget rather than in Monday's cut, since it moves 22 of the 24 excise inputs and the COICOP totals that the VAT coverage is being derived on.

Two changes before merge, and one wording fix:

  1. Fall back when a fold has too little weight. _can_cross_fit counts rows, so a target with zero-weight rows can pass it and still leave a training fold with no weight. sklearn then raises ZeroDivisionError: Weights sum to zero. A reproducer: ten donors, five per class, eight of them at weight zero. The in-sample gate fits; the cross-fitted one crashes. Please check each fold's training weight per class, and keep the in-sample gate when it falls short. The LCFS weights should all be positive, but this matters before other stages opt in.
  2. Check convergence before returning the calibration. When the out-of-fold scores are saturated, the Newton step is huge and the line search hits its 1e-10 floor. The loop then returns the identity map with no warning, so the rate balance fails silently. A reproducer: ten rows, five per class, every logit 29. That gives a positive-class mean of about 1 against a rate of 0.5. Please check the weighted residuals at exit, and fall back to an offsets-only fit or the class rates, or raise. Real gates probably never get there, and your LCFS figures show the balance held, but it should fail loudly.
  3. Wording. The balance is exact on the pooled out-of-fold donor scores, not on recipients. Also, in the chain, a sign that changes moves later targets' amounts through their predictors, even though the forests and uniforms are the same. The changelogs and the module docstring should say both.

Please also add Hypothesis tests for the invariants:

  • the weighted rate balance, including saturated and constant scores;
  • the sparse-weight fallback;
  • base-versus-head byte identity on the default path.

The two reviews (one by Claude, one by GPT-6.1 Sol) checked these, all on synthetic data:

  • The default path is byte-identical to the base, and so are 36 checkpoint byte hashes across all seven regimes.
  • Resuming under a different calibration is refused.
  • LCFS is the only opt-in.
  • The 22 UK manifest re-pins and the US seed-protocol and seed-map pins recompute.
  • On held-out data the cross-fitted gate lands at about 1.0 of the true rate with 100 or more positives; the in-sample gate lands at 0.05 to 0.5.

I can send you the reproducers and the proposed property tests.

This branch has not been deployed

No deployments
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.

2 participants