Skip to content

Serve budget windows through /v2/reports - #4290

Draft
anth-volk wants to merge 15 commits into
v2plan/c1-11-refuse-missing-parametersfrom
v2plan/c1-14-budget-window-reports
Draft

anth-volk wants to merge 15 commits into
v2plan/c1-11-refuse-missing-parametersfrom
v2plan/c1-14-budget-window-reports

Conversation

@anth-volk

@anth-volk anth-volk commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #4289

Summary

Budget windows in the detailed-save API's /v2/reports. A budget window compares a baseline and a reform simulation in each year of a window of 1 to 75 consecutive years, ending in 2200 or earlier. Its row has reports.budget_window_size and reports.year (the first year). Each calculation of it is a budget_window_runs row, and each year of that calculation a report_runs row naming the window run and its year.

Stacked on #4286, which stacks on #4281. The base is v2plan/c1-11-refuse-missing-parameters; retarget as the bases merge.

  • Read (GET /v2/reports/{report_id}): a budget window answers kind budget_window, its window (year, budget_window_size) and its newest budget-window run (created last, ties going to the larger ID). The run has its status, trigger, recalculate, versions and times, and its years in year order, each a report run with its own status, versions and error. Only once that run has succeeded does the read build result, in today's budget-window format. Each year's figures come from its budget_summary rows, read as an economy result's budget is, with that year's SPM receipts. The totals are summed as exact decimals, as the old simulation gateway's budget_window_results.py sums them. A succeeded window with a year missing, not succeeded or without budget figures answers 500, "The report's stored result cannot be read".
  • List (GET /v2/reports?report_id=…): budget windows are listed beside economy reports, with their newest runs and years but no results, in two queries. Household reports are still left out.
  • Never reads the figures: reading a window's definition and runs, listing and saving never read its result rows. So a window whose result cannot be read can still be listed and saved, and re-run once re-runs exist.
  • Create (POST /v2/reports): the body is told apart by kind. A budget window takes the first year and budget_window_size, and no include_cliffs, since cliff impacts are not calculated for a window. It is checked as an economy report is: the country has economy calculations, and each named policy is stored with every parameter it sets. It is then sent to the simulation service's POST /reports as report_type budget_window. The created answer's report_id and budget_window_run_id are read, and the window is read back from the tables. Refusals and failures are answered as an economy report's: the same 422, 502 and 503 rules, including the report_id of a window the service created and then failed.
  • Saved reports (/v2/user-reports): a saved report naming a detailed-save report must give that report's own window as budget_window_years: the same number for a window of two or more years, and null otherwise, since a saved report stores a one-year window as null. Anything else is 400, on create and on any update naming report_id or budget_window_years. A saved v1 report keeps the window it is given.
  • Shared code changed in the base PRs' modules:
    • The economy result rebuild's _budget and _spm_provenance are now budget_impact and stored_spm_provenance, unchanged, so the window rebuild reuses them.
    • read_report_rows also joins each report's newest budget-window run and reads its years.
    • require_economy_report became require_readable_report, which refuses only household reports.
    • listed_economy_reports became listed_reports.
    • The economy request model is now EconomyReportCreateRequest, and ReportCreateRequest is the union of both kinds.

Needed from the simulation service

The simulation service's budget-window POST /reports (C1 · 13) is not built yet; its session confirmed this shape. This PR sends:

{country, report_type: "budget_window", label, description, year, budget_window_size, region_code, dataset_name, baseline{policy_id, dynamic_id}, reform{policy_id, dynamic_id}, spm (only when given)}

It reads a 201 or 202 with report_id and budget_window_run_id (other fields are ignored), and every error answer as for an economy report (see #4281). A request with include_cliffs is refused by sim with 422. The rows it expects:

  • the report, type economy, with year (the first year) and budget_window_size, its inputs {"include_cliffs": false} plus spm when given, and the first year's two simulations;
  • one budget_window_runs row;
  • a pending report run per year, naming budget_window_run_id and year, all on one country package and PolicyEngine.py version. A window run's versions are read from its first year;
  • each year's own simulation runs (new ones under that year's simulations, or reused succeeded runs), which the Public API reads from each year's report run, never assuming they belong to the report's two simulations.

Until sim's /reports is deployed, a budget-window create answers 503, as an economy create does. Once the economy route is deployed but not the budget-window one, sim refuses report_type budget_window with its 422, which is answered as 422.

Route contracts

No route, workflow or stable response field changed, so the migration contracts regenerate unchanged. GET /v2/reports/{report_id}, the listing and POST /v2/reports gain the budget-window variant of their item and body, told apart by kind.

CI

This PR is the third of a stack (#4281, then #4286). The full pr.yml run started on it. If a later run skips jobs because the PR is stacked, the full run starts once it is the lowest unmerged PR.

v2-integration-check.yml gains three test files in two existing steps. No new environment variable or secret: the steps use the job's literal throwaway database values.

Testing

Each new or changed test was run by node ID, against PostgreSQL 17 as the managed runtime role for the integration tests:

  • tests/unit/v2/test_budget_window_result_rebuild.py;
  • tests/unit/v2/test_budget_window_report_read_routes.py;
  • tests/unit/v2/test_budget_window_report_create_routes.py;
  • tests/unit/v2/test_budget_window_submissions.py;
  • tests/unit/libs/test_simulation_budget_window_reports.py;
  • tests/unit/v2/test_saved_budget_window_reports.py;
  • tests/integration/test_v2_budget_window_report_reads.py;
  • tests/integration/test_v2_budget_window_report_creation.py;
  • tests/integration/test_v2_saved_budget_window_reports.py;
  • the changed tests/integration/test_v2_report_reads.py::test_listing_reads_named_reports_in_the_order_asked and tests/unit/test_alembic_workflows.py::test_reusable_v2_integration_check_uses_disposable_databases_and_coverage.

Each failing test was committed first and seen failing for the expected reason.

Checks: alembic -c alembic-v2.ini check (no operations), lint-imports, python -m scripts.rules validate, scripts/run_quality_guards.py, ruff, ruff format and mypy. The only file changed under policyengine_api/data/v2/ is a docstring in models/simulations.py. Explicit mypy on it reports the same 4 sa_type=enum_type(...) errors as on master.

The economy report and simulation submissions now share the client's body building and created-answer reading with budget windows. The old and new clients were compared over 760 cases of status, answer body and input, with no difference in what they send or answer. Moving the economy fields into the shared ReportDefinitionFields changes only the OpenAPI field order: include_cliffs now follows spm.

Review

Three read-only review rounds found no substantiated bug. Their quality findings are fixed, each code change test-first:

  • Round 1:
    • shared bases for the two kinds' fields, request bodies, created answers and reads;
    • a typed year status;
    • OpenAPI text for a window's year and the saved-report 400;
    • tests of listing and saving a succeeded window whose result cannot be read, and of the listing's two queries.
  • Round 2:
    • the window's 400 is documented only on POST and PATCH /v2/user-reports, so other user routes no longer show it;
    • the schema docstrings say each year has its own simulations;
    • the test helpers store each later year under its own simulations.
  • Round 3: test-helper names and constants, and wording.

One finding is left as is: a saved-report write reads the reports row twice, by primary key in one transaction. #4281's tests patch the first read to simulate a deleted report, and merging the reads would change those tests.

The branch merged #4286's update (master's admin user profiles) with no conflicts.

🤖 Generated with Claude Code

anth-volk and others added 15 commits November 12, 2026 21:14
…failing)

A budget-window report's calculation is a budget_window_runs row and one
report run per year. GET /v2/reports/{id} and the report listing should
answer the newest window run with its years, and, once that run has
succeeded, the window's result in today's budget-window format, built from
each year's budget rows. The listing test now expects budget windows
beside economy reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /v2/reports/{id} now reads a budget window: the report row whose
budget_window_size is set, its newest budget_window_runs row (created
last, ties going to the larger ID) and that run's report runs, one per
year. Once the window run has succeeded, the result is today's
budget-window format: each year's budget figures, read from its
budget_summary rows as an economy result's budget is, with its SPM
receipts, and the totals summed as exact decimals. A succeeded window
whose years cannot give the result answers 500. Reading the definition
and runs never reads the figures.

The listing answers budget windows beside economy reports, with their
newest runs and years, in two queries; household reports are still left
out. Both kinds are told apart by kind in the OpenAPI document.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ailing)

POST /v2/reports should take a budget window as kind budget_window, with
the first year and budget_window_size (1 to 75 years, ending by 2200),
and no include_cliffs. It is checked as an economy report is, then sent
to the simulation service's POST /reports as report_type budget_window,
whose created answer names the report and its budget-window run, and is
read back from the tables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /v2/reports takes kind budget_window beside kind economy, told apart
by kind in the OpenAPI document. A budget window names its first year
and budget_window_size (1 to 75 years, ending in 2200 or earlier) and
takes no include_cliffs, since cliff impacts are not calculated for a
window. It is checked as an economy report is (economy calculations in
the country, each policy stored with every parameter it sets), then sent
to the simulation service's POST /reports as report_type budget_window.
Its created answer names the report and its budget-window run; refusals
and failures are answered as a report's. The window is then read back
from the tables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing)

A detailed-save report stores its own window in budget_window_size, so a
saved report naming one should give the same budget_window_years, or none
for a report without a window of two or more years, on create and on
update; a disagreeing one is refused as contradicting references are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A saved report naming a detailed-save report now gives that report's own
budget window as budget_window_years, read from budget_window_size in the
write's transaction: the same number for a window of two or more years,
and none otherwise, since a saved report stores a one-year window as
none. A disagreeing create or update is refused with 400, as
contradicting references are. An update naming neither the report nor
the window is not checked, so older records can still be renamed. A v1
saved report keeps its own window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The report step runs the budget-window read and creation tests, and the
saved-record step the saved budget-window tests, against the job's
disposable PostgreSQL as the managed roles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rules

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed answers (failing)

Review round 1's test findings: a succeeded window whose years stored no
result rows is still listed and saved, since neither reads its figures;
the listing reads any number of windows in two queries; a saved report
whose window disagrees with its report answers 400 through the route.
The two failing tests expect the OpenAPI document to describe a window's
year as its first year, and the saved-report 400 to name the window
disagreement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Economy reports and budget windows share ReportDefinitionFields, so
  their common fields and the spm check are defined once; a window's year
  is described as its first year in the OpenAPI document.
- The report client builds both kinds' bodies from one definition, and
  reads every created answer in one place; its answers are unchanged.
- Both summary reads share ReportDefinitionRead.
- A window year's status is a ReportRunStatus, and the totals are summed
  through typed figure getters rather than field names.
- The saved-record 400 text names the budget window disagreement, and
  the docstrings say that a window's years each name their own
  simulation runs, that a window run can be running, and that the report
  service creates windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…window reports

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 2: the shared user-route 400 text named budget_window_years
on every user route, though only POST and PATCH /v2/user-reports can
refuse a saved report's window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…simulations

- The shared user-route 400 text is as before. POST and PATCH
  /v2/user-reports, the only routes that can refuse a saved report's
  budget window, carry a 400 entry of their own that names it, and the
  create's description says what the window must be.
- The SimulationRun docstring and the alembic-migrations skill no longer
  say a budget window compares the report's same two simulations in every
  year: the simulation service gives each year simulations of that year,
  or reuses succeeded runs, and the report's own simulations are its
  first year's. ReportDefinitionFields says the same.
- The integration helpers store each later window year under simulations
  of its own year, as the simulation service does, and the window read
  test checks each year names its own simulation runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The integration helpers' store_year_simulations, named for the rows it
  stores, compares a year with the stored report's own year; store_report
  takes the year, and the helpers' 2026 defaults come from one constant.
  The creation stand-in stores the window's submitted first year.
- The window read test's comment says what its assertion checks.
- The saved-report write 400 builds on the shared user-route text, and
  the create's description says what a request must give.
- The SimulationRun docstring opens with what a run calculates.

No behavior changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.28097% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.17%. Comparing base (6906a28) to head (9c10921).

Files with missing lines Patch % Lines
...ces/v2/stored_reports/database_connectors/reads.py 81.25% 0 Missing and 3 partials ⚠️
..._api/services/v2/stored_reports/transformations.py 85.71% 2 Missing and 1 partial ⚠️
...database_connectors/reads_budget_window_results.py 81.81% 0 Missing and 2 partials ⚠️
...d_reports/budget_window_results/transformations.py 97.72% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@                            Coverage Diff                             @@
##           v2plan/c1-11-refuse-missing-parameters    #4290      +/-   ##
==========================================================================
+ Coverage                                   93.12%   93.17%   +0.05%     
==========================================================================
  Files                                         357      360       +3     
  Lines                                       20480    20762     +282     
  Branches                                     3302     3343      +41     
==========================================================================
+ Hits                                        19072    19345     +273     
- Misses                                        838      840       +2     
- Partials                                      570      577       +7     
Flag Coverage Δ
integration 56.37% <85.49%> (+0.47%) ⬆️
unit 90.54% <84.89%> (-0.09%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

This branch was successfully deployed

1 active deployment
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant