Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4896e4b
Test checking and sending household reports to the simulation service…
anth-volk Nov 12, 2026
0f5f027
Check household reports and send them to the simulation service's /re…
anth-volk Nov 12, 2026
e91a3c5
Test reading household reports and their calculated households (failing)
anth-volk Nov 12, 2026
9154798
Test creating, reading and listing household reports at /v2/reports (…
anth-volk Nov 12, 2026
bbc40ba
Read, list and create household reports at /v2/reports
anth-volk Nov 12, 2026
e800fe1
Test household reports on migrated PostgreSQL as the runtime role, an…
anth-volk Nov 12, 2026
4f948b5
Run the household report integration tests in the cross-database check
anth-volk Nov 12, 2026
8f3afae
Describe household reports in the changelog and the migration contracts
anth-volk Nov 12, 2026
6afd8f2
Test that a household report is for its household's default year (fai…
anth-volk Nov 12, 2026
68456e9
Calculate a household report for its household's default year
anth-volk Nov 12, 2026
c2481eb
Test that a failed household report answers each side's failure (fail…
anth-volk Nov 12, 2026
1190597
Answer each side's failure for a failed household report run
anth-volk Nov 12, 2026
4060fc1
Test review round 1's findings: the shared household check, no region…
anth-volk Nov 12, 2026
c87b7c9
Fix review round 1's quality findings for household reports
anth-volk Nov 12, 2026
ff010ff
Test review round 2's findings: each side's failure schema, household…
anth-volk Nov 12, 2026
3ae0bea
Fix review round 2's quality findings for household reports
anth-volk Nov 12, 2026
9eda21a
Test review round 3's findings: a report's own side failure, every ki…
anth-volk Nov 12, 2026
bcc00a6
Fix review round 3's quality findings for household reports
anth-volk Nov 12, 2026
c40ae01
Fix review round 4's findings: name the report failure read, and stor…
anth-volk Nov 12, 2026
4e608c0
Merge master into v2plan/c1-16-household-reports
anth-volk Nov 12, 2026
7a8a328
List the household report input among ruff's runtime-evaluated model …
anth-volk Nov 12, 2026
c295d6c
Merge master, with budget-window reports, into household reports
anth-volk Nov 12, 2026
e4b6283
Remove the refusal of report kinds /v2/reports could not read yet
anth-volk Nov 12, 2026
b6c26f2
Test that a household report takes and sends no add_missing_variables…
anth-volk Nov 12, 2026
ed50463
Take no add_missing_variables on household reports
anth-volk Nov 12, 2026
659c747
Drop the removed case's ID from the report read error test
anth-volk Nov 12, 2026
4d61d9a
Drop the read tests' unused options argument
anth-volk Nov 12, 2026
bd3dcf8
Merge master into v2plan/c1-16-household-reports
anth-volk Nov 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/v2-integration-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 tests/integration/test_v2_saved_budget_window_reports.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
Expand Down
1 change: 1 addition & 0 deletions changelog.d/v2-household-reports.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +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 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.
4 changes: 2 additions & 2 deletions docs/engineering/migration-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down
3 changes: 2 additions & 1 deletion docs/generated/migration_contracts.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down Expand Up @@ -1417,6 +1417,7 @@
"status",
"message",
"result.item.id",
"result.item.kind",
"result.item.status",
"result.item.run",
"result.item.current_versions",
Expand Down
1 change: 1 addition & 0 deletions policyengine_api/fastapi_routes/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
31 changes: 18 additions & 13 deletions policyengine_api/fastapi_routes/resource_service_protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,10 +193,10 @@ class V2StoredReportResourceService(Protocol):
def get_report(self, *, country_id: str, report_id: UUID) -> StoredReportRead:
"""Return the country's report, its newest run and that run's result.

The report is an economy report or a budget window. Raise
``StoredReportNotFoundError`` when the country has no such report,
``ReportKindUnsupportedError`` for a household report, and
``EconomyResultUnreadableError`` or
The report is an economy report, a household report or a budget
window. Raise ``StoredReportNotFoundError`` when the country has no
such report, and ``EconomyResultUnreadableError``,
``HouseholdResultUnreadableError`` or
``BudgetWindowResultUnreadableError`` when a succeeded run's result
cannot be read.
"""
Expand All @@ -205,20 +205,25 @@ def get_report(self, *, country_id: str, report_id: UUID) -> StoredReportRead:
def list_reports(
self, *, country_id: str, report_ids: Sequence[UUID]
) -> tuple[StoredReportSummaryRead, ...]:
"""Return the country's economy reports and budget windows, as named."""
"""Return the country's reports of every kind among those named, in order."""
...

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,
``HouseholdCalculationCountryError`` for a household report in a
country without household calculations, ``HouseholdNotFoundError``
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
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.
"""
...

Expand Down
15 changes: 14 additions & 1 deletion policyengine_api/fastapi_routes/v2/reports/request_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from policyengine_api.services.v2.simulation_service.types import (
BudgetWindowReportCreationInput,
EconomyReportCreationInput,
HouseholdReportCreationInput,
)


Expand All @@ -19,6 +20,16 @@ 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 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.
"""


class BudgetWindowReportCreateRequest(BudgetWindowReportCreationInput):
"""A budget window to create; ``country_id`` must match the query's.

Expand All @@ -31,6 +42,8 @@ class BudgetWindowReportCreateRequest(BudgetWindowReportCreationInput):

# A report to create: ``kind`` says which.
ReportCreateRequest = Annotated[
EconomyReportCreateRequest | BudgetWindowReportCreateRequest,
EconomyReportCreateRequest
| HouseholdReportCreateRequest
| BudgetWindowReportCreateRequest,
Field(discriminator="kind"),
]
146 changes: 126 additions & 20 deletions policyengine_api/fastapi_routes/v2/reports/response_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
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,
)
Expand All @@ -26,6 +29,8 @@
from policyengine_api.services.v2.stored_reports.types import (
BudgetWindowReportRead,
BudgetWindowReportSummaryRead,
HouseholdReportRead,
HouseholdReportSummaryRead,
ReportRead,
ReportSummaryRead,
StoredReportRead,
Expand Down Expand Up @@ -75,31 +80,39 @@ 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.
"""

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:
Expand All @@ -122,6 +135,85 @@ def from_read(cls, read: ReportRead) -> ReportItem:
return cls.model_validate(read)


class HouseholdReportSummaryItem(ReportDefinitionItem):
"""A household report: one stored household under a baseline and a reform.

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.
"""

kind: Literal["household"]
household_id: UUID
status: Literal["not_run", "pending", "running", "succeeded", "failed"]
run: ReportRunItem | None

@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 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.

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: HouseholdReportSideFailureItem | None
reform: HouseholdReportSideFailureItem | None


class HouseholdReportItem(HouseholdReportSummaryItem):
"""A household report with its newest run's outcome.

``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:
"""Build the item from the service's read, with its result."""
return cls.model_validate(read)


class BudgetWindowYearItem(ReportRunItem):
"""One year of a budget-window run: the report run calculating that year.

Expand Down Expand Up @@ -214,17 +306,23 @@ def from_read(cls, read: BudgetWindowReportRead) -> BudgetWindowReportItem:
return cls.model_validate(read)


def report_item(read: StoredReportRead) -> ReportItem | BudgetWindowReportItem:
def report_item(
read: StoredReportRead,
) -> ReportItem | HouseholdReportItem | BudgetWindowReportItem:
"""Build the item of the report's kind from the service's read."""
if read.kind == "household":
return HouseholdReportItem.from_read(read)
if read.kind == "budget_window":
return BudgetWindowReportItem.from_read(read)
return ReportItem.from_read(read)


def report_summary_item(
read: StoredReportSummaryRead,
) -> ReportSummaryItem | BudgetWindowReportSummaryItem:
) -> ReportSummaryItem | HouseholdReportSummaryItem | BudgetWindowReportSummaryItem:
"""Build the summary of the report's kind from the service's read."""
if read.kind == "household":
return HouseholdReportSummaryItem.from_summary(read)
if read.kind == "budget_window":
return BudgetWindowReportSummaryItem.from_summary(read)
return ReportSummaryItem.from_summary(read)
Expand All @@ -233,15 +331,20 @@ def report_summary_item(
class ReportDetailResult(StrictReportAPIModel):
"""Result holding one report in ``item``; its ``kind`` says which model it is."""

item: Annotated[ReportItem | BudgetWindowReportItem, Field(discriminator="kind")]
item: Annotated[
ReportItem | HouseholdReportItem | BudgetWindowReportItem,
Field(discriminator="kind"),
]


class ReportListResult(StrictReportAPIModel):
"""The listed reports in ``items``, in the order named, without results."""

items: list[
Annotated[
ReportSummaryItem | BudgetWindowReportSummaryItem,
ReportSummaryItem
| HouseholdReportSummaryItem
| BudgetWindowReportSummaryItem,
Field(discriminator="kind"),
]
]
Expand Down Expand Up @@ -294,28 +397,31 @@ 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, which this route does not read yet."
),
422: error_response("The request does not match the schema."),
500: error_response(
"The newest run succeeded but its stored result cannot be read (for a "
"household report, a simulation run's calculated household; for a "
"budget window, a year has no succeeded run or no budget figures), 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 (such as a budget window of "
"fewer than 1 or more than 75 years, or one ending after 2200); the "
"country has no economy "
"reports (such as a household-only country); a policy the report "
"names sets parameters the running catalog version does not have "
"country has no economy reports (such as a household-only country), "
"for an economy report or budget window, 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 "
"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 "
Expand Down
Loading
Loading