From 4896e4b944a26bad90527a6ed6e8e53309ade1c2 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:18:37 +0400 Subject: [PATCH 01/25] Test checking and sending household reports to the simulation service (failing) Co-Authored-By: Claude Opus 5.5 --- ...st_simulation_household_report_requests.py | 160 +++++++++ .../v2/test_household_report_submissions.py | 338 ++++++++++++++++++ 2 files changed, 498 insertions(+) create mode 100644 tests/unit/libs/test_simulation_household_report_requests.py create mode 100644 tests/unit/v2/test_household_report_submissions.py diff --git a/tests/unit/libs/test_simulation_household_report_requests.py b/tests/unit/libs/test_simulation_household_report_requests.py new file mode 100644 index 000000000..c04ae96ff --- /dev/null +++ b/tests/unit/libs/test_simulation_household_report_requests.py @@ -0,0 +1,160 @@ +"""Create household reports through the simulation service's ``POST /reports``. + +A household report goes to the same route as an economy report and is +answered the same way, so these tests check its request body and a sample of +the answers; ``test_simulation_reports`` checks every answer. +""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +import pytest + +from policyengine_api.libs.simulation_reports import ( + REPORTS_PATH, + household_report_request, +) +from policyengine_api.services.v2.simulation_service.types import ( + HouseholdReportCreationInput, + SimulationServiceInternalError, + SimulationServiceInvalidRequestError, + SimulationServiceUnavailableError, + SubmittedReport, +) +from tests.unit.libs.test_simulation_reports import ( + ENVIRONMENT_VARIABLES, + POLICY_ID, + REPORT_CREATED, + REPORT_ID, + REPORT_RUN_ID, + SIMULATION_URL, + RecordingService, +) + +HOUSEHOLD_ID = UUID("00000000-0000-0000-0000-00000000b101") + + +@pytest.fixture(autouse=True) +def configured_simulation_service(monkeypatch): + """Name the Cloud Run simulation service; set no gateway credentials.""" + for name in ENVIRONMENT_VARIABLES: + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("SIMULATION_ENTRYPOINT_URL", f"{SIMULATION_URL}/") + + +def household_report_input(**changes: Any) -> HouseholdReportCreationInput: + values: dict[str, Any] = { + "country_id": "uk", + "kind": "household", + "label": "My household under the reform", + "household_id": str(HOUSEHOLD_ID), + "year": 2027, + "baseline": {}, + "reform": {"policy_id": str(POLICY_ID)}, + } + return HouseholdReportCreationInput.model_validate({**values, **changes}) + + +def test_a_household_report_is_posted_and_the_created_report_returned() -> None: + service = RecordingService() + + submitted = service.client().submit_household_report( + household_report_input(description="Doubles the credit") + ) + + assert submitted == SubmittedReport( + report_id=REPORT_ID, report_run_id=REPORT_RUN_ID + ) + (request,) = service.requests + assert request.method == "POST" + assert str(request.url) == f"{SIMULATION_URL}{REPORTS_PATH}" + assert service.sent_body() == { + "country": "uk", + "report_type": "household", + "label": "My household under the reform", + "description": "Doubles the credit", + "household_id": str(HOUSEHOLD_ID), + "year": 2027, + "baseline": {"policy_id": None}, + "reform": {"policy_id": str(POLICY_ID)}, + "add_missing_variables": False, + } + + +def test_a_report_of_the_baseline_alone_sends_a_null_reform() -> None: + body = household_report_input( + baseline={"policy_id": str(POLICY_ID)}, + reform=None, + add_missing_variables=True, + ) + + assert household_report_request(body) == { + "country": "uk", + "report_type": "household", + "label": "My household under the reform", + "description": None, + "household_id": str(HOUSEHOLD_ID), + "year": 2027, + "baseline": {"policy_id": str(POLICY_ID)}, + "reform": None, + "add_missing_variables": True, + } + + +def test_a_household_report_the_service_refuses_is_reported_with_its_reason() -> None: + service = RecordingService( + status_code=422, + body={ + "code": "invalid_household", + "detail": "The household's people have no ages", + }, + ) + + with pytest.raises(SimulationServiceInvalidRequestError) as refused: + service.client().submit_household_report(household_report_input()) + + assert str(refused.value) == ( + "The simulation service cannot run this request (invalid_household): " + "The household's people have no ages" + ) + + +def test_a_household_report_the_unavailable_service_created_is_named() -> None: + service = RecordingService( + status_code=503, + body={ + "code": "modal_call_refused", + "detail": "Modal refused the call, and the run was failed.", + "report_id": str(REPORT_ID), + }, + ) + + with pytest.raises(SimulationServiceUnavailableError) as unavailable: + service.client().submit_household_report(household_report_input()) + + assert unavailable.value.created_record_id == REPORT_ID + assert str(unavailable.value) == ( + f"The simulation service created report {REPORT_ID}, but is " + "unavailable (503 modal_call_refused)." + ) + + +def test_a_household_report_the_service_failed_may_have_been_created() -> None: + service = RecordingService( + status_code=500, body={"code": "internal_error", "detail": "boom"} + ) + + with pytest.raises(SimulationServiceInternalError, match="500 internal_error"): + service.client().submit_household_report(household_report_input()) + + +def test_an_answer_with_fields_the_public_api_does_not_read_is_accepted() -> None: + service = RecordingService( + body={**REPORT_CREATED, "baseline_simulation_id": str(HOUSEHOLD_ID)} + ) + + submitted = service.client().submit_household_report(household_report_input()) + + assert submitted.report_id == REPORT_ID diff --git a/tests/unit/v2/test_household_report_submissions.py b/tests/unit/v2/test_household_report_submissions.py new file mode 100644 index 000000000..95a42273e --- /dev/null +++ b/tests/unit/v2/test_household_report_submissions.py @@ -0,0 +1,338 @@ +"""Check household reports before the simulation service creates them.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any +from uuid import UUID, uuid4 + +import pytest +from pydantic import ValidationError +from sqlalchemy.exc import OperationalError + +from policyengine_api.data.v2.catalog.catalog_selection_validation import ( + MetadataCatalogUnavailableError, +) +from policyengine_api.services.v2.countries.types import CountryRead +from policyengine_api.services.v2.households.types import HouseholdRead +from policyengine_api.services.v2.households.validators import ( + HouseholdNotFoundError, +) +from policyengine_api.services.v2.policies.validators import ( + PolicyNotFoundError, + PolicyParametersMissingError, +) +from policyengine_api.services.v2.simulation_service.services import ( + SimulationSubmissionService, +) +from policyengine_api.services.v2.simulation_service.types import ( + HouseholdReportCreationInput, + SubmittedReport, +) +from policyengine_api.services.v2.simulation_service.validators import ( + HouseholdCalculationsUnsupportedError, + SubmissionStorageError, +) +from tests.unit.v2.test_simulation_submissions import ( + HOUSEHOLD_ONLY_UK, + OTHER_POLICY_ID, + OUTDATED_POLICY_ID, + REMOVED_PARAMETERS, + SECOND_OUTDATED_POLICY_ID, + SERVED_COUNTRIES, + STORED_POLICY_ID, + StoredCountries, + StoredPolicies, +) + +HOUSEHOLD_ID = UUID("00000000-0000-0000-0000-00000000d001") +OTHER_HOUSEHOLD_ID = UUID("00000000-0000-0000-0000-00000000d002") +STORED_AT = datetime(2026, 11, 13, tzinfo=UTC) +# A country whose countries row offers economy reports but no household +# calculations. +ECONOMY_ONLY_US = [ + CountryRead(id="uk", household_enabled=True, society_wide_enabled=True), + CountryRead(id="us", household_enabled=False, society_wide_enabled=True), +] + + +@dataclass +class StoredHouseholds: + """HOUSEHOLD_ID, stored in every country; any other household is not stored. + + With ``error``, every read raises it, as a database that cannot be read + does. + """ + + error: Exception | None = None + asked: list[tuple[str, UUID]] = field(default_factory=list) + + def get_household(self, *, country_id: str, household_id: UUID) -> HouseholdRead: + self.asked.append((country_id, household_id)) + if self.error is not None: + raise self.error + if household_id != HOUSEHOLD_ID: + msg = f"Household {household_id} was not found" + raise HouseholdNotFoundError(msg) + return HouseholdRead( + id=household_id, + country_id=country_id, + default_year=2026, + household_data={"people": {"you": {"age": {"2026": 40}}}}, + created_at=STORED_AT, + updated_at=STORED_AT, + ) + + +@dataclass +class RecordingHouseholdReportService: + """The simulation service's client, recording the household reports it is sent.""" + + household_reports: list[HouseholdReportCreationInput] = field(default_factory=list) + + def submit_household_report( + self, report: HouseholdReportCreationInput + ) -> SubmittedReport: + self.household_reports.append(report) + return SubmittedReport(report_id=uuid4(), report_run_id=uuid4()) + + +def unconfigured_client() -> RecordingHouseholdReportService: + msg = "the simulation service must not be asked" + raise AssertionError(msg) + + +def household_report_input(**changes: Any) -> HouseholdReportCreationInput: + values: dict[str, Any] = { + "country_id": "us", + "kind": "household", + "label": "My household under the reform", + "household_id": str(HOUSEHOLD_ID), + "year": 2026, + "baseline": {}, + "reform": {"policy_id": str(STORED_POLICY_ID)}, + } + return HouseholdReportCreationInput.model_validate({**values, **changes}) + + +def household_submissions( + client_factory: Any, + *, + countries: list[CountryRead] = SERVED_COUNTRIES, + households: StoredHouseholds | None = None, + policies: StoredPolicies | None = None, +) -> SimulationSubmissionService: + return SimulationSubmissionService( + countries=StoredCountries(countries=countries), + households=households or StoredHouseholds(), + policies=policies or StoredPolicies(), + client_factory=client_factory, + ) + + +def test_a_household_report_is_sent_once_its_country_household_and_policies_pass() -> ( + None +): + client = RecordingHouseholdReportService() + households = StoredHouseholds() + policies = StoredPolicies() + report = household_report_input( + baseline={"policy_id": str(STORED_POLICY_ID)}, add_missing_variables=True + ) + + submitted = household_submissions( + lambda: client, households=households, policies=policies + ).submit_household_report(report) + + assert client.household_reports == [report] + assert isinstance(submitted, SubmittedReport) + assert households.asked == [("us", HOUSEHOLD_ID)] + assert policies.asked == [("us", STORED_POLICY_ID)] + + +def test_a_household_only_country_can_submit_household_reports() -> None: + client = RecordingHouseholdReportService() + report = household_report_input(country_id="uk") + + household_submissions( + lambda: client, countries=HOUSEHOLD_ONLY_UK + ).submit_household_report(report) + + assert client.household_reports == [report] + + +@pytest.mark.parametrize( + "countries", + [ + pytest.param(ECONOMY_ONLY_US, id="household-calculations-off"), + pytest.param(SERVED_COUNTRIES[:1], id="no-countries-row"), + ], +) +def test_a_country_without_household_calculations_is_refused_before_anything_is_read( + countries: list[CountryRead], +) -> None: + households = StoredHouseholds() + policies = StoredPolicies() + service = household_submissions( + unconfigured_client, + countries=countries, + households=households, + policies=policies, + ) + + with pytest.raises(HouseholdCalculationsUnsupportedError, match="'us'"): + service.submit_household_report(household_report_input()) + assert households.asked == [] + assert policies.asked == [] + + +def test_a_household_not_stored_in_the_country_is_refused_before_the_policies() -> None: + policies = StoredPolicies() + service = household_submissions(unconfigured_client, policies=policies) + + with pytest.raises(HouseholdNotFoundError, match=str(OTHER_HOUSEHOLD_ID)): + service.submit_household_report( + household_report_input(household_id=str(OTHER_HOUSEHOLD_ID)) + ) + assert policies.asked == [] + + +@pytest.mark.parametrize("side", ["baseline", "reform"]) +def test_a_household_report_naming_a_policy_not_stored_in_the_country_is_refused( + side: str, +) -> None: + service = household_submissions(unconfigured_client) + + with pytest.raises(PolicyNotFoundError, match=str(OTHER_POLICY_ID)): + service.submit_household_report( + household_report_input(**{side: {"policy_id": str(OTHER_POLICY_ID)}}) + ) + + +def test_the_baselines_policy_with_missing_parameters_is_refused_first() -> None: + service = household_submissions(unconfigured_client) + + with pytest.raises(PolicyParametersMissingError) as refused: + service.submit_household_report( + household_report_input( + baseline={"policy_id": str(OUTDATED_POLICY_ID)}, + reform={"policy_id": str(SECOND_OUTDATED_POLICY_ID)}, + ) + ) + + assert refused.value.policy_id == OUTDATED_POLICY_ID + assert refused.value.missing_parameters == REMOVED_PARAMETERS + + +def test_a_policy_not_stored_comes_before_one_with_missing_parameters() -> None: + service = household_submissions(unconfigured_client) + + with pytest.raises(PolicyNotFoundError): + service.submit_household_report( + household_report_input( + baseline={"policy_id": str(OUTDATED_POLICY_ID)}, + reform={"policy_id": str(OTHER_POLICY_ID)}, + ) + ) + + +def test_a_report_of_the_baseline_alone_checks_only_the_baselines_policy() -> None: + client = RecordingHouseholdReportService() + policies = StoredPolicies() + report = household_report_input( + baseline={"policy_id": str(STORED_POLICY_ID)}, reform=None + ) + + household_submissions(lambda: client, policies=policies).submit_household_report( + report + ) + + assert policies.asked == [("us", STORED_POLICY_ID)] + assert client.household_reports[0].reform is None + + +@pytest.mark.parametrize( + ("households", "policies"), + [ + pytest.param( + StoredHouseholds(error=OperationalError("SELECT", {}, Exception())), + StoredPolicies(), + id="households-unreadable", + ), + pytest.param( + StoredHouseholds(), + StoredPolicies(error=OperationalError("SELECT", {}, Exception())), + id="policies-unreadable", + ), + pytest.param( + StoredHouseholds(), + StoredPolicies(error=MetadataCatalogUnavailableError("no catalog")), + id="catalog-unreadable", + ), + ], +) +def test_storage_that_cannot_be_read_while_checking_is_a_storage_failure( + households: StoredHouseholds, policies: StoredPolicies +) -> None: + service = household_submissions( + unconfigured_client, households=households, policies=policies + ) + + with pytest.raises(SubmissionStorageError): + service.submit_household_report(household_report_input()) + + +def test_a_household_report_without_a_reform_is_of_the_baseline_alone() -> None: + report = HouseholdReportCreationInput.model_validate( + { + "country_id": "us", + "kind": "household", + "label": "My household", + "household_id": str(HOUSEHOLD_ID), + "year": 2026, + "baseline": {}, + } + ) + + assert report.reform is None + assert report.add_missing_variables is False + assert report.description is None + + +@pytest.mark.parametrize( + "changes", + [ + pytest.param({"label": ""}, id="empty-label"), + pytest.param({"year": 1899}, id="year-before-1900"), + pytest.param({"year": 2201}, id="year-after-2200"), + pytest.param({"add_missing_variables": "true"}, id="option-not-a-boolean"), + pytest.param({"baseline": {"dynamic_id": str(uuid4())}}, id="dynamic"), + pytest.param({"region_code": "us"}, id="region"), + pytest.param({"household_id": "not-a-uuid"}, id="household-not-a-uuid"), + pytest.param({"kind": "economy"}, id="another-kind"), + ], +) +def test_a_household_report_outside_its_schema_is_refused( + changes: dict[str, Any], +) -> None: + with pytest.raises(ValidationError): + household_report_input(**changes) + + +@pytest.mark.parametrize("missing", ["label", "household_id", "year", "baseline"]) +def test_a_household_report_needs_its_label_household_year_and_baseline( + missing: str, +) -> None: + values: dict[str, Any] = { + "country_id": "us", + "kind": "household", + "label": "My household", + "household_id": str(HOUSEHOLD_ID), + "year": 2026, + "baseline": {}, + } + del values[missing] + + with pytest.raises(ValidationError): + HouseholdReportCreationInput.model_validate(values) From 0f5f027c46ce36c8f4a5181292874fe1b06dd831 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:21:05 +0400 Subject: [PATCH 02/25] Check household reports and send them to the simulation service's /reports The submission service checks that the country's row allows household calculations, that the household and each policy are stored in the country, and that the policies' parameters all exist in the running catalog, then sends report_type household with the household, year, policies and add_missing_variables. Co-Authored-By: Claude Opus 5.5 --- .../fastapi_routes/dependencies.py | 1 + policyengine_api/libs/simulation_reports.py | 51 +++++++++++++++-- .../v2/simulation_service/services.py | 43 +++++++++++++- .../services/v2/simulation_service/types.py | 57 +++++++++++++++++++ .../v2/simulation_service/validators.py | 24 +++++++- tests/integration/test_v2_report_creation.py | 5 ++ ...t_simulation_submission_database_routes.py | 5 ++ tests/unit/v2/test_simulation_submissions.py | 11 ++++ 8 files changed, 188 insertions(+), 9 deletions(-) diff --git a/policyengine_api/fastapi_routes/dependencies.py b/policyengine_api/fastapi_routes/dependencies.py index 8b016dd70..7208bfe98 100644 --- a/policyengine_api/fastapi_routes/dependencies.py +++ b/policyengine_api/fastapi_routes/dependencies.py @@ -229,6 +229,7 @@ def _default_simulation_submissions() -> SimulationServiceClient: return SimulationSubmissionService( countries=_default_v2_country_service_factory(), + households=_default_v2_household_service_factory(), policies=_default_v2_policy_service_factory(), client_factory=process_simulation_service_client, ) diff --git a/policyengine_api/libs/simulation_reports.py b/policyengine_api/libs/simulation_reports.py index 13740cec6..2257be402 100644 --- a/policyengine_api/libs/simulation_reports.py +++ b/policyengine_api/libs/simulation_reports.py @@ -15,8 +15,12 @@ names the country, ``report_type`` (``economy``), the label and description, the year, region and dataset its two simulations share, each simulation's policy and dynamic, ``include_cliffs``, and the SPM settings only when the -caller gave them. Neither names a PolicyEngine.py version: the simulation -service runs its default worker's. The simulation service answers: +caller gave them. A household report goes to the same ``POST /reports`` as +``report_type`` ``household``, naming the label and description, the stored +household, the year, each simulation's policy (the reform null for a report +of the baseline alone) and ``add_missing_variables``. None names a +PolicyEngine.py version: the simulation service runs its default worker's. +The simulation service answers: - 201 or 202 with the created record's ID and its first run's ID. One whose body cannot be read is SimulationServiceAnswerUnreadableError: the record @@ -131,6 +135,8 @@ from policyengine_api.services.v2.simulation_service.types import ( EconomyReportCreationInput, EconomySimulationCreationInput, + HouseholdPolicyChoice, + HouseholdReportCreationInput, PolicyChoice, ) @@ -241,6 +247,30 @@ def report_request(report: EconomyReportCreationInput) -> dict[str, Any]: return body +def _household_policy_choice(choice: HouseholdPolicyChoice) -> dict[str, str | None]: + return {"policy_id": None if choice.policy_id is None else str(choice.policy_id)} + + +def household_report_request(report: HouseholdReportCreationInput) -> dict[str, Any]: + """Return the simulation service's request body for the household report. + + ``reform`` is null for a report of the baseline alone. + """ + return { + "country": report.country_id, + "report_type": report.kind, + "label": report.label, + "description": report.description, + "household_id": str(report.household_id), + "year": report.year, + "baseline": _household_policy_choice(report.baseline), + "reform": ( + None if report.reform is None else _household_policy_choice(report.reform) + ), + "add_missing_variables": report.add_missing_variables, + } + + def post_simulation( http_client: httpx.Client, body: dict[str, Any] ) -> SimulationAnswer: @@ -478,9 +508,20 @@ def submit_report(self, report: EconomyReportCreationInput) -> SubmittedReport: Raise the errors ``SimulationServiceClient`` names. """ - answer = self._answer( - self._send_report, report_request(report), record="report" - ) + return self._submitted_report(report_request(report)) + + def submit_household_report( + self, report: HouseholdReportCreationInput + ) -> SubmittedReport: + """Create the household report, its simulations and runs; return its IDs. + + Raise the errors ``SimulationServiceClient`` names. + """ + return self._submitted_report(household_report_request(report)) + + def _submitted_report(self, body: dict[str, Any]) -> SubmittedReport: + """Send a report's request body and return the created report's IDs.""" + answer = self._answer(self._send_report, body, record="report") if answer.status_code not in CREATED_STATUSES: _raise_for_refusal(answer, "report") try: diff --git a/policyengine_api/services/v2/simulation_service/services.py b/policyengine_api/services/v2/simulation_service/services.py index 0867553fa..5522b63a1 100644 --- a/policyengine_api/services/v2/simulation_service/services.py +++ b/policyengine_api/services/v2/simulation_service/services.py @@ -11,6 +11,11 @@ Only then is the simulation service asked. The dynamics a submission names, its region and its dataset are checked by the simulation service, which reads them in the catalog version its default worker runs. + +A household report is checked the same way, except that its country's row +must allow household calculations instead (a household-only country passes), +and the household it names must be stored in the country, through the same +service as ``/v2/households``; the household is read before the policies. """ from __future__ import annotations @@ -29,6 +34,7 @@ from policyengine_api.services.v2.simulation_service.validators import ( SubmissionStorageError, require_economy_country, + require_household_country, ) if TYPE_CHECKING: @@ -39,6 +45,8 @@ CountrySource, EconomyReportCreationInput, EconomySimulationCreationInput, + HouseholdReportCreationInput, + HouseholdSource, PolicySource, SimulationServiceClient, SubmittedReport, @@ -91,16 +99,19 @@ def __init__( self, *, countries: CountrySource, + households: HouseholdSource, policies: PolicySource, client_factory: Callable[[], SimulationServiceClient], ) -> None: """Check through these sources; send through ``client_factory``'s client. - The client is built only once a submission passes the checks, so a - refused submission gets its own answer even when the simulation - service is not configured. + ``households`` is read only for a household report. The client is + built only once a submission passes the checks, so a refused + submission gets its own answer even when the simulation service is + not configured. """ self._countries = countries + self._households = households self._policies = policies self._client_factory = client_factory @@ -138,3 +149,29 @@ def submit_report(self, report: EconomyReportCreationInput) -> SubmittedReport: report.country_id, (report.baseline.policy_id, report.reform.policy_id) ) return self._client_factory().submit_report(report) + + def submit_household_report( + self, report: HouseholdReportCreationInput + ) -> SubmittedReport: + """Check the household report, then have the simulation service create it. + + Raise HouseholdCalculationsUnsupportedError unless the country's row + allows household calculations, then HouseholdNotFoundError for a + household the country does not store, then the policy checks' errors + (the baseline's policy first, and only the baseline's for a report of + the baseline alone), SubmissionStorageError when the database or its + catalog cannot be read, and then the simulation service's errors. + """ + country_id = report.country_id + with _storage_failures(): + require_household_country(self._countries.list_countries(), country_id) + self._households.get_household( + country_id=country_id, household_id=report.household_id + ) + policy_ids = [report.baseline.policy_id] + if report.reform is not None: + policy_ids.append(report.reform.policy_id) + require_runnable_policies( + self._policies, country_id=country_id, policy_ids=policy_ids + ) + return self._client_factory().submit_household_report(report) diff --git a/policyengine_api/services/v2/simulation_service/types.py b/policyengine_api/services/v2/simulation_service/types.py index dcfd1f00e..ff02cf109 100644 --- a/policyengine_api/services/v2/simulation_service/types.py +++ b/policyengine_api/services/v2/simulation_service/types.py @@ -32,6 +32,7 @@ from uuid import UUID from policyengine_api.services.v2.countries.types import CountryRead + from policyengine_api.services.v2.households.types import HouseholdRead from policyengine_api.services.v2.policies.types import CheckedPolicyRead # The bounds the simulation service and the tables accept. @@ -203,6 +204,43 @@ class EconomyReportCreationInput(EconomyReportFields): country_id: CountryId +class HouseholdPolicyChoice(StrictSubmissionModel): + """A household simulation's stored policy; none means current law.""" + + policy_id: ResourceId | None = None + + +class HouseholdReportFields(StrictSubmissionModel): + """A household report: one stored household under a baseline and a reform. + + ``household_id`` names a household stored through ``/v2/households`` in + the report's country, and both simulations calculate it in ``year``. + ``reform`` is null, or omitted, for a report of the baseline alone. With + ``add_missing_variables``, each simulation also calculates every yearly, + monthly and eternal variable the household lacks, as ``/calculate-full`` + does; otherwise it fills in only the values the household asks for. + """ + + kind: Literal["household"] + label: ReportLabel + description: ReportDescription | None = None + household_id: ResourceId + year: SimulationYear + baseline: HouseholdPolicyChoice + reform: HouseholdPolicyChoice | None = None + add_missing_variables: StrictBool = False + + +class HouseholdReportCreationInput(HouseholdReportFields): + """A household report to create in the country.""" + + country_id: CountryId + + +# A report of any kind to create; ``kind`` says which. +ReportCreationInput = EconomyReportCreationInput | HouseholdReportCreationInput + + @dataclass(frozen=True) class SubmittedSimulation: """The simulation the simulation service created, and the run it started.""" @@ -244,6 +282,7 @@ class SimulationServiceClient(Protocol): ``SimulationSubmissionService``, which checks a submission before sending it through a client, also raises EconomyCalculationsUnsupportedError, + HouseholdCalculationsUnsupportedError, HouseholdNotFoundError, PolicyNotFoundError, PolicyParametersMissingError and SubmissionStorageError from its checks. """ @@ -258,6 +297,16 @@ def submit_report(self, report: EconomyReportCreationInput) -> SubmittedReport: """Create the report, its simulations and their runs, and start them.""" ... + def submit_household_report( + self, report: HouseholdReportCreationInput + ) -> SubmittedReport: + """Create the household report, its simulations and runs; start them. + + It goes to the same route as ``submit_report``, so it raises the same + errors. + """ + ... + class CountrySource(Protocol): """The countries list a submission checks first.""" @@ -267,6 +316,14 @@ def list_countries(self) -> list[CountryRead]: ... +class HouseholdSource(Protocol): + """The stored households a household report names (``V2HouseholdService``).""" + + def get_household(self, *, country_id: str, household_id: UUID) -> HouseholdRead: + """Return the country's household, or raise ``HouseholdNotFoundError``.""" + ... + + class PolicySource(Protocol): """The stored policies a submission names.""" diff --git a/policyengine_api/services/v2/simulation_service/validators.py b/policyengine_api/services/v2/simulation_service/validators.py index 1c0c84a29..2adea32cf 100644 --- a/policyengine_api/services/v2/simulation_service/validators.py +++ b/policyengine_api/services/v2/simulation_service/validators.py @@ -18,8 +18,15 @@ class EconomyCalculationsUnsupportedError(ValueError): """ +class HouseholdCalculationsUnsupportedError(ValueError): + """The country has no household simulations or reports. + + Its countries row leaves ``household_enabled`` unset, or it has no row. + """ + + class SubmissionStorageError(RuntimeError): - """A submission's countries, policies or catalog could not be read.""" + """A submission's countries, households, policies or catalog could not be read.""" def require_economy_country(countries: Iterable[CountryRead], country_id: str) -> None: @@ -29,3 +36,18 @@ def require_economy_country(countries: Iterable[CountryRead], country_id: str) - return msg = f"Economy calculations are not available for country {country_id!r}" raise EconomyCalculationsUnsupportedError(msg) + + +def require_household_country( + countries: Iterable[CountryRead], country_id: str +) -> None: + """Raise HouseholdCalculationsUnsupportedError unless the row allows households. + + A household-only country (household calculations without economy + reports) passes. + """ + for country in countries: + if country.id == country_id and country.household_enabled: + return + msg = f"Household reports are not available for country {country_id!r}" + raise HouseholdCalculationsUnsupportedError(msg) diff --git a/tests/integration/test_v2_report_creation.py b/tests/integration/test_v2_report_creation.py index 4e5f64807..98efc910a 100644 --- a/tests/integration/test_v2_report_creation.py +++ b/tests/integration/test_v2_report_creation.py @@ -33,6 +33,10 @@ CountryDatabaseSession, ) from policyengine_api.services.v2.countries.services import V2CountryService +from policyengine_api.services.v2.households.database_session import ( + HouseholdDatabaseSession, +) +from policyengine_api.services.v2.households.services import V2HouseholdService from policyengine_api.services.v2.policies.database_session import ( PolicyDatabaseSession, ) @@ -171,6 +175,7 @@ def client_for( """Serve the routes as the runtime role, creating through ``simulation_service``.""" submissions = SimulationSubmissionService( countries=V2CountryService(CountryDatabaseSession(sessions)), + households=V2HouseholdService(HouseholdDatabaseSession(sessions)), policies=V2PolicyService( PolicyDatabaseSession(sessions), running_policyengine_version="4.2.0" ), diff --git a/tests/unit/v2/test_simulation_submission_database_routes.py b/tests/unit/v2/test_simulation_submission_database_routes.py index cb9d33688..5c3f96a44 100644 --- a/tests/unit/v2/test_simulation_submission_database_routes.py +++ b/tests/unit/v2/test_simulation_submission_database_routes.py @@ -31,6 +31,10 @@ CountryDatabaseSession, ) from policyengine_api.services.v2.countries.services import V2CountryService +from policyengine_api.services.v2.households.database_session import ( + HouseholdDatabaseSession, +) +from policyengine_api.services.v2.households.services import V2HouseholdService from policyengine_api.services.v2.policies.database_session import ( PolicyDatabaseSession, ) @@ -189,6 +193,7 @@ def submissions( sessions = sessionmaker(engine, class_=Session, expire_on_commit=False) return SimulationSubmissionService( countries=V2CountryService(CountryDatabaseSession(sessions)), + households=V2HouseholdService(HouseholdDatabaseSession(sessions)), policies=V2PolicyService( PolicyDatabaseSession(sessions), running_policyengine_version=RUNNING_VERSION, diff --git a/tests/unit/v2/test_simulation_submissions.py b/tests/unit/v2/test_simulation_submissions.py index 15a44f6b9..45fb541ab 100644 --- a/tests/unit/v2/test_simulation_submissions.py +++ b/tests/unit/v2/test_simulation_submissions.py @@ -128,6 +128,14 @@ def submit_simulation( return SubmittedSimulation(simulation_id=uuid4(), simulation_run_id=uuid4()) +class UnreadHouseholds: + """The stored households, which an economy submission never reads.""" + + def get_household(self, *, country_id: str, household_id: UUID) -> object: + msg = "an economy submission must not read a household" + raise AssertionError(msg) + + def unconfigured_client() -> RecordingSimulationService: msg = "the simulation service must not be asked" raise AssertionError(msg) @@ -162,6 +170,7 @@ def submissions( ) -> SimulationSubmissionService: return SimulationSubmissionService( countries=StoredCountries(), + households=UnreadHouseholds(), policies=policies or StoredPolicies(), client_factory=client_factory, ) @@ -197,6 +206,7 @@ def test_current_law_needs_no_stored_policy() -> None: def test_a_household_only_country_is_refused_before_sending() -> None: service = SimulationSubmissionService( countries=StoredCountries(countries=HOUSEHOLD_ONLY_UK), + households=UnreadHouseholds(), policies=StoredPolicies(), client_factory=unconfigured_client, ) @@ -210,6 +220,7 @@ def test_a_household_only_country_is_refused_before_sending() -> None: def test_a_country_without_a_countries_row_is_refused() -> None: service = SimulationSubmissionService( countries=StoredCountries(countries=SERVED_COUNTRIES[:1]), + households=UnreadHouseholds(), policies=StoredPolicies(), client_factory=unconfigured_client, ) From e91a3c5f9337548dd6464cf9b6c2b8fe92a5275a Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:23:00 +0400 Subject: [PATCH 03/25] Test reading household reports and their calculated households (failing) Co-Authored-By: Claude Opus 5.5 --- tests/unit/v2/test_household_report_reads.py | 477 +++++++++++++++++++ 1 file changed, 477 insertions(+) create mode 100644 tests/unit/v2/test_household_report_reads.py diff --git a/tests/unit/v2/test_household_report_reads.py b/tests/unit/v2/test_household_report_reads.py new file mode 100644 index 000000000..575352a98 --- /dev/null +++ b/tests/unit/v2/test_household_report_reads.py @@ -0,0 +1,477 @@ +"""Read household reports and their calculated households from the v2 tables. + +The tables are SQLite's copy of the v2 schema. Each household report's two +simulations calculate one stored household; once the newest report run has +succeeded, each simulation run's ``household_result`` holds the household +function's result document, which the read returns as the report's result. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING, Any +from uuid import UUID, uuid4 + +import pytest +from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdReportResult, + HouseholdResultUnreadableError, +) +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, create_engine + +from policyengine_api.data.v2.models import ( + V2_METADATA, + Household, + Policy, + Report, + ReportRun, + ReportRunStatus, + ReportRunTrigger, + Simulation, + SimulationRun, + SimulationStatus, + SimulationType, + TaxBenefitModel, + TaxBenefitModelVersion, +) +from policyengine_api.services.v2.household_calculations.types import ( + HouseholdCalculationOutput, +) +from policyengine_api.services.v2.simulation_service.types import PackageVersions +from policyengine_api.services.v2.stored_reports.database_session import ( + StoredReportDatabaseSession, +) +from policyengine_api.services.v2.stored_reports.services import V2StoredReportService +from policyengine_api.services.v2.stored_reports.types import ( + HouseholdReportRead, + HouseholdReportSummaryRead, + ReportSummaryRead, +) +from policyengine_api.services.v2.stored_reports.validators import ( + StoredReportNotFoundError, +) + +if TYPE_CHECKING: + from collections.abc import Iterator + + from sqlalchemy import Engine + +STORED_AT = datetime(2026, 11, 13, 9, tzinfo=UTC) +CURRENT_VERSIONS = PackageVersions( + country_package_version="1.5.0", policyengine_version="4.2.0" +) +RUN_VERSIONS = {"country_package_version": "1.4.0", "policyengine_version": "4.1.0"} +STORED_HOUSEHOLD = {"people": {"you": {"age": {"2026": 40}}}} +BASELINE_HOUSEHOLD = { + "people": {"you": {"age": {"2026": 40}, "net_income": {"2026": 30_000.0}}} +} +REFORM_HOUSEHOLD = { + "people": {"you": {"age": {"2026": 40}, "net_income": {"2026": 31_250.5}}} +} +SPM_PROVENANCE = {"scenario": "baseline", "geography": "national"} + + +def succeeded_document( + household: dict[str, Any], **output_changes: Any +) -> dict[str, Any]: + """Return the household function's result document for a calculated household.""" + return { + "status": "succeeded", + "schema_version": 1, + "output": { + "household": household, + "warnings": [], + "spm_config": None, + "spm_provenance": None, + **output_changes, + }, + } + + +def current_versions(country_id: str) -> PackageVersions: + return CURRENT_VERSIONS + + +@pytest.fixture +def engine() -> Iterator[Engine]: + database_engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + V2_METADATA.create_all(database_engine) + yield database_engine + database_engine.dispose() + + +@pytest.fixture +def sessions(engine: Engine) -> sessionmaker[Session]: + return sessionmaker(engine, class_=Session, expire_on_commit=False) + + +@pytest.fixture +def reports(sessions: sessionmaker[Session]) -> V2StoredReportService: + return V2StoredReportService( + StoredReportDatabaseSession(sessions), current_versions=current_versions + ) + + +@dataclass(frozen=True) +class StoredHouseholdReport: + """The IDs of a stored household report and the rows it names.""" + + report_id: UUID + household_id: UUID + policy_id: UUID + baseline_simulation_id: UUID + reform_simulation_id: UUID | None + report_run_id: UUID | None + baseline_run_id: UUID | None + reform_run_id: UUID | None + + +@dataclass(frozen=True) +class RunToStore: + """A report run to store: its status, and each simulation run's result.""" + + status: ReportRunStatus = ReportRunStatus.SUCCEEDED + baseline_result: dict[str, Any] | None = None + reform_result: dict[str, Any] | None = None + error_message: str | None = None + + @property + def finished(self) -> bool: + return self.status in {ReportRunStatus.SUCCEEDED, ReportRunStatus.FAILED} + + +def store_household_report( + session: Session, + *, + country: str = "us", + with_reform: bool = True, + run: RunToStore | None = None, + inputs: dict[str, Any] | None = None, +) -> StoredHouseholdReport: + """Store a household report as the simulation service and its functions do. + + Its baseline is current law and its reform a stored policy; without + ``with_reform`` it is a report of the baseline alone. ``run`` is its one + report run, with a run of each simulation; None stores no run. + """ + model = TaxBenefitModel(name=f"policyengine-{country}") + version = TaxBenefitModelVersion( + model=model, + version=f"reads-{uuid4().hex[:8]}", + current_law_id=1, + metadata_time_periods=[2026], + ) + household = Household( + country_id=country, + default_year=2026, + household_data=STORED_HOUSEHOLD, + canonicalization_version=1, + content_hash=uuid4().hex * 2, + ) + policy = Policy( + country_id=country, + tax_benefit_model=model, + tax_benefit_model_version=version, + canonicalization_version=1, + content_hash=uuid4().hex * 2, + ) + session.add_all([model, version, household, policy]) + session.flush() + + def simulation(policy_id: UUID | None) -> Simulation: + return Simulation( + simulation_type=SimulationType.HOUSEHOLD, + household_id=household.id, + policy_id=policy_id, + tax_benefit_model_version_id=version.id, + year=2026, + ) + + baseline = simulation(None) + reform = simulation(policy.id) if with_reform else None + session.add_all([side for side in (baseline, reform) if side is not None]) + session.flush() + report = Report( + label="My household under the reform", + description="Doubles the credit", + country=country, + tax_benefit_model_id=model.id, + baseline_simulation_id=baseline.id, + reform_simulation_id=None if reform is None else reform.id, + household_id=household.id, + policy_id=None if reform is None else policy.id, + year=2026, + inputs={"add_missing_variables": False} if inputs is None else inputs, + created_at=STORED_AT, + ) + session.add(report) + session.flush() + report_run_id = baseline_run_id = reform_run_id = None + if run is not None: + completed_at = STORED_AT + timedelta(minutes=1) if run.finished else None + simulation_status = SimulationStatus(run.status.value) + + def simulation_run( + simulation_id: UUID, document: dict[str, Any] | None + ) -> SimulationRun: + return SimulationRun( + simulation_id=simulation_id, + year=2026, + tax_benefit_model_version_id=version.id, + status=simulation_status, + started_at=STORED_AT, + completed_at=completed_at, + household_result=document, + **RUN_VERSIONS, + ) + + baseline_run = simulation_run(baseline.id, run.baseline_result) + reform_run = ( + None if reform is None else simulation_run(reform.id, run.reform_result) + ) + session.add_all([side for side in (baseline_run, reform_run) if side]) + session.flush() + report_run = ReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline_run.id, + reform_simulation_run_id=None if reform_run is None else reform_run.id, + status=run.status, + trigger=ReportRunTrigger.INITIAL, + created_at=STORED_AT, + started_at=STORED_AT, + completed_at=completed_at, + error_message=run.error_message, + **RUN_VERSIONS, + ) + session.add(report_run) + session.flush() + report_run_id = report_run.id + baseline_run_id = baseline_run.id + reform_run_id = None if reform_run is None else reform_run.id + session.commit() + return StoredHouseholdReport( + report_id=report.id, + household_id=household.id, + policy_id=policy.id, + baseline_simulation_id=baseline.id, + reform_simulation_id=None if reform is None else reform.id, + report_run_id=report_run_id, + baseline_run_id=baseline_run_id, + reform_run_id=reform_run_id, + ) + + +def succeeded_run(**changes: Any) -> RunToStore: + values: dict[str, Any] = { + "baseline_result": succeeded_document(BASELINE_HOUSEHOLD), + "reform_result": succeeded_document( + REFORM_HOUSEHOLD, + warnings=["ignored_input"], + spm_config={"geography_kind": "national"}, + spm_provenance=SPM_PROVENANCE, + ), + } + return RunToStore(**{**values, **changes}) + + +def test_a_succeeded_household_report_reads_both_calculated_households( + sessions: sessionmaker[Session], reports: V2StoredReportService +) -> None: + with sessions() as session: + stored = store_household_report( + session, run=succeeded_run(), inputs={"add_missing_variables": True} + ) + + report = reports.get_report(country_id="us", report_id=stored.report_id) + + assert isinstance(report, HouseholdReportRead) + assert report.kind == "household" + assert report.household_id == stored.household_id + assert (report.label, report.description, report.year) == ( + "My household under the reform", + "Doubles the credit", + 2026, + ) + assert report.options == {"add_missing_variables": True} + assert report.baseline_simulation is not None + assert report.baseline_simulation.id == stored.baseline_simulation_id + assert report.baseline_simulation.policy_id is None + assert report.reform_simulation is not None + assert report.reform_simulation.policy_id == stored.policy_id + assert report.status == "succeeded" + assert report.run is not None + assert report.run.id == stored.report_run_id + assert report.run.baseline_simulation_run_id == stored.baseline_run_id + assert report.run.reform_simulation_run_id == stored.reform_run_id + assert report.run.country_package_version == "1.4.0" + assert report.current_versions == CURRENT_VERSIONS + assert report.result == HouseholdReportResult( + baseline=HouseholdCalculationOutput( + household=BASELINE_HOUSEHOLD, + warnings=(), + spm_config=None, + spm_provenance=None, + ), + reform=HouseholdCalculationOutput( + household=REFORM_HOUSEHOLD, + warnings=("ignored_input",), + spm_config={"geography_kind": "national"}, + spm_provenance=SPM_PROVENANCE, + ), + ) + + +def test_a_report_of_the_baseline_alone_has_no_reform( + sessions: sessionmaker[Session], reports: V2StoredReportService +) -> None: + with sessions() as session: + stored = store_household_report( + session, with_reform=False, run=succeeded_run(reform_result=None) + ) + + report = reports.get_report(country_id="us", report_id=stored.report_id) + + assert isinstance(report, HouseholdReportRead) + assert report.reform_simulation is None + assert report.run is not None + assert report.run.reform_simulation_run_id is None + assert report.result is not None + assert report.result.baseline.household == BASELINE_HOUSEHOLD + assert report.result.reform is None + + +@pytest.mark.parametrize( + "status", + [ReportRunStatus.PENDING, ReportRunStatus.RUNNING, ReportRunStatus.FAILED], +) +def test_a_household_report_whose_newest_run_has_not_succeeded_has_no_result( + sessions: sessionmaker[Session], + reports: V2StoredReportService, + status: ReportRunStatus, +) -> None: + error_message = ( + "household_function_error" if status is ReportRunStatus.FAILED else None + ) + with sessions() as session: + stored = store_household_report( + session, run=RunToStore(status=status, error_message=error_message) + ) + + report = reports.get_report(country_id="us", report_id=stored.report_id) + + assert isinstance(report, HouseholdReportRead) + assert report.status == status.value + assert report.run is not None + assert report.run.error_message == error_message + assert report.result is None + + +def test_a_household_report_with_no_run_answers_its_definition( + sessions: sessionmaker[Session], reports: V2StoredReportService +) -> None: + with sessions() as session: + stored = store_household_report(session, run=None) + + report = reports.get_report(country_id="us", report_id=stored.report_id) + + assert isinstance(report, HouseholdReportRead) + assert (report.status, report.run, report.result) == ("not_run", None, None) + + +@pytest.mark.parametrize( + ("side", "document"), + [ + pytest.param("baseline_result", None, id="no-document"), + pytest.param( + "reform_result", + {"status": "failed", "schema_version": 1, "error": {"code": "x"}}, + id="failure-document", + ), + pytest.param( + "baseline_result", + {**succeeded_document(BASELINE_HOUSEHOLD), "schema_version": 2}, + id="another-schema-version", + ), + pytest.param( + "reform_result", + {"status": "succeeded", "schema_version": 1}, + id="no-output", + ), + ], +) +def test_a_succeeded_run_whose_calculated_household_cannot_be_read_is_unreadable( + sessions: sessionmaker[Session], + reports: V2StoredReportService, + side: str, + document: dict[str, Any] | None, +) -> None: + with sessions() as session: + stored = store_household_report(session, run=succeeded_run(**{side: document})) + + with pytest.raises(HouseholdResultUnreadableError): + reports.get_report(country_id="us", report_id=stored.report_id) + + +def test_a_household_report_of_another_country_is_not_found( + sessions: sessionmaker[Session], reports: V2StoredReportService +) -> None: + with sessions() as session: + stored = store_household_report(session, country="uk", run=succeeded_run()) + + with pytest.raises(StoredReportNotFoundError): + reports.get_report(country_id="us", report_id=stored.report_id) + + +def store_economy_report(session: Session) -> UUID: + """Store an economy report with no run, in the US.""" + model = TaxBenefitModel(name="policyengine-us-economy") + session.add(model) + session.flush() + report = Report( + label="Economy", + country="us", + tax_benefit_model_id=model.id, + year=2026, + inputs={"include_cliffs": False}, + created_at=STORED_AT, + ) + session.add(report) + session.commit() + return report.id + + +def test_a_list_names_household_reports_beside_economy_reports_without_results( + sessions: sessionmaker[Session], reports: V2StoredReportService +) -> None: + with sessions() as session: + household = store_household_report(session, run=succeeded_run()) + economy_report_id = store_economy_report(session) + other_country = store_household_report(session, country="uk") + + listed = reports.list_reports( + country_id="us", + report_ids=[ + household.report_id, + other_country.report_id, + economy_report_id, + household.report_id, + ], + ) + + assert [report.id for report in listed] == [ + household.report_id, + economy_report_id, + ] + household_summary, economy_summary = listed + assert type(household_summary) is HouseholdReportSummaryRead + assert household_summary.household_id == household.household_id + assert household_summary.status == "succeeded" + assert not hasattr(household_summary, "result") + assert type(economy_summary) is ReportSummaryRead From 9154798e7ddd3dc4a51c5e0207cb406933573095 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:25:55 +0400 Subject: [PATCH 04/25] Test creating, reading and listing household reports at /v2/reports (failing) Co-Authored-By: Claude Opus 5.5 --- tests/unit/v2/test_household_report_routes.py | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 tests/unit/v2/test_household_report_routes.py diff --git a/tests/unit/v2/test_household_report_routes.py b/tests/unit/v2/test_household_report_routes.py new file mode 100644 index 000000000..6531fcff8 --- /dev/null +++ b/tests/unit/v2/test_household_report_routes.py @@ -0,0 +1,315 @@ +"""Create, read and list household reports at /v2/reports over stand-in services.""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Any +from uuid import UUID + +import pytest + +from policyengine_api.services.v2.household_calculations.types import ( + HouseholdCalculationOutput, +) +from policyengine_api.services.v2.households.validators import ( + HouseholdNotFoundError, +) +from policyengine_api.services.v2.simulation_service.types import ( + HouseholdReportCreationInput, +) +from policyengine_api.services.v2.simulation_service.validators import ( + HouseholdCalculationsUnsupportedError, +) +from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdReportResult, + HouseholdResultUnreadableError, +) +from policyengine_api.services.v2.stored_reports.types import ( + HouseholdReportRead, + HouseholdReportSummaryRead, + ReportSimulationRead, +) +from tests.unit.v2.stored_report_support import ( + BASELINE_SIMULATION_ID, + CREATED_AT, + CURRENT_VERSIONS, + POLICY_ID, + REFORM_SIMULATION_ID, + REPORT_ID, + FakeReportService, + report_route_client, + report_run_read, + report_summary_read, +) + +HOUSEHOLD_ID = UUID("00000000-0000-0000-0000-00000000e001") +REPORTS = "/v2/reports?country_id=us" +BASELINE_HOUSEHOLD = {"people": {"you": {"net_income": {"2026": 30_000.0}}}} +REFORM_HOUSEHOLD = {"people": {"you": {"net_income": {"2026": 31_250.5}}}} + + +def household_summary_fields(**changes: Any) -> dict[str, Any]: + values: dict[str, Any] = { + "id": REPORT_ID, + "country_id": "us", + "kind": "household", + "label": "My household under the reform", + "description": None, + "year": 2026, + "region_code": None, + "dataset_name": None, + "household_id": HOUSEHOLD_ID, + "baseline_simulation": ReportSimulationRead( + id=BASELINE_SIMULATION_ID, policy_id=None, dynamic_id=None + ), + "reform_simulation": ReportSimulationRead( + id=REFORM_SIMULATION_ID, policy_id=POLICY_ID, dynamic_id=None + ), + "options": {"add_missing_variables": True}, + "created_at": CREATED_AT, + "status": "succeeded", + "run": report_run_read(), + "current_versions": CURRENT_VERSIONS, + } + return {**values, **changes} + + +def household_report_read(**changes: Any) -> HouseholdReportRead: + fields = { + "result": HouseholdReportResult( + baseline=HouseholdCalculationOutput( + household=BASELINE_HOUSEHOLD, + warnings=(), + spm_config=None, + spm_provenance=None, + ), + reform=HouseholdCalculationOutput( + household=REFORM_HOUSEHOLD, + warnings=("ignored_input",), + spm_config={"geography_kind": "national"}, + spm_provenance={"scenario": "baseline"}, + ), + ), + **household_summary_fields(), + **changes, + } + return HouseholdReportRead(**fields) + + +def household_body(**changes: Any) -> dict[str, Any]: + values: dict[str, Any] = { + "country_id": "us", + "kind": "household", + "label": "My household under the reform", + "household_id": str(HOUSEHOLD_ID), + "year": 2026, + "baseline": {}, + "reform": {"policy_id": str(POLICY_ID)}, + "add_missing_variables": True, + } + return {**values, **changes} + + +def test_a_household_report_is_created_and_answered_as_stored() -> None: + service = FakeReportService( + report=household_report_read( + status="pending", + run=report_run_read(status="pending", completed_at=None), + result=None, + ) + ) + + response = report_route_client(reports=service).post(REPORTS, json=household_body()) + + assert response.status_code == 201 + item = response.json()["result"]["item"] + assert item["kind"] == "household" + assert item["household_id"] == str(HOUSEHOLD_ID) + assert item["status"] == "pending" + assert item["result"] is None + assert item["options"] == {"add_missing_variables": True} + ((name, arguments),) = service.calls + assert name == "create_report" + assert arguments["report_input"] == HouseholdReportCreationInput.model_validate( + household_body() + ) + + +def test_a_household_report_of_the_baseline_alone_can_be_created() -> None: + service = FakeReportService( + report=household_report_read(reform_simulation=None, result=None) + ) + body = household_body() + del body["reform"] + + response = report_route_client(reports=service).post(REPORTS, json=body) + + assert response.status_code == 201 + assert response.json()["result"]["item"]["reform_simulation"] is None + ((_, arguments),) = service.calls + assert arguments["report_input"].reform is None + + +@pytest.mark.parametrize( + "changes", + [ + pytest.param({"label": None}, id="no-label"), + pytest.param({"year": None}, id="no-year"), + pytest.param({"household_id": None}, id="no-household"), + pytest.param({"region_code": "us"}, id="economy-field"), + pytest.param({"include_cliffs": True}, id="economy-option"), + pytest.param({"kind": "budget"}, id="unknown-kind"), + pytest.param({"add_missing_variables": "yes"}, id="option-not-a-boolean"), + ], +) +def test_a_household_report_outside_its_schema_is_refused_with_422( + changes: dict[str, Any], +) -> None: + service = FakeReportService() + body = { + name: value + for name, value in household_body(**changes).items() + if value is not None + } + + response = report_route_client(reports=service).post(REPORTS, json=body) + + assert response.status_code == 422 + assert service.calls == [] + + +def test_a_household_report_naming_another_country_is_refused_with_400() -> None: + service = FakeReportService() + + response = report_route_client(reports=service).post( + REPORTS, json=household_body(country_id="uk") + ) + + assert response.status_code == 400 + assert service.calls == [] + + +@pytest.mark.parametrize( + ("error", "status_code"), + [ + pytest.param( + HouseholdCalculationsUnsupportedError( + "Household reports are not available for country 'us'" + ), + 422, + id="no-household-calculations", + ), + pytest.param( + HouseholdNotFoundError(f"Household {HOUSEHOLD_ID} was not found"), + 404, + id="household-not-stored", + ), + ], +) +def test_a_household_report_the_checks_refuse_is_answered_with_its_reason( + error: Exception, status_code: int +) -> None: + service = FakeReportService(error=error) + + response = report_route_client(reports=service).post(REPORTS, json=household_body()) + + assert response.status_code == status_code + assert response.json()["message"] == str(error) + + +def test_a_household_report_answers_both_calculated_households() -> None: + service = FakeReportService(report=household_report_read()) + + response = report_route_client(reports=service).get( + f"/v2/reports/{REPORT_ID}?country_id=us" + ) + + assert response.status_code == 200 + item = response.json()["result"]["item"] + assert item["kind"] == "household" + assert item["household_id"] == str(HOUSEHOLD_ID) + assert item["year"] == 2026 + assert "region_code" not in item + assert item["run"]["baseline_simulation_run_id"] is not None + assert item["result"] == { + "baseline": { + "household": BASELINE_HOUSEHOLD, + "warnings": [], + "spm_config": None, + "spm_provenance": None, + }, + "reform": { + "household": REFORM_HOUSEHOLD, + "warnings": ["ignored_input"], + "spm_config": {"geography_kind": "national"}, + "spm_provenance": {"scenario": "baseline"}, + }, + } + + +def test_a_household_report_of_the_baseline_alone_answers_no_reform() -> None: + report = household_report_read(reform_simulation=None) + assert report.result is not None + service = FakeReportService( + report=replace(report, result=replace(report.result, reform=None)) + ) + + item = ( + report_route_client(reports=service) + .get(f"/v2/reports/{REPORT_ID}?country_id=us") + .json()["result"]["item"] + ) + + assert item["reform_simulation"] is None + assert item["result"]["reform"] is None + + +def test_a_household_report_whose_households_cannot_be_read_answers_500() -> None: + service = FakeReportService( + error=HouseholdResultUnreadableError("simulation run has no result") + ) + + response = report_route_client(reports=service).get( + f"/v2/reports/{REPORT_ID}?country_id=us" + ) + + assert response.status_code == 500 + assert response.json()["message"] == "The report's stored result cannot be read" + + +def test_a_list_answers_household_and_economy_reports_by_kind_without_results() -> None: + service = FakeReportService( + listed=( + HouseholdReportSummaryRead(**household_summary_fields()), + report_summary_read(), + ) + ) + + response = report_route_client(reports=service).get( + f"/v2/reports?country_id=us&report_id={REPORT_ID}" + ) + + assert response.status_code == 200 + household_item, economy_item = response.json()["result"]["items"] + assert household_item["kind"] == "household" + assert household_item["household_id"] == str(HOUSEHOLD_ID) + assert "result" not in household_item + assert economy_item["kind"] == "economy" + assert "result" not in economy_item + + +def test_the_create_routes_schema_offers_the_household_kind() -> None: + schema = report_route_client(reports=FakeReportService()).get("/v2/openapi.json") + + components = schema.json()["components"]["schemas"] + household_request = components["HouseholdReportCreateRequest"] + assert household_request["properties"]["kind"]["const"] == "household" + assert set(household_request["required"]) >= { + "country_id", + "kind", + "label", + "household_id", + "year", + "baseline", + } + assert "HouseholdReportItem" in components From bbc40ba1fc7f137dcae9ae50920b57772caf7fa6 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:28:17 +0400 Subject: [PATCH 05/25] Read, list and create household reports at /v2/reports A household report is told from an economy report by its household_id. Its result, once its newest run has succeeded, is each simulation run's calculated household from simulation_runs.household_result, read as the household function's version 1 result document; a document that cannot be read answers 500. Lists name household reports beside economy reports. The create body is a union told apart by kind, and the household checks' refusals answer 422 (no household calculations) and 404 (household not stored). Budget windows are still not read here. Co-Authored-By: Claude Opus 5.5 --- .../resource_service_protocols.py | 44 ++++--- .../v2/reports/request_models.py | 24 +++- .../v2/reports/response_models.py | 115 ++++++++++++++++-- .../fastapi_routes/v2/reports/routes.py | 58 +++++---- .../v2/simulation_resources_support.py | 13 +- .../reads_household_results.py | 34 ++++++ .../household_results/__init__.py | 1 + .../stored_reports/household_results/types.py | 54 ++++++++ .../household_results/validators.py | 64 ++++++++++ .../services/v2/stored_reports/services.py | 110 ++++++++++++----- .../v2/stored_reports/transformations.py | 76 ++++++++++-- .../services/v2/stored_reports/types.py | 49 +++++++- .../services/v2/stored_reports/validators.py | 27 ++-- tests/integration/test_v2_report_reads.py | 19 +-- tests/unit/v2/test_household_report_routes.py | 6 +- 15 files changed, 560 insertions(+), 134 deletions(-) create mode 100644 policyengine_api/services/v2/stored_reports/database_connectors/reads_household_results.py create mode 100644 policyengine_api/services/v2/stored_reports/household_results/__init__.py create mode 100644 policyengine_api/services/v2/stored_reports/household_results/types.py create mode 100644 policyengine_api/services/v2/stored_reports/household_results/validators.py diff --git a/policyengine_api/fastapi_routes/resource_service_protocols.py b/policyengine_api/fastapi_routes/resource_service_protocols.py index f444ab644..f468c6691 100644 --- a/policyengine_api/fastapi_routes/resource_service_protocols.py +++ b/policyengine_api/fastapi_routes/resource_service_protocols.py @@ -32,12 +32,12 @@ NativePolicyCreationInput, ) from policyengine_api.services.v2.simulation_service.types import ( - EconomyReportCreationInput, EconomySimulationCreationInput, + ReportCreationInput, ) from policyengine_api.services.v2.stored_reports.types import ( - ReportRead, - ReportSummaryRead, + StoredReportRead, + StoredReportSummaryRead, ) from policyengine_api.services.v2.stored_simulations.types import SimulationRead from policyengine_api.services.v2.user_accounts.types import ( @@ -190,33 +190,37 @@ def get_calculation(self, calculation_id: UUID) -> HouseholdCalculationRead: class V2StoredReportResourceService(Protocol): """Route-independent report operations for one request.""" - def get_report(self, *, country_id: str, report_id: UUID) -> ReportRead: - """Return the country's economy report, its newest run and that run's result. + def get_report(self, *, country_id: str, report_id: UUID) -> StoredReportRead: + """Return the country's report, its newest run and that run's result. - Raise ``StoredReportNotFoundError`` when the country has no such - report, ``ReportKindUnsupportedError`` for a household report or - budget window, and ``EconomyResultUnreadableError`` when a succeeded - run's result cannot be read. + The report is an economy report or a household report. Raise + ``StoredReportNotFoundError`` when the country has no such report, + ``ReportKindUnsupportedError`` for a budget window, and + ``EconomyResultUnreadableError`` or ``HouseholdResultUnreadableError`` + when a succeeded run's result cannot be read. """ ... def list_reports( self, *, country_id: str, report_ids: Sequence[UUID] - ) -> tuple[ReportSummaryRead, ...]: - """Return the country's economy reports among ``report_ids``, in that order.""" + ) -> tuple[StoredReportSummaryRead, ...]: + """Return the country's economy and household reports, as named.""" ... - def create_report(self, report_input: EconomyReportCreationInput) -> ReportRead: + def create_report(self, report_input: ReportCreationInput) -> StoredReportRead: """Create the report through the simulation service; return it as stored. - Raise ``EconomyCalculationsUnsupportedError`` for a country without - economy reports, ``PolicyNotFoundError`` for a policy the country - does not store, ``PolicyParametersMissingError`` for a policy that - sets parameters the running catalog version lacks, - ``SubmissionStorageError`` when the database or its catalog cannot be - read for those checks, the simulation service's errors, and - ``CreatedReportUnreadableError`` when the report it created cannot be - read back. + Raise ``EconomyCalculationsUnsupportedError`` for an economy report in + a country without economy reports, + ``HouseholdCalculationsUnsupportedError`` for a household report in a + country without household calculations, ``HouseholdNotFoundError`` + for a household the country does not store, ``PolicyNotFoundError`` + for a policy the country does not store, + ``PolicyParametersMissingError`` for a policy that sets parameters the + running catalog version lacks, ``SubmissionStorageError`` when the + database or its catalog cannot be read for those checks, the + simulation service's errors, and ``CreatedReportUnreadableError`` when + the report it created cannot be read back. """ ... diff --git a/policyengine_api/fastapi_routes/v2/reports/request_models.py b/policyengine_api/fastapi_routes/v2/reports/request_models.py index 836d8106b..f114ccbf6 100644 --- a/policyengine_api/fastapi_routes/v2/reports/request_models.py +++ b/policyengine_api/fastapi_routes/v2/reports/request_models.py @@ -1,14 +1,36 @@ """Strict HTTP request models for v2 reports.""" +from typing import Annotated + +from pydantic import Field + from policyengine_api.services.v2.simulation_service.types import ( EconomyReportCreationInput, + HouseholdReportCreationInput, ) -class ReportCreateRequest(EconomyReportCreationInput): +class EconomyReportCreateRequest(EconomyReportCreationInput): """An economy report to create; ``country_id`` must match the query's. Each simulation's empty object is current law. No field names a PolicyEngine.py version: the simulation service runs its default worker's. """ + + +class HouseholdReportCreateRequest(HouseholdReportCreationInput): + """A household report to create; ``country_id`` must match the query's. + + It calculates the stored household ``household_id`` in ``year`` under + ``baseline`` and ``reform``; each empty object is current law, and a null + or omitted ``reform`` makes a report of the baseline alone. No field names + a PolicyEngine.py version. + """ + + +# A report to create: ``kind`` says which. +ReportCreateRequest = Annotated[ + EconomyReportCreateRequest | HouseholdReportCreateRequest, + Field(discriminator="kind"), +] diff --git a/policyengine_api/fastapi_routes/v2/reports/response_models.py b/policyengine_api/fastapi_routes/v2/reports/response_models.py index a6843c029..33ca01daa 100644 --- a/policyengine_api/fastapi_routes/v2/reports/response_models.py +++ b/policyengine_api/fastapi_routes/v2/reports/response_models.py @@ -3,15 +3,18 @@ from __future__ import annotations from datetime import datetime -from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar +from typing import TYPE_CHECKING, Annotated, Any, Generic, Literal, TypeVar from uuid import UUID -from pydantic import BaseModel, ConfigDict, JsonValue +from pydantic import BaseModel, ConfigDict, Field, JsonValue from policyengine_api.fastapi_routes.v2.errors_support import ( V2ErrorResponse, V2MissingParametersErrorResponse, ) +from policyengine_api.fastapi_routes.v2.household_calculations.response_models import ( + HouseholdCalculationOutputItem, +) from policyengine_api.fastapi_routes.v2.simulation_resources_support import ( error_response, ) @@ -21,8 +24,12 @@ if TYPE_CHECKING: from policyengine_api.services.v2.stored_reports.types import ( + HouseholdReportRead, + HouseholdReportSummaryRead, ReportRead, ReportSummaryRead, + StoredReportRead, + StoredReportSummaryRead, ) @@ -115,16 +122,98 @@ def from_read(cls, read: ReportRead) -> ReportItem: return cls.model_validate(read) +class HouseholdReportSummaryItem(StrictReportAPIModel): + """A household report: one stored household under a baseline and a reform. + + Both simulations calculate the household ``household_id`` in ``year``; + ``reform_simulation`` is null for a report of the baseline alone. + ``status`` is the newest run's, or ``not_run`` for a report with no run. + ``run`` is the newest run, whatever its status and versions, naming each + simulation's run; ``current_versions`` are the versions the Public API + runs now, so a newest run on other versions is out of date. ``options`` + are the options the report was created with. + """ + + id: UUID + country_id: str + kind: Literal["household"] + label: str + description: str | None + year: int | None + household_id: UUID + baseline_simulation: ReportSimulationItem | None + reform_simulation: ReportSimulationItem | None + options: dict[str, JsonValue] + created_at: datetime + status: Literal["not_run", "pending", "running", "succeeded", "failed"] + run: ReportRunItem | None + current_versions: PackageVersionsItem + + @classmethod + def from_summary( + cls, read: HouseholdReportSummaryRead + ) -> HouseholdReportSummaryItem: + """Build the summary from the service's read, without a result.""" + return cls.model_validate(read) + + +class HouseholdReportResultItem(StrictReportAPIModel): + """Each simulation's calculated household. + + Each is the stored household with every value its calculation filled in, + as ``/v2/household-calculations`` answers one. ``reform`` is null for a + report of the baseline alone. + """ + + baseline: HouseholdCalculationOutputItem + reform: HouseholdCalculationOutputItem | None + + +class HouseholdReportItem(HouseholdReportSummaryItem): + """A household report with its newest run's calculated households. + + ``result`` is there once the newest run has succeeded, and null otherwise. + """ + + result: HouseholdReportResultItem | None + + @classmethod + def from_read(cls, read: HouseholdReportRead) -> HouseholdReportItem: + """Build the item from the service's read, with its result.""" + return cls.model_validate(read) + + +def report_item(read: StoredReportRead) -> ReportItem | HouseholdReportItem: + """Build the item of the report's kind from the service's read.""" + if read.kind == "household": + return HouseholdReportItem.from_read(read) + return ReportItem.from_read(read) + + +def report_summary_item( + read: StoredReportSummaryRead, +) -> ReportSummaryItem | HouseholdReportSummaryItem: + """Build the summary of the report's kind from the service's read.""" + if read.kind == "household": + return HouseholdReportSummaryItem.from_summary(read) + return ReportSummaryItem.from_summary(read) + + class ReportDetailResult(StrictReportAPIModel): - """Result holding one report in ``item``.""" + """Result holding one report in ``item``; its ``kind`` says which model it is.""" - item: ReportItem + item: Annotated[ReportItem | HouseholdReportItem, Field(discriminator="kind")] class ReportListResult(StrictReportAPIModel): """The listed reports in ``items``, in the order named, without results.""" - items: list[ReportSummaryItem] + items: list[ + Annotated[ + ReportSummaryItem | HouseholdReportSummaryItem, + Field(discriminator="kind"), + ] + ] ResultT = TypeVar("ResultT") @@ -175,23 +264,29 @@ class UnavailableAfterReportCreatedResponse(V2ErrorResponse): READ_RESPONSES: dict[int | str, dict[str, Any]] = { 404: error_response("No report has this ID in this country."), 422: error_response( - "The request does not match the schema, or the report is a household " - "report or a budget window, which this route does not read yet." + "The request does not match the schema, or the report is a budget " + "window, which this route does not read yet." ), 500: error_response( - "The newest run succeeded but its stored result cannot be read, or the " + "The newest run succeeded but its stored result cannot be read (for a " + "household report, a simulation run's calculated household), or the " "operation could not be completed." ), } CREATE_RESPONSES: dict[int | str, dict[str, Any]] = { 400: error_response("The body and query name different countries."), - 404: error_response("A policy the report names is not stored in the country."), + 404: error_response( + "A policy the report names, or a household report's household, is not " + "stored in the country." + ), 413: error_response("The request body exceeds 16 KiB."), 422: { "model": V2MissingParametersErrorResponse | V2ErrorResponse, "description": ( "The request does not match the schema; the country has no economy " - "reports (such as a household-only country); a policy the report " + "reports (such as a household-only country), for an economy report, " + "or no household calculations, for a household report; a policy the " + "report " "names sets parameters the running catalog version does not have " "(code missing_parameters, which names them; the baseline's policy is " "checked first); or the simulation service cannot run the report: its " diff --git a/policyengine_api/fastapi_routes/v2/reports/routes.py b/policyengine_api/fastapi_routes/v2/reports/routes.py index b57fcd6d8..554d6b717 100644 --- a/policyengine_api/fastapi_routes/v2/reports/routes.py +++ b/policyengine_api/fastapi_routes/v2/reports/routes.py @@ -3,9 +3,10 @@ The routes need no user credential: a user keeps a report through ``/v2/user-reports``. Reports, their runs and results are read from the detailed-save tables the simulation service and its Modal workers write; -reading never calls the simulation service and never starts a run. Creating -a report checks it, has the simulation service create and start it, and -reads it back. +reading never calls the simulation service and never starts a run. A report +is an economy report or a household report, which its ``kind`` names. +Creating a report checks it, has the simulation service create and start it, +and reads it back. """ from __future__ import annotations @@ -34,11 +35,11 @@ CreatedReportUnreadableResponse, ReportDetailResponse, ReportDetailResult, - ReportItem, ReportListResponse, ReportListResult, - ReportSummaryItem, UnavailableAfterReportCreatedResponse, + report_item, + report_summary_item, ) from policyengine_api.fastapi_routes.v2.simulation_resources_support import ( SUBMISSION_ERROR_MAPPINGS, @@ -61,6 +62,9 @@ from policyengine_api.services.v2.stored_reports.economy_results.types import ( EconomyResultUnreadableError, ) +from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdResultUnreadableError, +) from policyengine_api.services.v2.stored_reports.validators import ( CreatedReportUnreadableError, ReportKindUnsupportedError, @@ -101,7 +105,7 @@ V2ErrorMapping((ReportKindUnsupportedError,), 422), *SUBMISSION_ERROR_MAPPINGS, V2ErrorMapping( - (EconomyResultUnreadableError,), + (EconomyResultUnreadableError, HouseholdResultUnreadableError), 500, "The report's stored result cannot be read", ), @@ -169,9 +173,7 @@ def _create_report( message=str(error), report_id=error.created_record_id ), ) - return ReportDetailResponse( - result=ReportDetailResult(item=ReportItem.from_read(report)) - ) + return ReportDetailResponse(result=ReportDetailResult(item=report_item(report))) def build_v2_report_router(dependencies: NativeRouteDependencies) -> APIRouter: @@ -184,14 +186,19 @@ def build_v2_report_router(dependencies: NativeRouteDependencies) -> APIRouter: response_model=ReportDetailResponse, status_code=201, responses=CREATE_RESPONSES, - summary="Create an economy report", + summary="Create an economy report or a household report", description=( "Checks that the country has economy reports and stores each named " "policy, and that the running catalog version has every parameter " "those policies set, then has the simulation service create the " "report, its two " "simulations and their runs on its default worker's PolicyEngine.py " - "version, and start the run. Answers the report as stored, with its " + "version, and start the run. A household report (kind household) " + "is checked the same way, except that the country must have " + "household calculations instead (a household-only country does), " + "and the household it names must be stored in the country; its " + "simulations each calculate that household in the report's year. " + "Answers the report as stored, with its " "pending run; read it again to follow the run. A created report " "that cannot be read back answers 502 with its report_id, and one " "whose run the simulation service failed before answering 503 " @@ -214,12 +221,12 @@ def create() -> ReportDetailResponse | JSONResponse: @router.get( "/reports", response_model=ReportListResponse, - summary="List chosen economy reports", + summary="List chosen reports", description=( - "Lists the country's economy reports among the report_id values (at " - "most 25), in the order named and once each, with their newest runs " - "but without results, read in one query. Unknown reports, other " - "countries' and reports of other kinds are left out." + "Lists the country's economy and household reports among the " + "report_id values (at most 25), in the order named and once each, " + "with their newest runs but without results, read in one query. " + "Unknown reports, other countries' and budget windows are left out." ), ) def list_reports( @@ -231,7 +238,7 @@ def read() -> ReportListResponse: ) return ReportListResponse( result=ReportListResult( - items=[ReportSummaryItem.from_summary(report) for report in reports] + items=[report_summary_item(report) for report in reports] ) ) @@ -241,13 +248,16 @@ def read() -> ReportListResponse: "/reports/{report_id}", response_model=ReportDetailResponse, responses=READ_RESPONSES, - summary="Read an economy report", + summary="Read a report", description=( - "Returns the report, its newest run whatever its status and " - "versions, the versions the Public API runs now, and, once the " - "newest run has succeeded, its result in today's economy result " - "format. A report with no run, such as one migrated from v1, " - "answers its definition with no result; reading starts no run." + "Returns the economy or household report, its newest run whatever " + "its status and versions, the versions the Public API runs now, " + "and, once the newest run has succeeded, its result. An economy " + "report's result is in today's economy result format; a household " + "report's is each simulation's calculated household, as " + "/v2/household-calculations answers one. A report with no run, such " + "as one migrated from v1, answers its definition with no result; " + "reading starts no run." ), ) def get_report( @@ -259,7 +269,7 @@ def read() -> ReportDetailResponse: country_id=query.country_id, report_id=report_id ) return ReportDetailResponse( - result=ReportDetailResult(item=ReportItem.from_read(report)) + result=ReportDetailResult(item=report_item(report)) ) return _report_operation(read) diff --git a/policyengine_api/fastapi_routes/v2/simulation_resources_support.py b/policyengine_api/fastapi_routes/v2/simulation_resources_support.py index 971b73cb4..9d3940c43 100644 --- a/policyengine_api/fastapi_routes/v2/simulation_resources_support.py +++ b/policyengine_api/fastapi_routes/v2/simulation_resources_support.py @@ -22,6 +22,7 @@ run_v2_operation, v2_missing_parameters_response, ) +from policyengine_api.services.v2.households.validators import HouseholdNotFoundError from policyengine_api.services.v2.policies.validators import ( PolicyNotFoundError, PolicyParametersMissingError, @@ -34,6 +35,7 @@ ) from policyengine_api.services.v2.simulation_service.validators import ( EconomyCalculationsUnsupportedError, + HouseholdCalculationsUnsupportedError, SubmissionStorageError, ) @@ -47,7 +49,9 @@ # not logged. SUBMISSION_REFUSALS: tuple[type[Exception], ...] = ( PolicyNotFoundError, + HouseholdNotFoundError, EconomyCalculationsUnsupportedError, + HouseholdCalculationsUnsupportedError, SimulationServiceInvalidRequestError, ) # How the simulation service's answers and the submission checks are answered. @@ -61,9 +65,14 @@ # the record it created and failed is still a 503, and each create route # answers it with the record's ID (``unavailable_after_creation_answer``). SUBMISSION_ERROR_MAPPINGS = ( - V2ErrorMapping((PolicyNotFoundError,), 404), + V2ErrorMapping((PolicyNotFoundError, HouseholdNotFoundError), 404), V2ErrorMapping( - (EconomyCalculationsUnsupportedError, SimulationServiceInvalidRequestError), 422 + ( + EconomyCalculationsUnsupportedError, + HouseholdCalculationsUnsupportedError, + SimulationServiceInvalidRequestError, + ), + 422, ), V2ErrorMapping( (SimulationServiceInternalError, SimulationServiceAnswerUnreadableError), 502 diff --git a/policyengine_api/services/v2/stored_reports/database_connectors/reads_household_results.py b/policyengine_api/services/v2/stored_reports/database_connectors/reads_household_results.py new file mode 100644 index 000000000..1a307836c --- /dev/null +++ b/policyengine_api/services/v2/stored_reports/database_connectors/reads_household_results.py @@ -0,0 +1,34 @@ +"""Database reads of the calculated households a household report run names.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlmodel import col, select + +from policyengine_api.data.v2.models import SimulationRun +from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdResultRows, +) + +if TYPE_CHECKING: + from sqlmodel import Session + + from policyengine_api.data.v2.models import ReportRun + + +def read_household_result_rows( + session: Session, *, report_run: ReportRun +) -> HouseholdResultRows: + """Return the result documents of the report run's simulation runs, in one query. + + Each simulation run the report run names maps to its ``household_result``; + a run that does not exist is absent. + """ + simulation_run_ids = [report_run.baseline_simulation_run_id] + if report_run.reform_simulation_run_id is not None: + simulation_run_ids.append(report_run.reform_simulation_run_id) + statement = select( + col(SimulationRun.id), col(SimulationRun.household_result) + ).where(col(SimulationRun.id).in_(simulation_run_ids)) + return HouseholdResultRows(documents=dict(session.exec(statement).all())) diff --git a/policyengine_api/services/v2/stored_reports/household_results/__init__.py b/policyengine_api/services/v2/stored_reports/household_results/__init__.py new file mode 100644 index 000000000..0b1684816 --- /dev/null +++ b/policyengine_api/services/v2/stored_reports/household_results/__init__.py @@ -0,0 +1 @@ +"""Household report results read from each simulation run's calculated household.""" diff --git a/policyengine_api/services/v2/stored_reports/household_results/types.py b/policyengine_api/services/v2/stored_reports/household_results/types.py new file mode 100644 index 000000000..4ccd058c6 --- /dev/null +++ b/policyengine_api/services/v2/stored_reports/household_results/types.py @@ -0,0 +1,54 @@ +"""The result of a household report: each simulation's calculated household. + +A household report's simulations each calculate the report's stored +household under one policy. The household function stores each simulation +run's calculation in ``simulation_runs.household_result``, as the same +result document a household calculation stores in ``household_jobs.result`` +(``household_calculations.types.StoredExecutionResult``). Once the report's +run has succeeded, the result is both calculations, read from those +documents. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from uuid import UUID + + from policyengine_api.services.v2.household_calculations.types import ( + HouseholdCalculationOutput, + ) + + +class HouseholdResultUnreadableError(RuntimeError): + """A succeeded household report run's calculated households cannot be read. + + A simulation run it names is missing, or holds no result document of the + household contract's version 1 with a calculated household. + """ + + +@dataclass(frozen=True) +class HouseholdResultRows: + """The result documents of a report run's simulation runs, by run ID. + + A run with no stored document maps to None; a run that does not exist is + absent. + """ + + documents: dict[UUID, Any] + + +@dataclass(frozen=True) +class HouseholdReportResult: + """A household report's result: the baseline's and the reform's calculations. + + Each is the stored household with every value the calculation filled in, + its warnings and its SPM settings and receipt. ``reform`` is None for a + report of the baseline alone. + """ + + baseline: HouseholdCalculationOutput + reform: HouseholdCalculationOutput | None diff --git a/policyengine_api/services/v2/stored_reports/household_results/validators.py b/policyengine_api/services/v2/stored_reports/household_results/validators.py new file mode 100644 index 000000000..668634c73 --- /dev/null +++ b/policyengine_api/services/v2/stored_reports/household_results/validators.py @@ -0,0 +1,64 @@ +"""Checks of the calculated households a household report run's simulations stored.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import ValidationError + +from policyengine_api.services.v2.household_calculations.types import ( + StoredExecutionResult, +) +from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdReportResult, + HouseholdResultUnreadableError, +) + +if TYPE_CHECKING: + from uuid import UUID + + from policyengine_api.data.v2.models import ReportRun + from policyengine_api.services.v2.household_calculations.types import ( + HouseholdCalculationOutput, + ) + from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdResultRows, + ) + + +def _calculated_household( + rows: HouseholdResultRows, simulation_run_id: UUID +) -> HouseholdCalculationOutput: + """Return one simulation run's calculated household. + + Raise HouseholdResultUnreadableError when the run does not exist or its + document is not a version 1 result document with a calculated household. + """ + if simulation_run_id not in rows.documents: + msg = f"simulation run {simulation_run_id} does not exist" + raise HouseholdResultUnreadableError(msg) + try: + document = StoredExecutionResult.model_validate( + rows.documents[simulation_run_id] + ) + except ValidationError as error: + msg = f"simulation run {simulation_run_id} has no readable household result" + raise HouseholdResultUnreadableError(msg) from error + return document.calculation_output() + + +def require_household_report_result( + report_run: ReportRun, rows: HouseholdResultRows +) -> HouseholdReportResult: + """Return a succeeded report run's calculated households. + + The reform's is read only when the run names a reform simulation run. + Raise HouseholdResultUnreadableError when either cannot be read. + """ + reform_run_id = report_run.reform_simulation_run_id + return HouseholdReportResult( + baseline=_calculated_household(rows, report_run.baseline_simulation_run_id), + reform=None + if reform_run_id is None + else _calculated_household(rows, reform_run_id), + ) diff --git a/policyengine_api/services/v2/stored_reports/services.py b/policyengine_api/services/v2/stored_reports/services.py index dee832101..8b4b9da86 100644 --- a/policyengine_api/services/v2/stored_reports/services.py +++ b/policyengine_api/services/v2/stored_reports/services.py @@ -11,12 +11,15 @@ v1 has none, answers its definition with no result. The result is rebuilt from the newest run's result rows once it has -succeeded. Lists name at most 25 reports and read them, with their newest -runs, in one query, without results. - -Creating a report checks the country and the policies the report names, asks -the simulation service to create and start it, then reads it back from the -tables. Once the simulation service has answered with the report's ID, the +succeeded. A household report's result is instead each of its simulation +runs' calculated household (``simulation_runs.household_result``), read once +its newest report run has succeeded. Lists name at most 25 reports and read +them, with their newest runs, in one query, without results. + +Creating a report checks the country and the policies the report names (and +a household report's household), asks the simulation service to create and +start it, then reads it back from the tables. Once the simulation service +has answered with the report's ID, the report exists: a failure to read it back raises CreatedReportUnreadableError, which names it, so the caller reads it rather than submitting it again. Re-runs and run lists are not here. @@ -33,6 +36,9 @@ from policyengine_api.services.v2.simulation_service.types import ( SimulationServiceUnavailableError, ) +from policyengine_api.services.v2.stored_reports.database_connectors import ( + reads_household_results as household_result_reads, +) from policyengine_api.services.v2.stored_reports.database_connectors import ( reads_results as result_reads, ) @@ -42,15 +48,21 @@ from policyengine_api.services.v2.stored_reports.economy_results import ( transformations as result_transformations, ) +from policyengine_api.services.v2.stored_reports.household_results.validators import ( + require_household_report_result, +) from policyengine_api.services.v2.stored_reports.transformations import ( - listed_economy_reports, + household_report_read, + household_report_summary_read, + listed_reports, + report_kind, report_read, report_summary_read, ) from policyengine_api.services.v2.stored_reports.validators import ( CreatedReportUnreadableError, ReportStorageError, - require_economy_report, + require_readable_report, require_reform_run, require_report, ) @@ -59,17 +71,21 @@ from collections.abc import Callable, Iterator, Sequence from uuid import UUID + from sqlmodel import Session + from policyengine_api.services.v2.simulation_service.types import ( - EconomyReportCreationInput, PackageVersions, + ReportCreationInput, SimulationServiceClient, ) from policyengine_api.services.v2.stored_reports.database_session import ( StoredReportDatabaseSession, ) from policyengine_api.services.v2.stored_reports.types import ( - ReportRead, - ReportSummaryRead, + HouseholdReportRead, + StoredReportRead, + StoredReportRows, + StoredReportSummaryRead, ) @@ -88,7 +104,7 @@ def _storage_failures() -> Iterator[None]: class V2StoredReportService: - """Read v2 reports and their results, and create economy reports.""" + """Read v2 reports and their results, and create economy and household reports.""" def __init__( self, @@ -108,14 +124,16 @@ def __init__( self._current_versions = current_versions self._submissions = submissions - def get_report(self, *, country_id: str, report_id: UUID) -> ReportRead: - """Return the country's economy report, its newest run and that run's result. + def get_report(self, *, country_id: str, report_id: UUID) -> StoredReportRead: + """Return the country's report, its newest run and that run's result. - Raise StoredReportNotFoundError when the country has no such report, - ReportKindUnsupportedError for a household report or budget window, + The report is an economy report or a household report. Raise + StoredReportNotFoundError when the country has no such report, + ReportKindUnsupportedError for a budget window, EconomyResultUnreadableError when a succeeded run's rows lack a part - every result has, and ReportStorageError when the database cannot be - read. + every result has, HouseholdResultUnreadableError when a succeeded + household report run's calculated households cannot be read, and + ReportStorageError when the database cannot be read. """ with _storage_failures(), self._database_session.read() as session: stored = require_report( @@ -130,7 +148,8 @@ def get_report(self, *, country_id: str, report_id: UUID) -> ReportRead: report_id=report_id, country_id=country_id, ) - require_economy_report(stored) + if require_readable_report(stored) == "household": + return self._household_report(session, stored) run = stored.newest_run result = None if run is not None and run.status is ReportRunStatus.SUCCEEDED: @@ -148,14 +167,33 @@ def get_report(self, *, country_id: str, report_id: UUID) -> ReportRead: result=result, ) + def _household_report( + self, session: Session, stored: StoredReportRows + ) -> HouseholdReportRead: + """Return a household report, with its result once its newest run succeeded.""" + run = stored.newest_run + result = None + if run is not None and run.status is ReportRunStatus.SUCCEEDED: + result = require_household_report_result( + run, + household_result_reads.read_household_result_rows( + session, report_run=run + ), + ) + return household_report_read( + stored, + current_versions=self._current_versions(stored.report.country), + result=result, + ) + def list_reports( self, *, country_id: str, report_ids: Sequence[UUID] - ) -> tuple[ReportSummaryRead, ...]: - """Return the country's economy reports among ``report_ids``, without results. + ) -> tuple[StoredReportSummaryRead, ...]: + """Return the country's reports among ``report_ids``, without results. - They are in the order named, each once; unknown reports, other - countries' and reports of other kinds are left out. Raise - ReportStorageError when the database cannot be read. + They are economy and household reports, in the order named, each + once; unknown reports, other countries' and budget windows are left + out. Raise ReportStorageError when the database cannot be read. """ with _storage_failures(), self._database_session.read() as session: rows = read_report_rows( @@ -163,23 +201,29 @@ def list_reports( ) current_versions = self._current_versions(country_id) return tuple( - report_summary_read(stored, current_versions=current_versions) - for stored in listed_economy_reports(rows, report_ids=report_ids) + household_report_summary_read(stored, current_versions=current_versions) + if report_kind(stored.report) == "household" + else report_summary_read(stored, current_versions=current_versions) + for stored in listed_reports(rows, report_ids=report_ids) ) - def create_report(self, report_input: EconomyReportCreationInput) -> ReportRead: + def create_report(self, report_input: ReportCreationInput) -> StoredReportRead: """Create the report through the simulation service and return it as stored. - Raise the submission's errors (see ``SimulationSubmissionService``), - and CreatedReportUnreadableError, caused by the read's own error, when - the report the simulation service created cannot be read back in the - country: not stored there, the database unavailable, or any other - failure of the read. + An economy report and a household report each go through their own + submission. Raise the submission's errors (see + ``SimulationSubmissionService``), and CreatedReportUnreadableError, + caused by the read's own error, when the report the simulation service + created cannot be read back in the country: not stored there, the + database unavailable, or any other failure of the read. """ if self._submissions is None: msg = "The simulation service is not configured here" raise SubmissionsNotConfiguredError(msg) - submitted = self._submissions.submit_report(report_input) + if report_input.kind == "household": + submitted = self._submissions.submit_household_report(report_input) + else: + submitted = self._submissions.submit_report(report_input) try: return self.get_report( country_id=report_input.country_id, report_id=submitted.report_id diff --git a/policyengine_api/services/v2/stored_reports/transformations.py b/policyengine_api/services/v2/stored_reports/transformations.py index ff3c505e2..3e97828aa 100644 --- a/policyengine_api/services/v2/stored_reports/transformations.py +++ b/policyengine_api/services/v2/stored_reports/transformations.py @@ -5,6 +5,9 @@ from typing import TYPE_CHECKING from policyengine_api.services.v2.stored_reports.types import ( + HouseholdReportRead, + HouseholdReportSummaryRead, + ReportDefinitionRead, ReportRead, ReportRunRead, ReportSimulationRead, @@ -22,11 +25,18 @@ from policyengine_api.services.v2.stored_reports.economy_results.types import ( EconomyResult, ) + from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdReportResult, + ) from policyengine_api.services.v2.stored_reports.types import ( ReportKind, StoredReportRows, ) +# The kinds of report the report routes read; budget windows are read once +# their own routes exist. +READABLE_KINDS: frozenset[ReportKind] = frozenset({"economy", "household"}) + def report_kind(report: Report) -> ReportKind: """Return what the report compares, from the columns its kind sets.""" @@ -64,16 +74,14 @@ def _simulation(simulation: Simulation | None) -> ReportSimulationRead | None: ) -def report_summary_read( +def _report_definition( rows: StoredReportRows, *, current_versions: PackageVersions -) -> ReportSummaryRead: - """Return an economy report's definition and newest run, without a result.""" +) -> ReportDefinitionRead: + """Return what every report's read gives: its definition and current versions.""" report = rows.report - run = rows.newest_run - return ReportSummaryRead( + return ReportDefinitionRead( id=report.id, country_id=report.country, - kind="economy", label=report.label, description=report.description, year=report.year, @@ -83,9 +91,20 @@ def report_summary_read( reform_simulation=_simulation(rows.reform_simulation), options=dict(report.inputs), created_at=report.created_at, + current_versions=current_versions, + ) + + +def report_summary_read( + rows: StoredReportRows, *, current_versions: PackageVersions +) -> ReportSummaryRead: + """Return an economy report's definition and newest run, without a result.""" + run = rows.newest_run + return ReportSummaryRead( + **vars(_report_definition(rows, current_versions=current_versions)), + kind="economy", status="not_run" if run is None else run.status.value, run=None if run is None else report_run_read(run), - current_versions=current_versions, ) @@ -100,18 +119,51 @@ def report_read( return ReportRead(**vars(summary), result=result) -def listed_economy_reports( +def household_report_summary_read( + rows: StoredReportRows, *, current_versions: PackageVersions +) -> HouseholdReportSummaryRead: + """Return a household report's definition and newest run, without a result. + + Raise ValueError for a report naming no household, which is not a + household report. + """ + report = rows.report + if report.household_id is None: + msg = f"Report {report.id} is not a household report" + raise ValueError(msg) + run = rows.newest_run + return HouseholdReportSummaryRead( + **vars(_report_definition(rows, current_versions=current_versions)), + kind="household", + household_id=report.household_id, + status="not_run" if run is None else run.status.value, + run=None if run is None else report_run_read(run), + ) + + +def household_report_read( + rows: StoredReportRows, + *, + current_versions: PackageVersions, + result: HouseholdReportResult | None, +) -> HouseholdReportRead: + """Return a household report with its calculated households, if it has them.""" + summary = household_report_summary_read(rows, current_versions=current_versions) + return HouseholdReportRead(**vars(summary), result=result) + + +def listed_reports( rows: Iterable[StoredReportRows], *, report_ids: Sequence[UUID] ) -> list[StoredReportRows]: - """Return the economy reports among ``rows`` in the order of ``report_ids``. + """Return the economy and household reports among ``rows``, as named. - A report named twice is listed once, where it is first named; reports of - other kinds are left out. + They are in the order of ``report_ids``. A report named twice is listed + once, where it is first named; budget windows are left out. """ by_id = { stored.report.id: stored for stored in rows - if report_kind(stored.report) == "economy" + if report_kind(stored.report) in READABLE_KINDS } listed: list[StoredReportRows] = [] for report_id in dict.fromkeys(report_ids): diff --git a/policyengine_api/services/v2/stored_reports/types.py b/policyengine_api/services/v2/stored_reports/types.py index 9245ab1dd..f2b9f9b1d 100644 --- a/policyengine_api/services/v2/stored_reports/types.py +++ b/policyengine_api/services/v2/stored_reports/types.py @@ -3,7 +3,9 @@ A report compares a baseline and a reform simulation; each calculation of it is a report run. The simulation service creates reports and their runs, and its Modal workers store each run's result in the result tables; the Public -API reads all of them directly. +API reads all of them directly. A household report's simulations each +calculate one stored household, and its result is each simulation run's +calculated household. """ from __future__ import annotations @@ -24,6 +26,9 @@ from policyengine_api.services.v2.stored_reports.economy_results.types import ( EconomyResult, ) + from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdReportResult, + ) # A report's status is its newest run's, or not_run while it has none, as a # report migrated from v1 has none. @@ -65,8 +70,8 @@ class ReportSimulationRead: @dataclass(frozen=True) -class ReportSummaryRead: - """A report's definition, its newest run and the versions the Public API runs. +class ReportDefinitionRead: + """What every report's read gives: its definition and the current versions. ``current_versions`` are the country package and PolicyEngine.py versions the Public API serves now; a newest run on other versions is out of date. @@ -75,7 +80,6 @@ class ReportSummaryRead: id: UUID country_id: str - kind: Literal["economy"] label: str description: str | None year: int | None @@ -85,9 +89,16 @@ class ReportSummaryRead: reform_simulation: ReportSimulationRead | None options: dict[str, JsonValue] created_at: datetime + current_versions: PackageVersions + + +@dataclass(frozen=True) +class ReportSummaryRead(ReportDefinitionRead): + """An economy report's definition, its newest run and the current versions.""" + + kind: Literal["economy"] status: ReportStatusName run: ReportRunRead | None - current_versions: PackageVersions @dataclass(frozen=True) @@ -97,6 +108,34 @@ class ReportRead(ReportSummaryRead): result: EconomyResult | None +@dataclass(frozen=True) +class HouseholdReportSummaryRead(ReportDefinitionRead): + """A household report's definition, its newest run and the current versions. + + Both simulations calculate the stored household ``household_id`` in + ``year``; ``reform_simulation`` is None for a report of the baseline + alone. It has no region or dataset. ``run`` is the newest report run, + whatever its status and versions, naming each simulation's run. + """ + + kind: Literal["household"] + household_id: UUID + status: ReportStatusName + run: ReportRunRead | None + + +@dataclass(frozen=True) +class HouseholdReportRead(HouseholdReportSummaryRead): + """A household report with its calculated households, once its run succeeded.""" + + result: HouseholdReportResult | None + + +# A report of any kind these routes read. +StoredReportRead = ReportRead | HouseholdReportRead +StoredReportSummaryRead = ReportSummaryRead | HouseholdReportSummaryRead + + @dataclass(frozen=True) class StoredReportRows: """A report's row, its newest run and the catalog and simulation rows it names. diff --git a/policyengine_api/services/v2/stored_reports/validators.py b/policyengine_api/services/v2/stored_reports/validators.py index d74413c2b..418de6778 100644 --- a/policyengine_api/services/v2/stored_reports/validators.py +++ b/policyengine_api/services/v2/stored_reports/validators.py @@ -7,16 +7,21 @@ from policyengine_api.services.v2.stored_reports.economy_results.types import ( EconomyResultUnreadableError, ) -from policyengine_api.services.v2.stored_reports.transformations import report_kind +from policyengine_api.services.v2.stored_reports.transformations import ( + READABLE_KINDS, + report_kind, +) if TYPE_CHECKING: from uuid import UUID from policyengine_api.data.v2.models import ReportRun - from policyengine_api.services.v2.stored_reports.types import StoredReportRows + from policyengine_api.services.v2.stored_reports.types import ( + ReportKind, + StoredReportRows, + ) -READABLE_KIND = "economy" -KIND_NAMES = {"household": "a household report", "budget_window": "a budget window"} +KIND_NAMES = {"budget_window": "a budget window"} class StoredReportNotFoundError(LookupError): @@ -49,8 +54,8 @@ def __init__(self, *, report_id: UUID, country_id: str) -> None: class ReportKindUnsupportedError(ValueError): """The report is of a kind these routes do not read yet. - Household reports and budget windows are stored in the same tables, and - are read once their own routes exist. + Budget windows are stored in the same tables, and are read once their own + routes exist. """ @@ -64,15 +69,19 @@ def require_report( return rows -def require_economy_report(rows: StoredReportRows) -> None: - """Raise ReportKindUnsupportedError unless the report is an economy report.""" +def require_readable_report(rows: StoredReportRows) -> ReportKind: + """Return the report's kind: an economy report or a household report. + + Raise ReportKindUnsupportedError for a budget window. + """ kind = report_kind(rows.report) - if kind != READABLE_KIND: + if kind not in READABLE_KINDS: msg = ( f"Report {rows.report.id} is {KIND_NAMES[kind]}, which this route " "cannot read yet" ) raise ReportKindUnsupportedError(msg) + return kind def require_reform_run(report_run: ReportRun) -> UUID: diff --git a/tests/integration/test_v2_report_reads.py b/tests/integration/test_v2_report_reads.py index 457dcf37e..551fb3a9d 100644 --- a/tests/integration/test_v2_report_reads.py +++ b/tests/integration/test_v2_report_reads.py @@ -43,7 +43,6 @@ V2StoredSimulationService, ) from tests.fixtures.economy_results import uk_economy_result, us_economy_result -from tests.integration.simulation_rows import insert_row from tests.integration.stored_report_rows import ( REGION_CODES, STARTED_AT, @@ -302,28 +301,16 @@ def test_a_succeeded_run_without_result_rows_is_unreadable( } -def test_a_household_report_cannot_be_read_here_yet( +def test_a_budget_window_cannot_be_read_here_yet( owner_engine: Engine, catalogs: dict[str, CatalogRows], client: TestClient ) -> None: - catalog = catalogs["us"] - household_id = uuid4() with owner_engine.begin() as connection: - insert_row( - connection, - "households", - id=household_id, - country_id="us", - household_data="{}", - canonicalization_version=1, - content_hash=uuid4().hex * 2, - ) - catalog.household_ids.append(household_id) - report = store_report(connection, catalog, household_id=household_id) + report = store_report(connection, catalogs["us"], budget_window_size=3) response = client.get(f"/v2/reports/{report.report_id}?country_id=us") assert response.status_code == 422 - assert "household report" in response.json()["message"] + assert "budget window" in response.json()["message"] def test_listing_reads_named_economy_reports_in_the_order_asked( diff --git a/tests/unit/v2/test_household_report_routes.py b/tests/unit/v2/test_household_report_routes.py index 6531fcff8..66d29a3f1 100644 --- a/tests/unit/v2/test_household_report_routes.py +++ b/tests/unit/v2/test_household_report_routes.py @@ -130,8 +130,10 @@ def test_a_household_report_is_created_and_answered_as_stored() -> None: assert item["options"] == {"add_missing_variables": True} ((name, arguments),) = service.calls assert name == "create_report" - assert arguments["report_input"] == HouseholdReportCreationInput.model_validate( - household_body() + report_input = arguments["report_input"] + assert isinstance(report_input, HouseholdReportCreationInput) + assert report_input.model_dump() == ( + HouseholdReportCreationInput.model_validate(household_body()).model_dump() ) From e800fe13a1bdd9f5a44f4eb5b58b64ec1959c155 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:31:31 +0400 Subject: [PATCH 06/25] Test household reports on migrated PostgreSQL as the runtime role, and that CI runs them (failing) Co-Authored-By: Claude Opus 5.5 --- .../integration/test_v2_household_reports.py | 458 ++++++++++++++++++ tests/unit/test_alembic_workflows.py | 1 + 2 files changed, 459 insertions(+) create mode 100644 tests/integration/test_v2_household_reports.py diff --git a/tests/integration/test_v2_household_reports.py b/tests/integration/test_v2_household_reports.py new file mode 100644 index 000000000..6f765ffb3 --- /dev/null +++ b/tests/integration/test_v2_household_reports.py @@ -0,0 +1,458 @@ +"""Create, read and list household reports through /v2/reports on migrated PostgreSQL. + +The routes run as the Public API's runtime role, so the tests prove its +declared privileges cover every read: the countries row, the stored household +and policies the checks read, and the reports, simulations, runs and +calculated households the reads return (rule +``api.v2.runtime-privileges-tested-as-runtime-role``). The rows the +simulation service and its household functions write are stored by the +disposable database's owner, committed, and deleted after each test. The UK +is made household-only for each test and restored afterwards. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from datetime import timedelta +from typing import TYPE_CHECKING, Any +from uuid import UUID, uuid4 + +import pytest +from alembic import command +from alembic.config import Config +from fastapi.testclient import TestClient +from flask import Flask +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import NullPool +from sqlmodel import Session + +from policyengine_api.asgi_factory import create_asgi_app +from policyengine_api.constants import REPO +from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies +from policyengine_api.migration_flags import ( + RouteImplementation, + RouteImplementationSettings, +) +from policyengine_api.services.v2.countries.database_session import ( + CountryDatabaseSession, +) +from policyengine_api.services.v2.countries.services import V2CountryService +from policyengine_api.services.v2.households.database_session import ( + HouseholdDatabaseSession, +) +from policyengine_api.services.v2.households.services import V2HouseholdService +from policyengine_api.services.v2.policies.database_session import ( + PolicyDatabaseSession, +) +from policyengine_api.services.v2.policies.services import V2PolicyService +from policyengine_api.services.v2.simulation_service.services import ( + SimulationSubmissionService, +) +from policyengine_api.services.v2.simulation_service.types import ( + PackageVersions, + SubmittedReport, +) +from policyengine_api.services.v2.stored_reports.database_session import ( + StoredReportDatabaseSession, +) +from policyengine_api.services.v2.stored_reports.services import V2StoredReportService +from tests.integration.simulation_rows import insert_row +from tests.integration.stored_report_rows import ( + STARTED_AT, + delete_catalog, + store_catalog, + store_report, +) + +if TYPE_CHECKING: + from collections.abc import Iterator + + from sqlalchemy import Connection, Engine + + from policyengine_api.services.v2.simulation_service.types import ( + HouseholdReportCreationInput, + ) + from tests.integration.stored_report_rows import CatalogRows + +CURRENT_VERSIONS = {"country_package_version": "1.5.0", "policyengine_version": "4.2.0"} +STORED_HOUSEHOLD = {"people": {"you": {"age": {"2026": 40}}}} +BASELINE_HOUSEHOLD = { + "people": {"you": {"age": {"2026": 40}, "net_income": {"2026": 30000.0}}} +} +REFORM_HOUSEHOLD = { + "people": {"you": {"age": {"2026": 40}, "net_income": {"2026": 31250.5}}} +} + + +def current_versions(country_id: str) -> PackageVersions: + return PackageVersions(**CURRENT_VERSIONS) + + +def succeeded_document(household: dict[str, Any]) -> dict[str, Any]: + """Return the household function's result document for a calculated household.""" + return { + "status": "succeeded", + "schema_version": 1, + "output": { + "household": household, + "warnings": [], + "spm_config": None, + "spm_provenance": None, + }, + } + + +@dataclass(frozen=True) +class StoredHouseholdReport: + """A stored household report and its newest run's IDs.""" + + report_id: UUID + report_run_id: UUID + + +def store_household(connection: Connection, catalog: CatalogRows) -> UUID: + """Store a household in the catalog's country; ``delete_catalog`` removes it.""" + household_id = uuid4() + insert_row( + connection, + "households", + id=household_id, + country_id=catalog.country, + default_year=2026, + household_data=json.dumps(STORED_HOUSEHOLD), + canonicalization_version=1, + content_hash=uuid4().hex * 2, + ) + catalog.household_ids.append(household_id) + return household_id + + +@dataclass(frozen=True) +class SimulationRunToStore: + """A household simulation's run: its status and calculated household's document.""" + + status: str + document: dict[str, Any] | None + + @property + def finished(self) -> bool: + return self.status in {"succeeded", "failed"} + + +def store_household_simulation( + connection: Connection, + catalog: CatalogRows, + household_id: UUID, + *, + policy_id: UUID | None, + run: SimulationRunToStore, +) -> tuple[UUID, UUID]: + """Store a household simulation and one run; return both IDs.""" + simulation_id = uuid4() + insert_row( + connection, + "simulations", + id=simulation_id, + simulation_type="household", + household_id=household_id, + policy_id=policy_id, + tax_benefit_model_version_id=catalog.version_id, + year=2026, + ) + catalog.simulation_ids.append(simulation_id) + run_id = uuid4() + insert_row( + connection, + "simulation_runs", + id=run_id, + simulation_id=simulation_id, + year=2026, + tax_benefit_model_version_id=catalog.version_id, + status=run.status, + started_at=STARTED_AT, + completed_at=STARTED_AT + timedelta(minutes=1) if run.finished else None, + household_result=None if run.document is None else json.dumps(run.document), + **CURRENT_VERSIONS, + ) + return simulation_id, run_id + + +def store_household_report( + connection: Connection, + catalog: CatalogRows, + household_id: UUID, + *, + status: str = "succeeded", +) -> StoredHouseholdReport: + """Store a household report of current law and the catalog's policy, with a run. + + A succeeded run's simulation runs hold their calculated households. + """ + succeeded = status == "succeeded" + baseline_id, baseline_run_id = store_household_simulation( + connection, + catalog, + household_id, + policy_id=None, + run=SimulationRunToStore( + status, succeeded_document(BASELINE_HOUSEHOLD) if succeeded else None + ), + ) + reform_id, reform_run_id = store_household_simulation( + connection, + catalog, + household_id, + policy_id=catalog.policy_id, + run=SimulationRunToStore( + status, succeeded_document(REFORM_HOUSEHOLD) if succeeded else None + ), + ) + report_id = uuid4() + insert_row( + connection, + "reports", + id=report_id, + label="My household under the reform", + country=catalog.country, + tax_benefit_model_id=catalog.model_id, + policy_id=catalog.policy_id, + baseline_simulation_id=baseline_id, + reform_simulation_id=reform_id, + household_id=household_id, + year=2026, + inputs=json.dumps({"add_missing_variables": True}), + ) + catalog.report_ids.append(report_id) + report_run_id = uuid4() + insert_row( + connection, + "report_runs", + id=report_run_id, + report_id=report_id, + baseline_simulation_run_id=baseline_run_id, + reform_simulation_run_id=reform_run_id, + status=status, + trigger="initial", + started_at=STARTED_AT, + completed_at=( + STARTED_AT + timedelta(minutes=1) + if status in {"succeeded", "failed"} + else None + ), + **CURRENT_VERSIONS, + ) + return StoredHouseholdReport(report_id=report_id, report_run_id=report_run_id) + + +@dataclass +class StoringHouseholdReportService: + """Store what the simulation service would create for a household report.""" + + engine: Engine + catalog: CatalogRows + household_reports: list[HouseholdReportCreationInput] = field(default_factory=list) + + def submit_household_report( + self, report: HouseholdReportCreationInput + ) -> SubmittedReport: + self.household_reports.append(report) + with self.engine.begin() as connection: + stored = store_household_report( + connection, self.catalog, report.household_id, status="pending" + ) + return SubmittedReport( + report_id=stored.report_id, report_run_id=stored.report_run_id + ) + + +@pytest.fixture +def owner_engine(disposable_v2_database_url: str) -> Iterator[Engine]: + command.upgrade(Config(str(REPO / "alembic-v2.ini")), "head") + engine = create_engine(disposable_v2_database_url, poolclass=NullPool) + yield engine + engine.dispose() + + +@pytest.fixture +def household_only_uk(owner_engine: Engine) -> Iterator[CatalogRows]: + """Make the UK household-only, store a UK catalog, and restore both after.""" + with owner_engine.begin() as connection: + connection.execute( + text("UPDATE countries SET society_wide_enabled = false WHERE id = 'uk'") + ) + catalog = store_catalog(connection, "uk") + try: + yield catalog + finally: + # Each in its own transaction, so a failed cleanup still restores the UK. + with owner_engine.begin() as connection: + connection.execute( + text("UPDATE countries SET society_wide_enabled = true WHERE id = 'uk'") + ) + with owner_engine.begin() as connection: + delete_catalog(connection, catalog) + + +@pytest.fixture +def runtime_sessions(managed_v2_roles, v2_engine_as_role) -> Iterator[Any]: + engine = v2_engine_as_role(managed_v2_roles.runtime, poolclass=NullPool) + yield sessionmaker(engine, class_=Session) + engine.dispose() + + +def client_for( + sessions: Any, simulation_service: StoringHouseholdReportService | None = None +) -> TestClient: + """Serve the report routes as the runtime role, creating through the stand-in.""" + submissions = SimulationSubmissionService( + countries=V2CountryService(CountryDatabaseSession(sessions)), + households=V2HouseholdService(HouseholdDatabaseSession(sessions)), + policies=V2PolicyService( + PolicyDatabaseSession(sessions), running_policyengine_version="4.2.0" + ), + client_factory=lambda: simulation_service, + ) + dependencies = NativeRouteDependencies( + readiness_probe=lambda: True, + gateway_client_factory=lambda: None, + metadata_reader_factory=lambda: None, + v2_stored_report_service_factory=lambda: V2StoredReportService( + StoredReportDatabaseSession(sessions), + current_versions=current_versions, + submissions=submissions, + ), + ) + settings = RouteImplementationSettings( + health=RouteImplementation.FLASK_FALLBACK, + specification=RouteImplementation.FLASK_FALLBACK, + metadata=RouteImplementation.FLASK_FALLBACK, + ) + return TestClient( + create_asgi_app( + Flask(__name__), dependencies=dependencies, route_settings=settings + ) + ) + + +def household_body(household_id: UUID, policy_id: UUID) -> dict[str, Any]: + return { + "country_id": "uk", + "kind": "household", + "label": "My household under the reform", + "household_id": str(household_id), + "year": 2026, + "baseline": {}, + "reform": {"policy_id": str(policy_id)}, + "add_missing_variables": True, + } + + +def test_a_household_report_answers_both_calculated_households( + owner_engine: Engine, household_only_uk: CatalogRows, runtime_sessions: Any +) -> None: + catalog = household_only_uk + with owner_engine.begin() as connection: + household_id = store_household(connection, catalog) + stored = store_household_report(connection, catalog, household_id) + + response = client_for(runtime_sessions).get( + f"/v2/reports/{stored.report_id}?country_id=uk" + ) + + assert response.status_code == 200, response.text + item = response.json()["result"]["item"] + assert item["kind"] == "household" + assert item["household_id"] == str(household_id) + assert item["options"] == {"add_missing_variables": True} + assert item["status"] == "succeeded" + assert item["run"]["id"] == str(stored.report_run_id) + assert item["reform_simulation"]["policy_id"] == str(catalog.policy_id) + assert item["result"]["baseline"]["household"] == BASELINE_HOUSEHOLD + assert item["result"]["reform"]["household"] == REFORM_HOUSEHOLD + + +def test_household_reports_are_listed_beside_economy_reports( + owner_engine: Engine, household_only_uk: CatalogRows, runtime_sessions: Any +) -> None: + catalog = household_only_uk + with owner_engine.begin() as connection: + household_id = store_household(connection, catalog) + household_report = store_household_report( + connection, catalog, household_id, status="running" + ) + economy_report = store_report(connection, catalog) + + response = client_for(runtime_sessions).get( + "/v2/reports?country_id=uk" + f"&report_id={economy_report.report_id}" + f"&report_id={household_report.report_id}" + ) + + assert response.status_code == 200, response.text + items = response.json()["result"]["items"] + assert [(item["id"], item["kind"]) for item in items] == [ + (str(economy_report.report_id), "economy"), + (str(household_report.report_id), "household"), + ] + assert items[1]["status"] == "running" + assert all("result" not in item for item in items) + + +def test_a_household_only_country_creates_a_household_report( + owner_engine: Engine, household_only_uk: CatalogRows, runtime_sessions: Any +) -> None: + catalog = household_only_uk + with owner_engine.begin() as connection: + household_id = store_household(connection, catalog) + simulation_service = StoringHouseholdReportService(owner_engine, catalog) + + response = client_for(runtime_sessions, simulation_service).post( + "/v2/reports?country_id=uk", + json=household_body(household_id, catalog.policy_id), + ) + + assert response.status_code == 201, response.text + item = response.json()["result"]["item"] + assert item["id"] == str(catalog.report_ids[0]) + assert item["kind"] == "household" + assert item["status"] == "pending" + assert item["result"] is None + (sent,) = simulation_service.household_reports + assert sent.household_id == household_id + assert sent.add_missing_variables is True + + +def test_a_household_the_country_does_not_store_is_not_sent( + owner_engine: Engine, household_only_uk: CatalogRows, runtime_sessions: Any +) -> None: + simulation_service = StoringHouseholdReportService(owner_engine, household_only_uk) + + response = client_for(runtime_sessions, simulation_service).post( + "/v2/reports?country_id=uk", + json=household_body(uuid4(), household_only_uk.policy_id), + ) + + assert response.status_code == 404 + assert simulation_service.household_reports == [] + + +def test_a_household_only_country_still_refuses_economy_reports( + household_only_uk: CatalogRows, runtime_sessions: Any +) -> None: + response = client_for(runtime_sessions).post( + "/v2/reports?country_id=uk", + json={ + "country_id": "uk", + "kind": "economy", + "label": "Economy", + "year": 2026, + "region_code": "country/wales", + "baseline": {}, + "reform": {}, + }, + ) + + assert response.status_code == 422 + assert "Economy calculations are not available" in response.json()["message"] diff --git a/tests/unit/test_alembic_workflows.py b/tests/unit/test_alembic_workflows.py index feeea74e4..25ec4a3e6 100644 --- a/tests/unit/test_alembic_workflows.py +++ b/tests/unit/test_alembic_workflows.py @@ -260,6 +260,7 @@ def test_reusable_v2_integration_check_uses_disposable_databases_and_coverage(): assert "test_v1_household_dual_write.py" in workflow assert "test_mysql_household_spm_dual_write.py" in workflow assert "test_v2_household_calculation_reads.py" in workflow + assert "test_v2_household_reports.py" in workflow assert "test_mysql_update_row_counts.py" in workflow assert "test_runtime_cache_redis.py" in workflow # The steps run coverage and pytest, which only the dev extra declares; From 4f948b53aeb58a0b66efb3ca4e61234292ada915 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:32:15 +0400 Subject: [PATCH 07/25] Run the household report integration tests in the cross-database check Co-Authored-By: Claude Opus 5.5 --- .github/workflows/v2-integration-check.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/v2-integration-check.yml b/.github/workflows/v2-integration-check.yml index 58b557758..949dcbadf 100644 --- a/.github/workflows/v2-integration-check.yml +++ b/.github/workflows/v2-integration-check.yml @@ -81,8 +81,8 @@ jobs: run: uv run --frozen --extra dev coverage run -a --branch -m pytest -q tests/integration/test_v2_user_identity_persistence.py tests/integration/test_v2_user_type_persistence.py tests/integration/test_v2_saved_detailed_save_records.py - name: Test comparison-run persistence, the simulation API's roles and privilege reconciliation as the managed roles run: uv run --frozen --extra dev coverage run -a --branch -m pytest -q tests/integration/test_v2_stage12_comparison_persistence.py tests/integration/test_v2_simulation_role_privileges.py tests/integration/test_v2_privilege_reconciliation.py - - name: Test v2 household persistence, immediate v1 copying and household calculation reads - run: uv run --frozen --extra dev coverage run -a --branch -m pytest -q tests/integration/test_v2_household_persistence.py tests/integration/test_v1_household_dual_write.py tests/integration/test_mysql_household_spm_dual_write.py tests/integration/test_v2_household_calculation_reads.py + - name: Test v2 household persistence, immediate v1 copying, household calculation reads and household reports + run: uv run --frozen --extra dev coverage run -a --branch -m pytest -q tests/integration/test_v2_household_persistence.py tests/integration/test_v1_household_dual_write.py tests/integration/test_mysql_household_spm_dual_write.py tests/integration/test_v2_household_calculation_reads.py tests/integration/test_v2_household_reports.py - name: Test the row counts v1 ORM updates report on MySQL run: uv run --frozen --extra dev coverage run -a --branch -m pytest -q tests/integration/test_mysql_update_row_counts.py # The real US and UK models, reduced to a few parameter folders: the From 8f3afaedfbab8a275d73e254ec3d95aa08402de5 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:33:59 +0400 Subject: [PATCH 08/25] Describe household reports in the changelog and the migration contracts Co-Authored-By: Claude Opus 5.5 --- changelog.d/v2-household-reports.added.md | 1 + docs/engineering/migration-contracts.md | 4 ++-- docs/generated/migration_contracts.json | 3 ++- tests/contract/registry.py | 5 ++++- 4 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 changelog.d/v2-household-reports.added.md diff --git a/changelog.d/v2-household-reports.added.md b/changelog.d/v2-household-reports.added.md new file mode 100644 index 000000000..dcd5cf767 --- /dev/null +++ b/changelog.d/v2-household-reports.added.md @@ -0,0 +1 @@ +`/v2/reports` serves household reports: `POST /v2/reports` with `kind` `household` takes a stored household, the year, a baseline policy and an optional reform policy (null for a report of the baseline alone), and `add_missing_variables`, and has the simulation service create the report and its two household simulations. It answers 422 for a country without household calculations (a household-only country has them) and 404 for a household the country does not store, before asking the simulation service. Reading or listing a household report answers it with its newest run and, once that run has succeeded, each simulation's calculated household. diff --git a/docs/engineering/migration-contracts.md b/docs/engineering/migration-contracts.md index d9f04b4bd..7e56dd2db 100644 --- a/docs/engineering/migration-contracts.md +++ b/docs/engineering/migration-contracts.md @@ -200,13 +200,13 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi ### `detailed_save_reports_v2` - Current contract: `typed_v2_resources` -- Future owner: C1 · 8: v2 simulations and economy reports +- Future owner: C1 · 8: v2 simulations and economy reports; C1 · 16: household reports | Method | Path | Status | Route group | Required headers | Stable response fields | | --- | --- | ---: | --- | --- | --- | | `POST` | `/v2/reports?country_id=us` | 201 | `report` | none | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.kind`, `result.item.status`, `result.item.run`, `result.item.current_versions`, `result.item.result` | | `GET` | `/v2/reports?country_id=us&report_id={report_id}` | 200 | `report` | none | `status`, `message`, `result.items` | -| `GET` | `/v2/reports/{report_id}?country_id=us` | 200 | `report` | none | `status`, `message`, `result.item.id`, `result.item.status`, `result.item.run`, `result.item.current_versions`, `result.item.result` | +| `GET` | `/v2/reports/{report_id}?country_id=us` | 200 | `report` | none | `status`, `message`, `result.item.id`, `result.item.kind`, `result.item.status`, `result.item.run`, `result.item.current_versions`, `result.item.result` | | `POST` | `/v2/simulations?country_id=us` | 201 | `simulation` | none | `status`, `message`, `result.item.id`, `result.item.kind`, `result.item.status`, `result.item.run` | | `GET` | `/v2/simulations/{simulation_id}?country_id=us` | 200 | `simulation` | none | `status`, `message`, `result.item.id`, `result.item.status`, `result.item.run`, `result.item.current_versions` | diff --git a/docs/generated/migration_contracts.json b/docs/generated/migration_contracts.json index 6eca98bb9..38149a341 100644 --- a/docs/generated/migration_contracts.json +++ b/docs/generated/migration_contracts.json @@ -1374,7 +1374,7 @@ }, { "current_contract": "typed_v2_resources", - "future_owner_pr": "C1 \u00b7 8: v2 simulations and economy reports", + "future_owner_pr": "C1 \u00b7 8: v2 simulations and economy reports; C1 \u00b7 16: household reports", "name": "detailed_save_reports_v2", "requests": [ { @@ -1417,6 +1417,7 @@ "status", "message", "result.item.id", + "result.item.kind", "result.item.status", "result.item.run", "result.item.current_versions", diff --git a/tests/contract/registry.py b/tests/contract/registry.py index a35ea84b5..583b7988c 100644 --- a/tests/contract/registry.py +++ b/tests/contract/registry.py @@ -617,7 +617,9 @@ class WorkflowContract: WorkflowContract( name="detailed_save_reports_v2", current_contract="typed_v2_resources", - future_owner_pr="C1 · 8: v2 simulations and economy reports", + future_owner_pr=( + "C1 · 8: v2 simulations and economy reports; C1 · 16: household reports" + ), requests=( ContractRequest( method="POST", @@ -651,6 +653,7 @@ class WorkflowContract: "status", "message", "result.item.id", + "result.item.kind", "result.item.status", "result.item.run", "result.item.current_versions", From 6afd8f26f0d2fc6f3cd5589c8cdbfa35a83bb757 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:40:35 +0400 Subject: [PATCH 09/25] Test that a household report is for its household's default year (failing) Co-Authored-By: Claude Opus 5.5 --- .../integration/test_v2_household_reports.py | 26 +++++++++++-- ...st_simulation_household_report_requests.py | 11 ++---- tests/unit/v2/test_household_report_routes.py | 16 ++++++-- .../v2/test_household_report_submissions.py | 39 ++++++++++++++----- 4 files changed, 69 insertions(+), 23 deletions(-) diff --git a/tests/integration/test_v2_household_reports.py b/tests/integration/test_v2_household_reports.py index 6f765ffb3..36367496c 100644 --- a/tests/integration/test_v2_household_reports.py +++ b/tests/integration/test_v2_household_reports.py @@ -112,7 +112,9 @@ class StoredHouseholdReport: report_run_id: UUID -def store_household(connection: Connection, catalog: CatalogRows) -> UUID: +def store_household( + connection: Connection, catalog: CatalogRows, *, default_year: int | None = 2026 +) -> UUID: """Store a household in the catalog's country; ``delete_catalog`` removes it.""" household_id = uuid4() insert_row( @@ -120,7 +122,7 @@ def store_household(connection: Connection, catalog: CatalogRows) -> UUID: "households", id=household_id, country_id=catalog.country, - default_year=2026, + default_year=default_year, household_data=json.dumps(STORED_HOUSEHOLD), canonicalization_version=1, content_hash=uuid4().hex * 2, @@ -342,7 +344,6 @@ def household_body(household_id: UUID, policy_id: UUID) -> dict[str, Any]: "kind": "household", "label": "My household under the reform", "household_id": str(household_id), - "year": 2026, "baseline": {}, "reform": {"policy_id": str(policy_id)}, "add_missing_variables": True, @@ -438,6 +439,25 @@ def test_a_household_the_country_does_not_store_is_not_sent( assert simulation_service.household_reports == [] +def test_a_household_with_no_default_year_is_not_sent( + owner_engine: Engine, household_only_uk: CatalogRows, runtime_sessions: Any +) -> None: + with owner_engine.begin() as connection: + household_id = store_household(connection, household_only_uk, default_year=None) + simulation_service = StoringHouseholdReportService(owner_engine, household_only_uk) + + response = client_for(runtime_sessions, simulation_service).post( + "/v2/reports?country_id=uk", + json=household_body(household_id, household_only_uk.policy_id), + ) + + assert response.status_code == 422 + assert response.json()["message"] == ( + "This household has no default year to calculate." + ) + assert simulation_service.household_reports == [] + + def test_a_household_only_country_still_refuses_economy_reports( household_only_uk: CatalogRows, runtime_sessions: Any ) -> None: diff --git a/tests/unit/libs/test_simulation_household_report_requests.py b/tests/unit/libs/test_simulation_household_report_requests.py index c04ae96ff..f23fd2a28 100644 --- a/tests/unit/libs/test_simulation_household_report_requests.py +++ b/tests/unit/libs/test_simulation_household_report_requests.py @@ -50,7 +50,6 @@ def household_report_input(**changes: Any) -> HouseholdReportCreationInput: "kind": "household", "label": "My household under the reform", "household_id": str(HOUSEHOLD_ID), - "year": 2027, "baseline": {}, "reform": {"policy_id": str(POLICY_ID)}, } @@ -76,7 +75,6 @@ def test_a_household_report_is_posted_and_the_created_report_returned() -> None: "label": "My household under the reform", "description": "Doubles the credit", "household_id": str(HOUSEHOLD_ID), - "year": 2027, "baseline": {"policy_id": None}, "reform": {"policy_id": str(POLICY_ID)}, "add_missing_variables": False, @@ -96,7 +94,6 @@ def test_a_report_of_the_baseline_alone_sends_a_null_reform() -> None: "label": "My household under the reform", "description": None, "household_id": str(HOUSEHOLD_ID), - "year": 2027, "baseline": {"policy_id": str(POLICY_ID)}, "reform": None, "add_missing_variables": True, @@ -107,8 +104,8 @@ def test_a_household_report_the_service_refuses_is_reported_with_its_reason() -> service = RecordingService( status_code=422, body={ - "code": "invalid_household", - "detail": "The household's people have no ages", + "code": "household_not_calculable", + "detail": "The household has no default year", }, ) @@ -116,8 +113,8 @@ def test_a_household_report_the_service_refuses_is_reported_with_its_reason() -> service.client().submit_household_report(household_report_input()) assert str(refused.value) == ( - "The simulation service cannot run this request (invalid_household): " - "The household's people have no ages" + "The simulation service cannot run this request (household_not_calculable): " + "The household has no default year" ) diff --git a/tests/unit/v2/test_household_report_routes.py b/tests/unit/v2/test_household_report_routes.py index 66d29a3f1..25f9a8d14 100644 --- a/tests/unit/v2/test_household_report_routes.py +++ b/tests/unit/v2/test_household_report_routes.py @@ -11,6 +11,9 @@ from policyengine_api.services.v2.household_calculations.types import ( HouseholdCalculationOutput, ) +from policyengine_api.services.v2.household_calculations.validators import ( + HouseholdCalculationYearError, +) from policyengine_api.services.v2.households.validators import ( HouseholdNotFoundError, ) @@ -102,7 +105,6 @@ def household_body(**changes: Any) -> dict[str, Any]: "kind": "household", "label": "My household under the reform", "household_id": str(HOUSEHOLD_ID), - "year": 2026, "baseline": {}, "reform": {"policy_id": str(POLICY_ID)}, "add_missing_variables": True, @@ -156,7 +158,8 @@ def test_a_household_report_of_the_baseline_alone_can_be_created() -> None: "changes", [ pytest.param({"label": None}, id="no-label"), - pytest.param({"year": None}, id="no-year"), + # The report is for the household's default year, so it names none. + pytest.param({"year": 2026}, id="year"), pytest.param({"household_id": None}, id="no-household"), pytest.param({"region_code": "us"}, id="economy-field"), pytest.param({"include_cliffs": True}, id="economy-option"), @@ -206,6 +209,13 @@ def test_a_household_report_naming_another_country_is_refused_with_400() -> None 404, id="household-not-stored", ), + pytest.param( + HouseholdCalculationYearError( + "This household has no default year to calculate." + ), + 422, + id="household-without-default-year", + ), ], ) def test_a_household_report_the_checks_refuse_is_answered_with_its_reason( @@ -311,7 +321,7 @@ def test_the_create_routes_schema_offers_the_household_kind() -> None: "kind", "label", "household_id", - "year", "baseline", } + assert "year" not in household_request["properties"] assert "HouseholdReportItem" in components diff --git a/tests/unit/v2/test_household_report_submissions.py b/tests/unit/v2/test_household_report_submissions.py index 95a42273e..4161ab16a 100644 --- a/tests/unit/v2/test_household_report_submissions.py +++ b/tests/unit/v2/test_household_report_submissions.py @@ -15,6 +15,9 @@ MetadataCatalogUnavailableError, ) from policyengine_api.services.v2.countries.types import CountryRead +from policyengine_api.services.v2.household_calculations.validators import ( + HouseholdCalculationYearError, +) from policyengine_api.services.v2.households.types import HouseholdRead from policyengine_api.services.v2.households.validators import ( HouseholdNotFoundError, @@ -48,6 +51,8 @@ HOUSEHOLD_ID = UUID("00000000-0000-0000-0000-00000000d001") OTHER_HOUSEHOLD_ID = UUID("00000000-0000-0000-0000-00000000d002") +# A stored household with no default year, which no report can calculate. +UNDATED_HOUSEHOLD_ID = UUID("00000000-0000-0000-0000-00000000d003") STORED_AT = datetime(2026, 11, 13, tzinfo=UTC) # A country whose countries row offers economy reports but no household # calculations. @@ -59,7 +64,10 @@ @dataclass class StoredHouseholds: - """HOUSEHOLD_ID, stored in every country; any other household is not stored. + """HOUSEHOLD_ID and UNDATED_HOUSEHOLD_ID, stored in every country. + + Any other household is not stored. UNDATED_HOUSEHOLD_ID has no default + year. With ``error``, every read raises it, as a database that cannot be read does. @@ -72,13 +80,13 @@ def get_household(self, *, country_id: str, household_id: UUID) -> HouseholdRead self.asked.append((country_id, household_id)) if self.error is not None: raise self.error - if household_id != HOUSEHOLD_ID: + if household_id not in {HOUSEHOLD_ID, UNDATED_HOUSEHOLD_ID}: msg = f"Household {household_id} was not found" raise HouseholdNotFoundError(msg) return HouseholdRead( id=household_id, country_id=country_id, - default_year=2026, + default_year=None if household_id == UNDATED_HOUSEHOLD_ID else 2026, household_data={"people": {"you": {"age": {"2026": 40}}}}, created_at=STORED_AT, updated_at=STORED_AT, @@ -109,7 +117,6 @@ def household_report_input(**changes: Any) -> HouseholdReportCreationInput: "kind": "household", "label": "My household under the reform", "household_id": str(HOUSEHOLD_ID), - "year": 2026, "baseline": {}, "reform": {"policy_id": str(STORED_POLICY_ID)}, } @@ -198,6 +205,20 @@ def test_a_household_not_stored_in_the_country_is_refused_before_the_policies() assert policies.asked == [] +def test_a_household_with_no_default_year_is_refused_before_the_policies() -> None: + policies = StoredPolicies() + service = household_submissions(unconfigured_client, policies=policies) + + with pytest.raises(HouseholdCalculationYearError): + service.submit_household_report( + household_report_input( + household_id=str(UNDATED_HOUSEHOLD_ID), + reform={"policy_id": str(OTHER_POLICY_ID)}, + ) + ) + assert policies.asked == [] + + @pytest.mark.parametrize("side", ["baseline", "reform"]) def test_a_household_report_naming_a_policy_not_stored_in_the_country_is_refused( side: str, @@ -290,7 +311,6 @@ def test_a_household_report_without_a_reform_is_of_the_baseline_alone() -> None: "kind": "household", "label": "My household", "household_id": str(HOUSEHOLD_ID), - "year": 2026, "baseline": {}, } ) @@ -304,8 +324,8 @@ def test_a_household_report_without_a_reform_is_of_the_baseline_alone() -> None: "changes", [ pytest.param({"label": ""}, id="empty-label"), - pytest.param({"year": 1899}, id="year-before-1900"), - pytest.param({"year": 2201}, id="year-after-2200"), + # The report is for the household's default year, so it names none. + pytest.param({"year": 2026}, id="year"), pytest.param({"add_missing_variables": "true"}, id="option-not-a-boolean"), pytest.param({"baseline": {"dynamic_id": str(uuid4())}}, id="dynamic"), pytest.param({"region_code": "us"}, id="region"), @@ -320,8 +340,8 @@ def test_a_household_report_outside_its_schema_is_refused( household_report_input(**changes) -@pytest.mark.parametrize("missing", ["label", "household_id", "year", "baseline"]) -def test_a_household_report_needs_its_label_household_year_and_baseline( +@pytest.mark.parametrize("missing", ["label", "household_id", "baseline"]) +def test_a_household_report_needs_its_label_household_and_baseline( missing: str, ) -> None: values: dict[str, Any] = { @@ -329,7 +349,6 @@ def test_a_household_report_needs_its_label_household_year_and_baseline( "kind": "household", "label": "My household", "household_id": str(HOUSEHOLD_ID), - "year": 2026, "baseline": {}, } del values[missing] From 68456e94c97ca0e25b8517c8406f6246165d574f Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:41:25 +0400 Subject: [PATCH 10/25] Calculate a household report for its household's default year The request names no year, as the simulation service's household reports and household calculations take none; a household with no default year is refused with 422 before the policies are read. Co-Authored-By: Claude Opus 5.5 --- changelog.d/v2-household-reports.added.md | 2 +- .../fastapi_routes/v2/reports/request_models.py | 8 ++++---- .../fastapi_routes/v2/reports/response_models.py | 3 ++- .../fastapi_routes/v2/reports/routes.py | 5 +++-- .../v2/simulation_resources_support.py | 5 +++++ policyengine_api/libs/simulation_reports.py | 6 +++--- .../services/v2/simulation_service/services.py | 13 ++++++++++--- .../services/v2/simulation_service/types.py | 7 ++++--- 8 files changed, 32 insertions(+), 17 deletions(-) diff --git a/changelog.d/v2-household-reports.added.md b/changelog.d/v2-household-reports.added.md index dcd5cf767..842e5d678 100644 --- a/changelog.d/v2-household-reports.added.md +++ b/changelog.d/v2-household-reports.added.md @@ -1 +1 @@ -`/v2/reports` serves household reports: `POST /v2/reports` with `kind` `household` takes a stored household, the year, a baseline policy and an optional reform policy (null for a report of the baseline alone), and `add_missing_variables`, and has the simulation service create the report and its two household simulations. It answers 422 for a country without household calculations (a household-only country has them) and 404 for a household the country does not store, before asking the simulation service. Reading or listing a household report answers it with its newest run and, once that run has succeeded, each simulation's calculated household. +`/v2/reports` serves household reports: `POST /v2/reports` with `kind` `household` takes a stored household, a baseline policy and an optional reform policy (null for a report of the baseline alone), and `add_missing_variables`, and has the simulation service create the report and its two household simulations, which calculate the household's default year. It answers 422 for a country without household calculations (a household-only country has them) and for a household with no default year, and 404 for a household the country does not store, before asking the simulation service. Reading or listing a household report answers it with its newest run and, once that run has succeeded, each simulation's calculated household. diff --git a/policyengine_api/fastapi_routes/v2/reports/request_models.py b/policyengine_api/fastapi_routes/v2/reports/request_models.py index f114ccbf6..c47730e6c 100644 --- a/policyengine_api/fastapi_routes/v2/reports/request_models.py +++ b/policyengine_api/fastapi_routes/v2/reports/request_models.py @@ -22,10 +22,10 @@ class EconomyReportCreateRequest(EconomyReportCreationInput): class HouseholdReportCreateRequest(HouseholdReportCreationInput): """A household report to create; ``country_id`` must match the query's. - It calculates the stored household ``household_id`` in ``year`` under - ``baseline`` and ``reform``; each empty object is current law, and a null - or omitted ``reform`` makes a report of the baseline alone. No field names - a PolicyEngine.py version. + It calculates the stored household ``household_id`` in its default year + under ``baseline`` and ``reform``; each empty object is current law, and a + null or omitted ``reform`` makes a report of the baseline alone. No field + names a year or a PolicyEngine.py version. """ diff --git a/policyengine_api/fastapi_routes/v2/reports/response_models.py b/policyengine_api/fastapi_routes/v2/reports/response_models.py index 33ca01daa..68733123c 100644 --- a/policyengine_api/fastapi_routes/v2/reports/response_models.py +++ b/policyengine_api/fastapi_routes/v2/reports/response_models.py @@ -285,7 +285,8 @@ class UnavailableAfterReportCreatedResponse(V2ErrorResponse): "description": ( "The request does not match the schema; the country has no economy " "reports (such as a household-only country), for an economy report, " - "or no household calculations, for a household report; a policy the " + "or no household calculations, for a household report; a household " + "report's household has no default year; a policy the " "report " "names sets parameters the running catalog version does not have " "(code missing_parameters, which names them; the baseline's policy is " diff --git a/policyengine_api/fastapi_routes/v2/reports/routes.py b/policyengine_api/fastapi_routes/v2/reports/routes.py index 554d6b717..b2e352c16 100644 --- a/policyengine_api/fastapi_routes/v2/reports/routes.py +++ b/policyengine_api/fastapi_routes/v2/reports/routes.py @@ -196,8 +196,9 @@ def build_v2_report_router(dependencies: NativeRouteDependencies) -> APIRouter: "version, and start the run. A household report (kind household) " "is checked the same way, except that the country must have " "household calculations instead (a household-only country does), " - "and the household it names must be stored in the country; its " - "simulations each calculate that household in the report's year. " + "and the household it names must be stored in the country, with a " + "default year; its simulations each calculate that household in its " + "default year. " "Answers the report as stored, with its " "pending run; read it again to follow the run. A created report " "that cannot be read back answers 502 with its report_id, and one " diff --git a/policyengine_api/fastapi_routes/v2/simulation_resources_support.py b/policyengine_api/fastapi_routes/v2/simulation_resources_support.py index 9d3940c43..073ecfca3 100644 --- a/policyengine_api/fastapi_routes/v2/simulation_resources_support.py +++ b/policyengine_api/fastapi_routes/v2/simulation_resources_support.py @@ -22,6 +22,9 @@ run_v2_operation, v2_missing_parameters_response, ) +from policyengine_api.services.v2.household_calculations.validators import ( + HouseholdCalculationYearError, +) from policyengine_api.services.v2.households.validators import HouseholdNotFoundError from policyengine_api.services.v2.policies.validators import ( PolicyNotFoundError, @@ -52,6 +55,7 @@ HouseholdNotFoundError, EconomyCalculationsUnsupportedError, HouseholdCalculationsUnsupportedError, + HouseholdCalculationYearError, SimulationServiceInvalidRequestError, ) # How the simulation service's answers and the submission checks are answered. @@ -70,6 +74,7 @@ ( EconomyCalculationsUnsupportedError, HouseholdCalculationsUnsupportedError, + HouseholdCalculationYearError, SimulationServiceInvalidRequestError, ), 422, diff --git a/policyengine_api/libs/simulation_reports.py b/policyengine_api/libs/simulation_reports.py index 2257be402..df64e314c 100644 --- a/policyengine_api/libs/simulation_reports.py +++ b/policyengine_api/libs/simulation_reports.py @@ -17,8 +17,9 @@ policy and dynamic, ``include_cliffs``, and the SPM settings only when the caller gave them. A household report goes to the same ``POST /reports`` as ``report_type`` ``household``, naming the label and description, the stored -household, the year, each simulation's policy (the reform null for a report -of the baseline alone) and ``add_missing_variables``. None names a +household, each simulation's policy (the reform null for a report of the +baseline alone) and ``add_missing_variables``, but no year: the simulation +service calculates the household's default year. None names a PolicyEngine.py version: the simulation service runs its default worker's. The simulation service answers: @@ -262,7 +263,6 @@ def household_report_request(report: HouseholdReportCreationInput) -> dict[str, "label": report.label, "description": report.description, "household_id": str(report.household_id), - "year": report.year, "baseline": _household_policy_choice(report.baseline), "reform": ( None if report.reform is None else _household_policy_choice(report.reform) diff --git a/policyengine_api/services/v2/simulation_service/services.py b/policyengine_api/services/v2/simulation_service/services.py index 5522b63a1..9870023d2 100644 --- a/policyengine_api/services/v2/simulation_service/services.py +++ b/policyengine_api/services/v2/simulation_service/services.py @@ -15,7 +15,8 @@ A household report is checked the same way, except that its country's row must allow household calculations instead (a household-only country passes), and the household it names must be stored in the country, through the same -service as ``/v2/households``; the household is read before the policies. +service as ``/v2/households``, with a default year, which the report +calculates; the household is checked before the policies. """ from __future__ import annotations @@ -28,6 +29,9 @@ from policyengine_api.data.v2.catalog.catalog_selection_validation import ( MetadataCatalogUnavailableError, ) +from policyengine_api.services.v2.household_calculations.validators import ( + require_default_year, +) from policyengine_api.services.v2.policies.validators import ( require_policy_parameters_present, ) @@ -157,7 +161,9 @@ def submit_household_report( Raise HouseholdCalculationsUnsupportedError unless the country's row allows household calculations, then HouseholdNotFoundError for a - household the country does not store, then the policy checks' errors + household the country does not store, then + HouseholdCalculationYearError for one with no default year, then the + policy checks' errors (the baseline's policy first, and only the baseline's for a report of the baseline alone), SubmissionStorageError when the database or its catalog cannot be read, and then the simulation service's errors. @@ -165,9 +171,10 @@ def submit_household_report( country_id = report.country_id with _storage_failures(): require_household_country(self._countries.list_countries(), country_id) - self._households.get_household( + household = self._households.get_household( country_id=country_id, household_id=report.household_id ) + require_default_year(household.default_year) policy_ids = [report.baseline.policy_id] if report.reform is not None: policy_ids.append(report.reform.policy_id) diff --git a/policyengine_api/services/v2/simulation_service/types.py b/policyengine_api/services/v2/simulation_service/types.py index ff02cf109..a4bb132af 100644 --- a/policyengine_api/services/v2/simulation_service/types.py +++ b/policyengine_api/services/v2/simulation_service/types.py @@ -214,7 +214,8 @@ class HouseholdReportFields(StrictSubmissionModel): """A household report: one stored household under a baseline and a reform. ``household_id`` names a household stored through ``/v2/households`` in - the report's country, and both simulations calculate it in ``year``. + the report's country, and both simulations calculate it in its default + year, the year its values name, as a household calculation does. ``reform`` is null, or omitted, for a report of the baseline alone. With ``add_missing_variables``, each simulation also calculates every yearly, monthly and eternal variable the household lacks, as ``/calculate-full`` @@ -225,7 +226,6 @@ class HouseholdReportFields(StrictSubmissionModel): label: ReportLabel description: ReportDescription | None = None household_id: ResourceId - year: SimulationYear baseline: HouseholdPolicyChoice reform: HouseholdPolicyChoice | None = None add_missing_variables: StrictBool = False @@ -283,7 +283,8 @@ class SimulationServiceClient(Protocol): ``SimulationSubmissionService``, which checks a submission before sending it through a client, also raises EconomyCalculationsUnsupportedError, HouseholdCalculationsUnsupportedError, HouseholdNotFoundError, - PolicyNotFoundError, PolicyParametersMissingError and + HouseholdCalculationYearError, PolicyNotFoundError, + PolicyParametersMissingError and SubmissionStorageError from its checks. """ From c2481eb87a233b766f8aa209c7071991e917a328 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:42:31 +0400 Subject: [PATCH 11/25] Test that a failed household report answers each side's failure (failing) Co-Authored-By: Claude Opus 5.5 --- tests/unit/v2/test_household_report_reads.py | 96 ++++++++++++++++++- tests/unit/v2/test_household_report_routes.py | 42 ++++++++ 2 files changed, 134 insertions(+), 4 deletions(-) diff --git a/tests/unit/v2/test_household_report_reads.py b/tests/unit/v2/test_household_report_reads.py index 575352a98..6efc8d3f6 100644 --- a/tests/unit/v2/test_household_report_reads.py +++ b/tests/unit/v2/test_household_report_reads.py @@ -14,10 +14,6 @@ from uuid import UUID, uuid4 import pytest -from policyengine_api.services.v2.stored_reports.household_results.types import ( - HouseholdReportResult, - HouseholdResultUnreadableError, -) from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from sqlmodel import Session, create_engine @@ -38,12 +34,18 @@ TaxBenefitModelVersion, ) from policyengine_api.services.v2.household_calculations.types import ( + HouseholdCalculationFailure, HouseholdCalculationOutput, ) from policyengine_api.services.v2.simulation_service.types import PackageVersions from policyengine_api.services.v2.stored_reports.database_session import ( StoredReportDatabaseSession, ) +from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdReportFailure, + HouseholdReportResult, + HouseholdResultUnreadableError, +) from policyengine_api.services.v2.stored_reports.services import V2StoredReportService from policyengine_api.services.v2.stored_reports.types import ( HouseholdReportRead, @@ -72,6 +74,7 @@ "people": {"you": {"age": {"2026": 40}, "net_income": {"2026": 31_250.5}}} } SPM_PROVENANCE = {"scenario": "baseline", "geography": "national"} +INPUT_ERRORS = [{"name": "employment_incme", "message": "is not a variable"}] def succeeded_document( @@ -91,6 +94,21 @@ def succeeded_document( } +def failed_document(**error_changes: Any) -> dict[str, Any]: + """Return the household function's failure document for a refused calculation.""" + return { + "status": "failed", + "schema_version": 1, + "error": { + "code": "invalid_inputs", + "message": "The household names inputs the model does not have.", + "input_errors": INPUT_ERRORS, + "spm_error": None, + **error_changes, + }, + } + + def current_versions(country_id: str) -> PackageVersions: return CURRENT_VERSIONS @@ -312,6 +330,7 @@ def test_a_succeeded_household_report_reads_both_calculated_households( assert report.run.reform_simulation_run_id == stored.reform_run_id assert report.run.country_package_version == "1.4.0" assert report.current_versions == CURRENT_VERSIONS + assert report.failure is None assert report.result == HouseholdReportResult( baseline=HouseholdCalculationOutput( household=BASELINE_HOUSEHOLD, @@ -371,6 +390,74 @@ def test_a_household_report_whose_newest_run_has_not_succeeded_has_no_result( assert report.run is not None assert report.run.error_message == error_message assert report.result is None + # Neither simulation run holds a failure document here. + assert report.failure == ( + HouseholdReportFailure(baseline=None, reform=None) + if status is ReportRunStatus.FAILED + else None + ) + + +def test_a_failed_household_report_answers_each_sides_failure( + sessions: sessionmaker[Session], reports: V2StoredReportService +) -> None: + # Both sides are always calculated: the baseline succeeded, the reform's + # calculation was refused, so the report run failed. + with sessions() as session: + stored = store_household_report( + session, + run=RunToStore( + status=ReportRunStatus.FAILED, + error_message="household_calculation_failed: invalid_inputs", + baseline_result=succeeded_document(BASELINE_HOUSEHOLD), + reform_result=failed_document(), + ), + ) + + report = reports.get_report(country_id="us", report_id=stored.report_id) + + assert isinstance(report, HouseholdReportRead) + assert report.result is None + assert report.failure == HouseholdReportFailure( + baseline=None, + reform=HouseholdCalculationFailure( + code="invalid_inputs", + message="The household names inputs the model does not have.", + input_errors=tuple(INPUT_ERRORS), + spm_error=None, + ), + ) + + +@pytest.mark.parametrize( + "document", + [ + pytest.param({"status": "failed", "schema_version": 1}, id="no-error"), + pytest.param({**failed_document(), "schema_version": 2}, id="version-2"), + pytest.param({"unexpected": True}, id="another-document"), + ], +) +def test_a_failure_document_that_cannot_be_read_names_no_failure( + sessions: sessionmaker[Session], + reports: V2StoredReportService, + document: dict[str, Any], +) -> None: + # The run's error message still says why it failed. + with sessions() as session: + stored = store_household_report( + session, + with_reform=False, + run=RunToStore( + status=ReportRunStatus.FAILED, + error_message="household_calculation_failed", + baseline_result=document, + ), + ) + + report = reports.get_report(country_id="us", report_id=stored.report_id) + + assert isinstance(report, HouseholdReportRead) + assert report.failure == HouseholdReportFailure(baseline=None, reform=None) def test_a_household_report_with_no_run_answers_its_definition( @@ -383,6 +470,7 @@ def test_a_household_report_with_no_run_answers_its_definition( assert isinstance(report, HouseholdReportRead) assert (report.status, report.run, report.result) == ("not_run", None, None) + assert report.failure is None @pytest.mark.parametrize( diff --git a/tests/unit/v2/test_household_report_routes.py b/tests/unit/v2/test_household_report_routes.py index 25f9a8d14..8bd5fa4d2 100644 --- a/tests/unit/v2/test_household_report_routes.py +++ b/tests/unit/v2/test_household_report_routes.py @@ -9,6 +9,7 @@ import pytest from policyengine_api.services.v2.household_calculations.types import ( + HouseholdCalculationFailure, HouseholdCalculationOutput, ) from policyengine_api.services.v2.household_calculations.validators import ( @@ -24,6 +25,7 @@ HouseholdCalculationsUnsupportedError, ) from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdReportFailure, HouseholdReportResult, HouseholdResultUnreadableError, ) @@ -93,6 +95,7 @@ def household_report_read(**changes: Any) -> HouseholdReportRead: spm_provenance={"scenario": "baseline"}, ), ), + "failure": None, **household_summary_fields(), **changes, } @@ -276,6 +279,44 @@ def test_a_household_report_of_the_baseline_alone_answers_no_reform() -> None: assert item["result"]["reform"] is None +def test_a_failed_household_report_answers_each_sides_failure() -> None: + service = FakeReportService( + report=household_report_read( + status="failed", + run=report_run_read( + status="failed", error_message="household_calculation_failed" + ), + result=None, + failure=HouseholdReportFailure( + baseline=None, + reform=HouseholdCalculationFailure( + code="invalid_inputs", + message="The household names inputs the model does not have.", + input_errors=({"name": "employment_incme"},), + spm_error=None, + ), + ), + ) + ) + + item = ( + report_route_client(reports=service) + .get(f"/v2/reports/{REPORT_ID}?country_id=us") + .json()["result"]["item"] + ) + + assert item["result"] is None + assert item["failure"] == { + "baseline": None, + "reform": { + "code": "invalid_inputs", + "message": "The household names inputs the model does not have.", + "input_errors": [{"name": "employment_incme"}], + "spm_error": None, + }, + } + + def test_a_household_report_whose_households_cannot_be_read_answers_500() -> None: service = FakeReportService( error=HouseholdResultUnreadableError("simulation run has no result") @@ -306,6 +347,7 @@ def test_a_list_answers_household_and_economy_reports_by_kind_without_results() assert household_item["kind"] == "household" assert household_item["household_id"] == str(HOUSEHOLD_ID) assert "result" not in household_item + assert "failure" not in household_item assert economy_item["kind"] == "economy" assert "result" not in economy_item From 1190597474f6624157d509aa33cdec176556d62e Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:43:39 +0400 Subject: [PATCH 12/25] Answer each side's failure for a failed household report run A failed run's simulation runs may hold the household function's failure document; the read gives each side's code, message, input errors and SPM error, as a household calculation's read does, and none for a side without a readable one. Co-Authored-By: Claude Opus 5.5 --- changelog.d/v2-household-reports.added.md | 2 +- .../v2/reports/response_models.py | 22 +++++++- .../household_results/transformations.py | 54 +++++++++++++++++++ .../stored_reports/household_results/types.py | 26 +++++++-- .../services/v2/stored_reports/services.py | 30 ++++++++--- .../v2/stored_reports/transformations.py | 6 ++- .../services/v2/stored_reports/types.py | 9 +++- 7 files changed, 132 insertions(+), 17 deletions(-) create mode 100644 policyengine_api/services/v2/stored_reports/household_results/transformations.py diff --git a/changelog.d/v2-household-reports.added.md b/changelog.d/v2-household-reports.added.md index 842e5d678..f95e6173a 100644 --- a/changelog.d/v2-household-reports.added.md +++ b/changelog.d/v2-household-reports.added.md @@ -1 +1 @@ -`/v2/reports` serves household reports: `POST /v2/reports` with `kind` `household` takes a stored household, a baseline policy and an optional reform policy (null for a report of the baseline alone), and `add_missing_variables`, and has the simulation service create the report and its two household simulations, which calculate the household's default year. It answers 422 for a country without household calculations (a household-only country has them) and for a household with no default year, and 404 for a household the country does not store, before asking the simulation service. Reading or listing a household report answers it with its newest run and, once that run has succeeded, each simulation's calculated household. +`/v2/reports` serves household reports: `POST /v2/reports` with `kind` `household` takes a stored household, a baseline policy and an optional reform policy (null for a report of the baseline alone), and `add_missing_variables`, and has the simulation service create the report and its two household simulations, which calculate the household's default year. It answers 422 for a country without household calculations (a household-only country has them) and for a household with no default year, and 404 for a household the country does not store, before asking the simulation service. Reading or listing a household report answers it with its newest run; a read adds each simulation's calculated household once that run has succeeded, and each side's calculation error once it has failed. diff --git a/policyengine_api/fastapi_routes/v2/reports/response_models.py b/policyengine_api/fastapi_routes/v2/reports/response_models.py index 68733123c..80ac06c9d 100644 --- a/policyengine_api/fastapi_routes/v2/reports/response_models.py +++ b/policyengine_api/fastapi_routes/v2/reports/response_models.py @@ -13,6 +13,7 @@ V2MissingParametersErrorResponse, ) from policyengine_api.fastapi_routes.v2.household_calculations.response_models import ( + HouseholdCalculationErrorItem, HouseholdCalculationOutputItem, ) from policyengine_api.fastapi_routes.v2.simulation_resources_support import ( @@ -169,13 +170,30 @@ class HouseholdReportResultItem(StrictReportAPIModel): reform: HouseholdCalculationOutputItem | None +class HouseholdReportFailureItem(StrictReportAPIModel): + """Why each side of a failed run failed, as the household function said. + + Each is that side's calculation error, as ``/v2/household-calculations`` + answers one. A side is null when it succeeded, or failed without the + household function's error, as a run Modal refused does; the run's + ``error_message`` still says why it failed. ``reform`` is also null for + a report of the baseline alone. + """ + + baseline: HouseholdCalculationErrorItem | None + reform: HouseholdCalculationErrorItem | None + + class HouseholdReportItem(HouseholdReportSummaryItem): - """A household report with its newest run's calculated households. + """A household report with its newest run's outcome. - ``result`` is there once the newest run has succeeded, and null otherwise. + ``result`` holds the calculated households once the newest run has + succeeded, and ``failure`` each side's error once it has failed; each is + null otherwise. """ result: HouseholdReportResultItem | None + failure: HouseholdReportFailureItem | None @classmethod def from_read(cls, read: HouseholdReportRead) -> HouseholdReportItem: diff --git a/policyengine_api/services/v2/stored_reports/household_results/transformations.py b/policyengine_api/services/v2/stored_reports/household_results/transformations.py new file mode 100644 index 000000000..836f80b51 --- /dev/null +++ b/policyengine_api/services/v2/stored_reports/household_results/transformations.py @@ -0,0 +1,54 @@ +"""Read a failed household report run's failure documents.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import ValidationError + +from policyengine_api.services.v2.household_calculations.types import ( + StoredExecutionFailure, +) +from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdReportFailure, +) + +if TYPE_CHECKING: + from uuid import UUID + + from policyengine_api.data.v2.models import ReportRun + from policyengine_api.services.v2.household_calculations.types import ( + HouseholdCalculationFailure, + ) + from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdResultRows, + ) + + +def _side_failure( + rows: HouseholdResultRows, simulation_run_id: UUID | None +) -> HouseholdCalculationFailure | None: + """Return the side's failure, or None without a readable failure document.""" + if simulation_run_id is None: + return None + try: + document = StoredExecutionFailure.model_validate( + rows.documents.get(simulation_run_id) + ) + except ValidationError: + return None + return document.calculation_failure() + + +def household_report_failure( + report_run: ReportRun, rows: HouseholdResultRows +) -> HouseholdReportFailure: + """Return why each side of a failed report run failed, where it says. + + A side with no failure document, or one that cannot be read, gives None: + a failure is never made unreadable by its document. + """ + return HouseholdReportFailure( + baseline=_side_failure(rows, report_run.baseline_simulation_run_id), + reform=_side_failure(rows, report_run.reform_simulation_run_id), + ) diff --git a/policyengine_api/services/v2/stored_reports/household_results/types.py b/policyengine_api/services/v2/stored_reports/household_results/types.py index 4ccd058c6..5ff95ed28 100644 --- a/policyengine_api/services/v2/stored_reports/household_results/types.py +++ b/policyengine_api/services/v2/stored_reports/household_results/types.py @@ -4,9 +4,11 @@ household under one policy. The household function stores each simulation run's calculation in ``simulation_runs.household_result``, as the same result document a household calculation stores in ``household_jobs.result`` -(``household_calculations.types.StoredExecutionResult``). Once the report's -run has succeeded, the result is both calculations, read from those -documents. +(``household_calculations.types.StoredExecutionResult``), or, for a +calculation the household function refused, its failure document +(``StoredExecutionFailure``). Once the report's run has succeeded, the result +is both calculations, read from those documents; once it has failed, each +side's failure, where its document records one. """ from __future__ import annotations @@ -18,6 +20,7 @@ from uuid import UUID from policyengine_api.services.v2.household_calculations.types import ( + HouseholdCalculationFailure, HouseholdCalculationOutput, ) @@ -52,3 +55,20 @@ class HouseholdReportResult: baseline: HouseholdCalculationOutput reform: HouseholdCalculationOutput | None + + +@dataclass(frozen=True) +class HouseholdReportFailure: + """Why each side of a failed household report run failed, where it says. + + Each is the household function's error for that side's calculation: its + code, message, the inputs the country model does not recognize and any + SPM error. A side is None when it succeeded, has no failure document (a + run failed outside its calculation, such as one Modal refused, records + none) or has one that cannot be read; the report run's error message + still says why it failed. ``reform`` is also None for a report of the + baseline alone. + """ + + baseline: HouseholdCalculationFailure | None + reform: HouseholdCalculationFailure | None diff --git a/policyengine_api/services/v2/stored_reports/services.py b/policyengine_api/services/v2/stored_reports/services.py index 8b4b9da86..64fecad89 100644 --- a/policyengine_api/services/v2/stored_reports/services.py +++ b/policyengine_api/services/v2/stored_reports/services.py @@ -48,6 +48,9 @@ from policyengine_api.services.v2.stored_reports.economy_results import ( transformations as result_transformations, ) +from policyengine_api.services.v2.stored_reports.household_results import ( + transformations as household_result_transformations, +) from policyengine_api.services.v2.stored_reports.household_results.validators import ( require_household_report_result, ) @@ -170,20 +173,31 @@ def get_report(self, *, country_id: str, report_id: UUID) -> StoredReportRead: def _household_report( self, session: Session, stored: StoredReportRows ) -> HouseholdReportRead: - """Return a household report, with its result once its newest run succeeded.""" + """Return a household report with its newest run's outcome. + + Its calculated households are read once that run has succeeded, and + each side's failure once it has failed. + """ run = stored.newest_run - result = None - if run is not None and run.status is ReportRunStatus.SUCCEEDED: - result = require_household_report_result( - run, - household_result_reads.read_household_result_rows( - session, report_run=run - ), + result = failure = None + if run is not None and run.status in { + ReportRunStatus.SUCCEEDED, + ReportRunStatus.FAILED, + }: + rows = household_result_reads.read_household_result_rows( + session, report_run=run ) + if run.status is ReportRunStatus.SUCCEEDED: + result = require_household_report_result(run, rows) + else: + failure = household_result_transformations.household_report_failure( + run, rows + ) return household_report_read( stored, current_versions=self._current_versions(stored.report.country), result=result, + failure=failure, ) def list_reports( diff --git a/policyengine_api/services/v2/stored_reports/transformations.py b/policyengine_api/services/v2/stored_reports/transformations.py index 3e97828aa..2f23f3d34 100644 --- a/policyengine_api/services/v2/stored_reports/transformations.py +++ b/policyengine_api/services/v2/stored_reports/transformations.py @@ -26,6 +26,7 @@ EconomyResult, ) from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdReportFailure, HouseholdReportResult, ) from policyengine_api.services.v2.stored_reports.types import ( @@ -146,10 +147,11 @@ def household_report_read( *, current_versions: PackageVersions, result: HouseholdReportResult | None, + failure: HouseholdReportFailure | None, ) -> HouseholdReportRead: - """Return a household report with its calculated households, if it has them.""" + """Return a household report with its newest run's outcome, if it has one.""" summary = household_report_summary_read(rows, current_versions=current_versions) - return HouseholdReportRead(**vars(summary), result=result) + return HouseholdReportRead(**vars(summary), result=result, failure=failure) def listed_reports( diff --git a/policyengine_api/services/v2/stored_reports/types.py b/policyengine_api/services/v2/stored_reports/types.py index f2b9f9b1d..7df03f304 100644 --- a/policyengine_api/services/v2/stored_reports/types.py +++ b/policyengine_api/services/v2/stored_reports/types.py @@ -27,6 +27,7 @@ EconomyResult, ) from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdReportFailure, HouseholdReportResult, ) @@ -126,9 +127,15 @@ class HouseholdReportSummaryRead(ReportDefinitionRead): @dataclass(frozen=True) class HouseholdReportRead(HouseholdReportSummaryRead): - """A household report with its calculated households, once its run succeeded.""" + """A household report with its newest run's outcome. + + ``result`` holds the calculated households once that run has succeeded; + ``failure`` says why each side failed once it has failed. Both are None + otherwise. + """ result: HouseholdReportResult | None + failure: HouseholdReportFailure | None # A report of any kind these routes read. From 4060fc1c03f62baeaffcada6bf49af5cccba69ad Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:47:26 +0400 Subject: [PATCH 13/25] Test review round 1's findings: the shared household check, no region on household reads, the create dispatch (failing) Co-Authored-By: Claude Opus 5.5 --- .../integration/test_v2_household_reports.py | 45 +++++++-- tests/unit/v2/test_household_report_reads.py | 93 ++++++++++++++++++- tests/unit/v2/test_household_report_routes.py | 8 +- .../v2/test_household_report_submissions.py | 4 +- 4 files changed, 132 insertions(+), 18 deletions(-) diff --git a/tests/integration/test_v2_household_reports.py b/tests/integration/test_v2_household_reports.py index 36367496c..7c061ab91 100644 --- a/tests/integration/test_v2_household_reports.py +++ b/tests/integration/test_v2_household_reports.py @@ -187,10 +187,12 @@ def store_household_report( household_id: UUID, *, status: str = "succeeded", + with_reform: bool = True, ) -> StoredHouseholdReport: """Store a household report of current law and the catalog's policy, with a run. A succeeded run's simulation runs hold their calculated households. + Without ``with_reform``, it is a report of the baseline alone. """ succeeded = status == "succeeded" baseline_id, baseline_run_id = store_household_simulation( @@ -202,15 +204,17 @@ def store_household_report( status, succeeded_document(BASELINE_HOUSEHOLD) if succeeded else None ), ) - reform_id, reform_run_id = store_household_simulation( - connection, - catalog, - household_id, - policy_id=catalog.policy_id, - run=SimulationRunToStore( - status, succeeded_document(REFORM_HOUSEHOLD) if succeeded else None - ), - ) + reform_id = reform_run_id = None + if with_reform: + reform_id, reform_run_id = store_household_simulation( + connection, + catalog, + household_id, + policy_id=catalog.policy_id, + run=SimulationRunToStore( + status, succeeded_document(REFORM_HOUSEHOLD) if succeeded else None + ), + ) report_id = uuid4() insert_row( connection, @@ -219,7 +223,7 @@ def store_household_report( label="My household under the reform", country=catalog.country, tax_benefit_model_id=catalog.model_id, - policy_id=catalog.policy_id, + policy_id=catalog.policy_id if with_reform else None, baseline_simulation_id=baseline_id, reform_simulation_id=reform_id, household_id=household_id, @@ -374,6 +378,27 @@ def test_a_household_report_answers_both_calculated_households( assert item["result"]["reform"]["household"] == REFORM_HOUSEHOLD +def test_a_household_report_of_the_baseline_alone_answers_no_reform( + owner_engine: Engine, household_only_uk: CatalogRows, runtime_sessions: Any +) -> None: + with owner_engine.begin() as connection: + household_id = store_household(connection, household_only_uk) + stored = store_household_report( + connection, household_only_uk, household_id, with_reform=False + ) + + response = client_for(runtime_sessions).get( + f"/v2/reports/{stored.report_id}?country_id=uk" + ) + + assert response.status_code == 200, response.text + item = response.json()["result"]["item"] + assert item["reform_simulation"] is None + assert item["run"]["reform_simulation_run_id"] is None + assert item["result"]["baseline"]["household"] == BASELINE_HOUSEHOLD + assert item["result"]["reform"] is None + + def test_household_reports_are_listed_beside_economy_reports( owner_engine: Engine, household_only_uk: CatalogRows, runtime_sessions: Any ) -> None: diff --git a/tests/unit/v2/test_household_report_reads.py b/tests/unit/v2/test_household_report_reads.py index 6efc8d3f6..d00d51510 100644 --- a/tests/unit/v2/test_household_report_reads.py +++ b/tests/unit/v2/test_household_report_reads.py @@ -37,7 +37,11 @@ HouseholdCalculationFailure, HouseholdCalculationOutput, ) -from policyengine_api.services.v2.simulation_service.types import PackageVersions +from policyengine_api.services.v2.simulation_service.types import ( + HouseholdReportCreationInput, + PackageVersions, + SubmittedReport, +) from policyengine_api.services.v2.stored_reports.database_session import ( StoredReportDatabaseSession, ) @@ -53,6 +57,7 @@ ReportSummaryRead, ) from policyengine_api.services.v2.stored_reports.validators import ( + CreatedReportUnreadableError, StoredReportNotFoundError, ) @@ -312,6 +317,9 @@ def test_a_succeeded_household_report_reads_both_calculated_households( assert isinstance(report, HouseholdReportRead) assert report.kind == "household" assert report.household_id == stored.household_id + # A household report has no region or dataset. + assert not hasattr(report, "region_code") + assert not hasattr(report, "dataset_name") assert (report.label, report.description, report.year) == ( "My household under the reform", "Doubles the credit", @@ -563,3 +571,86 @@ def test_a_list_names_household_reports_beside_economy_reports_without_results( assert household_summary.status == "succeeded" assert not hasattr(household_summary, "result") assert type(economy_summary) is ReportSummaryRead + + +@dataclass +class StoringHouseholdReportService: + """The simulation service, storing a pending household report for each one sent. + + With ``answer_unstored``, it answers with a report ID it stored nothing + for. An economy report must not reach it. + """ + + sessions: sessionmaker[Session] + answer_unstored: bool = False + sent: list[HouseholdReportCreationInput] | None = None + + def submit_household_report( + self, report: HouseholdReportCreationInput + ) -> SubmittedReport: + self.sent = [*(self.sent or []), report] + if self.answer_unstored: + return SubmittedReport(report_id=uuid4(), report_run_id=uuid4()) + with self.sessions() as session: + stored = store_household_report( + session, run=RunToStore(status=ReportRunStatus.PENDING) + ) + assert stored.report_run_id is not None + return SubmittedReport( + report_id=stored.report_id, report_run_id=stored.report_run_id + ) + + def submit_report(self, report: object) -> SubmittedReport: + msg = "a household report must not be sent as an economy report" + raise AssertionError(msg) + + +def household_report_input() -> HouseholdReportCreationInput: + return HouseholdReportCreationInput.model_validate( + { + "country_id": "us", + "kind": "household", + "label": "My household under the reform", + "household_id": str(uuid4()), + "baseline": {}, + "reform": None, + } + ) + + +def test_a_household_report_is_sent_as_one_and_read_back_from_the_tables( + sessions: sessionmaker[Session], +) -> None: + simulation_service = StoringHouseholdReportService(sessions) + reports = V2StoredReportService( + StoredReportDatabaseSession(sessions), + current_versions=current_versions, + submissions=simulation_service, + ) + report_input = household_report_input() + + created = reports.create_report(report_input) + + assert simulation_service.sent == [report_input] + assert isinstance(created, HouseholdReportRead) + assert (created.status, created.result, created.failure) == ( + "pending", + None, + None, + ) + + +def test_a_created_household_report_that_cannot_be_read_back_is_named( + sessions: sessionmaker[Session], +) -> None: + reports = V2StoredReportService( + StoredReportDatabaseSession(sessions), + current_versions=current_versions, + submissions=StoringHouseholdReportService(sessions, answer_unstored=True), + ) + + with pytest.raises(CreatedReportUnreadableError) as unreadable: + reports.create_report(household_report_input()) + + assert unreadable.value.country_id == "us" + assert isinstance(unreadable.value.__cause__, StoredReportNotFoundError) diff --git a/tests/unit/v2/test_household_report_routes.py b/tests/unit/v2/test_household_report_routes.py index 8bd5fa4d2..ba9507e34 100644 --- a/tests/unit/v2/test_household_report_routes.py +++ b/tests/unit/v2/test_household_report_routes.py @@ -13,6 +13,7 @@ HouseholdCalculationOutput, ) from policyengine_api.services.v2.household_calculations.validators import ( + HouseholdCalculationCountryError, HouseholdCalculationYearError, ) from policyengine_api.services.v2.households.validators import ( @@ -21,9 +22,6 @@ from policyengine_api.services.v2.simulation_service.types import ( HouseholdReportCreationInput, ) -from policyengine_api.services.v2.simulation_service.validators import ( - HouseholdCalculationsUnsupportedError, -) from policyengine_api.services.v2.stored_reports.household_results.types import ( HouseholdReportFailure, HouseholdReportResult, @@ -201,8 +199,8 @@ def test_a_household_report_naming_another_country_is_refused_with_400() -> None ("error", "status_code"), [ pytest.param( - HouseholdCalculationsUnsupportedError( - "Household reports are not available for country 'us'" + HouseholdCalculationCountryError( + "Household calculations are not available for country us." ), 422, id="no-household-calculations", diff --git a/tests/unit/v2/test_household_report_submissions.py b/tests/unit/v2/test_household_report_submissions.py index 4161ab16a..d26f54e8b 100644 --- a/tests/unit/v2/test_household_report_submissions.py +++ b/tests/unit/v2/test_household_report_submissions.py @@ -16,6 +16,7 @@ ) from policyengine_api.services.v2.countries.types import CountryRead from policyengine_api.services.v2.household_calculations.validators import ( + HouseholdCalculationCountryError, HouseholdCalculationYearError, ) from policyengine_api.services.v2.households.types import HouseholdRead @@ -34,7 +35,6 @@ SubmittedReport, ) from policyengine_api.services.v2.simulation_service.validators import ( - HouseholdCalculationsUnsupportedError, SubmissionStorageError, ) from tests.unit.v2.test_simulation_submissions import ( @@ -188,7 +188,7 @@ def test_a_country_without_household_calculations_is_refused_before_anything_is_ policies=policies, ) - with pytest.raises(HouseholdCalculationsUnsupportedError, match="'us'"): + with pytest.raises(HouseholdCalculationCountryError, match="country us"): service.submit_household_report(household_report_input()) assert households.asked == [] assert policies.asked == [] From c87b7c96292e549ed2712bbe53636adb98ef838d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:49:05 +0400 Subject: [PATCH 14/25] Fix review round 1's quality findings for household reports Household reports use the household calculations' country check, error and household source; a household read has no region or dataset, which move to the economy read; the report items share one base of the fields every kind has, and the reads one helper for the newest run. Co-Authored-By: Claude Opus 5.5 --- changelog.d/v2-household-reports.added.md | 2 +- .../resource_service_protocols.py | 6 ++- .../v2/reports/response_models.py | 48 +++++++++---------- .../v2/simulation_resources_support.py | 6 +-- .../v2/simulation_service/services.py | 11 +++-- .../services/v2/simulation_service/types.py | 11 +---- .../v2/simulation_service/validators.py | 22 --------- .../v2/stored_reports/transformations.py | 27 +++++++---- .../services/v2/stored_reports/types.py | 18 ++++--- tests/unit/v2/test_household_report_routes.py | 2 - 10 files changed, 68 insertions(+), 85 deletions(-) diff --git a/changelog.d/v2-household-reports.added.md b/changelog.d/v2-household-reports.added.md index f95e6173a..a5461411c 100644 --- a/changelog.d/v2-household-reports.added.md +++ b/changelog.d/v2-household-reports.added.md @@ -1 +1 @@ -`/v2/reports` serves household reports: `POST /v2/reports` with `kind` `household` takes a stored household, a baseline policy and an optional reform policy (null for a report of the baseline alone), and `add_missing_variables`, and has the simulation service create the report and its two household simulations, which calculate the household's default year. It answers 422 for a country without household calculations (a household-only country has them) and for a household with no default year, and 404 for a household the country does not store, before asking the simulation service. Reading or listing a household report answers it with its newest run; a read adds each simulation's calculated household once that run has succeeded, and each side's calculation error once it has failed. +`/v2/reports` serves household reports: `POST /v2/reports` with `kind` `household` takes a stored household, a baseline policy and an optional reform policy (null for a report of the baseline alone), and `add_missing_variables`, and has the simulation service create the report and its household simulations (one for each policy), which calculate the household's default year. It answers 422 for a country without household calculations (a household-only country has them) and for a household with no default year, and 404 for a household the country does not store, before asking the simulation service. Reading or listing a household report answers it with its newest run; a read adds each simulation's calculated household once that run has succeeded, and each side's calculation error once it has failed. diff --git a/policyengine_api/fastapi_routes/resource_service_protocols.py b/policyengine_api/fastapi_routes/resource_service_protocols.py index f468c6691..2d0766c0b 100644 --- a/policyengine_api/fastapi_routes/resource_service_protocols.py +++ b/policyengine_api/fastapi_routes/resource_service_protocols.py @@ -212,9 +212,11 @@ def create_report(self, report_input: ReportCreationInput) -> StoredReportRead: Raise ``EconomyCalculationsUnsupportedError`` for an economy report in a country without economy reports, - ``HouseholdCalculationsUnsupportedError`` for a household report in a + ``HouseholdCalculationCountryError`` for a household report in a country without household calculations, ``HouseholdNotFoundError`` - for a household the country does not store, ``PolicyNotFoundError`` + for a household the country does not store, + ``HouseholdCalculationYearError`` for one with no default year, + ``PolicyNotFoundError`` for a policy the country does not store, ``PolicyParametersMissingError`` for a policy that sets parameters the running catalog version lacks, ``SubmissionStorageError`` when the diff --git a/policyengine_api/fastapi_routes/v2/reports/response_models.py b/policyengine_api/fastapi_routes/v2/reports/response_models.py index 80ac06c9d..35be72a71 100644 --- a/policyengine_api/fastapi_routes/v2/reports/response_models.py +++ b/policyengine_api/fastapi_routes/v2/reports/response_models.py @@ -76,11 +76,9 @@ class ReportSimulationItem(StrictReportAPIModel): dynamic_id: UUID | None -class ReportSummaryItem(StrictReportAPIModel): - """An economy report comparing a baseline and a reform simulation. +class ReportDefinitionItem(StrictReportAPIModel): + """What every report item gives: its definition and the current versions. - ``status`` is the newest run's, or ``not_run`` for a report with no run. - ``run`` is the newest run, whatever its status and versions; ``current_versions`` are the versions the Public API runs now, so a newest run on other versions is out of date. ``options`` are the options the report was created with. @@ -88,19 +86,29 @@ class ReportSummaryItem(StrictReportAPIModel): id: UUID country_id: str - kind: Literal["economy"] label: str description: str | None year: int | None - region_code: str | None - dataset_name: str | None baseline_simulation: ReportSimulationItem | None reform_simulation: ReportSimulationItem | None options: dict[str, JsonValue] created_at: datetime + current_versions: PackageVersionsItem + + +class ReportSummaryItem(ReportDefinitionItem): + """An economy report comparing a baseline and a reform simulation. + + Both simulations use the region and dataset named. ``status`` is the + newest run's, or ``not_run`` for a report with no run. ``run`` is the + newest run, whatever its status and versions. + """ + + kind: Literal["economy"] + region_code: str | None + dataset_name: str | None status: Literal["not_run", "pending", "running", "succeeded", "failed"] run: ReportRunItem | None - current_versions: PackageVersionsItem @classmethod def from_summary(cls, read: ReportSummaryRead) -> ReportSummaryItem: @@ -123,32 +131,20 @@ def from_read(cls, read: ReportRead) -> ReportItem: return cls.model_validate(read) -class HouseholdReportSummaryItem(StrictReportAPIModel): +class HouseholdReportSummaryItem(ReportDefinitionItem): """A household report: one stored household under a baseline and a reform. - Both simulations calculate the household ``household_id`` in ``year``; - ``reform_simulation`` is null for a report of the baseline alone. - ``status`` is the newest run's, or ``not_run`` for a report with no run. - ``run`` is the newest run, whatever its status and versions, naming each - simulation's run; ``current_versions`` are the versions the Public API - runs now, so a newest run on other versions is out of date. ``options`` - are the options the report was created with. + Both simulations calculate the household ``household_id`` in its default + year, ``year``; ``reform_simulation`` is null for a report of the + baseline alone. ``status`` is the newest run's, or ``not_run`` for a + report with no run. ``run`` is the newest run, whatever its status and + versions, naming each simulation's run. """ - id: UUID - country_id: str kind: Literal["household"] - label: str - description: str | None - year: int | None household_id: UUID - baseline_simulation: ReportSimulationItem | None - reform_simulation: ReportSimulationItem | None - options: dict[str, JsonValue] - created_at: datetime status: Literal["not_run", "pending", "running", "succeeded", "failed"] run: ReportRunItem | None - current_versions: PackageVersionsItem @classmethod def from_summary( diff --git a/policyengine_api/fastapi_routes/v2/simulation_resources_support.py b/policyengine_api/fastapi_routes/v2/simulation_resources_support.py index 073ecfca3..fbb15814e 100644 --- a/policyengine_api/fastapi_routes/v2/simulation_resources_support.py +++ b/policyengine_api/fastapi_routes/v2/simulation_resources_support.py @@ -23,6 +23,7 @@ v2_missing_parameters_response, ) from policyengine_api.services.v2.household_calculations.validators import ( + HouseholdCalculationCountryError, HouseholdCalculationYearError, ) from policyengine_api.services.v2.households.validators import HouseholdNotFoundError @@ -38,7 +39,6 @@ ) from policyengine_api.services.v2.simulation_service.validators import ( EconomyCalculationsUnsupportedError, - HouseholdCalculationsUnsupportedError, SubmissionStorageError, ) @@ -54,7 +54,7 @@ PolicyNotFoundError, HouseholdNotFoundError, EconomyCalculationsUnsupportedError, - HouseholdCalculationsUnsupportedError, + HouseholdCalculationCountryError, HouseholdCalculationYearError, SimulationServiceInvalidRequestError, ) @@ -73,7 +73,7 @@ V2ErrorMapping( ( EconomyCalculationsUnsupportedError, - HouseholdCalculationsUnsupportedError, + HouseholdCalculationCountryError, HouseholdCalculationYearError, SimulationServiceInvalidRequestError, ), diff --git a/policyengine_api/services/v2/simulation_service/services.py b/policyengine_api/services/v2/simulation_service/services.py index 9870023d2..8d7f91773 100644 --- a/policyengine_api/services/v2/simulation_service/services.py +++ b/policyengine_api/services/v2/simulation_service/services.py @@ -16,7 +16,8 @@ must allow household calculations instead (a household-only country passes), and the household it names must be stored in the country, through the same service as ``/v2/households``, with a default year, which the report -calculates; the household is checked before the policies. +calculates; the household is checked before the policies. These are the +checks ``/v2/household-calculations`` makes, with the same errors. """ from __future__ import annotations @@ -31,6 +32,7 @@ ) from policyengine_api.services.v2.household_calculations.validators import ( require_default_year, + require_household_country, ) from policyengine_api.services.v2.policies.validators import ( require_policy_parameters_present, @@ -38,19 +40,20 @@ from policyengine_api.services.v2.simulation_service.validators import ( SubmissionStorageError, require_economy_country, - require_household_country, ) if TYPE_CHECKING: from collections.abc import Callable, Iterable, Iterator from uuid import UUID + from policyengine_api.services.v2.household_calculations.types import ( + HouseholdSource, + ) from policyengine_api.services.v2.simulation_service.types import ( CountrySource, EconomyReportCreationInput, EconomySimulationCreationInput, HouseholdReportCreationInput, - HouseholdSource, PolicySource, SimulationServiceClient, SubmittedReport, @@ -159,7 +162,7 @@ def submit_household_report( ) -> SubmittedReport: """Check the household report, then have the simulation service create it. - Raise HouseholdCalculationsUnsupportedError unless the country's row + Raise HouseholdCalculationCountryError unless the country's row allows household calculations, then HouseholdNotFoundError for a household the country does not store, then HouseholdCalculationYearError for one with no default year, then the diff --git a/policyengine_api/services/v2/simulation_service/types.py b/policyengine_api/services/v2/simulation_service/types.py index a4bb132af..7d26c1527 100644 --- a/policyengine_api/services/v2/simulation_service/types.py +++ b/policyengine_api/services/v2/simulation_service/types.py @@ -32,7 +32,6 @@ from uuid import UUID from policyengine_api.services.v2.countries.types import CountryRead - from policyengine_api.services.v2.households.types import HouseholdRead from policyengine_api.services.v2.policies.types import CheckedPolicyRead # The bounds the simulation service and the tables accept. @@ -282,7 +281,7 @@ class SimulationServiceClient(Protocol): ``SimulationSubmissionService``, which checks a submission before sending it through a client, also raises EconomyCalculationsUnsupportedError, - HouseholdCalculationsUnsupportedError, HouseholdNotFoundError, + HouseholdCalculationCountryError, HouseholdNotFoundError, HouseholdCalculationYearError, PolicyNotFoundError, PolicyParametersMissingError and SubmissionStorageError from its checks. @@ -317,14 +316,6 @@ def list_countries(self) -> list[CountryRead]: ... -class HouseholdSource(Protocol): - """The stored households a household report names (``V2HouseholdService``).""" - - def get_household(self, *, country_id: str, household_id: UUID) -> HouseholdRead: - """Return the country's household, or raise ``HouseholdNotFoundError``.""" - ... - - class PolicySource(Protocol): """The stored policies a submission names.""" diff --git a/policyengine_api/services/v2/simulation_service/validators.py b/policyengine_api/services/v2/simulation_service/validators.py index 2adea32cf..9a9841736 100644 --- a/policyengine_api/services/v2/simulation_service/validators.py +++ b/policyengine_api/services/v2/simulation_service/validators.py @@ -18,13 +18,6 @@ class EconomyCalculationsUnsupportedError(ValueError): """ -class HouseholdCalculationsUnsupportedError(ValueError): - """The country has no household simulations or reports. - - Its countries row leaves ``household_enabled`` unset, or it has no row. - """ - - class SubmissionStorageError(RuntimeError): """A submission's countries, households, policies or catalog could not be read.""" @@ -36,18 +29,3 @@ def require_economy_country(countries: Iterable[CountryRead], country_id: str) - return msg = f"Economy calculations are not available for country {country_id!r}" raise EconomyCalculationsUnsupportedError(msg) - - -def require_household_country( - countries: Iterable[CountryRead], country_id: str -) -> None: - """Raise HouseholdCalculationsUnsupportedError unless the row allows households. - - A household-only country (household calculations without economy - reports) passes. - """ - for country in countries: - if country.id == country_id and country.household_enabled: - return - msg = f"Household reports are not available for country {country_id!r}" - raise HouseholdCalculationsUnsupportedError(msg) diff --git a/policyengine_api/services/v2/stored_reports/transformations.py b/policyengine_api/services/v2/stored_reports/transformations.py index 2f23f3d34..90546f90f 100644 --- a/policyengine_api/services/v2/stored_reports/transformations.py +++ b/policyengine_api/services/v2/stored_reports/transformations.py @@ -31,6 +31,7 @@ ) from policyengine_api.services.v2.stored_reports.types import ( ReportKind, + ReportStatusName, StoredReportRows, ) @@ -86,8 +87,6 @@ def _report_definition( label=report.label, description=report.description, year=report.year, - region_code=rows.region_code, - dataset_name=rows.dataset_name, baseline_simulation=_simulation(rows.baseline_simulation), reform_simulation=_simulation(rows.reform_simulation), options=dict(report.inputs), @@ -96,16 +95,28 @@ def _report_definition( ) +def _newest_run( + rows: StoredReportRows, +) -> tuple[ReportStatusName, ReportRunRead | None]: + """Return the report's status and newest run: ``not_run`` and None without one.""" + run = rows.newest_run + if run is None: + return "not_run", None + return run.status.value, report_run_read(run) + + def report_summary_read( rows: StoredReportRows, *, current_versions: PackageVersions ) -> ReportSummaryRead: """Return an economy report's definition and newest run, without a result.""" - run = rows.newest_run + status, run = _newest_run(rows) return ReportSummaryRead( **vars(_report_definition(rows, current_versions=current_versions)), kind="economy", - status="not_run" if run is None else run.status.value, - run=None if run is None else report_run_read(run), + region_code=rows.region_code, + dataset_name=rows.dataset_name, + status=status, + run=run, ) @@ -132,13 +143,13 @@ def household_report_summary_read( if report.household_id is None: msg = f"Report {report.id} is not a household report" raise ValueError(msg) - run = rows.newest_run + status, run = _newest_run(rows) return HouseholdReportSummaryRead( **vars(_report_definition(rows, current_versions=current_versions)), kind="household", household_id=report.household_id, - status="not_run" if run is None else run.status.value, - run=None if run is None else report_run_read(run), + status=status, + run=run, ) diff --git a/policyengine_api/services/v2/stored_reports/types.py b/policyengine_api/services/v2/stored_reports/types.py index 7df03f304..7ba4958c4 100644 --- a/policyengine_api/services/v2/stored_reports/types.py +++ b/policyengine_api/services/v2/stored_reports/types.py @@ -84,8 +84,6 @@ class ReportDefinitionRead: label: str description: str | None year: int | None - region_code: str | None - dataset_name: str | None baseline_simulation: ReportSimulationRead | None reform_simulation: ReportSimulationRead | None options: dict[str, JsonValue] @@ -95,9 +93,15 @@ class ReportDefinitionRead: @dataclass(frozen=True) class ReportSummaryRead(ReportDefinitionRead): - """An economy report's definition, its newest run and the current versions.""" + """An economy report's definition, its newest run and the current versions. + + ``region_code`` and ``dataset_name`` are the region and dataset both + simulations use, null when the report names none. + """ kind: Literal["economy"] + region_code: str | None + dataset_name: str | None status: ReportStatusName run: ReportRunRead | None @@ -113,10 +117,10 @@ class ReportRead(ReportSummaryRead): class HouseholdReportSummaryRead(ReportDefinitionRead): """A household report's definition, its newest run and the current versions. - Both simulations calculate the stored household ``household_id`` in - ``year``; ``reform_simulation`` is None for a report of the baseline - alone. It has no region or dataset. ``run`` is the newest report run, - whatever its status and versions, naming each simulation's run. + Both simulations calculate the stored household ``household_id`` in its + default year, ``year``; ``reform_simulation`` is None for a report of the + baseline alone. It has no region or dataset. ``run`` is the newest report + run, whatever its status and versions, naming each simulation's run. """ kind: Literal["household"] diff --git a/tests/unit/v2/test_household_report_routes.py b/tests/unit/v2/test_household_report_routes.py index ba9507e34..3e0eda3dc 100644 --- a/tests/unit/v2/test_household_report_routes.py +++ b/tests/unit/v2/test_household_report_routes.py @@ -59,8 +59,6 @@ def household_summary_fields(**changes: Any) -> dict[str, Any]: "label": "My household under the reform", "description": None, "year": 2026, - "region_code": None, - "dataset_name": None, "household_id": HOUSEHOLD_ID, "baseline_simulation": ReportSimulationRead( id=BASELINE_SIMULATION_ID, policy_id=None, dynamic_id=None From ff010ff8e99ddc9f84a2aa929c52792f8501ae7b Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:04:24 +0400 Subject: [PATCH 15/25] Test review round 2's findings: each side's failure schema, household 400 codes, failures on PostgreSQL (failing) Co-Authored-By: Claude Opus 5.5 --- .../integration/test_v2_household_reports.py | 116 +++++++++++++++--- ...st_simulation_household_report_requests.py | 28 +++++ tests/unit/v2/test_household_report_routes.py | 5 + tests/unit/v2/test_report_read_routes.py | 4 +- 4 files changed, 137 insertions(+), 16 deletions(-) diff --git a/tests/integration/test_v2_household_reports.py b/tests/integration/test_v2_household_reports.py index 7c061ab91..ecb367e8b 100644 --- a/tests/integration/test_v2_household_reports.py +++ b/tests/integration/test_v2_household_reports.py @@ -90,6 +90,19 @@ def current_versions(country_id: str) -> PackageVersions: return PackageVersions(**CURRENT_VERSIONS) +INPUT_ERRORS = [{"name": "employment_incme", "message": "is not a variable"}] +FAILED_DOCUMENT = { + "status": "failed", + "schema_version": 1, + "error": { + "code": "invalid_inputs", + "message": "The household names inputs the model does not have.", + "input_errors": INPUT_ERRORS, + "spm_error": None, + }, +} + + def succeeded_document(household: dict[str, Any]) -> dict[str, Any]: """Return the household function's result document for a calculated household.""" return { @@ -181,28 +194,49 @@ def store_household_simulation( return simulation_id, run_id +@dataclass(frozen=True) +class HouseholdReportToStore: + """A household report's run status, its sides, and their documents. + + A succeeded run's simulation runs hold their calculated households, + unless ``documents`` gives the baseline's and the reform's documents. + Without ``with_reform``, it is a report of the baseline alone. + """ + + status: str = "succeeded" + with_reform: bool = True + documents: tuple[dict[str, Any] | None, dict[str, Any] | None] | None = None + + def store_household_report( connection: Connection, catalog: CatalogRows, household_id: UUID, - *, - status: str = "succeeded", - with_reform: bool = True, + report: HouseholdReportToStore | None = None, ) -> StoredHouseholdReport: """Store a household report of current law and the catalog's policy, with a run. - A succeeded run's simulation runs hold their calculated households. - Without ``with_reform``, it is a report of the baseline alone. + Without ``report``, its run succeeded and both sides hold their households. """ + report = report or HouseholdReportToStore() + status = report.status + with_reform = report.with_reform + documents = report.documents succeeded = status == "succeeded" + baseline_document, reform_document = ( + ( + succeeded_document(BASELINE_HOUSEHOLD) if succeeded else None, + succeeded_document(REFORM_HOUSEHOLD) if succeeded else None, + ) + if documents is None + else documents + ) baseline_id, baseline_run_id = store_household_simulation( connection, catalog, household_id, policy_id=None, - run=SimulationRunToStore( - status, succeeded_document(BASELINE_HOUSEHOLD) if succeeded else None - ), + run=SimulationRunToStore(status, baseline_document), ) reform_id = reform_run_id = None if with_reform: @@ -211,9 +245,7 @@ def store_household_report( catalog, household_id, policy_id=catalog.policy_id, - run=SimulationRunToStore( - status, succeeded_document(REFORM_HOUSEHOLD) if succeeded else None - ), + run=SimulationRunToStore(status, reform_document), ) report_id = uuid4() insert_row( @@ -266,7 +298,10 @@ def submit_household_report( self.household_reports.append(report) with self.engine.begin() as connection: stored = store_household_report( - connection, self.catalog, report.household_id, status="pending" + connection, + self.catalog, + report.household_id, + HouseholdReportToStore(status="pending"), ) return SubmittedReport( report_id=stored.report_id, report_run_id=stored.report_run_id @@ -384,7 +419,10 @@ def test_a_household_report_of_the_baseline_alone_answers_no_reform( with owner_engine.begin() as connection: household_id = store_household(connection, household_only_uk) stored = store_household_report( - connection, household_only_uk, household_id, with_reform=False + connection, + household_only_uk, + household_id, + HouseholdReportToStore(with_reform=False), ) response = client_for(runtime_sessions).get( @@ -399,6 +437,56 @@ def test_a_household_report_of_the_baseline_alone_answers_no_reform( assert item["result"]["reform"] is None +def test_a_failed_household_report_answers_each_sides_failure( + owner_engine: Engine, household_only_uk: CatalogRows, runtime_sessions: Any +) -> None: + with owner_engine.begin() as connection: + household_id = store_household(connection, household_only_uk) + stored = store_household_report( + connection, + household_only_uk, + household_id, + HouseholdReportToStore( + status="failed", + documents=(succeeded_document(BASELINE_HOUSEHOLD), FAILED_DOCUMENT), + ), + ) + + response = client_for(runtime_sessions).get( + f"/v2/reports/{stored.report_id}?country_id=uk" + ) + + assert response.status_code == 200, response.text + item = response.json()["result"]["item"] + assert (item["status"], item["result"]) == ("failed", None) + assert item["failure"] == {"baseline": None, "reform": FAILED_DOCUMENT["error"]} + + +def test_a_succeeded_household_report_missing_a_calculation_answers_500( + owner_engine: Engine, household_only_uk: CatalogRows, runtime_sessions: Any +) -> None: + with owner_engine.begin() as connection: + household_id = store_household(connection, household_only_uk) + stored = store_household_report( + connection, + household_only_uk, + household_id, + HouseholdReportToStore( + documents=(succeeded_document(BASELINE_HOUSEHOLD), None) + ), + ) + + response = client_for(runtime_sessions).get( + f"/v2/reports/{stored.report_id}?country_id=uk" + ) + + assert response.status_code == 500 + assert response.json() == { + "status": "error", + "message": "The report's stored result cannot be read", + } + + def test_household_reports_are_listed_beside_economy_reports( owner_engine: Engine, household_only_uk: CatalogRows, runtime_sessions: Any ) -> None: @@ -406,7 +494,7 @@ def test_household_reports_are_listed_beside_economy_reports( with owner_engine.begin() as connection: household_id = store_household(connection, catalog) household_report = store_household_report( - connection, catalog, household_id, status="running" + connection, catalog, household_id, HouseholdReportToStore(status="running") ) economy_report = store_report(connection, catalog) diff --git a/tests/unit/libs/test_simulation_household_report_requests.py b/tests/unit/libs/test_simulation_household_report_requests.py index f23fd2a28..e7449c0ba 100644 --- a/tests/unit/libs/test_simulation_household_report_requests.py +++ b/tests/unit/libs/test_simulation_household_report_requests.py @@ -12,6 +12,7 @@ import pytest +from policyengine_api.fastapi_routes.v2.reports.routes import REPORT_ERROR_MAPPINGS from policyengine_api.libs.simulation_reports import ( REPORTS_PATH, household_report_request, @@ -118,6 +119,33 @@ def test_a_household_report_the_service_refuses_is_reported_with_its_reason() -> ) +@pytest.mark.parametrize( + "code", ["unknown_household", "no_household_function", "unknown_policy"] +) +def test_a_household_400_the_public_api_checked_first_is_unavailable( + code: str, +) -> None: + # The Public API checks the household and policies before sending, and + # the default worker's household function is the service's own state. + service = RecordingService( + status_code=400, body={"code": code, "detail": "No such row."} + ) + + with pytest.raises(SimulationServiceUnavailableError) as unavailable: + service.client().submit_household_report(household_report_input()) + + route_status = next( + mapping.status_code + for mapping in REPORT_ERROR_MAPPINGS + if isinstance(unavailable.value, mapping.error_types) + ) + assert route_status == 503 + assert str(unavailable.value) == ( + f"The simulation service refused the report ({code}): No such row." + ) + assert unavailable.value.created_record_id is None + + def test_a_household_report_the_unavailable_service_created_is_named() -> None: service = RecordingService( status_code=503, diff --git a/tests/unit/v2/test_household_report_routes.py b/tests/unit/v2/test_household_report_routes.py index 3e0eda3dc..6d71e8f0c 100644 --- a/tests/unit/v2/test_household_report_routes.py +++ b/tests/unit/v2/test_household_report_routes.py @@ -362,4 +362,9 @@ def test_the_create_routes_schema_offers_the_household_kind() -> None: "baseline", } assert "year" not in household_request["properties"] + # Each side's failure is read from the household function's failure + # document, which always names its code. + side_failure = components["HouseholdReportSideFailureItem"] + assert side_failure["properties"]["code"] == {"title": "Code", "type": "string"} + assert "submit" not in side_failure["description"] assert "HouseholdReportItem" in components diff --git a/tests/unit/v2/test_report_read_routes.py b/tests/unit/v2/test_report_read_routes.py index 5076ff51c..13c579538 100644 --- a/tests/unit/v2/test_report_read_routes.py +++ b/tests/unit/v2/test_report_read_routes.py @@ -93,9 +93,9 @@ def test_a_report_without_a_run_answers_with_no_run_and_no_result() -> None: "Report x was not found in country 'us'", ), ( - ReportKindUnsupportedError("Report x is a household report"), + ReportKindUnsupportedError("Report x is a budget window"), 422, - "Report x is a household report", + "Report x is a budget window", ), ( EconomyResultUnreadableError("the budget summary has no household_tax row"), From 3ae0bea8760655678f9a2c6b158b0f8b5166a447 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:05:19 +0400 Subject: [PATCH 16/25] Fix review round 2's quality findings for household reports Each side's failure has its own schema, whose code is always named and whose text is about reports; the stored documents are typed as JSON; every report kind has a name for the unsupported-kind message; and the create route's 422 text reads as one sentence. Co-Authored-By: Claude Opus 5.5 --- .../v2/reports/response_models.py | 29 +++++++++++++------ .../stored_reports/household_results/types.py | 6 ++-- .../services/v2/stored_reports/validators.py | 7 ++++- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/policyengine_api/fastapi_routes/v2/reports/response_models.py b/policyengine_api/fastapi_routes/v2/reports/response_models.py index 35be72a71..fd9903af1 100644 --- a/policyengine_api/fastapi_routes/v2/reports/response_models.py +++ b/policyengine_api/fastapi_routes/v2/reports/response_models.py @@ -13,7 +13,6 @@ V2MissingParametersErrorResponse, ) from policyengine_api.fastapi_routes.v2.household_calculations.response_models import ( - HouseholdCalculationErrorItem, HouseholdCalculationOutputItem, ) from policyengine_api.fastapi_routes.v2.simulation_resources_support import ( @@ -166,18 +165,31 @@ class HouseholdReportResultItem(StrictReportAPIModel): reform: HouseholdCalculationOutputItem | None +class HouseholdReportSideFailureItem(StrictReportAPIModel): + """The household function's error for one side's calculation. + + ``code`` is its error code, such as ``invalid_inputs``, with + ``input_errors`` naming each input the country model does not recognize + and ``spm_error`` an SPM error. + """ + + code: str + message: str + input_errors: list[dict[str, JsonValue]] + spm_error: dict[str, JsonValue] | None + + class HouseholdReportFailureItem(StrictReportAPIModel): """Why each side of a failed run failed, as the household function said. - Each is that side's calculation error, as ``/v2/household-calculations`` - answers one. A side is null when it succeeded, or failed without the - household function's error, as a run Modal refused does; the run's + A side is null when it succeeded, or failed without the household + function's error, as a run Modal refused does; the run's ``error_message`` still says why it failed. ``reform`` is also null for a report of the baseline alone. """ - baseline: HouseholdCalculationErrorItem | None - reform: HouseholdCalculationErrorItem | None + baseline: HouseholdReportSideFailureItem | None + reform: HouseholdReportSideFailureItem | None class HouseholdReportItem(HouseholdReportSummaryItem): @@ -300,9 +312,8 @@ class UnavailableAfterReportCreatedResponse(V2ErrorResponse): "The request does not match the schema; the country has no economy " "reports (such as a household-only country), for an economy report, " "or no household calculations, for a household report; a household " - "report's household has no default year; a policy the " - "report " - "names sets parameters the running catalog version does not have " + "report's household has no default year; a policy the report names " + "sets parameters the running catalog version does not have " "(code missing_parameters, which names them; the baseline's policy is " "checked first); or the simulation service cannot run the report: its " "422, or its 400 for a region, dataset or dynamic it does not have. The " diff --git a/policyengine_api/services/v2/stored_reports/household_results/types.py b/policyengine_api/services/v2/stored_reports/household_results/types.py index 5ff95ed28..1f815dcf4 100644 --- a/policyengine_api/services/v2/stored_reports/household_results/types.py +++ b/policyengine_api/services/v2/stored_reports/household_results/types.py @@ -14,11 +14,13 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING if TYPE_CHECKING: from uuid import UUID + from pydantic import JsonValue + from policyengine_api.services.v2.household_calculations.types import ( HouseholdCalculationFailure, HouseholdCalculationOutput, @@ -41,7 +43,7 @@ class HouseholdResultRows: absent. """ - documents: dict[UUID, Any] + documents: dict[UUID, JsonValue] @dataclass(frozen=True) diff --git a/policyengine_api/services/v2/stored_reports/validators.py b/policyengine_api/services/v2/stored_reports/validators.py index 418de6778..81f381dac 100644 --- a/policyengine_api/services/v2/stored_reports/validators.py +++ b/policyengine_api/services/v2/stored_reports/validators.py @@ -21,7 +21,12 @@ StoredReportRows, ) -KIND_NAMES = {"budget_window": "a budget window"} +# Every kind's name, so a kind left out of READABLE_KINDS is still named. +KIND_NAMES: dict[ReportKind, str] = { + "economy": "an economy report", + "household": "a household report", + "budget_window": "a budget window", +} class StoredReportNotFoundError(LookupError): From 9eda21aba2508bd5ef3a1083c52a13878c4f8451 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:14:50 +0400 Subject: [PATCH 17/25] Test review round 3's findings: a report's own side failure, every kind named, each side's run status (failing) Co-Authored-By: Claude Opus 5.5 --- .../integration/test_v2_household_reports.py | 62 ++++++++++++------- ...st_simulation_household_report_requests.py | 8 +-- tests/unit/v2/test_household_report_reads.py | 12 +++- tests/unit/v2/test_household_report_routes.py | 4 +- 4 files changed, 51 insertions(+), 35 deletions(-) diff --git a/tests/integration/test_v2_household_reports.py b/tests/integration/test_v2_household_reports.py index ecb367e8b..e87b9edf6 100644 --- a/tests/integration/test_v2_household_reports.py +++ b/tests/integration/test_v2_household_reports.py @@ -196,16 +196,33 @@ def store_household_simulation( @dataclass(frozen=True) class HouseholdReportToStore: - """A household report's run status, its sides, and their documents. + """A household report's run status, and the run of each of its sides. - A succeeded run's simulation runs hold their calculated households, - unless ``documents`` gives the baseline's and the reform's documents. - Without ``with_reform``, it is a report of the baseline alone. + ``side_runs`` gives the baseline's and the reform's simulation runs. + Without it, each side's run has the report run's status and, once + succeeded, its calculated household. Without ``with_reform``, it is a + report of the baseline alone, and the reform's run is not stored. """ status: str = "succeeded" with_reform: bool = True - documents: tuple[dict[str, Any] | None, dict[str, Any] | None] | None = None + side_runs: tuple[SimulationRunToStore, SimulationRunToStore] | None = None + + def runs(self) -> tuple[SimulationRunToStore, SimulationRunToStore]: + """Return the baseline's and the reform's simulation runs to store.""" + if self.side_runs is not None: + return self.side_runs + succeeded = self.status == "succeeded" + return ( + SimulationRunToStore( + self.status, + succeeded_document(BASELINE_HOUSEHOLD) if succeeded else None, + ), + SimulationRunToStore( + self.status, + succeeded_document(REFORM_HOUSEHOLD) if succeeded else None, + ), + ) def store_household_report( @@ -221,22 +238,9 @@ def store_household_report( report = report or HouseholdReportToStore() status = report.status with_reform = report.with_reform - documents = report.documents - succeeded = status == "succeeded" - baseline_document, reform_document = ( - ( - succeeded_document(BASELINE_HOUSEHOLD) if succeeded else None, - succeeded_document(REFORM_HOUSEHOLD) if succeeded else None, - ) - if documents is None - else documents - ) + baseline_run, reform_run = report.runs() baseline_id, baseline_run_id = store_household_simulation( - connection, - catalog, - household_id, - policy_id=None, - run=SimulationRunToStore(status, baseline_document), + connection, catalog, household_id, policy_id=None, run=baseline_run ) reform_id = reform_run_id = None if with_reform: @@ -245,7 +249,7 @@ def store_household_report( catalog, household_id, policy_id=catalog.policy_id, - run=SimulationRunToStore(status, reform_document), + run=reform_run, ) report_id = uuid4() insert_row( @@ -446,9 +450,16 @@ def test_a_failed_household_report_answers_each_sides_failure( connection, household_only_uk, household_id, + # Both sides are calculated: the baseline succeeded, the reform's + # calculation was refused, so the report run failed. HouseholdReportToStore( status="failed", - documents=(succeeded_document(BASELINE_HOUSEHOLD), FAILED_DOCUMENT), + side_runs=( + SimulationRunToStore( + "succeeded", succeeded_document(BASELINE_HOUSEHOLD) + ), + SimulationRunToStore("failed", FAILED_DOCUMENT), + ), ), ) @@ -472,7 +483,12 @@ def test_a_succeeded_household_report_missing_a_calculation_answers_500( household_only_uk, household_id, HouseholdReportToStore( - documents=(succeeded_document(BASELINE_HOUSEHOLD), None) + side_runs=( + SimulationRunToStore( + "succeeded", succeeded_document(BASELINE_HOUSEHOLD) + ), + SimulationRunToStore("succeeded", None), + ) ), ) diff --git a/tests/unit/libs/test_simulation_household_report_requests.py b/tests/unit/libs/test_simulation_household_report_requests.py index e7449c0ba..c9f3c8b2d 100644 --- a/tests/unit/libs/test_simulation_household_report_requests.py +++ b/tests/unit/libs/test_simulation_household_report_requests.py @@ -12,7 +12,6 @@ import pytest -from policyengine_api.fastapi_routes.v2.reports.routes import REPORT_ERROR_MAPPINGS from policyengine_api.libs.simulation_reports import ( REPORTS_PATH, household_report_request, @@ -131,15 +130,10 @@ def test_a_household_400_the_public_api_checked_first_is_unavailable( status_code=400, body={"code": code, "detail": "No such row."} ) + # The route answers SimulationServiceUnavailableError with 503. with pytest.raises(SimulationServiceUnavailableError) as unavailable: service.client().submit_household_report(household_report_input()) - route_status = next( - mapping.status_code - for mapping in REPORT_ERROR_MAPPINGS - if isinstance(unavailable.value, mapping.error_types) - ) - assert route_status == 503 assert str(unavailable.value) == ( f"The simulation service refused the report ({code}): No such row." ) diff --git a/tests/unit/v2/test_household_report_reads.py b/tests/unit/v2/test_household_report_reads.py index d00d51510..04dbb32b5 100644 --- a/tests/unit/v2/test_household_report_reads.py +++ b/tests/unit/v2/test_household_report_reads.py @@ -10,7 +10,7 @@ from dataclasses import dataclass from datetime import UTC, datetime, timedelta -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, get_args from uuid import UUID, uuid4 import pytest @@ -34,7 +34,6 @@ TaxBenefitModelVersion, ) from policyengine_api.services.v2.household_calculations.types import ( - HouseholdCalculationFailure, HouseholdCalculationOutput, ) from policyengine_api.services.v2.simulation_service.types import ( @@ -48,15 +47,18 @@ from policyengine_api.services.v2.stored_reports.household_results.types import ( HouseholdReportFailure, HouseholdReportResult, + HouseholdReportSideFailure, HouseholdResultUnreadableError, ) from policyengine_api.services.v2.stored_reports.services import V2StoredReportService from policyengine_api.services.v2.stored_reports.types import ( HouseholdReportRead, HouseholdReportSummaryRead, + ReportKind, ReportSummaryRead, ) from policyengine_api.services.v2.stored_reports.validators import ( + KIND_NAMES, CreatedReportUnreadableError, StoredReportNotFoundError, ) @@ -428,7 +430,7 @@ def test_a_failed_household_report_answers_each_sides_failure( assert report.result is None assert report.failure == HouseholdReportFailure( baseline=None, - reform=HouseholdCalculationFailure( + reform=HouseholdReportSideFailure( code="invalid_inputs", message="The household names inputs the model does not have.", input_errors=tuple(INPUT_ERRORS), @@ -654,3 +656,7 @@ def test_a_created_household_report_that_cannot_be_read_back_is_named( assert unreadable.value.country_id == "us" assert isinstance(unreadable.value.__cause__, StoredReportNotFoundError) + + +def test_every_report_kind_has_a_name_for_the_unsupported_kind_message() -> None: + assert set(KIND_NAMES) == set(get_args(ReportKind)) diff --git a/tests/unit/v2/test_household_report_routes.py b/tests/unit/v2/test_household_report_routes.py index 6d71e8f0c..f613f16f1 100644 --- a/tests/unit/v2/test_household_report_routes.py +++ b/tests/unit/v2/test_household_report_routes.py @@ -9,7 +9,6 @@ import pytest from policyengine_api.services.v2.household_calculations.types import ( - HouseholdCalculationFailure, HouseholdCalculationOutput, ) from policyengine_api.services.v2.household_calculations.validators import ( @@ -25,6 +24,7 @@ from policyengine_api.services.v2.stored_reports.household_results.types import ( HouseholdReportFailure, HouseholdReportResult, + HouseholdReportSideFailure, HouseholdResultUnreadableError, ) from policyengine_api.services.v2.stored_reports.types import ( @@ -285,7 +285,7 @@ def test_a_failed_household_report_answers_each_sides_failure() -> None: result=None, failure=HouseholdReportFailure( baseline=None, - reform=HouseholdCalculationFailure( + reform=HouseholdReportSideFailure( code="invalid_inputs", message="The household names inputs the model does not have.", input_errors=({"name": "employment_incme"},), From bcc00a67fc48964634b5b8ab406ab1eab0f323bd Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:15:57 +0400 Subject: [PATCH 18/25] Fix review round 3's quality findings for household reports Each side's failure is a report type of its own whose code is always named, read straight from the failure document; the integration tests give each side's simulation run its own status, and a test checks that every report kind has a name. Co-Authored-By: Claude Opus 5.5 --- .../household_results/transformations.py | 14 +++++++---- .../stored_reports/household_results/types.py | 25 ++++++++++++++----- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/policyengine_api/services/v2/stored_reports/household_results/transformations.py b/policyengine_api/services/v2/stored_reports/household_results/transformations.py index 836f80b51..eaa483a20 100644 --- a/policyengine_api/services/v2/stored_reports/household_results/transformations.py +++ b/policyengine_api/services/v2/stored_reports/household_results/transformations.py @@ -11,15 +11,13 @@ ) from policyengine_api.services.v2.stored_reports.household_results.types import ( HouseholdReportFailure, + HouseholdReportSideFailure, ) if TYPE_CHECKING: from uuid import UUID from policyengine_api.data.v2.models import ReportRun - from policyengine_api.services.v2.household_calculations.types import ( - HouseholdCalculationFailure, - ) from policyengine_api.services.v2.stored_reports.household_results.types import ( HouseholdResultRows, ) @@ -27,7 +25,7 @@ def _side_failure( rows: HouseholdResultRows, simulation_run_id: UUID | None -) -> HouseholdCalculationFailure | None: +) -> HouseholdReportSideFailure | None: """Return the side's failure, or None without a readable failure document.""" if simulation_run_id is None: return None @@ -37,7 +35,13 @@ def _side_failure( ) except ValidationError: return None - return document.calculation_failure() + error = document.error + return HouseholdReportSideFailure( + code=error.code, + message=error.message, + input_errors=tuple(error.input_errors), + spm_error=error.spm_error, + ) def household_report_failure( diff --git a/policyengine_api/services/v2/stored_reports/household_results/types.py b/policyengine_api/services/v2/stored_reports/household_results/types.py index 1f815dcf4..8ce6187c1 100644 --- a/policyengine_api/services/v2/stored_reports/household_results/types.py +++ b/policyengine_api/services/v2/stored_reports/household_results/types.py @@ -22,7 +22,6 @@ from pydantic import JsonValue from policyengine_api.services.v2.household_calculations.types import ( - HouseholdCalculationFailure, HouseholdCalculationOutput, ) @@ -59,18 +58,32 @@ class HouseholdReportResult: reform: HouseholdCalculationOutput | None +@dataclass(frozen=True) +class HouseholdReportSideFailure: + """The household function's error for one side's calculation. + + It is read from the side's failure document, which always names its + ``code``, such as ``invalid_inputs``; ``input_errors`` names each input + the country model does not recognize, and ``spm_error`` is an SPM error. + """ + + code: str + message: str + input_errors: tuple[dict[str, JsonValue], ...] + spm_error: dict[str, JsonValue] | None + + @dataclass(frozen=True) class HouseholdReportFailure: """Why each side of a failed household report run failed, where it says. - Each is the household function's error for that side's calculation: its - code, message, the inputs the country model does not recognize and any - SPM error. A side is None when it succeeded, has no failure document (a + Each is the household function's error for that side's calculation. A + side is None when it succeeded, has no failure document (a run failed outside its calculation, such as one Modal refused, records none) or has one that cannot be read; the report run's error message still says why it failed. ``reform`` is also None for a report of the baseline alone. """ - baseline: HouseholdCalculationFailure | None - reform: HouseholdCalculationFailure | None + baseline: HouseholdReportSideFailure | None + reform: HouseholdReportSideFailure | None From c40ae016bd71f5613b20c0aa0bc24f38575dd3c8 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:21:47 +0400 Subject: [PATCH 19/25] Fix review round 4's findings: name the report failure read, and store each side's recorded status Co-Authored-By: Claude Opus 5.5 --- .../v2/household_calculations/types.py | 9 ++++++--- tests/unit/v2/test_household_report_reads.py | 18 ++++++++++++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/policyengine_api/services/v2/household_calculations/types.py b/policyengine_api/services/v2/household_calculations/types.py index 99d40e3c6..fbae62f5b 100644 --- a/policyengine_api/services/v2/household_calculations/types.py +++ b/policyengine_api/services/v2/household_calculations/types.py @@ -202,9 +202,12 @@ class HouseholdCalculationRead: error: HouseholdCalculationFailure | None -# The result documents the household functions store in household_jobs.result. -# The simulation service's household contract owns their shape; these models -# and their two conversions are the only code that names its fields. +# The result documents the household functions store in household_jobs.result, +# and in simulation_runs.household_result for a household report's simulations. +# The simulation service's household contract owns their shape; these models, +# their two conversions and the household report failure read +# (stored_reports.household_results.transformations) are the only code that +# names its fields. class StoredDocumentModel(BaseModel): diff --git a/tests/unit/v2/test_household_report_reads.py b/tests/unit/v2/test_household_report_reads.py index 04dbb32b5..dd1291e8c 100644 --- a/tests/unit/v2/test_household_report_reads.py +++ b/tests/unit/v2/test_household_report_reads.py @@ -160,7 +160,11 @@ class StoredHouseholdReport: @dataclass(frozen=True) class RunToStore: - """A report run to store: its status, and each simulation run's result.""" + """A report run to store: its status, and each simulation run's result. + + Each simulation run has the status its document records, as the + household function writes it, or the report run's without one. + """ status: ReportRunStatus = ReportRunStatus.SUCCEEDED baseline_result: dict[str, Any] | None = None @@ -241,18 +245,24 @@ def simulation(policy_id: UUID | None) -> Simulation: report_run_id = baseline_run_id = reform_run_id = None if run is not None: completed_at = STORED_AT + timedelta(minutes=1) if run.finished else None - simulation_status = SimulationStatus(run.status.value) def simulation_run( simulation_id: UUID, document: dict[str, Any] | None ) -> SimulationRun: + recorded_status = (document or {}).get("status") + status = SimulationStatus( + recorded_status + if recorded_status in {"succeeded", "failed"} + else run.status.value + ) + finished = status in {SimulationStatus.SUCCEEDED, SimulationStatus.FAILED} return SimulationRun( simulation_id=simulation_id, year=2026, tax_benefit_model_version_id=version.id, - status=simulation_status, + status=status, started_at=STORED_AT, - completed_at=completed_at, + completed_at=completed_at or (STORED_AT if finished else None), household_result=document, **RUN_VERSIONS, ) From 7a8a3288d79f8ed2254008a82edb944ef1a16f44 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:38:01 +0400 Subject: [PATCH 20/25] List the household report input among ruff's runtime-evaluated model bases Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 480d313ac..7a224a983 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -479,6 +479,7 @@ runtime-evaluated-base-classes = [ "policyengine_api.services.v2.policies.types.StrictPolicyInput", "policyengine_api.services.v2.simulation_service.types.EconomyReportCreationInput", "policyengine_api.services.v2.simulation_service.types.EconomySimulationCreationInput", + "policyengine_api.services.v2.simulation_service.types.HouseholdReportCreationInput", "policyengine_api.services.v2.simulation_service.types.StrictSubmissionModel", "policyengine_api.services.v2.simulations.types.StrictContractModel", "policyengine_api.services.v2.stored_reports.economy_results.types.EconomyResultModel", From e4b6283c983672cca54c95a946a589bfa82cafe5 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:14:35 +0400 Subject: [PATCH 21/25] Remove the refusal of report kinds /v2/reports could not read yet Every kind of report is now read, listed and created here, so the 422 for an unreadable kind, its error and the kind filter on lists are gone. Co-Authored-By: Claude Opus 5.5 --- .../fastapi_routes/v2/reports/routes.py | 3 -- .../services/v2/stored_reports/services.py | 3 +- .../v2/stored_reports/transformations.py | 16 ++------- .../services/v2/stored_reports/validators.py | 35 ------------------- tests/unit/v2/test_household_report_reads.py | 8 +---- tests/unit/v2/test_report_read_routes.py | 6 ---- 6 files changed, 5 insertions(+), 66 deletions(-) diff --git a/policyengine_api/fastapi_routes/v2/reports/routes.py b/policyengine_api/fastapi_routes/v2/reports/routes.py index 2bc1a75f3..931a176eb 100644 --- a/policyengine_api/fastapi_routes/v2/reports/routes.py +++ b/policyengine_api/fastapi_routes/v2/reports/routes.py @@ -70,7 +70,6 @@ ) from policyengine_api.services.v2.stored_reports.validators import ( CreatedReportUnreadableError, - ReportKindUnsupportedError, ReportStorageError, StoredReportNotFoundError, ) @@ -100,12 +99,10 @@ # Refusals a caller can act on carry their own message and are not logged. REPORT_REFUSALS: tuple[type[Exception], ...] = ( StoredReportNotFoundError, - ReportKindUnsupportedError, *SUBMISSION_REFUSALS, ) REPORT_ERROR_MAPPINGS = ( V2ErrorMapping((StoredReportNotFoundError,), 404), - V2ErrorMapping((ReportKindUnsupportedError,), 422), *SUBMISSION_ERROR_MAPPINGS, V2ErrorMapping( ( diff --git a/policyengine_api/services/v2/stored_reports/services.py b/policyengine_api/services/v2/stored_reports/services.py index 6d1cc2ba5..13d8a95c8 100644 --- a/policyengine_api/services/v2/stored_reports/services.py +++ b/policyengine_api/services/v2/stored_reports/services.py @@ -79,7 +79,6 @@ from policyengine_api.services.v2.stored_reports.validators import ( CreatedReportUnreadableError, ReportStorageError, - require_readable_report, require_reform_run, require_report, ) @@ -179,7 +178,7 @@ def get_report(self, *, country_id: str, report_id: UUID) -> StoredReportRead: report_id=report_id, country_id=country_id, ) - kind = require_readable_report(stored) + kind = report_kind(stored.report) if kind == "budget_window": return self._budget_window(session, stored) if kind == "household": diff --git a/policyengine_api/services/v2/stored_reports/transformations.py b/policyengine_api/services/v2/stored_reports/transformations.py index 9cad0a5b8..358dd28f1 100644 --- a/policyengine_api/services/v2/stored_reports/transformations.py +++ b/policyengine_api/services/v2/stored_reports/transformations.py @@ -47,11 +47,6 @@ StoredReportRows, ) -# The kinds of report the report routes read: every kind there is. -READABLE_KINDS: frozenset[ReportKind] = frozenset( - {"economy", "household", "budget_window"} -) - def report_kind(report: Report) -> ReportKind: """Return what the report compares, from the columns its kind sets.""" @@ -253,16 +248,11 @@ def budget_window_read( def listed_reports( rows: Iterable[StoredReportRows], *, report_ids: Sequence[UUID] ) -> list[StoredReportRows]: - """Return the reports among ``rows`` these routes read, as named. + """Return the reports among ``rows`` in the order of ``report_ids``. - They are in the order of ``report_ids``. A report named twice is listed - once, where it is first named. + A report named twice is listed once, where it is first named. """ - by_id = { - stored.report.id: stored - for stored in rows - if report_kind(stored.report) in READABLE_KINDS - } + by_id = {stored.report.id: stored for stored in rows} listed: list[StoredReportRows] = [] for report_id in dict.fromkeys(report_ids): stored = by_id.get(report_id) diff --git a/policyengine_api/services/v2/stored_reports/validators.py b/policyengine_api/services/v2/stored_reports/validators.py index 5c506da22..60194338b 100644 --- a/policyengine_api/services/v2/stored_reports/validators.py +++ b/policyengine_api/services/v2/stored_reports/validators.py @@ -7,27 +7,15 @@ from policyengine_api.services.v2.stored_reports.economy_results.types import ( EconomyResultUnreadableError, ) -from policyengine_api.services.v2.stored_reports.transformations import ( - READABLE_KINDS, - report_kind, -) if TYPE_CHECKING: from uuid import UUID from policyengine_api.data.v2.models import ReportRun from policyengine_api.services.v2.stored_reports.types import ( - ReportKind, StoredReportRows, ) -# Every kind's name, so a kind left out of READABLE_KINDS is still named. -KIND_NAMES: dict[ReportKind, str] = { - "economy": "an economy report", - "household": "a household report", - "budget_window": "a budget window", -} - class StoredReportNotFoundError(LookupError): """No report has this ID in the requested country.""" @@ -56,14 +44,6 @@ def __init__(self, *, report_id: UUID, country_id: str) -> None: self.country_id = country_id -class ReportKindUnsupportedError(ValueError): - """The report is of a kind these routes do not read yet. - - Every kind of report is read now; a kind added before its reads would be - refused with this. - """ - - def require_report( rows: StoredReportRows | None, *, report_id: UUID, country_id: str ) -> StoredReportRows: @@ -74,21 +54,6 @@ def require_report( return rows -def require_readable_report(rows: StoredReportRows) -> ReportKind: - """Return the report's kind: an economy report, a household report or a window. - - Raise ReportKindUnsupportedError for a kind outside READABLE_KINDS. - """ - kind = report_kind(rows.report) - if kind not in READABLE_KINDS: - msg = ( - f"Report {rows.report.id} is {KIND_NAMES[kind]}, which this route " - "cannot read yet" - ) - raise ReportKindUnsupportedError(msg) - return kind - - def require_reform_run(report_run: ReportRun) -> UUID: """Return the reform simulation run an economy report run compares. diff --git a/tests/unit/v2/test_household_report_reads.py b/tests/unit/v2/test_household_report_reads.py index dd1291e8c..c5b42f8e4 100644 --- a/tests/unit/v2/test_household_report_reads.py +++ b/tests/unit/v2/test_household_report_reads.py @@ -10,7 +10,7 @@ from dataclasses import dataclass from datetime import UTC, datetime, timedelta -from typing import TYPE_CHECKING, Any, get_args +from typing import TYPE_CHECKING, Any from uuid import UUID, uuid4 import pytest @@ -54,11 +54,9 @@ from policyengine_api.services.v2.stored_reports.types import ( HouseholdReportRead, HouseholdReportSummaryRead, - ReportKind, ReportSummaryRead, ) from policyengine_api.services.v2.stored_reports.validators import ( - KIND_NAMES, CreatedReportUnreadableError, StoredReportNotFoundError, ) @@ -666,7 +664,3 @@ def test_a_created_household_report_that_cannot_be_read_back_is_named( assert unreadable.value.country_id == "us" assert isinstance(unreadable.value.__cause__, StoredReportNotFoundError) - - -def test_every_report_kind_has_a_name_for_the_unsupported_kind_message() -> None: - assert set(KIND_NAMES) == set(get_args(ReportKind)) diff --git a/tests/unit/v2/test_report_read_routes.py b/tests/unit/v2/test_report_read_routes.py index 13c579538..7df9bdce4 100644 --- a/tests/unit/v2/test_report_read_routes.py +++ b/tests/unit/v2/test_report_read_routes.py @@ -12,7 +12,6 @@ EconomyResultUnreadableError, ) from policyengine_api.services.v2.stored_reports.validators import ( - ReportKindUnsupportedError, ReportStorageError, StoredReportNotFoundError, ) @@ -92,11 +91,6 @@ def test_a_report_without_a_run_answers_with_no_run_and_no_result() -> None: 404, "Report x was not found in country 'us'", ), - ( - ReportKindUnsupportedError("Report x is a budget window"), - 422, - "Report x is a budget window", - ), ( EconomyResultUnreadableError("the budget summary has no household_tax row"), 500, From b6c26f2604cdd4dbf2fa94bed898a99c1760d495 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:16:53 +0400 Subject: [PATCH 22/25] Test that a household report takes and sends no add_missing_variables (failing) Co-Authored-By: Claude Opus 5.5 --- tests/integration/test_v2_household_reports.py | 6 ++---- .../libs/test_simulation_household_report_requests.py | 3 --- tests/unit/v2/test_household_report_reads.py | 8 +++----- tests/unit/v2/test_household_report_routes.py | 8 ++++---- tests/unit/v2/test_household_report_submissions.py | 8 +++----- 5 files changed, 12 insertions(+), 21 deletions(-) diff --git a/tests/integration/test_v2_household_reports.py b/tests/integration/test_v2_household_reports.py index e87b9edf6..ec9709bb9 100644 --- a/tests/integration/test_v2_household_reports.py +++ b/tests/integration/test_v2_household_reports.py @@ -264,7 +264,7 @@ def store_household_report( reform_simulation_id=reform_id, household_id=household_id, year=2026, - inputs=json.dumps({"add_missing_variables": True}), + inputs=json.dumps({}), ) catalog.report_ids.append(report_id) report_run_id = uuid4() @@ -389,7 +389,6 @@ def household_body(household_id: UUID, policy_id: UUID) -> dict[str, Any]: "household_id": str(household_id), "baseline": {}, "reform": {"policy_id": str(policy_id)}, - "add_missing_variables": True, } @@ -409,7 +408,7 @@ def test_a_household_report_answers_both_calculated_households( item = response.json()["result"]["item"] assert item["kind"] == "household" assert item["household_id"] == str(household_id) - assert item["options"] == {"add_missing_variables": True} + assert item["options"] == {} assert item["status"] == "succeeded" assert item["run"]["id"] == str(stored.report_run_id) assert item["reform_simulation"]["policy_id"] == str(catalog.policy_id) @@ -551,7 +550,6 @@ def test_a_household_only_country_creates_a_household_report( assert item["result"] is None (sent,) = simulation_service.household_reports assert sent.household_id == household_id - assert sent.add_missing_variables is True def test_a_household_the_country_does_not_store_is_not_sent( diff --git a/tests/unit/libs/test_simulation_household_report_requests.py b/tests/unit/libs/test_simulation_household_report_requests.py index c9f3c8b2d..500151c0f 100644 --- a/tests/unit/libs/test_simulation_household_report_requests.py +++ b/tests/unit/libs/test_simulation_household_report_requests.py @@ -77,7 +77,6 @@ def test_a_household_report_is_posted_and_the_created_report_returned() -> None: "household_id": str(HOUSEHOLD_ID), "baseline": {"policy_id": None}, "reform": {"policy_id": str(POLICY_ID)}, - "add_missing_variables": False, } @@ -85,7 +84,6 @@ def test_a_report_of_the_baseline_alone_sends_a_null_reform() -> None: body = household_report_input( baseline={"policy_id": str(POLICY_ID)}, reform=None, - add_missing_variables=True, ) assert household_report_request(body) == { @@ -96,7 +94,6 @@ def test_a_report_of_the_baseline_alone_sends_a_null_reform() -> None: "household_id": str(HOUSEHOLD_ID), "baseline": {"policy_id": str(POLICY_ID)}, "reform": None, - "add_missing_variables": True, } diff --git a/tests/unit/v2/test_household_report_reads.py b/tests/unit/v2/test_household_report_reads.py index c5b42f8e4..0771be0e3 100644 --- a/tests/unit/v2/test_household_report_reads.py +++ b/tests/unit/v2/test_household_report_reads.py @@ -235,7 +235,7 @@ def simulation(policy_id: UUID | None) -> Simulation: household_id=household.id, policy_id=None if reform is None else policy.id, year=2026, - inputs={"add_missing_variables": False} if inputs is None else inputs, + inputs={} if inputs is None else inputs, created_at=STORED_AT, ) session.add(report) @@ -318,9 +318,7 @@ def test_a_succeeded_household_report_reads_both_calculated_households( sessions: sessionmaker[Session], reports: V2StoredReportService ) -> None: with sessions() as session: - stored = store_household_report( - session, run=succeeded_run(), inputs={"add_missing_variables": True} - ) + stored = store_household_report(session, run=succeeded_run(), inputs={}) report = reports.get_report(country_id="us", report_id=stored.report_id) @@ -335,7 +333,7 @@ def test_a_succeeded_household_report_reads_both_calculated_households( "Doubles the credit", 2026, ) - assert report.options == {"add_missing_variables": True} + assert report.options == {} assert report.baseline_simulation is not None assert report.baseline_simulation.id == stored.baseline_simulation_id assert report.baseline_simulation.policy_id is None diff --git a/tests/unit/v2/test_household_report_routes.py b/tests/unit/v2/test_household_report_routes.py index f613f16f1..e569e0898 100644 --- a/tests/unit/v2/test_household_report_routes.py +++ b/tests/unit/v2/test_household_report_routes.py @@ -66,7 +66,7 @@ def household_summary_fields(**changes: Any) -> dict[str, Any]: "reform_simulation": ReportSimulationRead( id=REFORM_SIMULATION_ID, policy_id=POLICY_ID, dynamic_id=None ), - "options": {"add_missing_variables": True}, + "options": {}, "created_at": CREATED_AT, "status": "succeeded", "run": report_run_read(), @@ -106,7 +106,6 @@ def household_body(**changes: Any) -> dict[str, Any]: "household_id": str(HOUSEHOLD_ID), "baseline": {}, "reform": {"policy_id": str(POLICY_ID)}, - "add_missing_variables": True, } return {**values, **changes} @@ -128,7 +127,7 @@ def test_a_household_report_is_created_and_answered_as_stored() -> None: assert item["household_id"] == str(HOUSEHOLD_ID) assert item["status"] == "pending" assert item["result"] is None - assert item["options"] == {"add_missing_variables": True} + assert item["options"] == {} ((name, arguments),) = service.calls assert name == "create_report" report_input = arguments["report_input"] @@ -163,7 +162,8 @@ def test_a_household_report_of_the_baseline_alone_can_be_created() -> None: pytest.param({"region_code": "us"}, id="economy-field"), pytest.param({"include_cliffs": True}, id="economy-option"), pytest.param({"kind": "budget"}, id="unknown-kind"), - pytest.param({"add_missing_variables": "yes"}, id="option-not-a-boolean"), + # A household report always adds every variable the household lacks. + pytest.param({"add_missing_variables": True}, id="add-missing-variables"), ], ) def test_a_household_report_outside_its_schema_is_refused_with_422( diff --git a/tests/unit/v2/test_household_report_submissions.py b/tests/unit/v2/test_household_report_submissions.py index d26f54e8b..f54ead890 100644 --- a/tests/unit/v2/test_household_report_submissions.py +++ b/tests/unit/v2/test_household_report_submissions.py @@ -144,9 +144,7 @@ def test_a_household_report_is_sent_once_its_country_household_and_policies_pass client = RecordingHouseholdReportService() households = StoredHouseholds() policies = StoredPolicies() - report = household_report_input( - baseline={"policy_id": str(STORED_POLICY_ID)}, add_missing_variables=True - ) + report = household_report_input(baseline={"policy_id": str(STORED_POLICY_ID)}) submitted = household_submissions( lambda: client, households=households, policies=policies @@ -316,7 +314,6 @@ def test_a_household_report_without_a_reform_is_of_the_baseline_alone() -> None: ) assert report.reform is None - assert report.add_missing_variables is False assert report.description is None @@ -326,7 +323,8 @@ def test_a_household_report_without_a_reform_is_of_the_baseline_alone() -> None: pytest.param({"label": ""}, id="empty-label"), # The report is for the household's default year, so it names none. pytest.param({"year": 2026}, id="year"), - pytest.param({"add_missing_variables": "true"}, id="option-not-a-boolean"), + # A household report always adds every variable the household lacks. + pytest.param({"add_missing_variables": True}, id="add-missing-variables"), pytest.param({"baseline": {"dynamic_id": str(uuid4())}}, id="dynamic"), pytest.param({"region_code": "us"}, id="region"), pytest.param({"household_id": "not-a-uuid"}, id="household-not-a-uuid"), From ed504633a348e3f095edeb0387f4465765afc8bb Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:17:46 +0400 Subject: [PATCH 23/25] Take no add_missing_variables on household reports The user decided household reports always add every variable the household lacks, since their caller needs every value; the simulation service now refuses the field. The request body and the body sent to its /reports no longer have it, and reads find nothing about it in options. Household calculations keep their option. Co-Authored-By: Claude Opus 5.5 --- changelog.d/v2-household-reports.added.md | 2 +- policyengine_api/libs/simulation_reports.py | 7 +++---- .../services/v2/simulation_service/types.py | 10 +++++----- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/changelog.d/v2-household-reports.added.md b/changelog.d/v2-household-reports.added.md index a5461411c..0b042893a 100644 --- a/changelog.d/v2-household-reports.added.md +++ b/changelog.d/v2-household-reports.added.md @@ -1 +1 @@ -`/v2/reports` serves household reports: `POST /v2/reports` with `kind` `household` takes a stored household, a baseline policy and an optional reform policy (null for a report of the baseline alone), and `add_missing_variables`, and has the simulation service create the report and its household simulations (one for each policy), which calculate the household's default year. It answers 422 for a country without household calculations (a household-only country has them) and for a household with no default year, and 404 for a household the country does not store, before asking the simulation service. Reading or listing a household report answers it with its newest run; a read adds each simulation's calculated household once that run has succeeded, and each side's calculation error once it has failed. +`/v2/reports` serves household reports: `POST /v2/reports` with `kind` `household` takes a stored household, a baseline policy and an optional reform policy (null for a report of the baseline alone), and has the simulation service create the report and its household simulations (one for each policy), which calculate the household's default year with every variable the household lacks added. It answers 422 for a country without household calculations (a household-only country has them) and for a household with no default year, and 404 for a household the country does not store, before asking the simulation service. Reading or listing a household report answers it with its newest run; a read adds each simulation's calculated household once that run has succeeded, and each side's calculation error once it has failed. diff --git a/policyengine_api/libs/simulation_reports.py b/policyengine_api/libs/simulation_reports.py index e45633e9a..7b93efe21 100644 --- a/policyengine_api/libs/simulation_reports.py +++ b/policyengine_api/libs/simulation_reports.py @@ -20,9 +20,9 @@ ``budget_window_size`` in place of the year, and no ``include_cliffs``. A household report goes there too, as ``report_type`` ``household``, naming the label and description, the stored household, each simulation's policy -(the reform null for a report of the baseline alone) and -``add_missing_variables``, but no year: the simulation service calculates -the household's default year. None names a PolicyEngine.py version: the +(the reform null for a report of the baseline alone), but no year: the +simulation service calculates the household's default year, adding every +variable the household lacks. None names a PolicyEngine.py version: the simulation service runs its default worker's. The simulation service answers: @@ -301,7 +301,6 @@ def household_report_request(report: HouseholdReportCreationInput) -> dict[str, "reform": ( None if report.reform is None else _household_policy_choice(report.reform) ), - "add_missing_variables": report.add_missing_variables, } diff --git a/policyengine_api/services/v2/simulation_service/types.py b/policyengine_api/services/v2/simulation_service/types.py index 7a3686e3a..917479664 100644 --- a/policyengine_api/services/v2/simulation_service/types.py +++ b/policyengine_api/services/v2/simulation_service/types.py @@ -246,10 +246,11 @@ class HouseholdReportFields(StrictSubmissionModel): ``household_id`` names a household stored through ``/v2/households`` in the report's country, and both simulations calculate it in its default year, the year its values name, as a household calculation does. - ``reform`` is null, or omitted, for a report of the baseline alone. With - ``add_missing_variables``, each simulation also calculates every yearly, - monthly and eternal variable the household lacks, as ``/calculate-full`` - does; otherwise it fills in only the values the household asks for. + ``reform`` is null, or omitted, for a report of the baseline alone. Each + simulation calculates every yearly, monthly and eternal variable the + household lacks, as ``/calculate-full`` does, so the report gives every + value; there is no option to fill in only the values the household asks + for, as ``/v2/household-calculations`` has. """ kind: Literal["household"] @@ -258,7 +259,6 @@ class HouseholdReportFields(StrictSubmissionModel): household_id: ResourceId baseline: HouseholdPolicyChoice reform: HouseholdPolicyChoice | None = None - add_missing_variables: StrictBool = False class HouseholdReportCreationInput(HouseholdReportFields): From 659c74799b10906d45f9e329fd25191ec8859c29 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:23:12 +0400 Subject: [PATCH 24/25] Drop the removed case's ID from the report read error test Co-Authored-By: Claude Opus 5.5 --- tests/unit/v2/test_report_read_routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/v2/test_report_read_routes.py b/tests/unit/v2/test_report_read_routes.py index 7df9bdce4..53f2a0678 100644 --- a/tests/unit/v2/test_report_read_routes.py +++ b/tests/unit/v2/test_report_read_routes.py @@ -103,7 +103,7 @@ def test_a_report_without_a_run_answers_with_no_run_and_no_result() -> None: ), (RuntimeError("unexpected"), 500, "V2 report operation failed"), ], - ids=["not-found", "other-kind", "unreadable-result", "storage", "unexpected"], + ids=["not-found", "unreadable-result", "storage", "unexpected"], ) def test_report_read_failures_answer_typed_errors( error: Exception, status_code: int, message: str From 4d61d9aef1f77368cc64ab221453ecee115996cd Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:25:12 +0400 Subject: [PATCH 25/25] Drop the read tests' unused options argument Co-Authored-By: Claude Opus 5.5 --- tests/unit/v2/test_household_report_reads.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/unit/v2/test_household_report_reads.py b/tests/unit/v2/test_household_report_reads.py index 0771be0e3..1647d81e0 100644 --- a/tests/unit/v2/test_household_report_reads.py +++ b/tests/unit/v2/test_household_report_reads.py @@ -180,7 +180,6 @@ def store_household_report( country: str = "us", with_reform: bool = True, run: RunToStore | None = None, - inputs: dict[str, Any] | None = None, ) -> StoredHouseholdReport: """Store a household report as the simulation service and its functions do. @@ -235,7 +234,7 @@ def simulation(policy_id: UUID | None) -> Simulation: household_id=household.id, policy_id=None if reform is None else policy.id, year=2026, - inputs={} if inputs is None else inputs, + inputs={}, created_at=STORED_AT, ) session.add(report) @@ -318,7 +317,7 @@ def test_a_succeeded_household_report_reads_both_calculated_households( sessions: sessionmaker[Session], reports: V2StoredReportService ) -> None: with sessions() as session: - stored = store_household_report(session, run=succeeded_run(), inputs={}) + stored = store_household_report(session, run=succeeded_run()) report = reports.get_report(country_id="us", report_id=stored.report_id)