Skip to content

Port capital gains structure, salary sacrifice, and student loan stages #684

Closed

Description

@juaristi22

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; support gate 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

  1. juaristi22 commented on Aug 20, 2026

    @juaristi22
    CollaboratorAuthor

    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_consumption declared four NEED raking margins (income → tenure → accommodation → region) while the transform executed only the income margin (fixed at 2bc98b3e).

    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. The uk_aggregate_admin gate 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:

    1. Drift assert — manifest value equals the reviewed constant (the E7 arm; proves the declaration is reviewed).
    2. Executed-effect receipt — evidence the parameter actually shaped the output. E6 has precedent for this arm: the raking_zero_current_cells evidence attr on the raked frame, the per-margin residuals in acceptance_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_stock parameters, 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.

  2. juaristi22 commented on Aug 21, 2026

    @juaristi22
    CollaboratorAuthor

    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, sheet 2_1a_2023-24): 13 published rows, and the _MIN_DONOR_BAND_LOWER = 12_300 filter keeps 9 bands → 270 donors (30 per band). The incumbent's own history never held 780: DONORS_PER_BAND went 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_000 is 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 under obr/ 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_factor at 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·w inside 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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions