Skip to content

Draw SPI-channel incomes at the FRS twin's conditional rank (#840) - #1256

Merged
juaristi22 merged 2 commits into
mainfrom
uk-840-spi-draw
Oct 14, 2026
Merged

juaristi22 merged 2 commits into
mainfrom
uk-840-spi-draw

Conversation

@juaristi22

@juaristi22 juaristi22 commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Draw SPI-channel incomes at the FRS twin's conditional rank (#840)

Closes #840. Ticks the two #1095 items below (uk-data#529 and the SPI-row
half of UC gainful self-employment). On main since #1200 merged.

What changed

Stage 17 (hmrc_spi_income_spine) drew every SPI-channel claimant's and
partner's income leaves from the SPI tape conditioned on age, gender and
region only, at the model's random numbers; the FRS twin's incomes and the
composition-income relation they carry were discarded.

  • Bridge predictors (D1). The tape forest conditions on four facts about
    a record's income that the tape and the twin each carry: main_source (the
    largest of pay, pension and trade) and has_pay from the same leaf formulas
    on both sides; has_trade and at_or_over_spa from the tape's published
    indicator codes (SEINC_NUM, SPA; the leaf formulas serve only a composite
    record without codes) and, on the twin, its trade leaf or self-employed
    status and its age against State Pension age. The published MAINSRCE code
    validates the leaf-defined main source (0.969 of the classified weight on
    the full 2022-23 tape with the stage's preparation; floor 0.95) and the
    receipt reports each indicator code against its leaf both ways. The read
    requires the three code columns and refuses unknown codes by name.
  • No-record rule (D1). A twin with no taxable income (no pay, trade or
    pension, and at most £1,000 of taxable interest, dividends, property and
    miscellaneous income together) has no tax record for the tape to condition
    on; its copy keeps the twin's values at stage 1 and stage 2. Reserved band
    carriers are always drawn. The £1,000 boundary is a declared modelling
    choice in the manifest.
  • Twin-conditional draw (D2, D4). Reference forests fitted on the FRS base
    claimants and partners with the same predictors and chain order locate each
    twin's observed leaves (FittedRegimeGatedQRF.conditional_uniforms: the
    sign uniform in the gate's cumulative class probabilities, the quantile on
    the sign's magnitude forest through the new _Forest.quantile_positions,
    chained on the twin's observed earlier leaves); the tape forest draws at
    those uniforms through predict_from_uniforms.
  • Fail-closed on the hold-out (review). A full build refuses a reference
    the predictor cells cannot cross-fit (only a sampled rung or a synthetic
    fixture may locate in-sample, and its receipt says so), a recipient whose
    fold differs from its twin's, a frame without person_source_id, and a
    reference whose sign classes differ from the tape forest's on a located
    target. The reference locates a leaf given the twin's earlier analogue
    leaves while the tape forest draws it given every earlier output, the
    identity-uniform leaves included; moving the analogue targets ahead of
    them in the chain is a follow-up to measure.
  • Cross-fitted reference (D3). An in-sample reference holds a training
    row's own value as an atom (median 0.23 to 0.74 of the conditional's mass
    under the weighted bootstrap) and kept under half of the twin's rank, so
    the reference is cross-fitted in five folds keyed on the predictor cell: the
    forest locating a copy never saw its twin, and one forest locates a whole
    cell. Within-cell rank fidelity of the located quantile rises to 0.72-0.99.
  • Identity-keyed uniforms (D5). Leaves without an FRS analogue and the
    placements inside a class or a flat stretch take
    stable_identity_uniforms(person_source_id, seed, salt="spi_stage1:<target>:<part>");
    stage 1 consumes no model random stream, and a draw depends on the twin and
    the identity alone. Stage 2 keeps its seed + 1 stream; the band-donor
    resample, the uprating and the SPA guard are unchanged.
  • After UK landlords: bind the SPI net property amounts, leave property_wealth to the engine, add receipts and finance costs #1145 and UK: port the #1095 fixes that need no new policyengine-uk release #1200. The restricted finance costs leaf
    (property_finance_costs, the last stage-1 target) reaches the FRS rows only
    at property_components, after this stage, so it takes identity-keyed
    uniforms. The stage flags the rows it drew (person_is_spi_income_recipient,
    typed in the graph and allow-listed beside the band-carrier flag) and
    property_components reads the flag to tell tape-concept rows from
    FRS-concept ones, so an undrawn copy keeps its twin's property income on the
    FRS concept with the sublet split and the imputed finance costs its twin
    gets. The UC gainful self-employment refresh on drawn rows takes the root's
    whole rule (the self-employed main job as well as the earnings route), with
    a receipt of the rows each route flags; undrawn copies keep their twin's flag.
  • Surfaces. The manifest declares the bridge, the recipient income rule,
    the draw, the reference model (seed + 3, five folds, fold key) and the
    identity uniforms; spi_spine pins them with drift asserts; the stage
    evidence carries the bridge-validation receipt and the per-target transport
    receipt (analogue, FRS regime, uniform source, Kolmogorov distance from
    uniform overall and by main source, per-fold counts). Schema,
    source_stages.json, the coverage manifest, the UK spine parity fixture and
    the fit.qrf parity pins are regenerated. FRS rows are byte-unchanged at
    the stage.

Measured on the licensed spine twins (design weights, aggregates only)

Full receipts in experiments/840-spi-draw-receipts.md; the note carries
shares and weighted totals, and record counts stay with the raw receipts
beside the builds. The headline is R1f, the twins measured on #1200's head
(a50ffba5e, which main carries; the branch now sits on main after
#1260): control that base, treatment this branch.

FRS rows SPI, before SPI, after
Lone parents passing the UC award screen 0.634 0.532 0.636
Couples with children passing the screen 0.274 0.238 0.281
Lone-parent UC reporters' distinct source households, as a share of the FRS channel's 1.00 0.45 0.60
Full-time employees with pay (uk-data#529) 0.983 — 0.979
Full-time self-employed with trading income 0.908 — 0.908

Paired against their own twins, the copies earn when the twin earns (0.587
against 0.583; before 0.743) and report benefits at the twins' shares (UC
0.111 against 0.107; before 0.062). Lone parents land on their twins through
the engine (screened 0.648 against 0.646). All 37 spine gates pass; the HMRC
band replay stays reviewed_exclusions_only.

Downstream (R1e, R1f): FRS rows move only through the frame-level stages
after stage 17 (the CGT lane, the landlord stage's profit ranking, the energy
and fuel rakes, the salary-sacrifice conversion rate, the CGT mass moves, the
take-up residual draws). To hold in view: childless units and couples with
children pass the screen 17-43 % more often than their twins (the tape's
low-pay tail among earners, the annual tax-record concept, unchanged from
before); the investment leaves and the WAS wealth draws rise 10-50 % on SPI
rows as the tape conditionals given the bridge replace the cell marginal, and
the CGT lane moves part of the FRS channel's gains to SPI rows at design
weights; the stage-2 disability and carer over-imputation on drawn copies is
a pre-existing residual.

#1095 items

  • SPI-channel incomes ignore employment status (uk-data#529): pay and
    trade by employment status on SPI rows now match the FRS rows.
  • UC gainful self-employment on SPI rows: drawn rows take the root rule
    (has_trade conditions the draw); undrawn copies keep the twin's flag.

Tests

microcosm-fit (round trip, uniformity, two-population transport, zero-class
carry, chunked equality, refusals); engine-free UK (bridge, code refusal,
no-record rule, analogue map, transport, cell folds, cross-fit hold-out,
spine drift pins, spec loader golden hash); engine-side UK (identity-keyed
rewrites, three fit records). The engine-free, engine-uk and engine-us job lists ran
locally before the final rebase onto main (#1242), the parity, pin and
targeted files after it; tools/ci_test_plan.py verify, ruff check,
git diff --check.

🤖 Generated with Claude Code

@juaristi22
juaristi22 changed the base branch from uk-data-ports-1095-3 to main October 13, 2026 13:16
@vahid-ahmadi

Copy link
Copy Markdown
Contributor

@juaristi22 review of this PR's own diff against #1200's merge (48c07990..3205a173), not #1200's content.

Verdict: the method is sound and the tests really exercise it, but the committed receipts carry exact FRS source counts that need to come out before merge, and the "same formula" bridge claim does not match the code for two of the four predictors.

Findings

  1. must experiments/840-spi-draw-receipts.md (R1a, R1b, R1d, R1e, R1f) and the PR body commit exact unit counts from the licensed FRS and SPI runs: drawn, undrawn and carrier row counts, reference training rows, per-fold training and scored ranges, positive-row counts per target, matched FRS and SPI household, person and benefit-unit counts, and the "distinct FRS source households" columns, some of them small. The file follows a below-10 rule, but exact source counts in a public repo are still a disclosure risk and the PR body table repeats the source-household figures. Please drop the counts (shares and weighted totals are enough to make each point) or keep them licensed-side with the raw receipts, and edit the PR body table the same way. The fixture spi_donor.csv is synthetic and fine.

  2. should spi_income.py:131-134 says the bridge is derived "by the same formula from their own leaves" and the published codes "are never the source of a predictor", and the docs (docs/uk-income-anchors-280.md:345), changelog and PR body say the same. The code does not do that for two predictors: on the tape has_trade is SEINC_NUM == 1 and at_or_over_spa is SPA == 1, with leaves used only for composites (spi_income.py:1724-1729); on the twin they are self_employment_income > 0 or a self-employed main job, and age at or over State Pension age (_person_bridge). The manifest (sources.yaml:1517,1521) states this correctly, so the code is a defensible choice (pages filed, losses included, is a better match for a self-employed status than profit > 0). Please make the comment, docs, changelog and PR body say what the code does, and add the reverse share to the receipt (PROFITS > 0 among SEINC_NUM = 1, next to spi_income.py:2240), since the one-way share cannot show how many code-1 records have no profit. test_person_bridge_matches_the_tape_formulas only checks the twin side, and test_spi_donor_bridge_is_validated_against_the_published_codes covers a MAINSRCE disagreement and composites but no record whose SEINC_NUM or SPA disagrees with its leaves (the synthetic fixture also sets those codes from the leaves).

  3. should The 0.961 agreement figure is only traceable to R0 of the receipts file, measured on licensed data that is not committed. The same file's R1a reports 0.969 overall on "the production tape" and says it "agrees with R0", but R1a has partnership agreement 0.744 against R0's 0.079, and R0 explains the 0.079 by partnership profits being absent from PROFITS. Both cannot hold for the same tape and leaf definition. Please say which tape and preparation each figure comes from, and quote the one the code comment (spi_income.py:145-147) and PR body cite. The 0.95 gate sits close to both, so it matters which is right.

  4. should spi_rank_transport.py:321-326, 391 falls back to in-sample location when a fold's complement is empty and records it only in the receipt (cross_fitted: false, in_sample_folds). Nothing in the gates or tests reads those fields, so a production build could silently return to the in-sample location this PR shows to be weak (R1c). Please refuse in-sample folds outside the fixture path, or gate on cross_fitted.

  5. should The hold-out guarantee ("the forest that locates a copy never saw its twin") holds by construction only partly. cross_fit_transport recomputes the fold count as training_folds.max() + 1 (spi_rank_transport.py:297) rather than taking the used that cell_fold_ids returned for the training rows (discarded at the call in spi_income.py). If the top fold happened to be empty, recipients would be binned with a smaller count, floor(u * used) would differ from the twin's fold, and a copy could be located by a forest that saw its twin. Very unlikely with many cells, but easy to close: pass used through. Separately, recipient folds come from the recipients' own predictors (spi_rank_transport.py:298) and are never checked against the twin's. I checked that age_tail runs before spi_support_channel and only spi_income_band_donors sits between that and this stage, so I found no current path that moves a copy's cell values; an assert that a recipient's fold equals its twin's (joined on person_source_id) would keep it that way.

  6. should The chain conditioning sets differ between the two forests. The reference locates hmrc_spi_pay (chain position 8) given the twin's five earlier analogue leaves only, but the tape forest draws pay given the drawn values of all earlier outputs, including other_investment_income, gift_aid and charitable_investment_gifts, which take identity uniforms and so carry no information about the twin. Pay's rank is therefore read off a conditional that partly depends on random gift-aid and other-investment draws, which may be part of why pay has the weakest rank fidelity in R1d. Worth checking whether putting the analogue targets first in the stage-1 chain (or dropping the identity leaves from their conditioning) helps. At least the "same predictors and chain order" wording should mention the difference.

  7. should identity_column falls back to person_id when person_source_id is missing (spi_income.py:1002-1006), but the receipt still reports identity_key: person_source_id. With person_id, copies of one twin no longer share uniforms and the declared key is wrong. Since the manifest declares person_source_id and the band-donor stage already relies on it, please require the column and refuse without it.

  8. should The stage evidence now carries exact counts (recipient_rows, the undrawn and carrier counts, per-fold training_rows and scored_recipients, positive_rows per target and per main-source group, and the gainful self-employment route counts). The by-group 10-row floor applies to group size, not to positive_rows, so small positive counts can appear (R1a shows a suppressed positive-row cell for one target). If stage evidence is ever published with a release, these need the same suppression as item 1. If it always stays licensed-side, a line saying so in the manifest would settle it.

  9. nit conditional_uniforms checks that each twin's sign class exists in the reference gate, and both gates order classes the same way, but nothing checks that the reference and tape gates have the same class set per target. If the FRS reference ever fitted [-1, 0, 1] where the tape fitted [0, 1], the sign uniforms would shift meaning. The receipts show matching regimes today, so this is latent; a drift assert on the class sets (or on reference_regime against the tape regime) would close it.

  10. nit The no-record rule (spi_income.py:2137-2162) treats any pension, including a state-pension-only twin, as a tax record, and sums interest, dividends, property and miscellaneous income against one £1,000 floor, while the allowances are separate and property income here is profit, not gross receipts. It is declared as a modelling boundary and applied the same way to the reference population and the recipients, and stage 2 skips undrawn copies, so it is consistent. It would help to note in the manifest that state-pension-only twins are drawn against a tape that mostly holds taxpayers, which may push their pension leaves up.

  11. nit Because the located quantile is deterministic given the twin, copies stacked by the band-donor stage now get the same draws on every analogue leaf, not only on the identity-keyed ones. That is consistent with the design, but the docs only mention shared identity uniforms. One sentence saying stacked copies no longer differ on their stage-1 leaves would help readers of the band-donor stage.

  12. nit The changelog fragment is one line of about 2,900 characters. A two or three sentence summary that points to the docs section would be easier to read in the release notes.

What I checked

  • Read the full diff 48c07990..3205a173 (36 files): spi_rank_transport.py, the qrf.py additions (_locate_rows, quantile_positions, conditional_uniforms), the spi_income.py bridge, no-record rule, reference fit and gainful self-employment refresh, property_components.py, spi_spine.py drift asserts, sources.yaml, the docs section, the changelog and the receipts file.
  • Checked the sign uniform uses the same ascending class order in conditional_uniforms and _draw_target_from_uniforms, that unknown sign classes and undocumented MAINSRCE, SEINC_NUM and SPA codes are refused by name, that undrawn copies are excluded from the stage-1 and stage-2 writes and from the gainful self-employment refresh (drawn rows take the root's whole rule, undrawn copies keep the twin's flag), and that the reference population uses the same income rule as the recipients.
  • Fresh uv venv in a scratch worktree. Engine-free tests for the touched areas (test_qrf_stateless, test_uk_spi_rank_transport, test_uk_spi_income, test_uk_spi_spine, test_uk_spi_pension_age, test_uk_spi_band_donors, test_uk_property_components, test_uk_source_stages, test_uk_graph, test_spec_engine_loader): all pass, one skip (licensed SPI donor not staged). Engine-side tests/engine/uk/test_uk_spi_income.py and test_uk_spi_state_pension_age.py with the uk extra: all pass. ruff check ., git diff --check and tools/ci_test_plan.py verify are clean.
  • Reverted the source to the base with the PR's tests kept: reverting qrf.py makes 10 tests fail, including the new transport and cross-fit tests; reverting spi_income.py, spi_spine.py, property_components.py, graph.py and hmrc_source_contract.py makes 35 fail, including the new bridge, no-record, code-refusal, gainful self-employment, drift-pin and graph-read tests. The tests do exercise the new logic.
  • Could not check: anything on the licensed SPI tape or FRS (not available locally, and I did not fetch them), so none of the receipts' figures, the 0.961 or 0.969 agreement, or the rank-fidelity numbers are reproduced here. CI was still queued when I looked.

🤖 Generated with Claude Code

@juaristi22

Copy link
Copy Markdown
Collaborator Author

Twins on the #1200 base (receipts note section R1f; shares and weighted totals, the record counts stay with the raw receipts beside the builds). Control: uk-data-ports-1095-3 at a50ffba5e; treatment: this branch on that commit with the integration items. All 37 spine gates pass on both arms, including uk_stage_property_components and uk_stage_spi_benefit_coherence.

FRS rows SPI, control SPI, this branch
Lone parents passing the UC award screen 0.634 0.532 0.636
Couples with children passing the screen 0.274 0.238 0.281
Lone-parent UC reporters' distinct source households, as a share of the FRS channel's 1.00 0.45 0.60
Full-time employees with pay (uk-data#529) 0.983 — 0.979
Full-time self-employed with trading income 0.908 — 0.908

Paired against their own twins, the copies earn when the twin earns (0.587 against 0.583; control 0.743) and report benefits at the twins' shares (UC 0.111 against 0.107; control 0.062). Per unit through the engine, lone parents land on their twins (screened 0.648 against 0.646, receiving 0.575 against 0.581); childless units and couples with children pass the screen 17 to 43 % more often than their twins, the tape's low-pay tail among earners (R1c), unchanged from the control. At design weights SPI Universal Credit moves from £17.9bn to £36.4bn against £35.9bn on FRS rows; SPI income tax +8.5 %.

FRS rows move only through frame-level stages after stage 17: the CGT lane (capital_gains −16 % on FRS rows), the landlord stage's profit ranking against the PRIS bands (property_rental_income −6 % on FRS rows, new on this base), the energy and fuel rakes (±1–2 %), the salary-sacrifice conversion rate (FRS pay −0.10 %), the CGT mass moves on weights (97 % of FRS mass within ±1 %) and the take-up residual draws (under 0.3 % of FRS units each).

@juaristi22

Copy link
Copy Markdown
Collaborator Author

@vahid-ahmadi thank you for the close read. Addressed in 9da1fe7; item by item:

  1. must, counts. The receipts note now carries shares and weighted totals only: the recipient-domain table is in shares of the domain, the transport table's positive-row column is the positive share of the drawn, the group sizes are shares, the matched-row counts and the per-fold ranges are gone, the moved-column counts in R1e and R1f are shares of the channel's rows, and the "distinct source households" figures are the SPI channel's as a share of the FRS channel's. The PR body table follows; the earlier comment is deleted and re-posted without the counts. The raw receipts with the counts stay beside the builds, outside the repository.
  2. should, the bridge wording. The comment, the docs, the changelog and the PR body now say what the code does: main_source and has_pay from the leaf formulas on both sides, has_trade and at_or_over_spa from the tape's indicator codes (the leaves serving only a composite record) and the twin's trade leaf or status and age. The receipt reports the codes both ways (positive_profits_share_among_seinc_num joins the existing share), and the validation test now holds a record that files no trade pages with a profit and a State Pension recipient under State Pension age, checking that the codes win and that both directions move.
  3. should, 0.961 against 0.969. The 0.969 is the stage receipt (bridge_validation on the stage evidence), measured on the full tape with the stage's preparation (PAY + EPB + TAXTERM, PENSION + SRP, max(0, PROFITS − CAPALL − LOSSBF)); the 0.961 and the partnership figure near zero came from an exploratory script with the raw columns, and the note's explanation of it was wrong. R0, the code comment, the docs and the PR body now cite the stage receipt alone and say where the earlier figure came from.
  4. should, in-sample fallback. cross_fit_transport now refuses a single fold and an empty complement unless allow_in_sample is set; the stage passes it from the transform's sampled_rung (a scale-ladder rung or the synthetic fixture), so a full build fails closed. Tests cover the refusal at both levels.
  5. should, the fold count and the twin's fold. The stage passes the used that cell_fold_ids returned for the training rows, and cross_fit_transport takes the training rows' identities and refuses a recipient whose fold differs from its twin's. A test moves the copies' ages and sees the refusal.
  6. should, chain conditioning. Documented in the manifest's method, the docs and the PR body as a known difference and the follow-up to measure (analogue targets ahead of the identity leaves); not changed here, since the chain order is part of the fitted tape model and its fixtures.
  7. should, identity key. The stage refuses a person table without person_source_id; the fallback is gone, and a test covers the refusal.
  8. should, evidence counts. A positive-row count under the group floor is withheld in the stage evidence as the small groups already were, and the manifest's receipt text now says the counts in the stage evidence stay with the build and are not published.
  9. nit, class sets. FittedRegimeGatedQRF.sign_classes reports a target's classes; the transport receipt carries the reference's per target (folds that disagree are refused), and the stage refuses a located target whose reference classes differ from the tape forest's.
  10. nit, the rule. The manifest's recipient_income_rule, the module constant and the docs now note that a State Pension-only twin is drawn against a tape that mostly holds taxpayers, which can lift its pension leaves.
  11. nit, stacked copies. The docs now say that copies of one twin the band-donor stage stacks share every stage-1 draw, not only the identity-keyed ones.
  12. nit, changelog. Two sentences, pointing to the docs section.

The fit kernel's parity pins are re-recorded for the new accessor. Tests: the graph, fit and targeted engine-free files and the engine-side SPI files pass locally; CI is on the PR.

juaristi22 pushed a commit that referenced this pull request Oct 13, 2026
Review round on #1256 (Vahid, 13 October):

- The committed receipts carry shares and weighted totals only; record
  counts stay with the raw receipts beside the builds (the PR body and
  comment follow).
- The bridge wording says what the code does: main_source and has_pay
  from the leaf formulas on both sides, has_trade and at_or_over_spa
  from the tape's indicator codes (the leaves serving a composite record
  without codes) and the twin's trade leaf or status and age. The
  receipt reports the codes against the leaves both ways, and the
  validation test holds records whose codes disagree with their leaves.
- The main-source agreement cited everywhere is the stage receipt on the
  full tape with the stage's preparation (0.969); the earlier figure came
  from an exploratory raw-column script, and the note says so.
- cross_fit_transport takes the fold count the training rows used and the
  training rows' identities: it refuses a recipient whose fold differs
  from its twin's, a single fold or an empty complement unless the caller
  allows in-sample location (the spine passes the transform's
  sampled_rung, so a full build fails closed), and folds that disagree on
  a target's sign classes. The stage refuses a person table without
  person_source_id and a located target whose reference sign classes
  differ from the tape forest's (FittedRegimeGatedQRF.sign_classes, with
  its fit-package test). A positive-row count under the group floor is
  withheld in the stage evidence.
- The manifest, the docs and the changelog note the chain-conditioning
  difference between the two forests (the follow-up to measure), the
  State Pension-only case of the no-record rule and that stacked copies
  share every stage-1 draw; the changelog is two sentences.
- Regenerated: the source_stages mirror, the coverage manifest, the spec
  loader's golden hash and the fit.qrf parity pins (arm64/darwin/py3.14,
  the two linux keys derived).

Tests: graph package 756 passed; fit QRF 37 passed; the targeted
engine-free UK and shared files 1,263 passed; engine-side SPI and
release-coverage files 72 passed; ci_test_plan verify, ruff and git diff
--check clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vahid-ahmadi

Copy link
Copy Markdown
Contributor

@juaristi22 round 2, on 9da1fe7d2 (diff against 3205a1736, plus the whole branch for the counts).

Needs changes before merge: CI is red for three reasons the PR causes, one table of exact counts is still in the receipts note, and the earlier commits on the branch still carry the counts, so the branch needs rewriting.

Round-1 points

  1. must, counts: partly fixed. Most of the note is now shares and weighted totals, and the PR body is clean. Still there: the R1b "Universal Credit reporters after uc_reporter_redraw" table (experiments/840-spi-draw-receipts.md:126-129) still gives the distinct FRS source household counts next to the weighted totals, some of them small. Every earlier commit on the branch (2c192e3fe to 3205a1736) keeps the full count tables in that file, and two commit messages carry counts too: 2c192e3fe (distinct source households before and after) and 3205a1736 (FRS persons whose property_rental_income moved). 3da5130a5 bounds a small unit count in its message. See new item 4.
  2. should, bridge wording: fixed. The comment, docs, changelog and PR body now match the code, and the receipt computes the reverse share (spi_income.py:2274). Small gap: the note quotes it as "see the stage evidence" (experiments/840-spi-draw-receipts.md:27) rather than giving the share, and the new test (test_uk_spi_income.py:670-683) only moves a code to 0. It asserts the reverse share stays 1.0, so there is still no record with SEINC_NUM = 1 and no profit. See nit 6.
  3. should, 0.961 against 0.969: fixed. R0, the code comment (spi_income.py:150-154), the docs and the PR body now cite the stage receipt alone and say where the earlier figure came from.
  4. should, in-sample fallback: fixed. cross_fit_transport refuses one fold and an empty complement unless allow_in_sample is set. The full build passes _is_sampled(args) (spine_build.py:1516), and only the synthetic fixture sets it true (graph_kernels.py:662). Both refusals are tested.
  5. should, fold count and twin's fold: fixed. used is passed through, and recipients are checked against their twins' folds (spi_rank_transport.py:315-326). There is a test.
  6. should, chain conditioning: addressed in docs. It is now in the manifest method, the docs and the PR body as a known difference and a follow-up. That covers what I asked for.
  7. should, identity key: fixed. The fallback is gone and missing person_source_id is refused. There is a test.
  8. should, evidence counts: fixed. _uniformity withholds a positive-row count under 10, and the manifest receipt says the stage-evidence counts stay with the build.
  9. nit, class sets: partly fixed, and now breaks CI. See new item 1.
  10. nit, no-record rule: fixed (manifest, module constant, docs).
  11. nit, stacked copies: fixed (docs).
  12. nit, changelog: fixed.

New findings

  1. must The new class-set refusal (spi_income.py:1036-1050) stops the synthetic fixture build. On the fixture the FRS reference fits private_pension_income as positive-only ([1]) and the tape forest fits [0, 1]. That makes engine-uk (test_uk_graph.py::test_driver_projects_a_stage_record_for_every_graph_stage_on_the_fixture) and integration-uk fail, and I reproduced the first locally. The refusal is right in principle. A positive-only reference returns the identity placement as the sign uniform (qrf.py:1987-1996), so a copy of a twin with a pension could draw none on the tape. A positive-only reference needs every FRS training row in a fold to be positive on that leaf. The fixture produces that, and a full build will not. Two ways out, and the choice is yours: let sampled_rung allow the mismatch (recorded in the receipt), as it already allows in-sample location; or regenerate the fixture so the reference carries zeros on private_pension_income. Separately, neither this refusal nor the "folds disagree on the sign classes" refusal (spi_rank_transport.py:411-416) has a test, because _FakeFittedQRF.sign_classes always returns (0, 1) (test_support/microcosm_build/uk_spi_income.py:67).
  2. must engine-free fails on test_uk_release_assembler.py. The release certification's gates_manifest_sha256, policy_sha256 and spec_fingerprint no longer match the pins in microcosm-data/src/microcosm/data/contract.py:444-452. Locally three tests in that file fail at this head and all pass at the base 48c07990. The cause looks like the person.person_is_spi_income_recipient entry added to uk/gates.json in 249d7ab2, without re-recording the pins. It was already failing at 3205a1736.
  3. must Both engine-us jobs fail on test_us_coverage_is_exact_complete_and_honest: "seed protocol content digest differs" and "compiled seed map digest differs". The legacy-v1 seed protocol hashes microcosm.fit.qrf (spec_engine/seeds.py:361). On this branch its implementation_sha256 no longer matches EXPECTED_HASHES["seed_protocol"] (spec_engine/inventory_coverage.py:365-366), while at the base it does. The fit.qrf parity pins were re-recorded, but these two were not.
  4. must The counts in the earlier commits (point 1) stay public in the PR's commit history after a squash merge. Please rewrite the branch so that no commit carries them, in the receipts file or in the commit messages, then force-push. Old SHAs can stay reachable on GitHub by URL after a force-push, so it may be worth asking GitHub to purge them. The PR description's edit history also keeps the earlier table with source household counts, and individual revisions can be deleted from that history.
  5. nit The PR body still says it is stacked on UK: port the #1095 fixes that need no new policyengine-uk release #1200 and should be retargeted to main, but the base is already main. Its headline table quotes the pre-rebase R1d figures. Now that the branch sits on main and R1f was measured on that base, the table could use R1f.
  6. nit Please quote the reverse SEINC_NUM share in R0 rather than pointing to the stage evidence (it is a share over the whole tape). Also add a record with SEINC_NUM = 1 and no profit to the bridge test, so that the reverse share drops below 1.0.

What I checked

  • Read your reply, the 3205a1736..9da1fe7d2 diff (19 files), the current PR body, and every commit message on the branch. I also scanned the receipts file at each of the seven commits for count-like figures: the six earlier commits all carry the full tables, and the head keeps one table.
  • Fresh uv venv (--extra uk) in a scratch worktree. The engine-free files for the touched areas (test_uk_spi_rank_transport, test_uk_spi_income, test_uk_spi_spine, test_qrf_stateless, test_spec_engine_loader, test_uk_spi_band_donors, test_uk_source_stages, test_uk_graph, test_uk_property_components, test_uk_spi_pension_age) all pass, with one skip (licensed donor). test_uk_release_assembler.py fails 3 at this head and passes at 48c07990. Engine-side test_uk_spi_income.py and test_uk_spi_state_pension_age.py pass, and tests/engine/uk/test_uk_graph.py fails on the class-set refusal, as in CI. ruff check ., git diff --check and tools/ci_test_plan.py verify are clean.
  • Computed the legacy-v1 seed protocol digest at this head and at the base against the pin. I did not run the US engine suite locally, so the US failure is from the CI log plus that digest check.
  • Could not check: anything on the licensed SPI tape or FRS, so the 0.969 receipt, the reverse share and the R1f figures are not reproduced.

🤖 Generated with Claude Code

@juaristi22

Copy link
Copy Markdown
Collaborator Author

@vahid-ahmadi round 2 addressed. The branch is rewritten: two commits on the current main (f8b4b58 the change, 8cea07f the receipts note), neither carrying record counts in the file or the message; force-pushed. The old SHAs may stay reachable on GitHub by URL, and the PR description's earlier revisions can only be removed from the edit history in the GitHub UI, so I have left both to @juaristi22 to raise with GitHub if wanted.

Round-1 residuals

  1. counts. The R1b reporter table now gives the SPI channel's distinct source households as a share of the FRS channel's; no table in the note carries a count. The two commit messages are new and count-free.
  2. bridge test and reverse share. R0 quotes the reverse share (PROFITS > 0 among SEINC_NUM = 1: 0.898 on the full tape with the stage's preparation), and the validation test now holds a record that files trade pages with no profit, so the reverse share drops below one and the code still sets has_trade.

New findings

  1. class-set refusal on the fixture. Taken the first way: a full build refuses the mismatch; a sampled rung or a synthetic fixture (sampled_rung, the same switch that allows in-sample location) records it in the transport receipt (sign_class_mismatches). The fixture builds again (test_uk_graph.py on the engine side and the staging smoke integration pass locally). Both refusals are now tested: _sign_class_mismatches directly, the stage's refusal against its receipted path with the mismatch stubbed, and cross_fit_transport with a fake reference whose folds order different classes.
  2. gate-battery pins. Re-recorded in contract.py and in the data shard's test copies for the merged gates.json (the vintage pins and the release_cut part digests); the lockstep, assembler, certification and data-contract tests pass.
  3. US inventory digests. EXPECTED_HASHES["seed_protocol"] and ["seed_map"] re-pinned and the coverage report regenerated; the US contract test passes locally.
  4. history. Rewritten as above; the branch also now sits on the current main (after UK: lcfs_consumption on LCFS 2024-25, with the energy, income-band and prepayment fixes (#1164) #1260), with the generated surfaces regenerated there.
  5. PR body. The stacking sentence is gone and the headline table is R1f.
  6. reverse share and test. As in round-1 residual 2.

One more thing the rebase surfaced and I fixed: the recipient flag was written before the stage's other new columns, so the legacy oracle and the graph disagreed on person column order (H2 parity); it is now the stage's last new column, and H2 passes.

Tests on the final tree: the full engine-free, engine-uk and engine-us suites locally, plus the graph package, the fit QRF file and the targeted files; ruff, git diff --check and tools/ci_test_plan.py verify clean.

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

@juaristi22 round 3, on 8cea07f2b (two commits on 65d7d153).

Fine once the US spec-hash pin in test_us_multispine_pool_tool.py is re-recorded and the rest of engine_workflow/us passes. Both engine-us jobs fail on that pin. Everything else from round 2 is fixed.

Round-2 points

  1. must, counts in the receipts note: fixed. The R1b reporter table (experiments/840-spi-draw-receipts.md:121-130) now gives distinct source households as a share of the FRS channel's. I read the whole note at the head: shares, weighted totals and "<10" only. The column inventory ("67 person, 8 benefit-unit and 58 household columns") counts columns, not records.
  2. must, class-set refusal on the fixture: fixed. A full build refuses, and a sampled rung or the fixture records sign_class_mismatches in the receipt (spi_income.py:1035-1046). engine-uk and integration-uk are green. Both refusals now have tests, and I checked that they bite. Disabling the stage refusal (spi_income.py:1040), the mismatch detector (spi_income.py:2306), the receipt line (spi_income.py:1046) or the fold refusal (spi_rank_transport.py:412) each makes its test fail: test_stage_refuses_a_sign_class_mismatch_unless_sampled, test_sign_class_mismatches_name_the_located_targets_only and test_cross_fit_refuses_folds_that_disagree_on_sign_classes.
  3. must, gate-battery pins: fixed. contract.py:444-452 and the release_cut digests are re-recorded. The only input change is the one allow-list line for person.person_is_spi_income_recipient in uk/gates.json and terminal_gates.py, so the new pins are legitimate. test_uk_release_assembler.py and test_contract.py pass locally. Both engine-free jobs are green.
  4. must, US seed-protocol digests: fixed, but one pin is still missing (new item 1). seed_map and seed_protocol are re-pinned (inventory_coverage.py:365-366), and the coverage contract tests pass in CI. The re-pin hides no change in US behaviour. The qrf.py diff moves the existing draw into _grid_predictions and the uniform checks into _validate_uniform_mapping, and the rest is additive. The fit.qrf parity direct.csv outputs are unchanged on all three platforms; only pins.json (implementation hash, node key) moved. I also fitted a RegimeGatedQRF on synthetic data with microcosm-fit from the base and from the head, then drew 20,000 rows through predict and predict_from_uniforms. The outputs are bitwise identical.
  5. must, branch rewrite: fixed. Two commits, and neither the files nor the messages carry record counts. Agreed that old SHAs can stay reachable by URL, and that the description's edit history can only be cleared in the UI. GitHub Support can also purge unreachable objects and cached views on request. Whether to ask is your call, or the org's.
  6. nit, PR body: fixed. The stacking sentence is gone and the headline table is R1f.
  7. nit, reverse SEINC_NUM share and test: fixed. R0 quotes 0.898 (experiments/840-spi-draw-receipts.md:27), and the bridge test now holds a record with trade pages and no profit.

New findings

  1. must Both engine-us jobs fail on engine_workflow/us/test_us_multispine_pool_tool.py::test_constants_adapter_equals_live_constants_and_stays_out_of_identities. The test expects spec_sha256 3080fe93… and the run produces 3b2b17f3… (pin at test_us_multispine_pool_tool.py:341). The cause is the same as round-2 item 4: the US spec hash takes in microcosm.fit.qrf's source. I computed the US load_bundle("us").spec_sha256 with all source packages from each tree. The base gives 3080fe93… and the head gives 3b2b17f3…, which is the value you already wrote into docs/evidence/spec-engine/us-f0-coverage.json. Given point 4, re-pinning the test to 3b2b17f3… is legitimate. That shard runs with stop-on-first-failure, so please run the rest of engine_workflow/us once it passes.
  2. nit The R1e preamble still says every statistic is "a weighted aggregate … or a count of records" (experiments/840-spi-draw-receipts.md:291-292). No record counts are left, so "or a count of records" can go. R2 (:531) and the PR body still say the branch sits on UK: port the #1095 fixes that need no new policyengine-uk release #1200's head. It now sits on main after UK: lcfs_consumption on LCFS 2024-25, with the energy, income-band and prepayment fixes (#1164) #1260. R1f was measured on a50ffba5e, which is fine, but those two sentences could say so.
  3. nit The change commit's message says "The full engine-free, engine-uk and engine-us suites pass locally", and the PR body says much the same. CI says otherwise for engine-us at this head. When you amend for item 1, please run the US workflow shard before you keep that sentence.

What I checked

  • Read your reply, both commit messages, the PR body and the full receipts note at the head, and scanned the change commit's docs, manifest, code comments and changelog for count-like figures. Nothing found.
  • Fresh uv venv (--extra uk) in a scratch worktree. These all pass, with one skip (licensed donor): the touched engine-free files (test_uk_spi_rank_transport, test_uk_spi_income, test_uk_spi_spine, test_uk_graph, test_uk_property_components, test_uk_source_stages, test_uk_spi_band_donors, test_uk_spi_pension_age, test_uk_release_assembler, test_spec_engine_loader, test_qrf_stateless, the data test_contract) and engine-side test_uk_spi_income, test_uk_graph and test_uk_spi_state_pension_age. ruff check ., git diff --check and tools/ci_test_plan.py verify are clean.
  • I did not check the H2 parity fix (the recipient flag as the stage's last column) on its own. I rely on green engine-uk and the engine-side test_uk_graph run.
  • The four mutations in point 2, the base/head QRF bitwise comparison and the US spec hash at base and head.
  • CI on 8cea07f2b: lint, wheels, select-countries, engine-free (3.13, 3.14), engine-uk (3.13, 3.14) and integration-uk pass. engine-us (3.13, 3.14) fail as in new item 1.
  • Not run locally: the US engine suites (no US extra in my venv), so the US failure is from the CI logs plus the hash check. Nothing on the licensed SPI tape or FRS, so the R0 to R1f figures are not reproduced.

🤖 Generated with Claude Code

@juaristi22

Copy link
Copy Markdown
Collaborator Author

@vahid-ahmadi round 3 addressed; the branch is re-recorded as the same two commits (7ba544e the change, c09e6f7 the note), force-pushed.

  1. must, US spec-hash pin. test_us_multispine_pool_tool.py now pins 3b2b17f3…, the value the coverage report already carried. The whole engine-us job list (contract, scenario and workflow shards) ran locally in a venv with the US extra and passes; the commit message and the PR body now say exactly that.
  2. nit, wording. The R1e preamble no longer mentions counts of records; R2 and the PR body say R1f was measured on UK: port the #1095 fixes that need no new policyengine-uk release #1200's head (a50ffba5e) and that the branch now sits on main after UK: lcfs_consumption on LCFS 2024-25, with the energy, income-band and prepayment fixes (#1164) #1260.
  3. nit, the claim about local suites. Changed to what was run: the three job lists, locally, on the final tree.

Thank you for the mutation checks and the base/head bitwise comparison of the QRF draws; that is the confirmation the parity re-pins needed.

Maria Juaristi and others added 2 commits October 14, 2026 18:59
Stage 17 (hmrc_spi_income_spine) drew every SPI-channel claimant's and
partner's income leaves from the SPI tape conditioned on age, gender and
region only, at the model's random numbers. The FRS twin's own incomes,
and the relation between family composition and income they carry, were
discarded: half the lone-parent UC mass sat on SPI copies drawn as
taxpayers, SPI lone parents passed the award screen at about half the
FRS rate, and SPI employment status disagreed with the drawn income
(uk-data#529).

Bridge predictors. The tape forest now conditions on four facts about a
record's income that the tape and the twin each carry: main_source (the
largest of pay, pension and trade) and has_pay from the same leaf
formulas on both sides; has_trade and at_or_over_spa from the tape's
published indicator codes (SEINC_NUM, SPA; the leaf formulas serve a
composite record without codes) and, on the twin, its trade leaf or
self-employed status and its age against State Pension age. The read
requires the three code columns and refuses unknown codes by name; the
published MAINSRCE code validates the leaf-defined main source (0.969 of
the classified weight on the full 2022-23 tape with the stage's
preparation; floor 0.95) and the receipt reports each indicator code
against its leaf both ways.

Twin-conditional draw. Reference forests fitted on the FRS base claimants
and partners with the same predictors and chain order locate each twin's
observed leaves (FittedRegimeGatedQRF.conditional_uniforms, a Rosenblatt
transform: the sign uniform in the gate's cumulative class probabilities,
the quantile on the sign's magnitude forest through the new
_Forest.quantile_positions, the inverse of its draw), and the tape forest
draws at those uniforms through predict_from_uniforms. The reference is
cross-fitted in five folds keyed on the predictor cell, so the forest
locating a copy never saw its twin (an in-sample forest holds the row's
own value as an atom that took a median 0.23 to 0.74 of the conditional's
mass under the weighted bootstrap and kept under half of the twin's
rank). Leaves without an FRS analogue, and the placements inside a class
or a flat stretch, take uniforms keyed on person_source_id, so stage 1
consumes no model random stream. A full build refuses a reference the
cells cannot cross-fit (only a sampled rung or a synthetic fixture may
locate in-sample, and its receipt says so), a recipient whose fold
differs from its twin's, a frame without person_source_id, and a located
target whose reference sign classes differ from the tape forest's
(FittedRegimeGatedQRF.sign_classes).

No-record rule. A twin with no taxable income (no pay, trade or pension,
and at most £1,000 of taxable interest, dividends, property and
miscellaneous income together) has no tax record for the tape to
condition on; its copy keeps the twin's values at stage 1 and stage 2.
Reserved band carriers are always drawn. Stage 2, the band-donor
resample, the uprating and the SPA guard are unchanged.

Integration with #1145 and #1200. The restricted finance costs leaf
reaches the FRS rows only at property_components, so it takes
identity-keyed uniforms. The stage writes person_is_spi_income_recipient,
True exactly for the rows it drew, as its last new column; the landlord
stage reads it to tell tape-concept rows from FRS-concept ones (its graph
node declares the read, with a test that the declaration covers the
stage's inputs), so an undrawn copy keeps its twin's property income on
the FRS concept. The UC gainful self-employment refresh on drawn rows
takes the root's whole rule, undrawn copies keep their twin's flag, and
the evidence counts the rows each route flags.

Surfaces. The manifest declares the bridge, the recipient income rule,
the draw, the reference model and the identity uniforms; spi_spine pins
them with drift asserts; the stage evidence carries the bridge-validation
receipt and the per-target transport receipt (analogue, FRS regime and
sign classes, uniform source, Kolmogorov distance from uniform overall
and by main source; a positive-row count under the group floor is
withheld, and the counts stay with the build). Regenerated: the schema,
the source_stages mirror, the coverage manifest, the UK spine parity
fixture, the spec loader's golden hash, the fit.qrf parity pins, the US
spec-engine inventory digests, coverage report and the multispine pool
tool's spec-hash pin (the fit kernel's source hash moved), and the
gate-battery vintage and release_cut pins in the data contract (the
terminal-gate allow-list gained the flag).

Tests: fit (round trip, uniformity, two-population transport, zero-class
carry, chunked equality, sign classes, refusals); engine-free UK (bridge
and code refusal with disagreeing codes, no-record rule, analogue map,
transport, cell folds, cross-fit hold-out, in-sample and twin-fold and
sign-class refusals, spine drift pins, graph reads); engine-side UK
(identity-keyed rewrites, fit records, the recipient flag and the
refresh receipt). The engine-free, engine-uk and engine-us job lists
passed locally before the final rebase onto main; the parity, pin and
targeted files after it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
experiments/840-spi-draw-receipts.md: the tape receipts (R0), the spine
twins at design weights on the pre-rebase base (R1a to R1e: stage
evidence, the award screen and reporters by channel, rank fidelity and
the in-sample atom behind the cross-fit, the rebuilt treatment, and the
downstream moves by mechanism) and the twins on #1200's base (R1f).
Shares and weighted totals only; record counts stay with the raw
receipts beside the builds (CD137 cl. 8 / CD171 5.2.1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@juaristi22

Copy link
Copy Markdown
Collaborator Author

Rebased onto main at 1cfbf7d82 (after #1242): the same two commits, now 6211ee6f9 and 0bb3018c2. Main's own re-pins for the solver option and this branch's fit-kernel change meet in the spec golden hash, the US seed-protocol and seed-map digests, the coverage report and the multispine pool tool's spec hash, all recomputed on the combined tree; the UK spine parity fixture regenerates to the same bytes. The parity (H2), pin, graph and targeted files pass on this head; the three job lists ran locally on the head before this rebase.

@juaristi22
juaristi22 merged commit 121fd4d into main Oct 14, 2026
10 checks passed
juaristi22 added a commit that referenced this pull request Oct 14, 2026
The rebase onto main 121fd4d (#1256, #1279) settled the generated
files with main's side. Regenerated on the rebased tree: the gate
battery digests (main's export allow-list line beside this branch's),
the coverage manifest's source digests and the H2 fixture, whose
synthetic CGT asset-type facts follow the locked engine. The rendered
stages are unchanged. uv.lock merged cleanly (microcosm-emitter 0.2.1
from PyPI beside policyengine-uk 2.130.1), and the lock commits pin
its new digest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@juaristi22
juaristi22 deleted the uk-840-spi-draw branch October 15, 2026 10:24
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.

UK spine: composition-faithful SPI income draw — bridge predictors and rank-preserving replacement (with-children UC residual)

2 participants