Skip to content

Commit e665b62

Browse files
committed
feat: enable sim API cliff impacts
1 parent 10af713 commit e665b62

9 files changed

Lines changed: 223 additions & 22 deletions

File tree

projects/policyengine-api-simulation/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ dependencies = [
1616
"pydantic-settings (>=2.7.1,<3.0.0)",
1717
"opentelemetry-instrumentation-fastapi (>=0.51b0,<0.52)",
1818
"policyengine-fastapi",
19-
"policyengine==4.10.0",
19+
"policyengine==4.12.0",
2020
"policyengine-core==3.26.1",
2121
"policyengine-uk==2.88.20",
2222
"policyengine-us==1.700.0",

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,11 @@ def validate_end_year(self) -> "BudgetWindowBatchRequest":
184184
raise ValueError(
185185
f"budget-window end_year must be {self.MAX_END_YEAR} or earlier"
186186
)
187+
if self.include_cliffs is True:
188+
raise ValueError(
189+
"budget-window cliff impacts are not supported; use the single-year "
190+
"simulation endpoint with include_cliffs=true"
191+
)
187192
return self
188193

189194

projects/policyengine-api-simulation/src/policyengine_api_simulation/simulation_macro_output.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,16 @@ class LaborSupplyResponseOutput(MacroRootModel[dict[str, Any]]):
110110
pass
111111

112112

113+
class CliffImpactInSimulation(MacroOutputModel):
114+
cliff_gap: float
115+
cliff_share: float
116+
117+
118+
class CliffImpactOutput(MacroOutputModel):
119+
baseline: CliffImpactInSimulation
120+
reform: CliffImpactInSimulation
121+
122+
113123
class GeographicImpactOutput(MacroRootModel[list[dict[str, Any]]]):
114124
pass
115125

@@ -131,4 +141,4 @@ class SingleYearMacroOutput(MacroOutputModel):
131141
constituency_impact: GeographicImpactOutput | None
132142
local_authority_impact: GeographicImpactOutput | None
133143
congressional_district_impact: GeographicImpactOutput | None
134-
cliff_impact: None = None
144+
cliff_impact: CliffImpactOutput | None = None

projects/policyengine-api-simulation/src/policyengine_api_simulation/simulation_output_builder.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
AgePovertyOutput,
1515
BaselineReformValue,
1616
BudgetaryImpact,
17+
CliffImpactInSimulation,
18+
CliffImpactOutput,
1719
DecileOutput,
1820
DetailedBudgetOutput,
1921
DetailedBudgetProgramOutput,
@@ -252,10 +254,15 @@ def __post_init__(self) -> None:
252254
def analysis(self) -> Any:
253255
if self._analysis is None:
254256
self._analysis = self.country_module.economic_impact_analysis(
255-
self.baseline, self.reform
257+
self.baseline,
258+
self.reform,
259+
include_cliff_impacts=self._include_cliff_impacts(),
256260
)
257261
return self._analysis
258262

263+
def _include_cliff_impacts(self) -> bool:
264+
return self.simulation_params.get("include_cliffs") is True
265+
259266
def build(self) -> SingleYearMacroOutput:
260267
poverty_outputs = self._build_poverty_outputs()
261268
wealth_decile = getattr(self.analysis, "wealth_decile_impacts", None)
@@ -280,7 +287,7 @@ def build(self) -> SingleYearMacroOutput:
280287
congressional_district_impact=(self._build_congressional_district_impact()),
281288
constituency_impact=self._build_uk_constituency_impact(),
282289
local_authority_impact=self._build_uk_local_authority_impact(),
283-
cliff_impact=None,
290+
cliff_impact=self._build_cliff_impact(),
284291
)
285292

286293
def serialize(self) -> dict[str, Any]:
@@ -448,6 +455,18 @@ def _build_labor_supply_response(self) -> LaborSupplyResponseOutput | None:
448455
output = _output_model_dump(labor_supply_response)
449456
return LaborSupplyResponseOutput(output) if isinstance(output, dict) else None
450457

458+
def _build_cliff_impact(self) -> CliffImpactOutput | None:
459+
cliff_impact = getattr(self.analysis, "cliff_impact", None)
460+
if isinstance(cliff_impact, CliffImpactOutput):
461+
return cliff_impact
462+
output = _output_model_dump(cliff_impact)
463+
if not isinstance(output, Mapping):
464+
return None
465+
return CliffImpactOutput(
466+
baseline=CliffImpactInSimulation(**output["baseline"]),
467+
reform=CliffImpactInSimulation(**output["reform"]),
468+
)
469+
451470
def _build_geographic_impact_output(
452471
self, value: Any
453472
) -> GeographicImpactOutput | None:

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,27 @@ def test__given_submission__then_returns_job_id_and_poll_url(
229229
assert data["poll_url"] == "/jobs/mock-job-id-123"
230230
assert data["status"] == "submitted"
231231

232+
def test__given_submission_with_include_cliffs__then_forwards_worker_flag(
233+
self, mock_modal, client: TestClient
234+
):
235+
mock_modal["dicts"]["simulation-api-us-versions"] = {
236+
"latest": "1.500.0",
237+
"1.500.0": "policyengine-simulation-py4-10-0",
238+
}
239+
240+
response = client.post(
241+
"/simulate/economy/comparison",
242+
json={
243+
"country": "us",
244+
"scope": "macro",
245+
"reform": {},
246+
"include_cliffs": True,
247+
},
248+
)
249+
250+
assert response.status_code == 200
251+
assert mock_modal["func"].last_payload["include_cliffs"] is True
252+
232253
def test__given_submission_with_telemetry__then_preserves_run_id(
233254
self, mock_modal, client: TestClient
234255
):
@@ -714,6 +735,31 @@ def test__given_budget_window_submission__then_returns_parent_batch_job_id(
714735
"policyengine_bundle": expected_bundle("us", "1.500.0"),
715736
}
716737

738+
def test__given_budget_window_include_cliffs__then_returns_422(
739+
self, mock_modal, client: TestClient
740+
):
741+
mock_modal["dicts"]["simulation-api-us-versions"] = {
742+
"latest": "1.500.0",
743+
"1.500.0": "policyengine-simulation-py4-10-0",
744+
}
745+
746+
response = client.post(
747+
"/simulate/economy/budget-window",
748+
json={
749+
"country": "us",
750+
"region": "us",
751+
"scope": "macro",
752+
"reform": {},
753+
"start_year": "2026",
754+
"window_size": 3,
755+
"include_cliffs": True,
756+
},
757+
)
758+
759+
assert response.status_code == 422
760+
assert "cliff impacts are not supported" in response.text
761+
assert mock_modal["func"].last_payload is None
762+
717763
def test__given_budget_window_submission__then_initial_poll_returns_seed_state(
718764
self, mock_modal, client: TestClient
719765
):

projects/policyengine-api-simulation/tests/gateway/test_models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,16 @@ def test_budget_window_batch_request_rejects_non_general_target(self):
429429
target="cliff",
430430
)
431431

432+
def test_budget_window_batch_request_rejects_include_cliffs(self):
433+
with pytest.raises(ValidationError, match="cliff impacts are not supported"):
434+
BudgetWindowBatchRequest(
435+
country="us",
436+
region="us",
437+
start_year="2026",
438+
window_size=3,
439+
include_cliffs=True,
440+
)
441+
432442
def test_budget_window_batch_request_rejects_max_parallel_above_active_limit(self):
433443
with pytest.raises(ValidationError):
434444
BudgetWindowBatchRequest(

projects/policyengine-api-simulation/tests/test_simulation_output_builder.py

Lines changed: 103 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
REFORM_POVERTY_BY_AGE,
2020
REFORM_POVERTY_BY_GENDER,
2121
REFORM_POVERTY_BY_RACE,
22+
FakeModelOutput,
2223
fake_analysis,
2324
)
2425
from policyengine_api_simulation.simulation_runtime import RegionResolution
@@ -85,20 +86,32 @@ def _simulation_output_builder(
8586
baseline,
8687
reform,
8788
analysis=None,
89+
include_cliffs: bool | None = None,
8890
) -> SimulationOutputBuilder:
8991
analysis = analysis or fake_analysis()
92+
93+
def economic_impact_analysis(
94+
baseline_simulation,
95+
reform_simulation,
96+
*,
97+
include_cliff_impacts=False,
98+
):
99+
return analysis
100+
101+
simulation_params = {
102+
"country": country,
103+
"data_version": "1.115.5" if country == "us" else "1.55.10",
104+
}
105+
if include_cliffs is not None:
106+
simulation_params["include_cliffs"] = include_cliffs
107+
90108
country_module = SimpleNamespace(
91109
model=SimpleNamespace(version="1.700.0" if country == "us" else "2.88.20"),
92-
economic_impact_analysis=lambda baseline_simulation, reform_simulation: (
93-
analysis
94-
),
110+
economic_impact_analysis=economic_impact_analysis,
95111
)
96112
return SimulationOutputBuilder(
97113
country=country,
98-
simulation_params={
99-
"country": country,
100-
"data_version": "1.115.5" if country == "us" else "1.55.10",
101-
},
114+
simulation_params=simulation_params,
102115
country_module=country_module,
103116
dataset=SimpleNamespace(metadata={}),
104117
baseline=baseline,
@@ -218,11 +231,19 @@ def test_builder_calls_policyengine_economic_impact_analysis():
218231
baseline, reform = _macro_baseline_reform()
219232
analysis = fake_analysis()
220233
calls = []
234+
235+
def economic_impact_analysis(
236+
baseline_simulation,
237+
reform_simulation,
238+
*,
239+
include_cliff_impacts=False,
240+
):
241+
calls.append((baseline_simulation, reform_simulation, include_cliff_impacts))
242+
return analysis
243+
221244
country_module = SimpleNamespace(
222245
model=SimpleNamespace(version="1.700.0"),
223-
economic_impact_analysis=lambda baseline_simulation, reform_simulation: (
224-
calls.append((baseline_simulation, reform_simulation)) or analysis
225-
),
246+
economic_impact_analysis=economic_impact_analysis,
226247
)
227248
builder = SimulationOutputBuilder(
228249
country="us",
@@ -235,7 +256,68 @@ def test_builder_calls_policyengine_economic_impact_analysis():
235256

236257
assert builder.analysis is analysis
237258
assert builder.analysis is analysis
238-
assert calls == [(baseline, reform)]
259+
assert calls == [(baseline, reform, False)]
260+
261+
262+
def test_builder_passes_include_cliffs_to_policyengine_economic_impact_analysis():
263+
baseline, reform = _macro_baseline_reform()
264+
analysis = fake_analysis()
265+
calls = []
266+
267+
def economic_impact_analysis(
268+
baseline_simulation,
269+
reform_simulation,
270+
*,
271+
include_cliff_impacts=False,
272+
):
273+
calls.append(include_cliff_impacts)
274+
return analysis
275+
276+
country_module = SimpleNamespace(
277+
model=SimpleNamespace(version="1.700.0"),
278+
economic_impact_analysis=economic_impact_analysis,
279+
)
280+
builder = SimulationOutputBuilder(
281+
country="us",
282+
simulation_params={
283+
"country": "us",
284+
"data_version": "1.115.5",
285+
"include_cliffs": True,
286+
},
287+
country_module=country_module,
288+
dataset=SimpleNamespace(metadata={}),
289+
baseline=baseline,
290+
reform=reform,
291+
)
292+
293+
assert builder.analysis is analysis
294+
assert calls == [True]
295+
296+
297+
def test_builder_serializes_cliff_impact_when_requested(monkeypatch):
298+
baseline, reform = _macro_baseline_reform()
299+
_stub_policyengine_output_calls(monkeypatch, baseline, reform)
300+
analysis = fake_analysis()
301+
analysis.cliff_impact = FakeModelOutput(
302+
{
303+
"baseline": {"cliff_gap": 10.0, "cliff_share": 0.25},
304+
"reform": {"cliff_gap": 20.0, "cliff_share": 0.5},
305+
}
306+
)
307+
308+
output = _simulation_output_builder(
309+
"us",
310+
baseline,
311+
reform,
312+
analysis=analysis,
313+
include_cliffs=True,
314+
).build()
315+
316+
assert output.cliff_impact is not None
317+
assert output.model_dump(mode="json")["cliff_impact"] == {
318+
"baseline": {"cliff_gap": 10.0, "cliff_share": 0.25},
319+
"reform": {"cliff_gap": 20.0, "cliff_share": 0.5},
320+
}
239321

240322

241323
def test_normalise_policy_converts_legacy_period_range_keys():
@@ -438,11 +520,18 @@ def test_resolve_region_scopes_uk_country_from_national_dataset():
438520

439521
def test_builder_data_version_prefers_resolved_revision_then_dataset_metadata():
440522
baseline, reform = _macro_baseline_reform()
523+
524+
def economic_impact_analysis(
525+
baseline_simulation,
526+
reform_simulation,
527+
*,
528+
include_cliff_impacts=False,
529+
):
530+
return fake_analysis()
531+
441532
country_module = SimpleNamespace(
442533
model=SimpleNamespace(version="1.700.0"),
443-
economic_impact_analysis=lambda baseline_simulation, reform_simulation: (
444-
fake_analysis()
445-
),
534+
economic_impact_analysis=economic_impact_analysis,
446535
)
447536

448537
resolved_builder = SimulationOutputBuilder(

projects/policyengine-api-simulation/tests/test_standalone_simulation_contract.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,25 @@ def fake_run_simulation_impl(params):
6262

6363
assert response.status_code == 200
6464
assert response.json() == CURRENT_SINGLE_YEAR_MACRO_RESULT
65+
66+
67+
def test_standalone_simulation_route_forwards_include_cliffs(monkeypatch):
68+
def fake_run_simulation_impl(params):
69+
assert params == {
70+
"country": "us",
71+
"reform": {},
72+
"include_cliffs": True,
73+
}
74+
return CURRENT_SINGLE_YEAR_MACRO_RESULT
75+
76+
monkeypatch.setattr(
77+
"policyengine_api_simulation.simulation.run_simulation_impl",
78+
fake_run_simulation_impl,
79+
)
80+
81+
response = TestClient(app).post(
82+
"/simulate/economy/comparison",
83+
json={"country": "us", "reform": {}, "include_cliffs": True},
84+
)
85+
86+
assert response.status_code == 200

projects/policyengine-api-simulation/uv.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)