Skip to content

UK: company cars from the FRS job table (dashboard #316) - #1430

Merged
juaristi22 merged 38 commits into
mainfrom
uk-company-cars-316
Nov 2, 2026
Merged

juaristi22 merged 38 commits into
mainfrom
uk-company-cars-316

Conversation

@juaristi22

@juaristi22 juaristi22 commented Oct 30, 2026 •

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

  1. The HMRC facts are in. The feed is re-pinned to 08962f1, the merge of Add HMRC benefits in kind statistics, July 2026, and keep one OBR EFO number per line and year chronicle#399 on top of UK facts for chronicle#395: Northern Ireland domestic rates, Welsh bus journeys, the Childcare Offer for Wales, VED by payer and CPI gas weights chronicle#396 (1,512,256 rows; the schema is unchanged). uk/hmrc_benefits_in_kind_facts.json vendors the 96 2024-25 rows the stage reads, and is registered in country_package.json.
    • The other 35 vendored resources, and the DESNZ constituency resource, carry the same values under the new identity.
    • The local surface is unchanged apart from its feed label and the DESNZ keys.
    • The coverage manifest and the H2 fixture are regenerated.
  2. The OBR windows read both editions. Full eCPS input coverage: 158 required columns, 8 evidence-backed exclusions #399 keeps one OBR EFO number per line and year (OBR EFO March 2026: drop the years the October 2026 vintage also publishes (PolicyEngine/microcosm#1095) chronicle#397), so March 2026 now carries only FY2024-25 and October 2026 carries FY2025-26 on. The 19 OBR targets that window both years into calendar 2025 could no longer compile, since their window took both years from one series. A new selector key, source_table_by_opening_year, takes FY2024-25 from March's table and FY2025-26 from October's (María's ruling of 31 October).
  3. UK: take up #1095's actionable items on policyengine-uk 2.165.1 #1422 is in (11c1f4e), and main is merged here, so the diff is UK: company cars from the FRS job table (dashboard #316) #1430's own. This branch builds on UK: take up #1095's actionable items on policyengine-uk 2.165.1 #1422's policyengine-uk 2.165.1 lock, its targets and its gates.
    • Every shared surface is regenerated on 08962f1: the vendored resources (byte-identical), the national and local references, memberships and compile-parity receipts, the coverage manifest, the H2 fixture and the concept coverage.
    • On 08962f1, UK: take up #1095's actionable items on policyengine-uk 2.165.1 #1422's three OBR council tax nation rows take the same edition map as the other windowed OBR rows, and so does the signed-out obr.fuel_duties: 23 rows in all.
  4. The reviews. The independent review (issuecomment-6545997069) is addressed, with María's rulings of 31 October on items 2, 3, 7 and 10, and so are the second (issuecomment-6561698051) and third (issuecomment-6564905160).
  5. The build. A pair build on 2.165.1 against a control on the same base follows the merge, and its results are added below. The machine order is María's.

Merge order

Changes

  1. policyengine-uk 2.165.1, UK: take up #1095's actionable items on policyengine-uk 2.165.1 #1422's lock. 2.165.0 (Model the company car and car fuel benefits, with Class 1A NICs policyengine-uk#2750) brings the 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 runs right after the SPI income stage (hmrc_spi_income_spine; María's ruling of 31 October). Every later stage reads the tape rows' pay without the car benefits, as the engine's employment_income excludes them: salary_sacrifice and its bounds, the take-up draws' earnings rules and the CGT and ISA stages' income proxy. Every draw is keyed on identity. The stage fits no model and declares no fit-weight records.

    • Holders. FRS-channel people keep their car. A row drawn from the SPI tape (person_is_spi_income_recipient) keeps its twin's only where its drawn EPB is positive. The channel's undrawn copies and dependants keep their twin's pay and car, as FRS rows do.
    • Fuel. From FUELTYP: a hybrid is an alternative fuel; a diesel meets RDE2. The unknown fuels draw at HMRC Table 2C's shares together: a holder who reports private fuel draws among petrol and diesel, and the others draw electricity at the share that keeps Table 2C's. The tape rows that lose their car draw at the holders' share.
    • 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, from no lower than £5,000.
    • 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. The tape rows' 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) within the pay the row's employee pension contribution leaves. The SPI income stage capped that contribution at the drawn pay, and salary_sacrifice refuses one above the pay that remains; a row whose contribution takes all its pay loses its car. The receipt measures what the room costs beside a cap of EPB within pay alone: the cars it scales further or drops, and the list price they give up.

    • Where the car and fuel benefits don't fit together, the fuel goes first, so a car that fits without it is kept (María's ruling of 31 October).
    • A car that still doesn't fit has its list price scaled down, with its contributions dropped. A scaled price is the room over the car's appropriate percentage, so the CO2 draw, the 50-mile range and the RDE2 diesel move the 2030-31 base through these cars.
    • Below £5,000 the car is dropped.

    The stage then has the engine price its output again: on every tape row, pay plus the engine's benefits must equal the pay the row brought in. A carved row's UC gainful self-employment, which the SPI income stage set on the drawn pay, takes the root's rule again on the carved pay. Employer contributions stay as the SPI income stage set them on the drawn pay, as on every tape row that draws EPB.

  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 filter on the same term, so a tape row whose pay the carve takes to nothing still counts.
    • The annual simulation makes a car available for the share of the year the pay covers.
    • obr.ni_employer binds ni_class_1_employer. OBR's line is Class 1 employer NICs (table 4.4 row 18), and Class 1A sits in row 21.
  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 on a car outside 1 to 50 g/km;
    • an SPI benefit above EPB;
    • a tape row's employee pension contribution above its carved pay;
    • a tape row's pay plus the engine's benefits, priced again on the output, more than 5p from the pay the row brought in (single precision).
  8. 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). Comparing the calibrated file with Tables 1, 3A and 4 is a manual step for now.

Results

PENDING the pair build, which follows the merge. 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 list price base, electric and not, measured on the calibrated file. The stage's receipt (list_price.weighted_total_gbp) is at its pre-calibration weights. Two published comparators frame it: HMRC's 2024-25 taxable value implies roughly £27bn to £37bn, and the OBR's NICs line about £60bn.
  • What the base loses before calibration, from the receipt: the holders and list price the tape rows lose for want of EPB (holders.spi_dropped_without_epb; María's ruling of 31 October: measure first), and the list price the carve-out scales away or drops, including what the employee contribution's room takes.
  • The static 2030-31 cost of COMPANY_CAR_TAX_RISE_2030 against Table 6.1 row 86 (+£310m). Expect it well under £310m, from the size of the base (docs/uk-company-cars-316.md, "What to expect of the 2030-31 score"). The file holds the build year's fleet: fuel and CO2 are frozen, prices grow by CPI, and the weights are not projected. A file that matches Table 1 exactly should still fall about 30 to 45% short.
  • Gate and fit movement:
    • the re-pointed SPI rows and obr.income_tax. obr.ni_employer now measures Class 1 alone, but it is held out of the objective as a diagnostic until 2027-01-16, so the fit doesn't move with it;
    • HBAI income. 2.164.3 adds other_investment_income to market_income, household_market_income and hbai_household_net_income, and 2.164.4 to capital_income. Those feed stage predictors, so the draws below can move for households with other investment income:
      • was_wealth, which materializes household_net_income and capital_income;
      • was_lisa (household_net_income);
      • lcfs_consumption and etb_services (hbai_household_net_income).

Tests

  • New:
    • test_uk_company_cars.py, engine-free and engine. It covers the reviews' cases:
      • fuel first, the private-fuel draw, the unknown fuels meeting Table 2C together, and the lost cars drawing at the holders' share;
      • band 1's floor, the EPB cap, and the room for the employee pension contribution with what it costs;
      • salary_sacrifice run on the stage's output;
      • the UC gainful self-employment refresh, in the helper and through the stage, and the fit-weight evidence;
      • a declaration naming the old unknown-fuel rule refused;
      • the fence count;
      • the receipts for the EPB shortfall, the scaling and the output's CO2 shares, against the draw's;
    • test_uk_company_cars_stage_health.py;
    • the carrier tests and the code-view test in test_uk_frs_spine.py;
    • the named term and the annual car availability in test_uk_measure_simulation.py;
    • the ni_class_1_employer binding and its exclusion in test_uk_national_insurance_targets.py;
    • every HMRC employment income row reading the benefit-in-kind term in test_uk_population_targets.py.
  • The UK engine tests on 2.165.1 pass: company cars, coverage, the annual measures, concept mapping, the graph and the H2 parity, 75 tests. They show that:
    • the engine reads the stage's inputs;
    • a tape row's pay plus the engine's benefits is the tape's total to the penny;
    • the 2030-31 rise reaches the holders alone.
  • After the third review, on main with UK: take up #1095's actionable items on policyengine-uk 2.165.1 #1422 (head de41aba):
    • the 27 touched engine-free files pass: 1,442 tests, 8 skipped;
    • the coverage manifest and the H2 fixture are regenerated for the renamed fuel rule.
  • After the second review, which last touched the targets and the feed:
    • the feed-gated files, one process each against the pinned feed (CI skips them): the 15 other than bus targets, 239 passed and 2 skipped (the Scotland support and the authenticated national artifact aren't mounted here);
    • the generated surfaces are regenerated from the feed's cuts. The national compile-parity receipt, the membership's fuel duties entry, the coverage manifest and the H2 fixture changed as the fixes predict. The national references and the local receipt regenerated byte-identical.
  • The full engine-free suite, run locally in chunks on the merge with UK: take up #1095's actionable items on policyengine-uk 2.165.1 #1422's final head (4af602d): 14,487 tests, of which 14,332 pass and 152 skip. Three local-only checks fail: the uk-extra-absent test (this environment installs the extra), a live release-pointer test, and the orrery check, whose Node package isn't installed here. CI runs the full suite on every push.
  • Before UK: take up #1095's actionable items on policyengine-uk 2.165.1 #1422's third review, all 16 feed-gated files passed (252 tests, bus targets included), as did the national compile-parity regeneration on the full pinned artifact, with its release gate.
  • tools/run_integration_tests.sh was not run locally; CI's integration-uk runs it.

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.
  • A build step that compares the calibrated file with HMRC's Tables 1, 3A and 4; for now it is manual.
  • Employer pension contributions on a tape row's pay without EPB: the SPI income stage sets them on PAY + EPB + TAXTERM for every tape row, car or not.
  • The dashboard measure: policyengine.py certifies a build on ≥2.165.1, 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

juaristi22 and others added 6 commits October 31, 2026 01:02
The annual measures receipt lists every measure the test computes, and the
test never computed employment_income_with_benefits_in_kind_annual, so
engine-uk failed on the set comparison. With nobody holding a company car,
the measure is the year's pay.

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

- The stage prices its output again on the engine. On every SPI-channel
  row, pay plus the engine's car and fuel benefits must be the pay the
  row brought in. The receipt records the largest miss rounded up to the
  penny, and uk_stage_company_cars_invariants allows 5p for the engine's
  single precision. The old check compared the carve-out with itself, so
  it could not fail.
- The carve-out's budget also keeps the pay the row's pension deductions
  need. salary_sacrifice bounds employee contributions and salary
  sacrifice by pay and bonus sacrifice by half of it, and a later pay
  rewrite must hold those bounds.
- The receipt records the weighted total list price of electric cars and
  of the others: the base a one-point rise acts on.
- Every weighted figure in the receipt is withheld when a cell it rests
  on has fewer distinct source households than the floor. Shares that sum
  to one are withheld together. Invariant counts are banded.
- docs/uk-company-cars-316.md says what to expect of the 2030-31 score,
  and that an unknown fuel never draws a hybrid.
- The gate battery and certification digests are re-pinned for the
  gates.json change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…benefits in kind facts

The UK Chronicle feed moves to 08962f1, the merge of
PolicyEngine/chronicle#399, on top of 7d5d627, the merge of
PolicyEngine/chronicle#396. It has 1,512,256 rows: 53815b2's 1,511,063,
plus #396's 1,076 UK facts from PolicyEngine/chronicle#395 and #399's 544
benefits in kind facts, less 427 March EFO record sets. The schema digest
is unchanged. The merge commit's tree is the pull request head's
(ac9d7bb), so the artifact was built from that head's bundle and carries
the merge commit's identity.

- Vendored resources:
  - a new hmrc_benefits_in_kind_facts.json (96 rows in six selections),
    registered in the country package: the 2024-25 rows the company cars
    stage reads;
  - the 35 other vendored resources keep their rows under the new feed
    identity;
  - the DESNZ constituency resource keeps its 2,526 values, under keys
    re-derived from the new identity.
- obr.winter_fuel_allowance loses its pin to March's table. #399 keeps
  one OBR EFO number per line and year (PolicyEngine/chronicle#397), so
  FY2025-26 is October's outturn, 0.02% above the March projection.
- The local surface was regenerated from a cut of the artifact by the
  200bf6d rules. The references and the compile-parity receipt are
  byte-identical, the membership moves its feed label and the 1,263
  DESNZ keys, and the census restates the pin.
- The coverage manifest is regenerated. It also catches up with the
  company_cars stage.
- The H2 fixture is regenerated. The fixture tool runs company_cars in
  its legacy plan and audits its fuel type as a string column.

The national references, membership and compile-parity receipt follow in
the next commit. The 19 targets that window FY2024-25 and FY2025-26 into
calendar 2025 cannot compile on this feed: their window takes both years
from one series, and no EFO edition now carries both.

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

- The spine graph test counts 51 manifest stages.
- The acceptance receipt's pending transformations append company_cars
  after isa_subscriptions, as the production plan runs it.
- The facts loader drops its pre-vendoring guard, as the ISA loader reads
  its resource, and its test now loads hmrc_benefits_in_kind_facts.json
  against the feed pin and checks Table 1 and 2C's 2024-25 values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PolicyEngine/chronicle#397 keeps one OBR EFO number per line and year, the
latest edition's, so the March 2026 tables carry FY2024-25 and the October
2026 tables FY2025-26. Each edition's record sets are their own series, so
calendar_year_window found no series with both years.

A new selector key, source_table_by_opening_year, maps each opening year to
the table that carries it, as source_measure_id_by_opening_year does for
measures:

- a fact matches only the table its own year declares;
- the record set drops out of the series identity, so the mapped tables
  form the window's one series;
- a list-valued source_concept still names several series, each read
  across the tables;
- the selector cannot also declare source_table or the measure map;
- calendar_year_window_scaled_by_ratio reads its window the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
María's ruling of 31 October: the 19 OBR targets that window FY2024-25 and
FY2025-26 into calendar 2025 take FY2024-25 from the March 2026 EFO and
FY2025-26 from the October 2026 EFO, each year's latest edition after
PolicyEngine/chronicle#397. Their selectors declare
source_table_by_opening_year, and each note says so.

- Fifteen of the 19 move by under 1%: income tax -0.5% and UC +0.7%. The
  four larger moves are domestic rates +3.2%, Pension Credit +2.3%,
  Jobseeker's Allowance -2.5% and SDLT -1.2%.
- Three report-only diagnostics pinned March's FY2025-26 forecasts, which
  the feed no longer carries: the OBR's capital gains tax receipts, State
  Pension spending and total NICs. They pin October's outturns
  (observation, observed_only), with their notes restated. The NICs total
  now sits 2.1% above the class rows at the window.
- The test feed fixture's captured March rows give way to the feed's
  current ones: October's FY2025-26 income tax, incapacity benefits and
  capital gains tax, and March's FY2024-25 income tax in its v4 form. The
  national calibration tests' synthetic window facts carry each year's
  table.
- Regenerated from the 08962f1 national cut: the national references and
  membership, and the national compile-parity receipt. The receipt's OBR
  rationales name the two editions, and Winter Fuel, which binds FY2025-26
  alone, has a rationale of its own.
- The membership also catches up with policyengine-uk 2.164.1's October-EFO
  indices, which #1421 locked after #1420 had generated it: the eight SPI
  Table 3.10 self-employment bands move -0.55% and the thirteen dividend
  bands under 0.001%. The receipt already had these values.
- docs/uk-income-anchors-280.md describes the map, the re-pin runbook gains
  the 08962f1 entry, and the changelog fragment is complete.

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

Copy link
Copy Markdown
Collaborator Author

Thanks, Vahid. All five items are addressed. Item 2 led to one more fix, and to a disclosure pass on the stage's receipt.

1. The 2030-31 expectation and the list price base.

  • The receipt now records list_price.weighted_total_gbp for electric cars and for the others, each with its support band.
  • docs/uk-company-cars-316.md has a new section, "What to expect of the 2030-31 score". It says why the rise acts on list price, why the frozen electric share moves the baseline but not the rise, and why the static cost should come in well under £310m: the OBR's NICs line implies a base of about £60bn, against roughly half of that from HMRC's 2024-25 taxable value. It credits your arithmetic for the 30 to 45% range.
  • The body's Results now compare the receipt's base with that range, so the gap reads as base size.

2. The pay identity check couldn't fail.

  • The stage now prices its output again on the engine. On every SPI-channel row, pay plus the engine's company_car_benefit and car_fuel_benefit on the written inputs must equal the pay the row brought into the stage.
  • The receipt records the largest miss as spi_carve_out.max_abs_pay_identity_error_gbp, rounded up to the penny. uk_stage_company_cars_invariants allows 5p, for the engine's single precision (maximum_pay_identity_error_gbp: 0.05). The battery and certification digests are re-pinned for that gates.json change.
  • A new test runs the stage with an engine stub whose car benefit ignores the list price. The check reads a miss above £1,000, so it fails as it should.
  • While doing this I found one more thing to fix. salary_sacrifice keeps employee contributions and salary sacrifice within pay, and bonus sacrifice within half of it, and its comment asks any later pay rewrite to hold those bounds. The carve-out didn't. Its budget now also leaves the pay those deductions need, and the receipt counts the rows where that binds (limited_by_pension_deductions).
  • The disclosure pass covers every weighted figure in the receipt:
    • each one is withheld when a cell it rests on has fewer distinct source households than the floor, so support copies count once;
    • shares that sum to one are withheld together;
    • invariant counts are zero exact and banded otherwise.

3. The engine-uk failure. test_uk_annual_income_measures.py now computes employment_income_with_benefits_in_kind_annual and checks that it equals employment_income_annual when nobody has a car. Locally on 2.165.0, that file gives 4 passed and the company cars engine tests 3 passed.

4. The 2.164.3 and 2.164.4 predictor moves. They are in Results' gate and fit movement list:

  • was_wealth (household_net_income, capital_income);
  • was_lisa (household_net_income);
  • lcfs_consumption and etb_services (hbai_household_net_income).

5. Hybrids. The doc now says that an unknown fuel never draws a hybrid, since Table 2C counts hybrids inside petrol and diesel, and that the effect is small.

What I ran:

  • engine-free company cars and stage-health tests: 28 passed;
  • the engine tests above;
  • the contract, gate battery pin, country spec, release certification and terminal gate tests: 523 passed.

The chronicle#399 re-pin and the BiK vendoring are pushed alongside. So is the fix for the 19 OBR windows that #397 split across the March and October EFOs: they take FY2024-25 from March and FY2025-26 from October (María's ruling of 31 October). The body's Status says what each moved.

🤖 Generated with Claude Code

@juaristi22

Copy link
Copy Markdown
Collaborator Author

Independent review of #1430 at e67e76c29

This is an independent adversarial review. Four reviewers each took one area:

  • the stage;
  • facts and windows;
  • a cross-cutting pass;
  • conventions.

A verifier then reproduced each finding on synthetic data or published tables, and a last pass looked for gaps. Vahid's round isn't repeated. Nothing here uses licensed data. Paths are relative to packages/microcosm-build/src/microcosm/build/ unless they start with tools/ or docs/.

Verdict: two things to fix before the pair build (1 and 2), and two that will skew how its Results read (3 and 4).

Fix before the build

1. obr.ni_employer now counts Class 1A; OBR's line doesn't.

  • The row binds ni_employer (uk/uk_population_targets.json:2111). In policyengine-uk 2.165.0 that variable adds ni_class_1a, which is the rate × benefits_in_kind.
  • OBR's line is Class 1 employer NICs only. Class 1A sits in "Other NIC" (October EFO table 4.4, row 21, footnote).
  • Once this stage fills the car inputs, the measure gains Class 1A on them, about £420m in 2024-25 on HMRC Table 1, and the solve moves weight to absorb it.
  • Fix: bind ni_class_1_employer. The notes at :677 and :34829 still say no household carries Class 1A.

2. The carve-out breaks the identity-stability tool.

  • tools/verify_uk_identity_stability.py:2555 requires that pay plus salary sacrifice equal the pre-conversion pay exactly, on every converted record.
  • company_cars lowers SPI pay after salary_sacrifice has run, and keeps no carrier for the carved amount. salary_sacrifice converts SPI rows too (salary_sacrifice.py:658).
  • So a converted SPI person with a car makes --check e4, e5 and e8 raise. At full scale that overlap is near-certain.
  • e8 also re-bands CGT on pay the CGT stages never saw.

What the Results will measure

3. SPI copies keep a car only on an independent EPB draw (uk_runtime/company_cars.py:991).

  • A copy keeps its twin's car only where its drawn EPB is positive. EPB is drawn on identity-keyed uniforms that are never conditioned on the car (spi_rank_transport.py:123; spi_income.py:168-175).
  • The SPI channel holds half of the prior working-age mass (sources.yaml:1415-1416). So before calibration, holders and list price come to about (0.5 + 0.5p) of the FRS level, where p is the share of copies that draw a positive EPB.
  • No car target restores this.
  • The receipt gives only a rounded row count (spi_dropped_without_epb). Meanwhile the doc's "What to expect" puts the whole shortfall against £310m down to base size.
  • Suggestion: record a weighted figure for the dropped holders, or condition the keep on the twin; either would make this measurable.

4. Assumptions documented as "baseline-only" drive the 2030-31 base, through the price scaling (:918-925).

  • A scaled SPI car's price is the room ÷ the appropriate percentage at 2024-25 rates, and the 2030-31 rise is 1% of price.
  • On a £2,000 room and a £45k car, the scaled price is:
    • £25,000 at 8% (the fixed 50-mile range);
    • £14,286 at 14% (under 30 miles);
    • £45,000, unscaled, for an electric car at 2%;
    • £8,000 for an RDE2 diesel at 25%;
    • £6,897 at 29% for a diesel without RDE2.
  • That contradicts sources.yaml:6360 ("matters only to 2027-28") and docs/uk-company-cars-316.md:40-41 and :86-87.

5. The CO2 receipt predates the carve that skews the fleet (:1009-1017, :1147).

  • The carve drops or scales cars in inverse proportion to their appropriate percentage. So electric and low-CO2 cars survive, and high-CO2 cars drop first.
  • On synthetic data, the kept 1-50 g/km share rises from 0.39 to 0.50, and the electric share from 0.25 to 0.38.
  • The receipt reports the pre-carve shares, so "matches Table 2A" isn't true of the output.

6. The list-price base is read at pre-calibration weights (:1134).

  • list_price.weighted_total_gbp is at the stage's weights, which carry the SPI channel's prior 50%. Calibration moves holders after this point, and nothing measures list price on the calibrated file.
  • Yet the doc (:96-99) and the body plan to read it as the score's base.

Stage behaviour

7. Fuel-first ordering drops cars that would fit without fuel (:910-928).

  • With EPB of £9,000, an appropriate percentage of 29%, a £35k car and an £8,062 fuel benefit, nothing is kept. Without the fuel flag, the car is kept at £31,034.
  • This follows the 30 October ruling's wording literally, but the result isn't monotone.
  • Related: an unknown-fuel holder who reported private fuel draws electric at Table 2C's share (about 51%) and so loses that fuel (:1018).

8. Count rows drop SPI rows whose pay the carve takes to zero.

  • The employment-income counts filter on employment_income_annual != 0 (uk/uk_population_targets.json:3696, :31014), while the amounts read the new term.
  • An SPI row whose pay is all EPB can end with pay 0 and its car kept: carve_spi_benefits(epb=2000, pay=2000, retained=0, car=6000) does this. Such a row leaves the counts but stays in the amounts.
  • Suggestion: filter the counts on the new term.

9. The band-1 fallback draws prices from £0 (:197, :735).

  • With no band-1 WAS donors, prices are drawn uniformly on (0, £10,000]. About half fall below £5,000, the least price the carve treats as plausible.
  • A CARAMT capped at a near-zero price then zeroes the benefit.

10. Carving last leaves upstream pay rules unrestored (:188-192).

  • These were all drawn on pre-carve pay:
    • the bonus-sacrifice candidacy (pay ≥ UEL, salary_sacrifice.py:286);
    • UC take-up awards;
    • the Carer's Allowance earnings test;
    • the gainful self-employment flag.
  • So "nothing reads the pay it rewrites" holds only for later stages.
  • Suggestion: split the car part out of EPB inside the SPI income stage. That keeps these rules consistent and also clears item 2.

11. SPI-channel rows that aren't tape rows lose the twin's car (:538-543, :992).

  • Undrawn copies and dependants aged 16 to 19 keep their twin's pay with EPB 0, so no_epb drops the car.
  • spi_income documents person_is_spi_income_recipient for exactly this distinction, and property_components uses it.

Coverage and texts

12. company_cars has no coverage-manifest family.

  • Of the 15 stages that declare record_mass_conservation_receipt, it is the only one without a family_coverage entry. tools/build_uk_release_input_coverage_manifest.py:931 lists isa_subscriptions but not this stage.
  • So release_input_coverage.py never checks its mass record, nor requires it in the plan.

13. Small items:

  • the annual measure scales pay by employment_income_annual_share but not the car inputs, which puts a full-year benefit on part-year FRS pay (small);
  • the BIK fact fence's count is popped but never compared;
  • company_cars.py:196-216 re-declares FRS constants from frs_spine.py:957-963 (one of them unused), against docs/shared-constants.md;
  • _STAGE_HELPER_MODULES["company_cars"] omits uk_engine_adapter and salary_sacrifice;
  • the stage notes omit the pension-deduction cap and the re-pricing check;
  • graph.py:586 still says one engine materialization.

Cross-PR with #1422

Checked and found true

  • The OBR windows. The weights are 3/12 and 9/12, and income tax reproduces exactly. Editions match by row header for all 19 rows, the refusals work, and the value moves are as stated.
  • The BIK facts. There are 96 rows, and the tolerances match Chronicle's stated rounding. The re-pin changes only feed identity on the other 35 resources, and only the keys of the DESNZ resource.
  • The stage's mechanics:
    • the engine inputs' names, units and periods match 2.165.0;
    • the price scaling is exact;
    • the float32 re-pricing error stays under 2.3p;
    • the draws are deterministic;
    • the carriers are dropped at the release boundary.
  • Fixtures. The H2 fixture CSVs and the test frames are synthetic.

Disclosure-control items went to the author separately.

🤖 Generated with Claude Code

juaristi22 and others added 8 commits October 31, 2026 16:53
María plans to merge #1422 first, so #1430 builds on
it (uk-1095-next at 0a69ef4).

- The lock is #1422's policyengine-uk 2.165.1, with its stamps, pins and
  version mentions.
- #1422's three OBR council tax nation rows declare
  source_table_by_opening_year, as the other windowed OBR rows do on the
  08962f1 feed. The council tax, SDLT and UC notes #1422 restated keep the
  edition sentence.
- UK_ENGINE_DEFAULT_INPUTS signs only the three company car inputs the
  stage leaves at the engine's defaults, with a reason that states the
  full-year approximation. The six the stage writes are no longer signed.
- Both sides' gates are in the contract mirrors, and the gate battery and
  certification digests are re-pinned.
- Both sides' calendar-year window tests are kept.
- The generated surfaces keep one side here: the national references,
  membership and compile-parity receipt, the coverage manifest, the H2
  fixture and the concept coverage. The commits that follow regenerate
  them on the merged tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review is issuecomment-6545997069 on #1430. María's rulings of 31
October 2026: carve upstream, fuel first, and measure the EPB shortfall
before correcting it.

- Items 2 and 10: company_cars runs right after hmrc_spi_income_spine,
  so every later stage reads SPI-channel pay without the car benefits.
  That covers salary_sacrifice and its bounds, the take-up draws'
  earnings rules, the gainful self-employment flag and the CGT stages,
  and the identity tool's salary-sacrifice carrier holds. The carve no
  longer keeps room for pension deductions; salary_sacrifice bounds them
  on the carved pay.
- Item 11: only tape rows (person_is_spi_income_recipient) carve. The
  SPI channel's undrawn copies and dependants keep their twin's pay and
  car as FRS rows do, and the pay identity is checked on the tape rows.
- Item 7: where the car and fuel benefits do not fit inside EPB together,
  the fuel goes first, so a car that fits without it is always kept. An
  unknown-fuel holder who reports private fuel draws petrol or diesel.
- Item 3: the receipt records the weighted holders and list price the
  tape rows lose for want of EPB.
- Item 4: the receipt records the list price the scaling takes off and
  that of the cars it drops. The docs say the electric range and RDE2
  assumptions set a scaled car's price, so they move the 2030-31 base.
- Item 5: the receipt states the CO2 shares of the cars the output holds
  beside the draw's.
- Item 6: the docs say the receipt's figures are at the stage's weights,
  and the results measure the base on the calibrated file.
- Item 1: obr.ni_employer binds ni_class_1_employer. OBR's line is table
  4.4 row 18, Class 1 only; Class 1A is row 21. Three notes no longer say
  that no household carries Class 1A.
- Item 8: the employment income count rows filter on pay plus the car
  benefits, as the amounts read.
- Item 9: band 1's uniform draw starts at the declared minimum list
  price, GBP 5,000.
- Item 12: company_cars gains a coverage-manifest family.
- Item 13:
  - the annual simulation makes a car available for the share of the
    year the pay covers;
  - the fact fence's count is compared with the vendored rows;
  - CARVAL's bounds and FUELTYP's codes are defined once in frs_spine;
  - the stage's helper modules gain the engine adapter, spi_income and
    stochastic_assignment;
  - the stage notes and graph comments are restated.
- Cross-PR: the guard test counts every stage's declared outputs. The
  policyengine-uk 2.165.0 fragment gives way to #1422's 2.165.1.

The generated surfaces are regenerated on 08962f1 in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1422's head is now 43c3da7 (its review commits 7a95f37, 42913ab
and c0dd6ea, and their regeneration on 53815b2).

- The three OBR council tax nation rows are now
  calendar_year_window_scaled_by_ratio with obr.council_tax's MHCLG
  operands. They keep source_table_by_opening_year beside them, and their
  restated notes keep the edition sentence.
- The engine-defaults guard is #1422's: it counts every stage's declared
  outputs and rewrites. One assertion is added, that it sees the company
  car inputs.
- Both sides' gates are in the contract mirrors, and the gate battery and
  certification digests are re-pinned.
- The generated surfaces keep this branch's side, to be regenerated on
  08962f1 next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1422's 9d25e45 checks the DfE contract note for its observation
period, not the replaced snapshot sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run on the merged tree, one job at a time, from the 08962f1 cuts:

- The vendored resources: re-vendored on the full artifact. All 36 are
  byte-identical to the merge.
- The national references and membership: 1,409 active, 30 signed out.
  Relative to this branch's last regeneration, only #1422's changes move:
  - the three OBR council tax nation rows resolve, scaled to main
    residences, with their edition maps;
  - obr.council_tax moves -3.2% (main residences);
  - the DfE funded childcare rows take their 2026-01 observation period;
  - the SLC devolved total splits into Scotland, Wales and Northern
    Ireland;
  - the TFC top-up, the NI bus receipts and Scottish Child Payment are
    restated.
- The national compile-parity receipt follows.
- The local census, references, membership and compile-parity receipt are
  byte-identical to the merge.
- The coverage manifest, the H2 fixture (company_cars after the SPI income
  stage) and the policyengine-uk 2.165.1 concept coverage are regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1422's 5b693b7 moves the childcare copy take-up gate to the transferred
phase and restates restamps, counts and floors. The gate battery and
certification digests are re-pinned; the coverage manifest is regenerated
next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The national references, membership and compile-parity receipt are
  unchanged.
- The local compile-parity receipt takes #1422's restated rationale for its
  903 ONS small-area rows; no value moves.
- The coverage manifest and the H2 fixture follow the round's stage notes
  and receipts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1422's 8f7d692 expects the comparator's default SDC minimum of three in
its CLI test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22 and others added 4 commits October 31, 2026 21:26
#1422's ce9344c regenerates its local parity receipt with D18a's
reason on feed 53815b2. This branch's receipt on 08962f1 already
carries that reason, and the two files are identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1422's bd9839b regenerates its H2 spine parity fixture on #1422's
code. This branch keeps its own, regenerated after merging 5b693b7
on code that adds the company_cars stage. It already carries the SPI
receipt wording bd9839b brings, and it records this branch's own
hmrc_spi_income_spine contract digest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1422's 821e342 answers its third review: restamp failures raise and
name the row, the reference period is one constant
(UK_TARGET_REFERENCE_PERIOD), the spine gates read the assembled
boundary from graph_evidence, and the stage-health count bounds and the
floor-of-ten guard are tighter. It merges without conflicts, and
company_cars stays in the transferred phase, after frs_brma.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The company cars invariant counts an electric range set on a car
outside 1 to 50 g/km (`electric_range_outside_1_to_50_g_km`). The gate
notes and the stage-health docstring said "an electric range outside 1
to 50 g/km", which reads as a range measured in g/km. The gate digests
are re-pinned.

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

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Every item is addressed, on top of #1422's head, since #1422 merges first. María ruled on three of them on 31 October: the carve moves upstream (items 2 and 10), the fuel goes first (item 7), and the EPB shortfall is measured before anything corrects it (item 3).

Fix before the build

  1. obr.ni_employer binds ni_class_1_employer. In 2.165.0 ni_employer adds ni_class_1_employer and ni_class_1a. Chronicle's October package labels table 4.4 row 18 "Class 1 Employer NICs", and footnote 2 puts Class 1A in row 21 ("Other NIC"). The two notes that said no household carries Class 1A now say households carry it on the company cars and fuel, and the parity tool's rationale says so too. The registry-parity reason now cites October's table 4.8 and the 2.1% gap.
  2. The carve now runs upstream (items 2 and 10). company_cars runs right after hmrc_spi_income_spine. So salary_sacrifice converts the carved pay, and the identity tool's carrier holds: pay plus sacrifice is the pre-conversion pay. e8's CGT stages see the carved pay too, as do the bonus-sacrifice candidacy, the UC and Carer's Allowance earnings rules and the gainful self-employment flag. The carve no longer keeps room for pension deductions, since salary_sacrifice bounds them on the carved pay.

What the Results will measure

  1. Measured first. The receipt records the weighted holders and list price that the tape rows lose for want of EPB (holders.spi_dropped_without_epb), each withheld below the floor. "What to expect" names this beside the base size.
  2. Price scaling. The doc and the stage note now say a scaled price is the room over the appropriate percentage. So the CO2 draw, the 50-mile range and the RDE2 diesel move the 2030-31 base through the scaled cars, not only the baseline. Your arithmetic is in the doc. The receipt records the list price the scaling takes off, and that of the cars it drops (spi_carve_out.weighted_list_price_scaled_away_gbp and weighted_list_price_dropped_gbp).
  3. CO2 after the carve. The receipt keeps the draw's shares (co2.drawn) and adds the output's (co2.output).
  4. Pre-calibration weights. The doc and the body say the receipt's figures are at the stage's weights, and the results measure the base on the calibrated file.

Stage behaviour

  1. Fuel first. Where the car and fuel benefits don't fit inside EPB together, the fuel goes first. Your case now keeps the car, scaled to £31,034. An unknown-fuel holder who reports private fuel draws petrol or diesel at their Table 2C shares.
  2. Count rows. The two count rows filter on employment_income_with_benefits_in_kind_annual, as the amounts read it.
  3. Band 1's floor. Band 1's uniform draw starts at £5,000, the carve-out's declared minimum price, read from the same declaration.
  4. Tape rows only. The carve reads person_is_spi_income_recipient. The undrawn copies and the dependants aged 16 to 19 keep their twin's pay and car as FRS rows do, and the pay identity is checked on the tape rows.

Coverage and texts

  1. Coverage. company_cars has a family_coverage entry, and the coverage test requires the stage.
  2. Small items:
    • the annual simulation sets company_car_availability to the employment share, so the engine prorates a part-year employee's car by its own rule (ITEPA ss. 143 and 152);
    • the fence's count is compared with the vendored rows at run time;
    • CARVAL's bounds and FUELTYP's codes are defined once, in frs_spine, with a consistency test;
    • the helper modules gain uk_engine_adapter, spi_income and stochastic_assignment, and the stage no longer imports salary_sacrifice;
    • the stage notes name the re-pricing check;
    • the graph.py comment counts two materializations.

Cross-PR with #1422

  • In the merged tree the three council tax nation rows carry source_table_by_opening_year.
  • UK_ENGINE_DEFAULT_INPUTS signs only the three inputs the stage leaves at default, and the reason states the full-year approximation. The guard test now counts every stage's declared outputs, so a signed default on a written input fails.

What I ran, on the 08962f1 feed, on the merged tree with #1422's head 821e342:

  • the engine-free suite in chunks: 14,463 tests. Three local-only checks fail: the UK extra is installed here, the live release pointer, and orrery's Node package;
  • the national and local references, memberships and compile-parity receipts, and the H2 fixture, regenerated: byte-identical to the committed files;
  • the feed-gated files one per process, other than bus targets: 236 passed;
  • the UK engine tests for company cars, coverage, the annual measures, concept mapping, the graph and the H2 parity: 75 passed.

Before #1422's third review I also re-vendored the resources (byte-identical), regenerated the coverage manifest and the concept coverage, and ran the bus targets file and the national compile-parity regeneration on the full artifact, with its release gate. All passed. After the third review, the references and receipts those two runs check regenerate byte-identical from the feed's cuts, so I didn't repeat those 27 GB runs.

🤖 Generated with Claude Code

@juaristi22 juaristi22 changed the title UK: company cars from the FRS job table, and policyengine-uk 2.165.0 (dashboard #316) UK: company cars from the FRS job table (dashboard #316) Nov 1, 2026
#1422's 0da33d0 answers its fourth review: periods are read under
their declared type, a period's year needs four digits, the authored
observation period reads the selector's period type, the assembled
boundary moves to graph.py, and the floor-of-ten guard reads more
spellings of ten. It merges without conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1422's 4af602d answers its fifth review: an observation period is
read and moved as its reference's match policy reads it, a spelling
read two ways refuses to move, the compile-parity reasons are one
constant the receipt test replays, and the floor guard reads floor
words in more names and spellings. It merges without conflicts.

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

Copy link
Copy Markdown
Collaborator Author

Second review of #1430 at 40968ff53

This re-reviews this PR's commits since e67e76c29 (eb43bbb6a, 9a656579d, 4d7356e51 and 40968ff53) against my first review. They sit on top of #1422 at 821e342d1. Three reviewers took one area each:

Everything ran without the engine; where engine behaviour matters, it was read from the policyengine-uk 2.165.x source. Paths are relative to packages/microcosm-build/src/microcosm/build/ unless they start with packages/, tools/ or docs/.

Verdict: twelve of the round-1 items are fixed and two are partly fixed. Two problems would each stop the pair build:

  • company_cars declares empty fit-weight records, so uk_weights_audit fails every national and dense build (1);
  • with the carve upstream, salary_sacrifice can abort the spine (2).

The merge with #1428 also needs steps that git won't flag (3, 4).

Fix before the build

1. Every national fails uk_weights_audit (uk_runtime/company_cars.py:1373-1375).

  • Mechanism: the stage fits nothing, but declares fit_weight_records returning (). That empty list travels to the audit:
    • the stage evidence records an empty list (stage_evidence.py:69-79);
    • the sidecar keeps it (uk_runtime/graph_evidence.py:214-218);
    • rehydrate_uk_fit_weight_records turns any empty list into () (uk_runtime/release_certification.py:216-219);
    • so the release-blocking gate fails: "A production fit stage ran but emitted no FitWeightRecord evidence".
  • Probe: with the real classes and binding the audit fails; without company_cars it passes.
  • ISA: ISA has the same declaration at this head (isa_subscriptions.py:1646-1648). SPI donor savings rebased to SPI's 2023-24 bands (savings follow-up to #1418) #1428's b9bcd709c removes only ISA's, so the merged tree still fails on company_cars.
  • Fix: drop the property and its import, as b9bcd709c does, and add the evidence test SPI donor savings rebased to SPI's 2023-24 bands (savings follow-up to #1418) #1428 added for ISA.

2. With the carve upstream, salary_sacrifice can abort the spine (uk_runtime/company_cars.py:937-959; uk_runtime/salary_sacrifice.py:745-756).

  • Mechanism:
    • The SPI stage caps a tape row's employee pension contribution at its pre-carve pay (uk_runtime/spi_income.py:2553-2556).
    • company_cars now lowers that pay. eb43bbb6a removed round 1's room for pension deductions, on the grounds that salary_sacrifice bounds them.
    • It does bound salary and bonus sacrifice. For employee contributions it raises instead: "Employee pension contributions must not exceed the pay that remains". Its comment at :746-752 says a new pay rewrite must cap contributions again.
  • Probes with the real classes:
    • a tape row with PAY 0, EPB £3,000, a car and a £150 contribution ends with pay 0, and raises;
    • a row with £20,000 of pay, whose contribution was capped at £20,000, is carved to £16,760, and raises;
    • the same rows without a car run.
  • Also: employer contributions and the ASHE ceiling stay on pre-carve pay, so pension input is overstated on carved rows.
  • Fix:
    • keep room for the employee contribution in the carve's budget, or re-cap contributions after the carve and declare that rewrite;
    • add a test that runs salary_sacrifice on the stage's output;
    • update docs/uk-company-cars-316.md:74-75, which says salary_sacrifice keeps the deductions within pay.

Merging with #1428

3. A new HMRC employment count still reads pay alone.

  • SPI donor savings rebased to SPI's 2023-24 bands (savings follow-up to #1418) #1428 adds hmrc.spi.employment_income.count_by_self_employment_income_band. It filters on employment_income_annual != 0, with a note that this is "the variable Table 3.6's employment rows read". This PR moves those rows to employment_income_with_benefits_in_kind_annual.
  • The files merge without conflict, so the merged tree binds one HMRC employment count on pay alone. A tape row carved to zero pay, with its car kept, then leaves that count but stays in Table 3.6's: round-1 item 8 again.
  • Fix: one of the two PRs should switch the new row, and its note, to the benefit-in-kind term.

4. The merge needs steps git won't flag.

  • Vendored feed: SPI donor savings rebased to SPI's 2023-24 bands (savings follow-up to #1418) #1428 vendors uk/hmrc_spi_table_3_7_savings_rows.json stamped with feed 53815b2. On this PR's 08962f1 pin, vendored_rows refuses it ("vendored from a different Chronicle feed"), so the SPI income stage stops, and the vendoring test fails. The table needs re-vendoring on 08962f1, and SPI donor savings rebased to SPI's 2023-24 bands (savings follow-up to #1418) #1428's membership and parity receipt need regenerating there.
  • Stage count: both PRs move the stage count from 50 to 51 the same way, so the asserts merge silently at 51 with 52 stages. test_uk_graph.py:65 fails, and test_country_spec.py:1063 needs the same re-pin.
  • Otherwise: the trial merge has 21 conflicts, all resolved by keeping both sides, and the stage order comes out right: company_cars after the SPI stage, and the receipts stage last.

Round-1 items

# Status
1. obr.ni_employer Fixed. It binds ni_class_1_employer, which reproduces the old measure exactly. Correction: my round-1 note said the solve would move weight. That was wrong: the row is held out of the objective until 2027-01-16, so the fix corrects a diagnostic. Leftovers are in 6 below.
2. Identity tool Fixed. The carve runs right after the SPI stage. A probe chain (company_cars, then salary_sacrifice, then the tool's pre-sacrifice frame) holds. But see 2.
3. EPB shortfall Fixed. The receipt measures the lost holders and list price.
4. Price scaling Partly. The arithmetic and the receipt fields are right. But docs/uk-company-cars-316.md:46-47 and :108-109 still say the diesel supplement and the frozen electric share move only the baseline, which contradicts :86-94.
5. CO2 after the carve Fixed (co2.drawn and co2.output).
6. Pre-calibration weights Fixed in the texts.
7. Fuel first Fixed, and private fuel can't draw electric. The unknown-fuel shares are in 7 below.
8. Count rows Fixed. All four HMRC employment rows read the benefit-in-kind term; #1428's new row doesn't (3).
9. Band 1's floor Fixed.
10. Upstream pay rules Partly. Bonus sacrifice, UC take-up, Carer's Allowance and the CGT stages see carved pay. Exception: the UC gainful self-employment flag is set inside the SPI stage on pre-carve pay (uk_runtime/spi_income.py:3015-3047), and nothing refreshes it. A side-trade profit of £5,000, with pre-carve pay of £6,000 and carved pay of £1,000, should be flagged and isn't. The reply says this flag sees carved pay.
11. Tape rows only Fixed.
12. Coverage Fixed.
13. Small items Fixed. The availability share matches ITEPA ss. 143(3) and 152(1).
Cross-PR with #1422 Fixed. The nation rows carry the edition map, three defaults stay signed, and the guard counts every stage's outputs. A forward merge with #1422's current head (4af602d81) is clean, and its tests pass.

Also new

5. The calibrated-file report the doc promises doesn't exist.

  • The promise: docs/uk-company-cars-316.md:140-142 says the national reports the calibrated file against HMRC Tables 1, 3A and 4. So do the BIK facts' purpose text and the gate note (uk/gates.json:1059, "measured, not gated").
  • What exists: the only reader of HMRCCompanyCarStatistics is the stage itself, before calibration.
  • Fix: add the diagnostic, or say the comparison is a manual step.

6. The obr.ni_employer exclusion still describes ni_employer (uk/calibration_measure_exclusions.json:445-449).

  • The texts: its reason calls ni_employer the secondary Class 1 charge. Its condition says to rebind "when a locked policyengine-uk nets the allowance out of ni_employer".
  • The risk: with the row now on ni_class_1_employer, meeting that condition (pe-uk#2459) would leave the bound measure gross. Following the register would then rebind about £7.4bn high.
  • Also: tests/engine_free/uk/test_uk_national_insurance_targets.py:19-21 and the body's fit-movement note are stale, and no test pins the new binding.

7. Smaller:

  • Unknown-fuel shares: the unknown-fuel draw no longer meets Table 2C (uk_runtime/company_cars.py:658-672). Fuelled holders now exclude electric, while unfuelled ones keep the unconditional shares. So the electric share among unknown-fuel holders is (1 − f) × Table 2C's, where f is the share reporting private fuel.
  • Income proxy: the CGT and ISA income proxy (uk_runtime/cgt_imputation.py:191-208) sums employment_income alone. With the carve upstream, tape-row holders' car benefit drops out of it, though the engine's total income includes benefits_in_kind.
  • obr.fuel_duties: it is the only OBR window row without the edition map (uk/uk_population_targets.json:2214-2240). It is signed out today, but it would refuse if brought back.
  • Council tax nation rows: their parity reason omits the main-residence scaling. This comes from UK: take up #1095's actionable items on policyengine-uk 2.165.1 #1422 and is carried in this PR's regenerated receipt.

8. Stale texts:

  • uk/gates.json:1059 and uk_runtime/stage_health.py:5107 and :5136 still say "any SPI-channel row … less any salary sacrifice";
  • uk_runtime/salary_sacrifice.py:746-752;
  • docs/uk-isa-subscriptions-1415.md:5, though ISA is now last;
  • three docs count 19 rows with source_table_by_opening_year, where the merged tree has 22;
  • the engine test texts still say "SPI-channel".

9. Test gaps: no test does any of these:

  • runs salary_sacrifice on the stage's output;
  • checks the stage's fit-weight evidence;
  • pins the ni_class_1_employer binding;
  • compares co2.drawn with co2.output.

Checked and found true

  • The scaled-price arithmetic matches policyengine-uk 2.165.1. The electric range rule in 40968ff53 matches ITEPA s. 139 and the engine.
  • Draws stay keyed and uniquely salted, so moving the stage changes no other stage's draws.
  • The regenerated surfaces match their generators wherever that can be checked without the feed: the coverage manifest, local census, contract digests, parity reasons, uv.lock and concept coverage.
  • The nine merge resolutions drop no side.
  • The 2.1% gap the parity reason cites is consistent with October's tables.

Disclosure-control items went to the author separately.

🤖 Generated with Claude Code

@juaristi22
juaristi22 marked this pull request as ready for review November 1, 2026 18:01
juaristi22 and others added 4 commits November 1, 2026 18:14
#1422 merged as 11c1f4e, whose head 4af602d this branch already
merged. Main brings US, New Zealand and Belgian changes only; no UK file
moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Drop the stage's empty fit-weight records, which the release-cut
  weights audit read as a fitting stage that recorded none (as #1428
  does for ISA), with a test on the stage's evidence.
- Cap the tape rows' carve at EPB within the pay the employee pension
  contribution leaves, so salary_sacrifice never meets a contribution
  above pay. The review's two probes are tests, one of them running
  salary_sacrifice on the stage's output, and the invariants count a
  contribution above the carved pay.
- Take a carved row's UC gainful self-employment again on its carved
  pay, by the root's rule; the flag is a declared output and rewrite.
- Draw the unknown fuels to Table 2C together: the holders without
  private fuel draw electricity at Table 2C's share over the share the
  private-fuel holders leave.
- Say the calibrated-file comparison with Tables 1, 3A and 4 is a manual
  step (doc, stage note, fact fence, BIK purpose text, gate note).
- Restate the obr.ni_employer exclusion for the ni_class_1_employer
  binding, with a test pinning both; give obr.fuel_duties the edition
  map; give the council tax nation rows a parity reason that names the
  main-residence scaling.
- Restate the stale texts: the gate note and stage health, the
  salary_sacrifice comment, the ISA doc, the docs that counted 19 mapped
  rows, the engine tests and the doc's baseline-only sentences.

Gate digests re-pinned. Generated surfaces follow in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the 08962f1 cuts: the national compile-parity receipt gives the three
council tax nation rows their main-residence reason; the membership's
obr.fuel_duties entry matches the two tables its new edition map names
(it stays signed out); the coverage manifest and the H2 fixture carry
the company_cars declaration that writes the UC gainful flag. The
national references and the local receipt regenerate byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine reads employment_status as an EmploymentStatus name, whose
full-time employee member is FT_EMPLOYED.

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

Copy link
Copy Markdown
Collaborator Author

Thanks for the second review. Every item is addressed, on top of main now that #1422 is in (11c1f4e). Merging main brings no UK file.

Fix before the build

  1. Fit-weight records. company_cars no longer declares fit_weight_records or imports FitWeightRecord, as SPI donor savings rebased to SPI's 2023-24 bands (savings follow-up to #1418) #1428's b9bcd709c does for ISA. A test reads the stage's evidence and finds none. ISA's declaration is still on main until SPI donor savings rebased to SPI's 2023-24 bands (savings follow-up to #1418) #1428 lands.

  2. The carve keeps the contribution. The cap is now EPB within the pay the row's employee pension contribution leaves, so a carve never takes pay below the contribution the SPI stage capped. Your two probes are now tests, of the carve and of salary_sacrifice run on the stage's output:

    • the first keeps its car with £2,850 carved, leaving £150 of pay for its £150 contribution;
    • the second has no room, so it loses its car and keeps its £20,000.

    Both tests fail on the old cap. The stage's invariants also count a tape row whose contribution exceeds its carved pay, and the gate blocks on it. The doc, the stage note and salary_sacrifice's comment now say this. Employer contributions stay as the SPI stage set them on the drawn PAY + EPB + TAXTERM. That holds for every tape row that draws EPB, car or not, so it predates this PR; moving them onto pay alone is a change to the SPI stage. The doc says so.

Merging with #1428 (3 and 4)

These fall to whichever PR lands second. If #1430 lands first, #1428 needs to:

  • switch hmrc.spi.employment_income.count_by_self_employment_income_band, and its note, to employment_income_with_benefits_in_kind_annual;
  • re-vendor uk/hmrc_spi_table_3_7_savings_rows.json on 08962f1, and regenerate its membership and parity receipt there;
  • re-pin the stage count at 52 in test_uk_graph.py and test_country_spec.py.

Round-1 items that were only partly fixed

  • 4. The two sentences now say that the diesel supplement and the frozen electric percentage move the base through the scaled cars, as well as the baseline.
  • 10. You're right: the flag is set inside the SPI stage, before the carve, so my last reply was wrong about it. A carved row now takes the root's rule (frs_spine.uc_gainful_self_employment) again on its carved pay, so your side trade of £5,000 beside pay carved from £6,000 to £1,000 is flagged. The flag is now a declared output and rewrite of the stage, and the receipt counts the rows it turns on. A test covers your case.

Also new

  1. No calibrated-file report. Comparing the calibrated file with Tables 1, 3A and 4 is a manual step for now. The doc, the stage note, the fact fence, the BIK facts' purpose text and the gate note all say so.
  2. The obr.ni_employer exclusion.
    • Its reason now says the row binds ni_class_1_employer.
    • Its rebind condition asks for the allowance to be netted out of that measure. It also notes that netting it out of ni_employer alone would leave the bound measure gross.
    • A new test pins the binding and the condition.
    • The NICs-total test's docstring now says households carry Class 1A on company cars and fuel.
    • The body's fit-movement note now says the row is a diagnostic.
  3. Smaller:
    • Unknown fuels. Holders without private fuel now draw electricity at Table 2C's share divided by (1 − f), capped at 1, where f is the weighted share of unknown-fuel holders who report private fuel. The unknown fuels then meet Table 2C together. A test checks the shares, and the case where the private-fuel holders leave too little room.
    • Income proxy. The CGT and ISA proxy sums employment_income, so it now leaves the car benefits out on every row, as it always has on FRS rows. The doc says so. The engine computes the benefits; no column carries them for the proxy to add.
    • obr.fuel_duties carries the edition map and its note, so it reads if it comes back.
    • Council tax nation rows. Their parity reason now names the main-residence scaling, and the national receipt is regenerated.
  4. Stale texts. These now say tape rows, or are restated:
    • the gate note, and the stage-health docstring and message;
    • salary_sacrifice's comment;
    • the ISA doc, which now says "after every other stage";
    • the three docs that counted 19 mapped rows, which now count 23 (adding the three nation rows and fuel duties);
    • the engine tests.
  5. Tests. New tests cover:
    • salary_sacrifice on the stage's output;
    • the fit-weight evidence;
    • the ni_class_1_employer binding;
    • co2.drawn against co2.output (equal without a carve-out).

What I ran, on the 08962f1 feed, on main (11c1f4e) merged with these fixes (head 144d738):

  • the 26 touched engine-free files (the stage, salary sacrifice, stage health, gates, graph, manifests, coverage, parity, contract, ISA, NI targets and others): 1,408 passed, 8 skipped;

  • the generated surfaces, regenerated from the feed's cuts. Four changed as expected:

    • the national compile-parity receipt, with the three nation reasons;
    • the membership's fuel duties entry, which now matches two facts and stays signed out;
    • the coverage manifest;
    • the H2 fixture.

    The national references and the local receipt regenerated byte-identical;

  • the feed-gated files one per process, other than bus targets: 239 passed and 2 skipped (the Scotland support and the authenticated national artifact aren't mounted here);

  • the UK engine tests for company cars, coverage, the annual measures, concept mapping, the graph and the H2 parity: 75 passed.

CI runs the full engine-free suite on this head. Locally, the full suite passed on the previous merge with #1422's final head: 14,487 tests, and only the three checks that can't pass on this machine fail.

🤖 Generated with Claude Code

@juaristi22

Copy link
Copy Markdown
Collaborator Author

Third review of #1430 at 144d73836

This re-reviews 083ee9f7d, 51259e71b and 144d73836 against my second review at 40968ff53, along with the merges that bring in #1422 and main (14dc81547, after 11c1f4e69). Two reviewers:

Everything ran without the engine; where engine behaviour matters, it was read from the policyengine-uk source at the lock (2.165.1). Paths are relative to packages/microcosm-build/src/microcosm/build/ unless they start with packages/ or docs/.

Verdict: both build-stoppers and the other round-2 items are fixed, CI is green, and the main merge is clean. Nothing new blocks a build. What's left:

Merging with #1428

The reply's three steps are right: switching the employment count row, re-vendoring Table 3.7 on 08962f1, and the stage count at 52. The stage-count asserts now conflict, so git flags them, and at 52 the graph and country-spec tests pass. The trial merge now has 25 conflicting files, all resolved by keeping both sides. The stage order comes out right: 52 stages, company_cars after the SPI stage, trading_allowance_receipts last.

Five more steps are needed:

  • The employment count row (silent if forgotten). Switching it also means updating SPI donor savings rebased to SPI's 2023-24 bands (savings follow-up to #1418) #1428's test_the_table_3_10_employment_counts_read_annual_pay_by_profit_band (packages/microcosm-build/tests/engine_free/uk/test_uk_pension_age_benefit_targets.py), which pins employment_income_annual, and the Table 3.10 reason in uk/cross_grain_declarations.json ("adds the year's pay"). Nothing requires HMRC employment rows to read the benefit-in-kind term, so forgetting the switch merges green. A test across the hmrc.spi.employment_income.* rows would pin it.
  • The gate-battery pins in packages/microcosm-data/src/microcosm/data/contract.py (6 hunks) and packages/microcosm-data/tests/engine_free/shared/test_contract.py (3). Neither side's values hold on the merged spec, and packages/microcosm-build/tests/engine_free/shared/test_gate_battery_contract_pins.py fails 3 tests until they're regenerated. SPI donor savings rebased to SPI's 2023-24 bands (savings follow-up to #1418) #1428's 4dd83e999 records a certified national that then failed the release contract on these pins.
  • The coverage manifest: --check fails on the merge, so it needs regenerating.
  • The H2 fixture needs regenerating.
  • docs/uk-isa-subscriptions-1415.md:5 (silent). It now says ISA runs "after every other stage". SPI donor savings rebased to SPI's 2023-24 bands (savings follow-up to #1418) #1428 runs trading_allowance_receipts after it, and the line merges without a conflict.

New

1. Nothing tests the stage-level UC refresh (packages/microcosm-build/tests/engine_free/uk/test_uk_company_cars.py:597-613; uk_runtime/company_cars.py:1241-1244).

  • Why the test can't fail: the whole-stage test compares the carved rows' flags with the rule on carved pay. On its frame no carved row changes flag (seeds 1 to 5), so the "after" flag equals the "before" one, and the new receipt count is 0.
  • Mutation: both reviewers made the stage's call a no-op. All 31 tests in the file stayed green, and so did the 162 in the salary_sacrifice and stage-health files. Only the helper's own unit test catches a broken helper, and the engine frame has no self-employment income.
  • Fix: seed one tape holder with a side trade whose profit lies between its carved and its drawn pay, and assert both the flip and the receipt count.

2. The lost holders' fuel is now drawn at their own private-fuel share (uk_runtime/company_cars.py:1158-1174, with :688-703).

  • Mechanism: the tape rows without EPB call assign_fuel_types on their own, so their unfuelled rows draw electricity at e/(1 − f) with f taken over the lost rows alone, not over the holders. The comment says their prices are "drawn as the holders' are". Before 083ee9f7d, the lost draw reproduced the holders' draw exactly.
  • Synthetic probe: with Table 2C at 0.5, 0.4 and 0.1, 2,000 holders (a quarter fuelled) and 400 lost rows (none fuelled), the lost rows' electric share is 0.48 drawn alone and 0.65 drawn at the holders' share. 75 of the 400 change fuel.
  • Effect: it moves holders.spi_dropped_without_epb.weighted_list_price_gbp, the measure the 31 October ruling asked for, through the electric/non-electric price class.
  • Fix: pass the holders' electric probability to the lost rows' draw.
  • Also: every unknown-fuel draw now depends on its population's weighted f. Reordering rows still moves none, but a person's fuel can change when other rows or weights do, for example between spines.

3. The contribution room keeps a drawn contribution at the car's expense (uk_runtime/company_cars.py:985, :1008-1010; uk_runtime/spi_income.py:2536-2555).

  • Mechanism:
    • On a tape row, pay is PAY + EPB + TAXTERM. So the room lowers the budget below EPB only where the employee contribution exceeds PAY + TAXTERM, the row's cash pay.
    • That happens only because the SPI stage caps the forest-drawn net-pay contribution at a pay that includes EPB, and a net-pay contribution can't come out of benefits in kind.
    • On those rows, the carve now scales or drops the car to keep a contribution the build drew.
  • The other route: re-capping the contribution at the carved pay, the second option in my round-2 list, keeps the car. I offered both without saying what the first one costs.
  • Not measured: cap_below_epb_for_the_employee_contribution counts rows where the room lowers the budget, not those where it scales or drops a car. Those cars are pooled into list_price_scaled and dropped_below_minimum_price.
  • Probably small. A ruling, or a count of the cars the room scales or drops, would settle it.

4. The obr.ni_employer exclusion is still partly stale (uk/calibration_measure_exclusions.json:446).

  • Under-21 rate: the reason says the under-21 zero rate is one "which the engine also omits". policyengine-uk applies the under-21 and apprentice zero rates in ni_class_1_employer from 2.160.0 (utils/employer_ni.py), and the 2.165.1 lock has them. The UK local-area release: publish a K=15, 150,000-household dense dataset in at most two builds #1301 measurement it describes ran on an older engine, so the sentence needs a past tense.
  • Tracker: policyengine-uk#2459 ("Model the Employment Allowance so ni_employer is net of it") and the adjudication still ask for netting out of ni_employer. The reason now says that change would leave the bound measure gross, so #2459 landing as titled would look like it meets the rebind condition when it doesn't. A note on #2459 would keep them in step.

5. Texts:

  • Module docstring: step 2 (uk_runtime/company_cars.py:32-35) still says unknown fuels draw "at Table 2C's shares". Step 5 (:47-58) still caps at EPB alone, with no room for the contribution and no UC flag rewrite.
  • Fuel rule id: UNKNOWN_FUEL_RULE and its error text (:227-230, :368-372) still name the old rule. So a declaration written for the old rule still validates. A new id would make the change visible.
  • Pre-existing: salary_sacrifice lowers pay without re-deriving the UC flag (uk_runtime/salary_sacrifice.py:686-688), which is what this PR now does after the carve. Its edited comment (:746-752) names only contributions as what a pay rewrite must keep.

Round-2 items

# Status
1. Fit-weight records Fixed. The property and its import are gone. The new evidence test (packages/microcosm-build/tests/engine_free/uk/test_uk_company_cars.py:825) fails if the declaration comes back. With ISA's declaration also removed, a probe audit passes. ISA is the rest (Verdict).
2. Carve and salary_sacrifice Fixed. The budget is EPB within pay less the employee contribution (uk_runtime/company_cars.py:985). Both of my probes now run: PAY 0, EPB £3,000 and a £150 contribution keeps its car, with £2,850 carved and £150 of pay left. £20,000 of pay with a £20,000 contribution drops the car and keeps the pay. Salary and bonus sacrifice and the private-fuel variants run on the output. The new invariant can't fire on legitimate input. See 3 for the cost.
3. #1428's employment row Right but incomplete (Merging).
4. Merge steps Right but incomplete (Merging).
Round-1 item 4 Fixed. The doc's two sentences agree with the scaling.
Round-1 item 10 Fixed in code. A carved row takes the root rule on its carved pay, using the SPI stage's exact inputs, and my £5,000 side-trade case is flagged. Untested at the stage (1).
5. Calibrated-file report Fixed. It's a manual step, and every text says so.
6. obr.ni_employer Fixed as asked. The new binding test fails on revert. Residual texts are in 4.
7. Smaller Fixed. The unknown fuels meet Table 2C together wherever the private-fuel holders leave room (a 200,000-row probe). obr.fuel_duties carries the map, and the nation reasons name the main-residence scaling. The side effect is in 2.
8. Stale texts Fixed, except the module docstring (5).
9. Test gaps Fixed. Each new test fails under its mutation.

Checked and found true

  • The carve: on every path the benefit is at most the budget, which is at most pay less the contribution. The SPI cap is an exact minimum on the same rows, with no stage between them. The gate blocks on every violation key.
  • The UC refresh: it applies the SPI stage's rule and only turns flags on, only on carved rows. Its declaration matches across the sources, the graph, the coverage manifest, the H2 fixture and the export allow-list. Benefits in kind aren't UC earnings in 2.165, so carved pay is the right comparator.
  • The fuel draw: the shares sum to 1. Where the private-fuel holders leave too little room, every unfuelled car draws electricity and petrol and diesel keep Table 2C's ratio. f is weighted, and the salts are unchanged.
  • Regenerated surfaces: the nation parity reasons equal the generator's constant, and the replay passes for the national and local receipts. The fuel duties membership entry, the coverage manifest and the H2 fixture are consistent.
  • Merges: the remerge-diffs of 14dc81547, 23aadd8f3 and 4d4c1a6e6 are empty, and main brings no UK file. A trial merge with main at dfa5fe399 is clean, and main's one later commit (Harden the money run-entry walk and cover its gaps #1441) touches no UK file.
  • CI on 144d73836: every job passes. The touched files pass locally too: the stage, salary sacrifice, stage health, receipt counts, the UK and generic ledger targets, compile parity, the data contract, the NI targets and the graph.

Disclosure-control items went to the author separately.

🤖 Generated with Claude Code

juaristi22 and others added 2 commits November 1, 2026 22:50
- Draw the lost holders' fuel at the holders' electric share: the tape
  rows that lose their car for want of EPB passed their own private-fuel
  share to the unknown-fuel draw (unknown_fuel_electric_share; the draw
  takes the share as a parameter).
- Rename the unknown-fuel rule to table_2c_shares_together_..., in the
  schema and both manifests, so a declaration for the old rule is
  refused; a test says so.
- Measure what the employee contribution's room costs: the carve runs
  again inside EPB within pay alone, and the receipt counts the cars the
  room scales further or drops and the list price they give up.
- Test the stage-level UC refresh: a carved tape row given a side trade
  between its carved and drawn pay is flagged and counted (the review's
  no-op mutation now fails it).
- Pin every HMRC employment income row, amount or count, national or
  local, to the benefit-in-kind term, so a new row reading pay alone
  fails (the merge with microcosm#1428's Table 3.10 row).
- Restate the module docstring, the ISA doc's stage position (true with
  later stages), the salary_sacrifice comment (its conversion doesn't
  refresh the UC flag) and the ni_employer exclusion's under-21 sentence
  (the 2.165.1 lock applies the zero rates).

Generated surfaces follow in the next commit.

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

The source manifest's hash moves with the unknown-fuel rule's new id, and
the H2 fixture's copy of the company_cars declaration and its contract
digest follow.

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

Copy link
Copy Markdown
Collaborator Author

Thanks for the third review. #1428 lands after this PR and picks this code up, and builds run from there (María's call). So the merge-time items stay with #1428, and everything that tightens this PR on its own is fixed here.

Left for #1428

Fixed here

  1. The stage-level UC refresh is tested. The new test runs the stage once, gives a carved tape row a side trade between its carved and drawn pay, then runs the stage again. It asserts both the flip and the receipt count. Your no-op mutation now fails it.
  2. The lost holders draw at the holders' share. unknown_fuel_electric_share computes the holders' electric share once, and the lost rows' draw takes that share, so they draw as the holders do. A test reproduces your probe. The doc now says that matching Table 2C at the stage's weights makes each drawn fuel depend on the other drawn cars' private fuel and weights, as the CO2 split already does.
  3. The contribution room is now measured. The carve runs a second time inside EPB within pay alone. The receipt counts the cars the room scales further or drops (cars_the_employee_contribution_scales_or_drops), and the list price they give up (weighted_list_price_given_up_for_the_employee_contribution_gbp). The doc names the other route, re-capping the contribution at the carved pay, which keeps those cars. María chooses between them once the pair build has measured the cost.
  4. The obr.ni_employer texts. The under-21 sentence is now in the past tense, and says the 2.165.1 lock applies the under-21 and apprentice zero rates in ni_class_1_employer. policyengine-uk#2459 now has a note that the row binds ni_class_1_employer.
  5. Texts.
    • The module docstring's steps 2 and 5 now describe the joint fuel draw, the lost holders, the contribution room and the UC refresh.
    • The unknown-fuel rule is renamed table_2c_shares_together_non_electric_with_private_fuel in the schema and both manifests, and a test refuses a declaration that names the old rule.
    • salary_sacrifice's comment now says that its own conversion doesn't refresh the UC flag. That gap predates this PR, so I've left it for a follow-up.

What I ran, on head de41aba:

  • the 27 touched engine-free files: 1,442 passed, 8 skipped. They cover the stage, salary sacrifice, stage health, gates, the graph, the manifests, coverage, parity, the contract, ISA, the NI and population targets, and others;
  • the coverage manifest and the H2 fixture, regenerated. Only the manifest's source hash moved, plus the fixture's copy of the renamed rule and its contract digest;
  • the UK engine tests for company cars, coverage, the annual measures, concept mapping, the graph and the H2 parity: 75 passed.

Nothing here touches a target or the feed, so the references, memberships and parity receipts stand as last regenerated. CI runs the full engine-free suite on this head.

🤖 Generated with Claude Code

@juaristi22

Copy link
Copy Markdown
Collaborator Author

Fourth review of #1430 at de41aba62: merge check

This checks a22e33da2 and de41aba62 against my third review at 144d73836, looking only for anything that should block the merge.

Verdict: nothing blocks the merge once CI is green. At the time of writing, lint, wheels, integration-uk and both engine-uk jobs pass, and the engine-free and engine-us jobs are still running.

What I checked

  • No stage output changes:
    • The refactored fuel share gives the holders the same draws.
    • The tape rows that lose their car now draw at the holders' share, which moves only a receipt measure.
    • The second carve, inside EPB within pay alone, only measures what the contribution's room costs.
  • Tests: 615 passed, none skipped, in the nine files a merge could break:
    • the gate-battery pins and the data contract, which still hold with the renamed fuel rule;
    • the stage's tests, and the new employment-row test;
    • the receipt-count guard and both coverage checks;
    • source stages and stage health.
  • Main: its one commit since this PR's main merge (Harden the money run-entry walk and cover its gaps #1441) touches only transport files, and GitHub reports the PR as mergeable.

Round-3 items

# Status
1. Stage-level UC refresh Fixed. The new test gives a carved row a side trade between its two pays, and asserts the flip and the receipt count. It fails when the refresh is made a no-op.
2. Lost holders' fuel Fixed. The lost rows take the holders' electric share, and the new test reproduces the probe.
3. Contribution room Measured, with the choice of route left to a ruling after the pair build. One nit, not blocking: cars_the_employee_contribution_scales_or_drops also counts a car that keeps its price and loses only its private fuel to the room, while the name and the doc say "scales further or drops".
4. obr.ni_employer texts Fixed. The under-21 sentence is in the past tense, and policyengine-uk#2459 has the note.
5. Texts Fixed. The module docstring, the renamed rule (a test refuses the old name) and salary_sacrifice's comment are updated.
Merge steps Moved to #1428, which lands after this PR. The new test makes a forgotten switch of the Table 3.10 employment row fail loudly. The ISA doc line now holds either way.

What #1428 needs after this merges

  1. Switch the Table 3.10 employment count row, and its note, to employment_income_with_benefits_in_kind_annual. Update its pinning test and the Table 3.10 cross-grain reason to match.
  2. Re-vendor Table 3.7 on 08962f1, and regenerate its membership and parity receipt.
  3. Set the stage count to 52 in test_uk_graph.py and test_country_spec.py.
  4. Regenerate the gate-battery pins in contract.py and test_contract.py.
  5. Regenerate the coverage manifest and the H2 fixture.

Disclosure-control items went to the author separately.

🤖 Generated with Claude Code

@juaristi22
juaristi22 merged commit 3100f31 into main Nov 2, 2026
10 checks passed
juaristi22 added a commit that referenced this pull request Nov 2, 2026
Main brings #1422 (policyengine-uk 2.165.1 and the small-cell floor of
three) and #1430 (the company_cars stage and the Chronicle feed re-pin
to 08962f1). Following #1430's merge order for this PR:

- Both PRs add a stage in the same places, so the stage lists, kernels,
  transforms and coverage contracts keep both: company_cars after the
  SPI income stage, trading_allowance_receipts still last. The stage
  count is 52 in the spec tests, the graph test and the fixture tool.
- The UC take-up test keeps this PR's gap passing with #1422's source
  households and floor: band 0's pool withholds its flag (<3), band 2's,
  on four sources, says the count exceeds every entitled unit.
- The Table 3.10 employment count reads
  employment_income_with_benefits_in_kind_annual, the term every HMRC
  employment income row reads since #1430, with its notes, cross-grain
  reason, test and changelog.
- The support floor's adjudication keeps #1422's floor of three and this
  PR's extension to the employment count.
- Counts add both sides: 460 population targets and 398 in the registry
  scope, 45 held measure names, 185 mapped and 213 unmapped declarations,
  1,418 active references and 37 signed exclusions.

Regenerated on Chronicle 08962f1 (the artifact María supplied, digests
verified): the Table 3.7 savings rows (feed identity only; the values
are unchanged), the national references and membership and the national
compile-parity receipt from the national cut, then the coverage
manifest, the H2 fixture on policyengine-uk 2.165.1, and the gate-battery
pins and certification part digests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22 added a commit to PolicyEngine/policyengine.py that referenced this pull request Nov 2, 2026
… 2.165.1

The bundle moves from the national cut 0a4c8e2c (a8e25870c, 2.164.2) to
f6e33b93, built from PolicyEngine/microcosm#1428 at 329a99e71: the
independent review's fixes, recalibrated trading allowance claimant
rates, and main with PolicyEngine/microcosm#1422 and
PolicyEngine/microcosm#1430 merged. It is certified on its own build,
policyengine-uk 2.165.1.

The local-area cut stays 6528c865 (Max's call between it and c9df4b86),
re-audited for 2.165.1: no dropped column, and fourteen inputs added
after 2.162.12 take their defaults, now including the nine company car
inputs, so local-area results carry no company cars.

The lock, release lock, TROs and the household calculator snapshots are
regenerated on 2.165.1, and the bundle checks pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22 added a commit that referenced this pull request Nov 2, 2026
#1422 landed (11c1f4e), followed by #1430's company cars and #1432's
default load. Only pinned and generated files conflicted. The gate
battery and spine-part digests are re-pinned on the merged gates spec
(five move), and the release input coverage manifest and the charter-H2
fixture are regenerated; the fixture checks byte-identical.
policyengine-uk stays at 2.165.1, the lock main and #1428 share, and the
feed pin moves to main's 08962f1.

The H2 parity test and 86 related engine-free files pass (5,682 tests,
53 skipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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