Repository navigation
Conversation
salary_sacrifice_pension_ni_employee and _employer multiplied the whole amount returned to income by one rate. An amount crossing the upper earnings limit was charged entirely at the additional rate, an amount below the primary or secondary threshold was charged in full, and the age conditions on Class 1 were ignored. Each measure is now the complete Class 1 liability on earnings less the liability on the same earnings without the amount returned. They remain reporting measures: no tax total reads them. Refs #2361 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ence Property test: each measure equals the complete Class 1 liability of a simulation with the amount returned less that of the same people with nothing returned, under current law and in a world where the employee rates and upper earnings limit change within a year. Also bounds, exact zeros, the single-rate case and monotonicity. Refs #2361 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
shareholding and consumption_shareholding return a share of a national total but declared pounds; free_tv_licence_value returns pounds but declared a ratio; seven true/false variables declared a unit. The unit also sets the default quantity type in policyengine-core: a float with unit "/1" is a stock, any other float a flow, and a yearly flow read for a month is divided by twelve. So a month's read of a share was a twelfth of the share, and a month's read of the free TV licence value was the whole year's. Every reader of these variables in the model reads them for a year, so no yearly value changes. Refs #2362 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #2361
Fixes #2362
Two defects found by the independent specification of the tax-head partition written for #2235 (its defects 7 and 8). I confirmed both in the code on main before changing anything.
1. Salary sacrifice NI measures (#2361)
What was wrong
salary_sacrifice_pension_ni_employeemultiplied the whole amount returned to income (salary_sacrifice_returned_to_income) by one rate, chosen by whetheremployment_incomewas at or below the upper earnings limit.salary_sacrifice_pension_ni_employermultiplied it by the employer rate. Neither applied the Class 1 thresholds or age conditions, so neither matched what the model charges on that amount.What changes
Each measure is now the complete Class 1 liability on earnings less the liability on the same earnings without the amount returned.
ni_class_1_employee, month by month because that variable is monthly, and only for people liable to primary contributions (defined_for = "ni_liable").ni_class_1_employer, yearly as that variable is, only where secondary contributions are due (defined_for = "ni_class_1_secondary_liable"), with employer pension contributions in the base whengov.contrib.policyengine.employer_ni.exempt_employer_pension_contributionsis false.Both stay reporting measures. No variable in the model reads either one (
git grep), so no liability and no tax total changes.Ten single-person cases, run on main and on this branch (2025 thresholds, a £2,000 cap, no broad-base haircut;
examples.pyin the evidence folder). The "liability difference" column comes from the model's own liability variables in two simulations, with and without the amount returned:Invariants
For any pay, salary sacrifice, age, statutory sick pay and employer pension contribution:
ni_class_1_employeewith the amount returned lessni_class_1_employeewithout it. The employer measure equals the same difference inni_class_1_employer.Tests
policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py(Hypothesis). Invariant 1 is a differential test: the "without" side is a second simulation of the same people withsalary_sacrifice_returned_to_incomeset to zero, computed by the model's liability variables, not by the formulas under test. It runs under current law (2028, 2029, 2030) and in a second world where the employee rates and the upper earnings limit change within 2041 and employer pension contributions are charged. A year after 2040 is needed for that: fiscal-year conversion gives every parameter one value per year from 2015 to 2040.National totals (real run)
Pending. A base-and-head microsimulation on the Enhanced FRS (2029 and 2030, current law, plus a head run with nothing returned) is queued behind the shared heavy-run lock. It will report the national totals of both measures on main and here, their agreement with the liability difference, and that no tax total,
household_net_incomeorhbai_household_net_incomemoves.Where the measures are used, and whether a published figure moves
No published figure moves.
referenceon both variables) does not use either variable. Its revenue and distributional figures come from uk-salary-sacrifice-analysis, which takes the change ingov_balanceandhousehold_net_incomebetween two simulations (src/salary_sacrifice/analysis.py,no_behavioral.py) and never calculates the measures. Its worked example (£240 of employee NI on a £3,000 excess for a £50,000 earner) has earnings below the upper earnings limit, so the corrected measure gives the same £240; that case is now a YAML test. The post's "£2.1 billion in additional employer NI (at 15%)" is arithmetic in the post (£13.8 billion × 15%), not a read of the variable.salary_sacrifice_broad_base_haircut_rate(0.0016) is a fixed parameter derived from that arithmetic. It does not read the employer measure, so it does not move.head_variablesof its Autumn Budget 2025 measure (pipeline/build_ab2025_registry.py), so its result files hold their totals (results/uk/ab2025/ab2025__salary_sacrifice_pension_nics_cap_2000_*.json,totals.*.heads; for 2029 at policyengine-uk 2.89.2: £216.1m employee, £1,406.7m employer). Those files are pinned to that release and do not change. If that lane is recomputed on a release with this fix, those two recorded totals will change. No comparison value is computed from them: the staged exchequer effect is the change ingov_taxlessgov_spending(pipeline/stage_uk_ab2025.py), and the lane that compares with the OBR's NICs costing reads the complete liabilitiesni_class_1_employee,ni_class_1_employerandni_class_4(pipeline/compute_uk_obr_costings.py,NI_HEAD_VARIABLES).git grepon each default branch).2. Units (#2362)
shareholdingGBP/1consumption_shareholdingGBP/1free_tv_licence_value/1GBPsdlt_liable,lbtt_liable,benunit_is_rent_liable,is_pension_credit_eligible,is_guarantee_credit_eligible,is_savings_credit_eligibleGBPcar_is_ved_exempt/1The brief named the first three and
sdlt_liable. The other six true/false variables have the same defect, andtest_variable_units.pynow asserts that no true/false variable declares a unit.The unit is not only metadata
In policyengine-core the unit sets the default
quantity_type: a float with unit/1is a stock and any other float is a flow (variables/variable.py), and a yearly flow read for a month is divided by twelve while a yearly stock is not (simulations/simulation.py). So on main a month's read ofshareholdingreturned a twelfth of the share, and a month's read offree_tv_licence_valuereturned the whole year's pounds. With 0.25 and £180 entered for a year (unit_semantics.pyin the evidence folder):shareholdingconsumption_shareholdingfree_tv_licence_valueNo yearly value changes. Every reader of these three variables is a yearly variable that reads them for the year:
corporate_sdlt,baseline_business_rates,carbon_taxandcorporate_incident_tax_revenue_changereadshareholding;hbai_household_net_incomeaddsfree_tv_licence_value; nothing readsconsumption_shareholding. None is a dataset column or set withset_inputin the package. A true/false variable is a stock whatever its unit.Downstream
variable.unitunchanged in the metadata (policyengine_api/country.py,build_variables).formatVariableValue(app/src/utils/householdValues.ts) andformatChartValueForVariable(app/src/utils/chartValueFormatting.ts) show a/1variable as a percentage and acurrency-*variable as money, andVariableInput.tsxmultiplies a/1input by 100. Sofree_tv_licence_valuewill display in pounds rather than as a percentage, the two shares as percentages rather than pounds, and the flags without a pound sign.unitandquantity_typeinto its variable records (tax_benefit_models/common/model_version.py) and does not branch on them.Overlap with open PRs
free_tv_licence_value(for Correct the free TV licence value's liability trigger and currency treatment #2148) and adds its own metadata test.git merge-treemerges that file cleanly with this branch; Correct TV licence liability and free licence value #2147's only conflict is with main, intv_licence_discount.py, which this PR does not touch.sdlt_liable.py) and Move families onto Universal Credit when a legacy benefit they receive closes #2119 (is_pension_credit_eligible.py) merge cleanly with this branch.Out of scope
Other float variables declare a unit that does not match what they return (for example
income_elasticity_lsrandsubstitution_elasticity_lsrin pounds). Because the unit sets the quantity type, each needs its readers checked; that audit is a separate task, not this PR.axiom: n/a: reporting measures and unit metadata only; no liability, parameter or eligibility rule changes.
Commands run
The full suite (including
test_build_metadata.py) and the microsimulation tests were left to CI.Evidence (scripts, run outputs, review):
~/reviews/uk-salary-sacrifice-ni-measures-2361/.🤖 Generated with Claude Code
Reproducer:
policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.yaml,policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.yaml,policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py::test_measures_equal_the_difference_of_complete_liabilities,policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py::test_known_cases_cross_each_threshold_and_age_condition,policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py::test_rates_change_within_the_year_in_the_second_world,policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py::test_measures_are_deterministic