Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/validate-deployment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ jobs:
# runs only on a manual `deep` dispatch. force_build guarantees the deployed
# model is always freshly cloned, so the fast suite is enough to confirm a
# functional deploy on the automatic path.
timeout-minutes: 20
# 35 min: the slow leg now includes the OBR bridge end-to-end test (up to
# ~9 min against a cold server) on top of the closure probe (~7 min); the
# 20-min cap already timed out once on 2026-07-17.
timeout-minutes: 35
# On workflow_run, only validate when the deploy actually succeeded;
# schedule/manual runs always validate.
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
Expand Down
70 changes: 65 additions & 5 deletions integration/tests/test_remote_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,10 @@ async def test_score_reform_investment_closure_bounded_and_signed():


@pytest.mark.anyio
async def test_score_reform_obr_errors_actionably():
"""The unified score_reform tool is live; its OBR arm (pending the
static-costing bridge, #9) must fail with a pointer to obr_shock,
not silently or cryptically."""
async def test_score_reform_obr_refuses_corp_tax_actionably():
"""The OBR bridge (#9) must refuse corporation-tax reforms — they are not
household-borne, so the static-costing bridge cannot carry them — with a
pointer to the direct obr_shock TCPRO lever, not silently or cryptically."""
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client

Expand All @@ -156,12 +156,72 @@ async def test_score_reform_obr_errors_actionably():
await session.initialize()
res = await session.call_tool(
"score_reform",
{"country": "uk", "reform": {"x": 1}, "model": "obr"},
{"country": "uk",
"reform": {"gov.hmrc.corporation_tax.main_rate": 0.28},
"model": "obr"},
)
assert res.isError
assert "obr_shock" in json.dumps([c.text for c in res.content])


@pytest.mark.anyio
async def test_score_reform_microsim_scoreresult():
"""score_reform(model='microsim') on the hosted server: a basic-rate
rise must raise revenue, and the common ScoreResult block (#10) must be
present and coherent (units, basis, distributional)."""
import asyncio

out = await asyncio.wait_for(
_call(
"score_reform",
{"country": "uk",
"reform": {"gov.hmrc.income_tax.rates.uk[0].rate": 0.21},
"model": "microsim"},
),
timeout=300,
)
assert out["budgetary_impact_bn"] > 0, out["budgetary_impact_bn"]
score = out["score"]
assert score["model_class"] == "microsim"
rev = score["quantities"]["revenue"]
assert rev["delta_bn"] == out["budgetary_impact_bn"]
assert rev["units"] and rev["basis"]
assert score["distributional"]["decile_impacts"]


@pytest.mark.slow
@pytest.mark.anyio
async def test_score_reform_obr_bridge_end_to_end():
"""The full #9 pipeline on the hosted server: microsim static costing →
HHDI shock path → OBR second-round effects. A basic-rate RISE raises
revenue, so disposable income and hence GDP must FALL, by a sane
magnitude. One-year window to keep the runtime bounded; slow-marked, so
it runs in the scheduled full validation, not on every deploy."""
import asyncio

out = await asyncio.wait_for(
_call(
"score_reform",
{"country": "uk",
"reform": {"gov.hmrc.income_tax.rates.uk[0].rate": 0.21},
"model": "obr", "years": 1},
),
timeout=540,
)
costing = out["annual_costings_bn"][0]["budgetary_impact_bn"]
assert costing > 0, costing
# Sign: revenue raised => HHDI falls => GDP falls.
assert all(q < 0 for q in out["quarterly_shock_path_m"])
cum = out["cumulative_delta_gdp_bn_over_shock_periods"]
assert cum < 0, cum
# Magnitude: |cumulative GDP effect| within ~2x the annual costing
# (demand-side multiplier of order 1 over the shocked year).
assert abs(cum) < 2 * costing + 1, (cum, costing)
score = out["score"]
assert score["model_class"] == "semi-structural"
assert score["caveats"]


@pytest.mark.anyio
async def test_forecast_uk_small():
out = await _call("forecast_uk", {"horizons": 4, "draws": 200})
Expand Down
Loading