Repository navigation
Port capital gains structure, salary sacrifice, and student loan stages #684
Description
Workstream E8 of the UK-migration master epic #665; child of #145. Order constraint: after the SPI channel (income before capital gains and before salary-sacrifice stage 2).
Capital gains — the adjudicated split (2026-08-14)
The incumbent builds CGT in three moves: (1) incidence by doubling the dataset and running an unseeded torch optimization that splits each household's weight between its has-gains and no-gains copies to match the Advani-Summers incidence table; (2) amounts from a percentile spline (stops at p95); (3) a top tail of 30 donor households per HMRC size band at band-exact weights. The adjudication:
- Port the structure: doubling with
household_is_capital_gains_clone+ the 780 band donors (household_is_cgt_band_donor) — the population needs support for both states and for large gains, the certified spine's record count assumes it, and calibration needs the support to bind CGT band targets. - Keep the merged Impute capital gains amounts from the published joint distribution #560 amounts stage (HMRC table-3 joint-distribution redraw — better tail than the spline; signed difference vs the incumbent).
- Drop the torch sigmoid-blend: clones get an equal mass split at importance kind; WS-D's calibration against the chronicle CGT band facts does the blending the torch loop used to do. Signed difference with the named WS-D dependency. (The incumbent's blend loop is unseeded, and torch is not a microcosm-build dependency.)
Salary sacrifice
QRF stage 1 on the asked-subset (reported values kept exactly); the 5.4m headcount conversion as a declared anchor with its OBR provenance (a top_up_to_stock-style operation, seeded).
Student loans
Rule-based cohort assignment (plan by estimated university start year; PLAN_4 never imputed — documented UK-specific) plus below-threshold top-up to SLC liable-to-repay stocks. SLC stocks are chronicle-fact-fed (wave 1 carries the SLC facts) — coordinate with @juaristi22 before wiring any chronicle feed (wave-2 pieces are in flight).
Acceptance
- The pre-clone record-count identity reproduced: (base + 10k SPI) × 2 + 780 donors.
- CGT amounts vs the published surface per Impute capital gains amounts from the published joint distribution #560's embedded tests; the weight-mechanism divergence signed with measured incidence deltas.
- Determinism per the WS-E harness;
supportgate declared for donor-backed families.
UK-specificity note (required in the PR)
HMRC band structure, SLC plan rules, and the OBR anchor are UK; cloning, donor stacking, and stock top-up should be generic operations. List anything beyond that and why.
Depends on: E7 (income), E5 (student_loan_balance); related: #552, #364, #467.
Activity
juaristi22 commentedon Aug 20, 2026
Carried design note from the #730 (E6) review — implement in E8's PR
Recorded disposition: deferred here rather than re-spun onto #730, which is already reviewed and whose CI/licensed evidence is complete — the same precedent as the three #717 nits that rode E6. Source: @vahid-ahmadi's review of #730 (2026-08-20), the "worth a design note, not just a fix" paragraph.
The finding
E6's licensed run caught a defect the drift asserts were invented for and did not catch:
lcfs_consumptiondeclared four NEED raking margins (income → tenure → accommodation → region) while the transform executed only the income margin (fixed at2bc98b3e).The E7 asserts (the reviewed-constants block at
uk_runtime/spi_spine.py:162-165) compare manifest values against reviewed code constants — that is declaration equality, not execution. A parameter can match its reviewed constant exactly and still never reach the data. Theuk_aggregate_admingate could not see it either: its NEED anchors are national means, which a single-margin rake still satisfies. Only a licensed build surfaced it.The rule to document
Every declared operation parameter needs both arms, or an explicit statement of why the second is absent:
raking_zero_current_cellsevidence attr on the raked frame, the per-margin residuals inacceptance_checks.json, and unit tests that assert a per-parameter behavioural difference rather than a declaration match.Where a parameter's effect genuinely is not observable in the artifact, name what covers it instead — the standing precedent being seeded draws, covered by twin-build determinism rather than identity receipts.
E6's own parameters are covered by the regression tests added in the licensed round (per the review), so this is forward-looking discipline, not a re-open of #730.
Home
Extend the reviewed-constants comment block at
uk_runtime/spi_spine.py:162-165— where the E7 pattern is documented — to state both arms, so E8+ inherits it deliberately.Applies concretely in this issue's scope
At minimum: the 780 band-donor count and 30-per-band structure, the equal mass-split share replacing the torch blend, the OBR 5.4m headcount anchor and its
top_up_to_stockparameters, every declared seed, and the SLC plan-cohort rules. Each gets an assert or a receipt, called out in the PR body's verification section.juaristi22 commentedon Aug 21, 2026
Three factual corrections to this issue's text, recorded at implementation start (2026-08-21)
Established while planning the E8 implementation (stacked on #733, targeting FRS 2024-25); each restated in the PR body's corrections section.
1. The donor count is 270, not 780 — and the "before" was never 780 either. The 780 figure (30 × 26 bands) describes the 2023-artifact-era band file. At the incumbent pin ebf733c / v1.56.14, the band source is HMRC CGT statistics Table 2.1a (July 2025 release,
Table_2_2025_Size_of_gain.ods, sheet2_1a_2023-24): 13 published rows, and the_MIN_DONOR_BAND_LOWER = 12_300filter keeps 9 bands → 270 donors (30 per band). The incumbent's own history never held 780:DONORS_PER_BANDwent 300 → 30 over the same 9 bands (2,700 → 270, the L0-sparsity change). PR #733 already pins the resulting record identity: 52,846 households = (16,288 raw + 10,000 SPI) × 2 + 270. Note the donor band file cannot be replaced by the already-pinned Table 3 ODS: Table 3's gain bands have no £12,300 row, so the same filter would yield 8 bands = 240 donors and break the identity.2. The salary-sacrifice "5.4m OBR anchor" is neither 5.4m-as-anchor nor OBR.
TARGET_TOTAL = 5_400_000is a deliberate ~70% staging fraction (code comment: "so the calibrator can gently upweight rather than fight a large overshoot") of the real anchor: 7.7m total salary-sacrifice users (3.3m above the £2k cap / 4.3m below, growing 2.4%/yr from 2024), published by HMRC ("Salary sacrifice reform for pension contributions effective from 6 April 2029"). The incumbent exposes these underobr/target names — a misattribution. E8's committed anchor resource carries the 7.7m HMRC anchor, the documented staging ratio, and the 5.4m derived stage parameter, cited to HMRC.3. The torch blend is not unseeded. The incidence optimization initializes
blend_factorat zeros and runs deterministic Adam — no RNG anywhere in that loop. The drop decision stands on its real grounds: torch is not a microcosm-build dependency, and the loss is mis-specified — the clone half is modeled as(1−s)·s·winside the loss (the second assignment reads the already-overwritten first half) while the applied transform uses(1−s)·w, so the optimizer converges against a systematically biased objective. The adjudicated equal mass split + downstream calibration replaces it, with the standing honesty note that only the two aggregate CGT facts calibrate today (the 76 band facts remain fenced per #552).Add the E8 UK spine stages: CGT structure, salary sacrifice, student …
Add the E8 licensed-acceptance instruments: identity receipt and side…