One year semantics for the FRS build: survey year for observed amounts, policy year for modelled seeds - #480
Conversation
…ts, policy year for modelled seeds Fixes #475, #476, #477, #478; documents #479. - Disability categories now threshold reported amounts against the fiscal-converted `gov.dwp` rates at the survey year, the same tree the flags read. The `baseline` clone is taken before policyengine-uk's fiscal-year conversion, so `baseline` at 2024 carried the 2023-24 table and the categories ran a year stale relative to the flags (#475). - Both derivations convert annual reported amounts back to weekly with the survey's 365.25/7 factor. The categories divided by the model's 52, which inflated weekly amounts by 0.34% and stretched the GBP 1/week tolerance to GBP 1.37 (#476; the flags were already on the survey factor). - `create_frs` refuses a `year` that does not match the FRS release folder it reads (`frs_2024_25` is survey year 2024) instead of silently stamping the wrong `time_period` and mis-thresholding every category (#477). - `create_frs` takes a `policy_year` (the release's calibration year in the build) and gates the Disabled Students' Allowance seed on it, so the FRS 2024-25 dataset seeds DSA expenses for the years it is simulated at (#478). - The post-calibration rail, bus and road-fuel scalers keep running after the base-year materialisation; a comment records why, and that the saved base-year file is therefore not uprate-invertible on those columns (#479). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
vahid-ahmadi
left a comment
There was a problem hiding this comment.
Review at 575ae376 (Claude Code, high effort; fresh clone, the lockfile's policyengine-uk 2.93.0 installed, full suite run: 520 passed, 113 skipped, 1 xfailed, matching the PR body; ruff format --check and ruff check clean). Since these are my five issues, I went back through each premise rather than only the fix.
Per issue
#475 — closes, and the premise holds. Verified in 2.93.0 that tax_benefit_system.py clones baseline (:102) before convert_to_fiscal_year_parameters rewrites gov (:108), so parameters(Y).baseline.gov.dwp carries FY Y−1/Y: AA higher gov/baseline is 101.75/92.40 at 2023, 108.55/101.75 at 2024, 110.40/108.55 at 2025. The PR routes categories and flags through one _dwp_rate_parameters returning .gov.dwp (disability_benefits.py:66-77, used at :126 and :192); no .baseline read remains in the package. The £1/week tolerance is preserved on both paths and expressed in survey weeks.
#476 — closes, and you were right that my premise was inverted. frs.py:45 has been 365.25 / 7 since July 2025 and the reported-amount loop multiplies by it, so the data was never at 52; the flags matched it; the categories were the only 52-week path, dividing by model_api.WEEKS_IN_YEAR. The 0.34% error therefore ran in the promoting direction (tolerance effectively £1.37), and my "0.30 weeks of slack" and the year=2025 row were artefacts of assuming the convention I was criticising plus the #477 wrong-year scenario. The fix is the one I asked for, applied to the other path, and test_category_tolerance_is_one_pound_a_week_in_survey_weeks pins the shared constant. Yes, please file the residual fragility separately — the £1 gap being smaller than one April uprating step (£6.80 for AA higher) is the case worth tracking, and the new test_categories_and_flags_share_the_survey_fiscal_year_rates now encodes that a full-year reporter of the previous year's rate is LOWER and not enhanced, which is correct today and exactly the behaviour a future change to the gap would have to revisit.
#477 — closes, one question. validate_frs_survey_year (frs.py:539-559) runs inside create_frs before any tab is read, so both production call sites are covered, and it refuses both a mismatched year and a policy_year before the survey year. It validates against the folder-name pattern frs_YYYY_YY rather than CURRENT_FRS_RELEASE.survey_year or the tab sha, and when the folder does not match the pattern it silently skips (:550-551). I understand why — the synthetic-folder test call in test_legacy_benefit_proxies.py would otherwise need a release descriptor — and folder identity is what is available at that point. Worth one sentence in the docstring that a non-conforming folder name bypasses the check, or a warnings.warn on that branch; I am fine either way.
#478 — closes. DSA gate, the three eligibility reads and the maximum all read at policy_year; other grant capacities stay at year; policy_year defaults to year at both definitions so existing callers are unchanged; both build call sites pass calibration_year. The one seeded record on the licensed tabs is thin but no longer identically zero, and the arithmetic in the receipt is consistent (2,802.5 − 2,718.7 = 83.8).
#479 — documentation is the right disposition, with one correction to the comment. Your two points check out: petrol and diesel spend are in uprating_factors.csv, and the scalers run after the solve in either order, so reordering cannot make the round-trip exact. Your runtime argument also holds for rail and bus, and there is a reason the comment could name: policyengine-uk runtime-uprates bus_fare_spending (CPI, uprating_indices.yaml) and rail_usage (gov.dft.rail.ridership_index), so a factor fitted on a 2025-stamped file and then down-rated would be re-uprated at load. But the comment at create_datasets.py:314-318 lumps road fuel in with rail and bus, and fuel is fitted differently: the rail and bus scalers flip time_period and simulate at calibration_year (services.py:64-70, :96-102), whereas calibrate_dataset_fuel_litre_proxies_to_road_fuel passes int(dataset.time_period) (consumption.py:772-780), which is 2024 after materialisation, so it fits the 2024 litres target at 2024 prices and whether 2025 litres hit target depends on the proxy's uprating index, not this fit. Please split the fuel sentence out. I will close #479 as documented once that lands, leaving the scalers-as-targets question with microcosm#790.
Other
create_frs gains policy_year: int | None = None after the last existing kwarg, backward compatible; the renamed positional on calculate_disabled_students_allowance_reported_grant_capacity has one caller. The intended break — year=2025 on frs_2024_25 now raises with a pointer to uprate_dataset — is the #477 fix working. All six tests in test_frs_survey_year.py are hermetic (20 tests across the three changed files run in under two seconds with no licensed data). The changelog fragment is untyped like every other fragment in this repo, so convention followed; strictly the new kwarg is an addition. I cannot reproduce the "no category changes band" receipt without the UKDS tabs; the tests encode the mechanisms, not that claim.
Verdict
Approving. One should-fix (the fuel sentence in the #479 comment), one question (#477's folder-name-only check and its silent skip), and thank you for the correction on #476 — the issue's fix was right and its diagnosis was not.
…n on unchecked FRS folder names - The scaler comment in create_datasets.py treated road fuel like rail and bus. Rail and bus are fitted by simulating the saved base-year file at the calibration year (policyengine-uk re-uprates rail_usage by the ridership index and bus_fare_spending by CPI at load); the fuel litre-proxy scaler reads the saved file's own time_period and fits base-year litres at base-year pump prices to the base-year clearances, and petrol/diesel spending are in uprating_factors.csv with their own load-time indices. - validate_frs_survey_year now warns when the folder name is outside the frs_YYYY_YY convention instead of silently skipping the check; the docstring says so, and the tests cover both the warning and the silent conforming path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Thanks for re-deriving each premise rather than only reading the fix. Follow-up commit 746dd97:
Ruff clean; the five touched test files pass locally on 2.93.0 (49 tests). CI will rerun the licensed build on the new head. |
Fixes #475. Fixes #476. Fixes #477. Fixes #478. Documents #479.
The five issues Vahid filed on 2026-09-03 share one root:
create_frshas a singleyeardoing three jobs (survey vintage,time_periodstamp, policy year for parameter reads), and the two disability derivations read that year through two different parameter trees and two different week factors. This PR gives the build one set of semantics:yearis the survey year (fiscal yearyear/year + 1, the dataset'stime_period). It must match the FRS release folder being read, and observed benefit amounts are thresholded against the DWP rates in force during that fiscal year.policy_yearis the first policy year the dataset is simulated at (the release's calibration year in the build). It gates seeds for programmes policyengine-uk models from a later year than the survey, currently Disabled Students' Allowance.Per issue
parameters(year).gov.dwp, the fiscal-converted tree the flags already read. Thebaselineclone is taken before policyengine-uk's fiscal-year conversion, sobaselineat 2024 carried the 2023-24 table (AA higher 101.75 vs 108.55).frs.pyand the flags. The premise of the issue is inverted onmain:frs.pyhas annualised at 365.25/7 since July 2025 and the flags already used it; the categories were the odd path, dividing by the model's 52 (a 0.34% inflation in the promoting direction, inside the GBP 1/week tolerance). See the comment on #476.create_frsrefuses ayearthat does not match the release folder it reads (frs_2024_25is survey year 2024) with a message that says whatyearmeans and points atuprate_dataset. Folders outside thefrs_YYYY_YYconvention (synthetic test fixtures) are not validated.policy_year; both build call sites passcalibration_year. The FRS 2024-25 build therefore seedsdisabled_students_allowance_eligible_expensesinstead of writing zeros. Other grant capacities stay at the survey year.create_datasets.pyrecords why the four scalers run after the base-year materialisation (each is fitted on the configuration consumers actually run) and that the saved base-year file is not uprate-invertible on those columns. The order is unchanged: the issue's round-trip claim holds under either order, and refitting at the calibration year would land the runtime aggregates slightly off.Receipt on the licensed FRS 2024-25 tabs
create_frsrun on the pinned UKDS SN 9563 tabs (sha25605dd0069…54a9aeb, the release pin),year=2024, before (origin/main8629dbb) and after (this branch,policy_year=2025), both on policyengine-uk 2.93.0. Aggregates only; 34,966 persons, 68.25m weighted.is_disabled_for_benefits/ enhanced / severe, weighteddisabled_students_allowance_eligible_expenseseducation_grantsresidual, weightedtime_periodCategory counts on both sides: AA higher/lower 635/372; DLA care higher/middle/lower 212/292/90; DLA mobility higher/lower 255/257; PIP mobility enhanced/standard 983/500; PIP daily living enhanced/standard 966/793.
So #475 and #476 change no row on this vintage: respondents report the statutory rates in force at interview, and adjacent bands are ~49% apart, so neither the one-vintage threshold shift (6.8%) nor the 0.34% week factor crosses a boundary. Those two fixes remove latent fragility rather than move the shipped data. #478 opens the DSA seed, which the existing eligibility machinery fills with one record; the input is thin, but it is no longer identically zero.
Tests
test_disability_benefits.py: categories and flags share the survey fiscal year's rates (a person reporting the previous year's AA higher rate all year isLOWERand not enhanced); the category tolerance is GBP 1/week in survey weeks.test_education_grants_split.py: DSA seeded atpolicy_year=2025for a 2024 survey year, with grant capacities at the survey year;policy_yeardefaults toyear.test_frs_survey_year.py: folder-name parsing, the current release's folder matches its survey year, andcreate_frsrejects a mismatchedyearor apolicy_yearbefore the survey year.Locally, with the lockfile's policyengine-uk 2.93.0: the affected files (48 tests) pass; full suite result below.
Full suite on the branch with the same venv: 520 passed, 113 skipped, 1 xfailed (the skips are the licensed-data and built-artifact tests, as on
main).Not in scope: the GBP 1/week safety gap on the enhanced flag is smaller than one April uprating step (GBP 6.80/week for AA higher), so a respondent still reporting the previous year's rate loses
is_enhanced_disabled_for_benefits. That is real, one-directional and independent of this PR; raised on #476.🤖 Generated with Claude Code