From 787be8c85e59d4cd7a9a4e179a0f543b0bd049c6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 08:19:29 -0400 Subject: [PATCH 1/8] Charge Class 4 NICs on Chapter 2 profits with Schedule 2 loss relief Class 4 was charged on raw self_employment_income. It now uses ni_class_4_profits: profit after capital allowances and the trading allowance (ITTOIA 2005 Part 2 Chapter 2), less trading losses as SSCBA 1992 Sch. 2 para. 3 allows, carried forward without limit. Co-Authored-By: Claude Opus 5.5 --- .../class_4/ni_class_4_profits.yaml | 224 +++++++++ .../tests/test_ni_class_4_maximum.py | 3 + .../test_ni_class_4_profits_properties.py | 457 ++++++++++++++++++ .../tests/test_ni_class_4_properties.py | 19 +- .../national_insurance/class_4/ni_class_4.py | 6 +- .../class_4/ni_class_4_loss_relief.py | 28 ++ .../ni_class_4_losses_brought_forward.py | 55 +++ .../ni_class_4_losses_carried_forward.py | 32 ++ .../class_4/ni_class_4_main.py | 2 +- .../class_4/ni_class_4_maximum.py | 2 +- .../class_4/ni_class_4_profits.py | 31 ++ .../ni_class_4_profits_before_losses.py | 41 ++ 12 files changed, 891 insertions(+), 9 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_profits.yaml create mode 100644 policyengine_uk/tests/test_ni_class_4_profits_properties.py create mode 100644 policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py create mode 100644 policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py create mode 100644 policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_carried_forward.py create mode 100644 policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py create mode 100644 policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits_before_losses.py diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_profits.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_profits.yaml new file mode 100644 index 0000000000..392d459075 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_profits.yaml @@ -0,0 +1,224 @@ +# Class 4 profits: SSCBA 1992 s. 15(1)(b) and Sch. 2 paras. 2-3. +# Class 4 is charged on the profits chargeable to income tax under ITTOIA 2005 +# Part 2 Chapter 2, which are after capital allowances (CAA 2001 s. 247) and +# the trading allowance (ITTOIA 2005 ss. 783AF and 783AI; HMRC BIM86052), less +# trading losses as Sch. 2 para. 3 allows. +# 2026-27: lower profits limit £12,570, upper profits limit £50,270, main rate +# 6%, additional rate 2%. + +- name: Capital allowances reduce Class 4 profits + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: 30_000 + capital_allowances: 5_000 + output: + ni_class_4_profits_before_losses: 25_000 + ni_class_4_profits: 25_000 + # (25,000 - 12,570) * 6% + ni_class_4_main: 745.80 + ni_class_4: 745.80 + +- name: Partial trading allowance with receipts known (s. 783AI) + period: 2026 + absolute_error_margin: 0.01 + input: + # £300 of expenses: the £1,000 allowance is worth £700 more. + self_employment_gross_receipts: 20_300 + self_employment_income: 20_000 + output: + ni_class_4_profits: 19_300 + # (19,300 - 12,570) * 6% + ni_class_4: 403.80 + +- name: The trading allowance replaces expenses and capital allowances + period: 2026 + absolute_error_margin: 0.01 + input: + # Expenses £300 and capital allowances £400 are both replaced by the + # £1,000 allowance: 20,300 - 1,000 = 19,300. + self_employment_gross_receipts: 20_300 + self_employment_income: 20_000 + capital_allowances: 400 + output: + ni_class_4_profits_before_losses: 19_300 + ni_class_4_profits: 19_300 + ni_class_4: 403.80 + +- name: No trading allowance where expenses exceed it + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_gross_receipts: 22_000 + self_employment_income: 20_000 + output: + ni_class_4_profits: 20_000 + # (20,000 - 12,570) * 6% + ni_class_4: 445.80 + +- name: Receipts within the allowance give nil profits (s. 783AF) + period: 2026 + absolute_error_margin: 0 + input: + self_employment_gross_receipts: 950 + self_employment_income: 700 + output: + ni_class_4_profits_before_losses: 0 + ni_class_4_profits: 0 + ni_class_4: 0 + +- name: The current year's trading loss reduces Class 4 profits + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: 30_000 + trading_loss: 4_000 + output: + ni_class_4_loss_relief: 4_000 + ni_class_4_losses_carried_forward: 0 + ni_class_4_profits: 26_000 + # (26,000 - 12,570) * 6% + ni_class_4: 805.80 + +- name: A loss is set only against trade profits, and the rest carried forward + period: 2026 + absolute_error_margin: 0.01 + input: + # Sch. 2 para. 3(4): even where income tax sets the loss against + # employment income, for Class 4 it reduces trade profits only. + employment_income: 40_000 + self_employment_income: 5_000 + trading_loss: 8_000 + output: + ni_class_4_loss_relief: 5_000 + ni_class_4_losses_carried_forward: 3_000 + ni_class_4_profits: 0 + ni_class_4: 0 + +- name: A loss from a loss-making year is brought forward in full + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: + 2025: 0 + 2026: 30_000 + trading_loss: + 2025: 10_000 + 2026: 0 + output: + ni_class_4_losses_brought_forward: 10_000 + ni_class_4_loss_relief: 10_000 + ni_class_4_profits: 20_000 + # (20,000 - 12,570) * 6% + ni_class_4: 445.80 + +- name: In the loss year, the loss uses up that year's other trade profits first + period: 2025 + absolute_error_margin: 0.01 + input: + self_employment_income: + 2025: 6_000 + 2026: 30_000 + trading_loss: + 2025: 10_000 + 2026: 0 + output: + ni_class_4_loss_relief: 6_000 + ni_class_4_profits: 0 + ni_class_4_losses_carried_forward: 4_000 + +- name: Only the unrelieved part of a loss is brought forward + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: + 2025: 6_000 + 2026: 30_000 + trading_loss: + 2025: 10_000 + 2026: 0 + output: + # 10,000 loss less 6,000 relieved against 2025 profits. + ni_class_4_losses_brought_forward: 4_000 + ni_class_4_profits: 26_000 + # (26,000 - 12,570) * 6% + ni_class_4: 805.80 + +- name: Class 4 losses carry forward until used (Sch. 2 para. 3(4)(b)) + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: + 2024: 0 + 2025: 20_000 + 2026: 60_000 + trading_loss: + 2024: 50_000 + 2025: 0 + 2026: 0 + output: + # 50,000 from 2024, less 20,000 used in 2025. + ni_class_4_losses_brought_forward: 30_000 + ni_class_4_loss_relief: 30_000 + ni_class_4_profits: 30_000 + # (30,000 - 12,570) * 6% + ni_class_4: 1_045.80 + +- name: A supplied balance of unrelieved losses is brought forward + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: 30_000 + ni_class_4_losses_brought_forward: 7_000 + output: + ni_class_4_loss_relief: 7_000 + ni_class_4_profits: 23_000 + # (23,000 - 12,570) * 6% + ni_class_4: 625.80 + +- name: A supplied balance carries into later years + period: 2027 + absolute_error_margin: 0.01 + input: + self_employment_income: + 2026: 5_000 + 2027: 30_000 + ni_class_4_losses_brought_forward: + 2026: 7_000 + trading_loss: + 2026: 0 + 2027: 0 + output: + # 7,000 brought into 2026, less 5,000 used there. + ni_class_4_losses_brought_forward: 2_000 + ni_class_4_profits: 28_000 + +- name: Pension contributions are not deducted from Class 4 profits + period: 2026 + absolute_error_margin: 0.01 + input: + # Sch. 2 para. 3(2)(f)-(g): pension contributions relieve income tax only. + self_employment_income: 30_000 + personal_pension_contributions: 5_000 + output: + ni_class_4_profits: 30_000 + # (30,000 - 12,570) * 6% + ni_class_4: 1_045.80 + +- name: The annual maximum is worked out on Class 4 profits + period: 2026 + absolute_error_margin: 0.01 + input: + # Primary Class 1 on £60,000 of earnings exceeds Step Two (6% of the + # £37,700 main band, £2,262), so Case 3 applies: Step Four is nil. + employment_income: 60_000 + self_employment_income: 80_000 + capital_allowances: 20_000 + output: + ni_class_4_profits: 60_000 + # Main band before the maximum: 6% * 37,700. + ni_class_4_main: 2_262 + # Steps Six to Eight: 2% * (50,270 - 12,570) = 754. + # Step Nine: 2% * (60,000 - 50,270) = 194.60. + ni_class_4_maximum: 948.60 + ni_class_4: 948.60 diff --git a/policyengine_uk/tests/test_ni_class_4_maximum.py b/policyengine_uk/tests/test_ni_class_4_maximum.py index c32e9f69e9..f2d3780870 100644 --- a/policyengine_uk/tests/test_ni_class_4_maximum.py +++ b/policyengine_uk/tests/test_ni_class_4_maximum.py @@ -149,12 +149,15 @@ def test_class_2_counts_towards_annual_maximum_only_before_april_2024(year, expe # applies and Step Four is negative (Case 3), capping Class 4 at # 2% x 100 + 2% x 900 = £20. From then SI 2024/377 removes Class 2 # from regulation 100, so without Class 1 the maximum does not apply. + # Receipts of £3,000 put expenses above the £1,000 trading allowance, + # so the whole £1,000 profit is chargeable. sim = Simulation( situation={ "people": { "person": { "age": {year: 40}, "self_employment_income": {year: 1_000}, + "self_employment_gross_receipts": {year: 3_000}, "ni_class_2": {year: 182}, } }, diff --git a/policyengine_uk/tests/test_ni_class_4_profits_properties.py b/policyengine_uk/tests/test_ni_class_4_profits_properties.py new file mode 100644 index 0000000000..bc28d5e7fa --- /dev/null +++ b/policyengine_uk/tests/test_ni_class_4_profits_properties.py @@ -0,0 +1,457 @@ +"""Property tests for the profits Class 4 NICs are charged on. + +SSCBA 1992 s. 15(1)(b) and Sch. 2 charge Class 4 on the trade profits +chargeable to income tax under ITTOIA 2005 Part 2 Chapter 2, which are after +capital allowances (CAA 2001 s. 247) and the trading allowance (ITTOIA 2005 +ss. 783AF and 783AI), less trading losses as Sch. 2 para. 3 allows. + +Invariants, for every generated case: + +1. Differential: ni_class_4_profits_before_losses, ni_class_4_loss_relief, + ni_class_4_losses_brought_forward, ni_class_4_losses_carried_forward and + ni_class_4_profits equal an independent exact-rational reference: the + better of actual deductions and the allowance where receipts are known, + and losses set against each year's profits, earliest first, with the + rest carried forward without limit. +2. Accounting: trading_loss + losses brought forward = loss relief + losses + carried forward; losses brought forward equal last year's losses carried + forward; 0 <= loss relief <= profits before losses. +3. Bound: 0 <= ni_class_4 <= the s. 15(3) amount on the reference Class 4 + profits <= the s. 15(3) amount on the unrelieved profit. Where regulation + 100 cannot apply (no Class 1 and, from April 2024, Class 2 irrelevant), + ni_class_4 equals the s. 15(3) amount. +4. Monotonicity: ni_class_4 is non-decreasing in profit, at fixed expenses + (or unknown receipts), capital allowances, losses and employment income, + wherever Class 2 does not change; and non-increasing in capital + allowances and trading losses. +5. Carry-forward over many years matches the reference fold, whatever order + the years are calculated in, including beyond the engine's ten-step + spiral limit. + +test_ni_class_4_properties.py checks s. 15(3) and regulation 100 given these +profits, for arbitrary thresholds and rates. Comparisons allow float32 +rounding: the model stores values as float32. +""" + +from fractions import Fraction + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +ALLOWANCE = 1_000 +CASES = 24 # Each case is simulated twice: as drawn, and varied. +PEOPLE = 2 * CASES +PROPERTY_SETTINGS = settings( + max_examples=30, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +amount = st.one_of( + st.floats(min_value=0, max_value=2_500), + st.floats(min_value=0, max_value=300_000), + st.sampled_from( + [0.0, 999.99, 1_000.0, 1_000.01, 6_725.0, 12_570.0, 50_270.0, 50_271.0] + ), +) +profit = st.one_of(amount, st.floats(min_value=-5_000, max_value=0)) +expenses = st.one_of( + st.sampled_from([0.0, 150.0, 999.0, 1_000.0, 1_001.0]), + st.floats(min_value=0, max_value=5_000), +) +capital_allowances = st.one_of( + st.just(0.0), st.sampled_from([150.0, 900.0, 5_000.0]), amount +) +loss = st.one_of(st.just(0.0), amount) +# Receipts: unknown, consistent (profit + expenses), or inconsistently below +# profit, which the model treats as unknown. +receipts_mode = st.sampled_from(["unknown", "consistent", "below_profit"]) +year_inputs = st.tuples( + profit, + expenses, + capital_allowances, + loss, + receipts_mode, + st.floats(min_value=0.01, max_value=0.99), # receipts share if below profit +) +case = st.tuples( + year_inputs, # previous year + year_inputs, # current year + st.sampled_from([0.0, 0.0, 8_000.0, 30_000.0, 60_000.0, 200_000.0]), + st.floats(min_value=0, max_value=30_000), # variation +) +cases = st.lists(case, min_size=CASES, max_size=CASES) + + +def exact(x): + return Fraction(float(x)) + + +def reference_profits_before_losses(profit, receipts, capital_allowances): + """ITTOIA 2005 Part 2 Chapter 2 profits, after capital allowances and + the trading allowance, before loss relief.""" + if receipts > 0 and receipts >= profit: + if receipts <= ALLOWANCE: + # s. 783AF: the trade's profits are nil. + return Fraction(0) + # The better of actual deductions and the allowance (s. 783AI); + # capital allowances are a deduction (CAA 2001 s. 247) that the + # allowance replaces, so the two never stack. + actual_deductions = receipts - profit + capital_allowances + return max(Fraction(0), receipts - max(actual_deductions, ALLOWANCE)) + # Receipts unknown: a profit within the allowance is taken to come from + # receipts within it; a larger one already reflects the better deduction. + if profit <= ALLOWANCE: + return Fraction(0) + return max(Fraction(0), profit - capital_allowances) + + +def reference_losses(years): + """Sch. 2 para. 3: each year's losses (its own and those brought + forward) are set against its profits; the rest is carried forward to the + following years without limit (para. 3(4)(b); ITA 2007 s. 84).""" + brought_forward = Fraction(0) + out = [] + for profits, trading_loss in years: + losses = max(trading_loss, 0) + brought_forward + relief = min(losses, profits) + out.append( + dict( + brought_forward=brought_forward, + relief=relief, + profits=profits - relief, + carried_forward=losses - relief, + ) + ) + brought_forward = losses - relief + return out + + +def statutory_class_4(profits, class_4): + """s. 15(3) SSCBA 1992 in exact arithmetic.""" + lpl = exact(class_4.thresholds.lower_profits_limit) + upl = exact(class_4.thresholds.upper_profits_limit) + return exact(class_4.rates.main) * min(max(profits - lpl, 0), upl - lpl) + exact( + class_4.rates.additional + ) * max(profits - upl, 0) + + +def tolerance(*amounts): + # float32 has a 24-bit significand: a few ulps of the largest amount + # involved, plus a penny. + return 0.01 + 4e-7 * max(abs(float(a)) for a in amounts) + + +def receipts_for(profit, expenses, mode, share): + return np.select( + [mode == "consistent", mode == "below_profit"], + [profit + expenses, profit * share], + 0, + ) + + +@pytest.fixture(scope="module", params=[2023, 2026]) +def sim(request): + year = request.param + people = { + f"person_{i}": { + "age": {year - 1: 40, year: 41}, + **{ + variable: {year - 1: 0, year: 0} + for variable in [ + "employment_income", + "self_employment_income", + "self_employment_gross_receipts", + "capital_allowances", + "trading_loss", + ] + }, + } + for i in range(PEOPLE) + } + sim = Simulation( + situation={ + "people": people, + "benunits": { + f"benunit_{i}": {"members": [f"person_{i}"]} for i in range(PEOPLE) + }, + "households": { + f"household_{i}": {"members": [f"person_{i}"]} for i in range(PEOPLE) + }, + } + ) + return sim, year + + +def run(simulation, drawn, vary): + """Simulate each drawn case twice: as drawn, and with `vary` applied to + the current year's inputs. Returns stored inputs and model outputs.""" + sim, year = simulation + previous, current, employment, variation = zip(*drawn) + previous = [np.array(x) for x in zip(*previous)] + current = [np.array(x) for x in zip(*current)] + employment, variation = np.array(employment), np.array(variation) + varied = vary(*current, variation) + + def both(x, y): + return np.concatenate([x, y]) + + inputs = { + year - 1: [both(x, x) for x in previous], + year: [both(x, y) for x, y in zip(current, varied)], + } + for period, (profit, expenses, allowances, loss, mode, share) in inputs.items(): + sim.set_input("self_employment_income", period, profit) + sim.set_input( + "self_employment_gross_receipts", + period, + receipts_for(profit, expenses, mode, share), + ) + sim.set_input("capital_allowances", period, allowances) + sim.set_input("trading_loss", period, loss) + sim.set_input("employment_income", period, both(employment, employment)) + sim.reset_calculations() + + def calculate(variable, period): + return sim.calculate(variable, period).astype(float) + + return { + period: dict( + # Stored (float32) inputs, not the float64 draws. + profit=calculate("self_employment_income", period), + receipts=calculate("self_employment_gross_receipts", period), + capital_allowances=calculate("capital_allowances", period), + trading_loss=calculate("trading_loss", period), + **{ + name: calculate(f"ni_class_4_{name}", period) + for name in [ + "profits_before_losses", + "loss_relief", + "losses_brought_forward", + "losses_carried_forward", + "profits", + ] + }, + ni_class_4=calculate("ni_class_4", period), + ni_class_2=calculate("ni_class_2", period), + ni_class_1_employee=calculate("ni_class_1_employee", period), + ) + for period in (year - 1, year) + } + + +def reference(values, i): + years = [values[period] for period in sorted(values)] + profits = [ + reference_profits_before_losses( + exact(v["profit"][i]), + exact(v["receipts"][i]), + exact(v["capital_allowances"][i]), + ) + for v in years + ] + losses = reference_losses( + [(p, exact(v["trading_loss"][i])) for p, v in zip(profits, years)] + ) + return [dict(profits_before_losses=p, **l) for p, l in zip(profits, losses)] + + +def unchanged(profit, expenses, allowances, loss, mode, share, variation): + return profit, expenses, allowances, loss, mode, share + + +@PROPERTY_SETTINGS +@given(cases) +def test_class_4_profits_match_statutory_reference(sim, drawn): + values = run(sim, drawn, unchanged) + for i in range(PEOPLE): + expected = reference(values, i) + for period, reference_year in zip(sorted(values), expected): + model = values[period] + for name, expected_value in reference_year.items(): + model_name = { + "brought_forward": "losses_brought_forward", + "relief": "loss_relief", + "carried_forward": "losses_carried_forward", + }.get(name, name) + actual = model[model_name][i] + tol = tolerance(actual, expected_value, model["profit"][i]) + assert abs(actual - float(expected_value)) <= tol, ( + period, + i, + model_name, + actual, + float(expected_value), + ) + # Accounting identities. + tol = tolerance( + model["trading_loss"][i], + model["losses_brought_forward"][i], + model["profits_before_losses"][i], + ) + assert ( + abs( + max(model["trading_loss"][i], 0) + + model["losses_brought_forward"][i] + - model["loss_relief"][i] + - model["losses_carried_forward"][i] + ) + <= tol + ) + assert -tol <= model["loss_relief"][i] + assert model["loss_relief"][i] <= model["profits_before_losses"][i] + tol + previous, current = (values[period] for period in sorted(values)) + assert current["losses_brought_forward"][i] == pytest.approx( + previous["losses_carried_forward"][i], abs=0.01, rel=1e-6 + ) + + +@PROPERTY_SETTINGS +@given(cases) +def test_class_4_never_exceeds_uncapped_statutory_amount(sim, drawn): + values = run(sim, drawn, unchanged) + simulation, year = sim + current = values[year] + class_4 = simulation.tax_benefit_system.parameters( + f"{year}-01-01" + ).gov.hmrc.national_insurance.class_4 + for i in range(PEOPLE): + profits = reference(values, i)[-1]["profits"] + uncapped = statutory_class_4(profits, class_4) + unrelieved = statutory_class_4(max(exact(current["profit"][i]), 0), class_4) + model = current["ni_class_4"][i] + tol = tolerance(model, uncapped, current["profit"][i]) + assert uncapped <= unrelieved + assert -tol <= model <= float(uncapped) + tol, (i, model, float(uncapped)) + regulation_100_cannot_apply = current["ni_class_1_employee"][i] == 0 and ( + year >= 2024 or current["ni_class_2"][i] == 0 + ) + if regulation_100_cannot_apply: + assert abs(model - float(uncapped)) <= tol, (i, model, float(uncapped)) + + +def more_profit(profit, expenses, allowances, loss, mode, share, variation): + # Expenses stay fixed, so known receipts rise with profit. + return profit + variation, expenses, allowances, loss, mode, share + + +def more_capital_allowances(profit, expenses, allowances, loss, mode, share, variation): + return profit, expenses, allowances + variation, loss, mode, share + + +def more_trading_loss(profit, expenses, allowances, loss, mode, share, variation): + return profit, expenses, allowances, loss + variation, mode, share + + +@PROPERTY_SETTINGS +@given(cases) +def test_class_4_is_non_decreasing_in_profit(sim, drawn): + values = run(sim, drawn, more_profit) + current = values[sim[1]] + for i in range(CASES): + j = i + CASES + assert current["profit"][j] >= current["profit"][i] + assert current["profits"][j] >= current["profits"][i] - tolerance( + current["profits"][j] + ) + if current["ni_class_2"][j] == current["ni_class_2"][i]: + tol = tolerance(current["profit"][j], current["ni_class_4"][j]) + assert current["ni_class_4"][j] >= current["ni_class_4"][i] - tol, ( + i, + current["profit"][i], + current["profit"][j], + current["ni_class_4"][i], + current["ni_class_4"][j], + ) + + +@PROPERTY_SETTINGS +@given(cases, st.sampled_from([more_capital_allowances, more_trading_loss])) +def test_class_4_is_non_increasing_in_reliefs(sim, drawn, vary): + values = run(sim, drawn, vary) + current = values[sim[1]] + for i in range(CASES): + j = i + CASES + tol = tolerance(current["profit"][i], current["ni_class_4"][i]) + assert current["profits"][j] <= current["profits"][i] + tol + assert current["ni_class_4"][j] <= current["ni_class_4"][i] + tol, ( + vary.__name__, + i, + current["ni_class_4"][i], + current["ni_class_4"][j], + ) + + +@settings( + max_examples=15, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given( + st.lists( + st.tuples( + st.one_of(st.just(0.0), st.floats(0, 80_000)), # profit + st.one_of(st.just(0.0), st.just(0.0), st.floats(0, 150_000)), # loss + ), + min_size=2, + max_size=16, + ), + st.randoms(use_true_random=False), +) +def test_losses_carry_forward_over_many_years(years, random): + last_year = 2030 + first_year = last_year - len(years) + 1 + periods = list(range(first_year, last_year + 1)) + sim = Simulation( + situation={ + "people": { + "person": { + "age": {first_year: 40}, + "self_employment_income": dict( + zip(periods, (profit for profit, _ in years)) + ), + "trading_loss": dict(zip(periods, (loss for _, loss in years))), + } + }, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, + } + ) + expected = reference_losses( + [ + ( + reference_profits_before_losses( + exact(sim.calculate("self_employment_income", period)[0]), + Fraction(0), + Fraction(0), + ), + exact(sim.calculate("trading_loss", period)[0]), + ) + for period in periods + ] + ) + order = periods.copy() + random.shuffle(order) + for period in order: + reference_year = expected[period - first_year] + brought_forward = float( + sim.calculate("ni_class_4_losses_brought_forward", period)[0] + ) + profits = float(sim.calculate("ni_class_4_profits", period)[0]) + tol = tolerance(reference_year["brought_forward"], sum(l for _, l in years)) + assert abs(brought_forward - float(reference_year["brought_forward"])) <= tol, ( + period, + brought_forward, + float(reference_year["brought_forward"]), + ) + assert abs(profits - float(reference_year["profits"])) <= tol, ( + period, + profits, + float(reference_year["profits"]), + ) diff --git a/policyengine_uk/tests/test_ni_class_4_properties.py b/policyengine_uk/tests/test_ni_class_4_properties.py index 9f0e3fa512..da60adfcd8 100644 --- a/policyengine_uk/tests/test_ni_class_4_properties.py +++ b/policyengine_uk/tests/test_ni_class_4_properties.py @@ -6,8 +6,10 @@ apply (no primary Class 1, nor Class 2 before April 2024) or its maximum is at least the s.15(3) amount, ni_class_4 equals main * clamp(p - LPL, 0, UPL - LPL) + additional * max(p - UPL, 0), - where p is the full self-employment profit: Class 1 contributions are not - deducted (SSCBA 1992 Sch 2 para 2). + where p is ni_class_4_profits. With no capital allowances, receipts or + losses, p is the full self-employment profit, or nil within the trading + allowance: Class 1 contributions are not deducted (SSCBA 1992 Sch 2 + para 2). test_ni_class_4_profits_properties.py covers the other reliefs. 2. Differential: for everyone, ni_class_4 equals an exact-rational implementation of s.15(3) SSCBA 1992 capped by the literal regulation 100 steps (with the Case 1 comparison done exactly). The one exception is a @@ -140,6 +142,7 @@ def simulate(policy, people): variable: sim.calculate(variable, year) for variable in [ "ni_class_4", + "ni_class_4_profits", "self_employment_income", "ni_class_1_employee", "ni_class_1_employee_primary", @@ -152,6 +155,9 @@ def simulate(policy, people): assert ni.class_4.rates.main == policy["main_rate"] assert ni.class_4.rates.additional == policy["additional_rate"] values["class_2_flat_rate"] = ni.class_2.flat_rate + values["trading_allowance"] = sim.tax_benefit_system.parameters( + f"{year}-01-01" + ).gov.hmrc.income_tax.allowances.trading_allowance values["year"] = year return values @@ -176,7 +182,7 @@ def reference_class_4(policy, values, i): lpl, upl = exact(policy["lpl"]), exact(policy["upl"]) main_rate = exact(policy["main_rate"]) additional_rate = exact(policy["additional_rate"]) - profits = exact(values["self_employment_income"][i]) + profits = exact(values["ni_class_4_profits"][i]) employee_ni = exact(values["ni_class_1_employee"][i]) class_1 = exact(values["ni_class_1_employee_primary"][i]) class_2 = exact(values["ni_class_2"][i]) @@ -217,9 +223,14 @@ def test_class_4_matches_statute_and_exact_regulation_100(population): lpl, upl = policy["lpl"], policy["upl"] for i in range(len(people)): model = float(values["ni_class_4"][i]) - profits = float(values["self_employment_income"][i]) + profits = float(values["ni_class_4_profits"][i]) + self_employment_income = float(values["self_employment_income"][i]) tol = tolerance(profits, upl, model) + # Receipts unknown: a profit within the trading allowance is nil. + within_allowance = self_employment_income <= values["trading_allowance"] + assert profits == (0 if within_allowance else self_employment_income) + reference, reference_pre_maximum, maximum = reference_class_4(policy, values, i) assert abs(model - float(reference)) <= tol, (i, model, float(reference)) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4.py index 6f43033d47..d1d81de624 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4.py @@ -12,9 +12,9 @@ class ni_class_4(Variable): def formula(person, period, parameters): class_4 = parameters(period).gov.hmrc.national_insurance.class_4 - # Schedule 2 para 2: Class 4 is charged on the full trading profits; - # Class 1 contributions are not a deduction. - profits = person("self_employment_income", period) + # Schedule 2: Class 4 is charged on the Chapter 2 trade profits less + # the para 3 loss reliefs; Class 1 contributions are not a deduction. + profits = person("ni_class_4_profits", period) add_rate_income = max_( profits - class_4.thresholds.upper_profits_limit, 0, diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py new file mode 100644 index 0000000000..d2afbd37bc --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py @@ -0,0 +1,28 @@ +from policyengine_uk.model_api import * + + +class ni_class_4_loss_relief(Variable): + value_type = float + entity = Person + label = "Trading losses deducted from Class 4 profits" + documentation = ( + "Trading losses set against the year's Class 4 profits under SSCBA " + "1992 Sch. 2 para. 3: the current year's trading_loss and losses " + "brought forward, up to the profits. Unlike income tax, a loss only " + "ever reduces trade profits for Class 4: where it is set against " + "other income for income tax, it still reduces the year's Class 4 " + "profits, and any excess is carried forward (para. 3(4))." + ) + definition_period = YEAR + unit = GBP + reference = dict( + title="Social Security Contributions and Benefits Act 1992, Sch. 2 para. 3", + href="https://www.legislation.gov.uk/ukpga/1992/4/schedule/2/paragraph/3", + ) + + def formula(person, period, parameters): + losses = max_(person("trading_loss", period), 0) + max_( + person("ni_class_4_losses_brought_forward", period), 0 + ) + profits = person("ni_class_4_profits_before_losses", period) + return min_(losses, profits) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py new file mode 100644 index 0000000000..66ad63e9b9 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py @@ -0,0 +1,55 @@ +from policyengine_uk.model_api import * + + +class ni_class_4_losses_brought_forward(Variable): + value_type = float + entity = Person + label = "Trading losses brought forward for Class 4" + documentation = ( + "Trading losses from earlier years not yet deducted from Class 4 " + "profits, equal to the previous year's " + "ni_class_4_losses_carried_forward. By default, losses are carried " + "forward from the first year a trading_loss is known for, however " + "many years back. Set this for a year to supply the unrelieved " + "losses from before it; later years then build on that balance." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Social Security Contributions and Benefits Act 1992, Sch. 2 para. 3(1)(c) and (4)(b)", + href="https://www.legislation.gov.uk/ukpga/1992/4/schedule/2/paragraph/3", + ), + dict( + title="Income Tax Act 2007, ss. 83 and 84", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/84", + ), + ] + + def formula(person, period, parameters): + supplied = person.get_holder("ni_class_4_losses_brought_forward") + earlier_years = [ + known_period.start.year + for holder in (person.get_holder("trading_loss"), supplied) + for known_period in holder.get_known_periods() + if known_period.start < period.start + ] + brought_forward = person.empty_array() + if not earlier_years: + # No loss can be known for any earlier year. + return brought_forward + # Run the carry-forward year by year from the first year a loss is + # known for: each year's losses are deducted from that year's + # profits and the rest carried to the next (ITA 2007 s. 84). A loop + # rather than a formula on the previous year, which the engine would + # cut off as a spiral after ten years. + branch_name = person.simulation.branch_name + for years_back in range(period.start.year - min(earlier_years), 0, -1): + year = period.offset(-years_back, "year") + balance = supplied.get_array(year, branch_name) + if balance is not None: + brought_forward = balance + losses = max_(person("trading_loss", year), 0) + max_(brought_forward, 0) + profits = person("ni_class_4_profits_before_losses", year) + brought_forward = losses - min_(losses, profits) + return brought_forward diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_carried_forward.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_carried_forward.py new file mode 100644 index 0000000000..75e23fc50f --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_carried_forward.py @@ -0,0 +1,32 @@ +from policyengine_uk.model_api import * + + +class ni_class_4_losses_carried_forward(Variable): + value_type = float + entity = Person + label = "Trading losses carried forward for Class 4" + documentation = ( + "Trading losses not yet deducted from Class 4 profits at the end of " + "the year: the year's trading_loss and losses brought forward, less " + "those deducted this year. They reduce the Class 4 profits of the " + "following years, earliest first (SSCBA 1992 Sch. 2 para. 3(4)(b); " + "ITA 2007 ss. 83 and 84)." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Social Security Contributions and Benefits Act 1992, Sch. 2 para. 3(4)(b)", + href="https://www.legislation.gov.uk/ukpga/1992/4/schedule/2/paragraph/3", + ), + dict( + title="Income Tax Act 2007, s. 84", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/84", + ), + ] + + def formula(person, period, parameters): + losses = max_(person("trading_loss", period), 0) + max_( + person("ni_class_4_losses_brought_forward", period), 0 + ) + return losses - person("ni_class_4_loss_relief", period) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_main.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_main.py index 6f738716ba..74b467363e 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_main.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_main.py @@ -12,7 +12,7 @@ class ni_class_4_main(Variable): def formula(person, period, parameters): class_4 = parameters(period).gov.hmrc.national_insurance.class_4 - profits = person("self_employment_income", period) + profits = person("ni_class_4_profits", period) add_rate_income = max_( profits - class_4.thresholds.upper_profits_limit, 0, diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_maximum.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_maximum.py index 1d9cd4e3ba..b9e61c8aad 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_maximum.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_maximum.py @@ -35,7 +35,7 @@ def formula(person, period, parameters): # a wrong Case 1 dropped the additional-rate band (#1878). The unused # band is exactly zero at or above the UPL, so that equality is now # decided exactly. - profits = person("self_employment_income", period) + profits = person("ni_class_4_profits", period) unused_main_band = clip(upl - profits.astype(np.float64), 0, step_1) case_1 = (step_4_raw >= 0) & ( main_rate * unused_main_band + class_2_weeks_addition diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py new file mode 100644 index 0000000000..4266eaba7e --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py @@ -0,0 +1,31 @@ +from policyengine_uk.model_api import * + + +class ni_class_4_profits(Variable): + value_type = float + entity = Person + label = "Profits chargeable to Class 4 NICs" + documentation = ( + "Trade profits chargeable to income tax under ITTOIA 2005 Part 2 " + "Chapter 2 (after capital allowances and the trading allowance), less " + "trading losses as SSCBA 1992 Sch. 2 para. 3 allows. Personal " + "reliefs, interest relief and pension contributions are not deducted " + "(para. 3(2)), and neither are Class 1 contributions." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Social Security Contributions and Benefits Act 1992, s. 15(1) and (3)", + href="https://www.legislation.gov.uk/ukpga/1992/4/section/15", + ), + dict( + title="Social Security Contributions and Benefits Act 1992, Sch. 2 paras. 2 and 3", + href="https://www.legislation.gov.uk/ukpga/1992/4/schedule/2", + ), + ] + + def formula(person, period, parameters): + profits = person("ni_class_4_profits_before_losses", period) + loss_relief = person("ni_class_4_loss_relief", period) + return max_(profits - loss_relief, 0) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits_before_losses.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits_before_losses.py new file mode 100644 index 0000000000..28ea1dcb2d --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits_before_losses.py @@ -0,0 +1,41 @@ +from policyengine_uk.model_api import * + + +class ni_class_4_profits_before_losses(Variable): + value_type = float + entity = Person + label = "Trade profits chargeable under ITTOIA 2005 Part 2 Chapter 2" + documentation = ( + "Self-employment profit after capital allowances and the trading " + "allowance, before loss relief: the profits chargeable to income tax " + "under Chapter 2 of Part 2 of ITTOIA 2005 on which Class 4 NICs are " + "charged. Capital allowances are a trade expense (CAA 2001 s. 247). " + "The trading allowance recomputes the trade's profits (ITTOIA 2005 " + "ss. 783AF and 783AI), so no Class 4 NICs are due on income it covers " + "(HMRC BIM86052). Allowances in excess of profit do not create a loss " + "here; supply any such loss through trading_loss." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Social Security Contributions and Benefits Act 1992, s. 15(1)(b)", + href="https://www.legislation.gov.uk/ukpga/1992/4/section/15", + ), + dict( + title="Capital Allowances Act 2001, s. 247", + href="https://www.legislation.gov.uk/ukpga/2001/2/section/247", + ), + dict( + title="HMRC Business Income Manual BIM86052", + href="https://www.gov.uk/hmrc-internal-manuals/business-income-manual/bim86052", + ), + ] + + def formula(person, period, parameters): + profit = person("self_employment_income", period) + capital_allowances = person("capital_allowances", period) + # trading_allowance_deduction is the allowance's relief beyond actual + # expenses and capital allowances, so the two do not stack. + trading_allowance = person("trading_allowance_deduction", period) + return max_(profit - capital_allowances - trading_allowance, 0) From bb44635d9c7a3a8df564c2fb66124cc762c2eb8c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 08:34:43 -0400 Subject: [PATCH 2/8] Property-test a supplied brought-forward balance over many years Co-Authored-By: Claude Opus 5.5 --- .../test_ni_class_4_profits_properties.py | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/policyengine_uk/tests/test_ni_class_4_profits_properties.py b/policyengine_uk/tests/test_ni_class_4_profits_properties.py index bc28d5e7fa..49cee6e1aa 100644 --- a/policyengine_uk/tests/test_ni_class_4_profits_properties.py +++ b/policyengine_uk/tests/test_ni_class_4_profits_properties.py @@ -24,9 +24,9 @@ (or unknown receipts), capital allowances, losses and employment income, wherever Class 2 does not change; and non-increasing in capital allowances and trading losses. -5. Carry-forward over many years matches the reference fold, whatever order - the years are calculated in, including beyond the engine's ten-step - spiral limit. +5. Carry-forward over many years matches the reference fold, with or + without a supplied brought-forward balance, whatever order the years are + calculated in, including beyond the engine's ten-step spiral limit. test_ni_class_4_properties.py checks s. 15(3) and regulation 100 given these profits, for arbitrary thresholds and rates. Comparisons allow float32 @@ -111,13 +111,16 @@ def reference_profits_before_losses(profit, receipts, capital_allowances): return max(Fraction(0), profit - capital_allowances) -def reference_losses(years): +def reference_losses(years, supplied=None): """Sch. 2 para. 3: each year's losses (its own and those brought forward) are set against its profits; the rest is carried forward to the - following years without limit (para. 3(4)(b); ITA 2007 s. 84).""" + following years without limit (para. 3(4)(b); ITA 2007 s. 84). + `supplied` maps a year's index to a supplied brought-forward balance.""" brought_forward = Fraction(0) out = [] - for profits, trading_loss in years: + for index, (profits, trading_loss) in enumerate(years): + if supplied and index in supplied: + brought_forward = supplied[index] losses = max(trading_loss, 0) + brought_forward relief = min(losses, profits) out.append( @@ -402,23 +405,33 @@ def test_class_4_is_non_increasing_in_reliefs(sim, drawn, vary): min_size=2, max_size=16, ), + # Optionally, a brought-forward balance supplied for one year. + st.one_of( + st.none(), + st.tuples(st.integers(0, 15), st.floats(0, 100_000)), + ), st.randoms(use_true_random=False), ) -def test_losses_carry_forward_over_many_years(years, random): +def test_losses_carry_forward_over_many_years(years, opening_balance, random): last_year = 2030 first_year = last_year - len(years) + 1 periods = list(range(first_year, last_year + 1)) + person = { + "age": {first_year: 40}, + "self_employment_income": dict(zip(periods, (profit for profit, _ in years))), + "trading_loss": dict(zip(periods, (loss for _, loss in years))), + } + supplied = None + if opening_balance is not None: + index = opening_balance[0] % len(years) + person["ni_class_4_losses_brought_forward"] = { + periods[index]: opening_balance[1] + } + # Compare against the stored (float32) input. + supplied = {index: exact(np.float32(opening_balance[1]))} sim = Simulation( situation={ - "people": { - "person": { - "age": {first_year: 40}, - "self_employment_income": dict( - zip(periods, (profit for profit, _ in years)) - ), - "trading_loss": dict(zip(periods, (loss for _, loss in years))), - } - }, + "people": {"person": person}, "benunits": {"benunit": {"members": ["person"]}}, "households": {"household": {"members": ["person"]}}, } @@ -434,8 +447,10 @@ def test_losses_carry_forward_over_many_years(years, random): exact(sim.calculate("trading_loss", period)[0]), ) for period in periods - ] + ], + supplied, ) + total_losses = sum(loss for _, loss in years) + sum((supplied or {}).values()) order = periods.copy() random.shuffle(order) for period in order: @@ -444,7 +459,7 @@ def test_losses_carry_forward_over_many_years(years, random): sim.calculate("ni_class_4_losses_brought_forward", period)[0] ) profits = float(sim.calculate("ni_class_4_profits", period)[0]) - tol = tolerance(reference_year["brought_forward"], sum(l for _, l in years)) + tol = tolerance(total_losses) assert abs(brought_forward - float(reference_year["brought_forward"])) <= tol, ( period, brought_forward, From cb1b77f287221cee0376f847a6c4ed2261f2c31f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 08:35:47 -0400 Subject: [PATCH 3/8] Add changelog fragment and document the Class 4 profits measure Co-Authored-By: Claude Opus 5.5 --- changelog.d/1916.fixed.md | 1 + docs/book/programs/gov/hmrc/national-insurance.ipynb | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1916.fixed.md diff --git a/changelog.d/1916.fixed.md b/changelog.d/1916.fixed.md new file mode 100644 index 0000000000..d75e06f315 --- /dev/null +++ b/changelog.d/1916.fixed.md @@ -0,0 +1 @@ +- Charged Class 4 NICs on the profits chargeable under ITTOIA 2005 Part 2 Chapter 2, after capital allowances and the trading allowance, less trading losses under SSCBA 1992 Sch. 2 para. 3 (new `ni_class_4_profits`). Losses reduce only trade profits for Class 4 and carry forward until used. diff --git a/docs/book/programs/gov/hmrc/national-insurance.ipynb b/docs/book/programs/gov/hmrc/national-insurance.ipynb index 8c475d72b8..c7cde1248e 100644 --- a/docs/book/programs/gov/hmrc/national-insurance.ipynb +++ b/docs/book/programs/gov/hmrc/national-insurance.ipynb @@ -171,7 +171,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## How PolicyEngine computes National Insurance liability\n\nFor each person, PolicyEngine computes NI contributions class-by-class:\n\n- **Class 1 employee** (`ni_class_1_employee`): the sum of `ni_class_1_employee_primary` (main rate on earnings between the Primary Threshold and the Upper Earnings Limit) and `ni_class_1_employee_additional` (additional rate on earnings above the UEL).\n- **Class 1 employer** (`ni_class_1_employer`): paid on earnings above the Secondary Threshold; surfaced through the `ni_employer` variable.\n- **Class 2** (`ni_class_2`): a flat weekly rate paid by the self-employed with profits above the Small Profits Threshold.\n- **Class 3** (`ni_class_3`): a voluntary flat weekly rate; the variable is exposed but defaults to zero unless explicitly set.\n- **Class 4** (`ni_class_4`): main rate between the Lower and Upper Profits Limits plus additional rate above the UPL. Where the person also pays primary Class 1 contributions (or, before 6 April 2024, Class 2 contributions), liability is capped at the annual maximum in regulation 100 of the Social Security (Contributions) Regulations 2001 (`ni_class_4_maximum`).\n\nThe individual total `national_insurance` sums the four employee and self-employed components directly — `ni_class_1_employee + ni_class_2 + ni_class_3 + ni_class_4` — so voluntary Class 3 contributions are included in the household-side liability. The convenience aggregates `ni_employee` (Class 1 employee only) and `ni_self_employed` (Class 2 + Class 4) are also exposed for reporting. The Class 1 *employer* charge (`ni_class_1_employer`, surfaced as `ni_employer`) is an employer-side cost rather than part of the individual's `national_insurance`, so it is tracked separately and feeds the government revenue aggregates on its own.\n\nParameters live in `policyengine_uk/parameters/gov/hmrc/national_insurance/` and the per-class formulas in `policyengine_uk/variables/gov/hmrc/national_insurance/`." + "source": "## How PolicyEngine computes National Insurance liability\n\nFor each person, PolicyEngine computes NI contributions class-by-class:\n\n- **Class 1 employee** (`ni_class_1_employee`): the sum of `ni_class_1_employee_primary` (main rate on earnings between the Primary Threshold and the Upper Earnings Limit) and `ni_class_1_employee_additional` (additional rate on earnings above the UEL).\n- **Class 1 employer** (`ni_class_1_employer`): paid on earnings above the Secondary Threshold; surfaced through the `ni_employer` variable.\n- **Class 2** (`ni_class_2`): a flat weekly rate paid by the self-employed with profits above the Small Profits Threshold.\n- **Class 3** (`ni_class_3`): a voluntary flat weekly rate; the variable is exposed but defaults to zero unless explicitly set.\n- **Class 4** (`ni_class_4`): main rate between the Lower and Upper Profits Limits plus additional rate above the UPL, charged on `ni_class_4_profits`. Those are the trade profits chargeable to income tax under ITTOIA 2005 Part 2 Chapter 2 (`ni_class_4_profits_before_losses`: self-employment profit after capital allowances and the trading allowance), less trading losses as SSCBA 1992 Schedule 2 paragraph 3 allows (`ni_class_4_loss_relief`). A loss reduces only trade profits for Class 4: the current year's `trading_loss` first, with the rest carried forward to later years until used (`ni_class_4_losses_brought_forward`, `ni_class_4_losses_carried_forward`). Where the person also pays primary Class 1 contributions (or, before 6 April 2024, Class 2 contributions), liability is capped at the annual maximum in regulation 100 of the Social Security (Contributions) Regulations 2001 (`ni_class_4_maximum`).\n\nThe individual total `national_insurance` sums the four employee and self-employed components directly — `ni_class_1_employee + ni_class_2 + ni_class_3 + ni_class_4` — so voluntary Class 3 contributions are included in the household-side liability. The convenience aggregates `ni_employee` (Class 1 employee only) and `ni_self_employed` (Class 2 + Class 4) are also exposed for reporting. The Class 1 *employer* charge (`ni_class_1_employer`, surfaced as `ni_employer`) is an employer-side cost rather than part of the individual's `national_insurance`, so it is tracked separately and feeds the government revenue aggregates on its own.\n\nParameters live in `policyengine_uk/parameters/gov/hmrc/national_insurance/` and the per-class formulas in `policyengine_uk/variables/gov/hmrc/national_insurance/`." }, { "cell_type": "markdown", From 70cbd58eb7add067d9688697bd501e6a9142bc70 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 08:57:46 -0400 Subject: [PATCH 4/8] Document Class 4 loss limits and add a fixed spiral-limit regression test Addresses the independent review: documents trading_loss carry-over, that negative profit creates no loss (as for income tax), unmodelled carry-backs and para. 3(5) deductions, pooling across trades, and the s. 83(4) order. Co-Authored-By: Claude Opus 5.5 --- .../gov/hmrc/national-insurance.ipynb | 4 +-- .../test_ni_class_4_profits_properties.py | 26 +++++++++++++++++++ .../gov/hmrc/income_tax/bases/trading_loss.py | 7 +++++ .../class_4/ni_class_4_loss_relief.py | 10 ++++--- .../ni_class_4_losses_brought_forward.py | 4 ++- .../class_4/ni_class_4_profits.py | 4 ++- .../ni_class_4_profits_before_losses.py | 5 ++-- 7 files changed, 51 insertions(+), 9 deletions(-) diff --git a/docs/book/programs/gov/hmrc/national-insurance.ipynb b/docs/book/programs/gov/hmrc/national-insurance.ipynb index c7cde1248e..055826394e 100644 --- a/docs/book/programs/gov/hmrc/national-insurance.ipynb +++ b/docs/book/programs/gov/hmrc/national-insurance.ipynb @@ -22,7 +22,7 @@ "- **Class 1**: Paid by employees and their employers. This is based on the employee's earnings and is deducted directly from wages.\n", "- **Class 2**: Paid by self-employed people with profits above a certain threshold.\n", "- **Class 3**: Voluntary contributions, often made by people who want to fill gaps in their National Insurance record.\n", - "- **Class 4**: Paid by self-employed people with profits above a certain threshold, in addition to Class 2 NICs." + "- **Class 4**: Paid by self-employed people with profits above a certain threshold (in addition to Class 2 NICs before April 2024)." ] }, { @@ -171,7 +171,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## How PolicyEngine computes National Insurance liability\n\nFor each person, PolicyEngine computes NI contributions class-by-class:\n\n- **Class 1 employee** (`ni_class_1_employee`): the sum of `ni_class_1_employee_primary` (main rate on earnings between the Primary Threshold and the Upper Earnings Limit) and `ni_class_1_employee_additional` (additional rate on earnings above the UEL).\n- **Class 1 employer** (`ni_class_1_employer`): paid on earnings above the Secondary Threshold; surfaced through the `ni_employer` variable.\n- **Class 2** (`ni_class_2`): a flat weekly rate paid by the self-employed with profits above the Small Profits Threshold.\n- **Class 3** (`ni_class_3`): a voluntary flat weekly rate; the variable is exposed but defaults to zero unless explicitly set.\n- **Class 4** (`ni_class_4`): main rate between the Lower and Upper Profits Limits plus additional rate above the UPL, charged on `ni_class_4_profits`. Those are the trade profits chargeable to income tax under ITTOIA 2005 Part 2 Chapter 2 (`ni_class_4_profits_before_losses`: self-employment profit after capital allowances and the trading allowance), less trading losses as SSCBA 1992 Schedule 2 paragraph 3 allows (`ni_class_4_loss_relief`). A loss reduces only trade profits for Class 4: the current year's `trading_loss` first, with the rest carried forward to later years until used (`ni_class_4_losses_brought_forward`, `ni_class_4_losses_carried_forward`). Where the person also pays primary Class 1 contributions (or, before 6 April 2024, Class 2 contributions), liability is capped at the annual maximum in regulation 100 of the Social Security (Contributions) Regulations 2001 (`ni_class_4_maximum`).\n\nThe individual total `national_insurance` sums the four employee and self-employed components directly — `ni_class_1_employee + ni_class_2 + ni_class_3 + ni_class_4` — so voluntary Class 3 contributions are included in the household-side liability. The convenience aggregates `ni_employee` (Class 1 employee only) and `ni_self_employed` (Class 2 + Class 4) are also exposed for reporting. The Class 1 *employer* charge (`ni_class_1_employer`, surfaced as `ni_employer`) is an employer-side cost rather than part of the individual's `national_insurance`, so it is tracked separately and feeds the government revenue aggregates on its own.\n\nParameters live in `policyengine_uk/parameters/gov/hmrc/national_insurance/` and the per-class formulas in `policyengine_uk/variables/gov/hmrc/national_insurance/`." + "source": "## How PolicyEngine computes National Insurance liability\n\nFor each person, PolicyEngine computes NI contributions class-by-class:\n\n- **Class 1 employee** (`ni_class_1_employee`): the sum of `ni_class_1_employee_primary` (main rate on earnings between the Primary Threshold and the Upper Earnings Limit) and `ni_class_1_employee_additional` (additional rate on earnings above the UEL).\n- **Class 1 employer** (`ni_class_1_employer`): paid on earnings above the Secondary Threshold; surfaced through the `ni_employer` variable.\n- **Class 2** (`ni_class_2`): a flat weekly rate paid by the self-employed with profits above the Small Profits Threshold.\n- **Class 3** (`ni_class_3`): a voluntary flat weekly rate; the variable is exposed but defaults to zero unless explicitly set.\n- **Class 4** (`ni_class_4`): main rate between the Lower and Upper Profits Limits plus additional rate above the UPL, charged on `ni_class_4_profits`. Those are the trade profits chargeable to income tax under ITTOIA 2005 Part 2 Chapter 2 (`ni_class_4_profits_before_losses`: self-employment profit after capital allowances and the trading allowance), less trading losses as SSCBA 1992 Schedule 2 paragraph 3 allows (`ni_class_4_loss_relief`). A loss reduces only trade profits for Class 4: each year's losses (those brought forward, then the year's own `trading_loss`) are set against that year's trade profits, and the rest is carried forward until used (`ni_class_4_losses_brought_forward`, `ni_class_4_losses_carried_forward`). Carry-back claims and the paragraph 3(5) deductions for trade interest and royalties are not modelled. Where the person also pays primary Class 1 contributions (or, before 6 April 2024, Class 2 contributions), liability is capped at the annual maximum in regulation 100 of the Social Security (Contributions) Regulations 2001 (`ni_class_4_maximum`).\n\nThe individual total `national_insurance` sums the four employee and self-employed components directly — `ni_class_1_employee + ni_class_2 + ni_class_3 + ni_class_4` — so voluntary Class 3 contributions are included in the household-side liability. The convenience aggregates `ni_employee` (Class 1 employee only) and `ni_self_employed` (Class 2 + Class 4) are also exposed for reporting. The Class 1 *employer* charge (`ni_class_1_employer`, surfaced as `ni_employer`) is an employer-side cost rather than part of the individual's `national_insurance`, so it is tracked separately and feeds the government revenue aggregates on its own.\n\nParameters live in `policyengine_uk/parameters/gov/hmrc/national_insurance/` and the per-class formulas in `policyengine_uk/variables/gov/hmrc/national_insurance/`." }, { "cell_type": "markdown", diff --git a/policyengine_uk/tests/test_ni_class_4_profits_properties.py b/policyengine_uk/tests/test_ni_class_4_profits_properties.py index 49cee6e1aa..e15ae10c6d 100644 --- a/policyengine_uk/tests/test_ni_class_4_profits_properties.py +++ b/policyengine_uk/tests/test_ni_class_4_profits_properties.py @@ -470,3 +470,29 @@ def test_losses_carry_forward_over_many_years(years, opening_balance, random): profits, float(reference_year["profits"]), ) + + +def test_losses_carry_forward_beyond_the_spiral_limit(): + # A £720,000 loss in 2016 against £50,000 of profit a year: 2016-2029 + # use £700,000, so £20,000 is brought into 2030 and 2030's Class 4 + # profits are £30,000. A formula recursing on the previous year would + # be cut off by the engine's spiral guard after ten years, dropping the + # loss and leaving £50,000. + years = range(2016, 2031) + sim = Simulation( + situation={ + "people": { + "person": { + "age": {2016: 40}, + "self_employment_income": {year: 50_000 for year in years}, + "trading_loss": { + year: 720_000 if year == 2016 else 0 for year in years + }, + } + }, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, + } + ) + assert sim.calculate("ni_class_4_losses_brought_forward", 2030)[0] == 20_000 + assert sim.calculate("ni_class_4_profits", 2030)[0] == 30_000 diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bases/trading_loss.py b/policyengine_uk/variables/gov/hmrc/income_tax/bases/trading_loss.py index 0277b37f86..75757eb01c 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bases/trading_loss.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bases/trading_loss.py @@ -5,6 +5,13 @@ class trading_loss(Variable): value_type = float entity = Person label = "Loss from trading in the current year." + documentation = ( + "Trading losses made in the year. self_employment_income holds the " + "profits of trades that made one, so a loss here alongside a profit " + "comes from a different trade. Like other inputs, a value carries " + "into later years that are not set, so set them to zero for a " + "one-off loss." + ) reference = dict( title="Income Tax Act 2007 s. 64", href="https://www.legislation.gov.uk/ukpga/2007/3/section/64", diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py index d2afbd37bc..6e13485329 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py @@ -7,11 +7,15 @@ class ni_class_4_loss_relief(Variable): label = "Trading losses deducted from Class 4 profits" documentation = ( "Trading losses set against the year's Class 4 profits under SSCBA " - "1992 Sch. 2 para. 3: the current year's trading_loss and losses " - "brought forward, up to the profits. Unlike income tax, a loss only " + "1992 Sch. 2 para. 3: losses brought forward and the year's " + "trading_loss, up to the profits. Unlike income tax, a loss only " "ever reduces trade profits for Class 4: where it is set against " "other income for income tax, it still reduces the year's Class 4 " - "profits, and any excess is carried forward (para. 3(4))." + "profits, and any excess is carried forward (para. 3(4)). Assumes " + "the year's loss is claimed in that year: carry-back claims (ITA " + "2007 ss. 64(2)(b), 72 and 89) are not modelled. Losses are pooled " + "across trades, though s. 83 carries a loss forward only against " + "the same trade." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py index 66ad63e9b9..b642ae4003 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py @@ -11,7 +11,9 @@ class ni_class_4_losses_brought_forward(Variable): "ni_class_4_losses_carried_forward. By default, losses are carried " "forward from the first year a trading_loss is known for, however " "many years back. Set this for a year to supply the unrelieved " - "losses from before it; later years then build on that balance." + "losses from before it; later years then build on that balance. " + "trading_loss carries into later years that are not set, so set " + "them to zero for a one-off loss." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py index 4266eaba7e..953bb0b2eb 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py @@ -10,7 +10,9 @@ class ni_class_4_profits(Variable): "Chapter 2 (after capital allowances and the trading allowance), less " "trading losses as SSCBA 1992 Sch. 2 para. 3 allows. Personal " "reliefs, interest relief and pension contributions are not deducted " - "(para. 3(2)), and neither are Class 1 contributions." + "(para. 3(2)), and neither are Class 1 contributions. The para. 3(5) " + "deductions for trade interest and certain royalties are not " + "modelled." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits_before_losses.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits_before_losses.py index 28ea1dcb2d..e076d6b5d1 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits_before_losses.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits_before_losses.py @@ -12,8 +12,9 @@ class ni_class_4_profits_before_losses(Variable): "charged. Capital allowances are a trade expense (CAA 2001 s. 247). " "The trading allowance recomputes the trade's profits (ITTOIA 2005 " "ss. 783AF and 783AI), so no Class 4 NICs are due on income it covers " - "(HMRC BIM86052). Allowances in excess of profit do not create a loss " - "here; supply any such loss through trading_loss." + "(HMRC BIM86052). As for income tax in this model, a negative " + "self_employment_income, or capital allowances in excess of profit, " + "create no loss here; supply losses through trading_loss." ) definition_period = YEAR unit = GBP From 7f93ed51f34170fbde7c50ff578e5e0b4bf6d2a1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 17:42:58 -0400 Subject: [PATCH 5/8] Count a trading loss for Class 4 only in the year it is supplied for trading_loss carries into later years that are not set, so a one-off loss was deducted from Class 4 profits again in every later year. The new ni_class_4_trading_loss is the loss supplied for the year itself; relief, the carry-forward and the brought-forward fold read it, and the fold reads supplied values only, so its result no longer depends on what was calculated first. Co-Authored-By: Claude Opus 5.5 --- .../class-4-chapter-2-profits.fixed.md | 1 + .../gov/hmrc/national-insurance.ipynb | 2 +- policyengine_uk/tests/conftest.py | 13 ++ .../class_4/ni_class_4_profits.yaml | 95 +++++++++ .../tests/test_ni_class_4_maximum.py | 2 + .../test_ni_class_4_profits_properties.py | 195 ++++++++++++++++++ .../tests/test_ni_class_4_properties.py | 3 + .../test_trading_allowance_properties.py | 2 + policyengine_uk/utils/supplied_inputs.py | 78 +++++++ .../gov/hmrc/income_tax/bases/trading_loss.py | 6 +- .../class_4/ni_class_4_loss_relief.py | 20 +- .../ni_class_4_losses_brought_forward.py | 38 ++-- .../ni_class_4_losses_carried_forward.py | 10 +- .../class_4/ni_class_4_trading_loss.py | 38 ++++ 14 files changed, 471 insertions(+), 32 deletions(-) create mode 100644 changelog.d/class-4-chapter-2-profits.fixed.md create mode 100644 policyengine_uk/utils/supplied_inputs.py create mode 100644 policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.py diff --git a/changelog.d/class-4-chapter-2-profits.fixed.md b/changelog.d/class-4-chapter-2-profits.fixed.md new file mode 100644 index 0000000000..f8328f8229 --- /dev/null +++ b/changelog.d/class-4-chapter-2-profits.fixed.md @@ -0,0 +1 @@ +- Counted a trading loss for Class 4 NICs only in the year it is entered for, so a one-off `trading_loss` is relieved once and only its unrelieved part reduces later years' profits (new `ni_class_4_trading_loss`). diff --git a/docs/book/programs/gov/hmrc/national-insurance.ipynb b/docs/book/programs/gov/hmrc/national-insurance.ipynb index 73ceb409c1..20425241ec 100644 --- a/docs/book/programs/gov/hmrc/national-insurance.ipynb +++ b/docs/book/programs/gov/hmrc/national-insurance.ipynb @@ -171,7 +171,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## How PolicyEngine computes National Insurance liability\n\nFor each person, PolicyEngine computes NI contributions class-by-class:\n\n- **Class 1 employee** (`ni_class_1_employee`): the sum of `ni_class_1_employee_primary` (main rate on earnings between the Primary Threshold and the Upper Earnings Limit) and `ni_class_1_employee_additional` (additional rate on earnings above the UEL). These primary contributions are due from age 16 until state pension age (`ni_liable`): SSCBA 1992 s.6(3) ends them at pensionable age.\n- **Class 1 employer** (`ni_class_1_employer`): paid on earnings above the Secondary Threshold; surfaced through the `ni_employer` variable. These secondary contributions are due on earners over 16 with no upper age limit (`ni_class_1_secondary_liable`), so employers keep paying them for employees over state pension age (SSCBA 1992 s.6(1)(b) and s.6(3)).\n- **Class 2** (`ni_class_2`): a flat weekly rate paid by the self-employed with profits above the Small Profits Threshold.\n- **Class 3** (`ni_class_3`): a voluntary flat weekly rate; the variable is exposed but defaults to zero unless explicitly set.\n- **Class 4** (`ni_class_4`): main rate between the Lower and Upper Profits Limits plus additional rate above the UPL, charged on `ni_class_4_profits`. Those are the trade profits chargeable to income tax under ITTOIA 2005 Part 2 Chapter 2 (`ni_class_4_profits_before_losses`: self-employment profit after capital allowances and the trading allowance), less trading losses as SSCBA 1992 Schedule 2 paragraph 3 allows (`ni_class_4_loss_relief`). A loss reduces only trade profits for Class 4: each year's losses (those brought forward, then the year's own `trading_loss`) are set against that year's trade profits, and the rest is carried forward until used (`ni_class_4_losses_brought_forward`, `ni_class_4_losses_carried_forward`). Carry-back claims and the paragraph 3(5) deductions for trade interest and royalties are not modelled. Where the person also pays primary Class 1 contributions (or, before 6 April 2024, Class 2 contributions), liability is capped at the annual maximum in regulation 100 of the Social Security (Contributions) Regulations 2001 (`ni_class_4_maximum`).\n\nThe individual total `national_insurance` sums the four employee and self-employed components directly — `ni_class_1_employee + ni_class_2 + ni_class_3 + ni_class_4` — so voluntary Class 3 contributions are included in the household-side liability. The convenience aggregates `ni_employee` (Class 1 employee only) and `ni_self_employed` (Class 2 + Class 4) are also exposed for reporting. The Class 1 *employer* charge (`ni_class_1_employer`, surfaced as `ni_employer`) is an employer-side cost rather than part of the individual's `national_insurance`, so it is tracked separately and feeds the government revenue aggregates on its own.\n\nParameters live in `policyengine_uk/parameters/gov/hmrc/national_insurance/` and the per-class formulas in `policyengine_uk/variables/gov/hmrc/national_insurance/`." + "source": "## How PolicyEngine computes National Insurance liability\n\nFor each person, PolicyEngine computes NI contributions class-by-class:\n\n- **Class 1 employee** (`ni_class_1_employee`): the sum of `ni_class_1_employee_primary` (main rate on earnings between the Primary Threshold and the Upper Earnings Limit) and `ni_class_1_employee_additional` (additional rate on earnings above the UEL). These primary contributions are due from age 16 until state pension age (`ni_liable`): SSCBA 1992 s.6(3) ends them at pensionable age.\n- **Class 1 employer** (`ni_class_1_employer`): paid on earnings above the Secondary Threshold; surfaced through the `ni_employer` variable. These secondary contributions are due on earners over 16 with no upper age limit (`ni_class_1_secondary_liable`), so employers keep paying them for employees over state pension age (SSCBA 1992 s.6(1)(b) and s.6(3)).\n- **Class 2** (`ni_class_2`): a flat weekly rate paid by the self-employed with profits above the Small Profits Threshold.\n- **Class 3** (`ni_class_3`): a voluntary flat weekly rate; the variable is exposed but defaults to zero unless explicitly set.\n- **Class 4** (`ni_class_4`): main rate between the Lower and Upper Profits Limits plus additional rate above the UPL, charged on `ni_class_4_profits`. Those are the trade profits chargeable to income tax under ITTOIA 2005 Part 2 Chapter 2 (`ni_class_4_profits_before_losses`: self-employment profit after capital allowances and the trading allowance), less trading losses as SSCBA 1992 Schedule 2 paragraph 3 allows (`ni_class_4_loss_relief`). A loss reduces only trade profits for Class 4: each year's losses (those brought forward, then the year's own loss, `ni_class_4_trading_loss`) are set against that year's trade profits, and the rest is carried forward until used (`ni_class_4_losses_brought_forward`, `ni_class_4_losses_carried_forward`). A year's own loss is the `trading_loss` entered for that year, so a loss entered once is relieved once: it is not counted again in the later years that `trading_loss` carries into. Carry-back claims and the paragraph 3(5) deductions for trade interest and royalties are not modelled. Where the person also pays primary Class 1 contributions (or, before 6 April 2024, Class 2 contributions), liability is capped at the annual maximum in regulation 100 of the Social Security (Contributions) Regulations 2001 (`ni_class_4_maximum`).\n\nThe individual total `national_insurance` sums the four employee and self-employed components directly — `ni_class_1_employee + ni_class_2 + ni_class_3 + ni_class_4` — so voluntary Class 3 contributions are included in the household-side liability. The convenience aggregates `ni_employee` (Class 1 employee only) and `ni_self_employed` (Class 2 + Class 4) are also exposed for reporting. The Class 1 *employer* charge (`ni_class_1_employer`, surfaced as `ni_employer`) is an employer-side cost rather than part of the individual's `national_insurance`, so it is tracked separately and feeds the government revenue aggregates on its own.\n\nParameters live in `policyengine_uk/parameters/gov/hmrc/national_insurance/` and the per-class formulas in `policyengine_uk/variables/gov/hmrc/national_insurance/`." }, { "cell_type": "markdown", diff --git a/policyengine_uk/tests/conftest.py b/policyengine_uk/tests/conftest.py index 56ee3b207b..1a7d925b4b 100644 --- a/policyengine_uk/tests/conftest.py +++ b/policyengine_uk/tests/conftest.py @@ -12,6 +12,19 @@ os.environ["POLICYENGINE_UK_DEFAULT_DATASET"] = DEFAULT_TEST_DATASET_URL +@pytest.fixture(scope="module") +def cloned_uk_tax_benefit_system(): + """Load model code once, with independent model state per simulation.""" + import policyengine_uk.simulation as uk_simulation + + tax_benefit_system = uk_simulation.CountryTaxBenefitSystem() + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr( + uk_simulation, "CountryTaxBenefitSystem", tax_benefit_system.clone + ) + yield + + def pytest_collection_modifyitems(config, items): has_default_dataset = bool(os.environ.get("POLICYENGINE_UK_DEFAULT_DATASET")) if has_default_dataset: diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_profits.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_profits.yaml index 392d459075..76f96eb101 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_profits.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_profits.yaml @@ -164,6 +164,101 @@ # (30,000 - 12,570) * 6% ni_class_4: 1_045.80 +- name: A loss entered for one year only is relieved once + period: 2026 + absolute_error_margin: 0.01 + input: + # Sch. 2 para. 3(4)(b): only the excess of a loss reduces later years' + # profits. trading_loss carries 2025's value into 2026 and 2027, which + # are not set, but that is the same loss, not a new one each year. + self_employment_income: + 2026: 40_000 + 2027: 40_000 + trading_loss: + 2025: 10_000 + output: + ni_class_4_trading_loss: + 2025: 10_000 + 2026: 0 + 2027: 0 + ni_class_4_profits_before_losses: + 2025: 0 + 2026: 40_000 + 2027: 40_000 + ni_class_4_losses_brought_forward: + 2026: 10_000 + 2027: 0 + ni_class_4_loss_relief: + 2026: 10_000 + 2027: 0 + ni_class_4_losses_carried_forward: + 2025: 10_000 + 2026: 0 + 2027: 0 + ni_class_4_profits: + 2026: 30_000 + 2027: 40_000 + # 2026-27 and 2027-28 both have a £12,570 lower profits limit, a £50,270 + # upper profits limit and a 6% main rate. + # (30,000 - 12,570) * 6% and (40,000 - 12,570) * 6% + ni_class_4: + 2026: 1_045.80 + 2027: 1_645.80 + +- name: An unrelieved loss runs down over the following years + period: 2028 + absolute_error_margin: 0.01 + input: + # No losses are entered after 2025: each later year deducts what is left. + self_employment_income: + 2025: 0 + 2026: 20_000 + 2027: 20_000 + 2028: 20_000 + trading_loss: + 2025: 50_000 + output: + ni_class_4_losses_brought_forward: + 2026: 50_000 + 2027: 30_000 + 2028: 10_000 + ni_class_4_profits: + 2026: 0 + 2027: 0 + 2028: 10_000 + ni_class_4_losses_carried_forward: + 2028: 0 + +- name: A year with no loss entered between two loss years adds no loss + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: + 2024: 0 + 2025: 2_000 + 2026: 20_000 + trading_loss: + 2024: 5_000 + 2026: 3_000 + output: + ni_class_4_trading_loss: + 2024: 5_000 + 2025: 0 + 2026: 3_000 + # 5,000 from 2024, less 2,000 used in 2025. + ni_class_4_losses_brought_forward: + 2025: 5_000 + 2026: 3_000 + ni_class_4_loss_relief: + 2025: 2_000 + 2026: 6_000 + ni_class_4_profits: + 2025: 0 + 2026: 14_000 + # (14,000 - 12,570) * 6% + ni_class_4: + 2026: 85.80 + - name: A supplied balance of unrelieved losses is brought forward period: 2026 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/test_ni_class_4_maximum.py b/policyengine_uk/tests/test_ni_class_4_maximum.py index f2d3780870..a61f8afb37 100644 --- a/policyengine_uk/tests/test_ni_class_4_maximum.py +++ b/policyengine_uk/tests/test_ni_class_4_maximum.py @@ -3,6 +3,8 @@ from policyengine_uk import Simulation +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + def test_class_4_annual_maximum_applies_case_3_steps(): year = 2026 diff --git a/policyengine_uk/tests/test_ni_class_4_profits_properties.py b/policyengine_uk/tests/test_ni_class_4_profits_properties.py index e15ae10c6d..e19ba93d38 100644 --- a/policyengine_uk/tests/test_ni_class_4_profits_properties.py +++ b/policyengine_uk/tests/test_ni_class_4_profits_properties.py @@ -27,6 +27,14 @@ 5. Carry-forward over many years matches the reference fold, with or without a supplied brought-forward balance, whatever order the years are calculated in, including beyond the engine's ten-step spiral limit. +6. Each loss is relieved once. With losses entered for only some years, the + model matches a reference in which a year without an entry makes no new + loss, though the engine carries the last entry into it. Total relief + never exceeds the losses entered (relief plus the final carry-forward + equals them), Class 4 profits are never negative, and a year with no + loss entered and none brought forward has no relief. Calculating income + tax's loss relief or the carried-over trading_loss first, or calculating + on a branch, changes none of this. test_ni_class_4_properties.py checks s. 15(3) and regulation 100 given these profits, for arbitrary thresholds and rates. Comparisons allow float32 @@ -39,9 +47,12 @@ import pytest from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st +from policyengine_core.reforms import Reform from policyengine_uk import Simulation +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + ALLOWANCE = 1_000 CASES = 24 # Each case is simulated twice: as drawn, and varied. PEOPLE = 2 * CASES @@ -496,3 +507,187 @@ def test_losses_carry_forward_beyond_the_spiral_limit(): ) assert sim.calculate("ni_class_4_losses_brought_forward", 2030)[0] == 20_000 assert sim.calculate("ni_class_4_profits", 2030)[0] == 30_000 + + +def single_person(person): + return Simulation( + situation={ + "people": {"person": person}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, + } + ) + + +@settings( + max_examples=20, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given( + st.lists( + st.tuples( + st.one_of(st.just(0.0), st.floats(0, 80_000)), # profit + st.one_of(st.just(0.0), st.floats(0, 150_000)), # loss + st.booleans(), # whether the loss is entered for the year + ), + min_size=2, + max_size=12, + ), + st.randoms(use_true_random=False), +) +def test_a_loss_entered_for_some_years_is_relieved_once(years, random): + last_year = 2030 + first_year = last_year - len(years) + 1 + periods = list(range(first_year, last_year + 1)) + entered = { + period: loss + for period, (_, loss, is_entered) in zip(periods, years) + if is_entered + } + person = { + "age": {first_year: 40}, + "self_employment_income": dict( + zip(periods, (profit for profit, _, _ in years)) + ), + } + if entered: + person["trading_loss"] = entered + sim = single_person(person) + # Fill the engine's caches first, in random order: trading_loss carried + # into the years without an entry, and income tax's loss relief, which + # reads it there. Neither may count as a new loss for Class 4. + for period in random.sample(periods, random.randint(0, len(periods))): + sim.calculate(random.choice(["trading_loss", "loss_relief"]), period) + + # Independent reference: the stored (float32) inputs, a loss only in the + # years it is entered for, and the year-by-year fold. + stored_losses = { + period: exact(np.float32(loss)) for period, loss in entered.items() + } + expected = reference_losses( + [ + ( + reference_profits_before_losses( + exact(np.float32(profit)), Fraction(0), Fraction(0) + ), + stored_losses.get(period, Fraction(0)), + ) + for period, (profit, _, _) in zip(periods, years) + ] + ) + names = [ + "losses_brought_forward", + "loss_relief", + "losses_carried_forward", + "profits_before_losses", + "profits", + ] + queries = [(period, name) for period in periods for name in names] + random.shuffle(queries) + model = {period: {} for period in periods} + for period, name in queries: + model[period][name] = float(sim.calculate(f"ni_class_4_{name}", period)[0]) + + total_entered = float(sum(stored_losses.values())) + tol = tolerance(total_entered, *(profit for profit, _, _ in years)) + for period in periods: + reference_year = expected[period - first_year] + values = model[period] + for name, reference_name in [ + ("losses_brought_forward", "brought_forward"), + ("loss_relief", "relief"), + ("losses_carried_forward", "carried_forward"), + ("profits", "profits"), + ]: + assert abs(values[name] - float(reference_year[reference_name])) <= tol, ( + period, + name, + values[name], + float(reference_year[reference_name]), + ) + # Class 4 profits are never negative, nor above profits before losses. + assert 0 <= values["profits"] <= values["profits_before_losses"] + tol + assert values["loss_relief"] >= 0 + # No relief without a loss entered for the year or brought forward. + if stored_losses.get(period, 0) == 0 and values["losses_brought_forward"] == 0: + assert values["loss_relief"] == 0, (period, values) + # Conservation: each loss entered is relieved at most once, and what is + # not relieved is still being carried forward at the end. + total_relief = sum(model[period]["loss_relief"] for period in periods) + assert total_relief <= total_entered + tol + assert ( + abs(total_relief + model[last_year]["losses_carried_forward"] - total_entered) + <= tol + ) + + +def test_class_4_counts_only_supplied_losses_on_branches(): + # A £10,000 loss entered for 2025 only, with £40,000 of profit in 2026 + # and 2027. A branch made before any calculation adds a £5,000 loss in + # 2026. Each simulation counts only the losses supplied to it. + sim = single_person( + { + "age": {2025: 40}, + "self_employment_income": {2026: 40_000, 2027: 40_000}, + "trading_loss": {2025: 10_000}, + } + ) + branch = sim.get_branch("extra_2026_loss") + branch.set_input("trading_loss", 2026, np.array([5_000.0])) + # The engine carries the 2025 loss into 2027, but it is not a new loss. + assert sim.calculate("trading_loss", 2027)[0] == 10_000 + assert sim.calculate("ni_class_4_profits", 2026)[0] == 30_000 + assert sim.calculate("ni_class_4_profits", 2027)[0] == 40_000 + # On the branch, 2026 relieves its own 5,000 and the 10,000 from 2025. + assert branch.calculate("ni_class_4_trading_loss", 2026)[0] == 5_000 + assert branch.calculate("ni_class_4_profits", 2026)[0] == 25_000 + assert branch.calculate("ni_class_4_profits", 2027)[0] == 40_000 + # The branch's input does not reach the simulation it was made from. + assert sim.calculate("ni_class_4_trading_loss", 2026)[0] == 0 + + +class neutralize_trading_loss(Reform): + def apply(self): + self.neutralize_variable("trading_loss") + + +def test_class_4_respects_a_neutralized_supplied_loss(): + sim = single_person( + { + "self_employment_income": {2026: 40_000, 2027: 40_000}, + "trading_loss": {2025: 10_000}, + } + ) + assert sim.calculate("ni_class_4_profits", 2026)[0] == 30_000 + sim.apply_reform(neutralize_trading_loss) + # The reform leaves the raw input in storage, but it is no longer an + # effective loss. Read the current variable, not the holder's old one. + assert sim.calculate("trading_loss", 2025)[0] == 0 + assert sim.calculate("ni_class_4_trading_loss", 2025)[0] == 0 + for year in (2026, 2027): + assert sim.calculate("ni_class_4_loss_relief", year)[0] == 0 + assert sim.calculate("ni_class_4_profits", year)[0] == 40_000 + + +def test_class_4_does_not_restore_an_abolished_supplied_balance(): + sim = single_person( + { + "self_employment_income": {2026: 5_000, 2027: 30_000}, + "ni_class_4_losses_brought_forward": {2026: 7_000}, + } + ) + assert sim.calculate("ni_class_4_profits", 2027)[0] == 28_000 + sim.apply_reform( + { + "gov.abolitions.ni_class_4_losses_brought_forward": { + "2026-01-01.2026-12-31": True + } + } + ) + # The balance variable is enabled again in 2027. Its historical fold + # must still honour the 2026 abolition of the stored opening balance. + assert sim.calculate("ni_class_4_losses_brought_forward", 2026)[0] == 0 + assert sim.calculate("ni_class_4_losses_brought_forward", 2027)[0] == 0 + assert sim.calculate("ni_class_4_profits", 2027)[0] == 30_000 diff --git a/policyengine_uk/tests/test_ni_class_4_properties.py b/policyengine_uk/tests/test_ni_class_4_properties.py index da60adfcd8..64178ccc94 100644 --- a/policyengine_uk/tests/test_ni_class_4_properties.py +++ b/policyengine_uk/tests/test_ni_class_4_properties.py @@ -32,11 +32,14 @@ from fractions import Fraction import numpy as np +import pytest from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + CLASS_4 = "gov.hmrc.national_insurance.class_4" CLASS_2_FLAT_RATE = "gov.hmrc.national_insurance.class_2.flat_rate" YEARS = [2022, 2023, 2024, 2025, 2026, 2027, 2028, 2029, 2030] diff --git a/policyengine_uk/tests/test_trading_allowance_properties.py b/policyengine_uk/tests/test_trading_allowance_properties.py index 08ca9bfe31..f24978ca8b 100644 --- a/policyengine_uk/tests/test_trading_allowance_properties.py +++ b/policyengine_uk/tests/test_trading_allowance_properties.py @@ -14,6 +14,8 @@ from policyengine_uk import Simulation +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + YEAR = 2027 ALLOWANCE = 1_000 OTHER_EMPLOYMENT_INCOME = [0, 8_000, 30_000, 60_000, 110_000, 200_000] diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py new file mode 100644 index 0000000000..96d566b9eb --- /dev/null +++ b/policyengine_uk/utils/supplied_inputs.py @@ -0,0 +1,78 @@ +"""Values supplied as inputs, as distinct from values the engine fills in. + +The engine stores more than the inputs it is given. An input variable that is +not set for a period may inherit a previously known value +(``auto_carry_over_input_variables``), and calculated values are cached next to +inputs. A holder's known periods include both. policyengine-core records each +(variable, branch, period) that ``set_input`` fills in +``Simulation._user_input_keys``, and nothing else adds to it, so it is +independent of what has been calculated before. +""" + +from typing import List, Optional + +import numpy as np +from policyengine_core.errors import ParameterNotFoundError +from policyengine_core.periods import Period + + +def _visible_branch_names(simulation) -> List[str]: + """This branch, then the branches it was made from, then the default.""" + names = [] + branch = simulation + while branch is not None: + names.append(branch.branch_name) + branch = getattr(branch, "parent_branch", None) + if "default" not in names: + names.append("default") + return names + + +def supplied_input_periods(population, variable_name: str) -> List[Period]: + """Periods for which ``variable_name`` was supplied as an input, on this + branch or one it was made from, earliest first.""" + simulation = population.simulation + branch_names = set(_visible_branch_names(simulation)) + input_keys = getattr(simulation, "_user_input_keys", None) or () + return sorted( + { + period + for name, branch_name, period in input_keys + if name == variable_name and branch_name in branch_names + }, + key=lambda period: period.start, + ) + + +def supplied_input( + population, variable_name: str, period: Period +) -> Optional[np.ndarray]: + """The value of ``variable_name`` for ``period`` if it was supplied as an + input, on this branch or the nearest one it was made from; otherwise + None. A value the engine carried over from an earlier period, or cached + after a calculation, is not an input.""" + simulation = population.simulation + input_keys = getattr(simulation, "_user_input_keys", None) or () + holder = population.get_holder(variable_name) + for branch_name in _visible_branch_names(simulation): + if (variable_name, branch_name, period) in input_keys: + # Read the stored input itself: Holder.get_array would fall back + # to other branches' values, including cached calculations. + value = holder._get_array_from_storage(period, branch_name) + if value is not None: + # Preserve the engine's reform handling while reading the + # supplied value rather than a branch's calculated cache. + system = simulation.tax_benefit_system + disabled = system.get_variable(variable_name).is_neutralized + try: + disabled = ( + disabled + or system.parameters(period).gov.abolitions[variable_name] + ) + except (ParameterNotFoundError, KeyError): + # Input variables have no generated abolition parameter. + pass + if disabled: + return holder.default_array() + return value + return None diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bases/trading_loss.py b/policyengine_uk/variables/gov/hmrc/income_tax/bases/trading_loss.py index 75757eb01c..c7e92feaec 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bases/trading_loss.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bases/trading_loss.py @@ -9,8 +9,10 @@ class trading_loss(Variable): "Trading losses made in the year. self_employment_income holds the " "profits of trades that made one, so a loss here alongside a profit " "comes from a different trade. Like other inputs, a value carries " - "into later years that are not set, so set them to zero for a " - "one-off loss." + "into later years that are not set, and income tax's loss_relief " + "reads it there, so set those years to zero for a one-off loss. " + "Class 4 counts a loss only in the year it is set for " + "(ni_class_4_trading_loss)." ) reference = dict( title="Income Tax Act 2007 s. 64", diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py index 6e13485329..db9eb45d9a 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py @@ -7,15 +7,15 @@ class ni_class_4_loss_relief(Variable): label = "Trading losses deducted from Class 4 profits" documentation = ( "Trading losses set against the year's Class 4 profits under SSCBA " - "1992 Sch. 2 para. 3: losses brought forward and the year's " - "trading_loss, up to the profits. Unlike income tax, a loss only " - "ever reduces trade profits for Class 4: where it is set against " - "other income for income tax, it still reduces the year's Class 4 " - "profits, and any excess is carried forward (para. 3(4)). Assumes " - "the year's loss is claimed in that year: carry-back claims (ITA " - "2007 ss. 64(2)(b), 72 and 89) are not modelled. Losses are pooled " - "across trades, though s. 83 carries a loss forward only against " - "the same trade." + "1992 Sch. 2 para. 3: losses brought forward and the year's own " + "loss (ni_class_4_trading_loss), up to the profits. Unlike income " + "tax, a loss only ever reduces trade profits for Class 4: where it " + "is set against other income for income tax, it still reduces the " + "year's Class 4 profits, and any excess is carried forward (para. " + "3(4)). Assumes the year's loss is claimed in that year: carry-back " + "claims (ITA 2007 ss. 64(2)(b), 72 and 89) are not modelled. Losses " + "are pooled across trades, though s. 83 carries a loss forward only " + "against the same trade." ) definition_period = YEAR unit = GBP @@ -25,7 +25,7 @@ class ni_class_4_loss_relief(Variable): ) def formula(person, period, parameters): - losses = max_(person("trading_loss", period), 0) + max_( + losses = person("ni_class_4_trading_loss", period) + max_( person("ni_class_4_losses_brought_forward", period), 0 ) profits = person("ni_class_4_profits_before_losses", period) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py index b642ae4003..e5d6d17bad 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py @@ -1,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.supplied_inputs import ( + supplied_input, + supplied_input_periods, +) class ni_class_4_losses_brought_forward(Variable): @@ -9,11 +13,12 @@ class ni_class_4_losses_brought_forward(Variable): "Trading losses from earlier years not yet deducted from Class 4 " "profits, equal to the previous year's " "ni_class_4_losses_carried_forward. By default, losses are carried " - "forward from the first year a trading_loss is known for, however " + "forward from the first year a trading_loss is supplied for, however " "many years back. Set this for a year to supply the unrelieved " "losses from before it; later years then build on that balance. " - "trading_loss carries into later years that are not set, so set " - "them to zero for a one-off loss." + "Each loss counts once, in the year it is supplied for " + "(ni_class_4_trading_loss), so a one-off loss needs no zeros in " + "later years." ) definition_period = YEAR unit = GBP @@ -29,29 +34,34 @@ class ni_class_4_losses_brought_forward(Variable): ] def formula(person, period, parameters): - supplied = person.get_holder("ni_class_4_losses_brought_forward") + # Only supplied values count. The engine also stores values it fills + # in (an input carried into a later year, or a cached calculation), + # and whether those exist depends on what was calculated first. earlier_years = [ - known_period.start.year - for holder in (person.get_holder("trading_loss"), supplied) - for known_period in holder.get_known_periods() - if known_period.start < period.start + supplied_period.start.year + for variable in ( + "trading_loss", + "ni_class_4_trading_loss", + "ni_class_4_losses_brought_forward", + ) + for supplied_period in supplied_input_periods(person, variable) + if supplied_period.start < period.start ] brought_forward = person.empty_array() if not earlier_years: - # No loss can be known for any earlier year. + # No loss is supplied for any earlier year. return brought_forward # Run the carry-forward year by year from the first year a loss is - # known for: each year's losses are deducted from that year's + # supplied for: each year's losses are deducted from that year's # profits and the rest carried to the next (ITA 2007 s. 84). A loop # rather than a formula on the previous year, which the engine would # cut off as a spiral after ten years. - branch_name = person.simulation.branch_name for years_back in range(period.start.year - min(earlier_years), 0, -1): year = period.offset(-years_back, "year") - balance = supplied.get_array(year, branch_name) + balance = supplied_input(person, "ni_class_4_losses_brought_forward", year) if balance is not None: - brought_forward = balance - losses = max_(person("trading_loss", year), 0) + max_(brought_forward, 0) + brought_forward = max_(balance, 0) + losses = person("ni_class_4_trading_loss", year) + brought_forward profits = person("ni_class_4_profits_before_losses", year) brought_forward = losses - min_(losses, profits) return brought_forward diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_carried_forward.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_carried_forward.py index 75e23fc50f..72a82b6656 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_carried_forward.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_carried_forward.py @@ -7,10 +7,10 @@ class ni_class_4_losses_carried_forward(Variable): label = "Trading losses carried forward for Class 4" documentation = ( "Trading losses not yet deducted from Class 4 profits at the end of " - "the year: the year's trading_loss and losses brought forward, less " - "those deducted this year. They reduce the Class 4 profits of the " - "following years, earliest first (SSCBA 1992 Sch. 2 para. 3(4)(b); " - "ITA 2007 ss. 83 and 84)." + "the year: the year's own loss (ni_class_4_trading_loss) and losses " + "brought forward, less those deducted this year. They reduce the " + "Class 4 profits of the following years, earliest first (SSCBA 1992 " + "Sch. 2 para. 3(4)(b); ITA 2007 ss. 83 and 84)." ) definition_period = YEAR unit = GBP @@ -26,7 +26,7 @@ class ni_class_4_losses_carried_forward(Variable): ] def formula(person, period, parameters): - losses = max_(person("trading_loss", period), 0) + max_( + losses = person("ni_class_4_trading_loss", period) + max_( person("ni_class_4_losses_brought_forward", period), 0 ) return losses - person("ni_class_4_loss_relief", period) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.py new file mode 100644 index 0000000000..fed2f5323d --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.py @@ -0,0 +1,38 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.supplied_inputs import supplied_input + + +class ni_class_4_trading_loss(Variable): + value_type = float + entity = Person + label = "Trading losses made in the year, for Class 4" + documentation = ( + "The year's trading_loss where one is supplied for the year, and nil " + "otherwise. Like other inputs, trading_loss carries an earlier " + "year's value into later years that are not set, but for Class 4 a " + "loss is relieved once: it reduces the profits of the year it is " + "made, and only the excess reduces the profits of later years " + "(ni_class_4_losses_brought_forward). A loss supplied for one year " + "is therefore not a new loss in the years after it." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Social Security Contributions and Benefits Act 1992, Sch. 2 para. 3(4)", + href="https://www.legislation.gov.uk/ukpga/1992/4/schedule/2/paragraph/3", + ), + dict( + title="Income Tax Act 2007, s. 83(2)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/83", + ), + ] + + def formula(person, period, parameters): + # Only a supplied value is a loss made in the year. Reading + # trading_loss through the engine would return an earlier year's loss + # carried into this one, and relieve it again. + loss = supplied_input(person, "trading_loss", period) + if loss is None: + return person.empty_array() + return max_(loss, 0) From ddfdfff629ebc9513a62cb972f6171626769920f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 07:36:58 -0400 Subject: [PATCH 6/8] Drop the abolition check: PolicyEngine-UK builds no gov.abolitions The UK system does not call core's add_abolition_parameters (it has not since #1274), so a supplied input can only be disabled by neutralizing its variable. Keep that check and drop the abolition lookup and its test, which applied a reform to a parameter that does not exist. Co-Authored-By: Claude Opus 5.5 --- .../test_ni_class_4_profits_properties.py | 22 ------------------- policyengine_uk/utils/supplied_inputs.py | 20 +++++------------ 2 files changed, 6 insertions(+), 36 deletions(-) diff --git a/policyengine_uk/tests/test_ni_class_4_profits_properties.py b/policyengine_uk/tests/test_ni_class_4_profits_properties.py index e19ba93d38..fffe57d2a2 100644 --- a/policyengine_uk/tests/test_ni_class_4_profits_properties.py +++ b/policyengine_uk/tests/test_ni_class_4_profits_properties.py @@ -669,25 +669,3 @@ def test_class_4_respects_a_neutralized_supplied_loss(): for year in (2026, 2027): assert sim.calculate("ni_class_4_loss_relief", year)[0] == 0 assert sim.calculate("ni_class_4_profits", year)[0] == 40_000 - - -def test_class_4_does_not_restore_an_abolished_supplied_balance(): - sim = single_person( - { - "self_employment_income": {2026: 5_000, 2027: 30_000}, - "ni_class_4_losses_brought_forward": {2026: 7_000}, - } - ) - assert sim.calculate("ni_class_4_profits", 2027)[0] == 28_000 - sim.apply_reform( - { - "gov.abolitions.ni_class_4_losses_brought_forward": { - "2026-01-01.2026-12-31": True - } - } - ) - # The balance variable is enabled again in 2027. Its historical fold - # must still honour the 2026 abolition of the stored opening balance. - assert sim.calculate("ni_class_4_losses_brought_forward", 2026)[0] == 0 - assert sim.calculate("ni_class_4_losses_brought_forward", 2027)[0] == 0 - assert sim.calculate("ni_class_4_profits", 2027)[0] == 30_000 diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index 96d566b9eb..55b9e654d8 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -12,7 +12,6 @@ from typing import List, Optional import numpy as np -from policyengine_core.errors import ParameterNotFoundError from policyengine_core.periods import Period @@ -60,19 +59,12 @@ def supplied_input( # to other branches' values, including cached calculations. value = holder._get_array_from_storage(period, branch_name) if value is not None: - # Preserve the engine's reform handling while reading the - # supplied value rather than a branch's calculated cache. - system = simulation.tax_benefit_system - disabled = system.get_variable(variable_name).is_neutralized - try: - disabled = ( - disabled - or system.parameters(period).gov.abolitions[variable_name] - ) - except (ParameterNotFoundError, KeyError): - # Input variables have no generated abolition parameter. - pass - if disabled: + # A neutralized variable reads as its default, as it does + # through the engine. (PolicyEngine-UK builds no + # gov.abolitions parameters, so there is no abolition switch + # to honour here.) + variable = simulation.tax_benefit_system.get_variable(variable_name) + if variable.is_neutralized: return holder.default_array() return value return None From a7abbd43b24a9e255283fd111552e6d0bfc103cd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 14:34:25 -0400 Subject: [PATCH 7/8] Keep input provenance per simulation; use each year's allowance in the test reference A plain clone shared the original's record of supplied inputs, so a loss supplied on the clone made the original count its own carried-over loss again. UK simulations now copy that record when cloning and drop it when arrays are deleted, and the helpers ignore a key with no stored array. The property reference reads each year's trading allowance, which is nil before 2017-18. Co-Authored-By: Claude Opus 5.5 --- .../class-4-chapter-2-profits.fixed.md | 2 + policyengine_uk/simulation.py | 37 +++++- .../test_ni_class_4_profits_properties.py | 121 +++++++++++++++++- policyengine_uk/utils/supplied_inputs.py | 12 +- 4 files changed, 162 insertions(+), 10 deletions(-) diff --git a/changelog.d/class-4-chapter-2-profits.fixed.md b/changelog.d/class-4-chapter-2-profits.fixed.md index f8328f8229..e0f6bc5988 100644 --- a/changelog.d/class-4-chapter-2-profits.fixed.md +++ b/changelog.d/class-4-chapter-2-profits.fixed.md @@ -1 +1,3 @@ - Counted a trading loss for Class 4 NICs only in the year it is entered for, so a one-off `trading_loss` is relieved once and only its unrelieved part reduces later years' profits (new `ni_class_4_trading_loss`). +- Isolated supplied-input provenance between simulation clones and removed it for deleted inputs, preventing Class 4 from relieving cached carry-over losses again. +- Corrected the Class 4 property-test reference to use each year's trading allowance parameter, including the zero allowance in historical years. diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index 22ddaa61a1..2233bfb407 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -9,7 +9,7 @@ # PolicyEngine core imports from policyengine_core.data import Dataset from policyengine_core.enums import Enum as CoreEnum -from policyengine_core.periods import period as period_ +from policyengine_core.periods import Period, period as period_ from policyengine_core.parameters import Parameter from policyengine_core.reforms import Reform from policyengine_core.simulations import Simulation as CoreSimulation @@ -219,6 +219,41 @@ def __init__( if scenario.parameter_changes is not None: self.apply_parameter_changes(scenario.parameter_changes) + def clone( + self, + debug: bool = False, + trace: bool = False, + clone_tax_benefit_system: bool = True, + ) -> "Simulation": + clone = super().clone(debug, trace, clone_tax_benefit_system) + # policyengine-core 3.32.9: simulations/simulation.py::Simulation.clone + # shallow-copies __dict__, while holders/holder.py::Holder.clone copies + # value storage. Holder.set_input's provenance and context must belong + # to the same simulation as that storage, including for plain clones. + clone._user_input_keys = set(getattr(self, "_user_input_keys", ())) + clone._user_input_contexts = list(getattr(self, "_user_input_contexts", ())) + return clone + + def delete_arrays(self, variable: str, period: Period = None) -> None: + super().delete_arrays(variable, period) + # policyengine-core 3.32.9: Simulation.delete_arrays and + # holders/holder.py::Holder.delete_arrays remove storage, but retain + # provenance keys. Drop those keys before carry-over can refill storage. + # Inspect storage after core's deletion to honour period containment + # and the current branch, ancestor branches and default branch. + input_keys = getattr(self, "_user_input_keys", None) + if input_keys: + branch_names = set(self._get_visible_branch_names()) + holder = self.get_holder(variable) + deleted_keys = { + (name, branch_name, input_period) + for name, branch_name, input_period in input_keys + if name == variable + and branch_name in branch_names + and holder._get_array_from_storage(input_period, branch_name) is None + } + input_keys.difference_update(deleted_keys) + def reset_calculations(self): for variable in self.tax_benefit_system.variables: if variable not in self.input_variables: diff --git a/policyengine_uk/tests/test_ni_class_4_profits_properties.py b/policyengine_uk/tests/test_ni_class_4_profits_properties.py index fffe57d2a2..a53cee1559 100644 --- a/policyengine_uk/tests/test_ni_class_4_profits_properties.py +++ b/policyengine_uk/tests/test_ni_class_4_profits_properties.py @@ -47,13 +47,14 @@ import pytest from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st +from policyengine_core.periods import period as period_ from policyengine_core.reforms import Reform from policyengine_uk import Simulation +from policyengine_uk.utils.supplied_inputs import supplied_input, supplied_input_periods pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") -ALLOWANCE = 1_000 CASES = 24 # Each case is simulated twice: as drawn, and varied. PEOPLE = 2 * CASES PROPERTY_SETTINGS = settings( @@ -103,21 +104,29 @@ def exact(x): return Fraction(float(x)) -def reference_profits_before_losses(profit, receipts, capital_allowances): +def reference_allowance(simulation, year): + return exact( + simulation.tax_benefit_system.parameters( + f"{year}-01-01" + ).gov.hmrc.income_tax.allowances.trading_allowance + ) + + +def reference_profits_before_losses(profit, receipts, capital_allowances, allowance): """ITTOIA 2005 Part 2 Chapter 2 profits, after capital allowances and the trading allowance, before loss relief.""" if receipts > 0 and receipts >= profit: - if receipts <= ALLOWANCE: + if receipts <= allowance: # s. 783AF: the trade's profits are nil. return Fraction(0) # The better of actual deductions and the allowance (s. 783AI); # capital allowances are a deduction (CAA 2001 s. 247) that the # allowance replaces, so the two never stack. actual_deductions = receipts - profit + capital_allowances - return max(Fraction(0), receipts - max(actual_deductions, ALLOWANCE)) + return max(Fraction(0), receipts - max(actual_deductions, allowance)) # Receipts unknown: a profit within the allowance is taken to come from # receipts within it; a larger one already reflects the better deduction. - if profit <= ALLOWANCE: + if profit <= allowance: return Fraction(0) return max(Fraction(0), profit - capital_allowances) @@ -237,6 +246,7 @@ def calculate(variable, period): return { period: dict( # Stored (float32) inputs, not the float64 draws. + allowance=reference_allowance(sim, period), profit=calculate("self_employment_income", period), receipts=calculate("self_employment_gross_receipts", period), capital_allowances=calculate("capital_allowances", period), @@ -266,6 +276,7 @@ def reference(values, i): exact(v["profit"][i]), exact(v["receipts"][i]), exact(v["capital_allowances"][i]), + v["allowance"], ) for v in years ] @@ -454,6 +465,7 @@ def test_losses_carry_forward_over_many_years(years, opening_balance, random): exact(sim.calculate("self_employment_income", period)[0]), Fraction(0), Fraction(0), + reference_allowance(sim, period), ), exact(sim.calculate("trading_loss", period)[0]), ) @@ -519,6 +531,40 @@ def single_person(person): ) +def test_pre_allowance_profits_exhaust_the_loss_in_the_historical_reference(): + sim = single_person( + { + "self_employment_income": {2015: 500, 2016: 500}, + "trading_loss": {2015: 1_000, 2016: 0}, + } + ) + periods = (2015, 2016) + profits = [ + reference_profits_before_losses( + Fraction(500), + Fraction(0), + Fraction(0), + reference_allowance(sim, period), + ) + for period in periods + ] + assert profits == [500, 500] + expected = reference_losses(zip(profits, (Fraction(1_000), Fraction(0)))) + assert [year["relief"] for year in expected] == [500, 500] + assert [year["carried_forward"] for year in expected] == [500, 0] + for period, reference_year in zip(periods, expected): + assert sim.calculate("ni_class_4_profits_before_losses", period)[0] == 500 + for name, reference_name in [ + ("losses_brought_forward", "brought_forward"), + ("loss_relief", "relief"), + ("losses_carried_forward", "carried_forward"), + ("profits", "profits"), + ]: + assert sim.calculate(f"ni_class_4_{name}", period)[0] == float( + reference_year[reference_name] + ) + + @settings( max_examples=20, deadline=None, @@ -570,7 +616,10 @@ def test_a_loss_entered_for_some_years_is_relieved_once(years, random): [ ( reference_profits_before_losses( - exact(np.float32(profit)), Fraction(0), Fraction(0) + exact(np.float32(profit)), + Fraction(0), + Fraction(0), + reference_allowance(sim, period), ), stored_losses.get(period, Fraction(0)), ) @@ -648,6 +697,66 @@ def test_class_4_counts_only_supplied_losses_on_branches(): assert sim.calculate("ni_class_4_trading_loss", 2026)[0] == 0 +def test_class_4_counts_only_supplied_losses_on_plain_clones(): + sim = single_person( + { + "age": {2025: 40}, + "self_employment_income": {2026: 40_000, 2027: 40_000}, + "trading_loss": {2025: 10_000}, + } + ) + clone = sim.clone() + clone.set_input("trading_loss", 2026, np.array([5_000.0])) + # Warm the original's carry-over cache after the clone records its input. + # A shared provenance set would count this cached 10,000 as a new loss. + assert sim.calculate("trading_loss", 2026)[0] == 10_000 + observed = ( + float(sim.calculate("ni_class_4_profits", 2026)[0]), + round(float(sim.calculate("ni_class_4", 2026)[0]), 2), + ) + assert observed == (30_000, 1_045.80) + assert clone.calculate("ni_class_4_profits", 2026)[0] == 25_000 + for simulation in (sim, clone): + assert simulation.calculate("ni_class_4_profits", 2027)[0] == 40_000 + assert clone._user_input_keys is not sim._user_input_keys + assert clone._user_input_contexts is not sim._user_input_contexts + + +@pytest.mark.parametrize("on_branch", [False, True]) +def test_class_4_does_not_count_a_deleted_loss_after_recalculation(on_branch): + original = single_person( + { + "age": {2025: 40}, + "self_employment_income": {2026: 40_000, 2027: 40_000}, + "trading_loss": {2025: 10_000}, + } + ) + sim = original.get_branch("deleted_loss") if on_branch else original + sim.set_input("trading_loss", 2026, np.array([5_000.0])) + sim.delete_arrays("trading_loss", 2026) + # Deletion leaves the earlier loss available for engine carry-over, but + # that replacement cache must not retain the deleted input's provenance. + assert sim.calculate("trading_loss", 2026)[0] == 10_000 + observed = ( + float(sim.calculate("ni_class_4_profits", 2026)[0]), + round(float(sim.calculate("ni_class_4", 2026)[0]), 2), + ) + assert observed == (30_000, 1_045.80) + assert sim.calculate("ni_class_4_profits", 2027)[0] == 40_000 + if on_branch: + assert original.calculate("ni_class_4_profits", 2026)[0] == 30_000 + + +def test_supplied_input_helpers_ignore_missing_stored_arrays(): + sim = single_person({"trading_loss": {2025: 10_000, 2026: 5_000}}) + population = sim.get_variable_population("trading_loss") + # Direct holder deletion leaves core's provenance keys behind. Neither + # helper may identify the missing stored value as an effective input. + population.get_holder("trading_loss").delete_arrays(period_(2026)) + assert supplied_input(population, "trading_loss", period_(2026)) is None + assert supplied_input_periods(population, "trading_loss") == [period_(2025)] + + class neutralize_trading_loss(Reform): def apply(self): self.neutralize_variable("trading_loss") diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index 55b9e654d8..2caee278b0 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -5,8 +5,9 @@ (``auto_carry_over_input_variables``), and calculated values are cached next to inputs. A holder's known periods include both. policyengine-core records each (variable, branch, period) that ``set_input`` fills in -``Simulation._user_input_keys``, and nothing else adds to it, so it is -independent of what has been calculated before. +``Simulation._user_input_keys``. UK simulations isolate this provenance on +cloning and remove keys when deleting arrays; the helpers also require a +stored value, so missing arrays do not count as supplied inputs. """ from typing import List, Optional @@ -33,11 +34,14 @@ def supplied_input_periods(population, variable_name: str) -> List[Period]: simulation = population.simulation branch_names = set(_visible_branch_names(simulation)) input_keys = getattr(simulation, "_user_input_keys", None) or () + holder = population.get_holder(variable_name) return sorted( { period for name, branch_name, period in input_keys - if name == variable_name and branch_name in branch_names + if name == variable_name + and branch_name in branch_names + and holder._get_array_from_storage(period, branch_name) is not None }, key=lambda period: period.start, ) @@ -57,6 +61,8 @@ def supplied_input( if (variable_name, branch_name, period) in input_keys: # Read the stored input itself: Holder.get_array would fall back # to other branches' values, including cached calculations. + # Core 3.32.9's Holder.delete_arrays leaves provenance keys behind; + # a missing stored array must be ignored even if its key remains. value = holder._get_array_from_storage(period, branch_name) if value is not None: # A neutralized variable reads as its default, as it does From 9ca5d6eb5f80f2617e540a4d777154fbf837aa75 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 15:23:30 -0400 Subject: [PATCH 8/8] Forget a supplied input's record when its holder value is deleted Holder.delete_arrays removes a stored value but leaves core's record of it as an input (policyengine-core#559). The engine could then carry an earlier loss into the emptied year, and the stale record made Class 4 count that carried value as a second loss. Simulation.calculate now drops such records for the variables the supplied-input helpers read before the engine can refill the period, and the helpers refuse any variable outside that set. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/simulation.py | 25 +++++---- .../test_ni_class_4_profits_properties.py | 49 +++++++++++++++++- policyengine_uk/utils/supplied_inputs.py | 51 ++++++++++++++++++- 3 files changed, 109 insertions(+), 16 deletions(-) diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index 2233bfb407..13444134b0 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -30,6 +30,10 @@ ) from policyengine_uk.data.dataset_sources import materialize_gcs_dataset_url from policyengine_uk.utils.dependencies import get_variable_dependencies +from policyengine_uk.utils.supplied_inputs import ( + SUPPLIED_INPUT_VARIABLES, + drop_missing_supplied_inputs, +) from policyengine_uk.reforms import create_structural_reforms_from_parameters from policyengine_uk.parameters.gov.simulation.labour_supply_responses.aliases import ( canonicalize_lsr_parameter_path, @@ -236,23 +240,12 @@ def clone( def delete_arrays(self, variable: str, period: Period = None) -> None: super().delete_arrays(variable, period) - # policyengine-core 3.32.9: Simulation.delete_arrays and + # policyengine-core's Simulation.delete_arrays and # holders/holder.py::Holder.delete_arrays remove storage, but retain # provenance keys. Drop those keys before carry-over can refill storage. # Inspect storage after core's deletion to honour period containment # and the current branch, ancestor branches and default branch. - input_keys = getattr(self, "_user_input_keys", None) - if input_keys: - branch_names = set(self._get_visible_branch_names()) - holder = self.get_holder(variable) - deleted_keys = { - (name, branch_name, input_period) - for name, branch_name, input_period in input_keys - if name == variable - and branch_name in branch_names - and holder._get_array_from_storage(input_period, branch_name) is None - } - input_keys.difference_update(deleted_keys) + drop_missing_supplied_inputs(self, variable) def reset_calculations(self): for variable in self.tax_benefit_system.variables: @@ -651,6 +644,12 @@ def calculate( period = period_(period) + if variable_name in SUPPLIED_INPUT_VARIABLES: + # A value deleted straight from the holder leaves its input record + # behind. Forget it before the engine can refill the period with a + # carried-over value that the record would pass off as supplied. + drop_missing_supplied_inputs(self, variable_name) + return super().calculate( variable_name, period, map_to=map_to, decode_enums=decode_enums ) diff --git a/policyengine_uk/tests/test_ni_class_4_profits_properties.py b/policyengine_uk/tests/test_ni_class_4_profits_properties.py index a53cee1559..98a193d677 100644 --- a/policyengine_uk/tests/test_ni_class_4_profits_properties.py +++ b/policyengine_uk/tests/test_ni_class_4_profits_properties.py @@ -51,7 +51,11 @@ from policyengine_core.reforms import Reform from policyengine_uk import Simulation -from policyengine_uk.utils.supplied_inputs import supplied_input, supplied_input_periods +from policyengine_uk.utils.supplied_inputs import ( + SUPPLIED_INPUT_VARIABLES, + supplied_input, + supplied_input_periods, +) pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") @@ -757,6 +761,49 @@ def test_supplied_input_helpers_ignore_missing_stored_arrays(): assert supplied_input_periods(population, "trading_loss") == [period_(2025)] +@pytest.mark.parametrize("on_branch", [False, True]) +@pytest.mark.parametrize("deleted_loss", [5_000.0, 10_000.0]) +def test_class_4_does_not_count_a_loss_deleted_from_its_holder(on_branch, deleted_loss): + original = single_person( + { + "age": {2025: 40}, + "self_employment_income": {2026: 40_000, 2027: 40_000}, + "trading_loss": {2025: 10_000}, + } + ) + sim = original.get_branch("deleted_loss") if on_branch else original + sim.set_input("trading_loss", 2026, np.array([deleted_loss])) + # Holder.delete_arrays removes the stored value but not core's record of + # it. The engine then carries the 2025 loss into 2026; with the stale + # record that 10,000 would count as a second loss (profits of 20,000 and + # contributions of 445.80), also when the deleted loss was 10,000 too. + sim.get_holder("trading_loss").delete_arrays(period_(2026), sim.branch_name) + assert sim.calculate("trading_loss", 2026)[0] == 10_000 + population = sim.get_variable_population("trading_loss") + assert supplied_input(population, "trading_loss", period_(2026)) is None + assert supplied_input_periods(population, "trading_loss") == [period_(2025)] + observed = ( + float(sim.calculate("ni_class_4_profits", 2026)[0]), + round(float(sim.calculate("ni_class_4", 2026)[0]), 2), + ) + assert observed == (30_000, 1_045.80) + assert sim.calculate("ni_class_4_profits", 2027)[0] == 40_000 + if on_branch: + assert original.calculate("ni_class_4_profits", 2026)[0] == 30_000 + + +def test_supplied_input_helpers_refuse_an_unregistered_variable(): + sim = single_person({"employment_income": {2026: 20_000}}) + population = sim.get_variable_population("employment_income") + # Simulation.calculate keeps input records in step with storage only for + # SUPPLIED_INPUT_VARIABLES, so the helpers answer for no other variable. + assert "employment_income" not in SUPPLIED_INPUT_VARIABLES + with pytest.raises(ValueError, match="SUPPLIED_INPUT_VARIABLES"): + supplied_input(population, "employment_income", period_(2026)) + with pytest.raises(ValueError, match="SUPPLIED_INPUT_VARIABLES"): + supplied_input_periods(population, "employment_income") + + class neutralize_trading_loss(Reform): def apply(self): self.neutralize_variable("trading_loss") diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index 2caee278b0..f28a1a5163 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -8,6 +8,13 @@ ``Simulation._user_input_keys``. UK simulations isolate this provenance on cloning and remove keys when deleting arrays; the helpers also require a stored value, so missing arrays do not count as supplied inputs. + +``Holder.delete_arrays`` removes a stored value without removing its record +(policyengine-core#559). The engine could then carry an earlier value into the +empty period, and the stale record would pass it off as supplied. So +``Simulation.calculate`` forgets the records of a variable in +``SUPPLIED_INPUT_VARIABLES`` whose stored value is gone before the engine can +refill it, and the helpers only answer for those variables. """ from typing import List, Optional @@ -16,6 +23,25 @@ from policyengine_core.periods import Period +# Variables that formulas ask about through the helpers below. +SUPPLIED_INPUT_VARIABLES = frozenset( + { + "ni_class_4_losses_brought_forward", + "ni_class_4_trading_loss", + "trading_loss", + } +) + + +def _check_registered(variable_name: str) -> None: + if variable_name not in SUPPLIED_INPUT_VARIABLES: + raise ValueError( + f"{variable_name} is not in SUPPLIED_INPUT_VARIABLES, so " + "Simulation.calculate does not keep its input records in step " + "with storage. Add it there before asking whether it was supplied." + ) + + def _visible_branch_names(simulation) -> List[str]: """This branch, then the branches it was made from, then the default.""" names = [] @@ -28,9 +54,29 @@ def _visible_branch_names(simulation) -> List[str]: return names +def drop_missing_supplied_inputs(simulation, variable_name: str) -> None: + """Forget each record of ``variable_name`` as an input, on this branch + or one it was made from, whose stored value has been deleted.""" + input_keys = getattr(simulation, "_user_input_keys", None) + if not input_keys: + return + branch_names = set(_visible_branch_names(simulation)) + holder = simulation.get_holder(variable_name) + input_keys.difference_update( + { + (name, branch_name, period) + for name, branch_name, period in input_keys + if name == variable_name + and branch_name in branch_names + and holder._get_array_from_storage(period, branch_name) is None + } + ) + + def supplied_input_periods(population, variable_name: str) -> List[Period]: """Periods for which ``variable_name`` was supplied as an input, on this branch or one it was made from, earliest first.""" + _check_registered(variable_name) simulation = population.simulation branch_names = set(_visible_branch_names(simulation)) input_keys = getattr(simulation, "_user_input_keys", None) or () @@ -54,6 +100,7 @@ def supplied_input( input, on this branch or the nearest one it was made from; otherwise None. A value the engine carried over from an earlier period, or cached after a calculation, is not an input.""" + _check_registered(variable_name) simulation = population.simulation input_keys = getattr(simulation, "_user_input_keys", None) or () holder = population.get_holder(variable_name) @@ -61,8 +108,8 @@ def supplied_input( if (variable_name, branch_name, period) in input_keys: # Read the stored input itself: Holder.get_array would fall back # to other branches' values, including cached calculations. - # Core 3.32.9's Holder.delete_arrays leaves provenance keys behind; - # a missing stored array must be ignored even if its key remains. + # Holder.delete_arrays leaves provenance keys behind; a missing + # stored array must be ignored even if its key remains. value = holder._get_array_from_storage(period, branch_name) if value is not None: # A neutralized variable reads as its default, as it does