Repository navigation
Conversation
policyengine-uk's trading allowance relieves income charged under ITTOIA 2005 Part 5 Chapter 8 (s. 783AB) and reads it from income_not_otherwise_charged (PolicyEngine/policyengine-uk#2726). Of the fields the build sums into miscellaneous_income, only the FRS odd-job amount (ODDJOB.OJAMT) records casual income that Chapter 8 charges, so the build now returns it alongside miscellaneous_income. The SPI-synthetic copy keeps none: its trading profits are SPI PROFITS, which may already have used the allowance. miscellaneous_income itself stays bitwise unchanged whenever no odd-job amount carries an FRS missing-value code; a code now counts as zero. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Calibration fit against the base build: pass
A drop of more than 2.0% fails. Notes
National targets in a worse error band on the head (9)
Flags are for review: builds of the same code move 2 to 12 national targets to a worse band (#598). Bands are of |estimate / target - 1|; no target's estimate or error is published. Income distribution and single ages against the base build: passHBAI FYE 2025 (DWP), period 2024: equivalised income in £ a week, rates and Gini in per cent. A value outside HBAI's 95% interval is marked (outside).
FlagsNone. These measures report and flag but do not fail yet: their same-code spread is unmeasured (#598). Aggregate impact of the data change: passBase dataset Checks: 0 broken by this change, 4 failing on both sides, 0 fixed. Moves flagged for review: 173 (thresholds in Notes
Moves flagged for review
133 more flags are in the report artifact. Receipts by tax head
Spending by benefit and service
Caseloads
Government balance, each component counted once
Model totals
Components the model counts twice
Household income totals
Population
Poverty rates
People in poverty
Inequality
Mean household net income in each side's own deciles
Standard reform costings (change in gov_balance)
Standard reforms: change in each reform's target
Household net income by decileThe datasets hold different households, so households are not matched; compare the mean income in each side's own deciles. Checks failing on both base and head (4)
Counted-once checkCounted-once check not run: not requested. Provenance
Base dataset: Head dataset: Receipts by tax head: head levels
Spending by benefit and service: head levels
Caseloads: head levels
Government balance, each component counted once: head levels
Model totals: head levels
Components the model counts twice: head levels
Household income totals: head levels
Population: head levels
Poverty rates: head levels
People in poverty: head levels
Inequality: head levels
Mean household net income in each side's own deciles: head levels
Standard reform costings (change in gov_balance): head levels
Standard reforms: change in each reform's target: head levels
|
The data checks now reduce each dataset to counts in helper functions and assert only on those counts. Pytest's assertion rewriting printed the DataFrame argument of an assertion that failed, and the non-negative income test printed the smallest value: a single record's amount. The bound check now also counts negative and non-finite miscellaneous income, so a NaN there no longer passes, and a parametrised test covers each violation. The frs.py docstring now calls odd-job income a stand-in for Chapter 8 income: the FRS records it outside the employment and self-employment questions, but it does not say how HMRC would treat each job. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An error inside a data test's reduction could still print records: pandas quotes an unconvertible value in its message, and under -l the traceback shows the DataFrame and arrays the reduction holds. counts_or_fail runs the reduction and turns any error into a failure that names only the function and the error's type, with no traceback, no chained exception and no record-bearing locals, even under --full-trace -l. Both data tests in test_income_not_otherwise_charged.py and the non-negative income test now reduce through it; test_record_safe.py checks it, warnings raised as errors included. The test module's summary and the changelog now call odd-job income a proxy for Chapter 8 income. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pytrace=False suppresses pytest's usual traceback, but --full-trace overrides it; the docstring now says so, and that the frames it then shows hold only the function, the dataset, the arguments and the error type's name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Status at 30e9cc5 (29 Oct, 05:3xZ):
|
PolicyEngine/policyengine-uk#2726 gives the trading allowance its relief on income charged under ITTOIA 2005 Part 5 Chapter 8 (s. 783AB), and reads that income from a new person input,
income_not_otherwise_charged, the Chapter 8 part ofmiscellaneous_income. It defaults to zero, so the relief has no aggregate effect until the data sets it. This PR sets it.Draft until #2726 merges and ships in a release. uk-data PRs land in the batch on Max's go (d833).
What changes
datasets/frs.py: a newfrs_miscellaneous_income(person, oddjob)returns(miscellaneous_income, income_not_otherwise_charged).create_frscalls it in place of the old inline block. The second output is the FRS odd-job income, the part of the first that stands in for Chapter 8 income.datasets/imputations/income.py:clear_income_not_otherwise_chargedsets the input to zero on the SPI-synthetic copy, next toclear_frs_reported_capital.tests/test_income_not_otherwise_charged.py.tests/test_non_negative_incomes.pynow lists the variable and reports a count of negative values instead of the smallest one.tests/record_safe.py:counts_or_failruns a test's reduction of a dataset to counts and turns any error inside it into a failure that names only the function and the error's type. It discards the error's own traceback, chains no exception and suppresses pytest's usual traceback;--full-traceoverrides that suppression, but the frames it then shows hold no record. The data tests in both files reduce through it;tests/test_record_safe.pychecks it.miscellaneous_incomeitself does not change (see the differential below).Why only odd-job income
miscellaneous_incomesums the ODDJOB amount and six person-table fields. FRS 2024-25 (UKDA SN 9563) data dictionary and question instructions:private_transfer_incomealso counts (#627)The build keeps the existing odd-job rule: jobs from the last 7 days count, as weekly amounts. That rule undercounts and misannualises odd-job income; #640 reports it and leaves the fix to its own PR, so this one changes neither total.
Negative OJAMT values are FRS missing-value codes (−1 to −9). The new function counts them as zero; the old block added them as negative pounds. No OJNOW = 1 row in the 2024-25 FRS carries a code, so the built data does not change.
SPI-synthetic rows hold none
The SPI-synthetic copy's trading profits are SPI PROFITS, which
datasets/spi.pytakes to be after the trading allowance, while its miscellaneous income stays its FRS donor's (neither imputation stage replaces it). policyengine-uk gives a person's trades the allowance first and their income not otherwise charged what is left. On these rows the donor's odd-job income would take an allowance the SPI profit may already have used, so the build sets the input to zero there. The sensitivity run below (variant C) shows what keeping the donors' values would add.Invariants
For any person table and ODDJOB table, including FRS missing-value codes and blanks:
income_not_otherwise_chargednever exceedsmiscellaneous_income.miscellaneous_income − income_not_otherwise_chargedequals the annualised floored sum of the six other fields, andincome_not_otherwise_chargedequals the annualised odd-job income from jobs in the last 7 days.income_not_otherwise_charged.miscellaneous_incomeequals the 1.59.1 formula whenever no odd-job amount carries a missing-value code.For the built datasets:
miscellaneous_income.miscellaneous_income, so invariant 1 holds in every year once policyengine-uk reads it.Invariants 1–4 run as Hypothesis property tests (300 examples each, derandomised). Invariants 5–6 run on the built datasets:
counts_or_failreduces each dataset to counts, and the tests assert only on those counts, so neither a failed assertion nor an error inside the reduction prints a record. A parametrised test checks that the bound check counts each kind of violation, a NaNmiscellaneous_incomeamong them. Invariant 7 skips until the lock reads the variable.Evidence
CI (run 40804946941, at 4e6d10f): the Test job builds the FRS and the enhanced FRS from this branch and runs the whole suite on them, invariants 5 and 6 included: 2,672 passed, 5 skipped (the uprating check among them), 1 xfailed. Later pushes changed only tests, docstrings and the changelog.
Local runs (my own, at ff0ccc3; I keep the aggregate logs with the review):
test_energy_calibration.py, which reads WAS round 8 microdata this machine lacks.test_income_not_otherwise_charged.pypasses 13 tests and skips 2 (the enhanced-FRS check, and the uprating check until the relock);test_record_safe.pypasses 4.pytest -v,-vv -l --tb=long,-vv -l --full-traceand-vv -l --tb=long -W error, the 12 failing checks print no sentinel amount in any of the 12 runs. The round-2 tests (3088545) printed none on bound failures but up to 49 lines on missing columns and up to 291 on text; the round-1 tests (4e6d10f) printed some in every run, up to 161 lines.miscellaneous_income; ×52 on the input; OJNOW = 2; a no-op clear; the floor on the sum instead of each job. The unmutated control passes.main(e1acdf9) and from this branch. Every person, benefit-unit and household column matches (34,966 / 18,850 / 16,288 rows) apart from the new one. Matched onperson_id, the branch build'smiscellaneous_incomealso equals the released 1.59.1 base's (the impact run's checks).mainmerged with #2726 (merge tree 289f6fc, 2.164.1),build_uprating_factors_table()runs and givesincome_not_otherwise_chargedthe same row asmiscellaneous_income. #2726's branch alone listscash_isatwice inuprating_indices.yaml, which the generator rejects;mainlists it once, and the rehearsed merge tree holds no duplicate entry.Aggregate impact
I ran policyengine-uk at #2726's head (9d4f7591b) on the released 1.59.1 enhanced FRS, holding its calibrated weights fixed. A sets the input to zero everywhere (what the model reads today); B is this PR. Rows map to their FRS source person through
source_household_idand position in the household (0 unmatched rows; age and sex match on every row;miscellaneous_incomematches to within 2.2e-16 relative). Counts rest on at least 44 FRS households.income_not_otherwise_charged(total)trading_allowance_reliefsweighs against a current trading loss, so it could change a trade's relief and Class 4 for someone with a supplied loss; in this data it changed no one's.uc_individual_earned_income_before_mifcountsmiscellaneous_income_before_trading_allowanceas earnings and deductsuc_income_tax_on_earnings: the model's income tax on those earnings, taken as the lowest slice of the person's non-savings income (UC Regulations 2013 reg. 55(5)(b)). The relief cuts that tax, so the deduction shrinks and UC falls slightly.Sensitivity (C): SPI-donor rows keep their FRS donor's odd-job income. Income tax falls by £15.5m, £19.2m and £25.1m in the three years, and UC by £0.32m, £0.35m and £0.51m.
Calibration: this run keeps the calibrated weights fixed. CI builds both sides with the locked policyengine-uk 2.122.2, which does not read the new column. Its fit comparison at 4e6d10f put 64.6% of national targets within 10% on the head against 64.8% on the base, and moved 11 national targets to a worse error band and 9 to a better one; builds of the same code move 2 to 12 national targets to a worse band (#598). No run here measures the calibration's response to the input; paired builds on the same relocked model and uprating factors will.
Merge order
policyengine-uk>=and regeneratesuprating_factors.csvanduprating_growth_factors.csv;test_uprated_exactly_as_miscellaneous_incomethen runs. That relock carries a much larger change on its own (the rehearsal adds 40 rows and moves 82 of the 84 existing rows by more than 1e-6, diesel spending's factor by up to 0.40), so it belongs in its own PR.Follow-ups
miscellaneous_incomeandprivate_transfer_income.axiom: n/a: data change
🤖 Generated with Claude Code