Port the FRS derived layer as declarative source stages - #703
Conversation
Six stages appended after frs_spine (employment, council tax cell means, disability categories/flags, education incl. the grant/DSA split, legacy proxies), the generic impute_cell_means operation kind, the multi-stage spine driver, and the UK nonnegative_columns gate declaration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Repairs the #691 x #692 seam: #691 moved household weights to the typed vector only (the carrier column is dropped), while #692's frs_spine still declared household_weight as a stage output, so the StagePlan produces check fails on main. The weight mass is attested by the typed vector and the sidecar total, not a column. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
UKSingleYearDataset takes fiscal_year, not time_period; the adapter's materialize path had no real-engine consumer until the E3 stages, so the protocol tests could not see the mismatch. Adds an engine-gated construction test (cheap: no simulation) that pins the real kwarg. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The incumbent's map is inverted (low codes read as school-level, 17-21 as degrees). Verified against the FRS 2023-24 raw aggregates: code 1 (Doctorate) is 1.8% of adults at the highest mean earnings, 18/19 are near-empty niche baccalaureates, and the GCSE band carries the mass the inverted map displaced. Code 87 stays unmapped to the fillna default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vahid-ahmadi
left a comment
There was a problem hiding this comment.
I checked this the same way as #692 — every derivation line-by-line against the incumbent's code — and the port discipline holds almost everywhere. Verified faithful: the disability stage exactly (threshold max(0, rate − 1), ascending overwrite, and the flag operator asymmetry — dla_sc > for enhanced vs dla_sc >= for severe — preserved and pinned, with the parameters(year).baseline.gov.dwp call shape matching the incumbent's _dwp_category_threshold_parameters and your empirical cross-check settling the rest); the council-tax cell means exactly (raw ctannual > 0 donor filter over netted values, dropna=False, no-donor cells → 0, missing = <0 | isna, and the dead ct_mean.replace(-1, …) correctly not replicated — netted donor values are clipped ≥ 0, so a −1 mean is unconstructible); the proxies exactly (the JSA scale-invariance argument is correct: tothours·k < 16·k is the same person set for any k, so signed difference 4 is genuinely inert); the grant allocator near-verbatim against the incumbent's own allocate_reported_education_grants; and the np.select education cascade preserving the incumbent's elif ordering including the pinned-dead POST_SECONDARY branch. Both main-repair commits are right — I confirmed fiscal_year against the incumbent's own UKSingleYearDataset(..., fiscal_year=year) call, and the household_weight drop is the correct resolution of the #691×#692 seam. The driver generalization is clean, and the cross-stage artifact-pin consistency check is a nice fail-closed touch. All 91 tests in this PR's files pass for me with the uk extra.
The EDUCQUAL correction is verified and endorsed. The incumbent's map is the older codeframe misapplied: recent FRS EDUCQUAL is the harmonised highest-qualification frame that starts at 1 = Doctorate and descends, and your decisive checks (code 1 at 1.8% of adults with top mean earnings; 18/19 near-empty baccalaureates; the mass in the 36–82 GCSE band) are exactly the right kind of evidence — this is the #692 lesson applied in the harder direction, catching the incumbent's coded-domain error rather than inheriting it. Two follow-ons: (1) this bug ships in production policyengine-uk-data today — highest_education feeds live models, so it deserves an upstream issue with your aggregates as the evidence, the same way uk-data#448/#449 flowed the other way; (2) the manifest's "swappable constant pending licensed UKDS data-dictionary verification" note is honest — close it by actually citing the 2023-24 data dictionary when you have it open, which also satisfies the checklist rule we adopted after #692.
One thing I'd act on before merge: the EMA port doesn't do what the PR and the manifest say it does.
The incumbent's construction is fill_with_mean(person, "adema", "ademaamt") (child: chema/chemaamt):
needs_fill = (table[code] == 1) & (table[amount] < 0) # participants with sentinel amounts
fill_mean = table[amount][(table[code] == 1) & (table[amount] >= 0)].mean()
filled_values = np.where(needs_fill, fill_mean, table[amount])
return np.maximum(filled_values, 0) * multiplier_ema in frs_education.py reads no participation flag at all and computes max(amount, 0) × WEEKS_IN_YEAR, with _num's .fillna(0) upstream — so a participant coded flag=1, amount=−1 gets £0 where the incumbent gives the participant mean. The mean-fill doesn't exist anywhere in the stage. Yet the PR body calls this "EMA fill-with-mean (the impute_cell_means degenerate single-cell case)" and the manifest's operation scope says "EMA cell-mean degenerate fills" (source_stages.json:468) — declared behavior with no implementation behind it, which is exactly the class this repo's own receipts culture exists to prevent. Signed difference 5 covers only the ×52 → WEEKS_IN_YEAR multiplier and reads as if the rest of fill_with_mean were preserved.
Two adjacent problems in the same function:
- Column names. Adult tries
("emaamt", "edumaamt"); the incumbent readsademaamt, falling back toedumaamtonly whenademais absent."emaamt"looks like a typo for"ademaamt"— if the 2023-24 adult tab carriesademaamt, your first choice never hits and the code silently reads the legacy-era column. The child fallback("chemaamt", "eduma")names the incumbent's participation flag as an amount — wrong-typed if ever exercised. - The acceptance can't exonerate this. Adult EMA runs −54% against the register, attributed to "tiny columns" — but the missing mean-fill and a possibly-wrong amount column are unexamined candidate contributors to that deficit. The nonzero-share match at ±0.0000 is four-decimal rounding on a column whose true share is ~0.0003, so it has no power here.
The fix is small either way: port fill_with_mean's semantics with the incumbent's column pairs, or — if the survey genuinely has no fillable rows at this vintage — sign the actual difference (no flag gate, no mean-fill, sentinel→0) and correct the manifest text and PR body to match. What can't stand is the declaration of a fill that isn't there.
Smaller
- Unmapped
empstaticodes above 11 fall to"CHILD"in your map default; the incumbent's post-map.fillna("LONG_TERM_DISABLED")sends them there instead. Inert if the domain stops at 11, but the truncated-zip artifact you carefully preserved for code 11 shows the domain has surprised people before — one line makes the fallback match. derive_frs_employment's docstring says missingmjobsect/sic"fail loudly," and they do — butempstatigoes throughaligned.get(...), which on a missing column fails as anAttributeErroron a float rather than aKeyErrornaming the column. If empstati's absence should also be loud, direct-index it like its siblings.
The disability signed difference is handled exactly right — running the incumbent's own functions on your spine and getting bit-identical shares is the strongest possible form of "not a port defect," and deferring the composition question to E10 with the reference-vintage explanation on record is the honest disposition.
The EMA derivation now actually implements the declared participation- gated mean fill (donor mean for participants with sentinel amounts) with the incumbent's column pairs — adema/ademaamt preferred, eduma/edumaamt when adema is absent (the 2023-24 vintage), chema/chemaamt for children. Unmapped empstati codes above the domain now take the incumbent's LONG_TERM_DISABLED fallback instead of CHILD, and empstati is direct- indexed so its absence fails as loudly as mjobsect/sic. The manifest's EMA operation declares the true fill semantics and the EDUCQUAL note records the completed verification. The DWP-rates instant test skips without the uk extra (the wheel gate and us-extra CI failure). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@vahid-ahmadi — all four items addressed in e1f2290; you were right on every count, and the EMA finding was the real thing: declared behavior with no implementation behind it. EMA. On your "the acceptance can't exonerate this" point — it can now, with the fill implemented: the licensed rebuild is share- and payload-identical, and the raw tabs show 11 adult participants and 24 child participants, zero sentinel-negative amounts at this vintage, so the fill is provably inert here and contributes nothing to the register deltas. The −54%/−17% weighted deltas are small-cell sensitivity: those columns have 11/24 unweighted carriers, and the register totals ride on the calibrated, uprated weights of those same few rows. Signed difference 5 and the acceptance section now say exactly this. empstati fallback. Now the incumbent's post-map empstati loudness. Direct-indexed like its siblings; the parametrized missing-column refusal test now covers all three. CI. The wheels failure (and the us-extra test lanes) was the Jan-1 instant-pin test constructing the real parameter tree in an engine-less venv — now EDUCQUAL follow-ons. Upstream defect filed with the aggregates as evidence: PolicyEngine/policyengine-uk-data#459. The manifest note now records the completed raw-aggregate verification rather than "pending"; the data-dictionary citation itself will be added when the licensed 2023-24 documentation is staged locally — we don't have it to cite honestly today. |
UK Data Service SN 9367, DOI 10.5255/UKDA-SN-9367-2, adult table: every mapped label matches the dictionary verbatim; code 87 is undocumented there (carried by ~13% of adults) and deliberately falls to the default. Also closes the E2 manifest's vintage-DOI gap with the same identity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The driver tests stubbed the rules engine but not the three deferred parameter readers (DWP disability rates, JSA hours, DSA maximum), so the us-extra CI lane — which has no policyengine-uk — failed at the disability stage's reader. The tests now stub all three readers and block the engine import outright, so a regression to eager engine use fails loudly in every environment instead of only engine-less ones. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…olumns The gate pre-filtered its required set to columns present on the frame, so a frame missing every declared nonnegative output passed with columns_required=0 — and candidate-only outputs like sic_industry_division are outside the 145-column coverage contract, so nothing else would catch their absence. The required set now derives from the build's scheduled stage roster (the same build_stage_names the preflight coverage gate attests, now also supplied to the terminal phase): a scheduled stage's missing declared column fails through the shared gate's missing-column path, and unscheduled stages' columns are not demanded. Regression tests cover missing-column, negative-value, clean-pass, and unscheduled-stage cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rates The head-to-head receipt against the incumbent's own create_frs output (same tabs, pinned revision) caught a one-row flag divergence: the incumbent's baseline-vs-plain parameter split is value-bearing, not just provenance. The baseline clone escapes policyengine-uk's fiscal-year conversion (April-2022-era weekly rates, 92.40 — what the categories threshold against), while the plain tree the flags read carries the fiscal-2023-24 values (101.75). The flag reader now reads the plain tree through the model system exactly as the incumbent does; the engine-gated test pins the value-bearing split. With this, all 26 E3 columns agree row-for-row with the incumbent at the pin except the two signed differences (EDUCQUAL, EMA multiplier). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Derived-layer receipt: every E3 column compared head-to-head against the incumbent's own code on the same tabs — the analogue of E2's raw-fidelity receipt, with one deliberate method change: instead of the published base artifact, the comparator is the incumbent's
The 100% set includes every category and flag, The receipt caught a real one-row port bug, fixed in Method note: comparison over the common person/household ids with weights mapped through the household linkage; the incumbent dataset was built in its own pinned checkout and venv, output kept beside the other licensed acceptance artifacts; determinism pair for the E3 spine re-verified |
…ions, spec-armed thresholds The #630 case-2 ruling and the weighted-integrity tracking note, landed as one policy motion on the post-#710 surface: - uk_input_mass_parity compares against a named reference from a reviewed registry in uk/gates.json (identity pins, canonical totals digest, scope note), with reviewed exclusions scoped per reference (register schema 3) and the armed reference + scope note recorded in the signed report. First entry: charitable_investment_gifts excluded on the channel-blind efrs-post-calibration reference (+15,168.8% on the post-#710 surface; SPI-channel-exclusive). - The input-mass and QRF-tail thresholds move into gates.json parameters at the measured edges minted from the post-#710 measurement run (worst surviving drift 4.521811483823806 at the 0.0 reference floor; top_k 100, max_top_share 0.9970712395200448, min_nonzero_records 274); the five CLI arming flags retire. A missing licensed totals sidecar now records evidence_absent instead of silently disarming, and the input-mass evidence payload seals the exclusions in force plus their committed/override provenance. - uk_weight_ratio keeps its exact certified June bound: the #630 finding-2 breach was adjudicated an upstream SPI allocation defect and fixed by #710; the entry notes record that resolution and the no-headroom fence discipline (relocating the derivation prose #691 deleted). The microcosm-data mirrors move in the same reviewed change (active reference, required detail fields, the three vintage digests and the input-mass evidence pin, producer-recomputed over the merged spec incl. #703's uk_nonnegative_columns), and a new lockstep test holds the contract's numeric mirrors equal to the committed spec — restoring the chain #691's constant deletion broke. Refs #630, #609, #610, #635, #665, #705, #710. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Workstream E3 of the UK migration (#145, master epic #665). Closes #679. Stacked on the merged #692 (E2); based on current main.
What
Ports the incumbent FRS derived layer as six declarative source stages appended after
frs_spineinuk/source_stages.json, each a whole-stage callable with its own lockstep, determinism, and hand-computed-fixture tests, run by the (now multi-stage)tools/build_uk_frs_spine.pydriver:frs_employment—employment_status(exact incumbent semantics incl. the truncated-zip artifact: code 11 → LONG_TERM_DISABLED, never OTHER_INACTIVE),employment_sector,sic_industry_division; deliberately fail-loud on missingmjobsect/sic(incumbent posture preserved).frs_council_tax— the (region, band, single-adult) cell-mean imputation via the new genericimpute_cell_meansoperation kind, ported post-uk-data#449 (Scottish Water netted out of CTANNUAL): rawctannual > 0donor filter over netted values,dropna=Falsecells, reported-zero households neither donors nor imputed, no-donor cells → 0. Re-readshousehol.tab(byte-identical artifact pin to the spine's) because the spine'scouncil_tax_reportedis clipped and the raw −1/NaN missing semantics matter.frs_disability— the five categories (thresholdsmax(0, weekly_rate − £1), ascending overwrite, default NONE,.baseline.gov.dwprates) and the three flags (plaingov.dwprates, annualized £1×WEEKS gap, the incumbent's operator asymmetry on dla_sc preserved and pinned). Pure functions over (person, frozen-rates dataclass) so E7's SPI-donor refresh reuses them.frs_education—current_educationcascade (incl. the incumbent's unreachable POST_SECONDARY branch, pinned dead), the correctedEDUCQUAL_MAP(signed difference, below), QYP flags/entry age/terminal date, EMA fill-with-mean (theimpute_cell_meansdegenerate single-cell case),receives_benefits_in_own_right.frs_legacy_proxies— the three ESA/JSA claimant-state proxies (explicitly proxies, not entitlement);state_pension_agematerialized through the rules-engine adapter (consumed, never persisted); JSA thresholdgov.dwp.JSA.hours.single × WEEKS_IN_YEARon the spine's signed hours scale (person-set identical to the incumbent's 832-hour cutoff; boundary pinned both sides).frs_education_grant_split— the proportional allocator over engine-materialized capacities (childcare_grant,parents_learning_allowance,adult_dependants_grant) with the DSA year gate (zero vector pre-2025, alignment-pinned); rewriteseducation_grantsin place (declared via arewriteskey —frs_spinestays the one canonical producer) and emitsdisabled_students_allowance_eligible_expenses(reference-documented zero layer at this vintage).Supporting changes:
materialize_rules_engine_predictorsdeclarations with predictor-lockstep tests (first manifest use of the U1 adapter); parameter readers on the CGT deferred-import pattern but resolving through the model system'sparameters(year)— the incumbent's exact call shape, including its value-bearing baseline-vs-plain split (signed difference 2)semantics — an April-rates trap a June read would hit); theuk_nonnegative_columnsterminal gate declared (bringing E2's 24nonnegative_outputsdeclarations live;UK_NONNEGATIVE_SOURCE_OUTPUTSmirrors the US pattern; presence stays owned by the coverage contract); microcosm-data mirror digests refreshed; driver sidecar/shares generalized to multi-stage (fixes therecords[0]` single-stage emission).Two commits here repair main, and can be cherry-picked independently:
UKSingleYearDatasettakesfiscal_year, nottime_period; E3 is the adapter's first real-engine consumer, so no existing test could see it. Adds a cheap engine-gated construction test.UK-specificity note (required by #679)
UK-specific: the FRS coding maps (empstati/mjobsect/typeed2/educqual/train), DWP rate structures and the baseline-vs-plain parameter split, QYP legislative approximations, Scottish Water netting, and the value-bearing baseline-vs-plain parameter-tree split. Generic contributions any country can use: the
impute_cell_meansoperation kind (cell means and fill-with-mean as one declarative primitive), the multi-stage single-driver sidecar/shares pattern, and the consumed-not-persisted rules-engine materialization pattern. The incumbent package name appears nowhere in the tree (concatenation idiom preserved).Signed differences
highest_educationcomposition moves from {TERTIARY 9.9%, POST_SECONDARY 21.3%} (inverted map — implausible for the UK) to {TERTIARY 28.1%, POST_SECONDARY 8.4%}. Code 87 (no qualification data) stays unmapped to the UPPER_SECONDARY default.is_disabled/enhanced/severeunweighted shares run above the 145-column reference. Verified NOT a port defect by the head-to-head receipt: all five categories and all three flags agree row-for-row at 100.0000% with the incumbent's owncreate_frsat the pinned revision on the same tabs (after the receipt caught andded274d4fixed a one-row rate-tree divergence — the incumbent's baseline-vs-plain parameter split is value-bearing: the baseline clone escapes policyengine-uk's fiscal-year conversion, so categories threshold at April-2022-era rates while flags use fiscal-2023-24 values; both readers now reproduce their respective incumbent paths exactly). The residual deltas against the frozen reference are purely the artifact's vintage (rev 655dd07e predates the recent disability-logic fixes) plus composition (the SPI-synthetic/donor stack, ~37% of persons, which E7 adds). Re-measured at matching composition at E10.16 × WEEKS_IN_YEARon E2's signed hours scale (person-set provably identical to the incumbent's 832 cutoff).fill_with_mean— the same U8 class as E2's three sites. The rest offill_with_meanis preserved exactly (participation-gated donor-mean fill, incumbent column pairs with the adema→eduma alias): at the 2023-24 vintage the fill is provably inert — 11 adult / 24 child participants, zero sentinel-negative amounts — so the multiplier is the only live difference.ct_mean.replace(-1, …)(unreachable by construction).receives_benefits_in_own_rightproduced infrs_education— one-producer placement settled on #145 (2026-08-17): E3 is the canonical producer; E7 has no rewrite obligation (the incumbent never re-derives the flag on SPI donors).Licensed-data acceptance (all data stayed local; disclosure-safe aggregates only)
compare_uk_h5_payload.pyverdictpayload_identical: true. Both attempts landed hash-chained Logbook rows (pipelineuk-frs-spine).Implementation notes
Codex implemented under
/codex-implement-plan; recorded deviations, both reviewed as more faithful than the plan: baseline DWP rates read viaCountryTaxBenefitSystem().parameters(year).baseline.gov.dwp(the raw parameter files carry no.baselinesubtree — this is the incumbent's exact call shape), and the pre-2025 DSA policy is inert-zero because the 2023 DSA maximum resolves to None (never consumed in the incumbent either). Post-rebase repairs after #691/#692 merged to main: the four gates/contract-pin conflicts re-expressed on main's refactored test structure, and the six stage rebuilds now passhousehold_weights=explicitly per #691's frame contract.🤖 Generated with Claude Code