Skip to content

OBR policy effects → DB ingest: 266 claims, four macro-effect metrics - #81

Open
vahid-ahmadi wants to merge 6 commits into
uk/obr-policy-effects-harvestfrom
uk/obr-policy-effects-ingest
Open

OBR policy effects → DB ingest: 266 claims, four macro-effect metrics#81
vahid-ahmadi wants to merge 6 commits into
uk/obr-policy-effects-harvestfrom
uk/obr-policy-effects-ingest

Conversation

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

Stacked on #75 (base uk/obr-policy-effects-harvest); retargets to main when #75 merges. Both branches carry a merge of current main so the post-#74 build machinery is present — the diff here is this lane's nine files only.

#75 landed 266 rows in data/externals/obr-policy-effects.json that nothing consumed: no build_db step read the file, so the claims never reached the DB or the app. This wires them in.

What's here

  • scorecard_db/ingest_obr_policy_effects.py — new source obr_policy_effects, registered in build_db's chain after uk_deductions (external claims before the campaign attaches). Same contract as ingest_uk_externals: require_fields on every row, closed identity registries, values passed through verbatim, calibration_relationship from relationships.uk_relationship and never inline. One transaction replaces the source wholesale with the baseline-registration gate inside it.
  • Four new Metric members. gdp_level_effect (a package's effect on the real-GDP path) and supply_side_impact (one measure's effect on potential output) are deliberately distinct rather than one "GDP effect" metric — unifying them would merge a demand-inclusive path with a supply-side scoring. decisions_effect_on_borrowing (PSNB) is likewise kept apart from revenue_change and cash_requirement_change (PSNCR), the same unconfusability rule UC deductions: FRR family re-harvested from primary sources + DB ingest #52 applied.
  • Reform worlds: each row carries a policy_ref reform naming what was scored — obr_<event>_package for the chart families, obr_<event>_<measure> for the supply-side family, so the same measure re-scored at a later event is a different world. Baseline stays the null current_law: OBR scores an announcement against the law in force at its own scoring date, which is exactly the convention baselines.py documents (announcement vintage is a condition, not a baseline world). No new baseline registered.
  • scorecard_db/uk_aliases.py — the source's own closed vocabulary (29 programs, 26 subgroups, geography, units). Registered as its own namespace with nothing aliased into obr's welfare programs: this source's employer_nics is a measure whose supply-side effect is scored, not a spending line.
  • tests/test_obr_policy_effects_ingest.py — 24 tests; suite 277 passed / 2 skipped.

Two identity decisions the data forced

1. conditions["decomposition"] — caught by finish(), not by inspection. The October 2024 workbook prints the AB2024 package twice: chart 2.A by expenditure component, 2.B by measure/channel. Both publish a total and a demand_multipliers, so the two totals collided on one claim_id (they agree to ~1e-14, being the same series rendered twice). The decomposition is therefore identity-bearing, not provenance. It keys off (fiscal_event, sheet) because the sheet id alone is not the identity — C2.A is by-channel in the Nov 2023 and Mar 2024 workbooks and by-expenditure-component in Oct 2024. Unregistered pairs raise.

2. The supply-side horizon — the one interpretive call in this PR. Briefing paper No.10's Table 2.1 states its year in words, never as a digit: "Supply-side impact is the impact on potential output in the fifth year of our forecast." period is an int, so something had to be chosen. The note rides verbatim in conditions["horizon_note"], conditions["horizon"] names it symbolically, and the year maps in exactly one place — _BP10_HORIZON_FY = "2030-31" (the fifth year of the November 2025 forecast the paper accompanies). Flagging it explicitly: if you read the horizon differently, it is a one-constant change, and the test pins the mapping rather than the reasoning.

Verification

  • uk_policy_effects step: {"claims": 266, "by_metric": {"gdp_level_effect": 151, "cpi_inflation_effect": 36, "supply_side_impact": 19, "decisions_effect_on_borrowing": 60}} — exact accounting, so a drifted harvest regeneration fails here rather than silently resizing the catalog.
  • Build deterministic across two from-scratch runs (8fb2adf0…), and both no-drift gates clean — replicated exactly as ci.yml runs them (build ×2 → git diff --exit-code → pytest → git diff --exit-code).
  • Table B.1's aggregate_level/parent guard survives into conditions and is asserted at DB level (6 total / 24 subtotal / 30 component, zero orphaned non-total rows), so summing borrowing effects by FY cannot double-count.
  • All 266 are held_out: no pe-uk-data target or policyengine-uk parameter is fitted to a macro-effect path — they are what the Macro members get scored against.

Derived-file changes are the build's own output, committed as built: data/lanes.json gains the lane entry, mirrored to the app copy. The feed's top-level stamp stays on the UK family's constant (FEED_UPDATED) because the build ends on this step while the suite ends on whichever UK ingest test runs last — all three writers have to agree or the no-drift gate fails; the comment says so.

PE counterparts are step 3 of #55 and are not computed here.

Builds #55 (step 2: DB ingest).

🤖 Generated with Claude Code

r and others added 3 commits August 20, 2026 15:25
…metrics

The harvest (#75) landed 266 rows in data/externals/obr-policy-effects.json
that nothing consumed — invisible to the DB and the app. This wires them
in as their own source (obr_policy_effects), registered in build_db's
chain after uk_deductions.

- Four new Metric members. gdp_level_effect (package effect on the
  real-GDP path) and supply_side_impact (one measure's effect on
  POTENTIAL output) are deliberately distinct, as is
  decisions_effect_on_borrowing (PSNB) from revenue_change and
  cash_requirement_change (PSNCR).
- Reform worlds are policy_ref slugs — the fiscal event's package, or
  the measure for the supply-side family — against the null current_law
  baseline, per baselines.py's documented convention (announcement
  vintage is a condition, not a baseline world). No new baseline
  registered.
- conditions["decomposition"], keyed off (fiscal_event, sheet): the
  October 2024 workbook prints the AB2024 package twice (2.A by
  expenditure component, 2.B by measure/channel), so both publish a
  'total' and a 'demand_multipliers'. Without the axis the two totals
  collide on one claim_id — finish() caught it. The sheet id alone is
  not the identity: C2.A is by-channel in the Nov 2023 and Mar 2024
  workbooks.
- Briefing paper No.10 states its horizon in words ("the fifth year of
  our forecast"), never a digit. The note rides verbatim in conditions,
  horizon names it symbolically, and the year maps in one place
  (_BP10_HORIZON_FY) for a reviewer to re-key.
- Table B.1's aggregate_level/parent guard survives into conditions, so
  summing borrowing effects by FY cannot double-count. All 266 are
  held_out (relationships.uk_relationship, never inline): no pe-uk-data
  target or policyengine-uk parameter is fitted to a macro-effect path.

Derived-file changes are the build's own output, committed as built:
data/lanes.json gains the lane entry and its updated date, mirrored to
the app copy.

Suite: 277 passed / 2 skipped (24 new); build deterministic across two
runs; no-drift gate clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Contributor

Gate round 1 (reviewed as a pair with #75) — the transaction shape is right; five findings, four blocking

Clean and verified: adapter-to-DB accounting is exact (266 rows, 266 unique ids, 151/36/19/60, no ingest drops), the transaction correctly does delete + insert + register_baselines_txn + lane atomically with the feed sync post-commit, builder registration is present in build_db.py (the load-bearing post-#74 integration — results survive CI rebuilds), lane mirrors match, and no wins language anywhere.

  1. Blocking — all 266 claims get the wrong baseline. The ingest defaults every claim to registered current_law (_reform() supplies no baseline; the test requires baseline is None) — contradicting OBR published economic effects of policy: AS2023–AB2025 packages, supply-side scorings, March 2026 decisions (external side) #75's own declaration that rows score against each EFO's pre-measures forecast. The registration gate passes only because it registers the wrong world. Each fiscal event needs its announcement-baseline world registered with provenance and carried on the claims (mirrored in baseline_policy), or a PE result computed against current law will silently read as comparable.

  2. Blocking — all 19 BP10 claims are miskeyed to FY2030-31. BP10 reviews scores from five earlier EFOs; each value belongs to its scoring-event horizon (AS2023 NICs → 2028-29, AB2024 public investment → 2029-30, …). Period is claim identity, so all 19 need per-event periods.

  3. Blocking — no deliberate unit concepts. models.py adds metrics only; the mapping collapses GDP-level deviations, CPI percentage points, and potential-output percent-of-GDP into PERCENT, and annual borrowing into bare GBP — and the staged unit is canon-checked then discarded, so a GDP row mislabeled gbp_nominal stages as percent instead of raising. The staged label must bind to the expected concept per metric (the UC deductions: FRR family re-harvested from primary sources + DB ingest #52 pattern: validate-then-map, raise on drift).

  4. Blocking — 176/266 publication dates are wrong. All 187 chart claims share a generic URL + 2025-11-26, misdating the 116 AS2023/SB2024/AB2024 claims; the 60 March-2026 claims carry the Wayback capture date (2026-03-16) instead of the publication date (2026-03-03). Publication provenance is per-event, and archive-capture dates are retrieval metadata, not publication dates.

  5. Closed-condition and staging gates incomplete. Seven emitted keys are absent from STANDARD_CONDITIONS (decomposition, fiscal_event, horizon, horizon_note, measure_type, scope, sign_convention); rows missing borrowing sign conventions or subtotal parents stage instead of raising; and the held-out evidence should name pe-uk-data@dd68c73 explicitly like the neighboring registry entries.

The skeleton is genuinely good — the fixes are concentrated in claim identity (baseline, period, units, dates), which is exactly where they're cheapest to fix before anything attaches.

🤖 Generated with Claude Code

r and others added 2 commits August 21, 2026 12:41
Reviewed as a pair with #75, which this branch now carries; the harvest
side supplies the per-row provenance the fixes here consume.

1. Baselines are the rounds' own pre-measures worlds, not current_law.
   Every claim now carries a ReformRef.baseline descriptor —
   {policy: obr_pre_measures_<round>, counterfactual: <kind>} — mirrored
   into conditions["baseline_policy"], with the counterfactual kind
   split as Briefing paper No.10 chapter 2 splits it (legislated
   parameters for tax/welfare, the pre-existing activity baseline for
   DEL, the regulatory baseline for planning). March 2026 Table B.1 keys
   the November 2025 Budget forecast, its own stated counterfactual. All
   eleven (round, counterfactual) worlds are registered in baselines.py,
   a row whose baseline disagrees with its own round raises, and a
   baseline without a locator raises.

2. BP10 horizons are per scoring round. The paper re-states scorings from
   five earlier events, so "the fifth year of our forecast" is each
   measure's own round's fifth year: 2027-28 (Mar 2023) through 2029-30
   (Mar 2025). All 19 were keyed to 2030-31, the November 2025 round's
   horizon. Period is claim identity, so those were 19 claims about years
   OBR never scored them for. _BP10_HORIZON resolves per event and an
   unregistered round raises rather than borrowing another's year.

3. Unit concepts are deliberate, and validated before mapping. Three new
   UnitConcept members — PERCENT_OF_REAL_GDP, PERCENTAGE_POINTS,
   PERCENT_OF_POTENTIAL_GDP — replace the collapse into bare PERCENT,
   and the staged label is now compared against the metric's expected
   unit and raises on drift instead of being canon-checked then
   discarded. Bare "percent" is no longer a registered label for this
   source, so the three cannot collapse back.

4. Publication provenance is per artifact. Each round's claims carry
   their own release date and dated URL rather than one generic
   obr.uk/publications/ stamp dated 2025-11-26, and the 60 March-2026
   claims carry the publication date (2026-03-03) instead of the Wayback
   capture (2026-03-16). An unregistered artifact raises.

5. `basis` stops squatting: it is `forecast` on every row (its repo-wide
   meaning) and the scoring method moved to its own `scoring_method`
   condition, which also keys the relationship lookup.

266 claims and the exact accounting unchanged; the DB builds
deterministically (two builds agree on content_hash), suite 299 passed,
ruff format clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
vahid-ahmadi pushed a commit that referenced this pull request Aug 21, 2026
1. Units are no longer conflated. GDP-level deviations, CPI effects and
   supply-side impacts were all emitting bare `percent`; they are three
   different quantities and now carry three unit concepts —
   `percent_of_real_gdp`, `percentage_points`, `percent_of_potential_gdp`
   — with `gbp_nominal` unchanged on Table B.1. Same rule that split
   GBP_PER_WEEK from bare GBP, and the adapter test now pins the mapping
   per metric instead of entrenching the conflation.

2. 266 is source-reconciled. Table B.1 prints 66 numeric cells across 11
   lines; the parser selected 10 and the memo current-budget line fell
   through a silent `continue`. Every value-bearing line is now
   classified — emitted (TB1_ROWS) or a declared drop (TB1_DROPS, with a
   reason) — and an unclassified value line raises. The build reports and
   asserts `272 source cells = 266 claims + 6 deliberate drops`; the memo
   line is dropped because it scores the current budget, not PSNB.

3. Baseline provenance is per row, not one blanket sentence. Each row
   carries the pre-measures world of its own round
   (`obr_pre_measures_<event>`), the counterfactual KIND — Briefing paper
   No.10 scores tax/welfare measures against a legislated-parameter
   counterfactual and DEL/regulatory measures against the pre-existing
   activity baseline — and a locator. March 2026 Table B.1 keys
   `obr_november_2025_budget_forecast`, its own stated counterfactual,
   and the March 2025 WCA reversal's locator records that it scores
   against the WCA-adjusted world AS2023 created.

4. Identity vocabulary is closed, and `basis` stops squatting. Chart
   series labels, BP10 measures, types and channels are explicit
   registries; an unregistered label raises instead of `_slug()` minting
   one. `basis` is now `forecast` (its standard meaning everywhere in
   this repo) and the scoring method moved to its own `scoring_method`
   axis.

Also: publication provenance is per artifact, so each round's claims
carry their own release date and URL rather than one generic
obr.uk/publications/ stamp — the input to #81's date fix.

266 claims unchanged; suite 261 passed, ruff format clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
@vahid-ahmadi

Copy link
Copy Markdown
Contributor Author

All five addressed in 50f1dea (this branch now carries the repaired #75).

  1. Baselines. No claim defaults to current_law any more. Each carries {policy: obr_pre_measures_<round>, counterfactual: <kind>} mirrored into conditions["baseline_policy"], with the counterfactual kind split as BP10 ch.2 splits it. March 2026 keys the November 2025 Budget forecast. All eleven (round, counterfactual) worlds are registered in baselines.py; a row whose baseline disagrees with its own round raises, and a baseline without a locator raises.

  2. BP10 periods. Agreed and fixed — the paper re-states scorings from five earlier events, so "the fifth year of our forecast" is each measure's own round: 2027-28 (Mar 2023) through 2029-30 (Mar 2025). _BP10_HORIZON resolves per event and an unregistered round raises rather than borrowing another's year. A test asserts 2031 appears nowhere.

  3. Units. Three new UnitConcept members replace the collapse into PERCENT, and the staged label is now compared against the metric's expected unit and raises on drift instead of being canon-checked then discarded. Bare "percent" is no longer a registered label for this source, so the three cannot collapse back.

  4. Publication dates. Per artifact now: 2023-11-22 / 2024-03-06 / 2024-10-30 / 2025-11-26, and the 60 March-2026 claims carry 2026-03-03 rather than the Wayback capture. An unregistered artifact raises.

  5. basis. Now forecast on every row; the scoring method moved to conditions["scoring_method"], which also keys the relationship lookup.

266 claims and the exact accounting unchanged; two builds agree on content_hash; suite 299 passed, ruff format clean.

@DTrim99 DTrim99 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed on tip 50f1dea (includes #75) — pytest tests/test_obr_policy_effects_ingest.py = 38 passed. Clean ingest, same discipline as the rest of the lane.

Verified:

  • Accounting — no drops here (every staged row ingests), so it degenerates to staged=ingested=266, asserted via per-metric _EXPECTED (raises on drift) + the finish collision gate; 266 reconciles as 151+36+19+60.
  • The four macro metrics (gdp_level_effect, cpi_inflation_effect, supply_side_impact, decisions_effect_on_borrowing) are genuine and correctly kept unconfusable — real-GDP path vs potential-output supply-side scoring, and PSNB vs revenue_change/PSNCR. CPI carried as percentage_points (a rate effect, not a level) is the right call. None should have been a drop.
  • Fail-loud on unknown field / every closed axis / unit-label drift / unregistered artifact / (event,sheet) / baseline — all tested.
  • held_out — all 266 → HELD_OUT with a "scored, never consumed" basis (macro paths are what Macro members are scored against, not calibration targets); the decomposition DISTINCT edge keyed on (event,sheet) correctly resolves the twice-printed AB2024 collision.
  • Values pass through verbatim (no re-derivation/re-signing), baselines.py registers the 11 (round, counterfactual) pre-measures worlds + the Mar-2026 post-measures world inside the ingest txn (asserted complete, no current_law default), and the lanes.json mirror is semantically identical.

Two tiny notes: the PR body still describes an outdated single-constant _BP10_HORIZON_FY, but the code correctly uses the per-event _BP10_HORIZON (body stale, code right); and the only lanes.json byte-diff is an unrelated em-dash escaping in a _schema note. Approving — merge after #75.

Found in an integration review pass. Six of the open UK branches insert
a build step at the same anchor in build_db.py, so it conflicts on
nearly every merge — and the conflict region SPLITS A STEP TUPLE, which
I confirmed by simulating the merge queue: a naive both-sides union
produces invalid Python. That is the good case. The bad case is a
resolution that drops a step, which ships a database missing an entire
lane while every test still passes, because the tests that would notice
are the ones the dropped ingest brought with it.

So the import list is now the contract: a module imported at the top of
build_db must appear in the chain. build() checks it before running
anything, and the failure names the merge-resolution cause rather than
just the symptom. Verified by deleting a step and watching it refuse.

This is a small guard on a real hazard — with twelve UK branches in
flight against one ordered list, the queue will hit this conflict
repeatedly, and 'resolve by taking both sides' is exactly what a hurried
resolver does.

Suite 301 passed, two builds agree on content_hash, ruff format clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
@vahid-ahmadi

Copy link
Copy Markdown
Contributor Author

Integration review — three findings, all fixed. I re-checked all twelve open UK branches together rather than one at a time, and simulated the merge queue.

1. This branch and #64 had never met the post-#74 gates

Both forked before #74 (the database leaving git), so neither carried scorecard_db/build_db.py and both still tracked data/scorecard.db as a committed binary. Because CI config is per-branch, they were running the pre-#74 workflow — which means the determinism check and the no-drift gate had never executed against them at all. The green tick was a weaker check than it looked, and both of us read it as the current one.

Current main is merged into both now. They build from scratch, two builds agree on content_hash, the tree is clean afterwards, and the suites go 287 → 325 (#49) and 321 → 359 (#64) because main's own tests come with it. No conflicts: main's deletion of the committed database wins over an untouched file on the branch side, so nothing resurrects.

2. build_db.py will conflict on nearly every merge, and the conflict is dangerous

Six of the open UK branches insert a build step at the same anchor. I merged them in order on a scratch branch: the conflict region splits a step tuple, so resolving it by taking both sides produces invalid Python. That is the good case.

The bad case is a resolution that drops a step. That ships a database missing an entire lane while every test still passes — because the tests that would have noticed are the ones the dropped ingest brought with it.

So build() now refuses a chain that imports an ingest it never runs. The import list is the contract; the error names the merge-resolution cause rather than the symptom. Verified by deleting a step and watching it refuse:

build_db imports ['ingest_obr_policy_effects'] but never runs them. Either the
chain lost a step in a merge resolution (build_db.py conflicts on nearly every
branch, and the conflict splits a step tuple), or the import is dead.

It is on this branch because this is the first in the queue that touches build_db.py; once it lands it protects the five behind it.

3. A merge-order note for whoever runs the queue

The UK ingest steps are mutually independent except uk_thinktanks, which must run before produce_uk#86 changed produce_campaign_uk's blocked-family reasoning on the basis that RF claims now exist. The current insertion point (after uk_deductions) satisfies that. be_jrc stays last, as its own comment requires.

Everything else verified clean: all twelve branches build, pass, and leave no drift individually.

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.

3 participants