Skip to content

Commit 2ef0ea2

Browse files
authored
Merge pull request #442 from PolicyEngine/feat/multi-year-sim-api
Add budget-window batch gateway contracts
2 parents 6898ebf + 95e3c9c commit 2ef0ea2

8 files changed

Lines changed: 793 additions & 11 deletions

File tree

projects/policyengine-api-simulation/fixtures/gateway/test_endpoints.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def from_name(cls, app_name: str, func_name: str):
7474
@pytest.fixture
7575
def mock_modal(monkeypatch):
7676
"""Patch Modal calls in the gateway endpoints module."""
77+
from src.modal import budget_window_state
7778
from src.modal.gateway import endpoints
7879

7980
mock_func = MockFunction()
@@ -101,6 +102,7 @@ class MockModal:
101102
FunctionCall = MockFunctionCall
102103

103104
monkeypatch.setattr(endpoints, "modal", MockModal)
105+
monkeypatch.setattr(budget_window_state, "modal", MockModal)
104106

105107
return {
106108
"func": mock_func,
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Helpers for budget-window batch job state."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import UTC, datetime
6+
7+
import modal
8+
9+
from src.modal.gateway.models import (
10+
BudgetWindowBatchRequest,
11+
BudgetWindowBatchState,
12+
BudgetWindowBatchStatusResponse,
13+
PolicyEngineBundle,
14+
)
15+
16+
BUDGET_WINDOW_JOB_DICT_NAME = "simulation-api-budget-window-jobs"
17+
18+
19+
def _budget_window_job_store():
20+
return modal.Dict.from_name(BUDGET_WINDOW_JOB_DICT_NAME, create_if_missing=True)
21+
22+
23+
def _utc_now_iso() -> str:
24+
return datetime.now(UTC).isoformat()
25+
26+
27+
def _build_years(start_year: str, window_size: int) -> list[str]:
28+
base_year = int(start_year)
29+
return [str(base_year + offset) for offset in range(window_size)]
30+
31+
32+
def create_initial_batch_state(
33+
*,
34+
batch_job_id: str,
35+
request: BudgetWindowBatchRequest,
36+
resolved_version: str,
37+
resolved_app_name: str,
38+
bundle: PolicyEngineBundle,
39+
) -> BudgetWindowBatchState:
40+
years = _build_years(request.start_year, request.window_size)
41+
now = _utc_now_iso()
42+
43+
return BudgetWindowBatchState(
44+
batch_job_id=batch_job_id,
45+
status="submitted",
46+
country=request.country,
47+
region=request.region,
48+
version=resolved_version,
49+
target=request.target,
50+
resolved_app_name=resolved_app_name,
51+
policyengine_bundle=bundle,
52+
start_year=request.start_year,
53+
window_size=request.window_size,
54+
max_parallel=request.max_parallel,
55+
request_payload=request.model_dump(exclude={"telemetry"}, mode="json"),
56+
years=years,
57+
queued_years=list(years),
58+
running_years=[],
59+
completed_years=[],
60+
failed_years=[],
61+
child_jobs={},
62+
partial_annual_impacts={},
63+
result=None,
64+
error=None,
65+
created_at=now,
66+
updated_at=now,
67+
run_id=request.telemetry.run_id if request.telemetry else None,
68+
)
69+
70+
71+
def get_batch_job_state(batch_job_id: str) -> BudgetWindowBatchState | None:
72+
payload = _budget_window_job_store().get(batch_job_id)
73+
if payload is None:
74+
return None
75+
return BudgetWindowBatchState.model_validate(payload)
76+
77+
78+
def put_batch_job_state(state: BudgetWindowBatchState) -> None:
79+
serialized = state.model_dump(mode="json")
80+
_budget_window_job_store()[state.batch_job_id] = serialized
81+
82+
83+
def build_batch_status_response(
84+
state: BudgetWindowBatchState,
85+
) -> BudgetWindowBatchStatusResponse:
86+
total_years = len(state.years)
87+
progress = (
88+
0 if total_years == 0 else round(len(state.completed_years) / total_years * 100)
89+
)
90+
91+
return BudgetWindowBatchStatusResponse(
92+
status=state.status,
93+
progress=progress,
94+
completed_years=state.completed_years,
95+
running_years=state.running_years,
96+
queued_years=state.queued_years,
97+
failed_years=state.failed_years,
98+
child_jobs=state.child_jobs,
99+
result=state.result,
100+
error=state.error,
101+
resolved_app_name=state.resolved_app_name,
102+
policyengine_bundle=state.policyengine_bundle,
103+
run_id=state.run_id,
104+
)

projects/policyengine-api-simulation/src/modal/gateway/endpoints.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
from fastapi.responses import JSONResponse
1111

1212
from src.modal.gateway.models import (
13+
BudgetWindowBatchRequest,
14+
BudgetWindowBatchStatusResponse,
15+
BudgetWindowBatchSubmitResponse,
1316
JobStatusResponse,
1417
JobSubmitResponse,
1518
PingRequest,
@@ -156,6 +159,25 @@ async def submit_simulation(request: SimulationRequest):
156159
)
157160

158161

162+
@router.post(
163+
"/simulate/economy/budget-window",
164+
response_model=BudgetWindowBatchSubmitResponse,
165+
response_model_exclude_none=True,
166+
)
167+
async def submit_budget_window_batch(request: BudgetWindowBatchRequest):
168+
"""
169+
Submit a budget-window batch job.
170+
171+
This contract-first endpoint is intentionally disabled until the
172+
orchestration worker lands in the follow-up PR. That keeps the route
173+
mergeable without falsely claiming that work has started.
174+
"""
175+
raise HTTPException(
176+
status_code=501,
177+
detail="Budget-window batch orchestration is not implemented yet",
178+
)
179+
180+
159181
@router.get(
160182
"/jobs/{job_id}",
161183
response_model=JobStatusResponse,
@@ -205,6 +227,24 @@ async def get_job_status(job_id: str):
205227
)
206228

207229

230+
@router.get(
231+
"/budget-window-jobs/{batch_job_id}",
232+
response_model=BudgetWindowBatchStatusResponse,
233+
response_model_exclude_none=True,
234+
)
235+
async def get_budget_window_job_status(batch_job_id: str):
236+
"""
237+
Poll for budget-window batch status.
238+
239+
This contract-first endpoint is intentionally disabled until the
240+
orchestration worker lands in the follow-up PR.
241+
"""
242+
raise HTTPException(
243+
status_code=501,
244+
detail="Budget-window batch orchestration is not implemented yet",
245+
)
246+
247+
208248
@router.get("/versions")
209249
async def list_versions():
210250
"""List all available versions for all countries."""

projects/policyengine-api-simulation/src/modal/gateway/generate_openapi.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
from fastapi import FastAPI
1616

1717
from src.modal.gateway.models import (
18+
BudgetWindowBatchRequest,
19+
BudgetWindowBatchStatusResponse,
20+
BudgetWindowBatchSubmitResponse,
1821
JobStatusResponse,
1922
JobSubmitResponse,
2023
PingRequest,
@@ -48,6 +51,24 @@ async def submit_simulation(request: SimulationRequest) -> JobSubmitResponse:
4851
"""
4952
raise NotImplementedError("Stub for OpenAPI generation")
5053

54+
@app.post(
55+
"/simulate/economy/budget-window",
56+
response_model=BudgetWindowBatchSubmitResponse,
57+
responses={
58+
200: {"description": "Budget-window batch submitted successfully"},
59+
400: {"description": "Invalid request (unknown country/version/year)"},
60+
},
61+
)
62+
async def submit_budget_window_batch(
63+
request: BudgetWindowBatchRequest,
64+
) -> BudgetWindowBatchSubmitResponse:
65+
"""
66+
Submit a budget-window batch job.
67+
68+
Returns immediately with a parent batch job ID for polling.
69+
"""
70+
raise NotImplementedError("Stub for OpenAPI generation")
71+
5172
@app.get(
5273
"/jobs/{job_id}",
5374
response_model=JobStatusResponse,
@@ -70,6 +91,27 @@ async def get_job_status(job_id: str) -> JobStatusResponse:
7091
"""
7192
raise NotImplementedError("Stub for OpenAPI generation")
7293

94+
@app.get(
95+
"/budget-window-jobs/{batch_job_id}",
96+
response_model=BudgetWindowBatchStatusResponse,
97+
responses={
98+
200: {
99+
"description": "Batch complete",
100+
"model": BudgetWindowBatchStatusResponse,
101+
},
102+
202: {"description": "Batch submitted or running"},
103+
404: {"description": "Batch job not found"},
104+
500: {"description": "Batch failed"},
105+
},
106+
)
107+
async def get_budget_window_job_status(
108+
batch_job_id: str,
109+
) -> BudgetWindowBatchStatusResponse:
110+
"""
111+
Poll for budget-window batch status.
112+
"""
113+
raise NotImplementedError("Stub for OpenAPI generation")
114+
73115
@app.get("/versions")
74116
async def list_versions() -> dict:
75117
"""List all available versions for all countries."""

0 commit comments

Comments
 (0)