Skip to content

Bus fare cap: an earned gap, and the near-miss that nearly made it false (#106) - #108

Open
vahid-ahmadi wants to merge 1 commit into
mainfrom
uk/bus-fares-gap
Open

Bus fare cap: an earned gap, and the near-miss that nearly made it false (#106)#108
vahid-ahmadi wants to merge 1 commit into
mainfrom
uk/bus-fares-gap

Conversation

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

Closes #106. Branches from main — independent of the other UK PRs.

The £2/£3 bus fare cap cannot be scored at the certified pin. The verdict matters less than how it was reached, because my first pass at it was wrong — and it was wrong in exactly the way #100 and #101 were wrong.

The near-miss

That first pass searched the parameter tree, found gov.dft has only rail and spending, and concluded bus was absent from the model.

It isn't. bus_subsidy_spending is a variable, and a parameter-only search misses it:

dft_subsidy_spending   adds = [rail_subsidy_spending, bus_subsidy_spending]

Bus subsidy is a household-level quantity and its incidence is modelled. Caught before publishing this time, rather than in review afterwards.

The gap that survives

Narrower and more useful than "PolicyEngine doesn't do buses":

rail bus
quantity rail_usage
price lever gov.dft.rail.fare_index
relationship rail_usage × fare_index flat input
reform possible yes no

bus_subsidy_spending is a pure input — no formula, no parameter behind it. So the gap is a missing factorisation, not a missing subject area. That makes the upstream ask small and precedented: give bus what rail already has (bus_usage, gov.dft.bus.fare_index, and the product between them). It would make every future fare-cap question scoreable, not just this one.

The validator enforces the lesson, not the conclusion

--resolve can't catch a false gap — a not_expressible entry carries no path to resolve. So:

  • A gap claim needs a name search covering variables as well as parameters. --probe re-runs both against a real engine and refuses drift.
  • The registry must record what the engine does have. Omitting the present-but-unusable machinery overstates the gap.
  • has_formula must be False. That single fact is what the verdict rests on — if it changes upstream, the probe fails loudly rather than leaving a stale opinion in the repo.
  • The nearest lever (gov.contrib.policyengine.economy.transport, "raise transport expenses by this percentage", currently 0) must be named and ruled out. It moves all transport, isn't bus-specific, and isn't a fare cap — using it would model something else and label it as the measure. Unruled-out, a reader finds it and assumes nobody looked.
  • No staged value claims — the DfT-commissioned Frontier Economics/SYSTRA evaluation figures belong to their originators (Ingest the two harvested-but-unused UK think-tank families: IFS (268) + Resolution Foundation (71) #86/Ingest the two harvested-but-unused UK think-tank families: IFS + Resolution Foundation (#86) #91).

One honest detail pinned in the tests: the search pattern's bus matches gov.hmrc.business_rates. Substring false positive, not a bus lever, recorded as such so nobody mistakes it later.

Why hold it at all

The £3 cap runs to March 2027, so it's live for the 28 October Budget, and DfT has a published independent evaluation of the £2 cap. The claims are worth holding even while the counterpart isn't computable — that's what a mode-1 lane with a named gap is for.

Gates

277 tests (14 new), ruff format --check clean, two build_db runs agree on content_hash, no drift.

#107 is the scoreable half of the pair.

…lse (#106)

The £2/£3 bus fare cap cannot be scored at the certified pin. The
verdict matters less than how it was reached, because the first pass at
it was WRONG.

That first pass searched the parameter tree, found gov.dft has only
rail and spending, and concluded bus was absent from the model. It is
not. bus_subsidy_spending is a VARIABLE, and a parameter-only search
misses it — the same shape as the two false engine gaps in #100 and
#101, caught this time before publishing rather than after.

What is actually there:

  dft_subsidy_spending  adds = [rail_subsidy_spending, bus_subsidy_spending]

So bus subsidy is a household-level quantity and its incidence IS
modelled. The gap is narrower and more useful than "PolicyEngine does
not do buses": bus_subsidy_spending is a PURE INPUT — no formula, no
parameter behind it — where rail has the price/quantity split a reform
needs (rail_usage x gov.dft.rail.fare_index). The gap is a missing
FACTORISATION, not a missing subject area, which is why the upstream
ask is small and precedented: give bus what rail already has.

The validator enforces the lesson rather than the conclusion:

  - a gap claim needs a NAME SEARCH, and one that covers variables as
    well as parameters (--probe re-runs both against a real engine and
    refuses drift)
  - the registry must say what the engine DOES have; omitting the
    present-but-unusable machinery overstates the gap
  - has_formula must be False — that single fact is what the verdict
    rests on, so if it changes upstream the probe fails loudly instead
    of leaving a stale opinion in the repo
  - the nearest lever (gov.contrib.policyengine.economy.transport) must
    be named AND ruled out, or a reader finds it and assumes nobody
    looked
  - no staged value claims: the DfT-commissioned evaluation figures
    belong to their originators (#86/#91)

One honest detail pinned in the tests: the search pattern's "bus"
matches gov.hmrc.business_rates. It is a substring false positive, not
a bus lever, and it is recorded as such.

#105 is the scoreable half of the pair.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
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.

Bus fares (mode 1 + engine gap): the £3 cap has no lever, and the fix is the factorisation rail already has

1 participant