Skip to content

One year semantics for the FRS build: survey year for observed amounts, policy year for modelled seeds - #480

Merged
juaristi22 merged 2 commits into
mainfrom
fix/frs-year-semantics-475-479
Sep 4, 2026
Merged

One year semantics for the FRS build: survey year for observed amounts, policy year for modelled seeds#480
juaristi22 merged 2 commits into
mainfrom
fix/frs-year-semantics-475-479

Conversation

@juaristi22

@juaristi22 juaristi22 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes #475. Fixes #476. Fixes #477. Fixes #478. Documents #479.

The five issues Vahid filed on 2026-09-03 share one root: create_frs has a single year doing three jobs (survey vintage, time_period stamp, 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:

  • year is the survey year (fiscal year year/year + 1, the dataset's time_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_year is 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

Issue Change
#475 Categories now read parameters(year).gov.dwp, the fiscal-converted tree the flags already read. The baseline clone is taken before policyengine-uk's fiscal-year conversion, so baseline at 2024 carried the 2023-24 table (AA higher 101.75 vs 108.55).
#476 Categories convert annual amounts back to weekly with the survey's 365.25/7, matching frs.py and the flags. The premise of the issue is inverted on main: frs.py has 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.
#477 create_frs refuses a year that does not match the release folder it reads (frs_2024_25 is survey year 2024) with a message that says what year means and points at uprate_dataset. Folders outside the frs_YYYY_YY convention (synthetic test fixtures) are not validated.
#478 The DSA gate and eligibility reads are keyed to policy_year; both build call sites pass calibration_year. The FRS 2024-25 build therefore seeds disabled_students_allowance_eligible_expenses instead of writing zeros. Other grant capacities stay at the survey year.
#479 A comment in create_datasets.py records 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_frs run on the pinned UKDS SN 9563 tabs (sha256 05dd0069…54a9aeb, the release pin), year=2024, before (origin/main 8629dbb) and after (this branch, policy_year=2025), both on policyengine-uk 2.93.0. Aggregates only; 34,966 persons, 68.25m weighted.

Surface Before After
Disability categories (5 columns, every band, unweighted and weighted) see below identical: no row changes band
is_disabled_for_benefits / enhanced / severe, weighted 6.18m / 3.01m / 3.59m identical
disabled_students_allowance_eligible_expenses all zero 1 record: 4.7k weighted people, GBP 83.8m
education_grants residual, weighted GBP 2,802.5m GBP 2,718.7m (the DSA allocation moved out of the residual)
time_period 2024 2024

Category 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 is LOWER and not enhanced); the category tolerance is GBP 1/week in survey weeks.
  • test_education_grants_split.py: DSA seeded at policy_year=2025 for a 2024 survey year, with grant capacities at the survey year; policy_year defaults to year.
  • test_frs_survey_year.py: folder-name parsing, the current release's folder matches its survey year, and create_frs rejects a mismatched year or a policy_year before 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

…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 vahid-ahmadi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@juaristi22

Copy link
Copy Markdown
Collaborator Author

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.

@juaristi22
juaristi22 merged commit 6b2fc86 into main Sep 4, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment