Skip to content

UK: company cars from the FRS job table, and policyengine-uk 2.165.0 (dashboard #316) - #1430

Draft
juaristi22 wants to merge 12 commits into
mainfrom
uk-company-cars-316
Draft

juaristi22 wants to merge 12 commits into
mainfrom
uk-company-cars-316

Conversation

@juaristi22

Copy link
Copy Markdown
Collaborator

What and why

Budget 2026 raises every company car appropriate percentage by one point in 2030-31: Table 6.1 row 86, +£310m in 2030-31 and +£345m in 2031-32 (OBR split: income tax 226.2, NICs 92.4). The dashboard can't score it (PolicyEngine/uk-autumn-budget-dashboard-2026#316), because neither the engine nor the data had company cars.

Without the stage, a file on 2.165.0 still scores the rise at £0.

Status: draft, CI expected red

Three things are still to come:

  1. The HMRC facts. They come from Add HMRC benefits in kind statistics, July 2026, and keep one OBR EFO number per line and year chronicle#399 (HMRC Benefits in kind statistics, July 2026: 544 facts, raw workbook in R2). Once it merges, this PR:

    • re-pins the feed (docs/uk-chronicle-feed-repin.md);
    • vendors uk/hmrc_benefits_in_kind_facts.json and adds its country_package.json entry.

    Until then, every test that builds the UK graph fails on the missing resource, and so do the stage-plan tests.

  2. The regenerations. The H2 fixture and the coverage manifest are regenerated once the facts are vendored; until then their parity and preflight tests fail.

  3. The build. A pair build on 2.165.0 against a control on the same base, with the results below. The machine order is María's.

Merge order

Changes

  1. policyengine-uk 2.165.0.

    • The lock, the floors, the version mentions and the US worker identity's lock digest.
    • The uprating and bonus-sacrifice pins keep every value.
    • Concept coverage gains the nine company car inputs.
    • 2.164.3 also counts other_investment_income in market and HBAI income, which the spine fills (UK: data fixes for the Budget 2026 household measures #1418), so HBAI-based outputs move too.
  2. The FRS job tab's company car answers. The root stage carries four columns per person from their car job with the lowest JOBTYPE (one car per person, as in the engine):

    • the CARVAL band, or "unknown";
    • FUELTYP, or "unknown";
    • EXPBEN03 (private fuel);
    • CARAMT where CARCON says the employee contributed.

    They are dropped at the release boundary.

  3. The company_cars stage, the last in the roster, after isa_subscriptions, whose income cells read employment_income. Nothing reads the pay it rewrites. Every draw is keyed on identity.

    • Holders. FRS-channel people keep their car. An SPI-channel person keeps their twin's only where their drawn EPB is positive.
    • Fuel. From FUELTYP: a hybrid is an alternative fuel; a diesel meets RDE2. An unknown fuel draws at HMRC Table 2C's shares.
    • List price. Drawn within the CARVAL band from WAS round-8 company cars' list prices, grown by the engine's CPI index from 2021. The open and unknown bands draw by fuel class. A cell with fewer than 3 WAS households (the floor of 3) is drawn uniformly.
    • CO2. Zero for an electric car. For the others, HMRC Table 2A's shares among cars that emit CO2, hybrids first into 1 to 50 g/km, uniformly within each band; a car at 1 to 50 g/km takes a 50-mile electric range.
    • Defaults kept. Availability, payments for private use and the 2020 registration flag keep the engine's defaults.
  4. SPI-channel pay. It is the tape's PAY + EPB + TAXTERM, so the engine's car and fuel benefits come out of employment_income, capped at EPB (María's ruling of 30 October):

    • private fuel goes first where its benefit alone exceeds EPB;
    • a car that still doesn't fit has its list price scaled down, with its contributions dropped;
    • below £5,000 the car is dropped.

    Pay plus the benefits is the tape's total on every such row.

  5. Targets. hmrc.spi.employment_income.amount_by_total_income_band and the local hmrc.employment_income.amount read employment_income_with_benefits_in_kind_annual, a new named term for employment income plus benefits_in_kind, which HMRC's employment income counts. The two count rows keep employment_income_annual.

  6. The facts. hmrc_company_car_statistics.py reads Full eCPS input coverage: 158 required columns, 8 evidence-backed exclusions #399's 2024-25 rows by their source record ids, in persons and pounds:

    • a cell the feed lacks is refused, never read as zero;
    • a resource whose Table 2A or 2C total, or electric count, sits from Table 1 beyond each sheet's rounding is refused too.
  7. Gates. uk_stage_company_cars_invariants is release-blocking and runs on the synthetic smoke too. It fails on:

    • a non-finite or negative value;
    • a holder without a price;
    • inputs on a person without a car;
    • an unknown fuel;
    • a CO2 figure missing for a car that emits it, or set for an electric car;
    • private fuel on an electric car;
    • contributions above the price;
    • an electric range outside 1 to 50 g/km;
    • an SPI benefit above EPB, or SPI pay off the tape's total.
  8. Measured, not bound. No HMRC company car fact is a calibration target: the stage draws to Tables 2A and 2C, so binding them would be circular (María's ruling of 28 October on the ISA facts). The calibrated file is reported against Tables 1, 3A and 4.

Results

PENDING the pair build. Every figure is checked for disclosure before it goes in.

  • Holders against HMRC's 920,000 (2024-25 provisional), and by income range against Table 3A.
  • Car and fuel taxable value, income tax and Class 1A against Table 1 (£3,070m, £1,090m, £420m).
  • The static 2030-31 cost of COMPANY_CAR_TAX_RISE_2030 against Table 6.1 row 86 (+£310m).
  • Gate and fit movement: the re-pointed SPI rows, obr.income_tax, obr.ni_employer (Class 1A), and HBAI income (2.164.3).

Tests

  • New:
    • test_uk_company_cars.py, engine-free and engine (22 tests);
    • test_uk_company_cars_stage_health.py;
    • the carrier tests in test_uk_frs_spine.py;
    • the named term in test_uk_measure_simulation.py.
  • The engine tests on 2.165.0 show that:
    • the engine reads the stage's inputs;
    • SPI pay plus the engine's benefits is the tape's total to the penny;
    • the 2030-31 rise reaches the holders alone.
  • The engine-free suite, run locally in chunks. Everything passes except the failures listed under Status, plus two local-only ones:
    • the uk-extra-absent test, since this environment installs the extra;
    • a live release-pointer test.
  • After vendoring: the full suite, tools/run_integration_tests.sh and the H2 parity test.

Disclosure

The FRS and WAS are UK Data Service data (SN 9563, SN 7215). The stage's receipt carries weighted aggregates, rounded counts and support bands only. The HMRC figures quoted here are published.

Not in this PR

  • Class 1A is not passed through to households (Pass Class 1A NICs through the employer NI incidence policyengine-uk#2752). The 2030-31 rise is scored statically, comparable with the OBR's costing.
  • Vans, fuel provided for part of the year, optional remuneration arrangements, and the rest of the "not modelled" list in policyengine-uk#2750.
  • Whether to anchor holders to Table 3A by income range, if the FRS count falls short of HMRC's. That is María's call after the results.
  • The dashboard measure: policyengine.py certifies a build on ≥2.165.0, then the dashboard adopts the release and adds the measure.

🤖 Generated with Claude Code

juaristi22 and others added 12 commits October 30, 2026 13:40
2.165.0 (PolicyEngine/policyengine-uk#2750) models the company car and
car fuel benefits in kind, and the Class 1A NICs on them, from nine person
inputs that default to no car. The lock moves from #1421's 2.164.2 so the
company_cars stage can write them
(PolicyEngine/uk-autumn-budget-dashboard-2026#316).

The uprating and bonus-sacrifice pins keep every value. Concept coverage
gains the nine inputs, and the liquid-assets reason and its test name the
two GBP stocks among them. The coverage manifest and the H2 spine fixture
are regenerated with the stage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first piece of the company car work for
PolicyEngine/uk-autumn-budget-dashboard-2026#316, not yet declared in the
manifest. policyengine-uk 2.165.0 taxes the company car and car fuel
benefits from nine person inputs that default to no car.

- uk_runtime/company_cars.py writes six of them for each employee the FRS
  job record gives a company car (the root stage's carriers, which
  SPI-channel rows inherit from their twins):
  - fuel from the FRS code (a hybrid is an alternative fuel, a diesel meets
    RDE2 as declared), and an unknown fuel at Table 2C's shares;
  - list price within the respondent's band from WAS round-8 company cars'
    prices grown by the engine's CPI index, the open and unknown bands by
    fuel class, and a uniform draw where a cell has too few donor
    households;
  - CO2 at Table 2A's shares among cars that emit it, hybrids first into
    1 to 50 g/km, which takes the declared electric range;
  - private fuel and capital contributions from the job record.
  The engine's defaults stay for availability, private-use payments and
  the 2020 registration flag, which no survey records.
- On SPI-channel rows, whose pay is the tape's PAY + EPB + TAXTERM, a car
  is kept only where EPB is positive, and the engine's car and fuel
  benefits come out of employment_income, capped at EPB: fuel goes first
  where it alone exceeds EPB, and a car that still does not fit has its
  price scaled down (its contributions dropped), or is dropped below the
  declared minimum price (María's ruling of 30 October 2026).
- uk_runtime/hmrc_company_car_statistics.py types HMRC's Benefits in kind
  statistics (July 2026): the Table 2A and 2C margins the stage draws to
  and the Table 1 totals it is measured against. The vendored resource
  waits for the Chronicle package; until then the loader names that.
- Engine-free tests cover the declaration checks, the donor, the draws'
  bounds and margins, identity keying and the carve-out arithmetic; the
  engine tests check that policyengine-uk 2.165.0 reads the inputs, that
  SPI pay plus its benefits is the tape's total, and that the 2030-31 rise
  reaches the holders alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The root stage now writes four carriers the company_cars stage reads to
set policyengine-uk 2.165.0's company car inputs
(PolicyEngine/uk-autumn-budget-dashboard-2026#316):
frs_company_car_price_band (CARVAL 1 to 9, or 10 for "Don't know" or
unanswered), frs_company_car_fuel_code (FUELTYP 1 to 5, or 6 likewise),
frs_company_car_private_fuel (EXPBEN03) and frs_company_car_contribution
(CARAMT where CARCON is 1, one total to date). A person's company car is
the one on their EXPBEN01 job with the lowest JOBTYPE, so a person has at
most one, as in the engine. The survey asks CARVAL and CARCON of a company
van too; they are taken as the car's.

SPI-channel rows inherit their FRS twin's carriers, as they do every root
column. The carriers are no engine inputs: they are declared in both
manifests, typed in the graph's root cells, and dropped at the release
boundary, and the H2 fixture tool's synthetic job tab gives two employees a
car (the fixture is regenerated with the stage).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The receipt's checks block counts the rows breaking an invariant any
population keeps, for the stage-health gate to require all zero: a
non-finite or negative value, a holder without a list price, an input
written for someone without a car, an unknown fuel type, a car that emits
CO2 without a figure, an electric car with one, private fuel on an electric
car, contributions above the list price, an electric range outside 1 to 50
g/km, and an SPI benefit above its EPB. FRS holders without pay are
reported, not failed: the benefit is taxable whatever the pay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
For PolicyEngine/uk-autumn-budget-dashboard-2026#316, before the stage is
declared:

- gates.json: uk_stage_company_cars_invariants (stage_health, transferred,
  release-blocking, no population fact check, so the synthetic smoke
  build blocks on it too) fails on any invariant break the receipt counts
  and on SPI-channel pay plus its carved benefits off the tape's total by
  more than maximum_pay_identity_error_gbp. stage_health gains the
  company_cars check; the spine gate scope, the battery's parameter keys,
  the microcosm-data contract mirrors and every digest the edit moves
  follow (the battery's policy, manifest and fingerprint, the spine and
  release-cut part digests).
- The export surface allows the six person inputs the stage writes, in
  both allow-lists in the same order.
- The source manifest's operation kinds and the sources schema gain the
  stage's six kinds, each a closed branch.
- The stage reads its carriers' names from frs_spine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
For PolicyEngine/uk-autumn-budget-dashboard-2026#316:

- uk/spec/sources.yaml, and source_stages.json (its typed projection),
  declare company_cars as the last stage, after isa_subscriptions, whose
  income cells read employment_income: nothing then reads the SPI-channel
  pay the stage rewrites. It reads the vendored HMRC benefits-in-kind facts
  and the WAS round-8 person tab, and has eight operations (the facts
  check, the donor, fuel, list prices, CO2, the SPI carve-out, the mass
  receipt and the reviewed fence). It writes six non-negative person
  inputs and rewrites employment_income.
- Graph and build wiring: the stage's WAS person source, an open read
  surface (one engine materialization over the frame), its typed cells,
  and a reader-isolation boundary, since it rewrites a cell the
  open-surface readers before it bind; the kernel binds its module and
  helpers and its fixture implementation; the spine build gets its
  implementation, its WAS person input and its identity-keyed seeds.
- The stage's output constants follow the manifest (the six inputs and
  the rewritten pay).
- Fixtures and tests: the H2 fixture tool's WAS persons carry company
  vehicles and the household weight, and its stage count is 51; the
  roster tests follow, and isa_subscriptions is no longer the last stage
  in its notes and test.
- docs/uk-company-cars-316.md and the changelog fragment.

The graph tests need the vendored facts resource, which waits for the
Chronicle package: until it is vendored they fail on the missing
hmrc_benefits_in_kind_facts.json, and the H2 fixture and coverage manifest
are regenerated with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HMRC's employment income counts taxable benefits in kind, which
policyengine-uk 2.165.0 keeps out of employment_income. SPI-channel pay is
the tape's PAY + EPB + TAXTERM, from which the company_cars stage now
carves the company car and car fuel benefits, so the rows on HMRC's
employment income would lose them (María's ruling of 30 October 2026,
PolicyEngine/uk-autumn-budget-dashboard-2026#316).

- A named term, employment_income_with_benefits_in_kind
  (employment_income + benefits_in_kind), and its annual measure on the
  annual income simulation.
- hmrc.spi.employment_income.amount_by_total_income_band and the local
  hmrc.employment_income.amount read it: on SPI-channel rows that is the
  tape's total as before the carve-out; on FRS rows, pay plus the car
  benefits. Their notes say so. The two count rows keep filtering on
  employment_income_annual, which only asks whether a person has pay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pany_cars

The local hmrc.employment_income.amount row now reads pay plus
benefits_in_kind, so the stub engine the local metric tests run on
declares it (zero for everyone, so no expected figure moves). The
private-school attendance stage is followed by isa_subscriptions and now
company_cars, neither of which writes its flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A list price band now draws from the WAS round-8 donor cars when at least
3 households carry them, the floor María set for every UK output on 29
October 2026 and CD171's absolute floor, instead of 10. The stage still
refuses a declared minimum below 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The loader reads the 2024-25 rows of hmrc-benefits-in-kind-july-2026
(PolicyEngine/chronicle#399) by their source record ids,
<record set>.<row>.<measure>, in persons and pounds as Chronicle carries
them: Table 1's car and fuel recipients, taxable value, income tax and
Class 1A liabilities, Table 2A's twenty CO2 bands, Table 2C's three fuels,
and Tables 3A and 4 by income range and region. Chronicle emits no fact
for a cell HMRC marks as suppressed, so a cell the stage needs and the
feed lacks is refused, never read as zero, and a resource whose Table 2A
or 2C total, or electric count, sits from Table 1 beyond the sheets'
rounding is refused too.

The band and fuel ids are Chronicle's row ids (co2_0 ... co2_170_plus;
diesel_including_hybrids, petrol_including_hybrids, fully_electric), and
the stage's verify_vendored_fact_resource declaration names the twelve
2024-25 record sets. The test support builds a synthetic vendored payload
and types it with the same loader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vahid-ahmadi

Copy link
Copy Markdown
Contributor

Review of 21e3623 (draft, so an early look as a comment). First round; there are no earlier comments.

The stage writes what policyengine-uk 2.165.0 expects. I checked the nine inputs at the 2.165.0 tag. The six written ones have the right units, periods and enum names (GBP stocks uprated by CPI, g/km, miles, CarFuelType names, bool). The draws are keyed on identity, there is one car per person by lowest JOBTYPE, household weights pass through, and no company car fact is bound. The stage is the last spine stage, so calibration still runs after it: it adds no weights, but the solve then moves holders, which fits "measured, not bound". There are four shoulds and one nit.

Findings

1. should: on a frozen fleet, expect the static 2030-31 score well under £310m. The gap comes from the size of the base, not the EV share.

  • The rise is one point in every band, zero-emission and the maximum included. So the extra taxable value is about 1% of total list price (less contributions), plus 1% of the fuel multiplier for each fuel-benefit holder. The frozen EV share barely moves the rise. It moves the baseline level instead (EVs go from 3% to 9% by 2029-30).
  • The base the rise acts on is the build year's fleet, carried forward to 2030-31. Fuel type and CO2 are not uprated, list price and contributions rise by CPI, and the weights are not projected.
  • Rough HMRC-implied base for 2024-25: Table 1 car taxable value £3,070m over Table 2A's band-weighted 2024-25 appropriate percentages gives about £27bn to £37bn of list price (depending on PHEV range and EV price premium). With CPI to 2030-31 and 40,000 fuel holders, that is about £330m to £440m of extra taxable value. At HMRC's 2024-25 ratios (income tax 35.5% of taxable value, Class 1A 15%), that is roughly £170m to £225m.
  • The OBR's NICs line (£92.4m / 15%) implies about £616m of extra taxable value, roughly a £60bn base. Its income tax line gives the same 36.7% ratio. So even a file that hits HMRC's 920,000 holders and £3,070m exactly should come in around 30 to 45% short. Recipients rose from 840,000 to 920,000 in one year, and HMRC flags under-reporting of payrolled cars.
  • Matching Table 1's taxable value does not pin this base: a file can hit £3,070m with a different EV mix and different prices. Suggest the receipt add weighted total list price (by electric and not), so Results can compare it with the HMRC-implied range and read the £310m gap as base size.

2. should: the SPI pay-identity check in the release-blocking gate can't fail.
company_cars.py:983-987 sets new_pay = pay - carve.benefit, then measures |new_pay + carve.benefit - pay|, so max_abs_pay_plus_benefit_error_gbp is zero by construction. That means uk_stage_company_cars_invariants (stage_health.py:4954-4960) never tests the claim in its notes. The real risk is the engine's benefit on the written inputs differing from the carved amount. The price scaling assumes the defaults for availability and private-use payments, and that contributions were dropped, so a later engine change would break it without the gate noticing. Suggest one more materialize of company_car_benefit + car_fuel_benefit on the output frame, comparing each SPI-channel row with carved.

3. should: one CI failure is not on the body's expected-red list.
engine-uk fails on tests/engine/uk/test_uk_annual_income_measures.py:156: set(receipt["computed_measures"]) == set(UK_ANNUAL_INCOME_MEASURES). The test never computes the new employment_income_with_benefits_in_kind_annual. This is independent of chronicle#399. Computing it there, and asserting it equals employment_income_annual when nobody has a car, would fix it. The job stops at this first failure ("1 failed" and nothing passed), so CI has not run the new engine tests in test_uk_company_cars.py yet.

4. should: the lock step pulls in more than the body lists.
2.164.2 → 2.165.0 includes 2.164.3 (other_investment_income in market_income, household_market_income and hbai_household_net_income) and 2.164.4 (also in capital_income). The body mentions only HBAI outputs. These engine values are stage predictors:

  • was_wealth materializes household_net_income and capital_income (was_wealth.py:63-89);
  • was_lisa reads household_net_income (was_lisa.py:164);
  • lcfs_consumption and etb_services use hbai_household_net_income (lcfs_consumption.py:650, etb_services.py:62).

So the wealth, LISA, consumption and services draws can move for households with other investment income. Suggest adding these to Results' "gate and fit movement" list for the pair build. 2.165.0 also adds Class 1A to ni_employer, which the body already covers.

5. nit: unknown-fuel draws never produce a hybrid.
company_cars.py:588 draws only ELECTRIC, PETROL or diesel, and :920 flags hybrids from the FRS code alone. So "petrol/diesel including hybrids" from Table 2C never feeds the hybrids-first split into 1 to 50 g/km. The effect is small, but worth a sentence in the doc.

Checked and fine: the three defaults (availability 1, no private-use payments, the 2020 flag) only matter in 2020-22 or slightly raise each car's taxable value for part-year holders; the WAS donor (cars not vans, household weight, floor of 3); CARVAL/FUELTYP mapping and the open band; CO2 bands within Table 2A's bounds; the SPI carve-out arithmetic (fuel first, exact price scaling, £5,000 floor); the re-pointed SPI targets, since taxable_employment_income now includes benefits_in_kind, so total-income bands agree.

Verdict

Sound design. Items 2 and 3 are small code fixes. Item 1 is about setting expectations before the pair build, and item 4 is about what that build should report. Not ready until the #399 vendoring, the regenerations and the build, as the body says.

What I ran

Fresh clone at 21e3623 and uv sync --all-packages --locked (no UK engine extra), deleted afterwards; one process at a time.

  • Engine-free pytest: test_uk_company_cars.py and test_uk_company_cars_stage_health.py gave 24 passed. With test_uk_frs_spine.py, test_uk_measure_simulation.py, test_uk_source_stages.py, test_uk_terminal_gates.py, the ISA, local-target, private-school and stochastic tests, test_engine_core_pins.py, test_country_spec.py and microcosm-data's test_contract.py, 848 passed and 4 failed. All 4 are KeyError: 'hmrc_benefits_in_kind_facts.json', as expected.
  • I read the 2.165.0 variables, parameters and the company_car_tax_rise_2030 scenario at the tag, and the 2.164.2...2.165.0 compare.
  • CI: engine-free fails on the missing facts and integration-uk on the same resource (expected), engine-uk as in item 3; lint, wheels and engine-us pass.

Not verified: the engine tests (no UK engine locally, no build or Modal), any FRS or WAS figure, and the score itself. Item 1's figures are rough arithmetic from published HMRC and OBR numbers.

🤖 Generated with Claude Code

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants