diff --git a/.github/workflows/v2-integration-check.yml b/.github/workflows/v2-integration-check.yml index d7781e417..f30c17d6f 100644 --- a/.github/workflows/v2-integration-check.yml +++ b/.github/workflows/v2-integration-check.yml @@ -14,6 +14,7 @@ jobs: services: mysql: image: mysql:8.4 + # Disposable job-owned credentials, matching the v1 lifecycle target. env: MYSQL_ROOT_PASSWORD: policyengine_test MYSQL_DATABASE: policyengine_alembic_test @@ -73,9 +74,9 @@ jobs: - name: Test v2 metadata publication and resource routes run: uv run coverage run -a --branch -m pytest -q tests/integration/test_v2_catalog_publication.py tests/integration/test_v2_metadata_routes.py - name: Test v2 policy persistence and immediate v1 mirroring - run: uv run coverage run -a --branch -m pytest -q tests/integration/test_v2_policy_persistence.py tests/integration/test_v1_policy_dual_write.py tests/integration/test_v2_user_policy_mirroring.py tests/integration/test_v1_user_policy_dual_write.py + run: uv run coverage run -a --branch -m pytest -q tests/integration/test_v2_policy_persistence.py tests/integration/test_v1_policy_dual_write.py tests/integration/test_mysql_policy_dual_write.py tests/integration/test_v2_user_policy_mirroring.py tests/integration/test_v1_user_policy_dual_write.py - name: Test v2 household persistence and immediate v1 copying - run: uv run coverage run -a --branch -m pytest -q tests/integration/test_v2_household_persistence.py tests/integration/test_v1_household_dual_write.py + run: uv run 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 - name: Qualify production-scale v2 metadata publication run: uv run coverage run -a --branch -m pytest -q tests/integration/test_v2_catalog_publication_qualification.py env: diff --git a/changelog.d/canonical-spm-bundle-capability.fixed.md b/changelog.d/canonical-spm-bundle-capability.fixed.md new file mode 100644 index 000000000..349011c70 --- /dev/null +++ b/changelog.d/canonical-spm-bundle-capability.fixed.md @@ -0,0 +1 @@ +Decide whether a bundle predates the canonical SPM contract from the installed country model's capability rather than an allowlist of bundle version strings, so an automated bundle bump no longer rejects every US request, and report an uncertified canonical bundle through the readiness probe instead of only on each request. diff --git a/changelog.d/canonical-spm-contract-consistency.fixed.md b/changelog.d/canonical-spm-contract-consistency.fixed.md new file mode 100644 index 000000000..8c0ae755c --- /dev/null +++ b/changelog.d/canonical-spm-contract-consistency.fixed.md @@ -0,0 +1 @@ +Report every SPM failure with the offending field and reason rather than validator internals, whichever of a request, a bundle manifest, a worker capability or a country receipt failed; answer a country package installed without its Simulation with a typed configuration failure instead of an internal error, so the readiness probe reports not-ready rather than raising; reject an spm key on a country that has no SPM settings as unsupported rather than as a correctable null; resolve the capability probe's package by the repository's own country-package map and read any failure to load that package as a model without the canonical constructor rather than an internal error; and bring the served specification and the canonical SPM contract document back in line with what an omitted selection now does. diff --git a/changelog.d/canonical-spm-inherited-defaults.fixed.md b/changelog.d/canonical-spm-inherited-defaults.fixed.md new file mode 100644 index 000000000..910e6dd79 --- /dev/null +++ b/changelog.d/canonical-spm-inherited-defaults.fixed.md @@ -0,0 +1 @@ +Treat an omitted SPM selection as an inherited default rather than a chosen measurement, so a certified bundle no longer rejects a calculation or a stored household replay that never asked for one: its SPM-dependent variables come back null while the rest of the calculation succeeds, a household saved without a selection stores none and keeps its hash, and an explicit null selection is rejected instead of quietly inheriting the defaults. diff --git a/changelog.d/canonical-spm-receipt-identity.fixed.md b/changelog.d/canonical-spm-receipt-identity.fixed.md new file mode 100644 index 000000000..7fa5b543b --- /dev/null +++ b/changelog.d/canonical-spm-receipt-identity.fixed.md @@ -0,0 +1 @@ +Match a canonical SPM receipt against a resolved selection by its settings rather than its exact JSON, so stored household replay hits the computed-household cache when the country omits null values, and report an unreadable country receipt as a typed configuration failure instead of an internal error. diff --git a/changelog.d/canonical-spm-request-contract.added.md b/changelog.d/canonical-spm-request-contract.added.md new file mode 100644 index 000000000..af85b1946 --- /dev/null +++ b/changelog.d/canonical-spm-request-contract.added.md @@ -0,0 +1 @@ +Add certified canonical SPM settings, structured household input errors, stored selection replay, and cached calculation provenance. Expose metadata capability for an explicit app geography choice and reject unsupported canonical worker execution until its coordinated release is certified. Preserve the currently pinned legacy model without promoting dependency versions. diff --git a/changelog.d/canonical-spm-review-fixes.fixed.md b/changelog.d/canonical-spm-review-fixes.fixed.md new file mode 100644 index 000000000..8e501d929 --- /dev/null +++ b/changelog.d/canonical-spm-review-fixes.fixed.md @@ -0,0 +1,3 @@ +Parse explicit SPM selections on economy HTTP requests, report unsupported measurement +years only when SPM executes, replay failed jobs and historical padded simulation IDs, +validate cached household and worker receipts, and document economy/metadata SPM contracts. diff --git a/changelog.d/canonical-spm-terminal-errors.fixed.md b/changelog.d/canonical-spm-terminal-errors.fixed.md new file mode 100644 index 000000000..def90281b --- /dev/null +++ b/changelog.d/canonical-spm-terminal-errors.fixed.md @@ -0,0 +1,7 @@ +Persist typed canonical SPM worker failures for annual and budget-window requests, +including invalid segmented results, so later polls replay the same code and +message after API service recreation without contacting a vanished job. Preserve +canonical cache identity and existing cache lifetime and runtime refresh rules. +Preserve Stage 11 immutability for all stored households, including historical +households without saved SPM settings; changed inputs, labels or settings require +a new replacement household. Use a public worker PR documentation link. diff --git a/changelog.d/canonical-spm-terminal-stored-results.fixed.md b/changelog.d/canonical-spm-terminal-stored-results.fixed.md new file mode 100644 index 000000000..aacebf1d5 --- /dev/null +++ b/changelog.d/canonical-spm-terminal-stored-results.fixed.md @@ -0,0 +1 @@ +Record a terminal SPM failure when a stored economy result cannot be certified, so polling reaches an end instead of re-deriving the same rejection from the same payload, report a household's saved artifact hash that this deployment lacks as a configuration failure rather than a caller error, and keep validator internals out of SPM error messages. diff --git a/changelog.d/household-population-identity.fixed.md b/changelog.d/household-population-identity.fixed.md new file mode 100644 index 000000000..2416588d7 --- /dev/null +++ b/changelog.d/household-population-identity.fixed.md @@ -0,0 +1,4 @@ +Use strict US numeric household identity consistently for simulation replay, +and comparison report snapshots, preserving historical +saved spellings. Document simulation persistence requests and responses in the +served OpenAPI specification. diff --git a/changelog.d/persisted-policy-mirror-identity.fixed.md b/changelog.d/persisted-policy-mirror-identity.fixed.md new file mode 100644 index 000000000..9b7bb54d2 --- /dev/null +++ b/changelog.d/persisted-policy-mirror-identity.fixed.md @@ -0,0 +1 @@ +Qualify mirrored policy identity against real MySQL JSON storage and PostgreSQL retries, retaining distinct native numeric inputs and rejecting conflicting historical mappings without rewriting them. diff --git a/changelog.d/simulation-household-gap-lock.fixed.md b/changelog.d/simulation-household-gap-lock.fixed.md new file mode 100644 index 000000000..5ac3f2b8c --- /dev/null +++ b/changelog.d/simulation-household-gap-lock.fixed.md @@ -0,0 +1 @@ +Stop taking a locking read on the linked household row when creating a simulation: households are immutable, so the lock serialized against a mutation path that does not exist while a locking read of an absent numeric id took an InnoDB gap lock that briefly blocked unrelated household inserts. diff --git a/docs/canonical-spm.md b/docs/canonical-spm.md new file mode 100644 index 000000000..43a96ceee --- /dev/null +++ b/docs/canonical-spm.md @@ -0,0 +1,281 @@ +# Canonical SPM household API contract + +This contract is enabled only by an installed, certified US bundle that pins its +SPM forecast hash/scenario and supports the country `spm` constructor. This change +does not select a new released model or promote a deployment. + +Whether a bundle predates this contract is decided by the installed country +model's capability, never by a bundle version string; the automated bundle update +moves those strings for unrelated countries and patch releases, and an allowlist +of known versions would turn a routine release into a rejection of every US +request. A bundle whose US model does not implement the `spm` constructor retains +its existing behavior when settings are omitted and rejects explicit SPM settings +with `SPM_SETTINGS_UNSUPPORTED`, whatever its version. A bundle whose US model +does implement the constructor but ships no certified `measurements.spm` +configuration fails closed with `SPM_CONFIGURATION_UNAVAILABLE`: such a +deployment also fails `/readiness-check`, so the condition is reported where the +release is gated rather than only on each request. Other countries retain their +behavior and reject US-only SPM settings. + +## Selecting a measurement + +`GET /us/metadata` exposes `result.spm.available`. When true, `settings_schema` +is the public Pydantic schema and `defaults` contains the certified selection. +Clients must offer an explicit local/national choice before running SPM-dependent +household calculations. The web app requires that choice when support is +available. An omitted API choice does not infer national geography. + +The `spm` object accepts only these fields: + +| Field | Meaning | +| --- | --- | +| `forecast_content_sha256` | Optional expected hash; if supplied must equal the bundle's independently pinned artifact. | +| `scenario` | A scenario in that artifact; omission uses the certified default. | +| `geography_kind` | `county` (default), `national`, or `metro`. | +| `geography_id` | Required for `metro`; forbidden for `county` and `national`. | +| `county_vintage` | `"2020"`. | +| `as_of` | Optional information-date constraint accepted by the artifact. | + +Unknown fields and invalid selections are rejected. Only the household's +`county_fips` input identifies a local county. Computed county, state, first county +in state and congressional district do not identify an SPM area. The full +canonical artifact supplies 2022–2035 measurements; no consumer CPI extrapolation +or congressional-district geographic factors are introduced. + +`POST /us/calculate` and `/us/calculate-full` take settings beside the existing +household and policy objects: + +```json +{ + "household": { + "people": {"you": {"age": {"2026": 40}}}, + "households": {"household": {"members": ["you"], "state_code": {"2026": "CA"}}}, + "spm_units": {"unit": {"members": ["you"], "spm_unit_spm_threshold": {"2026": null}}} + }, + "policy": {}, + "spm": {"geography_kind": "national"} +} +``` + +National is an explicit request, not a fallback for a missing county. A tax-only +request does not need geography or composition solely to construct a simulation +or read its provenance. SPM-dependent requests validate the required primitives +when the country calculates them. `/calculate-full` and stored household replay +request the full output set, which includes SPM dependencies. + +### Choosing a measurement, or not + +A measurement is chosen by a request that sends `spm`, or by the household whose +saved selection a replay reads. Omitting `spm` inherits the certified bundle's +defaults to construct the simulation but chooses nothing, and the two cases +differ in exactly one way: + +- **A chosen measurement whose primitives are missing is a request error.** No + county and no explicit geography, or no classified SPM adult, returns the typed + 400 below rather than a result with holes in it. The caller asked for this + measurement, so the API does not quietly decline to compute it. +- **An inherited default is not a choice, so its dependants are merely + unavailable.** Variables that need the missing primitive come back null, the + way any other variable the model cannot compute does, and the rest of the + calculation is returned normally with HTTP 200. An axes request spells that + null as a correctly sized array of nulls beside a response warning, as it does + for every other variable it cannot calculate. + +Certifying a bundle therefore never makes an existing or newly created household +uncalculable. A state-only household saved before certification, with no saved +`spm`, still replays under any policy through +`GET /us/household/{id}/policy/{policy_id}` and through `/calculate-full`; its +SPM-dependent variables are null. This is the commitment clients may rely on: a +missing SPM primitive never turns a request that chose nothing into a 400. + +The certified boundary itself is the exception, and it is deliberate. A bundle +whose measurement configuration this build cannot resolve — no certified +configuration for a model that implements the constructor, a manifest this API +cannot read, or an artifact that will not load — fails every US calculation with +a configuration code, whatever the caller sent, and also fails +`/readiness-check`: it is meant to stop a deployment rather than to be met on a +request. `GET /us/metadata` still answers, reporting `spm.available` false. + +A country receipt this API cannot read in full is the same class of failure and +carries the same code, but it is found only on a calculation that produced a +receipt: readiness resolves settings and never constructs a simulation, so it +cannot report that one ahead of the requests. Whichever of them fails, the +message names the offending field and reason; no SPM message quotes validator +internals or their documentation URL, wherever the settings came from. + +`POST /us/household` stores a selection only when the caller sent one. A +household created without `spm` keeps the household hash, the stored JSON and the +`GET /us/household/{id}` response shape it would have had before certification, +and its replay chooses nothing. Certification is still validated on that request, +so a bundle this build cannot serve is rejected; only the resolved defaults it +would have produced are left unwritten, because storing them would record a +choice the caller never made and make the household's own replay assert it. + +An explicit `"spm": null` is not an omission. `POST /us/household`, +`/us/calculate` and `/us/calculate-full` reject it with `SPM_SETTINGS_INVALID`, +matching the v2 document validator and the simulation routes; omit the field to +inherit the certified defaults. On any other country the same routes reject an +`spm` key at all, null included, with `SPM_SETTINGS_UNSUPPORTED`, as +`POST /{country}/simulation` already did: there is no shape of it to correct. + +Tax-only calculations can use periods outside the artifact's measurement years, +including a valid metro selection, without generating SPM receipts. When an SPM +dependency actually executes for an unsupported year, a request that chose the +measurement returns a structured failure with the calculator's typed +`SPM_YEAR_UNAVAILABLE` code; a request that chose nothing leaves that year's +dependants null like any other missing primitive, under the rule above. The API +preserves typed input errors and does not reclassify unrelated or untyped +`ValueError`s. + +## Storage, replay and responses + +`POST /us/household` accepts `spm` beside `data`. +`GET /us/household/{id}` returns it in `result.spm`, beside `household_json`. +When the caller sends one, the database stores the resolved selection atomically +in the existing household JSON and includes it in the household hash; a request +that sends none stores none, as above. Settings are removed from the entity +input object before calculation. All stored households are immutable, including +historical households without saved `spm` and households without a simulation. +`PUT /us/household/{id}` is unsupported and returns HTTP 405. Changing inputs, +labels or an SPM selection requires creating a new household. Clients should send +the saved `spm` when creating a replacement that keeps the same measurement. +The original household, simulations and reports retain their inputs, selection, +hash and model version. + +When Stage11 household dual writes are selected, the same source transaction +retains a create event containing the household's stored selection, complete and +resolved when the caller sent one and absent when it did not. The v2 translation +preserves that saved selection in `household_data.spm` and includes it in +canonical household identity. It does not resolve the selection against +current bundle defaults. Historical documents without `spm` retain their +existing shape and identity. Household inputs and SPM settings use existing JSON +storage; no additional schema migration is required. + +`GET /us/household/{id}/policy/{policy_id}` replays the stored selection under the +selected policy. No independent simulation-level override is supported. Top-level +`spm` on simulation POST/PATCH is rejected instead of silently ignored. + +Successful canonical calculations add `spm_config` and `spm_provenance` beside +`result`. A receipt describes the measurement the simulation was constructed +with, not a guarantee that every SPM-dependent variable produced a value: a +calculation that never chose a measurement still carries the inherited one's +receipt beside its null cells. Read the values to learn which of them a +measurement produced. Provenance comes from the actual simulation and includes artifact, +scenario, years, geography, composition/storage methods and runtime versions. +It remains in JSON form through stored replay and cache hits. A tax-only receipt +may have empty `years` and `geographies` because no SPM measurement was requested. +Clients saving simulation outputs must retain this full response envelope. + +`POST /us/simulation` takes `population_id`, `population_type` (`household` or +`geography`) and integer `policy_id`. It creates a pending record (HTTP 201) or +returns an existing record with its saved status/output (HTTP 200); it does not +run a calculation. Household simulations use the linked household's selection. + +US household references consisting entirely of ASCII digits share one numeric +identity across simulation lookup and comparison report linkage. For example, historical `"00001"` and new `"1"` can form a +comparison report, while each saved simulation and report input keeps its own +spelling. Suffixes, decimals, signs, whitespace and Unicode digits do not create +numeric aliases. Household immutability applies regardless of simulation linkage, +country, certification, or whether the household has saved `spm`. + +`PATCH /us/simulation` identifies the record with body `id` and accepts `status` +(`pending`, `complete` or `error`), `output` and `error_message`. At least one +update field must be non-null, and `complete` requires non-null `output`. Store +the full calculation envelope inside `output`, including its `spm_config` and +`spm_provenance`. A JSON-encoded output string is also accepted. This endpoint +stores the supplied output; it does not validate SPM receipt integrity. Null +update fields are ignored. The legacy `api_version` input is ignored; writes +record the installed country model version. Top-level `spm`, even null, returns +HTTP 400 `SPM_SETTINGS_UNSUPPORTED` on POST and PATCH. + +POST, PATCH and `GET /us/simulation/{id}` return the simulation record inside +`result`. Non-string JSON `output` and `simulation_spec_json` are returned as +JSON-encoded strings, or null when absent. Stored scalar strings are returned +unchanged. Decode a saved household `output` envelope once to recover the +calculation result and SPM receipts. `active_run_id` identifies a pending/running +run and becomes null when +inactive; `latest_successful_run_id` identifies the latest successful run. These +run fields and specification metadata may be null on historical records. The +served `/specification` documents these request and response shapes through both +Flask and the native specification route. + +HTTP response cache identity includes the normalized selection and model/bundle +versions, and validates certification before reading the cache. The stored +calculated-household cache uses schema version 2, includes the selection in +identity, and stores settings/provenance atomically with the result and its +warnings. Missing or mismatched canonical receipts are cache misses. + +## Errors + +SPM input failures return HTTP 400 in the existing validation envelope: + +```json +{ + "status": "error", + "message": "A county, explicit area, or national selection is required", + "result": null, + "errors": [{"code": "SPM_GEOGRAPHY_REQUIRED", "message": "A county, explicit area, or national selection is required"}] +} +``` + +`SPM_GEOGRAPHY_REQUIRED` indicates missing explicit geography for an SPM +dependency; `SPM_GEOGRAPHY_UNAVAILABLE` indicates malformed/unknown county or area; +`SPM_COMPOSITION_REQUIRED` indicates no classified SPM adult. Country error text +is retained. `SPM_YEAR_UNAVAILABLE` indicates an unsupported measurement year. +Settings errors use `SPM_SETTINGS_INVALID`, +`SPM_SETTINGS_UNSUPPORTED`, or `SPM_CONFIGURATION_UNAVAILABLE`. + +A stored household whose saved artifact hash is not the installed one returns +`SPM_CONFIGURATION_UNAVAILABLE` on replay, not `SPM_SETTINGS_INVALID`: the saved +selection is that household's identity rather than something this caller got +wrong, and the deployment is what cannot serve it. The same mismatch sent in a +request is still `SPM_SETTINGS_INVALID`. Error messages never quote validator +internals; a rejected selection reports the offending field and reason only. + +## Economy worker selection + +Both `GET /us/economy/{policy_id}/over/{baseline_policy_id}` and its +`/budget-window` counterpart accept `spm` as one URL-encoded JSON object in the +query string. For example, before URL encoding: + +```text +/us/economy/123/over/456?region=us&time_period=2026&spm={"geography_kind":"national"} +/us/economy/123/over/456/budget-window?region=us&start_year=2026&window_size=2&spm={"geography_kind":"national"} +``` + +Use the client's query-encoding support to encode that JSON once. All query +parameters are scalar: repeated keys, unknown parameters, malformed JSON and +duplicate fields inside the SPM object return HTTP 400. `region` is required; +annual requests require `time_period`, while budget-window requests require +`start_year` and `window_size` (1–75, ending no later than 2099). Years use four +digits. Optional fields are `dataset` (default `default`), `version` (installed +country model version), `target` (`general`, or annual-only `cliff`), and the +deprecated no-op boolean `include_district_breakdowns`. Omitted `spm` inherits +certified bundle defaults. The served `/specification` generates these query +declarations from the same typed models as the Flask parser. + +Before cache access or submission, +the API validates the selected worker application's `canonical-spm-v1` capability +from `/versions`, including agreement with the bundle's independently pinned +artifact hash. An old or uncertified worker returns `SPM_CONFIGURATION_UNAVAILABLE`. + +Normalized settings are sent to both annual and budget-window jobs and participate +in economy cache identity. Results carry `spm_config` and `spm_provenance` with +`baseline` and `reform` receipt lists (one per executed regional segment). Each +budget-window annual row carries these fields; annual results expose them inside +`result`, and budget-window results inside `result.annualImpacts`. Stored results require matching +settings and valid receipts. Typed SPM input errors remain structured 400s +through asynchronous submission and polling. Poll-time typed failures retain +their code and message for the existing cache lifetime. Later reads replay that +failure after API service restarts without polling or resubmitting the failed +job. Canonical cache identity and runtime-bundle refresh rules still apply. + +See the [canonical SPM worker PR](https://github.com/PolicyEngine/policyengine-sim-api/pull/677) +for the implemented worker paths and remaining coordinated release gates. + + +Partial selections preserve omitted fields in JSON; omissions inherit the certified +bundle defaults. The selection schema deliberately supplies no wire defaults, +so generated clients preserve that distinction. Resolved settings freeze all six +fields. Responses may omit null geography_id/as_of values, but must include every +non-null resolved field; receipt replay never substitutes current defaults. diff --git a/docs/engineering/skills/testing.md b/docs/engineering/skills/testing.md index 5a93ac4bd..d64c1c126 100644 --- a/docs/engineering/skills/testing.md +++ b/docs/engineering/skills/testing.md @@ -85,10 +85,10 @@ RUNTIME_CACHE_TEST_URL="redis://127.0.0.1:6379/0" uv run pytest tests/integratio ``` The real Redis-compatible suite must be an explicit integration run and cover -two independent connections, TTL expiry, atomic calculated-household results -and warnings, bounded lookup indexes, completed-result miss semantics, and -token-safe coordination claims. Unit tests use the deterministic in-memory -fake and do not require network credentials. +two independent connections, TTL expiry, atomic calculated-household results, +warnings and SPM receipts, bounded lookup indexes, completed-result miss +semantics, and token-safe coordination claims. Unit tests use the deterministic +in-memory fake and do not require network credentials. Startup, deployment, SQLite-removal, and unchanged API migration contracts: @@ -195,27 +195,29 @@ uv run pytest \ Use reviewed local MySQL and disposable PostgreSQL targets for the lifecycle, persistence, and cross-database transaction tests. The v1 side of each -cross-database test must use MySQL rather than SQLite: +cross-database test must use MySQL rather than SQLite. Run these files +sequentially because they share the disposable databases; the lifecycle tests +prepare both schemas before the persistence tests: ```bash ALEMBIC_DATABASE_URL="mysql+pymysql://.../policyengine_alembic_test" \ V2_ALEMBIC_DISPOSABLE_TEST=1 \ V2_MIGRATION_DATABASE_URL="postgresql+psycopg://.../policyengine_v2_alembic_test" \ uv run pytest \ + tests/integration/test_alembic_mysql_lifecycle.py \ tests/integration/test_alembic_v2_lifecycle.py \ tests/integration/test_v2_policy_persistence.py \ tests/integration/test_v1_policy_dual_write.py \ + tests/integration/test_mysql_policy_dual_write.py \ tests/integration/test_v2_user_policy_mirroring.py \ tests/integration/test_v1_user_policy_dual_write.py -q ``` -Continue to run the isolated v1 MySQL lifecycle and compatibility suite because -the cross-database tests assume an upgraded v1 schema, while Phase 10 must also -preserve every v1 read and response contract: +Continue to run the v1 compatibility suite because Phase 10 must also preserve +every v1 read and response contract: ```bash uv run pytest \ - tests/integration/test_alembic_mysql_lifecycle.py \ tests/contract/test_v1_route_contracts.py \ tests/unit/services/test_policy_service.py \ tests/unit/services/test_user_policy_service.py \ @@ -280,7 +282,8 @@ uv run --frozen pytest \ tests/integration/test_alembic_mysql_lifecycle.py \ tests/integration/test_alembic_v2_lifecycle.py \ tests/integration/test_v2_household_persistence.py \ - tests/integration/test_v1_household_dual_write.py -q + tests/integration/test_v1_household_dual_write.py \ + tests/integration/test_mysql_household_spm_dual_write.py -q ``` Regenerate migration contracts, run repository checks, and confirm the diff --git a/policyengine_api/country.py b/policyengine_api/country.py index d92102077..1caa0f50f 100644 --- a/policyengine_api/country.py +++ b/policyengine_api/country.py @@ -27,6 +27,13 @@ get_bundle_default_dataset_option, ) from policyengine_api.services.household_calculation_service import CalculationResult +from policyengine_api.spm import ( + SPM_INPUT_ERROR_CODES, + calculation_spm_receipt, + normalize_spm_selection, + spm_error_detail, + spm_metadata, +) logger = logging.getLogger(__name__) @@ -117,6 +124,7 @@ def build_metadata(self): }[self.country_id], basicInputs=self.tax_benefit_system.basic_inputs, modelled_policies=self.tax_benefit_system.modelled_policies, + spm=spm_metadata(self.country_id), version=get_package_version( self.country_package_name.replace("_", "-") ), @@ -421,8 +429,17 @@ def calculate( self, household: dict, reform: Union[dict, None], + spm: dict | None = None, + spm_requested: bool = False, ) -> CalculationResult: - simulation, system = self._create_simulation(household, reform) + """Calculate requested variables, optionally under a chosen measurement. + + `spm_requested` says the measurement was chosen, by this request or by the + household it replays, so a missing SPM primitive is a request error. An + inherited bundle default was not chosen, and a variable that depends on it + stays unavailable the way every other uncomputable variable does. + """ + simulation, system = self._create_simulation(household, reform, spm=spm) household = json.loads(json.dumps(household)) @@ -475,6 +492,16 @@ def calculate( entity_result ) except Exception as error: + detail = spm_error_detail(error) + # A chosen measurement reports its missing primitives. An + # inherited one leaves its dependants unavailable — but only for + # a missing primitive. A configuration failure says this build + # cannot certify the measurement at all, which is never a null + # cell in somebody's results. + if detail is not None and ( + spm_requested or detail["code"] not in SPM_INPUT_ERROR_CODES + ): + raise _record_calculation_failure( household, calculation_warnings, @@ -490,13 +517,16 @@ def calculate( return CalculationResult( household=household, warnings=tuple(calculation_warnings), + **calculation_spm_receipt(simulation), ) def _create_simulation( self, household: dict, reform: Union[dict, None], + spm: dict | None = None, ): + selection = normalize_spm_selection(getattr(self, "country_id", ""), spm) normalized_reform = None if reform: system = self.tax_benefit_system.clone() @@ -510,10 +540,13 @@ def _create_simulation( simulation = self.country_package.Simulation( tax_benefit_system=system, situation=household, + **({"spm": selection} if selection is not None else {}), ) - return simulation, system + return simulation, simulation.tax_benefit_system simulation_kwargs = {"situation": household} + if selection is not None: + simulation_kwargs["spm"] = selection if normalized_reform: simulation_kwargs["reform"] = normalized_reform simulation = self.country_package.Simulation(**simulation_kwargs) diff --git a/policyengine_api/fastapi_routes/v2/households/document_models.py b/policyengine_api/fastapi_routes/v2/households/document_models.py index c2a383cd1..a7ab499a8 100644 --- a/policyengine_api/fastapi_routes/v2/households/document_models.py +++ b/policyengine_api/fastapi_routes/v2/households/document_models.py @@ -2,9 +2,20 @@ from __future__ import annotations -from typing import Annotated, TypeAlias +from typing import Annotated, Any, TypeAlias -from pydantic import BaseModel, ConfigDict, Field, JsonValue, StringConstraints +from pydantic import ( + BaseModel, + ConfigDict, + Field, + JsonValue, + StringConstraints, + model_serializer, + field_validator, +) +from pydantic.json_schema import SkipJsonSchema + +from policyengine_api.spm import SPMSelection DocumentIdentifier = Annotated[ @@ -32,6 +43,11 @@ class HouseholdPersonRecord(HouseholdEntityRecord): PersonRecords = Annotated[list[HouseholdPersonRecord], Field(max_length=1_000)] +def _omit_spm_default(schema: dict[str, Any]) -> None: + # Omission preserves historical identity; explicit null is not a selection. + schema.pop("default", None) + + class USHouseholdDocument(StrictHouseholdDocumentModel): people: PersonRecords household: EntityRecords @@ -39,6 +55,23 @@ class USHouseholdDocument(StrictHouseholdDocumentModel): tax_unit: EntityRecords spm_unit: EntityRecords marital_unit: EntityRecords + spm: SPMSelection | SkipJsonSchema[None] = Field( + default=None, json_schema_extra=_omit_spm_default + ) + + @field_validator("spm", mode="before") + @classmethod + def require_spm_object(cls, value: Any) -> Any: + if value is None: + raise ValueError("spm must be an object when supplied") + return value + + @model_serializer(mode="wrap") + def serialize_document(self, handler: Any) -> dict[str, Any]: + document: dict[str, Any] = handler(self) + if self.spm is None: + document.pop("spm", None) + return document class UKHouseholdDocument(StrictHouseholdDocumentModel): diff --git a/policyengine_api/libs/simulation_entrypoint.py b/policyengine_api/libs/simulation_entrypoint.py index cea087cb9..009d76c57 100644 --- a/policyengine_api/libs/simulation_entrypoint.py +++ b/policyengine_api/libs/simulation_entrypoint.py @@ -20,6 +20,7 @@ REQUEST_ID_HEADER, current_request_id, ) +from policyengine_api.worker_spm import validate_worker_spm, raise_worker_spm_error def _required_base_url(env_name: str) -> str: @@ -143,12 +144,44 @@ def __init__(self, entrypoint: str | None = None): ) def _normalize_submission_payload(self, payload: dict) -> dict: + from policyengine_api.constants import ( + POLICYENGINE_VERSION, + COUNTRY_PACKAGE_VERSIONS, + ) + + country = payload.get("country", "us") + bundle_version = payload.get("policyengine_version") or POLICYENGINE_VERSION + resolved = validate_worker_spm( + country, + payload.get("spm"), + gateway=self, + policyengine_version=bundle_version, + model_version=payload.get("model_version") + or COUNTRY_PACKAGE_VERSIONS.get(country), + ) + if resolved is not None: + if bundle_version == "latest": + from policyengine_api.spm import SPMValidationError + + raise SPMValidationError( + "SPM_SETTINGS_INVALID", + "Canonical SPM execution requires an exact PolicyEngine version", + ) + payload = { + **payload, + "spm": resolved, + "policyengine_version": bundle_version, + } + modal_payload = { key: value for key, value in payload.items() if value is not None } if "model_version" in modal_payload: modal_payload["version"] = modal_payload.pop("model_version") - modal_payload.pop("data_version", None) + if resolved is None: + # Preserve the versioned legacy transport contract. Canonical + # workers accept and validate an explicit data artifact revision. + modal_payload.pop("data_version", None) return modal_payload def run(self, payload: dict) -> ModalSimulationExecution: @@ -178,6 +211,7 @@ def run(self, payload: dict) -> ModalSimulationExecution: f"{self.base_url}/simulate/economy/comparison", json=modal_payload, ) + raise_worker_spm_error(response) response.raise_for_status() data = response.json() @@ -231,6 +265,7 @@ def run_budget_window_batch(self, payload: dict) -> ModalBudgetWindowBatchExecut f"{self.base_url}/simulate/economy/budget-window", json=modal_payload, ) + raise_worker_spm_error(response) response.raise_for_status() data = response.json() @@ -268,6 +303,62 @@ def run_budget_window_batch(self, payload: dict) -> ModalBudgetWindowBatchExecut ) raise + def get_spm_capability(self, country, version=None, *, policyengine_version=None): + from policyengine_api.spm import SPMValidationError + + try: + response = self.client.get(f"{self.base_url}/versions") + response.raise_for_status() + versions = response.json() + if not isinstance(versions, dict): + raise ValueError("Invalid worker versions response") + route_kind = "policyengine" if policyengine_version else country + route_version = policyengine_version or version + routes = versions[route_kind] + if not isinstance(routes, dict): + raise ValueError("Invalid worker route map") + if route_version is None or route_version == "latest": + route_version = routes["latest"] + if not isinstance(route_version, str) or not route_version: + raise ValueError("Invalid worker route version") + app_name = routes[route_version] + if not isinstance(app_name, str) or not app_name: + raise ValueError("Invalid worker application identity") + # Registry bundles and their capabilities are keyed by wrapper + # version. Country-version routes identify only the deployed app; + # resolve its unique bundle instead of treating the app as a key. + if policyengine_version: + bundle_version = route_version + else: + bundle_routes = versions["policyengine"] + if not isinstance(bundle_routes, dict) or any( + not isinstance(bundle, str) + or not bundle + or not isinstance(app, str) + or not app + for bundle, app in bundle_routes.items() + ): + raise ValueError("Invalid worker bundle route map") + candidates = [ + bundle + for bundle, app in bundle_routes.items() + if bundle != "latest" and app == app_name + ] + if len(candidates) != 1: + raise ValueError("Worker route has no unambiguous bundle identity") + bundle_version = candidates[0] + capabilities = versions.get("spm_capabilities") + return ( + capabilities.get(bundle_version) + if isinstance(capabilities, dict) + else None + ) + except (httpx.HTTPError, KeyError, TypeError, ValueError) as exc: + raise SPMValidationError( + "SPM_CONFIGURATION_UNAVAILABLE", + "Cannot validate the selected worker's canonical SPM capability", + ) from exc + def resolve_app_name( self, country: str, @@ -277,6 +368,7 @@ def resolve_app_name( """Resolve the current gateway app name for a country/model version.""" if policyengine_version is not None: response = self.client.get(f"{self.base_url}/versions/policyengine") + raise_worker_spm_error(response) response.raise_for_status() policyengine_version_map = response.json() try: @@ -332,6 +424,7 @@ def get_execution_by_id(self, job_id: str) -> ModalSimulationExecution: """ try: response = self.client.get(f"{self.base_url}/jobs/{job_id}") + raise_worker_spm_error(response) if response.status_code not in (200, 202, 500): response.raise_for_status() data = response.json() @@ -375,6 +468,7 @@ def get_budget_window_batch_by_id( response = self.client.get( f"{self.base_url}/budget-window-jobs/{batch_job_id}" ) + raise_worker_spm_error(response) if response.status_code not in (200, 202, 500): response.raise_for_status() data = response.json() diff --git a/policyengine_api/openapi_spec.yaml b/policyengine_api/openapi_spec.yaml index a03a4d69c..4e0d0f8b2 100644 --- a/policyengine_api/openapi_spec.yaml +++ b/policyengine_api/openapi_spec.yaml @@ -61,6 +61,8 @@ paths: type: object economy_options: type: object + spm: + $ref: '#/components/schemas/SPMDiscovery' current_law_id: type: integer basicInputs: @@ -255,7 +257,20 @@ paths: post: summary: Set a household's input data operationId: post_household - description: Set a household's input data for a given country ID. + description: >- + Set a household's input data for a given country ID. Certified US bundles + accept spm beside data and store the resolved selection with the household + when the caller sends one; a request that sends none stores none and keeps + the household_hash and response shape identical inputs had before the + bundle was certified. An explicit null spm is rejected with + SPM_SETTINGS_INVALID, and any spm on another country with + SPM_SETTINGS_UNSUPPORTED. + Saved household inputs and SPM selections are immutable. Create a new + household to change either; household PUT is not supported. Dual-write + creation preserves the stored selection in the v2 household content and + identity. National SPM requires an explicit selection; county SPM reads + county_fips from household inputs. A state alone does not identify an SPM + area. parameters: - name: country_id in: path @@ -269,12 +284,18 @@ paths: application/json: schema: type: object + required: [data] properties: label: type: string + nullable: true data: type: object + spm: + $ref: '#/components/schemas/SPMSelection' responses: + 400: + $ref: '#/components/responses/SPMValidationError' 201: description: OK content: @@ -345,6 +366,8 @@ paths: type: object household_hash: type: string + spm: + $ref: '#/components/schemas/SPMSelection' label: type: string nullable: true @@ -376,7 +399,10 @@ paths: get: summary: Get a household's output data under a given policy operationId: get_household_under_policy - description: Get a household's output data under a given policy with specified country ID, household ID, and policy ID. + description: >- + Get a household's output data under a given policy. Calculations replay + the household's stored SPM selection and return its actual provenance. + SPM geography/composition errors are structured 400 responses. parameters: - name: country_id in: path @@ -397,6 +423,8 @@ paths: schema: type: string responses: + 400: + $ref: '#/components/responses/SPMValidationError' 200: description: The household data under a given policy. content: @@ -411,6 +439,10 @@ paths: nullable: true result: type: object + spm_config: + $ref: '#/components/schemas/SPMSelection' + spm_provenance: + $ref: '#/components/schemas/SPMProvenance' 404: description: Household or policy not found. content: @@ -445,10 +477,23 @@ paths: message: type: string /{country_id}/calculate: - post: + post: &household_calculation_operation summary: Calculate household and policy without storing data operationId: get_calculate - description: Lightweight endpoint for passing in household and policy JSON objects and calculating without storing data. Specify the country ID. + description: >- + Calculate requested household variables without storing data. Certified + US bundles accept spm beside household and policy. National SPM requires + an explicit selection; county SPM uses household county_fips inputs. + Sending spm chooses that measurement: a missing county, area or classified + SPM adult is then a typed 400. Omitting spm inherits the certified + defaults without choosing them, so a dependency the model cannot compute + is returned null with HTTP 200 instead. Geography/composition errors occur + only when an SPM dependency is needed, so a state-only tax calculation can + succeed either way. Metro selection requires geography_id; national and + county selections forbid geography_id. The selected artifact and scenario + must belong to the certified bundle. An explicit null spm is rejected with + SPM_SETTINGS_INVALID, and any spm on another country with + SPM_SETTINGS_UNSUPPORTED. parameters: - name: country_id in: path @@ -467,7 +512,11 @@ paths: type: object policy: type: object + spm: + $ref: '#/components/schemas/SPMSelection' responses: + 400: + $ref: '#/components/responses/SPMValidationError' 200: description: The calculation result. content: @@ -482,6 +531,10 @@ paths: nullable: true result: type: object + spm_config: + $ref: '#/components/schemas/SPMSelection' + spm_provenance: + $ref: '#/components/schemas/SPMProvenance' 500: description: Error calculating household under policy. content: @@ -504,6 +557,131 @@ paths: type: string message: type: string + /{country_id}/calculate-full: + post: + <<: *household_calculation_operation + operationId: get_calculate_full + summary: Calculate all household variables without storing data + description: >- + Calculate household variables after adding omitted yearly variables. + Accepts the same household, policy, and certified US spm settings as + calculate, and requests the full output set, which includes SPM + dependencies. A request that sends spm needs household county_fips, an + explicit metro area, or an explicit national selection for them; state + alone returns the typed 400. A request that sends none returns HTTP 200 + with those dependencies null, so certifying a bundle never makes an + existing household uncalculable. + /{country_id}/simulation: + post: + summary: Create or retrieve a simulation record + operationId: create_simulation + description: >- + Persist the country, population and policy identity without running a + calculation. Returns 201 for a new pending record and 200 for an existing + record, preserving its saved status and output. Household calculations + inherit the SPM selection saved on the linked household. Select SPM there + before creating the simulation. All stored households have immutable inputs, + labels and selections, including those without saved spm or simulation links. + Changing any of these requires a new household. Household PUT is unsupported. + Top-level spm is rejected, + including null, with HTTP 400 SPM_SETTINGS_UNSUPPORTED. US numeric household aliases match + historical zero-padded IDs; existing records keep their saved spelling. + parameters: + - name: country_id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SimulationCreate' + responses: + 201: + description: New pending simulation record. + content: &simulation_record_content + application/json: + schema: + $ref: '#/components/schemas/SimulationResponse' + 200: + description: Existing simulation record; no new simulation was created. + content: *simulation_record_content + 400: + description: Invalid request or unsupported independent SPM selection. + content: &simulation_error_content + application/json: + schema: + $ref: '#/components/schemas/SimulationError' + patch: + summary: Store simulation output, status or error + operationId: update_simulation + description: >- + Update the record selected by body id within the path country. Supply at + least one non-null status, output or error_message; complete status requires + non-null output. Null update fields are ignored, and omitted fields retain + their values. The installed country model version and current run state + are updated with the record. Preserve the full household calculation + envelope, including spm_config and spm_provenance, inside output. Its SPM + selection belongs to the linked household; this operation does not select + a measurement or validate stored receipt integrity. Top-level spm is + rejected, including null, with HTTP 400 SPM_SETTINGS_UNSUPPORTED. + parameters: + - name: country_id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SimulationUpdate' + responses: + 200: + description: Updated simulation record. + content: *simulation_record_content + 400: + description: Invalid update, no fields to update, or independent SPM selection. + content: *simulation_error_content + 404: + description: The simulation does not exist in the selected country. + content: *simulation_error_content + /{country_id}/simulation/{simulation_id}: + get: + summary: Get a stored simulation record + operationId: get_simulation + description: >- + Read a country-scoped simulation record without running a calculation. + Object and array outputs and simulation_spec_json use the legacy + JSON-encoded string representation, or null when absent. Stored scalar + strings are returned unchanged. Decode a saved household output envelope + to read its spm_config and spm_provenance. Run identifiers and + specification metadata may be null on historical records. + parameters: + - name: country_id + in: path + required: true + schema: + type: string + - name: simulation_id + in: path + required: true + schema: + type: integer + minimum: 1 + responses: + 200: + description: Stored simulation; the response message is null. + content: *simulation_record_content + 400: + description: Invalid country or simulation ID. + content: *simulation_error_content + 404: + description: The simulation does not exist in the selected country. + content: *simulation_error_content /{country_id}/report: post: summary: Create a report output @@ -699,50 +877,10 @@ paths: required: true schema: type: string - - name: region - in: query - description: The sub-national region. - required: false - schema: - type: string - - name: time_period - in: query - description: Reform time period - required: false - schema: - type: string - - name: version - in: query - description: API version number - required: false - schema: - type: string - - name: include_district_breakdowns - in: query - description: Deprecated no-op. Congressional district results are returned automatically for US national and state-level simulations. - deprecated: true - required: false - schema: - type: boolean - default: false + # Query parameters are generated from policyengine_api.query_parameters. responses: 200: - description: Calculating economic impact. - content: - application/json: - schema: - type: object - properties: - status: - type: string - message: - type: string - nullable: true - result: - type: object - nullable: true - 200: - description: Calculation queue position. + description: Annual economic impact, progress, or persisted error state. Canonical completed results include the selected SPM configuration and actual baseline/reform receipts. content: application/json: schema: @@ -750,28 +888,20 @@ paths: properties: status: type: string + enum: [ok, computing, error] message: type: string nullable: true result: type: object nullable: true - average_time: - type: float - 200: - description: The economic impact. - content: - application/json: - schema: - type: object - properties: - status: - type: string - message: - type: string - nullable: true - result: - type: object + properties: + spm_config: + $ref: '#/components/schemas/SPMSelection' + spm_provenance: + $ref: '#/components/schemas/SPMWorkerProvenance' + 400: + $ref: '#/components/responses/SPMValidationError' 404: description: Invalid country ID. content: @@ -807,52 +937,7 @@ paths: required: true schema: type: string - - name: region - in: query - description: The sub-national region. - required: true - schema: - type: string - - name: start_year - in: query - description: First year in the budget window. - required: true - schema: - type: string - - name: window_size - in: query - description: Number of years to include in the budget window. - required: true - schema: - type: integer - - name: dataset - in: query - description: Dataset selection. - required: false - schema: - type: string - default: default - - name: version - in: query - description: API version number. - required: false - schema: - type: string - - name: include_district_breakdowns - in: query - description: Deprecated no-op. Congressional district results are returned automatically for US national and state-level simulations. - deprecated: true - required: false - schema: - type: boolean - default: false - - name: target - in: query - description: Impact target. Budget-window calculations only support general impacts. - required: false - schema: - type: string - default: general + # Query parameters are generated from policyengine_api.query_parameters. responses: 200: description: Budget-window economic impact, progress, or error state. @@ -873,6 +958,21 @@ paths: result: type: object nullable: true + properties: + kind: + type: string + enum: [budgetWindow] + annualImpacts: + type: array + items: + type: object + properties: + year: + type: string + spm_config: + $ref: '#/components/schemas/SPMSelection' + spm_provenance: + $ref: '#/components/schemas/SPMWorkerProvenance' progress: type: integer nullable: true @@ -892,19 +992,7 @@ paths: type: string nullable: true 400: - description: Invalid budget-window request. - content: - application/json: - schema: - type: object - properties: - status: - type: string - message: - type: string - result: - type: object - nullable: true + $ref: '#/components/responses/SPMValidationError' 404: description: Invalid country ID. content: @@ -970,3 +1058,268 @@ paths: type: object servers: type: array +components: + schemas: + SimulationCreate: + type: object + required: [population_id, population_type, policy_id] + not: + required: [spm] + properties: + population_id: + description: Household ID or geography identifier; US numeric household IDs also accept JSON integers. + oneOf: + - type: string + minLength: 1 + - type: integer + population_type: + type: string + enum: [household, geography] + policy_id: + type: integer + SimulationUpdate: + type: object + required: [id] + not: + required: [spm] + description: At least one non-null status, output or error_message is required. Null fields do not clear saved values. + anyOf: + - required: [status] + properties: + status: + not: + enum: [null] + - required: [output] + properties: + output: + not: + enum: [null] + - required: [error_message] + properties: + error_message: + not: + enum: [null] + properties: + id: + type: integer + description: Simulation ID within the path country. + status: + type: string + nullable: true + enum: [pending, complete, error, null] + output: + description: >- + Saved JSON output, or a JSON-encoded string which the service decodes + before storage. For household calculations preserve the complete + StoredHouseholdOutputEnvelope. Legacy outputs can have other JSON + shapes. Null is ignored, and complete status requires non-null output. + x-household-envelope-schema: + $ref: '#/components/schemas/StoredHouseholdOutputEnvelope' + error_message: + type: string + nullable: true + api_version: + deprecated: true + description: Ignored for every supplied JSON value; updates use the installed country model version. + oneOf: + - required: [status, output] + properties: + status: + enum: [complete] + output: + not: + enum: [null] + - not: + required: [status] + properties: + status: + enum: [complete] + SimulationResponse: + type: object + required: [status, message, result] + properties: + status: + type: string + enum: [ok] + message: + type: string + nullable: true + description: Creation/update confirmation; null for GET. + result: + $ref: '#/components/schemas/SimulationRecord' + SimulationRecord: + type: object + required: + - id + - country_id + - api_version + - population_id + - population_type + - policy_id + - status + - output + - error_message + - simulation_spec_json + - simulation_spec_schema_version + - active_run_id + - latest_successful_run_id + properties: + id: + type: integer + country_id: + type: string + api_version: + type: string + description: Country model version recorded on creation or latest update. + population_id: + type: string + description: Saved population spelling, including historical zero-padded household IDs. + population_type: + type: string + enum: [household, geography] + policy_id: + type: integer + status: + type: string + description: Stored calculation state, normally pending, running, complete or error. + output: + type: string + nullable: true + description: >- + Non-string JSON output is JSON-encoded, or null when absent. Stored + scalar strings are returned unchanged. Decode a saved household + calculation envelope once to recover spm_config and spm_provenance + beside its result, not beside this record. + x-household-envelope-schema: + $ref: '#/components/schemas/StoredHouseholdOutputEnvelope' + error_message: + type: string + nullable: true + simulation_spec_json: + type: string + nullable: true + description: JSON-encoded country_id, population_id, population_type and policy_id; null on historical records without specification metadata. + simulation_spec_schema_version: + type: integer + nullable: true + description: Version 1 for the current identity specification; may be null on historical records. + active_run_id: + type: string + format: uuid + nullable: true + description: Current pending/running run ID; null when inactive or absent on a historical record. + latest_successful_run_id: + type: string + format: uuid + nullable: true + description: Most recent successful run ID; null until one exists or on a historical record without run metadata. + StoredHouseholdOutputEnvelope: + type: object + description: >- + Household calculation envelope saved as simulation output. Keep result + and both SPM fields together. Legacy calculations omit SPM fields; this + descriptive schema does not add validation to the persistence endpoint. + properties: + status: + type: string + message: + type: string + nullable: true + result: + type: object + nullable: true + spm_config: + $ref: '#/components/schemas/SPMSelection' + spm_provenance: + $ref: '#/components/schemas/SPMProvenance' + SimulationError: + type: object + required: [status, message, result] + properties: + status: + type: string + enum: [error] + message: + type: string + result: + type: object + nullable: true + errors: + type: array + description: Present for a rejected top-level SPM override. + items: + type: object + required: [code, message] + properties: + code: + type: string + enum: [SPM_SETTINGS_UNSUPPORTED] + message: + type: string + # The public loader replaces these with the public typed model schemas, + # converting optional fields to OpenAPI 3.0 nullable so validation stays aligned. + SPMDiscovery: + type: object + description: SPM availability for this installed country bundle. Available bundles also expose the selection schema and resolved defaults. + required: [available] + properties: + available: + type: boolean + settings_schema: + type: object + description: JSON Schema for SPMSelection, preserving omitted fields. + defaults: + $ref: '#/components/schemas/SPMSelection' + SPMWorkerProvenance: + type: object + additionalProperties: false + required: [baseline, reform] + properties: + baseline: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/SPMProvenance' + reform: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/SPMProvenance' + SPMSelection: + description: Generated from policyengine_api.spm.SPMSelection. + SPMProvenance: + description: Generated from policyengine_api.spm.SPMProvenance. + responses: + SPMValidationError: + description: >- + Household input or settings validation failed. SPM errors always include + result null and an errors array with code/message objects; other validation + errors may omit those fields. SPM household input codes + are SPM_GEOGRAPHY_REQUIRED, SPM_GEOGRAPHY_UNAVAILABLE, + SPM_COMPOSITION_REQUIRED, and SPM_YEAR_UNAVAILABLE. Unsupported or uncertified settings use + SPM_SETTINGS_INVALID, SPM_SETTINGS_UNSUPPORTED, or + SPM_CONFIGURATION_UNAVAILABLE. + content: + application/json: + schema: + type: object + required: [status, message] + properties: + status: + type: string + enum: [error] + message: + type: string + result: + type: object + nullable: true + errors: + type: array + items: + type: object + required: [message] + properties: + code: + type: string + message: + type: string diff --git a/policyengine_api/query_parameters.py b/policyengine_api/query_parameters.py index e0016a326..60af041be 100644 --- a/policyengine_api/query_parameters.py +++ b/policyengine_api/query_parameters.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Iterable, Mapping +import json from types import UnionType from typing import Annotated, Any, Literal, TypeVar, Union, get_args, get_origin from uuid import UUID @@ -13,11 +14,17 @@ BeforeValidator, ConfigDict, Field, + model_validator, ) from policyengine_api.data.v2.catalog.catalog_selection import ( validate_policyengine_version, ) +from policyengine_api.spm import SPMSelection +from policyengine_api.utils.budget_window import ( + BUDGET_WINDOW_MAX_END_YEAR, + BUDGET_WINDOW_MAX_YEARS, +) DEFAULT_QUERY_LIMIT = 100 @@ -91,6 +98,86 @@ class StrictQueryParameters(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) +def _unique_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for name, value in pairs: + if name in result: + raise ValueError(f"SPM field {name!r} must not be repeated") + result[name] = value + return result + + +def parse_spm_query_value(value: Any) -> Any: + """Decode a single JSON object, preserving omissions and rejecting duplicates.""" + if isinstance(value, str): + value = json.loads(value, object_pairs_hook=_unique_json_object) + if not isinstance(value, (dict, SPMSelection)): + raise ValueError("spm must be a JSON object") + return value + + +def parse_integer_query_value(value: Any) -> Any: + """Preserve integer-string parsing; decimal spellings such as 2.0 are invalid.""" + return int(value) if isinstance(value, str) else value + + +EconomyRegion = Annotated[str, Field(min_length=1, description="Sub-national region")] +EconomyYear = Annotated[ + str, Field(pattern=r"^[0-9]{4}$", description="Four-digit calendar year") +] +SPMQuerySelection = Annotated[SPMSelection, BeforeValidator(parse_spm_query_value)] + + +class EconomyQuery(StrictQueryParameters): + """Common legacy economy query options; identifiers remain in the path.""" + + region: EconomyRegion + dataset: str = Field(default="default", description="Dataset selection") + version: str | None = Field( + default=None, + description="Country model version; omission uses the installed version", + ) + include_district_breakdowns: bool = Field( + default=False, + deprecated=True, + description="Deprecated no-op; district results are returned automatically", + ) + spm: SPMQuerySelection | None = Field( + default=None, description="A single JSON-encoded SPM selection object" + ) + + def calculation_options(self) -> dict[str, Any]: + """Return only public computation options, with omissions preserved.""" + return {} if self.spm is None else {"spm": self.spm.model_dump(mode="json")} + + +class AnnualEconomyQuery(EconomyQuery): + """Complete query contract for the annual economy route.""" + + time_period: EconomyYear + target: Literal["general", "cliff"] = "general" + + +class BudgetWindowEconomyQuery(EconomyQuery): + """Complete query contract for the budget-window economy route.""" + + start_year: EconomyYear + window_size: Annotated[int, BeforeValidator(parse_integer_query_value)] = Field( + ge=1, + le=BUDGET_WINDOW_MAX_YEARS, + description="Number of years; start_year + window_size - 1 must not exceed 2099", + ) + target: Literal["general"] = "general" + + @model_validator(mode="after") + def validate_end_year(self) -> BudgetWindowEconomyQuery: + if int(self.start_year) + self.window_size - 1 > BUDGET_WINDOW_MAX_END_YEAR: + raise ValueError( + f"budget-window end_year must be {BUDGET_WINDOW_MAX_END_YEAR} or earlier" + ) + return self + + class CountryQuery(StrictQueryParameters): """Required country selection shared by country-scoped routes.""" diff --git a/policyengine_api/readiness.py b/policyengine_api/readiness.py index 58a15f4d0..dfcdf2325 100644 --- a/policyengine_api/readiness.py +++ b/policyengine_api/readiness.py @@ -15,7 +15,7 @@ def validate_policy_runtime_configuration() -> None: - """Validate v1 source selectors and native v2 persistence settings.""" + """Validate v1 source selectors, v2 persistence and measurement settings.""" from policyengine_api.data.v2.settings import ( load_v2_runtime_database_settings, @@ -26,12 +26,17 @@ def validate_policy_runtime_configuration() -> None: get_v1_policy_read_source, get_v1_policy_write_source, ) + from policyengine_api.spm import normalize_spm_selection get_v1_policy_write_source() get_v1_policy_read_source() get_v1_household_write_source() get_v1_household_read_source() load_v2_runtime_database_settings() + # A bundle whose measurement configuration this build cannot serve would + # reject every country request. Report it here, where the deployment is + # gated, instead of only on the requests themselves. + normalize_spm_selection("us", None) def mark_not_ready() -> None: diff --git a/policyengine_api/routes/economy_routes.py b/policyengine_api/routes/economy_routes.py index 1a4655f93..0adbcb74d 100644 --- a/policyengine_api/routes/economy_routes.py +++ b/policyengine_api/routes/economy_routes.py @@ -1,4 +1,11 @@ from flask import Blueprint, Response, request +from pydantic import ValidationError +from policyengine_api.query_parameters import ( + AnnualEconomyQuery, + BUDGET_WINDOW_MAX_YEARS, + BudgetWindowEconomyQuery, + parse_multidict_query, +) from policyengine_api.services.economy_service import ( EconomyService, EconomicImpactResult, @@ -8,9 +15,13 @@ from policyengine_api.utils import get_current_law_policy_id from policyengine_api.utils.payload_validators import validate_country from policyengine_api.constants import COUNTRY_PACKAGE_VERSIONS +from policyengine_api.spm import ( + SPMValidationError, + readable_validation_error, + spm_error_detail, +) import json from http import HTTPStatus -from typing import Literal economy_bp = Blueprint("economy", __name__) economy_service = EconomyService() @@ -25,8 +36,31 @@ def _json_response(payload: dict, status: int = 200) -> Response: ) -def _bad_request_response(message: str) -> Response: - return _make_error_response(message, 400, result=None) +def _bad_request_response(error: str | ValueError) -> Response: + if isinstance(error, ValidationError): + first = error.errors()[0] + field = first["loc"][0] if first["loc"] else None + message = readable_validation_error(error) + if field == "spm": + error = SPMValidationError("SPM_SETTINGS_INVALID", message) + else: + if first["type"] == "missing": + message = f"Missing required query parameter: {field}" + elif field == "window_size": + message = ( + "window_size must be an integer" + if first["type"] not in {"greater_than_equal", "less_than_equal"} + else f"window_size must be between 1 and {BUDGET_WINDOW_MAX_YEARS}" + ) + elif field == "target": + if error.title == BudgetWindowEconomyQuery.__name__: + message = "Budget-window calculations only support target=general" + elif field is None: + message = first["msg"].removeprefix("Value error, ") + error = message + detail = spm_error_detail(error) if isinstance(error, ValueError) else None + fields = {"errors": [detail]} if detail is not None else {} + return _make_error_response(error, 400, result=None, **fields) @economy_bp.route( @@ -40,35 +74,23 @@ def get_economic_impact(country_id: str, policy_id: int, baseline_policy_id: int baseline_policy_id or get_current_law_policy_id(country_id) ) - # Pop items from query params - query_parameters = request.args - options = dict(query_parameters) - options = json.loads(json.dumps(options)) - region = options.pop("region") - dataset = options.pop("dataset", "default") - time_period = options.pop("time_period") - - # Deprecated no-op retained for older app-v2 callers. - options.pop("include_district_breakdowns", None) - target: Literal["general", "cliff"] = options.pop("target", "general") - api_version = options.pop("version", COUNTRY_PACKAGE_VERSIONS.get(country_id)) - try: + query = parse_multidict_query(AnnualEconomyQuery, request.args) economic_impact_result: EconomicImpactResult = ( economy_service.get_economic_impact( country_id=country_id, policy_id=policy_id, baseline_policy_id=baseline_policy_id, - region=region, - dataset=dataset, - time_period=time_period, - options=options, - api_version=api_version, - target=target, + region=query.region, + dataset=query.dataset, + time_period=query.time_period, + options=query.calculation_options(), + api_version=query.version or COUNTRY_PACKAGE_VERSIONS.get(country_id), + target=query.target, ) ) except ValueError as error: - return _bad_request_response(str(error)) + return _bad_request_response(error) result_dict: dict[str, str | dict | None] = economic_impact_result.to_dict() @@ -101,55 +123,24 @@ def get_budget_window_economic_impact( baseline_policy_id or get_current_law_policy_id(country_id) ) - query_parameters = request.args - options = dict(query_parameters) - options = json.loads(json.dumps(options)) - region = options.pop("region", None) - if not region: - return _bad_request_response("Missing required query parameter: region") - - dataset = options.pop("dataset", "default") - start_year = options.pop("start_year", None) - if not start_year: - return _bad_request_response("Missing required query parameter: start_year") - - window_size_raw = options.pop("window_size", None) - if window_size_raw is None: - return _bad_request_response("Missing required query parameter: window_size") - - try: - window_size = int(window_size_raw) - except (TypeError, ValueError): - return _bad_request_response("window_size must be an integer") - - # Deprecated no-op retained for older app-v2 callers. - options.pop("include_district_breakdowns", None) - - target: Literal["general", "cliff"] = options.pop("target", "general") - if target != "general": - return _bad_request_response( - "Budget-window calculations only support target=general" - ) - - api_version = options.pop("version", COUNTRY_PACKAGE_VERSIONS.get(country_id)) - try: + query = parse_multidict_query(BudgetWindowEconomyQuery, request.args) economic_impact_result: BudgetWindowEconomicImpactResult = ( economy_service.get_budget_window_economic_impact( country_id=country_id, policy_id=policy_id, baseline_policy_id=baseline_policy_id, - region=region, - dataset=dataset, - start_year=start_year, - window_size=window_size, - options=options, - api_version=api_version, - target=target, + region=query.region, + dataset=query.dataset, + start_year=query.start_year, + window_size=query.window_size, + options=query.calculation_options(), + api_version=query.version or COUNTRY_PACKAGE_VERSIONS.get(country_id), + target=query.target, ) ) except ValueError as error: - return _bad_request_response(str(error)) + return _bad_request_response(error) result_dict = economic_impact_result.to_dict() diff --git a/policyengine_api/routes/household_routes.py b/policyengine_api/routes/household_routes.py index 68a59334f..f62d559aa 100644 --- a/policyengine_api/routes/household_routes.py +++ b/policyengine_api/routes/household_routes.py @@ -1,11 +1,13 @@ import json import logging import time +from functools import wraps -from flask import Blueprint, Response, request +from flask import Blueprint, Response, g, request from policyengine_core.errors import SituationParsingError from werkzeug.exceptions import BadRequest, NotFound +from policyengine_api.constants import COUNTRY_PACKAGE_VERSIONS, POLICYENGINE_VERSION from policyengine_api.data.v1_models import Household from policyengine_api.data.v2.catalog.catalog_selection import SUPPORTED_V2_COUNTRY_IDS from policyengine_api.extensions import cache @@ -30,7 +32,12 @@ HouseholdPersistenceError, HouseholdService, ) -from policyengine_api.utils import make_cache_key +from policyengine_api.spm import ( + SPMValidationError, + normalize_spm_selection, + spm_error_detail, +) +from policyengine_api.utils import hash_object from policyengine_api.utils.input_validation import format_unrecognized_inputs_message from policyengine_api.utils.payload_validators import ( validate_country, @@ -99,14 +106,102 @@ def _household_persistence_failure( def _serialize_household(household: Household) -> dict: - return { + inputs = dict(household.household_json) + spm = inputs.pop("spm", None) + result = { "id": household.id, "country_id": household.country_id, "label": household.label, "api_version": household.api_version, - "household_json": household.household_json, + "household_json": inputs, "household_hash": household.household_hash, } + if spm is not None: + result["spm"] = spm + return result + + +def _spm_error_response(error: Exception) -> Response | None: + detail = spm_error_detail(error) + if detail is not None: + return _make_error_response( + detail["message"], 400, result=None, errors=[detail] + ) + return None + + +def _calculation_response(calculation) -> dict: + result = dict(status="ok", message=None, result=calculation.household) + if calculation.warnings: + result["warnings"] = list(calculation.warnings) + for field in ("spm_config", "spm_provenance"): + value = getattr(calculation, field, None) + if value is not None: + result[field] = value + return result + + +def _requested_spm(country_id: str, payload: dict): + """Read a chosen selection, treating an explicit null as a malformed one. + + A v2 document and a simulation record both refuse an explicit null rather + than reading it as "no choice". v1 must not be the one surface where null + quietly inherits the certified defaults, because omission and choice no + longer mean the same thing for storage or for replay. + + A country without SPM settings reports the field itself as unsupported, + exactly as a non-null selection does there, rather than inviting the caller + to correct the shape of a field it would reject whatever shape it had. + """ + if "spm" not in payload: + return None + selection = payload["spm"] + if country_id != "us": + raise SPMValidationError( + "SPM_SETTINGS_UNSUPPORTED", "SPM settings are only available for the US" + ) + if selection is None: + raise SPMValidationError( + "SPM_SETTINGS_INVALID", + "spm must be an object; omit it to inherit the certified defaults.", + ) + return selection + + +def _validate_calculation_spm(func): + """Validate current certification before an HTTP cache can satisfy a request.""" + + @wraps(func) + def wrapped(country_id, *args, **kwargs): + payload = request.get_json() + if not isinstance(payload, dict): + raise BadRequest("Calculation payload must be a JSON object.") + try: + selection = _requested_spm(country_id, payload) + g.spm_requested = selection is not None + g.spm = normalize_spm_selection(country_id, selection) + except ValueError as error: + response = _spm_error_response(error) + if response is not None: + return response + raise + return func(country_id, *args, **kwargs) + + return wrapped + + +def _calculation_cache_key(*args, **kwargs): + country_id = request.view_args["country_id"] + return hash_object( + { + "schema": 2, + "path": request.full_path, + "payload": request.get_json(), + "spm": g.spm, + "country_version": COUNTRY_PACKAGE_VERSIONS[country_id], + "policyengine_version": POLICYENGINE_VERSION, + } + ) @household_bp.route("//household/", methods=["GET"]) @@ -171,11 +266,13 @@ def post_household(country_id: str) -> Response: copy_to_v2 = _should_copy_to_v2(country_id, write_source) persistence_started_at = time.perf_counter() try: + selection = _requested_spm(country_id, payload) creation = household_service.create_household( country_id, household_json, label, record_mirror_event=copy_to_v2, + **({"spm": selection} if selection is not None else {}), ) except HouseholdPersistenceError as error: return _household_persistence_failure( @@ -184,6 +281,11 @@ def post_household(country_id: str) -> Response: configured_write_source=write_source, started_at=persistence_started_at, ) + except ValueError as error: + response = _spm_error_response(error) + if response is not None: + return response + raise household_id = creation.household.id if copy_to_v2: @@ -248,6 +350,9 @@ def get_household_under_policy(country_id: str, household_id: str, policy_id: st errors=[invalid_input.to_dict() for invalid_input in error.invalid_inputs], ) except Exception as error: + response = _spm_error_response(error) + if response is not None: + return response logging.exception(error) return _make_error_response( f"Error calculating household #{household_id} under policy " @@ -255,10 +360,7 @@ def get_household_under_policy(country_id: str, household_id: str, policy_id: st 500, ) - response_body = dict(status="ok", message=None, result=calculation.household) - if calculation.warnings: - response_body["warnings"] = list(calculation.warnings) - return response_body + return _calculation_response(calculation) def _calculate(country_id: str, *, add_missing: bool) -> dict | Response: @@ -272,6 +374,11 @@ def _calculate(country_id: str, *, add_missing: bool) -> dict | Response: household_json, policy_json, add_missing=add_missing, + **( + {"spm": g.spm, "spm_requested": g.get("spm_requested", False)} + if g.get("spm") is not None + else {} + ), ) except InvalidHouseholdInputsError as error: return _make_error_response( @@ -281,35 +388,40 @@ def _calculate(country_id: str, *, add_missing: bool) -> dict | Response: errors=[invalid_input.to_dict() for invalid_input in error.invalid_inputs], ) except SituationParsingError as error: + response = _spm_error_response(error) + if response is not None: + return response return _make_error_response( f"Invalid household payload: {error}", 400, result=None, ) except Exception as error: + response = _spm_error_response(error) + if response is not None: + return response logging.exception(error) return _make_error_response( f"Error calculating household under policy: {error}", 500, ) - response_body = dict(status="ok", message=None, result=calculation.household) - if calculation.warnings: - response_body["warnings"] = list(calculation.warnings) - return response_body + return _calculation_response(calculation) @household_bp.route("//calculate", methods=["POST"]) -@cache.cached(make_cache_key=make_cache_key) @validate_country +@_validate_calculation_spm +@cache.cached(make_cache_key=_calculation_cache_key) def get_calculate(country_id: str) -> dict | Response: """Calculate a household without adding omitted yearly variables.""" return _calculate(country_id, add_missing=False) @household_bp.route("//calculate-full", methods=["POST"]) -@cache.cached(make_cache_key=make_cache_key) @validate_country +@_validate_calculation_spm +@cache.cached(make_cache_key=_calculation_cache_key) def get_calculate_full(country_id: str) -> dict | Response: """Calculate a household after adding omitted yearly variables.""" return _calculate(country_id, add_missing=True) diff --git a/policyengine_api/routes/simulation_routes.py b/policyengine_api/routes/simulation_routes.py index 9476d6d67..05275f152 100644 --- a/policyengine_api/routes/simulation_routes.py +++ b/policyengine_api/routes/simulation_routes.py @@ -6,13 +6,26 @@ import pydantic from policyengine_api.data.v1_models import Simulation +from policyengine_api.response_factory import _make_error_response from policyengine_api.services.simulation_service import SimulationService +from policyengine_api.spm import SPMValidationError from policyengine_api.utils.payload_validators import validate_country simulation_bp = Blueprint("simulation", __name__) simulation_service = SimulationService() +def _spm_override_response(payload: dict) -> Response | None: + if "spm" not in payload: + return None + error = SPMValidationError( + "SPM_SETTINGS_UNSUPPORTED", + "Simulation records do not accept SPM overrides. Select SPM settings on " + "the linked household before creating its simulation.", + ) + return _make_error_response(error, 400, result=None, errors=[error.to_dict()]) + + def _serialize_v1_simulation(simulation: Simulation) -> dict: """Project canonical ORM JSON objects onto the legacy v1 response shape.""" @@ -50,6 +63,8 @@ def create_simulation(country_id: str) -> Response: payload = request.json if payload is None: raise BadRequest("Payload missing from request") + if (response := _spm_override_response(payload)) is not None: + return response # Extract required fields population_id = payload.get("population_id") @@ -167,6 +182,8 @@ def update_simulation(country_id: str) -> Response: payload = request.json if payload is None: raise BadRequest("Payload missing from request") + if (response := _spm_override_response(payload)) is not None: + return response # Extract optional fields status = payload.get("status") diff --git a/policyengine_api/runtime_cache/household_calculations.py b/policyengine_api/runtime_cache/household_calculations.py index 847d0c6a2..363f9d49e 100644 --- a/policyengine_api/runtime_cache/household_calculations.py +++ b/policyengine_api/runtime_cache/household_calculations.py @@ -3,14 +3,17 @@ from dataclasses import asdict, dataclass from typing import Any +from pydantic import ValidationError + from policyengine_api.runtime_cache.core import ( CacheBackend, CacheNamespace, RecoverableJSONCache, ) +from policyengine_api.spm import SPMProvenance, resolved_spm_settings -HOUSEHOLD_CALCULATION_SCHEMA_VERSION = 1 +HOUSEHOLD_CALCULATION_SCHEMA_VERSION = 2 HOUSEHOLD_CALCULATION_TTL_SECONDS = 86_400 @@ -23,16 +26,19 @@ class HouseholdCalculationIdentity: policy_hash: str country_package_version: str policyengine_version: str + spm: dict[str, Any] | None = None @dataclass(frozen=True) class CachedHouseholdCalculation: household: dict[str, Any] warnings: tuple[str, ...] = () + spm_config: dict[str, Any] | None = None + spm_provenance: dict[str, Any] | None = None class HouseholdCalculationCache: - """Cache a calculated household and its response warnings atomically.""" + """Cache a calculated household, its warnings and its receipts atomically.""" def __init__(self, client: CacheBackend, namespace: CacheNamespace) -> None: self._cache = RecoverableJSONCache( @@ -59,9 +65,34 @@ def get( return None if not all(isinstance(warning, str) for warning in warnings): return None + spm_config = payload.get("spm_config") + spm_provenance = payload.get("spm_provenance") + if any( + value is not None and not isinstance(value, dict) + for value in (spm_config, spm_provenance) + ): + return None + if identity.spm is not None: + # A stored receipt may omit its null values, so compare resolved + # settings rather than raw JSON. Requiring exact equality would miss + # the cache on every replay of a canonical calculation. + if resolved_spm_settings(spm_config) != identity.spm: + return None + try: + receipt = SPMProvenance.model_validate(spm_provenance) + except ValidationError: + return None + if ( + receipt.forecast_sha256 != identity.spm.get("forecast_content_sha256") + or receipt.scenario != identity.spm.get("scenario") + or receipt.geography_kind != identity.spm.get("geography_kind") + ): + return None return CachedHouseholdCalculation( household=household, warnings=tuple(warnings), + spm_config=spm_config, + spm_provenance=spm_provenance, ) def set( diff --git a/policyengine_api/runtime_cache/reform_impacts.py b/policyengine_api/runtime_cache/reform_impacts.py index 0a5e89062..920b4f422 100644 --- a/policyengine_api/runtime_cache/reform_impacts.py +++ b/policyengine_api/runtime_cache/reform_impacts.py @@ -41,7 +41,8 @@ class CachedReformImpact: message: str | None start_time: datetime | None end_time: datetime | None - execution_id: str + execution_id: str | None + error_code: str | None = None def _datetime_to_wire(value: datetime | None) -> str | None: @@ -90,7 +91,12 @@ def _impact_from_wire(payload: Any) -> CachedReformImpact | None: message=payload.get("message"), start_time=_datetime_from_wire(payload.get("start_time")), end_time=_datetime_from_wire(payload.get("end_time")), - execution_id=str(payload["execution_id"]), + execution_id=( + str(payload["execution_id"]) + if payload["execution_id"] is not None + else None + ), + error_code=payload.get("error_code"), ) except (KeyError, TypeError, ValueError): return None @@ -257,8 +263,18 @@ def _score(impact: CachedReformImpact) -> float: value = value.replace(tzinfo=timezone.utc) return value.timestamp() - def set(self, impact: CachedReformImpact) -> bool: - record_key = self._record_key(impact.execution_id) + def set( + self, + impact: CachedReformImpact, + *, + record_execution_id: str | None = None, + ) -> bool: + # Terminal errors clear the worker handle in the payload while keeping + # the original record key and lookup indexes stable. + record_execution_id = record_execution_id or impact.execution_id + if not record_execution_id: + raise ValueError("a reform-impact cache record identifier is required") + record_key = self._record_key(record_execution_id) indexes = (self._scope_index(impact), self._recent_index()) try: ttl_seconds = jittered_ttl(REFORM_IMPACT_TTL_SECONDS) @@ -383,13 +399,17 @@ def matching( def update( self, execution_id: str, + *, + clear_execution_id: bool = False, **changes: Any, ) -> CachedReformImpact | None: impact = self.get_by_execution_id(execution_id) if impact is None: return None + if clear_execution_id: + changes["execution_id"] = None updated = replace(impact, **changes) - return updated if self.set(updated) else None + return updated if self.set(updated, record_execution_id=execution_id) else None def delete_matching_computing( self, diff --git a/policyengine_api/services/budget_window_cache.py b/policyengine_api/services/budget_window_cache.py index 0d2171428..ae2e0cf6b 100644 --- a/policyengine_api/services/budget_window_cache.py +++ b/policyengine_api/services/budget_window_cache.py @@ -74,6 +74,10 @@ def build_key( def _result_key(cache_key: str) -> str: return f"{cache_key}:result" + @staticmethod + def _error_key(cache_key: str) -> str: + return f"{cache_key}:terminal-error" + @staticmethod def _batch_key(cache_key: str) -> str: return f"{cache_key}:batch-job-id" @@ -94,12 +98,27 @@ def _handle_cache_error( ) def get_completed_result(self, cache_key: str) -> dict[str, Any] | None: + return self._get_payload(self._result_key(cache_key), "result") + + def get_terminal_error(self, cache_key: str) -> dict[str, str] | None: + """Replay a typed failure independently of completed success payloads.""" + error = self._get_payload(self._error_key(cache_key), "terminal-error") + if ( + error is not None + and set(error) == {"code", "message"} + and isinstance(error["code"], str) + and isinstance(error["message"], str) + ): + return error + return None + + def _get_payload(self, key: str, kind: str) -> dict[str, Any] | None: started_at = time.perf_counter() try: - payload = self.client.get(self._result_key(cache_key)) + payload = self.client.get(key) except Exception: self._handle_cache_error( - "read-result", + f"read-{kind}", event="connection-failed", started_at=started_at, ) @@ -111,7 +130,7 @@ def get_completed_result(self, cache_key: str) -> dict[str, Any] | None: ) if payload is not None and result is None: self._handle_cache_error( - "decode-result", + f"decode-{kind}", event="decode-failed", started_at=started_at, ) @@ -119,7 +138,7 @@ def get_completed_result(self, cache_key: str) -> dict[str, Any] | None: record_cache_event( family=BUDGET_WINDOW_CACHE_FAMILY, event="hit" if isinstance(result, dict) else "miss", - operation="read-result", + operation=f"read-{kind}", started_at=started_at, ) return result if isinstance(result, dict) else None @@ -129,10 +148,17 @@ def set_completed_result( cache_key: str, result: dict[str, Any], ) -> bool: + return self._set_payload(self._result_key(cache_key), result, "result") + + def set_terminal_error(self, cache_key: str, error: dict[str, str]) -> bool: + """Retain deterministic typed failures for the existing result lifetime.""" + return self._set_payload(self._error_key(cache_key), error, "terminal-error") + + def _set_payload(self, key: str, result: dict[str, Any], kind: str) -> bool: started_at = time.perf_counter() try: stored = self.client.set( - self._result_key(cache_key), + key, encode_envelope( BUDGET_WINDOW_CACHE_FAMILY, BUDGET_WINDOW_CACHE_SCHEMA_VERSION, @@ -142,7 +168,7 @@ def set_completed_result( ) except Exception: self._handle_cache_error( - "write-result", + f"write-{kind}", event="write-failed", started_at=started_at, ) @@ -150,7 +176,7 @@ def set_completed_result( record_cache_event( family=BUDGET_WINDOW_CACHE_FAMILY, event="write", - operation="write-result", + operation=f"write-{kind}", started_at=started_at, ) return bool(stored) diff --git a/policyengine_api/services/economy_service.py b/policyengine_api/services/economy_service.py index 99efba4a5..fc3947f84 100644 --- a/policyengine_api/services/economy_service.py +++ b/policyengine_api/services/economy_service.py @@ -33,6 +33,8 @@ ReformImpactsService, ) from policyengine_api.utils import budget_window as budget_window_utils +from policyengine_api.worker_spm import validate_worker_spm, validate_worker_result +from policyengine_api.spm import SPMSelection, SPMValidationError from pydantic import BaseModel, Field load_dotenv() @@ -70,6 +72,7 @@ class ImpactStatus(Enum): class SimulationOptions(BaseModel): + spm: SPMSelection | None = None country: str scope: Literal["macro", "household"] = "macro" reform: dict[str, Any] @@ -386,8 +389,29 @@ def get_budget_window_economic_impact( ) cache_key = self._build_budget_window_cache_key(setup_options) + cached_error = self._budget_window_cache.get_terminal_error(cache_key) + if cached_error is not None: + raise SPMValidationError(**cached_error) + cached_result = self._budget_window_cache.get_completed_result(cache_key) if cached_result is not None: + try: + validate_worker_result( + cached_result, + setup_options.options.get("spm"), + expected_years=years, + ) + except SPMValidationError as error: + # A stored payload this build cannot certify is terminal, not + # transient: the selection is part of this cache key, so a + # recomputation would submit the identical job and store the + # identical receipts. Record it the way the batch poll does + # and the read above replays it, instead of re-deriving the + # same failure from the same payload on every later poll. + self._budget_window_cache.set_terminal_error( + cache_key, error.to_dict() + ) + raise return BudgetWindowEconomicImpactResult.completed( cached_result, cache_status="result-hit", @@ -397,6 +421,7 @@ def get_budget_window_economic_impact( if batch_job_id: return self._get_budget_window_result_from_batch_job_id( batch_job_id=batch_job_id, + spm=setup_options.options.get("spm"), cache_key=cache_key, total_years=len(years), queued_years_on_submit=years, @@ -479,6 +504,7 @@ def _build_budget_window_batch_payload( ) sim_config: SimulationOptions = self._setup_sim_options( country_id=setup_options.country_id, + spm=setup_options.options.get("spm"), reform_policy=reform_policy, baseline_policy=baseline_policy, region=setup_options.region, @@ -553,11 +579,23 @@ def _get_budget_window_result_from_batch_job_id( cache_key: str, total_years: int, queued_years_on_submit: list[str], + spm: dict | None = None, cache_status: Optional[str] = None, ) -> BudgetWindowEconomicImpactResult: - batch_execution = self._simulation_gateway.get_budget_window_batch_by_id( - batch_job_id - ) + try: + batch_execution = self._simulation_gateway.get_budget_window_batch_by_id( + batch_job_id + ) + if batch_execution.status in EXECUTION_STATUSES_SUCCESS: + result = batch_execution.result + if isinstance(result, dict) and result: + validate_worker_result( + result, spm, expected_years=queued_years_on_submit + ) + except SPMValidationError as error: + if self._budget_window_cache.set_terminal_error(cache_key, error.to_dict()): + self._budget_window_cache.clear_batch_job_id(cache_key) + raise if batch_execution.status in EXECUTION_STATUSES_SUCCESS: result = batch_execution.result @@ -646,6 +684,15 @@ def _build_economic_impact_setup_options( api_version: str, target: Literal["general", "cliff"] = "general", ) -> EconomicImpactSetupOptions: + resolved_spm = validate_worker_spm( + country_id, + options.get("spm"), + gateway=self._simulation_gateway, + policyengine_version=POLICYENGINE_VERSION, + model_version=COUNTRY_PACKAGE_VERSIONS.get(country_id), + ) + if resolved_spm is not None: + options = {**options, "spm": resolved_spm} process_id: str = self._create_process_id() cache_version = get_economy_impact_cache_version(country_id, api_version) country_package_version = COUNTRY_PACKAGE_VERSIONS.get(country_id) @@ -949,6 +996,11 @@ def _handle_execution_state( setup_options=setup_options, execution=execution, ) + validate_worker_result( + result, + setup_options.options.get("spm"), + expected_year=setup_options.time_period, + ) self._set_reform_impact_complete( setup_options=setup_options, reform_impact_json=result, @@ -999,6 +1051,19 @@ def _handle_completed_impact( most_recent_impact: ReformImpact, ) -> EconomicImpactResult: result = self._parse_json_object(most_recent_impact.reform_impact_json) + try: + validate_worker_result( + result, + setup_options.options.get("spm"), + expected_year=setup_options.time_period, + ) + except SPMValidationError as error: + self._record_uncertifiable_stored_impact( + setup_options=setup_options, + most_recent_impact=most_recent_impact, + error=error, + ) + raise return EconomicImpactResult.completed( data=self._with_policyengine_bundle( result=result, @@ -1006,10 +1071,53 @@ def _handle_completed_impact( ) ) + def _record_uncertifiable_stored_impact( + self, + setup_options: EconomicImpactSetupOptions, + most_recent_impact: ReformImpact, + error: SPMValidationError, + ) -> None: + """Persist a stored result's certification failure, best effort. + + Later polls then replay the typed failure from storage rather than + re-deriving it from a payload this build cannot certify, which otherwise + leaves the caller with no terminal state to reach. + + Only this request's own record is marked. `_get_most_recent_impact` can + fall back to a record matched by options-hash prefix, and marking that + one terminal would wedge the caller whose spelling owns it. + + The caller's answer is the typed error either way, so a storage failure + here — already logged by `_set_reform_impact_error` — must not replace it. + """ + if not most_recent_impact.execution_id: + return + if most_recent_impact.options_hash != setup_options.options_hash: + return + try: + self._set_reform_impact_error( + setup_options=setup_options, + message=error.message, + error_code=error.code, + execution_id=most_recent_impact.execution_id, + ) + except Exception: + pass + def _handle_failed_impact( self, most_recent_impact: ReformImpact, ) -> EconomicImpactResult: + if getattr(most_recent_impact, "error_code", None): + # A typed SPM failure is terminal. Replay its code on every later + # poll rather than flattening it into an untyped upstream failure: + # the caller needs the code to know the deployment, not the job, + # is what cannot serve the request. + raise SPMValidationError( + most_recent_impact.error_code, most_recent_impact.message + ) + # Failed executions have no successful output or SPM receipts to + # validate. Replay their original failure on every later poll. return EconomicImpactResult.error( message=( most_recent_impact.message or "Simulation entrypoint execution failed" @@ -1021,16 +1129,25 @@ def _handle_computing_impact( setup_options: EconomicImpactSetupOptions, most_recent_impact: ReformImpact, ) -> EconomicImpactResult: - execution = self._simulation_gateway.get_execution_by_id( - most_recent_impact.execution_id - ) - execution_state = self._simulation_gateway.get_execution_status(execution) - return self._handle_execution_state( - execution_state=execution_state, - setup_options=setup_options, - reform_impact=most_recent_impact, - execution=execution, - ) + try: + execution = self._simulation_gateway.get_execution_by_id( + most_recent_impact.execution_id + ) + execution_state = self._simulation_gateway.get_execution_status(execution) + return self._handle_execution_state( + execution_state=execution_state, + setup_options=setup_options, + reform_impact=most_recent_impact, + execution=execution, + ) + except SPMValidationError as error: + self._set_reform_impact_error( + setup_options=setup_options, + message=error.message, + error_code=error.code, + execution_id=most_recent_impact.execution_id, + ) + raise def _handle_create_impact( self, @@ -1044,6 +1161,7 @@ def _handle_create_impact( sim_config: SimulationOptions = self._setup_sim_options( country_id=setup_options.country_id, + spm=setup_options.options.get("spm"), reform_policy=reform_policy, baseline_policy=baseline_policy, region=setup_options.region, @@ -1137,6 +1255,7 @@ def _setup_sim_options( policyengine_version: str | None = None, data_version: str | None = None, dataset: str = "default", + spm: dict | None = None, ) -> SimulationOptions: """ Set up the simulation options for the simulation API job. @@ -1145,6 +1264,7 @@ def _setup_sim_options( return SimulationOptions.model_validate( { "country": country_id, + "spm": spm, "scope": scope, "reform": self._parse_json_object(reform_policy), "baseline": self._parse_json_object(baseline_policy), @@ -1518,6 +1638,7 @@ def _set_reform_impact_error( setup_options: EconomicImpactSetupOptions, message: str, execution_id: str, + error_code: str | None = None, ): """ In the reform_impact table, set the status of the impact to "error" and store the error message. @@ -1533,6 +1654,7 @@ def _set_reform_impact_error( options_hash=setup_options.options_hash, message=message, execution_id=execution_id, + **({"error_code": error_code} if error_code is not None else {}), ) except Exception as e: logger.log_struct( diff --git a/policyengine_api/services/household_calculation_service.py b/policyengine_api/services/household_calculation_service.py index 97707b0a6..8eb26e11f 100644 --- a/policyengine_api/services/household_calculation_service.py +++ b/policyengine_api/services/household_calculation_service.py @@ -24,12 +24,15 @@ ) from policyengine_api.utils.deprecated_inputs import drop_deprecated_inputs from policyengine_api.utils.input_validation import find_unrecognized_inputs +from policyengine_api.spm import normalize_spm_selection @dataclass(frozen=True) class CalculationResult: household: dict warnings: tuple[str, ...] = () + spm_config: dict | None = None + spm_provenance: dict | None = None @dataclass(frozen=True) @@ -37,6 +40,8 @@ class HouseholdCalculationResult: household: dict warnings: tuple[str, ...] = () cached: bool = False + spm_config: dict | None = None + spm_provenance: dict | None = None class HouseholdNotFoundError(LookupError): @@ -126,6 +131,7 @@ def _cache_identity( household: Household, policy: Policy, api_version: str, + spm: dict | None = None, ) -> HouseholdCalculationIdentity: return HouseholdCalculationIdentity( country_id=country_id, @@ -135,6 +141,7 @@ def _cache_identity( policy_hash=policy.policy_hash, country_package_version=api_version, policyengine_version=POLICYENGINE_VERSION, + spm=spm, ) def _get_inputs( @@ -169,6 +176,8 @@ def _store_result( CachedHouseholdCalculation( household=calculation.household, warnings=warnings, + spm_config=calculation.spm_config, + spm_provenance=calculation.spm_provenance, ), ) @@ -184,11 +193,17 @@ def calculate_stored_household( raise HouseholdNotFoundError(household_id) if policy is None: raise PolicyNotFoundError(policy_id) + household_inputs = deepcopy(household.household_json) + # A household saved without a selection never chose a measurement, so its + # replay keeps the historical output set rather than failing closed. + saved_spm = household_inputs.pop("spm", None) + spm = normalize_spm_selection(country_id, saved_spm, stored=True) cache_identity = self._cache_identity( country_id, household, policy, api_version, + spm, ) cached = self._cache.get(cache_identity) if cached is not None: @@ -196,12 +211,14 @@ def calculate_stored_household( household=cached.household, warnings=cached.warnings, cached=True, + spm_config=cached.spm_config, + spm_provenance=cached.spm_provenance, ) countries = self._countries() country = countries.get(country_id) household_json = add_yearly_variables( - deepcopy(household.household_json), + household_inputs, country_id, countries, ) @@ -217,7 +234,15 @@ def calculate_stored_household( calculation_started_at = time.perf_counter() try: - raw_calculation = country.calculate(household_json, policy.policy_json) + raw_calculation = country.calculate( + household_json, + policy.policy_json, + **( + {"spm": spm, "spm_requested": saved_spm is not None} + if spm is not None + else {} + ), + ) except Exception: record_cache_event( family="household-calculation", @@ -232,6 +257,8 @@ def calculate_stored_household( calculation = CalculationResult( household=raw_calculation.household, warnings=tuple(getattr(raw_calculation, "warnings", ())), + spm_config=getattr(raw_calculation, "spm_config", None), + spm_provenance=getattr(raw_calculation, "spm_provenance", None), ) else: # Temporary compatibility for test doubles and country packages @@ -256,6 +283,8 @@ def calculate_stored_household( return HouseholdCalculationResult( household=calculation.household, warnings=response_warnings, + spm_config=calculation.spm_config, + spm_provenance=calculation.spm_provenance, ) def calculate_household( @@ -265,10 +294,13 @@ def calculate_household( policy_json: dict, *, add_missing: bool = False, + spm: dict | None = None, + spm_requested: bool = False, ) -> HouseholdCalculationResult: """Validate and calculate request-provided household and policy data.""" countries = self._countries() country = countries.get(country_id) + spm = normalize_spm_selection(country_id, spm) household_json = deepcopy(household_json) if add_missing: household_json = add_yearly_variables( @@ -287,17 +319,23 @@ def calculate_household( if invalid_inputs: raise InvalidHouseholdInputsError(invalid_inputs) - raw_calculation = country.calculate(household_json, policy_json) + raw_calculation = country.calculate( + household_json, + policy_json, + **({"spm": spm, "spm_requested": spm_requested} if spm is not None else {}), + ) if isinstance(raw_calculation, dict): household = raw_calculation calculation_warnings = () else: household = raw_calculation.household - calculation_warnings = getattr(raw_calculation, "warnings", ()) + calculation_warnings = tuple(getattr(raw_calculation, "warnings", ())) return HouseholdCalculationResult( household=household, warnings=( tuple(warning.message for warning in deprecated_inputs.warnings) + calculation_warnings ), + spm_config=getattr(raw_calculation, "spm_config", None), + spm_provenance=getattr(raw_calculation, "spm_provenance", None), ) diff --git a/policyengine_api/services/household_service.py b/policyengine_api/services/household_service.py index 0ba78aea7..cc2adc33c 100644 --- a/policyengine_api/services/household_service.py +++ b/policyengine_api/services/household_service.py @@ -2,6 +2,7 @@ from collections.abc import Callable import copy +from copy import deepcopy from dataclasses import dataclass from datetime import datetime, timezone from enum import StrEnum @@ -26,6 +27,27 @@ LegacyHouseholdSnapshot, ) from policyengine_api.utils import hash_object +from policyengine_api.spm import SPMValidationError, normalize_spm_selection + + +def household_storage_json(country_id: str, household_json: dict, spm=None) -> dict: + """Keep the measurement selection in the same atomic JSON/hash as inputs. + + Only a selection the caller actually sent is stored. Certification still runs + for every household, so an unservable bundle is rejected here too, but writing + back the defaults it resolved would fabricate a choice nobody made: it changes + the household hash and `GET` shape for identical inputs, and it would make the + household's replay assert a measurement the caller never asked for. + """ + if "spm" in household_json: + raise SPMValidationError( + "SPM_SETTINGS_INVALID", "Supply spm beside data, not inside household data." + ) + result = deepcopy(household_json) + selected = normalize_spm_selection(country_id, spm) + if spm is not None and selected is not None: + result["spm"] = selected + return result HOUSEHOLD_MIRROR_PAYLOAD_SCHEMA_VERSION = 1 @@ -130,7 +152,10 @@ def create_household( label: str | None, *, record_mirror_event: bool = False, + spm: dict | None = None, ) -> HouseholdCreateResult: + # Validate before the persistence boundary so typed input failures remain 400. + household_json = household_storage_json(country_id, household_json, spm) try: with self._sessions.begin() as session: household = self._create_household( diff --git a/policyengine_api/services/reform_impacts_service.py b/policyengine_api/services/reform_impacts_service.py index 7ced6709b..81e66cad2 100644 --- a/policyengine_api/services/reform_impacts_service.py +++ b/policyengine_api/services/reform_impacts_service.py @@ -209,6 +209,7 @@ def set_error_reform_impact( options_hash, message, execution_id: str, + error_code: str | None = None, ) -> CachedReformImpact | None: del ( country_id, @@ -223,6 +224,8 @@ def set_error_reform_impact( execution_id, status="error", message=message, + error_code=error_code, + clear_execution_id=error_code is not None, end_time=self._now(), ) diff --git a/policyengine_api/services/report_spec_service.py b/policyengine_api/services/report_spec_service.py index 38b014257..93790586f 100644 --- a/policyengine_api/services/report_spec_service.py +++ b/policyengine_api/services/report_spec_service.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import Session from policyengine_api.data.v1_models import ReportOutput, Simulation +from policyengine_api.utils.population_identity import canonical_numeric_household_id REPORT_SPEC_SCHEMA_VERSION = 1 @@ -146,13 +147,20 @@ def build_report_spec( self._validate_report_country(report_output, simulation_1, simulation_2) report_kind = self.infer_report_kind(simulation_1, simulation_2) if report_kind in HOUSEHOLD_REPORT_KINDS: - if ( - simulation_2 is not None - and simulation_2.population_id != simulation_1.population_id - ): - raise ValueError( - "Household comparison report specs require matching household IDs" - ) + if simulation_2 is not None: + household_ids = [ + canonical_numeric_household_id( + simulation.country_id, + simulation.population_id, + simulation.population_type, + ) + or simulation.population_id + for simulation in (simulation_1, simulation_2) + ] + if household_ids[0] != household_ids[1]: + raise ValueError( + "Household comparison report specs require matching household IDs" + ) return HouseholdReportSpec( country_id=report_output.country_id, report_kind=report_kind, diff --git a/policyengine_api/services/simulation_service.py b/policyengine_api/services/simulation_service.py index 5e073440f..dfbe828ce 100644 --- a/policyengine_api/services/simulation_service.py +++ b/policyengine_api/services/simulation_service.py @@ -8,6 +8,10 @@ from policyengine_api.constants import COUNTRY_PACKAGE_VERSIONS from policyengine_api.data.orm import get_v1_session_factory from policyengine_api.data.v1_models import Simulation, SimulationRun +from policyengine_api.utils.population_identity import ( + canonical_numeric_household_id, + population_id_matches, +) @dataclass(frozen=True) @@ -124,15 +128,21 @@ def _find_existing_simulation( *, for_update: bool = False, ) -> Simulation | None: + exact_identity = Simulation.population_id == population_id statement = ( select(Simulation) .where( Simulation.country_id == country_id, - Simulation.population_id == population_id, + population_id_matches( + Simulation.population_id, + country_id, + population_id, + population_type, + ), Simulation.population_type == population_type, Simulation.policy_id == policy_id, ) - .order_by(Simulation.id.desc()) + .order_by(exact_identity.desc(), Simulation.id.desc()) ) if for_update: statement = statement.with_for_update() @@ -169,6 +179,20 @@ def get_or_create_simulation( population_type: str, policy_id: int, ) -> SimulationCreateResult: + numeric_identity = canonical_numeric_household_id( + country_id, population_id, population_type + ) + if numeric_identity is not None: + # JSON clients can send an integer. Keep SQL comparison textual so + # MySQL cannot coerce a nonnumeric stored ID into a numeric alias. + population_id = str(population_id) + # Linking is not serialized against household edits: there are none. A + # household is immutable once created, so simulation and report identity + # stay valid without a lock, and a locking read of an id that does not + # exist yet takes an InnoDB gap lock that blocks unrelated household + # inserts into that range. Repeated creates are serialized by the + # locking read of `simulations` below, which is what they contend on; + # the household row was never the exclusion for a non-numeric id. with self._sessions.begin() as session: simulation = self._find_existing_simulation( session, @@ -183,7 +207,7 @@ def get_or_create_simulation( simulation = self._create_simulation( session, country_id, - population_id, + numeric_identity or population_id, population_type, policy_id, ) diff --git a/policyengine_api/services/v2/households/transformations.py b/policyengine_api/services/v2/households/transformations.py index 95f41205c..f969ad160 100644 --- a/policyengine_api/services/v2/households/transformations.py +++ b/policyengine_api/services/v2/households/transformations.py @@ -178,6 +178,8 @@ def translate_legacy_household( "people", *(LEGACY_COLLECTION_NAMES[item] for item in collections), } + if snapshot.country_id == "us": + allowed_source.add("spm") unsupported = sorted(set(source) - allowed_source) if unsupported: raise LegacyHouseholdTranslationError( @@ -255,7 +257,9 @@ def translate_legacy_household( } ) - document = {"people": people, **normalized_groups} + document: dict[str, Any] = {"people": people, **normalized_groups} + if "spm" in source: + document["spm"] = source["spm"] try: normalized = normalize_household_document(snapshot.country_id, document) except ValueError as error: diff --git a/policyengine_api/services/v2/households/validators.py b/policyengine_api/services/v2/households/validators.py index 843048988..7c6040c7a 100644 --- a/policyengine_api/services/v2/households/validators.py +++ b/policyengine_api/services/v2/households/validators.py @@ -7,6 +7,7 @@ from uuid import UUID from policyengine_api.data.v2.models import LegacyHouseholdMapping +from policyengine_api.spm import SPMSelection MAXIMUM_HOUSEHOLD_ENTITIES = 1_000 @@ -113,6 +114,8 @@ def normalize_household_document( if type(document) is not dict: raise HouseholdValidationError("household_data must be an object") allowed = {"people", *collections} + if country_id == "us": + allowed.add("spm") unsupported = sorted(set(document) - allowed) if unsupported: raise HouseholdValidationError( @@ -122,6 +125,16 @@ def normalize_household_document( raise HouseholdValidationError("household_data must contain people") normalized: dict[str, Any] = {} + if "spm" in document: + if type(document["spm"]) is not dict: + raise HouseholdValidationError("spm must be an object") + try: + SPMSelection.model_validate(document["spm"]) + except ValueError as error: + raise HouseholdValidationError("spm selection is invalid") from error + # Saved selections are immutable identity, not a request to recertify + # against whichever scientific bundle is currently installed. + normalized["spm"] = dict(document["spm"]) identifiers: dict[str, set[str]] = {} for collection in ("people", *collections): records = document.get(collection, []) diff --git a/policyengine_api/specification.py b/policyengine_api/specification.py index 21c6395a3..11b4b3c8f 100644 --- a/policyengine_api/specification.py +++ b/policyengine_api/specification.py @@ -2,17 +2,66 @@ from __future__ import annotations +import json from pathlib import Path from typing import cast import yaml from policyengine_api.constants import VERSION from policyengine_api.json_types import JSONObject +from policyengine_api.query_parameters import ( + AnnualEconomyQuery, + BudgetWindowEconomyQuery, +) +from policyengine_api.spm import SPMProvenance, SPMSelection DEFAULT_SPECIFICATION_PATH = Path(__file__).with_name("openapi_spec.yaml") +def _openapi_30_schema(value): + """Convert Pydantic's optional JSON Schema fields to OpenAPI 3.0 nullable.""" + if isinstance(value, list): + return [_openapi_30_schema(item) for item in value] + if not isinstance(value, dict): + return value + result = {key: _openapi_30_schema(item) for key, item in value.items()} + if "const" in result: + result["enum"] = [result.pop("const")] + variants = result.get("anyOf", []) + if {"type": "null"} in variants: + non_null = [item for item in variants if item != {"type": "null"}] + if len(non_null) != 1: + raise ValueError("SPM optional schemas must have one non-null type") + result.pop("anyOf") + result.update(non_null[0]) + result["nullable"] = True + return result + + +def _economy_query_parameters(model): + """Publish the same complete scalar query contract used by Flask.""" + schema = model.model_json_schema() + result = [] + for name, field in model.model_fields.items(): + parameter = {"name": name, "in": "query", "required": field.is_required()} + description = field.description + if description: + parameter["description"] = description + if field.deprecated: + parameter["deprecated"] = True + if name == "spm": + parameter["content"] = { + "application/json": { + "schema": {"$ref": "#/components/schemas/SPMSelection"} + } + } + else: + parameter["schema"] = _openapi_30_schema(schema["properties"][name]) + result.append(parameter) + return result + + def load_specification( path: Path = DEFAULT_SPECIFICATION_PATH, version: str = VERSION, @@ -22,7 +71,28 @@ def load_specification( if not isinstance(document, dict) or not isinstance(document.get("info"), dict): raise ValueError("OpenAPI specification must contain an info object") document["info"]["version"] = version - return cast(JSONObject, document) + schemas = document.get("components", {}).get("schemas", {}) + for name, model in { + "SPMSelection": SPMSelection, + "SPMProvenance": SPMProvenance, + }.items(): + if name in schemas: + schemas[name] = _openapi_30_schema(model.model_json_schema()) + economy_path = "/{country_id}/economy/{policy_id}/over/{baseline_policy_id}" + for suffix, query_model in ( + ("", AnnualEconomyQuery), + ("/budget-window", BudgetWindowEconomyQuery), + ): + operation = document.get("paths", {}).get(economy_path + suffix, {}).get("get") + if operation is not None: + operation["parameters"] = [ + parameter + for parameter in operation.get("parameters", []) + if parameter["in"] != "query" + ] + _economy_query_parameters(query_model) + # YAML accepts both quoted and numeric response codes. JSON object keys are + # strings; normalize before Flask sorts them while serializing the document. + return cast(JSONObject, json.loads(json.dumps(document))) OPENAPI_SPECIFICATION = load_specification() diff --git a/policyengine_api/spm.py b/policyengine_api/spm.py new file mode 100644 index 000000000..c3c627ea9 --- /dev/null +++ b/policyengine_api/spm.py @@ -0,0 +1,437 @@ +"""Public SPM settings and the certified US bundle boundary. + +Household geography and composition are checked by the country only when an +SPM dependency is calculated. Resolving these settings never calculates one. +""" + +from __future__ import annotations + +import importlib +import inspect +from functools import lru_cache +from datetime import date +from typing import Any, Literal + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + ValidationError, + field_validator, + model_serializer, + model_validator, +) + + +class SPMSelection(BaseModel): + """Select a forecast scenario and an explicit household SPM geography.""" + + @staticmethod + def _selection_schema(schema): + # Omitted fields inherit the certified bundle, which may select a + # different geography. Generated clients must preserve that omission. + for field in schema.get("properties", {}).values(): + field.pop("default", None) + schema["oneOf"] = [ + { + "properties": {"geography_kind": {"enum": ["county", "national"]}}, + "not": { + "required": ["geography_id"], + "properties": {"geography_id": {"type": "string"}}, + }, + }, + { + "required": ["geography_kind", "geography_id"], + "properties": { + "geography_kind": {"enum": ["metro"]}, + "geography_id": { + "type": "string", + "minLength": 1, + "pattern": r"\S", + }, + }, + }, + ] + + model_config = ConfigDict( + frozen=True, extra="forbid", json_schema_extra=_selection_schema + ) + forecast_content_sha256: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") + scenario: str | None = Field(default=None, min_length=1, pattern=r"^\S+$") + geography_kind: Literal["county", "national", "metro"] = "county" + geography_id: str | None = Field(default=None, min_length=1) + county_vintage: str = Field(default="2020", pattern=r"^[0-9]{4}$") + as_of: str | None = Field( + default=None, + description="ISO calendar date (YYYY-MM-DD); must satisfy the selected artifact's information date", + json_schema_extra={ + "format": "date", + "pattern": r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$", + }, + ) + + @model_serializer(mode="wrap") + def serialize_selection(self, handler): + """Keep omitted options distinct from explicit choices in nested JSON.""" + return { + name: value + for name, value in handler(self).items() + if name in self.model_fields_set + } + + @field_validator("as_of") + @classmethod + def validate_as_of(cls, value): + if value is not None and date.fromisoformat(value).isoformat() != value: + raise ValueError("as_of must be an ISO calendar date (YYYY-MM-DD)") + return value + + @model_validator(mode="after") + def validate_location(self): + if self.geography_kind == "metro": + if not self.geography_id or not self.geography_id.strip(): + raise ValueError("An SPM area selection requires geography_id") + elif self.geography_id is not None: + raise ValueError("Only an SPM area selection accepts geography_id") + return self + + +class SPMProvenance(BaseModel): + """JSON receipt matching the country's public provenance contract.""" + + model_config = ConfigDict(extra="forbid") + forecast_id: str + forecast_sha256: str + scenario: str + geography_kind: str + runtime_versions: dict[str, str | None] + years: dict[str, dict[str, Any]] + geographies: list[dict[str, Any]] + composition_method: str + storage_method: str + + +class SPMValidationError(ValueError): + """An SPM request/configuration error safe for the API's 4xx envelope.""" + + def __init__(self, code: str, message: str): + self.code = code + self.message = message + super().__init__(message) + + def to_dict(self) -> dict[str, str]: + return {"code": self.code, "message": self.message} + + +def readable_validation_error(error: ValidationError) -> str: + """Render pydantic's report as an API message. + + `str(ValidationError)` carries a version-pinned pydantic documentation URL + and echoes the offending input back. Neither belongs in a message a client + displays to its own users, and the URL invites them to read our validator's + internals as their own contract. + """ + return "; ".join( + ": ".join( + part + for part in ( + ".".join(str(item) for item in detail["loc"]), + detail["msg"].removeprefix("Value error, "), + ) + if part + ) + for detail in error.errors() + ) + + +def error_message(error: BaseException) -> str: + """Render any SPM failure as an API message, never as validator internals. + + Settings arrive from a caller, a bundle manifest, a worker capability and a + country receipt, and every one of those is validated by the same models. A + message a client displays to its own users must read the same whichever of + them failed. + """ + if isinstance(error, ValidationError): + return readable_validation_error(error) + return str(error) + + +SPM_INPUT_ERROR_CODES = frozenset( + { + "SPM_GEOGRAPHY_REQUIRED", + "SPM_GEOGRAPHY_UNAVAILABLE", + "SPM_COMPOSITION_REQUIRED", + "SPM_YEAR_UNAVAILABLE", + } +) + + +def spm_error_detail(error: BaseException) -> dict[str, str] | None: + """Recognize country errors, including those wrapped by dependency tracing.""" + visited = set() + while error is not None and id(error) not in visited: + visited.add(id(error)) + if isinstance(error, SPMValidationError) or ( + isinstance(error, ValueError) + and getattr(error, "code", None) in SPM_INPUT_ERROR_CODES + ): + return {"code": error.code, "message": str(error)} + error = error.__cause__ or error.__context__ + return None + + +def simulation_supports_spm(simulation_type) -> bool: + """Old US constructors also accept **kwargs, so that alone is insufficient.""" + parameters = inspect.signature(simulation_type).parameters + return "spm" in parameters or ( + hasattr(simulation_type, "spm_config") + and callable(getattr(simulation_type, "spm_provenance", None)) + ) + + +# A resolved selection freezes all six fields, but output transports may omit +# null values. Only those omissions are safe: a receipt must never inherit a +# non-null setting from today's bundle defaults. +REQUIRED_RESOLVED_SPM_FIELDS = frozenset( + {"forecast_content_sha256", "scenario", "geography_kind", "county_vintage"} +) + + +def resolved_spm_settings(settings: object) -> dict | None: + """Expand receipt settings to every resolved field, or None if unusable. + + Returns None when the settings are unreadable or omit a field whose value + cannot be recovered, so no caller ever compares a receipt against today's + defaults. Every consumer of a receipt's settings shares this one rule. + """ + try: + config = SPMSelection.model_validate(settings) + except ValidationError: + return None + if not REQUIRED_RESOLVED_SPM_FIELDS <= config.model_fields_set: + return None + return {name: getattr(config, name) for name in SPMSelection.model_fields} + + +def _current_bundle() -> dict: + from policyengine_api.constants import _policyengine_bundle + + return _policyengine_bundle + + +def _installed_country_implements_spm(country_id: str) -> bool: + """Whether the installed country model implements the canonical constructor. + + Version strings move for reasons that have nothing to do with SPM — a wrapper + release for another country, a patch bump — so they cannot decide whether a + bundle predates this contract, and keying on them makes an unrelated bundle + bump reject every request. Constructor support can decide it: a model without + it computes SPM exactly as it always has, and a model with it still needs a + certified configuration before this API will run it. + """ + from policyengine_api.constants import COUNTRIES, COUNTRY_PACKAGE_NAMES + + package_name = dict(zip(COUNTRIES, COUNTRY_PACKAGE_NAMES)).get(country_id) + if package_name is None: + return False + try: + simulation_type = importlib.import_module(package_name).Simulation + except (AttributeError, ImportError, OSError, TypeError, ValueError): + # A model this build cannot load is a model without the canonical + # constructor, whatever stopped the import: a missing distribution, a + # package without a Simulation, an extension that will not initialize. + # Reading one of those as an internal failure would turn every US + # request on an uncertified bundle into a 500 rather than the legacy + # behaviour the bundle actually has. A certified bundle never reaches + # this probe and still fails closed through its own import below. + return False + try: + return simulation_supports_spm(simulation_type) + except (TypeError, ValueError): + return False + + +@lru_cache(maxsize=4) +def _selected_forecast(expected_sha256: str): + from spm_calculator.rolling_forecast import load_forecast + + return load_forecast(expected_sha256=expected_sha256) + + +def normalize_spm_selection( + country_id: str, + selection: SPMSelection | dict | None, + *, + stored: bool = False, +) -> dict | None: + """Resolve request identity against the installed bundle's certification. + + Omitted settings retain legacy behavior for any bundle whose installed US + model lacks the canonical constructor, whatever the manifest calls itself. A + certified bundle always resolves a hash and scenario before caching. + + `stored` says the selection was read back from storage rather than sent by + this caller, which changes only how a hash that no longer matches the + installed artifact is reported. Nothing else about the resolution differs. + """ + if country_id != "us": + if selection is not None: + raise SPMValidationError( + "SPM_SETTINGS_UNSUPPORTED", "SPM settings are only available for the US" + ) + return None + + try: + chosen = SPMSelection.model_validate({} if selection is None else selection) + except ValidationError as error: + raise SPMValidationError( + "SPM_SETTINGS_INVALID", readable_validation_error(error) + ) from error + + bundle = _current_bundle() + if not isinstance(bundle, dict): + raise SPMValidationError( + "SPM_CONFIGURATION_UNAVAILABLE", "The installed bundle manifest is invalid" + ) + measurements = bundle.get("measurements") + configured = measurements.get("spm") if isinstance(measurements, dict) else None + if not isinstance(configured, dict): + if _installed_country_implements_spm(country_id): + # A model that can run canonical SPM must never run it uncertified, + # whichever settings the caller did or did not send. + raise SPMValidationError( + "SPM_CONFIGURATION_UNAVAILABLE", + "The installed bundle has no certified SPM measurement configuration", + ) + if selection is None: + return None + raise SPMValidationError( + "SPM_SETTINGS_UNSUPPORTED", + "This US model bundle does not support canonical SPM settings", + ) + + try: + defaults = SPMSelection.model_validate(configured) + if defaults.forecast_content_sha256 is None or defaults.scenario is None: + raise ValueError("The bundle must pin the SPM artifact hash and scenario") + simulation_type = importlib.import_module("policyengine_us").Simulation + if not simulation_supports_spm(simulation_type): + raise ValueError("The installed US model does not support canonical SPM") + forecast = _selected_forecast(defaults.forecast_content_sha256) + except (AttributeError, ImportError, ValueError, TypeError, OSError) as error: + # A country package installed without its Simulation raises AttributeError + # here. The capability probe already reads that as "no canonical model"; + # letting it escape instead would make /readiness-check raise rather than + # report not-ready, and every US request a 500 rather than a typed 400. + raise SPMValidationError( + "SPM_CONFIGURATION_UNAVAILABLE", error_message(error) + ) from error + + if chosen.forecast_content_sha256 not in (None, defaults.forecast_content_sha256): + # A saved selection is identity, not a request. The caller sent nothing + # wrong; this deployment simply does not have the artifact the household + # was measured against, which is the same class of failure as an + # uncertified bundle rather than a correctable caller mistake. + if stored: + raise SPMValidationError( + "SPM_CONFIGURATION_UNAVAILABLE", + "This deployment does not have the SPM artifact this household's " + "saved selection was measured against", + ) + raise SPMValidationError( + "SPM_SETTINGS_INVALID", + "SPM selection does not match this bundle's artifact hash", + ) + values = {name: getattr(defaults, name) for name in SPMSelection.model_fields} + values.update(chosen.model_dump(exclude_unset=True)) + if ( + "geography_kind" in chosen.model_fields_set + and chosen.geography_kind != defaults.geography_kind + ): + values["geography_id"] = chosen.geography_id + values["forecast_content_sha256"] = defaults.forecast_content_sha256 + values["scenario"] = chosen.scenario or defaults.scenario + try: + resolved = SPMSelection.model_validate(values) + # The artifact validates scenario/information date without household input. + forecast.entry( + forecast.years[0], scenario=resolved.scenario, as_of=resolved.as_of + ) + if resolved.county_vintage != "2020": + raise ValueError("Unsupported county vintage: use 2020") + except ValueError as error: + raise SPMValidationError( + "SPM_SETTINGS_INVALID", error_message(error) + ) from error + if resolved.geography_kind == "metro": + _validate_metro_selection(forecast, resolved) + return resolved.model_dump(mode="json") + + +def _validate_metro_selection(forecast, selection: SPMSelection) -> None: + """A selected area must exist in the artifact, without guessing a year. + + Area definitions may change between years. Accept an area available in any + covered year; the country owns validation for the actual calculation year. + This reads geography metadata and creates no household calculation receipt. + """ + for year in forecast.years: + try: + forecast.geography_factor( + year, + "renter", + scenario=selection.scenario, + kind="metro", + geoid=selection.geography_id, + as_of=selection.as_of, + ) + except ValueError: + continue + return + raise SPMValidationError( + "SPM_GEOGRAPHY_UNAVAILABLE", + f"SPM area is unavailable in the selected artifact: {selection.geography_id}", + ) + + +def spm_metadata(country_id: str) -> dict: + """Advertise an available selection only after the certified boundary passes.""" + if country_id != "us": + return {"available": False} + try: + defaults = normalize_spm_selection(country_id, None) + except SPMValidationError: + return {"available": False} + if defaults is None: + return {"available": False} + return { + "available": True, + "settings_schema": SPMSelection.model_json_schema(), + "defaults": defaults, + } + + +def calculation_spm_receipt(simulation) -> dict: + """Read existing calculation receipts; never request SPM calculations here.""" + if not hasattr(simulation, "spm_config"): + return {} + # A receipt this API cannot read in full is an uncertified country contract, + # not a caller error and not an internal failure. Report it as a typed + # configuration failure rather than letting pydantic surface a 500, and + # never publish a receipt with unrecognized fields dropped. + try: + return { + "spm_config": SPMSelection.model_validate(simulation.spm_config).model_dump( + mode="json" + ), + "spm_provenance": SPMProvenance.model_validate( + simulation.spm_provenance() + ).model_dump(mode="json"), + } + except ValidationError as error: + raise SPMValidationError( + "SPM_CONFIGURATION_UNAVAILABLE", + "The installed country model reported an SPM receipt this API cannot " + f"certify: {readable_validation_error(error)}", + ) from error diff --git a/policyengine_api/utils/population_identity.py b/policyengine_api/utils/population_identity.py new file mode 100644 index 000000000..c8be5030f --- /dev/null +++ b/policyengine_api/utils/population_identity.py @@ -0,0 +1,47 @@ +"""Shared identity rules for legacy simulation population references.""" + +from sqlalchemy import LargeBinary, and_, cast +from sqlalchemy.sql import ColumnElement, SQLColumnExpression + + +def canonical_numeric_household_id( + country_id: str, population_id: str | int, population_type: str +) -> str | None: + """Resolve only US household references made entirely of ASCII decimal digits. + + The returned value is for comparison and new records. Saved simulation IDs + and report input snapshots retain their original spelling. + """ + value = str(population_id) + if ( + country_id == "us" + and population_type == "household" + and value.isascii() + and value.isdecimal() + ): + return value.lstrip("0") or "0" + return None + + +def population_id_matches( + column: SQLColumnExpression[str], + country_id: str, + population_id: str | int, + population_type: str, +) -> ColumnElement[bool]: + """Match US numeric aliases without integer casts or collation equivalence.""" + if country_id != "us" or population_type != "household": + return column == population_id + numeric_id = canonical_numeric_household_id( + country_id, population_id, population_type + ) + if numeric_id is None: + # MySQL text equality can ignore trailing spaces or equate Unicode + # characters. Opaque IDs must match their bytes, not a numeric alias. + return cast(column, LargeBinary) == str(population_id).encode("utf-8") + # '$' can match before a final newline. The second condition guarantees + # every character is an ASCII digit, including under MySQL regex rules. + return and_( + column.regexp_match(f"^0*{numeric_id}$"), + ~column.regexp_match("[^0-9]"), + ) diff --git a/policyengine_api/worker_spm.py b/policyengine_api/worker_spm.py new file mode 100644 index 000000000..28d8566c8 --- /dev/null +++ b/policyengine_api/worker_spm.py @@ -0,0 +1,153 @@ +"""Canonical economy execution requires the selected worker's certified capability.""" + +from policyengine_api.spm import ( + SPMSelection, + SPMProvenance, + SPMValidationError, + error_message, + normalize_spm_selection, + resolved_spm_settings, +) + + +def validate_worker_spm( + country_id: str, + selection: object = None, + *, + gateway=None, + policyengine_version=None, + model_version=None, +) -> dict | None: + country_id = country_id.lower() if isinstance(country_id, str) else country_id + resolved = normalize_spm_selection(country_id, selection) + if resolved is None: + return None + capability = None + if gateway is not None: + capability = gateway.get_spm_capability( + country_id, model_version, policyengine_version=policyengine_version + ) + try: + if not isinstance(capability, dict) or set(capability) != { + "contract_version", + "defaults", + }: + raise ValueError( + "The selected worker has no certified canonical SPM capability" + ) + if capability["contract_version"] != "canonical-spm-v1": + raise ValueError("The selected worker uses an unsupported SPM contract") + defaults = SPMSelection.model_validate(capability["defaults"]) + if not defaults.scenario or not defaults.forecast_content_sha256: + raise ValueError( + "The worker capability must pin SPM artifact hash and scenario" + ) + if defaults.forecast_content_sha256 != resolved["forecast_content_sha256"]: + raise ValueError("The API and worker SPM artifact identities differ") + except ValueError as exc: + raise SPMValidationError( + "SPM_CONFIGURATION_UNAVAILABLE", error_message(exc) + ) from exc + return resolved + + +def validate_worker_result( + result: dict, + selection: dict | None, + *, + expected_year: str | int | None = None, + expected_years: list[str] | None = None, +) -> None: + if selection is None: + return + try: + if not isinstance(result, dict): + raise ValueError("Worker SPM result must be an object") + if result.get("kind") == "budgetWindow": + if expected_year is not None: + raise ValueError("Expected an annual SPM result") + rows = result.get("annualImpacts") + size = result.get("windowSize") + if type(size) is not int or size < 1: + raise ValueError("Budget window size must be a positive integer") + if not isinstance(rows, list) or not all( + isinstance(row, dict) for row in rows + ): + raise ValueError("Budget window annual impacts must be objects") + if len(rows) != size: + raise ValueError("Budget window has incomplete SPM receipts") + actual = [str(row.get("year")) for row in rows] + if any( + len(year) != 4 or not year.isascii() or not year.isdigit() + for year in actual + ): + raise ValueError("Budget window annual impacts require calendar years") + if len(set(actual)) != len(actual): + raise ValueError("Budget window SPM result years differ from request") + if expected_years is not None: + wanted = [str(year) for year in expected_years] + if actual != wanted: + raise ValueError( + "Budget window SPM result years differ from request" + ) + for row in rows: + validate_worker_result(row, selection, expected_year=row.get("year")) + return + if expected_years is not None: + raise ValueError("Expected a budget window SPM result") + resolved = resolved_spm_settings(result.get("spm_config")) + if resolved is None: + raise ValueError("Worker result has incomplete resolved SPM settings") + if resolved != selection: + raise ValueError("Worker result SPM settings differ from the request") + receipts = result.get("spm_provenance") + if not isinstance(receipts, dict) or set(receipts) != {"baseline", "reform"}: + raise ValueError("Worker result has no baseline/reform SPM receipts") + for side in ("baseline", "reform"): + if not isinstance(receipts[side], list) or not receipts[side]: + raise ValueError("Worker result has an empty SPM receipt") + for item in receipts[side]: + receipt = SPMProvenance.model_validate(item) + if ( + expected_year is not None + and str(expected_year) not in receipt.years + ): + raise ValueError("Worker SPM receipt does not cover requested year") + if ( + receipt.forecast_sha256 != selection["forecast_content_sha256"] + or receipt.scenario != selection["scenario"] + or receipt.geography_kind != selection["geography_kind"] + ): + raise ValueError( + "Worker result SPM provenance differs from the request" + ) + except (ValueError, TypeError, KeyError) as exc: + raise SPMValidationError( + "SPM_CONFIGURATION_UNAVAILABLE", error_message(exc) + ) from exc + + +def raise_worker_spm_error(response) -> None: + """Preserve public typed input errors across asynchronous HTTP polling.""" + if response.status_code not in (400, 422): + return + try: + errors = response.json().get("errors", []) + except (ValueError, AttributeError): + return + for error in errors: + if ( + isinstance(error, dict) + and error.get("code") + in { + "SPM_GEOGRAPHY_REQUIRED", + "SPM_GEOGRAPHY_UNAVAILABLE", + "SPM_COMPOSITION_REQUIRED", + "SPM_YEAR_UNAVAILABLE", + "SPM_SETTINGS_INVALID", + "SPM_SETTINGS_UNSUPPORTED", + "SPM_CONFIGURATION_UNAVAILABLE", + } + and isinstance(error.get("message"), str) + ): + raise SPMValidationError(error["code"], error["message"]) diff --git a/tests/fixtures/services/economy_service.py b/tests/fixtures/services/economy_service.py index 8b22ef534..02553bf39 100644 --- a/tests/fixtures/services/economy_service.py +++ b/tests/fixtures/services/economy_service.py @@ -163,6 +163,7 @@ def mock_budget_window_cache(): """Mock Redis-backed budget-window cache.""" mock_cache = MagicMock() mock_cache.build_key.return_value = "budget-window-cache-key" + mock_cache.get_terminal_error.return_value = None mock_cache.get_completed_result.return_value = None mock_cache.get_batch_job_id.return_value = None mock_cache.claim_batch_start.return_value = True diff --git a/tests/integration/test_alembic_mysql_lifecycle.py b/tests/integration/test_alembic_mysql_lifecycle.py index 07c1cd1d2..b9f61c8c1 100644 --- a/tests/integration/test_alembic_mysql_lifecycle.py +++ b/tests/integration/test_alembic_mysql_lifecycle.py @@ -205,7 +205,7 @@ def test_policy_mirror_snapshot_uses_mysql_json_representation(): engine.dispose() -def test_household_mirror_event_uses_mysql_json_representation(): +def test_household_mirror_event_uses_mysql_json_representation(monkeypatch): database_url = _ephemeral_mysql_url() command.upgrade(_alembic_config(database_url), "head") engine = create_engine(database_url) @@ -215,12 +215,26 @@ def test_household_mirror_event_uses_mysql_json_representation(): request_household = { "people": {"you": {"phase11_mysql_json_rate": 0.040940000000000004}} } + resolved_spm = { + "forecast_content_sha256": "a" * 64, + "scenario": "baseline", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + } + # Keep bundle discovery outside this real MySQL JSON and event contract. + monkeypatch.setattr( + "policyengine_api.services.household_service.normalize_spm_selection", + lambda country_id, selection: dict(resolved_spm), + ) try: result = service.create_household( "us", request_household, "Stage 11 MySQL JSON normalization", + spm=resolved_spm, record_mirror_event=True, ) stored = service.get_household("us", result.household.id) @@ -230,7 +244,8 @@ def test_household_mirror_event_uses_mysql_json_representation(): assert request_household != stored.household_json assert result.snapshot.household_json == stored.household_json assert result.snapshot.household_json == { - "people": {"you": {"phase11_mysql_json_rate": 0.04094}} + "people": {"you": {"phase11_mysql_json_rate": 0.04094}}, + "spm": resolved_spm, } with sessions() as session: event = session.get(HouseholdMirrorEvent, result.mirror_event_id) diff --git a/tests/integration/test_mysql_household_spm_dual_write.py b/tests/integration/test_mysql_household_spm_dual_write.py new file mode 100644 index 000000000..ec42e3485 --- /dev/null +++ b/tests/integration/test_mysql_household_spm_dual_write.py @@ -0,0 +1,346 @@ +"""Canonical SPM household events on disposable MySQL and PostgreSQL. + +Use the local, Alembic-migrated targets required by test_mysql_policy_dual_write. +Only scientific bundle selection is deterministic; storage, JSON conversion, +fingerprints, transactions, mirror replay, and content identity remain real. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from copy import deepcopy +from dataclasses import dataclass +import json +from uuid import uuid4 + +import pytest +from sqlalchemy import create_engine, delete, select, text +from sqlalchemy.exc import DataError +from sqlalchemy.orm import sessionmaker +from sqlmodel import Session + +from policyengine_api.constants import COUNTRY_PACKAGE_VERSIONS +from policyengine_api.data.v1_models import ( + Household as V1Household, + HouseholdMirrorEvent, +) +from policyengine_api.data.v2.models import Household, LegacyHouseholdMapping +from policyengine_api.services import household_service +from policyengine_api.services.household_mirroring import ( + HouseholdMirrorUnavailableError, + process_household_event_after_commit, +) +from policyengine_api.services.household_service import HouseholdService +from policyengine_api.services.v2.households.database_session import ( + HouseholdDatabaseSession, +) +from policyengine_api.services.v2.households.services import V2HouseholdService +from policyengine_api.services.v2.households.transformations import ( + legacy_household_fingerprint, +) +from policyengine_api.utils import hash_object +from tests.integration.test_mysql_policy_dual_write import _mysql_url, _postgres_url + + +NATIONAL_SPM = { + "forecast_content_sha256": "a" * 64, + "scenario": "baseline", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, +} + + +def _source_document(marker: str, value: float = 40.0) -> dict: + document = { + "people": { + "adult": { + "age": {"2026": 40}, + "stage11_marker": {"2026": marker}, + "employment_income": {"2026": value}, + } + } + } + for collection in ( + "households", + "families", + "tax_units", + "spm_units", + "marital_units", + ): + document[collection] = {"group": {"members": ["adult"]}} + return document + + +@dataclass +class HouseholdDatabases: + mysql_sessions: sessionmaker + postgres_sessions: sessionmaker + source: HouseholdService + destination: V2HouseholdService + marker: str + + def create(self, *, spm=None, value=40.0): + return self.source.create_household( + "us", + _source_document(self.marker, value), + self.marker, + spm=spm, + record_mirror_event=True, + ) + + def mirror(self, creation, *, destination=None, require_pending=False): + return process_household_event_after_commit( + "us", + creation.household.id, + event_service=self.source, + mirror_factory=lambda: destination or self.destination, + require_pending=require_pending, + ) + + def source_receipt(self, creation): + with self.mysql_sessions() as session: + source = session.get(V1Household, creation.household.id) + event = session.get(HouseholdMirrorEvent, creation.mirror_event_id) + assert source is not None and event is not None + return { + "source_id": source.id, + "household_json": deepcopy(source.household_json), + "household_hash": source.household_hash, + "event_id": event.id, + "payload_json": deepcopy(event.payload_json), + "fingerprint": event.source_fingerprint_sha256, + }, event.processed_at + + +@pytest.fixture +def databases(monkeypatch): + # Validate both explicit disposable targets before constructing either engine. + mysql_url, postgres_url = _mysql_url(), _postgres_url() + mysql_engine = create_engine(mysql_url) + postgres_engine = create_engine(postgres_url) + mysql_sessions = sessionmaker(mysql_engine, expire_on_commit=False) + postgres_sessions = sessionmaker( + postgres_engine, class_=Session, expire_on_commit=False + ) + + def resolved_selection(country_id, selection): + assert country_id == "us" + return deepcopy(NATIONAL_SPM if selection is None else selection) + + monkeypatch.setattr( + household_service, "normalize_spm_selection", resolved_selection + ) + marker = f"mysql-household-spm-{uuid4().hex}" + try: + yield HouseholdDatabases( + mysql_sessions, + postgres_sessions, + HouseholdService(mysql_sessions), + V2HouseholdService(HouseholdDatabaseSession(postgres_sessions)), + marker, + ) + finally: + try: + with mysql_sessions() as session: + source_ids = list( + session.scalars( + select(V1Household.id).where(V1Household.label == marker) + ) + ) + with postgres_engine.begin() as connection: + mapping_filter = ( + LegacyHouseholdMapping.country_id == "us", + LegacyHouseholdMapping.legacy_household_id.in_(source_ids), + ) + destination_ids = list( + connection.scalars( + select(LegacyHouseholdMapping.household_id).where( + *mapping_filter + ) + ) + ) + connection.execute( + delete(LegacyHouseholdMapping).where(*mapping_filter) + ) + connection.execute( + delete(Household).where(Household.id.in_(destination_ids)) + ) + with mysql_engine.begin() as connection: + connection.execute( + delete(HouseholdMirrorEvent).where( + HouseholdMirrorEvent.legacy_household_id.in_(source_ids), + HouseholdMirrorEvent.country_id == "us", + ) + ) + connection.execute( + delete(V1Household).where(V1Household.id.in_(source_ids)) + ) + finally: + mysql_engine.dispose() + postgres_engine.dispose() + + +@pytest.mark.parametrize("fail_destination", [False, True], ids=["success", "rollback"]) +@pytest.mark.parametrize( + "value", + [0.04 + 3 / 1_000_000, 0.040940000000000004, -5.684341886080803e-14], + ids=["float-retry", "stage11-float", "negative-small-float"], +) +def test_spm_source_event_and_postgres_retry_keep_exact_persisted_identity( + databases, value, fail_destination, record_property +): + creation = databases.create(value=value, spm=NATIONAL_SPM) + before, processed_at = databases.source_receipt(creation) + assert processed_at is None + assert creation.snapshot is not None + assert before["household_json"] == creation.snapshot.household_json + assert before["household_json"]["spm"] == NATIONAL_SPM + assert before["payload_json"]["snapshot"] == creation.snapshot.model_dump( + mode="json" + ) + assert before["fingerprint"] == legacy_household_fingerprint(creation.snapshot) + with databases.mysql_sessions() as session: + raw_json = session.execute( + text("SELECT household_json FROM household WHERE id = :id"), + {"id": creation.household.id}, + ).scalar_one() + assert json.loads(raw_json) == before["household_json"] + stored_value = before["household_json"]["people"]["adult"]["employment_income"][ + "2026" + ] + if value == 0.04 + 3 / 1_000_000: + assert repr(value) == "0.040003000000000004" + assert stored_value == 0.040003 and stored_value != value + record_property("input_value", repr(value)) + record_property("mysql_household_json", raw_json) + record_property("mysql_source_receipt", json.dumps(before, sort_keys=True)) + + if fail_destination: + + class FailingTransaction(HouseholdDatabaseSession): + @contextmanager + def transaction(self): + with super().transaction() as session: + yield session + session.execute(text("SELECT 1 / 0")) + + with pytest.raises(HouseholdMirrorUnavailableError) as failure: + databases.mirror( + creation, + destination=V2HouseholdService( + FailingTransaction(databases.postgres_sessions) + ), + ) + assert isinstance(failure.value.__cause__, DataError) + assert databases.source_receipt(creation) == (before, None) + with databases.postgres_sessions() as session: + assert ( + session.scalar( + select(LegacyHouseholdMapping).where( + LegacyHouseholdMapping.country_id == "us", + LegacyHouseholdMapping.legacy_household_id + == creation.household.id, + ) + ) + is None + ) + + first = databases.mirror(creation, require_pending=True) + replay = databases.mirror(creation) + assert first.household_created and first.mapping_created + assert replay.household_id == first.household_id + assert not replay.household_created and not replay.mapping_created + after, completed_at = databases.source_receipt(creation) + assert after == before and completed_at is not None + + # A repeated create is a new immutable legacy source with one shared content row. + retried_creation = databases.create(value=value, spm=NATIONAL_SPM) + retried_mirror = databases.mirror(retried_creation) + assert retried_creation.household.id != creation.household.id + assert retried_mirror.household_id == first.household_id + assert not retried_mirror.household_created and retried_mirror.mapping_created + with databases.postgres_sessions() as session: + destination = session.get(Household, first.household_id) + assert destination is not None + assert destination.household_data["spm"] == NATIONAL_SPM + assert ( + destination.household_data["people"][0]["values"]["employment_income"][ + "2026" + ] + == stored_value + ) + record_property("postgres_household_id", str(first.household_id)) + record_property("mysql_retry_household_id", retried_creation.household.id) + + +def test_legacy_absence_and_distinct_spm_selections_keep_distinct_content(databases): + # Seed an actual pre-SPM legacy source, preserving its absence of a selection. + with databases.mysql_sessions.begin() as session: + legacy_json = _source_document(databases.marker) + legacy = V1Household( + country_id="us", + label=databases.marker, + api_version=COUNTRY_PACKAGE_VERSIONS["us"], + household_json=legacy_json, + household_hash=hash_object(legacy_json), + ) + session.add(legacy) + session.flush() + session.refresh(legacy) + HouseholdService._record_mirror_event(session, legacy) + legacy_result = process_household_event_after_commit( + "us", + legacy.id, + event_service=databases.source, + mirror_factory=lambda: databases.destination, + ) + selections = [ + NATIONAL_SPM, + {**NATIONAL_SPM, "forecast_content_sha256": "b" * 64}, + {**NATIONAL_SPM, "geography_kind": "county", "geography_id": None}, + ] + destination_ids = {legacy_result.household_id} + for selection in selections: + creation = databases.create(spm=selection) + result = databases.mirror(creation) + destination_ids.add(result.household_id) + with databases.postgres_sessions() as session: + destination = session.get(Household, result.household_id) + assert destination is not None + assert destination.household_data["spm"] == selection + assert len(destination_ids) == 4 + with databases.postgres_sessions() as session: + saved_legacy = session.get(Household, legacy_result.household_id) + assert saved_legacy is not None and "spm" not in saved_legacy.household_data + saved_source = databases.source.get_household("us", legacy.id) + assert saved_source is not None and "spm" not in saved_source.household_json + + +def test_a_creation_that_sends_no_selection_persists_and_mirrors_without_one( + databases, +): + """Certifying a bundle must not move an unselected household's stored JSON. + + This fixture resolves an omitted selection to NATIONAL_SPM exactly as a + certified bundle would. Writing that back would record a choice the caller + never made, change `household_hash` for identical inputs, and make the + household's own replay assert a measurement nobody asked for. + """ + document = _source_document(databases.marker) + creation = databases.create() + receipt, _ = databases.source_receipt(creation) + + assert "spm" not in receipt["household_json"] + assert receipt["household_json"] == document + assert receipt["household_hash"] == hash_object(document) + assert creation.snapshot is not None + assert "spm" not in creation.snapshot.household_json + assert receipt["fingerprint"] == legacy_household_fingerprint(creation.snapshot) + + result = databases.mirror(creation) + with databases.postgres_sessions() as session: + destination = session.get(Household, result.household_id) + assert destination is not None + assert "spm" not in destination.household_data diff --git a/tests/integration/test_mysql_policy_dual_write.py b/tests/integration/test_mysql_policy_dual_write.py new file mode 100644 index 000000000..e3a778192 --- /dev/null +++ b/tests/integration/test_mysql_policy_dual_write.py @@ -0,0 +1,424 @@ +"""Real MySQL JSON storage and PostgreSQL policy-mirroring regressions. + +Opt in with ALEMBIC_DATABASE_URL pointing at local policyengine_alembic_test +and, for cross-database tests, V2_ALEMBIC_DISPOSABLE_TEST=1 plus +V2_MIGRATION_DATABASE_URL pointing at local policyengine_v2_alembic_test. +Prepare both schemas with their respective Alembic upgrades before running. +Only rows created by these fixtures are removed during cleanup. +""" + +from __future__ import annotations + +import json +import os +from contextlib import contextmanager +from dataclasses import dataclass +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import create_engine, delete, func, select, text +from sqlalchemy.engine import Engine, make_url +from sqlalchemy.exc import DataError +from sqlalchemy.orm import sessionmaker +from sqlmodel import Session + +from policyengine_api.constants import POLICYENGINE_VERSION +from policyengine_api.data.v1_models import Policy as V1Policy +from policyengine_api.data.v2.migration_target import ( + V2_ALEMBIC_DISPOSABLE_TEST, + load_v2_alembic_settings, +) +from policyengine_api.data.v2.models import ( + LegacyPolicyMapping, + Parameter, + ParameterValue, + Policy, + TaxBenefitModel, + TaxBenefitModelVersion, +) +from policyengine_api.data.v2.settings import V2_MIGRATION_DATABASE_URL +from policyengine_api.services.policy_mirroring import ( + PolicyMirrorUnavailableError, + mirror_policy_after_commit, +) +from policyengine_api.services.policy_service import PolicyService, PolicySetResult +from policyengine_api.services.v2.policies.database_session import PolicyDatabaseSession +from policyengine_api.services.v2.policies.services import V2PolicyService +from policyengine_api.services.v2.policies.types import ( + NativePolicyCreationInput, + PolicyParameterValueInput, +) +from policyengine_api.services.v2.policies.validators import ( + LegacyPolicyMappingIntegrityError, +) +from policyengine_api.utils import hash_object + +INPUT_VALUE = 0.04 + 3 / 1_000_000 +STORED_VALUE = 0.040003 +PARAMETER_NAME = "gov.phase10.mysql_storage_rate" +PERIOD = "2026-01-01.2100-12-31" + + +def _mysql_url() -> str: + database_url = os.environ.get("ALEMBIC_DATABASE_URL", "") + if not database_url: + pytest.skip("ALEMBIC_DATABASE_URL is not set") + url = make_url(database_url) + if ( + url.drivername != "mysql+pymysql" + or url.host not in {"127.0.0.1", "localhost"} + or url.database != "policyengine_alembic_test" + or not url.username + or url.password is None + ): + pytest.fail( + "MySQL mirror tests require explicit test credentials and the local " + "mysql+pymysql policyengine_alembic_test schema" + ) + return database_url + + +def _postgres_url() -> str: + database_url = os.environ.get(V2_MIGRATION_DATABASE_URL, "") + if not database_url: + pytest.skip(f"{V2_MIGRATION_DATABASE_URL} is not set") + if os.environ.get(V2_ALEMBIC_DISPOSABLE_TEST) != "1": + pytest.fail("MySQL mirror tests require v2 disposable-test mode") + settings = load_v2_alembic_settings( + { + V2_MIGRATION_DATABASE_URL: database_url, + V2_ALEMBIC_DISPOSABLE_TEST: "1", + } + ) + return settings.url.render_as_string(hide_password=False) + + +@dataclass +class MySQLSource: + engine: Engine + service: PolicyService + label_prefix: str + + def create(self, label: str = "first") -> PolicySetResult: + return self.service.set_policy( + "us", + f"{self.label_prefix}{label}", + {PARAMETER_NAME: {PERIOD: INPUT_VALUE}}, + prepare_for_mirroring=True, + ) + + +@pytest.fixture +def mysql_source(): + engine = create_engine(_mysql_url()) + source = MySQLSource( + engine, + PolicyService(sessionmaker(engine, expire_on_commit=False)), + f"mysql-mirror-{uuid4().hex}-", + ) + try: + yield source + finally: + try: + with engine.begin() as connection: + connection.execute( + delete(V1Policy).where( + V1Policy.label.startswith(source.label_prefix, autoescape=True) + ) + ) + finally: + engine.dispose() + + +@dataclass +class PostgreSQLDestination: + sessions: sessionmaker + service: V2PolicyService + model_id: UUID + + def mirror(self, creation: PolicySetResult): + assert creation.snapshot is not None + return mirror_policy_after_commit( + creation.snapshot, + mirror_factory=lambda: self.service, + ) + + def counts(self) -> tuple[int, int, int]: + policy_ids = select(Policy.id).where( + Policy.tax_benefit_model_id == self.model_id + ) + with self.sessions() as session: + return tuple( + session.scalar(select(func.count()).select_from(table).where(clause)) + for table, clause in ( + (Policy, Policy.id.in_(policy_ids)), + (ParameterValue, ParameterValue.policy_id.in_(policy_ids)), + ( + LegacyPolicyMapping, + LegacyPolicyMapping.policy_id.in_(policy_ids), + ), + ) + ) + + +@pytest.fixture +def postgres_destination(): + engine = create_engine(_postgres_url()) + sessions = sessionmaker(engine, class_=Session, expire_on_commit=False) + model_id = None + try: + with sessions.begin() as session: + model = TaxBenefitModel(name="policyengine-us") + version = TaxBenefitModelVersion( + model=model, + version=POLICYENGINE_VERSION, + current_law_id=1, + metadata_time_periods=[2026], + ) + session.add( + Parameter( + name=PARAMETER_NAME, + tax_benefit_model_version=version, + ) + ) + session.flush() + model_id = model.id + yield PostgreSQLDestination( + sessions, V2PolicyService(PolicyDatabaseSession(sessions)), model_id + ) + finally: + try: + if model_id is not None: + with engine.begin() as connection: + policy_ids = select(Policy.id).where( + Policy.tax_benefit_model_id == model_id + ) + version_ids = select(TaxBenefitModelVersion.id).where( + TaxBenefitModelVersion.model_id == model_id + ) + for table, clause in ( + ( + LegacyPolicyMapping, + LegacyPolicyMapping.policy_id.in_(policy_ids), + ), + (ParameterValue, ParameterValue.policy_id.in_(policy_ids)), + (Policy, Policy.tax_benefit_model_id == model_id), + ( + Parameter, + Parameter.tax_benefit_model_version_id.in_(version_ids), + ), + ( + TaxBenefitModelVersion, + TaxBenefitModelVersion.model_id == model_id, + ), + (TaxBenefitModel, TaxBenefitModel.id == model_id), + ): + connection.execute(delete(table).where(clause)) + finally: + engine.dispose() + + +def test_first_snapshot_matches_real_mysql_json_storage( + mysql_source, record_property +) -> None: + creation = mysql_source.create() + assert creation.snapshot is not None + with mysql_source.engine.connect() as connection: + raw_json = connection.execute( + text("SELECT policy_json FROM policy WHERE id = :id AND country_id = 'us'"), + {"id": creation.policy_id}, + ).scalar_one() + persisted = json.loads(raw_json) + record_property("input_value", repr(INPUT_VALUE)) + record_property("mysql_policy_json", raw_json) + record_property("mysql_legacy_policy_id", creation.policy_id) + + # This assertion makes the regression sensitive to actual MySQL storage; + # a SQLite replacement or a backend preserving the input is insufficient. + assert repr(INPUT_VALUE) == "0.040003000000000004" + assert persisted == {PARAMETER_NAME: {PERIOD: STORED_VALUE}} + assert persisted[PARAMETER_NAME][PERIOD] != INPUT_VALUE + assert creation.snapshot.policy_json == persisted + assert creation.snapshot.source_policy_hash == hash_object( + {PARAMETER_NAME: {PERIOD: INPUT_VALUE}} + ) + assert creation.snapshot == mysql_source.service.get_policy_snapshot( + "us", creation.policy_id + ) + + +@pytest.mark.parametrize("fail_destination", [False, True], ids=["success", "rollback"]) +def test_mysql_first_write_retry_and_relabel_keep_one_postgres_uuid( + mysql_source, postgres_destination, fail_destination, record_property +) -> None: + creation = mysql_source.create() + assert creation.is_existing_policy is False + assert creation.snapshot == mysql_source.service.get_policy_snapshot( + "us", creation.policy_id + ) + assert creation.snapshot.policy_json == {PARAMETER_NAME: {PERIOD: STORED_VALUE}} + + if fail_destination: + + class FailingTransaction(PolicyDatabaseSession): + @contextmanager + def transaction(self): + with super().transaction() as session: + yield session + # Fail on the real server after policy/value/mapping inserts + # and before COMMIT, exercising PostgreSQL rollback itself. + session.execute(text("SELECT 1 / 0")) + + failing_service = V2PolicyService( + FailingTransaction(postgres_destination.sessions) + ) + with pytest.raises(PolicyMirrorUnavailableError) as failure: + mirror_policy_after_commit( + creation.snapshot, mirror_factory=lambda: failing_service + ) + assert isinstance(failure.value.__cause__, DataError) + assert postgres_destination.counts() == (0, 0, 0) + assert mysql_source.service.get_policy("us", creation.policy_id) is not None + first = None + else: + first = postgres_destination.mirror(creation) + assert first.policy_created is True + assert first.mapping_created is True + + # Repeat the real source service call, including a new MySQL session, as an + # HTTP retry would after destination failure or a lost successful response. + retry_creation = mysql_source.create() + assert retry_creation.is_existing_policy is True + assert retry_creation.policy_id == creation.policy_id + assert retry_creation.snapshot == creation.snapshot + retry = postgres_destination.mirror(retry_creation) + assert retry.policy_created is fail_destination + assert retry.mapping_created is fail_destination + if first is not None: + assert retry.policy_id == first.policy_id + + relabeled = mysql_source.create("relabeled") + assert relabeled.is_existing_policy is False + assert relabeled.policy_id != creation.policy_id + assert relabeled.snapshot.policy_json == creation.snapshot.policy_json + assert relabeled.snapshot.source_policy_hash == creation.snapshot.source_policy_hash + relabel_result = postgres_destination.mirror(relabeled) + record_property("mysql_first_legacy_policy_id", creation.policy_id) + record_property("mysql_retry_legacy_policy_id", retry_creation.policy_id) + record_property("mysql_relabel_legacy_policy_id", relabeled.policy_id) + record_property( + "postgres_first_policy_id", + "rolled_back" if first is None else str(first.policy_id), + ) + record_property("postgres_retry_policy_id", str(retry.policy_id)) + record_property("postgres_relabel_policy_id", str(relabel_result.policy_id)) + assert relabel_result.policy_id == retry.policy_id + assert relabel_result.policy_created is False + assert relabel_result.mapping_created is True + assert ( + postgres_destination.mirror(mysql_source.create()).policy_id == retry.policy_id + ) + assert ( + postgres_destination.mirror(mysql_source.create("relabeled")).policy_id + == retry.policy_id + ) + assert postgres_destination.counts() == (1, 1, 2) + with postgres_destination.sessions() as session: + mappings = session.scalars( + select(LegacyPolicyMapping).where( + LegacyPolicyMapping.policy_id == retry.policy_id + ) + ).all() + assert {row.legacy_policy_id for row in mappings} == { + creation.policy_id, + relabeled.policy_id, + } + assert {row.source_policy_hash for row in mappings} == { + creation.snapshot.source_policy_hash + } + + +def test_native_postgres_policies_preserve_the_distinct_input_number( + mysql_source, postgres_destination, record_property +) -> None: + mirrored = postgres_destination.mirror(mysql_source.create()) + item = postgres_destination.service.get_policy( + country_id="us", policy_id=mirrored.policy_id + ) + parameter = item.parameter_values[0] + assert parameter.value == STORED_VALUE + + def create_native(value): + return postgres_destination.service.create_policy( + NativePolicyCreationInput( + country_id="us", + tax_benefit_model_id=postgres_destination.model_id, + parameter_values=[ + PolicyParameterValueInput( + parameter_id=parameter.parameter_id, + value=value, + start_date=parameter.start_date, + end_date=parameter.end_date, + ) + ], + ) + ) + + stored = create_native(STORED_VALUE) + distinct = create_native(INPUT_VALUE) + record_property("stored_value", repr(STORED_VALUE)) + record_property("distinct_input_value", repr(INPUT_VALUE)) + record_property("postgres_stored_policy_id", str(stored.item.id)) + record_property("postgres_distinct_policy_id", str(distinct.item.id)) + assert stored.created is False + assert stored.item.id == mirrored.policy_id + assert distinct.created is True + assert distinct.item.id != mirrored.policy_id + assert distinct.item.parameter_values[0].value == INPUT_VALUE + assert create_native(INPUT_VALUE).item.id == distinct.item.id + assert postgres_destination.counts() == (2, 2, 1) + + +def test_historical_pre_storage_mapping_is_rejected_without_repair( + mysql_source, postgres_destination, record_property +) -> None: + creation = mysql_source.create() + assert creation.snapshot is not None + assert creation.snapshot.policy_json == {PARAMETER_NAME: {PERIOD: STORED_VALUE}} + # Reproduce the old first-write snapshot in disposable PostgreSQL only. + historical = creation.snapshot.model_copy( + update={"policy_json": {PARAMETER_NAME: {PERIOD: INPUT_VALUE}}} + ) + original = mirror_policy_after_commit( + historical, mirror_factory=lambda: postgres_destination.service + ) + retry = mysql_source.create() + assert retry.snapshot.source_policy_hash == historical.source_policy_hash + assert retry.snapshot.legacy_policy_id == historical.legacy_policy_id + assert retry.snapshot.policy_json != historical.policy_json + + with pytest.raises(PolicyMirrorUnavailableError) as failure: + postgres_destination.mirror(retry) + assert isinstance(failure.value.__cause__, LegacyPolicyMappingIntegrityError) + assert "translated immutable content" in str(failure.value.__cause__) + record_property("mysql_legacy_policy_id", creation.policy_id) + record_property("postgres_historical_policy_id", str(original.policy_id)) + record_property("retry_error", type(failure.value.__cause__).__name__) + assert postgres_destination.counts() == (1, 1, 1) + with postgres_destination.sessions() as session: + mapping = session.scalar( + select(LegacyPolicyMapping).where( + LegacyPolicyMapping.country_id == "us", + LegacyPolicyMapping.legacy_policy_id == creation.policy_id, + ) + ) + assert mapping.policy_id == original.policy_id + assert mapping.source_policy_hash == historical.source_policy_hash + assert ( + session.scalar( + select(ParameterValue.value_json).where( + ParameterValue.policy_id == original.policy_id + ) + ) + == INPUT_VALUE + ) diff --git a/tests/integration/test_runtime_cache_redis.py b/tests/integration/test_runtime_cache_redis.py index e7fd14a5c..acaffa329 100644 --- a/tests/integration/test_runtime_cache_redis.py +++ b/tests/integration/test_runtime_cache_redis.py @@ -94,6 +94,57 @@ def test_cached_household_calculation_is_shared_between_connections( assert HouseholdCalculationCache(second, namespace).get(identity) == value +def test_cached_calculation_receipts_survive_a_real_round_trip(redis_pair) -> None: + """The selection is part of cache identity and its receipts travel with the + result, so a replay under the same measurement reads back a complete receipt + and a replay under a different one misses.""" + first, second, namespace = redis_pair + selection = { + "forecast_content_sha256": "c" * 64, + "scenario": "baseline", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + } + identity = HouseholdCalculationIdentity( + country_id="us", + household_id=3, + policy_id=4, + household_hash="household", + policy_hash="policy", + country_package_version="1.2.3", + policyengine_version="4.5.6", + spm=selection, + ) + value = CachedHouseholdCalculation( + household={"people": {"you": {}}}, + warnings=(), + # A canonical receipt may omit its null settings; that is the same choice. + spm_config={ + name: setting for name, setting in selection.items() if setting is not None + }, + spm_provenance={ + "forecast_id": "test-artifact", + "forecast_sha256": selection["forecast_content_sha256"], + "scenario": "baseline", + "geography_kind": "national", + "runtime_versions": {"policyengine-us": "test-only"}, + "years": {"2026": {"status": "forecast"}}, + "geographies": [], + "composition_method": "classified-inputs", + "storage_method": "formula", + }, + ) + + assert HouseholdCalculationCache(first, namespace).set(identity, value) + assert HouseholdCalculationCache(second, namespace).get(identity) == value + other = HouseholdCalculationIdentity( + **{**vars(identity), "spm": {**selection, "geography_kind": "county"}} + ) + assert HouseholdCalculationCache(second, namespace).get(other) is None + + def test_real_claim_is_exclusive_token_safe_and_expires(redis_pair) -> None: first, second, namespace = redis_pair key = namespace.family_key("claims", 1, "work") diff --git a/tests/unit/routes/test_canonical_spm.py b/tests/unit/routes/test_canonical_spm.py new file mode 100644 index 000000000..e3c55a015 --- /dev/null +++ b/tests/unit/routes/test_canonical_spm.py @@ -0,0 +1,840 @@ +"""Exercise real HTTP, ORM replay and both cache layers at the country boundary.""" + +from copy import deepcopy +from types import SimpleNamespace + +from flask import Flask +import pytest + +from policyengine_api import spm +from policyengine_api.data.v1_models import Household, Policy, Simulation +from policyengine_api.extensions import cache +from policyengine_api.routes import household_routes +from policyengine_api.runtime_cache.core import CacheNamespace +from policyengine_api.runtime_cache.fake import InMemoryCacheBackend +from policyengine_api.runtime_cache.household_calculations import ( + HouseholdCalculationCache, +) +from policyengine_api.services.household_calculation_service import ( + CalculationResult, + HouseholdCalculationService, +) +from policyengine_api.services.household_service import HouseholdService +from policyengine_api.services.simulation_service import SimulationService +from policyengine_api.utils import hash_object + + +HOUSEHOLD = {"people": {"you": {"age": {"2026": 40}}}} +FORECAST_HASH = "a" * 64 + + +class CountryInputError(ValueError): + """The public ValueError/code/to_dict protocol used by country packages.""" + + def __init__(self, code): + self.code = code + super().__init__("An explicit household input is required") + + def to_dict(self): + return {"code": self.code, "message": str(self)} + + +class Country: + metadata = { + "variables": { + "age": {"entity": "person", "definitionPeriod": "year", "name": "age"} + }, + "entities": {"person": {"plural": "people", "roles": {}}}, + "parameters": {"gov.test.amount": {}}, + } + + def __init__(self): + self.calls = [] + self.error_code = None + + def calculate(self, household, policy, **kwargs): + self.calls.append((deepcopy(household), deepcopy(policy), deepcopy(kwargs))) + if self.error_code: + # A country reports a typed SPM input error only for a chosen + # measurement; otherwise the dependent variable is left unavailable. + if kwargs.get("spm_requested"): + raise CountryInputError(self.error_code) + household = deepcopy(household) + household.setdefault("spm_units", {}).setdefault("spm_unit", {})[ + "spm_unit_spm_threshold" + ] = {"2026": None} + config = kwargs.get("spm") + receipt = ( + None + if config is None + else { + "forecast_id": "test-artifact", + "forecast_sha256": config["forecast_content_sha256"], + "scenario": config["scenario"], + "geography_kind": config["geography_kind"], + "runtime_versions": {"policyengine-us": "test-only"}, + "years": {"2026": {"status": "forecast"}}, + "geographies": [], + "composition_method": "classified-inputs", + "storage_method": "formula", + } + ) + return CalculationResult(household, (), config, receipt) + + +@pytest.fixture +def certified(monkeypatch): + # A test-only certificate/forecast, never a proposed production release pin. + bundle = { + "measurements": { + "spm": {"forecast_content_sha256": FORECAST_HASH, "scenario": "baseline"} + } + } + monkeypatch.setattr(spm, "_current_bundle", lambda: bundle) + monkeypatch.setattr(spm, "simulation_supports_spm", lambda _: True) + monkeypatch.setattr( + spm, + "_selected_forecast", + lambda _: SimpleNamespace( + years=[2022], entry=lambda *a, **k: {}, geography_factor=lambda *a, **k: 1.0 + ), + ) + return bundle + + +@pytest.fixture +def harness(monkeypatch, orm_session_factory): + country = Country() + service = HouseholdCalculationService( + primary_session_factory=orm_session_factory, + cache=HouseholdCalculationCache( + InMemoryCacheBackend(), CacheNamespace("test", "spm") + ), + country_provider=lambda: {"us": country, "uk": country}, + ) + monkeypatch.setattr(household_routes, "household_calculation_service", service) + monkeypatch.setattr( + household_routes, "household_service", HouseholdService(orm_session_factory) + ) + with orm_session_factory.begin() as session: + session.add_all( + [ + Policy( + id=2, + country_id="us", + policy_json={}, + policy_hash="baseline", + api_version="test", + ), + Policy( + id=3, + country_id="us", + policy_json={"gov.test.amount": {"2026-01-01.2026-12-31": 20}}, + policy_hash="reform", + api_version="test", + ), + ] + ) + app = Flask(__name__) + app.config.update(TESTING=True, CACHE_TYPE="SimpleCache") + cache.init_app(app) + app.register_blueprint(household_routes.household_bp) + with app.app_context(): + cache.clear() + return app.test_client(), country + + +@pytest.mark.parametrize("code", sorted(spm.SPM_INPUT_ERROR_CODES)) +@pytest.mark.parametrize("path", ["/us/calculate", "/us/calculate-full", "stored"]) +def test_spm_input_errors_are_structured_400(certified, harness, code, path): + client, country = harness + country.error_code = code + if path == "stored": + created = client.post( + "/us/household", + json={"data": HOUSEHOLD, "spm": {"geography_kind": "national"}}, + ) + household_id = created.json["result"]["household_id"] + response = client.get(f"/us/household/{household_id}/policy/2") + else: + response = client.post( + path, json={"household": HOUSEHOLD, "spm": {"geography_kind": "national"}} + ) + assert response.status_code == 400 + assert response.json == { + "status": "error", + "result": None, + "message": "An explicit household input is required", + "errors": [ + {"code": code, "message": "An explicit household input is required"} + ], + } + + +@pytest.mark.parametrize( + "selection", + [ + {"unexpected": True}, + {"geography_kind": "national", "geography_id": "31080"}, + {"geography_kind": "metro"}, + {"forecast_content_sha256": "b" * 64}, + {"county_vintage": "2010"}, + [], + "national", + ], +) +@pytest.mark.parametrize("path", ["/us/calculate", "/us/household"]) +def test_invalid_settings_never_reach_country_or_storage( + certified, harness, selection, path +): + client, country = harness + response = client.post( + path, json={"household": HOUSEHOLD, "data": HOUSEHOLD, "spm": selection} + ) + assert response.status_code == 400 + assert response.json["errors"][0]["code"] == "SPM_SETTINGS_INVALID" + assert country.calls == [] + + +def test_settings_and_provenance_survive_storage_reform_replay_and_replacement( + certified, harness +): + client, country = harness + response = client.post( + "/us/household", json={"data": HOUSEHOLD, "spm": {"geography_kind": "national"}} + ) + assert response.status_code == 201 + household_id = response.json["result"]["household_id"] + url = f"/us/household/{household_id}" + stored = client.get(url).json["result"] + assert stored["spm"]["forecast_content_sha256"] == FORECAST_HASH + assert stored["household_json"] == HOUSEHOLD + + first = client.get(url + "/policy/2") + cached = client.get(url + "/policy/2") + assert first.status_code == cached.status_code == 200 + assert first.json == cached.json + assert first.json["spm_config"] == stored["spm"] + assert first.json["spm_provenance"]["forecast_sha256"] == FORECAST_HASH + assert len(country.calls) == 1 + assert "spm" not in country.calls[0][0] + assert client.get(url + "/policy/3").status_code == 200 + assert country.calls[1][2]["spm"] == stored["spm"] + assert country.calls[1][1] != country.calls[0][1] + + # Stage11 households are immutable even before a simulation is linked. + changed = {"people": {"you": {"age": {"2026": 41}}}} + assert client.put(url, json={"data": changed}).status_code == 405 + assert client.get(url).json["result"] == stored + + replacement = client.post( + "/us/household", json={"data": changed, "spm": stored["spm"]} + ) + assert replacement.status_code == 201 + replacement_id = replacement.json["result"]["household_id"] + assert replacement_id != household_id + replacement_url = f"/us/household/{replacement_id}" + edited = client.get(replacement_url).json["result"] + assert edited["spm"] == stored["spm"] + assert edited["household_hash"] != stored["household_hash"] + assert client.get(replacement_url + "/policy/2").status_code == 200 + assert len(country.calls) == 3 + + # Changing only measurement settings creates a distinct immutable identity. + reselection = client.post( + "/us/household", json={"data": changed, "spm": {"geography_kind": "county"}} + ) + assert reselection.status_code == 201 + latest_id = reselection.json["result"]["household_id"] + latest_url = f"/us/household/{latest_id}" + latest = client.get(latest_url).json["result"] + assert latest["household_hash"] != edited["household_hash"] + assert ( + client.get(latest_url + "/policy/2").json["spm_config"]["geography_kind"] + == "county" + ) + assert len(country.calls) == 4 + assert client.get(url).json["result"] == stored + assert client.get(url + "/policy/2").json == first.json + assert len(country.calls) == 4 + + +@pytest.mark.parametrize( + "selection", + [ + {"geography_kind": "county"}, + {"scenario": "alternative"}, + {"geography_kind": "metro", "geography_id": "31080"}, + {"as_of": "2026-09-09"}, + ], +) +def test_http_cache_varies_with_measurement_settings(certified, harness, selection): + client, country = harness + payload = {"household": HOUSEHOLD, "spm": {"geography_kind": "national"}} + first = client.post("/us/calculate", json=payload) + assert first.status_code == 200 + assert client.post("/us/calculate", json=payload).json == first.json + assert len(country.calls) == 1 + payload["spm"].update(selection) + second = client.post("/us/calculate", json=payload) + assert second.status_code == 200 + assert len(country.calls) == 2 + assert first.json["spm_config"] != second.json["spm_config"] + + +def test_certification_checked_before_cached_response(certified, harness): + client, country = harness + payload = {"household": HOUSEHOLD, "spm": {"geography_kind": "national"}} + assert client.post("/us/calculate", json=payload).status_code == 200 + certified.clear() + response = client.post("/us/calculate", json=payload) + assert response.status_code == 400 + assert response.json["errors"][0]["code"] == "SPM_CONFIGURATION_UNAVAILABLE" + assert len(country.calls) == 1 + + +@pytest.mark.parametrize("country_id", ["us", "uk"]) +def test_legacy_country_requests_do_not_receive_spm(harness, country_id): + client, country = harness + response = client.post(f"/{country_id}/calculate", json={"household": HOUSEHOLD}) + assert response.status_code == 200 + assert country.calls[0][2] == {} + assert "spm_provenance" not in response.json + assert "spm_config" not in response.json + + +def test_http_cache_default_artifact_hash_is_part_of_identity(certified, harness): + client, country = harness + payload = {"household": HOUSEHOLD} + first = client.post("/us/calculate", json=payload) + certified["measurements"]["spm"]["forecast_content_sha256"] = "b" * 64 + second = client.post("/us/calculate", json=payload) + assert first.status_code == second.status_code == 200 + assert len(country.calls) == 2 + assert first.json["spm_config"] != second.json["spm_config"] + + +def test_linked_household_measurement_is_immutable_for_saved_reports( + certified, harness, orm_session_factory +): + client, _ = harness + created = client.post( + "/us/household", json={"data": HOUSEHOLD, "spm": {"geography_kind": "national"}} + ) + household_id = created.json["result"]["household_id"] + url = f"/us/household/{household_id}" + simulation = ( + SimulationService(orm_session_factory) + .get_or_create_simulation("us", str(household_id), "household", 2) + .simulation + ) + assert simulation.population_id == str(household_id) + + for payload in [ + {"data": HOUSEHOLD, "spm": {"geography_kind": "county"}}, + {"data": {"people": {"you": {"age": {"2026": 41}}}}}, + ]: + rejected = client.put(url, json=payload) + assert rejected.status_code == 405 + assert ( + client.put(url, json={"data": HOUSEHOLD, "label": "New label"}).status_code + == 405 + ) + saved = client.get(url).json["result"] + assert saved["spm"]["geography_kind"] == "national" + assert saved["household_json"] == HOUSEHOLD + + +def test_certification_protects_linked_households_without_saved_spm( + certified, harness, orm_session_factory +): + client, _ = harness + historical_output = {"result": {"household_net_income": 12345}} + with orm_session_factory.begin() as session: + household = Household( + country_id="us", + label="Historical household", + household_json=deepcopy(HOUSEHOLD), + household_hash="historical-input-hash", + api_version="historical-model", + ) + session.add(household) + session.flush() + household_id = household.id + population_id = str(household_id).zfill(5) + simulation = Simulation( + country_id="us", + population_id=population_id, + population_type="household", + policy_id=2, + status="complete", + output=deepcopy(historical_output), + api_version="historical-model", + ) + session.add(simulation) + session.flush() + simulation_id = simulation.id + + url = f"/us/household/{household_id}" + original = client.get(url).json["result"] + assert "spm" not in original + changed = {"people": {"you": {"age": {"2026": 41}}}} + for payload in ( + {"data": changed}, + {"data": HOUSEHOLD, "spm": {"geography_kind": "national"}}, + {"data": HOUSEHOLD, "spm": {}}, + ): + rejected = client.put(url, json=payload) + assert rejected.status_code == 405 + assert client.get(url).json["result"] == original + + renamed = client.put(url, json={"data": HOUSEHOLD, "label": "New label"}) + assert renamed.status_code == 405 + assert client.get(url).json["result"] == original + + replacement = client.post( + "/us/household", json={"data": changed, "spm": {"geography_kind": "national"}} + ) + assert replacement.status_code == 201 + replacement_id = replacement.json["result"]["household_id"] + assert replacement_id != household_id + saved = client.get(f"/us/household/{replacement_id}").json["result"] + assert saved["household_json"] == changed + assert saved["spm"]["geography_kind"] == "national" + replacement_simulation = SimulationService( + orm_session_factory + ).get_or_create_simulation("us", str(replacement_id), "household", 2) + assert replacement_simulation.created is True + assert replacement_simulation.simulation.id != simulation_id + assert client.get(url).json["result"] == original + with orm_session_factory() as session: + historical = session.get(Simulation, simulation_id) + assert historical.population_id == population_id + assert historical.status == "complete" + assert historical.output == historical_output + assert historical.api_version == "historical-model" + + +@pytest.mark.parametrize("write_source", ["cloud_sql", "dual_write"]) +def test_household_creation_retains_resolved_spm_before_selected_mirror( + certified, harness, orm_session_factory, monkeypatch, write_source +): + from policyengine_api.data.v1_models import HouseholdMirrorEvent + from sqlalchemy import select + from unittest.mock import Mock + + monkeypatch.setenv("DB_WRITE_HOUSEHOLD", write_source) + observed = [] + + def copy_committed_event(country_id, household_id, **_kwargs): + with orm_session_factory() as session: + household = session.get(Household, household_id) + event = session.scalar( + select(HouseholdMirrorEvent).where( + HouseholdMirrorEvent.legacy_household_id == household_id + ) + ) + assert household.country_id == country_id == "us" + assert event is not None + snapshot = event.payload_json["snapshot"] + assert snapshot["household_json"] == household.household_json + assert snapshot["source_household_hash"] == household.household_hash + observed.append(deepcopy(snapshot["household_json"]["spm"])) + + copy_event = Mock(side_effect=copy_committed_event) + monkeypatch.setattr( + household_routes, "process_household_event_after_commit", copy_event + ) + client, country = harness + created = client.post( + "/us/household", json={"data": HOUSEHOLD, "spm": {"geography_kind": "national"}} + ) + assert created.status_code == 201 + saved = client.get(f"/us/household/{created.json['result']['household_id']}").json[ + "result" + ] + assert saved["spm"]["forecast_content_sha256"] == FORECAST_HASH + assert saved["spm"]["geography_kind"] == "national" + assert country.calls == [] + if write_source == "dual_write": + copy_event.assert_called_once() + assert observed == [saved["spm"]] + else: + copy_event.assert_not_called() + with orm_session_factory() as session: + assert session.scalar(select(HouseholdMirrorEvent)) is None + + +@pytest.mark.parametrize("write_source", ["cloud_sql", "dual_write"]) +def test_invalid_spm_stops_before_database_and_mirror( + certified, harness, monkeypatch, write_source +): + from unittest.mock import Mock + + sessions = Mock() + monkeypatch.setenv("DB_WRITE_HOUSEHOLD", write_source) + monkeypatch.setattr( + household_routes, "household_service", HouseholdService(sessions) + ) + copy_event = Mock() + monkeypatch.setattr( + household_routes, "process_household_event_after_commit", copy_event + ) + client, _ = harness + response = client.post( + "/us/household", json={"data": HOUSEHOLD, "spm": {"geography_kind": "metro"}} + ) + assert response.status_code == 400 + assert response.json["errors"][0]["code"] == "SPM_SETTINGS_INVALID" + sessions.begin.assert_not_called() + copy_event.assert_not_called() + + +@pytest.mark.parametrize("write_source", ["cloud_sql", "dual_write"]) +def test_valid_spm_database_timeout_keeps_stage11_safe_persistence_response( + certified, harness, monkeypatch, write_source +): + from sqlalchemy.exc import TimeoutError + from unittest.mock import Mock + + sessions = Mock() + sessions.begin.side_effect = TimeoutError("database credential secret") + monkeypatch.setenv("DB_WRITE_HOUSEHOLD", write_source) + monkeypatch.setattr( + household_routes, "household_service", HouseholdService(sessions) + ) + copy_event = Mock() + monkeypatch.setattr( + household_routes, "process_household_event_after_commit", copy_event + ) + client, _ = harness + response = client.post( + "/us/household", json={"data": HOUSEHOLD, "spm": {"geography_kind": "national"}} + ) + assert response.status_code == 503 + assert response.json["status"] == "error" + assert "SPM_SETTINGS_INVALID" not in response.text + assert "secret" not in response.text + sessions.begin.assert_called_once() + copy_event.assert_not_called() + + +def test_stored_replay_hits_the_calculation_cache_when_a_receipt_omits_nulls( + certified, harness +): + """A canonical country may omit null receipt settings; replay must still hit. + + Requiring exact JSON equality between the receipt and the resolved identity + made every stored replay recompute. + """ + client, country = harness + calculate = country.calculate + + def omitting_null_settings(household, policy, **kwargs): + result = calculate(household, policy, **kwargs) + return CalculationResult( + household=result.household, + warnings=result.warnings, + spm_config={ + key: value + for key, value in result.spm_config.items() + if value is not None + }, + spm_provenance=result.spm_provenance, + ) + + country.calculate = omitting_null_settings + created = client.post( + "/us/household", json={"data": HOUSEHOLD, "spm": {"geography_kind": "national"}} + ) + assert created.status_code == 201, created.json + url = f"/us/household/{created.json['result']['household_id']}/policy/2" + first = client.get(url) + assert first.status_code == 200, first.json + assert set(first.json["spm_config"]) == { + "forecast_content_sha256", + "scenario", + "geography_kind", + "county_vintage", + } + cached = client.get(url) + assert cached.status_code == 200 + assert cached.json == first.json + assert len(country.calls) == 1 + + +@pytest.mark.parametrize("version", ["5.2.1", "5.4.0", "6.0.0"]) +def test_bundle_version_bump_without_measurements_still_serves_us_requests( + harness, monkeypatch, version +): + """A bundle bump for unrelated reasons must not 400 every US surface. + + The automated bundle update moves these version strings on its own, so an + allowlist of known versions turns a routine release into a US outage. + """ + monkeypatch.setattr( + spm, + "_current_bundle", + lambda: { + "policyengine_version": version, + "packages": {"policyengine-us": {"version": "1.764.6"}}, + }, + ) + monkeypatch.setattr(spm, "simulation_supports_spm", lambda _: False) + client, country = harness + for path in ("/us/calculate", "/us/calculate-full"): + response = client.post(path, json={"household": HOUSEHOLD}) + assert response.status_code == 200, (path, response.json) + assert "spm_config" not in response.json + + created = client.post("/us/household", json={"data": HOUSEHOLD}) + assert created.status_code == 201, created.json + household_id = created.json["result"]["household_id"] + assert "spm" not in client.get(f"/us/household/{household_id}").json["result"] + replay = client.get(f"/us/household/{household_id}/policy/2") + assert replay.status_code == 200, replay.json + assert "spm_config" not in replay.json + assert all(call[2] == {} for call in country.calls) + + # An explicit selection is still refused rather than silently ignored. + refused = client.post( + "/us/calculate", + json={"household": HOUSEHOLD, "spm": {"geography_kind": "national"}}, + ) + assert refused.status_code == 400 + assert refused.json["errors"][0]["code"] == "SPM_SETTINGS_UNSUPPORTED" + + +def test_canonical_model_without_certification_refuses_every_us_surface( + harness, monkeypatch +): + """Capability without a certified configuration still fails closed.""" + monkeypatch.setattr( + spm, + "_current_bundle", + lambda: { + "policyengine_version": "5.2.0", + "packages": {"policyengine-us": {"version": "1.764.6"}}, + }, + ) + monkeypatch.setattr(spm, "simulation_supports_spm", lambda _: True) + client, country = harness + for path in ("/us/calculate", "/us/calculate-full", "/us/household"): + response = client.post(path, json={"household": HOUSEHOLD, "data": HOUSEHOLD}) + assert response.status_code == 400, (path, response.json) + assert response.json["errors"][0]["code"] == "SPM_CONFIGURATION_UNAVAILABLE" + assert country.calls == [] + + +@pytest.mark.parametrize("code", sorted(spm.SPM_INPUT_ERROR_CODES)) +def test_household_saved_without_a_selection_replays_with_null_spm_variables( + certified, harness, orm_session_factory, code +): + """A household that never chose a measurement keeps replaying under any policy. + + Certifying a bundle must not make an existing state-only household + uncalculable; its SPM-dependent variables are simply unavailable. + """ + client, country = harness + with orm_session_factory.begin() as session: + session.add( + Household( + id=77, + country_id="us", + household_json=deepcopy(HOUSEHOLD), + household_hash="legacy-household", + api_version="test", + ) + ) + country.error_code = code + response = client.get("/us/household/77/policy/2") + assert response.status_code == 200, response.json + assert response.json["status"] == "ok" + assert ( + response.json["result"]["spm_units"]["spm_unit"]["spm_unit_spm_threshold"][ + "2026" + ] + is None + ) + assert country.calls[0][2]["spm_requested"] is False + assert client.get("/us/household/77").json["result"].get("spm") is None + + +@pytest.mark.parametrize("code", sorted(spm.SPM_INPUT_ERROR_CODES)) +def test_household_saved_with_a_selection_still_fails_closed_on_replay( + certified, harness, code +): + client, country = harness + created = client.post( + "/us/household", json={"data": HOUSEHOLD, "spm": {"geography_kind": "national"}} + ) + country.error_code = code + url = f"/us/household/{created.json['result']['household_id']}/policy/2" + response = client.get(url) + assert response.status_code == 400 + assert response.json["errors"][0]["code"] == code + assert country.calls[0][2]["spm_requested"] is True + + +@pytest.mark.parametrize("code", sorted(spm.SPM_INPUT_ERROR_CODES)) +@pytest.mark.parametrize("path", ["/us/calculate", "/us/calculate-full"]) +def test_calculation_without_a_chosen_measurement_returns_null_spm_variables( + certified, harness, code, path +): + """An omitted selection inherits defaults but does not assert a choice.""" + client, country = harness + country.error_code = code + response = client.post(path, json={"household": HOUSEHOLD}) + assert response.status_code == 200, response.json + assert ( + response.json["result"]["spm_units"]["spm_unit"]["spm_unit_spm_threshold"][ + "2026" + ] + is None + ) + assert country.calls[0][2]["spm_requested"] is False + assert country.calls[0][2]["spm"]["geography_kind"] == "county" + + +def test_certification_does_not_change_what_an_unselected_household_stores( + certified, harness, orm_session_factory +): + """Identical inputs keep their hash, stored JSON and GET shape. + + Writing back the defaults a caller never chose would move every household + hash the day a bundle is certified, and record a choice nobody made. + """ + client, country = harness + created = client.post("/us/household", json={"data": HOUSEHOLD}) + assert created.status_code == 201, created.json + household_id = created.json["result"]["household_id"] + + stored = client.get(f"/us/household/{household_id}").json["result"] + assert "spm" not in stored + assert stored["household_json"] == HOUSEHOLD + assert stored["household_hash"] == hash_object(HOUSEHOLD) + with orm_session_factory.begin() as session: + assert session.get(Household, household_id).household_json == HOUSEHOLD + + +@pytest.mark.parametrize("code", sorted(spm.SPM_INPUT_ERROR_CODES)) +def test_a_household_created_without_a_selection_replays_rather_than_failing( + certified, harness, code +): + """The write-time rule and the replay-time rule have to agree. + + Storing resolved defaults here would make this household's own replay assert + a measurement its creator never asked for. + """ + client, country = harness + created = client.post("/us/household", json={"data": HOUSEHOLD}) + household_id = created.json["result"]["household_id"] + country.error_code = code + + response = client.get(f"/us/household/{household_id}/policy/2") + assert response.status_code == 200, response.json + assert ( + response.json["result"]["spm_units"]["spm_unit"]["spm_unit_spm_threshold"][ + "2026" + ] + is None + ) + assert country.calls[0][2]["spm_requested"] is False + + +@pytest.mark.parametrize( + "path,body", + [ + ("/us/calculate", {"household": HOUSEHOLD, "spm": None}), + ("/us/calculate-full", {"household": HOUSEHOLD, "spm": None}), + ("/us/household", {"data": HOUSEHOLD, "spm": None}), + ], +) +def test_an_explicit_null_selection_is_rejected_rather_than_read_as_omission( + certified, harness, path, body +): + """v2 documents and simulation records refuse a null; v1 must not differ.""" + client, country = harness + response = client.post(path, json=body) + assert response.status_code == 400, response.json + assert response.json["errors"][0]["code"] == "SPM_SETTINGS_INVALID" + assert country.calls == [] + + +@pytest.mark.parametrize("path", ["/us/calculate", "/us/calculate-full"]) +def test_an_uncertified_bundle_also_rejects_an_explicit_null_selection(harness, path): + client, country = harness + response = client.post(path, json={"household": HOUSEHOLD, "spm": None}) + assert response.status_code == 400, response.json + assert response.json["errors"][0]["code"] == "SPM_SETTINGS_INVALID" + assert country.calls == [] + + +def test_a_saved_selection_this_deployment_cannot_serve_is_not_a_caller_error( + certified, harness +): + """A household's saved artifact is its identity, not a correctable request.""" + client, _ = harness + created = client.post( + "/us/household", json={"data": HOUSEHOLD, "spm": {"geography_kind": "national"}} + ) + household_id = created.json["result"]["household_id"] + certified["measurements"]["spm"]["forecast_content_sha256"] = "b" * 64 + + response = client.get(f"/us/household/{household_id}/policy/2") + assert response.status_code == 400, response.json + assert response.json["errors"][0]["code"] == "SPM_CONFIGURATION_UNAVAILABLE" + + # The same mismatch sent in a request is still the caller's to fix. + requested = client.post( + "/us/calculate", + json={ + "household": HOUSEHOLD, + "spm": {"forecast_content_sha256": FORECAST_HASH}, + }, + ) + assert requested.status_code == 400 + assert requested.json["errors"][0]["code"] == "SPM_SETTINGS_INVALID" + + +def test_a_rejected_selection_never_quotes_validator_internals(certified, harness): + """The message a client shows its users is ours, not pydantic's.""" + client, _ = harness + response = client.post( + "/us/calculate", + json={"household": HOUSEHOLD, "spm": {"geography_kind": "nowhere"}}, + ) + assert response.status_code == 400 + message = response.json["errors"][0]["message"] + assert "geography_kind" in message + assert "errors.pydantic.dev" not in message + assert "input_value" not in message + assert "validation error for" not in message + + +@pytest.mark.parametrize( + "path,body", + [ + ("/uk/calculate", {"household": HOUSEHOLD, "spm": None}), + ("/uk/calculate-full", {"household": HOUSEHOLD, "spm": None}), + ("/uk/household", {"data": HOUSEHOLD, "spm": None}), + ( + "/uk/calculate", + {"household": HOUSEHOLD, "spm": {"geography_kind": "national"}}, + ), + ], +) +def test_a_country_without_spm_reports_the_field_itself_as_unsupported( + certified, harness, path, body +): + """A null there is not a shape to correct; the field does not exist for UK. + + `POST /uk/simulation` already answers any `spm` key, null included, with + SPM_SETTINGS_UNSUPPORTED. A calculate or create on the same country must not + invite the caller to fix a field it would reject whatever shape it had. + """ + client, country = harness + response = client.post(path, json=body) + assert response.status_code == 400, response.json + assert response.json["errors"][0]["code"] == "SPM_SETTINGS_UNSUPPORTED" + assert country.calls == [] diff --git a/tests/unit/routes/test_economy_failure_replay.py b/tests/unit/routes/test_economy_failure_replay.py new file mode 100644 index 000000000..a1889f603 --- /dev/null +++ b/tests/unit/routes/test_economy_failure_replay.py @@ -0,0 +1,133 @@ +"""Annual worker failures retain their status when subsequent HTTP polls replay them.""" + +from types import SimpleNamespace +from unittest.mock import Mock + +from flask import Flask +import pytest + +from policyengine_api.routes import economy_routes +from policyengine_api.runtime_cache.core import CacheNamespace +from policyengine_api.runtime_cache.fake import InMemoryCacheBackend +from policyengine_api.runtime_cache.reform_impacts import ReformImpactCache +from policyengine_api.services import economy_service as economy_module +from policyengine_api.services.economy_service import EconomyService +from policyengine_api.services.reform_impacts_service import ReformImpactsService +from policyengine_api import worker_spm + + +SELECTION = { + "forecast_content_sha256": "a" * 64, + "scenario": "baseline", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, +} +EXECUTION_ID = "failed-worker-job" +WORKER_ERROR = "Worker exhausted available memory" +STORED_ERROR = f"Simulation entrypoint execution failed: {WORKER_ERROR}" + + +@pytest.fixture(params=[SELECTION, None], ids=["canonical", "legacy"]) +def failed_job_harness(request, monkeypatch): + # Isolate bundle certification from this real route/service/cache replay test. + # The fake worker reports an ordinary execution failure, with no SPM output. + selection = request.param + monkeypatch.setattr(worker_spm, "normalize_spm_selection", lambda *_: selection) + gateway = Mock() + gateway.get_spm_capability.return_value = { + "contract_version": "canonical-spm-v1", + "defaults": SELECTION, + } + gateway.resolve_app_name.side_effect = lambda country_id, version=None, **kwargs: ( + "test-worker", + version, + ) + gateway.get_execution_by_id.return_value = SimpleNamespace(error=WORKER_ERROR) + gateway.get_execution_status.return_value = "failed" + cache = ReformImpactCache( + InMemoryCacheBackend(), CacheNamespace("test", "failed-job-replay") + ) + impacts = ReformImpactsService(cache) + service = EconomyService( + reform_impacts_service_=impacts, + simulation_entrypoint_=gateway, + ) + setup = service._build_economic_impact_setup_options( + country_id="us", + policy_id=1, + baseline_policy_id=2, + region="us", + dataset="default", + time_period="2026", + options={}, + api_version="1.0.0", + ) + service._resolve_runtime_bundle_for_setup_options(setup) + impacts.set_reform_impact( + country_id=setup.country_id, + policy_id=setup.reform_policy_id, + baseline_policy_id=setup.baseline_policy_id, + region=setup.region, + dataset=setup.dataset, + time_period=setup.time_period, + options=setup.options, + options_hash=setup.options_hash, + status="computing", + api_version=setup.api_version, + reform_impact_json={}, + start_time=None, + execution_id=EXECUTION_ID, + ) + monkeypatch.setattr(economy_routes, "economy_service", service) + logger = Mock() + monkeypatch.setattr(economy_module, "logger", logger) + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(economy_routes.economy_bp) + return app.test_client(), cache, gateway, logger, selection + + +def test_repeated_failed_polls_replay_failure_without_success_receipts( + failed_job_harness, +): + client, cache, gateway, logger, _ = failed_job_harness + url = "/us/economy/1/over/2?region=us&time_period=2026&version=1.0.0" + + first = client.get(url) + # An untyped upstream failure is the simulation service's, not the caller's. + assert first.status_code == 502 + assert first.json == {"status": "error", "message": STORED_ERROR, "result": None} + assert cache.get_by_execution_id(EXECUTION_ID).message == STORED_ERROR + + for _ in range(2): + response = client.get(url) + assert response.status_code == 502, response.json + assert response.json == first.json + stored = cache.get_by_execution_id(EXECUTION_ID) + assert stored.status == "error" + assert stored.message == STORED_ERROR + assert stored.reform_impact_json == {} + assert logger.log_struct.call_args.args[0] == {"message": STORED_ERROR} + + gateway.get_execution_by_id.assert_called_once_with(EXECUTION_ID) + gateway.get_execution_result.assert_not_called() + gateway.run.assert_not_called() + + +@pytest.mark.parametrize( + "failed_job_harness", [SELECTION], indirect=True, ids=["canonical"] +) +def test_successful_cached_job_still_requires_canonical_receipts(failed_job_harness): + client, cache, gateway, _, _ = failed_job_harness + cache.update(EXECUTION_ID, status="ok", message="Completed") + + response = client.get( + "/us/economy/1/over/2?region=us&time_period=2026&version=1.0.0" + ) + + assert response.status_code == 400 + assert response.json["errors"][0]["code"] == "SPM_CONFIGURATION_UNAVAILABLE" + gateway.get_execution_by_id.assert_not_called() + gateway.run.assert_not_called() diff --git a/tests/unit/routes/test_economy_spm_query.py b/tests/unit/routes/test_economy_spm_query.py new file mode 100644 index 000000000..aae3f21c8 --- /dev/null +++ b/tests/unit/routes/test_economy_spm_query.py @@ -0,0 +1,267 @@ +"""Real Flask economy requests validate JSON selections before service dispatch.""" + +import json +from types import SimpleNamespace +from unittest.mock import Mock + +from flask import Flask +import pytest + +from policyengine_api import spm +from policyengine_api.routes import economy_routes +from policyengine_api.services.economy_service import EconomyService + + +@pytest.fixture(params=[False, True], ids=["annual", "budget-window"]) +def economy_http(request, monkeypatch): + budget_window = request.param + defaults = { + "forecast_content_sha256": "a" * 64, + "scenario": "baseline", + "geography_kind": "county", + } + monkeypatch.setattr( + spm, "_current_bundle", lambda: {"measurements": {"spm": defaults}} + ) + monkeypatch.setattr(spm, "simulation_supports_spm", lambda _: True) + monkeypatch.setattr( + spm, + "_selected_forecast", + lambda _: SimpleNamespace(years=[2026], entry=lambda *args, **kwargs: {}), + ) + gateway = Mock() + gateway.get_spm_capability.return_value = { + "contract_version": "canonical-spm-v1", + "defaults": defaults, + } + service = EconomyService(simulation_entrypoint_=gateway) + setups = [] + + def calculate(**kwargs): + # Keep the actual service's selection and worker-capability boundary; + # stop before database/cache access and computational submission. + kwargs["time_period"] = kwargs.pop("start_year", kwargs.get("time_period")) + kwargs.pop("window_size", None) + setup = service._build_economic_impact_setup_options(**kwargs) + setups.append(setup) + return SimpleNamespace( + cache_status=None, + to_dict=lambda: { + "status": "computing", + "message": None, + "data": None, + "progress": 0, + "completed_years": [], + "computing_years": [], + "queued_years": [], + "error": None, + }, + ) + + dispatch = Mock(side_effect=calculate) + method = ( + "get_budget_window_economic_impact" if budget_window else "get_economic_impact" + ) + monkeypatch.setattr(economy_routes.economy_service, method, dispatch) + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(economy_routes.economy_bp) + path = "/us/economy/123/over/456" + query = [("region", "us")] + if budget_window: + path += "/budget-window" + query += [("start_year", "2026"), ("window_size", "2")] + else: + query += [("time_period", "2026")] + return app.test_client(), path, query, dispatch, gateway, setups + + +def test_json_spm_selection_reaches_real_worker_validation(economy_http): + client, path, query, dispatch, gateway, setups = economy_http + response = client.get( + path, query_string=query + [("spm", json.dumps({"geography_kind": "national"}))] + ) + assert response.status_code == 200, response.get_json() + assert response.get_json()["status"] == "computing" + assert dispatch.call_args.kwargs["options"] == { + "spm": {"geography_kind": "national"} + } + assert setups[0].options["spm"]["geography_kind"] == "national" + gateway.get_spm_capability.assert_called_once() + gateway.run.assert_not_called() + gateway.run_budget_window_batch.assert_not_called() + + +@pytest.mark.parametrize( + "value", + [ + "{", + "null", + "[]", + '"national"', + '{"unknown":true}', + '{"geography_kind":"metro"}', + '{"as_of":"2026-02-30"}', + '{"geography_kind":"national","geography_kind":"county"}', + ], +) +def test_malformed_spm_query_never_dispatches(economy_http, value): + client, path, query, dispatch, gateway, _ = economy_http + response = client.get(path, query_string=query + [("spm", value)]) + assert response.status_code == 400 + assert response.get_json()["errors"][0]["code"] == "SPM_SETTINGS_INVALID" + dispatch.assert_not_called() + gateway.get_spm_capability.assert_not_called() + + +@pytest.mark.parametrize("field", ["spm", "region", "dataset", "version", "target"]) +def test_duplicate_scalar_economy_queries_never_dispatch(economy_http, field): + client, path, query, dispatch, gateway, _ = economy_http + query = [(key, value) for key, value in query if key != field] + response = client.get(path, query_string=query + [(field, "{}"), (field, "{}")]) + assert response.status_code == 400 + assert "must not be repeated" in response.get_json()["message"] + dispatch.assert_not_called() + gateway.get_spm_capability.assert_not_called() + + +def test_unknown_economy_query_never_dispatches(economy_http): + client, path, query, dispatch, _, _ = economy_http + response = client.get(path, query_string=query + [("spmm", "{}")]) + assert response.status_code == 400 + assert "spmm" in response.get_json()["message"] + dispatch.assert_not_called() + + +def test_economy_required_query_fields_are_rejected_before_dispatch(economy_http): + client, path, query, dispatch, _, _ = economy_http + for missing, _ in query: + response = client.get( + path, query_string=[pair for pair in query if pair[0] != missing] + ) + assert response.status_code == 400 + assert missing in response.get_json()["message"] + dispatch.assert_not_called() + + +def test_omitted_selection_and_query_defaults_are_preserved(economy_http): + client, path, query, dispatch, _, setups = economy_http + response = client.get(path, query_string=query) + assert response.status_code == 200 + assert dispatch.call_args.kwargs["options"] == {} + assert dispatch.call_args.kwargs["dataset"] == "default" + assert dispatch.call_args.kwargs["target"] == "general" + assert setups[0].options["spm"]["geography_kind"] == "county" + + +def test_every_required_and_deprecated_query_field_is_scalar(economy_http): + client, path, query, dispatch, _, _ = economy_http + for field, value in query + [("include_district_breakdowns", "true")]: + duplicates = [(key, item) for key, item in query if key != field] + duplicates += [(field, value), (field, value)] + response = client.get(path, query_string=duplicates) + assert response.status_code == 400 + assert "must not be repeated" in response.get_json()["message"] + dispatch.assert_not_called() + + +@pytest.mark.parametrize("value", ["2.0", "0", "76", "abc"]) +def test_invalid_numeric_query_values_never_dispatch(economy_http, value): + client, path, query, dispatch, _, _ = economy_http + field = "window_size" if path.endswith("/budget-window") else "time_period" + query = [(key, item) for key, item in query if key != field] + response = client.get(path, query_string=query + [(field, value)]) + assert response.status_code == 400 + dispatch.assert_not_called() + + +@pytest.mark.parametrize("value", ["", "2026.0", "no-year"]) +def test_malformed_economy_years_never_dispatch(economy_http, value): + client, path, query, dispatch, _, _ = economy_http + field = "start_year" if path.endswith("/budget-window") else "time_period" + query = [(key, item) for key, item in query if key != field] + response = client.get(path, query_string=query + [(field, value)]) + assert response.status_code == 400 + dispatch.assert_not_called() + + +@pytest.mark.parametrize("version", ["5.2.1", "6.0.0"]) +def test_bundle_version_bump_without_measurements_still_dispatches( + economy_http, monkeypatch, version +): + """An unconfigured bundle must not block economy requests that send no spm.""" + client, path, query, dispatch, gateway, _ = economy_http + monkeypatch.setattr( + spm, + "_current_bundle", + lambda: { + "policyengine_version": version, + "packages": {"policyengine-us": {"version": "1.764.6"}}, + }, + ) + monkeypatch.setattr(spm, "simulation_supports_spm", lambda _: False) + response = client.get(path, query_string=query) + assert response.status_code == 200, response.get_json() + assert dispatch.call_args.kwargs["options"] == {} + gateway.get_spm_capability.assert_not_called() + + +def test_canonical_model_without_certification_refuses_economy_requests( + economy_http, monkeypatch +): + client, path, query, dispatch, gateway, _ = economy_http + monkeypatch.setattr( + spm, + "_current_bundle", + lambda: { + "policyengine_version": "5.2.0", + "packages": {"policyengine-us": {"version": "1.764.6"}}, + }, + ) + monkeypatch.setattr(spm, "simulation_supports_spm", lambda _: True) + response = client.get(path, query_string=query) + assert response.status_code == 400 + assert response.get_json()["errors"][0]["code"] == "SPM_CONFIGURATION_UNAVAILABLE" + gateway.get_spm_capability.assert_not_called() + + +PYDANTIC_INTERNALS = ("errors.pydantic.dev", "input_value", "validation error for") + + +@pytest.mark.parametrize( + "selection", + [ + {"geography_kind": "nowhere"}, + {"unreviewed_option": True}, + {"county_vintage": "not-a-year"}, + ], + ids=["invalid-value", "unknown-field", "bad-pattern"], +) +def test_a_rejected_economy_selection_quotes_no_validator_internals( + economy_http, selection +): + """The economy query parser is the other surface that rendered pydantic raw. + + Its 400 body is what an economy client displays to its own users, so it must + name the offending field and reason and nothing else. + """ + client, path, query, dispatch, _, _ = economy_http + response = client.get(path, query_string=query + [("spm", json.dumps(selection))]) + assert response.status_code == 400, response.get_json() + error = response.get_json()["errors"][0] + assert error["code"] == "SPM_SETTINGS_INVALID" + assert not any(part in error["message"] for part in PYDANTIC_INTERNALS) + assert error["message"] + dispatch.assert_not_called() + + +def test_a_rejected_economy_query_parameter_quotes_no_validator_internals(economy_http): + """A non-spm query failure renders through the same helper.""" + client, path, query, dispatch, _, _ = economy_http + response = client.get( + path, query_string=[item for item in query if item[0] != "region"] + ) + assert response.status_code == 400 + message = response.get_json()["message"] + assert not any(part in message for part in PYDANTIC_INTERNALS) + dispatch.assert_not_called() diff --git a/tests/unit/routes/test_household_numeric_identity.py b/tests/unit/routes/test_household_numeric_identity.py new file mode 100644 index 000000000..524cf4e5b --- /dev/null +++ b/tests/unit/routes/test_household_numeric_identity.py @@ -0,0 +1,227 @@ +"""Immutable households retain strict numeric simulation alias boundaries.""" + +from copy import deepcopy +import json + +from flask import Flask +import pytest + +from policyengine_api.data.v1_models import Simulation, SimulationRun +from policyengine_api.routes import household_routes, simulation_routes +from policyengine_api.services import household_service +from policyengine_api.services.household_service import HouseholdService +from policyengine_api.services.simulation_service import SimulationService + + +INPUTS = {"people": {"you": {"age": {"2026": 40}}}} +NATIONAL = { + "forecast_content_sha256": "a" * 64, + "scenario": "baseline", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, +} + + +@pytest.fixture +def household_identity_client(monkeypatch, orm_session_factory): + # Only certification is substituted. HTTP, services, SQL and transactions + # execute normally against the isolated in-memory ORM fixture. + def normalize(country_id, selection): + assert country_id == "us" + return {**NATIONAL, **(selection or {})} + + monkeypatch.setattr(household_service, "normalize_spm_selection", normalize) + monkeypatch.setattr( + household_routes, "household_service", HouseholdService(orm_session_factory) + ) + monkeypatch.setattr( + simulation_routes, + "simulation_service", + SimulationService(orm_session_factory), + ) + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(household_routes.household_bp) + app.register_blueprint(simulation_routes.simulation_bp) + client = app.test_client() + created = client.post( + "/us/household", + json={ + "data": INPUTS, + "label": "Original", + "spm": {"geography_kind": "national"}, + }, + ) + assert created.status_code == 201, created.json + return client, created.json["result"]["household_id"] + + +@pytest.mark.parametrize( + "spelling", + [ + "{id}suffix", + "{id}.0", + "+{id}", + " {id}", + "{id} ", + "{id}\n", + "arabic", + "fullwidth", + ], + ids=[ + "suffix", + "decimal", + "plus", + "leading-space", + "trailing-space", + "newline", + "arabic-digits", + "fullwidth-digits", + ], +) +def test_opaque_simulation_identity_stays_distinct_for_immutable_household( + household_identity_client, spelling +): + client, household_id = household_identity_client + url = f"/us/household/{household_id}" + original = client.get(url).json["result"] + changed = deepcopy(INPUTS) + changed["people"]["you"]["age"]["2026"] = 41 + control = client.put(url, json={"data": changed, "label": "Before simulation"}) + assert control.status_code == 405 + + if spelling in {"arabic", "fullwidth"}: + digits = "٠١٢٣٤٥٦٧٨٩" if spelling == "arabic" else "0123456789" + population_id = str(household_id).translate(str.maketrans("0123456789", digits)) + else: + population_id = spelling.format(id=household_id) + simulation = client.post( + "/us/simulation", + json={ + "population_id": population_id, + "population_type": "household", + "policy_id": 1, + }, + ) + assert simulation.status_code == 201, simulation.json + simulation_id = simulation.json["result"]["id"] + assert simulation.json["result"]["population_id"] == population_id + + changed["people"]["you"]["age"]["2026"] = 42 + response = client.put(url, json={"data": changed, "label": "After simulation"}) + assert response.status_code == 405 + assert client.get(url).json["result"] == original + + saved = client.get(f"/us/simulation/{simulation_id}").json["result"] + assert saved["population_id"] == population_id + assert json.loads(saved["simulation_spec_json"])["population_id"] == population_id + + canonical = client.post( + "/us/simulation", + json={ + "population_id": str(household_id), + "population_type": "household", + "policy_id": 1, + }, + ) + assert canonical.status_code == 201, canonical.json + canonical_id = canonical.json["result"]["id"] + assert canonical_id != simulation_id + assert canonical.json["result"]["population_id"] == str(household_id) + + opaque_replay = client.post( + "/us/simulation", + json={ + "population_id": population_id, + "population_type": "household", + "policy_id": 1, + }, + ) + assert opaque_replay.status_code == 200, opaque_replay.json + assert opaque_replay.json["result"]["id"] == simulation_id + assert opaque_replay.json["result"]["population_id"] == population_id + assert ( + json.loads(opaque_replay.json["result"]["simulation_spec_json"])[ + "population_id" + ] + == population_id + ) + + numeric_replay = client.post( + "/us/simulation", + json={ + "population_id": str(household_id).zfill(5), + "population_type": "household", + "policy_id": 1, + }, + ) + assert numeric_replay.status_code == 200, numeric_replay.json + assert numeric_replay.json["result"]["id"] == canonical_id + assert numeric_replay.json["result"]["population_id"] == str(household_id) + + +@pytest.mark.parametrize("reference", ["plain", "new-padded", "historical-padded"]) +@pytest.mark.parametrize("change", ["inputs", "selection"]) +def test_numeric_link_preserves_immutable_household_and_saved_simulation( + household_identity_client, orm_session_factory, reference, change +): + client, household_id = household_identity_client + url = f"/us/household/{household_id}" + original = client.get(url).json["result"] + population_id = ( + str(household_id) if reference == "plain" else str(household_id).zfill(5) + ) + output = {"result": deepcopy(INPUTS), "spm_config": deepcopy(NATIONAL)} + if reference == "historical-padded": + with orm_session_factory.begin() as session: + session.add( + Simulation( + country_id="us", + population_id=population_id, + population_type="household", + policy_id=1, + api_version="historical", + status="complete", + output=output, + ) + ) + + linked = client.post( + "/us/simulation", + json={ + "population_id": population_id, + "population_type": "household", + "policy_id": 1, + }, + ) + assert linked.status_code == (200 if reference == "historical-padded" else 201) + saved_spelling = ( + population_id if reference == "historical-padded" else str(household_id) + ) + simulation_id = linked.json["result"]["id"] + assert linked.json["result"]["population_id"] == saved_spelling + + changed = {"data": deepcopy(INPUTS), "label": "Rejected edit"} + if change == "inputs": + changed["data"]["people"]["you"]["age"]["2026"] = 41 + else: + changed["spm"] = {"geography_kind": "county"} + rejected = client.put(url, json=changed) + assert rejected.status_code == 405 + assert client.get(url).json["result"] == original + + renamed = client.put(url, json={"data": INPUTS, "label": "Renamed"}) + assert renamed.status_code == 405 + assert client.get(url).json["result"] == original + saved = client.get(f"/us/simulation/{simulation_id}").json["result"] + assert saved["population_id"] == saved_spelling + assert json.loads(saved["simulation_spec_json"])["population_id"] == saved_spelling + if reference == "historical-padded": + assert saved["status"] == "complete" + assert json.loads(saved["output"]) == output + with orm_session_factory() as session: + run = session.get(SimulationRun, saved["latest_successful_run_id"]) + assert run.simulation_spec_snapshot_json["population_id"] == saved_spelling + assert run.output == output diff --git a/tests/unit/routes/test_report_household_identity.py b/tests/unit/routes/test_report_household_identity.py new file mode 100644 index 000000000..36ae50b8c --- /dev/null +++ b/tests/unit/routes/test_report_household_identity.py @@ -0,0 +1,192 @@ +"""Report linkage preserves historical US household identity and saved spelling.""" + +import json + +from flask import Flask +import pytest +from sqlalchemy import select + +from policyengine_api.data.v1_models import ( + Household, + ReportOutput, + ReportOutputRun, + Simulation, +) +from policyengine_api.routes import report_output_routes, simulation_routes +from policyengine_api.services.report_output_service import ReportOutputService +from policyengine_api.services.report_spec_service import ReportSpecService +from policyengine_api.services.simulation_service import SimulationService + + +def test_historical_baseline_and_new_reform_persist_complete_comparison_report( + monkeypatch, orm_session_factory +): + historical_output = {"household": {"people": {"you": {"age": {"2026": 40}}}}} + with orm_session_factory.begin() as session: + session.add( + Household( + id=1, + country_id="us", + api_version="historical", + household_json=historical_output["household"], + household_hash="historical-household-1", + ) + ) + baseline = Simulation( + country_id="us", + population_id="00001", + population_type="household", + policy_id=1, + api_version="historical", + status="complete", + output=historical_output, + ) + session.add(baseline) + session.flush() + baseline_id = baseline.id + + monkeypatch.setattr( + simulation_routes, "simulation_service", SimulationService(orm_session_factory) + ) + monkeypatch.setattr( + report_output_routes, + "report_output_service", + ReportOutputService(orm_session_factory), + ) + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(simulation_routes.simulation_bp) + app.register_blueprint(report_output_routes.report_output_bp) + client = app.test_client() + + replay = client.post( + "/us/simulation", + json={"population_id": "00001", "population_type": "household", "policy_id": 1}, + ) + assert replay.status_code == 200, replay.json + assert replay.json["result"]["id"] == baseline_id + assert replay.json["result"]["population_id"] == "00001" + assert replay.json["result"]["status"] == "complete" + assert json.loads(replay.json["result"]["output"]) == historical_output + + reform = client.post( + "/us/simulation", + json={"population_id": "00001", "population_type": "household", "policy_id": 2}, + ) + assert reform.status_code == 201, reform.json + reform_id = reform.json["result"]["id"] + assert reform_id != baseline_id + assert reform.json["result"]["population_id"] == "1" + assert reform.json["result"]["status"] == "pending" + + payload = { + "simulation_1_id": baseline_id, + "simulation_2_id": reform_id, + "year": "2026", + } + expected_spec = { + "country_id": "us", + "report_kind": "household_comparison", + "time_period": "2026", + "simulation_1": { + "population_type": "household", + "population_id": "00001", + "policy_id": 1, + }, + "simulation_2": { + "population_type": "household", + "population_id": "1", + "policy_id": 2, + }, + } + created = client.post("/us/report", json=payload) + assert created.status_code == 201, created.json + report_id = created.json["result"]["id"] + replayed = client.post("/us/report", json=payload) + retrieved = client.get(f"/us/report/{report_id}") + assert replayed.status_code == retrieved.status_code == 200 + for response in (created, replayed, retrieved): + result = response.json["result"] + assert result["id"] == report_id + assert result["report_kind"] == "household_comparison" + assert result["report_spec_json"] == expected_spec + assert result["report_spec_schema_version"] == 1 + assert result["report_spec_status"] == "explicit" + assert result["status"] == "pending" + + with orm_session_factory() as session: + saved_baseline = session.get(Simulation, baseline_id) + saved_reform = session.get(Simulation, reform_id) + assert saved_baseline.population_id == "00001" + assert saved_baseline.output == historical_output + assert saved_baseline.simulation_spec_json["population_id"] == "00001" + assert saved_reform.population_id == "1" + assert saved_reform.simulation_spec_json["population_id"] == "1" + saved_report = session.get(ReportOutput, report_id) + assert saved_report.report_kind == "household_comparison" + assert saved_report.report_spec_json == expected_spec + assert saved_report.report_spec_schema_version == 1 + assert saved_report.report_spec_status == "explicit" + runs = list( + session.scalars( + select(ReportOutputRun).where( + ReportOutputRun.report_output_id == report_id + ) + ) + ) + assert len(runs) == 1 + assert saved_report.active_run_id == runs[0].id + assert runs[0].report_spec_snapshot_json == expected_spec + assert runs[0].status == "pending" + + +def comparison_models(country_id, population_type, first_id, second_id): + first = Simulation( + id=1, + country_id=country_id, + population_type=population_type, + population_id=first_id, + policy_id=1, + ) + second = Simulation( + id=2, + country_id=country_id, + population_type=population_type, + population_id=second_id, + policy_id=2, + ) + report = ReportOutput( + country_id=country_id, simulation_1_id=1, simulation_2_id=2, year="2026" + ) + return report, first, second + + +@pytest.mark.parametrize( + "unrelated_id", ["1suffix", "1.0", "+1", "1\n", "١", "1", "2"] +) +def test_household_report_does_not_alias_nonnumeric_or_different_ids(unrelated_id): + models = comparison_models("us", "household", "00001", unrelated_id) + with pytest.raises(ValueError, match="matching household IDs"): + ReportSpecService().build_report_spec(*models) + + +@pytest.mark.parametrize( + "country_id,population_type", + [("uk", "household"), ("us", "geography"), ("uk", "geography")], +) +def test_numeric_report_aliases_are_limited_to_us_households( + country_id, population_type +): + models = comparison_models(country_id, population_type, "00001", "1") + with pytest.raises(ValueError, match="require matching"): + ReportSpecService().build_report_spec(*models) + + +@pytest.mark.parametrize( + "population_id", ["household-1", "1suffix", "1.0", "+1", "1\n", "١", "1"] +) +def test_matching_literal_household_ids_retain_saved_spelling(population_id): + models = comparison_models("us", "household", population_id, population_id) + spec = ReportSpecService().build_report_spec(*models) + assert spec.simulation_1.population_id == population_id + assert spec.simulation_2.population_id == population_id diff --git a/tests/unit/routes/test_simulation_spm.py b/tests/unit/routes/test_simulation_spm.py new file mode 100644 index 000000000..59b591a5a --- /dev/null +++ b/tests/unit/routes/test_simulation_spm.py @@ -0,0 +1,96 @@ +"""Simulation records must not silently discard independent SPM overrides.""" + +import json +from unittest.mock import patch + +import pytest +from flask import Flask +from sqlalchemy import func, select + +from policyengine_api.data.v1_models import Simulation, SimulationRun +from policyengine_api.routes.simulation_routes import simulation_bp +from policyengine_api.services.simulation_service import SimulationService + + +@pytest.mark.parametrize("country_id", ["us", "uk"]) +@pytest.mark.parametrize("spm", [None, {}, {"geography_kind": "national"}]) +@pytest.mark.parametrize("method", ["POST", "PATCH"]) +def test_simulation_records_reject_spm_overrides_before_read_or_write( + country_id, spm, method +): + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(simulation_bp) + payload = {"spm": spm} + if method == "POST": + payload.update(population_id="1", population_type="household", policy_id=1) + else: + payload.update(id=1, status="complete", output={}) + + with patch( + "policyengine_api.routes.simulation_routes.simulation_service" + ) as service: + response = app.test_client().open( + f"/{country_id}/simulation", method=method, json=payload + ) + + assert response.status_code == 400 + result = response.get_json() + assert result["status"] == "error" + assert result["result"] is None + assert result["errors"] == [ + {"code": "SPM_SETTINGS_UNSUPPORTED", "message": result["message"]} + ] + assert "linked household" in result["message"] + assert not service.mock_calls + + +@pytest.mark.parametrize("population_id", ["00001", "1", "0000001", 1]) +def test_repost_historical_numeric_household_alias_replays_completed_output( + orm_session_factory, population_id +): + output = {"household": {"people": {}}, "spm_config": {"geography_kind": "national"}} + with orm_session_factory.begin() as session: + historical = Simulation( + country_id="us", + population_id="00001", + population_type="household", + policy_id=1, + api_version="historical", + status="complete", + output=output, + ) + session.add(historical) + session.flush() + historical_id = historical.id + + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(simulation_bp) + service = SimulationService(orm_session_factory) + with patch("policyengine_api.routes.simulation_routes.simulation_service", service): + for _ in range(2): + response = app.test_client().post( + "/us/simulation", + json={ + "population_id": population_id, + "population_type": "household", + "policy_id": 1, + }, + ) + assert response.status_code == 200 + result = response.get_json()["result"] + assert result["id"] == historical_id + assert result["status"] == "complete" + assert json.loads(result["output"]) == output + assert result["population_id"] == "00001" + assert ( + json.loads(result["simulation_spec_json"])["population_id"] == "00001" + ) + + with orm_session_factory() as session: + assert session.scalar(select(func.count()).select_from(Simulation)) == 1 + run = session.scalar(select(SimulationRun)) + assert run.status == "complete" + assert run.output == output + assert run.simulation_spec_snapshot_json["population_id"] == "00001" diff --git a/tests/unit/routes/test_spm_year_worker_polling.py b/tests/unit/routes/test_spm_year_worker_polling.py new file mode 100644 index 000000000..797a57097 --- /dev/null +++ b/tests/unit/routes/test_spm_year_worker_polling.py @@ -0,0 +1,375 @@ +"""Typed worker failures become durable terminal HTTP polling responses.""" + +from flask import Flask +import httpx +import pytest + +from policyengine_api import worker_spm +from policyengine_api.libs.simulation_entrypoint import SimulationEntrypointClient +from policyengine_api.routes import economy_routes +from policyengine_api.runtime_cache.core import CacheNamespace +from policyengine_api.runtime_cache.fake import InMemoryCacheBackend +from policyengine_api.runtime_cache.reform_impacts import ReformImpactCache +from policyengine_api.services import economy_service as economy_module +from policyengine_api.services.budget_window_cache import BudgetWindowCache +from policyengine_api.services.economy_service import EconomyService +from policyengine_api.services.reform_impacts_service import ReformImpactsService + + +SELECTION = { + "forecast_content_sha256": "a" * 64, + "scenario": "baseline", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, +} +YEAR_ERROR = { + "code": "SPM_YEAR_UNAVAILABLE", + "message": "Forecast has no entry for 2036", +} + + +def segmented_result(year, invalid_side=None): + receipt = { + "forecast_id": "test-only", + "forecast_sha256": SELECTION["forecast_content_sha256"], + "scenario": SELECTION["scenario"], + "geography_kind": SELECTION["geography_kind"], + "runtime_versions": {}, + "years": {year: {}}, + "geographies": [], + "composition_method": "classified", + "storage_method": "formula", + } + result = { + "year": year, + "spm_config": SELECTION, + "spm_provenance": { + "baseline": [dict(receipt), dict(receipt)], + "reform": [dict(receipt), dict(receipt)], + }, + } + if invalid_side: + result["spm_provenance"][invalid_side][1]["years"] = {"2000": {}} + return result + + +@pytest.mark.parametrize( + "failure", [400, 422, "segmented-baseline", "segmented-reform"] +) +@pytest.mark.parametrize("budget_window", [False, True], ids=["annual", "window"]) +def test_typed_worker_error_is_terminal_and_replays_after_service_recreation( + monkeypatch, failure, budget_window +): + # Certification is synthetic; the HTTP consumer, routes and both caches are + # real. No worker execution or dataset loading is needed for this boundary. + monkeypatch.setattr(worker_spm, "normalize_spm_selection", lambda *_: SELECTION) + monkeypatch.setattr(economy_module, "COUNTRY_PACKAGE_VERSIONS", {"us": "1.0.0"}) + monkeypatch.setattr(economy_module, "POLICYENGINE_VERSION", "5.3.1") + job_id = "unsupported-year-job" + job_path = f"/budget-window-jobs/{job_id}" if budget_window else f"/jobs/{job_id}" + received = [] + worker_app = "test-worker" + typed_error = YEAR_ERROR + if isinstance(failure, str): + typed_error = { + "code": "SPM_CONFIGURATION_UNAVAILABLE", + "message": "Worker SPM receipt does not cover requested year", + } + side = failure.removeprefix("segmented-") + worker_result = ( + { + "kind": "budgetWindow", + "windowSize": 2, + "annualImpacts": [ + segmented_result("2035"), + segmented_result("2036", side), + ], + } + if budget_window + else segmented_result("2036", side) + ) + + def transport(request): + received.append((request.method, request.url.path)) + if request.method == "POST" and worker_app == "replacement-worker": + assert request.url.path == "/simulate/economy/comparison" + return httpx.Response( + 200, json={"job_id": "replacement-job", "status": "submitted"} + ) + assert request.method == "GET", "Polling must not submit a replacement job" + if request.url.path == "/versions": + return httpx.Response( + 200, + json={ + "policyengine": {"5.3.1": worker_app}, + "spm_capabilities": { + "5.3.1": { + "contract_version": "canonical-spm-v1", + "defaults": SELECTION, + } + }, + }, + ) + if request.url.path == "/versions/policyengine": + return httpx.Response(200, json={"5.3.1": worker_app}) + assert request.url.path == job_path + if received.count(("GET", job_path)) > 1: + return httpx.Response(404, json={"detail": "Job expired"}) + if isinstance(failure, str): + return httpx.Response( + 200, json={"status": "complete", "result": worker_result} + ) + return httpx.Response( + failure, + json={"status": "error", "errors": [typed_error]}, + ) + + gateway = object.__new__(SimulationEntrypointClient) + gateway.base_url = "https://worker.invalid" + backend = InMemoryCacheBackend() + namespace = CacheNamespace("test", "typed-year-worker-polling") + annual_cache = ReformImpactCache(backend, namespace) + annual_impacts = ReformImpactsService(annual_cache) + window_cache = BudgetWindowCache(backend, namespace) + service = EconomyService( + reform_impacts_service_=annual_impacts, + budget_window_cache_=window_cache, + simulation_entrypoint_=gateway, + ) + monkeypatch.setattr(economy_routes, "economy_service", service) + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(economy_routes.economy_bp) + + with httpx.Client(transport=httpx.MockTransport(transport)) as gateway.client: + setup = service._build_economic_impact_setup_options( + country_id="us", + policy_id=1, + baseline_policy_id=2, + region="us", + dataset="default", + time_period="budget_window:2035:2" if budget_window else "2036", + options={}, + api_version="1.0.0", + ) + if budget_window: + cache_key = service._build_budget_window_cache_key(setup) + window_cache.store_batch_job_id(cache_key, job_id) + url = "/us/economy/1/over/2/budget-window" + query = {"start_year": "2035", "window_size": "2"} + else: + service._resolve_runtime_bundle_for_setup_options(setup) + annual_impacts.set_reform_impact( + country_id=setup.country_id, + policy_id=setup.reform_policy_id, + baseline_policy_id=setup.baseline_policy_id, + region=setup.region, + dataset=setup.dataset, + time_period=setup.time_period, + options=setup.options, + options_hash=setup.options_hash, + status="computing", + api_version=setup.api_version, + reform_impact_json={}, + start_time=None, + execution_id=job_id, + ) + url = "/us/economy/1/over/2" + query = {"time_period": "2036"} + + received.clear() + for _ in range(2): + response = app.test_client().get( + url, + query_string={"region": "us", "version": "1.0.0", **query}, + ) + assert response.status_code == 400, response.json + assert response.json == { + "status": "error", + "result": None, + "message": typed_error["message"], + "errors": [typed_error], + } + if budget_window: + assert window_cache.get_completed_result(cache_key) is None + assert window_cache.get_batch_job_id(cache_key) is None + assert window_cache.get_terminal_error(cache_key) == typed_error + else: + stored = annual_cache.get_by_execution_id(job_id) + assert stored.status == "error" + assert stored.reform_impact_json == {} + assert stored.error_code == typed_error["code"] + assert stored.message == typed_error["message"] + assert stored.execution_id is None + assert stored.end_time is not None + assert stored.options_hash == setup.options_hash + assert stored.options_json == setup.options + + # The worker job vanishes after its first failure. Recreate both + # services and cache facades to prove replay uses shared storage. + annual_cache = ReformImpactCache(backend, namespace) + annual_impacts = ReformImpactsService(annual_cache) + window_cache = BudgetWindowCache(backend, namespace) + service = EconomyService( + reform_impacts_service_=annual_impacts, + budget_window_cache_=window_cache, + simulation_entrypoint_=gateway, + ) + monkeypatch.setattr(economy_routes, "economy_service", service) + + if not budget_window: + # Preserve the existing retry policy: only a newly resolved worker + # creates a fresh canonical cache identity and permits submission. + worker_app = "replacement-worker" + monkeypatch.setattr(service, "_get_policy_jsons", lambda *_: ({}, {})) + response = app.test_client().get( + url, + query_string={"region": "us", "version": "1.0.0", **query}, + ) + assert response.status_code == 200, response.json + assert response.json["status"] == "computing" + replacement = annual_cache.get_by_execution_id("replacement-job") + assert replacement.options_hash != setup.options_hash + assert replacement.options_json == setup.options + assert annual_cache.get_by_execution_id(job_id).status == "error" + assert received.count(("POST", "/simulate/economy/comparison")) == 1 + + assert received.count(("GET", job_path)) == 1 + + +@pytest.mark.parametrize("budget_window", [False, True], ids=["annual", "window"]) +def test_an_uncertifiable_stored_result_becomes_terminal_instead_of_repeating( + monkeypatch, budget_window +): + """A stored success this build cannot certify needs somewhere to end. + + Both stores already replay a recorded typed failure before they read their + success payload. Without recording one, the same uncertifiable payload is + re-validated on every poll: a 400 the caller can never move past and a + worker handle that is never released. + """ + monkeypatch.setattr(worker_spm, "normalize_spm_selection", lambda *_: SELECTION) + monkeypatch.setattr(economy_module, "COUNTRY_PACKAGE_VERSIONS", {"us": "1.0.0"}) + monkeypatch.setattr(economy_module, "POLICYENGINE_VERSION", "5.3.1") + job_id = "uncertifiable-stored-result" + received = [] + + def transport(request): + received.append((request.method, request.url.path)) + assert request.method == "GET", "A stored result must not submit a job" + if request.url.path == "/versions": + return httpx.Response( + 200, + json={ + "policyengine": {"5.3.1": "test-worker"}, + "spm_capabilities": { + "5.3.1": { + "contract_version": "canonical-spm-v1", + "defaults": SELECTION, + } + }, + }, + ) + if request.url.path == "/versions/policyengine": + return httpx.Response(200, json={"5.3.1": "test-worker"}) + raise AssertionError(f"Unexpected worker call: {request.url.path}") + + gateway = object.__new__(SimulationEntrypointClient) + gateway.base_url = "https://worker.invalid" + backend = InMemoryCacheBackend() + namespace = CacheNamespace("test", "uncertifiable-stored-result") + annual_cache = ReformImpactCache(backend, namespace) + annual_impacts = ReformImpactsService(annual_cache) + window_cache = BudgetWindowCache(backend, namespace) + service = EconomyService( + reform_impacts_service_=annual_impacts, + budget_window_cache_=window_cache, + simulation_entrypoint_=gateway, + ) + monkeypatch.setattr(economy_routes, "economy_service", service) + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(economy_routes.economy_bp) + + # A stored payload whose reform receipt covers the wrong year: a success + # this build cannot certify, exactly as a worker defect would leave it. + with httpx.Client(transport=httpx.MockTransport(transport)) as gateway.client: + setup = service._build_economic_impact_setup_options( + country_id="us", + policy_id=1, + baseline_policy_id=2, + region="us", + dataset="default", + time_period="budget_window:2035:2" if budget_window else "2036", + options={}, + api_version="1.0.0", + ) + if budget_window: + cache_key = service._build_budget_window_cache_key(setup) + window_cache.set_completed_result( + cache_key, + { + "kind": "budgetWindow", + "windowSize": 2, + "annualImpacts": [ + segmented_result("2035"), + segmented_result("2036", "reform"), + ], + }, + ) + url = "/us/economy/1/over/2/budget-window" + query = {"start_year": "2035", "window_size": "2"} + else: + service._resolve_runtime_bundle_for_setup_options(setup) + annual_impacts.set_reform_impact( + country_id=setup.country_id, + policy_id=setup.reform_policy_id, + baseline_policy_id=setup.baseline_policy_id, + region=setup.region, + dataset=setup.dataset, + time_period=setup.time_period, + options=setup.options, + options_hash=setup.options_hash, + status="ok", + api_version=setup.api_version, + reform_impact_json=segmented_result("2036", "reform"), + start_time=None, + execution_id=job_id, + ) + url = "/us/economy/1/over/2" + query = {"time_period": "2036"} + + first = None + for _ in range(2): + response = app.test_client().get( + url, + query_string={"region": "us", "version": "1.0.0", **query}, + ) + assert response.status_code == 400, response.json + assert response.json["errors"][0]["code"] == "SPM_CONFIGURATION_UNAVAILABLE" + if first is None: + first = response.json + assert response.json == first + + if budget_window: + terminal = window_cache.get_terminal_error(cache_key) + assert terminal["code"] == "SPM_CONFIGURATION_UNAVAILABLE" + else: + stored = annual_cache.get_by_execution_id(job_id) + assert stored.status == "error" + assert stored.error_code == "SPM_CONFIGURATION_UNAVAILABLE" + assert stored.execution_id is None + assert stored.options_hash == setup.options_hash + + # Replay must come from storage, not from re-reading the payload. + annual_cache = ReformImpactCache(backend, namespace) + annual_impacts = ReformImpactsService(annual_cache) + window_cache = BudgetWindowCache(backend, namespace) + service = EconomyService( + reform_impacts_service_=annual_impacts, + budget_window_cache_=window_cache, + simulation_entrypoint_=gateway, + ) + monkeypatch.setattr(economy_routes, "economy_service", service) diff --git a/tests/unit/runtime_cache/test_household_calculations.py b/tests/unit/runtime_cache/test_household_calculations.py index 702f5ebf9..27b23a044 100644 --- a/tests/unit/runtime_cache/test_household_calculations.py +++ b/tests/unit/runtime_cache/test_household_calculations.py @@ -1,8 +1,11 @@ """Calculated-household result cache tests.""" -from policyengine_api.runtime_cache.core import CacheNamespace +import pytest + +from policyengine_api.runtime_cache.core import CacheNamespace, encode_envelope from policyengine_api.runtime_cache.fake import InMemoryCacheBackend from policyengine_api.runtime_cache.household_calculations import ( + HOUSEHOLD_CALCULATION_SCHEMA_VERSION, CachedHouseholdCalculation, HouseholdCalculationCache, HouseholdCalculationIdentity, @@ -46,3 +49,177 @@ def test_household_and_warnings_share_one_atomic_versioned_value() -> None: assert cache.cache_key(identity) != cache.cache_key( _identity(country_package_version="9.9.9") ) + + +def test_selection_participates_in_cache_identity() -> None: + cache = HouseholdCalculationCache(InMemoryCacheBackend(), _namespace()) + + assert cache.cache_key(_identity()) != cache.cache_key( + _identity(spm={"geography_kind": "national"}) + ) + assert cache.cache_key(_identity(spm={"geography_kind": "national"})) != ( + cache.cache_key(_identity(spm={"geography_kind": "county"})) + ) + + +def test_receipt_bearing_payload_supersedes_the_receiptless_schema() -> None: + """Entries written before the selection joined this family are misses.""" + backend = InMemoryCacheBackend() + cache = HouseholdCalculationCache(backend, _namespace()) + identity = _identity() + previous_version = HOUSEHOLD_CALCULATION_SCHEMA_VERSION - 1 + backend.set( + _namespace().key( + "household-calculation", + previous_version, + {name: value for name, value in vars(identity).items() if name != "spm"}, + ), + encode_envelope( + "household-calculation", + previous_version, + {"household": {"people": {}}, "warnings": []}, + ), + ) + + assert cache.get(identity) is None + + +SPM_CONFIG = { + "forecast_content_sha256": "a" * 64, + "scenario": "baseline", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, +} +SPM_RECEIPT = { + "forecast_id": "test-artifact", + "forecast_sha256": "a" * 64, + "scenario": "baseline", + "geography_kind": "national", + "runtime_versions": {"policyengine-us": "test"}, + "years": {}, + "geographies": [], + "composition_method": "test composition", + "storage_method": "test storage", +} + + +@pytest.mark.parametrize( + "receipt", + [ + {}, + {**SPM_RECEIPT, "forecast_sha256": "b" * 64}, + {**SPM_RECEIPT, "scenario": "other"}, + {**SPM_RECEIPT, "geography_kind": "county"}, + {**SPM_RECEIPT, "years": []}, + {**SPM_RECEIPT, "unexpected": True}, + ], + ids=["empty", "hash", "scenario", "geography", "years-shape", "unknown-field"], +) +def test_household_cache_rejects_invalid_or_mismatched_spm_receipts(receipt): + cache = HouseholdCalculationCache(InMemoryCacheBackend(), _namespace()) + identity = _identity(spm=SPM_CONFIG) + value = CachedHouseholdCalculation( + household={"people": {}}, + spm_config=SPM_CONFIG, + spm_provenance=receipt, + ) + assert cache.set(identity, value) is True + assert cache.get(identity) is None + + +@pytest.mark.parametrize("years", [{}, {"2024": {"source": "test"}}]) +def test_household_cache_keeps_valid_receipts_including_lazy_tax_only(years): + cache = HouseholdCalculationCache(InMemoryCacheBackend(), _namespace()) + identity = _identity(spm=SPM_CONFIG) + value = CachedHouseholdCalculation( + household={"people": {}}, + spm_config=SPM_CONFIG, + spm_provenance={**SPM_RECEIPT, "years": years}, + ) + assert cache.set(identity, value) is True + assert cache.get(identity) == value + + +@pytest.mark.parametrize("calculate_spm", [False, True], ids=["tax-only", "measured"]) +def test_real_country_provider_receipt_round_trips_household_cache(calculate_spm): + country_spm = pytest.importorskip("policyengine_us.spm") + provider = country_spm.create_spm_provider({"geography_kind": "national"}) + if calculate_spm: + provider.calculate_unit( + year=provider.forecast.years[0], + adults=1, + children=0, + tenure="renter", + ) + config = country_spm.spm_config(provider) + receipt = provider.provenance() + assert bool(receipt["years"]) is calculate_spm + + cache = HouseholdCalculationCache(InMemoryCacheBackend(), _namespace()) + identity = _identity(spm=config) + value = CachedHouseholdCalculation( + household={"people": {}}, + spm_config=config, + spm_provenance=receipt, + ) + assert cache.set(identity, value) is True + assert cache.get(identity) == value + + +OMITTED_NULL_CONFIG = { + key: value for key, value in SPM_CONFIG.items() if value is not None +} + + +def test_household_cache_hits_when_receipt_omits_null_settings(): + """A canonical receipt may omit its null values; that is still the same selection.""" + cache = HouseholdCalculationCache(InMemoryCacheBackend(), _namespace()) + identity = _identity(spm=SPM_CONFIG) + value = CachedHouseholdCalculation( + household={"people": {}}, + spm_config=OMITTED_NULL_CONFIG, + spm_provenance=SPM_RECEIPT, + ) + assert cache.set(identity, value) is True + assert cache.get(identity) == value + + +@pytest.mark.parametrize("omitted", sorted(OMITTED_NULL_CONFIG)) +def test_household_cache_rejects_receipt_omitting_a_nonnull_setting(omitted): + """An omitted non-null setting must never inherit today's resolved default.""" + cache = HouseholdCalculationCache(InMemoryCacheBackend(), _namespace()) + identity = _identity(spm=SPM_CONFIG) + value = CachedHouseholdCalculation( + household={"people": {}}, + spm_config={ + key: item for key, item in OMITTED_NULL_CONFIG.items() if key != omitted + }, + spm_provenance=SPM_RECEIPT, + ) + assert cache.set(identity, value) is True + assert cache.get(identity) is None + + +@pytest.mark.parametrize( + "changed", + [ + {"geography_kind": "county"}, + {"scenario": "other"}, + {"county_vintage": "2010"}, + {"geography_id": "31080"}, + {"as_of": "2026-09-09"}, + {"unexpected": True}, + ], +) +def test_household_cache_rejects_receipt_with_different_settings(changed): + cache = HouseholdCalculationCache(InMemoryCacheBackend(), _namespace()) + identity = _identity(spm=SPM_CONFIG) + value = CachedHouseholdCalculation( + household={"people": {}}, + spm_config={**OMITTED_NULL_CONFIG, **changed}, + spm_provenance=SPM_RECEIPT, + ) + assert cache.set(identity, value) is True + assert cache.get(identity) is None diff --git a/tests/unit/runtime_cache/test_reform_impacts.py b/tests/unit/runtime_cache/test_reform_impacts.py index cb5a1e5ed..98b134b4f 100644 --- a/tests/unit/runtime_cache/test_reform_impacts.py +++ b/tests/unit/runtime_cache/test_reform_impacts.py @@ -2,6 +2,8 @@ from datetime import datetime +import pytest + from policyengine_api.runtime_cache.core import CacheNamespace from policyengine_api.runtime_cache.fake import InMemoryCacheBackend from policyengine_api.runtime_cache.reform_impacts import ( @@ -153,3 +155,52 @@ def test_reform_impact_updates_and_deletes_only_matching_computing_values() -> N ) assert cache.get_by_execution_id("delete") is None assert cache.get_by_execution_id("retain") is not None + + +def test_terminal_error_clears_worker_handle_without_changing_cache_identity( + monkeypatch, +) -> None: + import policyengine_api.runtime_cache.reform_impacts as module + + monkeypatch.setattr(module, "jittered_ttl", lambda _ttl: 123) + backend = InMemoryCacheBackend() + cache = ReformImpactCache(backend, _namespace()) + cache.set(_impact("job-a", "selection-a", 1)) + cache.set(_impact("job-b", "selection-b", 2)) + for job in ("job-a", "job-b"): + cache.update( + job, + status="error", + error_code="SPM_YEAR_UNAVAILABLE", + message=job, + clear_execution_id=True, + ) + + cache = ReformImpactCache(backend, _namespace()) + failure = cache.get_by_execution_id("job-a") + assert failure.execution_id is None + assert failure.error_code == "SPM_YEAR_UNAVAILABLE" + assert failure.options_hash == "selection-a" + assert failure.reform_impact_id == reform_impact_id("job-a") + assert {row.options_hash for row in cache.recent(10)} == { + "selection-a", + "selection-b", + } + assert cache.update("job-a", message="updated").message == "updated" + assert cache.get_by_execution_id("job-b").message == "job-b" + with pytest.raises(ValueError, match="record identifier is required"): + cache.set(failure) + assert set(backend._expires.values()) == {123} + backend.advance(123) + assert cache.recent(10) == [] + + +def test_failed_terminal_error_write_keeps_existing_worker_handle(monkeypatch): + cache = ReformImpactCache(InMemoryCacheBackend(), _namespace()) + cache.set(_impact("job", "selection", 1)) + monkeypatch.setattr(cache, "set", lambda *args, **kwargs: False) + + assert cache.update("job", status="error", clear_execution_id=True) is None + unchanged = cache.get_by_execution_id("job") + assert unchanged.status == "computing" + assert unchanged.execution_id == "job" diff --git a/tests/unit/services/test_budget_window_cache.py b/tests/unit/services/test_budget_window_cache.py index d71467bc2..0bb51ee9d 100644 --- a/tests/unit/services/test_budget_window_cache.py +++ b/tests/unit/services/test_budget_window_cache.py @@ -93,6 +93,36 @@ def test_completed_result_round_trips(): assert cache.get_completed_result("budget_window:v1:us:key") == result +def test_terminal_error_round_trips_separately_from_success_and_other_selections( + monkeypatch, +): + import policyengine_api.services.budget_window_cache as module + + monkeypatch.setattr(module, "jittered_ttl", lambda _ttl: 123) + backend = FakeRedis() + cache = BudgetWindowCache(client=backend) + error = {"code": "SPM_YEAR_UNAVAILABLE", "message": "No forecast for 2036"} + identity = { + "country_id": "us", + "reform_policy_id": 1, + "baseline_policy_id": 2, + "region": "us", + "dataset": "default", + "time_period": "budget_window:2035:2", + "api_version": "v1", + } + failed_key = cache.build_key(**identity, options_hash="canonical-selection-a") + other_key = cache.build_key(**identity, options_hash="canonical-selection-b") + assert cache.set_terminal_error(failed_key, error) + cache = BudgetWindowCache(client=backend) + assert cache.get_terminal_error(failed_key) == error + assert cache.get_terminal_error(other_key) is None + assert cache.get_completed_result(failed_key) is None + assert set(backend._expires.values()) == {123} + backend.advance(123) + assert cache.get_terminal_error(failed_key) is None + + def test_completed_result_ttl_is_jittered_but_coordination_ttls_are_exact( monkeypatch, ): diff --git a/tests/unit/services/test_economy_service.py b/tests/unit/services/test_economy_service.py index 94e11f364..97c942623 100644 --- a/tests/unit/services/test_economy_service.py +++ b/tests/unit/services/test_economy_service.py @@ -18,6 +18,7 @@ ImpactStatus, ) from policyengine_api.services.policy_service import PolicyService +from policyengine_api.spm import SPMValidationError from tests.fixtures.services.economy_service import ( MOCK_API_VERSION, MOCK_BASELINE_POLICY_ID, @@ -1203,6 +1204,45 @@ def test__given_failed_batch_poll__returns_failed( "budget-window-cache-key" ) + def test_typed_error_write_failure_retains_batch_identity( + self, + economy_service, + base_params, + mock_simulation_entrypoint, + mock_budget_window_cache, + ): + error = SPMValidationError("SPM_YEAR_UNAVAILABLE", "No forecast for 2036") + mock_budget_window_cache.get_batch_job_id.return_value = "expired-job" + mock_budget_window_cache.set_terminal_error.return_value = False + mock_simulation_entrypoint.get_budget_window_batch_by_id.side_effect = error + + with pytest.raises(SPMValidationError) as raised: + economy_service.get_budget_window_economic_impact(**base_params) + + assert raised.value is error + mock_budget_window_cache.clear_batch_job_id.assert_not_called() + mock_budget_window_cache.set_completed_result.assert_not_called() + mock_simulation_entrypoint.run_budget_window_batch.assert_not_called() + + def test_untyped_poll_error_keeps_existing_retry_behavior( + self, + economy_service, + base_params, + mock_simulation_entrypoint, + mock_budget_window_cache, + ): + error = make_http_status_error(422, payload={"detail": "Unknown error"}) + mock_budget_window_cache.get_batch_job_id.return_value = "existing-job" + mock_simulation_entrypoint.get_budget_window_batch_by_id.side_effect = error + + with pytest.raises(httpx.HTTPStatusError) as raised: + economy_service.get_budget_window_economic_impact(**base_params) + + assert raised.value is error + mock_budget_window_cache.set_terminal_error.assert_not_called() + mock_budget_window_cache.clear_batch_job_id.assert_not_called() + mock_simulation_entrypoint.run_budget_window_batch.assert_not_called() + def test__given_existing_start_claim__does_not_submit_duplicate_batch( self, economy_service, @@ -1657,6 +1697,24 @@ def setup_options(self): options_hash=MOCK_OPTIONS_HASH, ) + def test_untyped_poll_error_does_not_persist_a_terminal_failure( + self, + economy_service, + setup_options, + mock_simulation_entrypoint, + mock_reform_impacts_service, + ): + impact = create_mock_reform_impact(status="computing") + error = make_http_status_error(422, payload={"detail": "Unknown error"}) + mock_simulation_entrypoint.get_execution_by_id.side_effect = error + + with pytest.raises(httpx.HTTPStatusError) as raised: + economy_service._handle_computing_impact(setup_options, impact) + + assert raised.value is error + mock_reform_impacts_service.set_error_reform_impact.assert_not_called() + mock_simulation_entrypoint.run.assert_not_called() + def test__given_succeeded_state__returns_completed_result( self, economy_service, diff --git a/tests/unit/services/test_policy_service.py b/tests/unit/services/test_policy_service.py index f9255ecbf..1abda22a1 100644 --- a/tests/unit/services/test_policy_service.py +++ b/tests/unit/services/test_policy_service.py @@ -188,6 +188,11 @@ def test_set_policy_does_not_build_v2_snapshot_unless_requested( "policyengine_api.services.policy_service.hash_object", lambda value: "new-hash", ) + monkeypatch.setattr( + service._sessions.class_, + "refresh", + lambda *args, **kwargs: pytest.fail("cloud_sql-only writes must not refresh"), + ) result = service.set_policy( "ca", @@ -221,3 +226,23 @@ def test_set_policy_propagates_flush_failure( with pytest.raises(SQLAlchemyError, match="insert failed"): service.set_policy("us", "Policy", {}) + + +def test_mirror_snapshot_read_failure_rolls_back_new_v1_policy( + service, orm_session_factory, monkeypatch +): + with monkeypatch.context() as failing_read: + failing_read.setattr( + orm_session_factory.class_, + "refresh", + lambda *args, **kwargs: (_ for _ in ()).throw( + SQLAlchemyError("snapshot read failed") + ), + ) + with pytest.raises(SQLAlchemyError, match="snapshot read failed"): + service.set_policy("us", "Failed snapshot", {}, prepare_for_mirroring=True) + + assert service.search_policies("us", "Failed snapshot") == [] + retry = service.set_policy("us", "Failed snapshot", {}, prepare_for_mirroring=True) + assert retry.is_existing_policy is False + assert retry.snapshot == service.get_policy_snapshot("us", retry.policy_id) diff --git a/tests/unit/services/test_simulation_service.py b/tests/unit/services/test_simulation_service.py index 0f8a3e81a..bc5d4ef2e 100644 --- a/tests/unit/services/test_simulation_service.py +++ b/tests/unit/services/test_simulation_service.py @@ -173,3 +173,39 @@ def test_update_simulation_without_values_is_a_noop(service): def test_update_missing_simulation_raises(service): with pytest.raises(LookupError, match="Simulation #999 not found"): service.update_simulation("us", 999, status="complete") + + +@pytest.mark.parametrize("population_id", ["1", "00001", 1, "household-1"]) +def test_creating_a_simulation_never_locks_a_household_row( + service, orm_session_factory, population_id +): + """A numeric household id must not take a locking read of the household table. + + Households are immutable, so the lock served no mutation; a locking read of + an id that does not exist yet takes an InnoDB gap lock that briefly blocks + unrelated household inserts into that range. What serializes a repeated + create is the locking read of `simulations` itself, which is still taken. + """ + from sqlalchemy import event + + statements = [] + engine = orm_session_factory.kw["bind"] + + @event.listens_for(engine, "before_cursor_execute") + def record(conn, cursor, statement, parameters, context, executemany): + statements.append(" ".join(statement.split()).lower()) + + try: + result = service.get_or_create_simulation( + country_id="us", + population_id=population_id, + population_type="household", + policy_id=1, + ) + finally: + event.remove(engine, "before_cursor_execute", record) + + assert result.created is True + selects = [item for item in statements if item.startswith("select")] + assert not any("from household" in item for item in selects), selects + assert any("from simulations" in item for item in selects), selects diff --git a/tests/unit/services/test_spm_household_identity.py b/tests/unit/services/test_spm_household_identity.py new file mode 100644 index 000000000..2ed2572f1 --- /dev/null +++ b/tests/unit/services/test_spm_household_identity.py @@ -0,0 +1,199 @@ +"""Saved household references preserve their measurement and input identity.""" + +from copy import deepcopy +from unittest.mock import Mock + +import pytest + +from policyengine_api.data.v1_models import Household, Simulation +from policyengine_api.services import household_service +from policyengine_api.services.household_service import HouseholdService +from policyengine_api.services.simulation_service import SimulationService +from policyengine_api.spm import SPMValidationError + + +INPUTS = {"people": {"you": {"age": {"2026": 40}}}} +NATIONAL = { + "forecast_content_sha256": "a" * 64, + "scenario": "baseline", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, +} + + +@pytest.fixture +def canonical_household(monkeypatch, orm_session_factory): + def normalize(country_id, selection): + assert country_id == "us" + return {**NATIONAL, **(selection or {})} + + monkeypatch.setattr(household_service, "normalize_spm_selection", normalize) + service = HouseholdService(orm_session_factory) + household = service.create_household( + "us", INPUTS, "Original", spm=NATIONAL + ).household + return service, household + + +def test_numeric_household_aliases_share_canonical_simulation_identity( + canonical_household, orm_session_factory +): + _, household = canonical_household + simulations = SimulationService(orm_session_factory) + + first = simulations.get_or_create_simulation( + "us", str(household.id).zfill(5), "household", 1 + ) + second = simulations.get_or_create_simulation( + "us", str(household.id), "household", 1 + ) + + assert first.created is True + assert second.created is False + assert first.simulation.id == second.simulation.id + assert first.simulation.population_id == str(household.id) + assert first.simulation.simulation_spec_json["population_id"] == str(household.id) + + +def test_uk_household_population_ids_keep_their_existing_representation( + orm_session_factory, +): + created = SimulationService(orm_session_factory).get_or_create_simulation( + "uk", "0001", "household", 1 + ) + assert created.simulation.population_id == "0001" + assert created.simulation.simulation_spec_json["population_id"] == "0001" + + +@pytest.mark.parametrize("unrelated_id", ["1suffix", "1.0", "+1", " 1", "1\n", "١"]) +def test_numeric_alias_lookup_does_not_reuse_nonnumeric_population_ids( + orm_session_factory, unrelated_id +): + with orm_session_factory.begin() as session: + session.add( + Simulation( + country_id="us", + population_id=unrelated_id, + api_version="historical", + population_type="household", + policy_id=1, + status="complete", + output={"unrelated": True}, + ) + ) + + result = SimulationService(orm_session_factory).get_or_create_simulation( + "us", "0001", "household", 1 + ) + assert result.created is True + assert result.simulation.population_id == "1" + assert result.simulation.output is None + + +def test_exact_historical_identity_wins_over_an_existing_numeric_alias( + orm_session_factory, +): + with orm_session_factory.begin() as session: + historical = Simulation( + country_id="us", + population_id="00001", + api_version="historical", + population_type="household", + policy_id=1, + status="complete", + output={"historical": True}, + ) + session.add(historical) + session.flush() + historical_id = historical.id + session.add( + Simulation( + country_id="us", + population_id="1", + api_version="historical", + population_type="household", + policy_id=1, + status="pending", + ) + ) + + result = SimulationService(orm_session_factory).get_or_create_simulation( + "us", "00001", "household", 1 + ) + assert result.created is False + assert result.simulation.id == historical_id + assert result.simulation.status == "complete" + assert result.simulation.output == {"historical": True} + + +def test_replacement_household_preserves_historical_zero_padded_reference( + canonical_household, orm_session_factory +): + service, household = canonical_household + original = deepcopy(household.household_json) + with orm_session_factory.begin() as session: + session.add( + Simulation( + country_id="us", + population_id=str(household.id).zfill(5), + population_type="household", + policy_id=1, + api_version="historical", + status="complete", + output={"spm_config": NATIONAL}, + ) + ) + + replacement = service.create_household( + "us", INPUTS, "Replacement", spm={"geography_kind": "county"} + ).household + + assert replacement.id != household.id + assert replacement.household_hash != household.household_hash + assert replacement.household_json["spm"]["geography_kind"] == "county" + replay = SimulationService(orm_session_factory).get_or_create_simulation( + "us", str(household.id), "household", 1 + ) + assert replay.created is False + assert replay.simulation.population_id == str(household.id).zfill(5) + assert replay.simulation.output == {"spm_config": NATIONAL} + + saved = service.get_household("us", household.id) + assert saved.household_json == original + assert saved.household_hash == household.household_hash + + +def test_legacy_read_does_not_reinterpret_inputs_when_certificate_unavailable( + monkeypatch, orm_session_factory +): + with orm_session_factory.begin() as session: + household = Household( + country_id="us", + label="Original", + household_json=deepcopy(INPUTS), + household_hash="original-input-hash", + api_version="legacy-test", + ) + session.add(household) + session.flush() + household_id = household.id + SimulationService(orm_session_factory).get_or_create_simulation( + "us", str(household_id), "household", 1 + ) + normalize = Mock( + side_effect=SPMValidationError( + "SPM_CONFIGURATION_UNAVAILABLE", "Future bundle is not certified" + ) + ) + monkeypatch.setattr(household_service, "normalize_spm_selection", normalize) + + service = HouseholdService(orm_session_factory) + saved = service.get_household("us", household_id) + + normalize.assert_not_called() + assert saved.label == "Original" + assert saved.household_json == INPUTS + assert saved.household_hash == "original-input-hash" + assert saved.api_version == "legacy-test" diff --git a/tests/unit/services/test_worker_spm.py b/tests/unit/services/test_worker_spm.py new file mode 100644 index 000000000..156d91147 --- /dev/null +++ b/tests/unit/services/test_worker_spm.py @@ -0,0 +1,681 @@ +"""SPM cannot be cached or submitted to a worker without a certified contract.""" + +from unittest.mock import Mock, patch + +import pytest +from flask import Flask + +from policyengine_api.libs.simulation_entrypoint import SimulationEntrypointClient +from policyengine_api.routes.economy_routes import economy_bp +from policyengine_api.services.economy_service import EconomyService +from policyengine_api.spm import SPMValidationError +from policyengine_api.worker_spm import validate_worker_spm + + +@pytest.mark.parametrize("selection", [None, {"geography_kind": "national"}]) +def test_canonical_worker_selection_is_rejected_including_resolved_default(selection): + with patch( + "policyengine_api.worker_spm.normalize_spm_selection", + return_value={"geography_kind": "national"}, + ) as normalize: + with pytest.raises(SPMValidationError) as error: + validate_worker_spm("us", selection) + normalize.assert_called_once_with("us", selection) + assert error.value.code == "SPM_CONFIGURATION_UNAVAILABLE" + + +@pytest.mark.parametrize("country_id", ["us", "uk"]) +def test_legacy_worker_selection_remains_usable(country_id): + with patch( + "policyengine_api.worker_spm.normalize_spm_selection", return_value=None + ): + assert validate_worker_spm(country_id) is None + + +@pytest.mark.parametrize("country_id", ["us", "US", "uk"]) +def test_actual_settings_validator_rejects_explicit_settings_on_legacy_worker( + country_id, +): + with patch( + "policyengine_api.spm._current_bundle", + return_value={ + "policyengine_version": "5.2.0", + "packages": {"policyengine-us": {"version": "1.764.6"}}, + }, + ): + assert validate_worker_spm(country_id) is None + with pytest.raises(SPMValidationError) as error: + validate_worker_spm(country_id, {"geography_kind": "national"}) + assert error.value.code == "SPM_SETTINGS_UNSUPPORTED" + + +@pytest.mark.parametrize("country_id", ["us", "US"]) +def test_uncertified_canonical_bundle_cannot_default_to_legacy_worker(country_id): + """A model that can run canonical SPM never reaches a worker uncertified.""" + with patch("policyengine_api.spm._current_bundle", return_value={}): + with patch("policyengine_api.spm.simulation_supports_spm", return_value=True): + with pytest.raises(SPMValidationError) as error: + validate_worker_spm(country_id) + assert error.value.code == "SPM_CONFIGURATION_UNAVAILABLE" + + +@pytest.mark.parametrize("country_id", ["us", "US"]) +def test_unconfigured_bundle_keeps_the_legacy_worker_path(country_id): + """An unconfigured bundle whose model predates the contract stays legacy. + + No worker capability is consulted, so an automated bundle bump cannot stall + every economy request. + """ + gateway = Mock() + with patch("policyengine_api.spm._current_bundle", return_value={}): + with patch("policyengine_api.spm.simulation_supports_spm", return_value=False): + assert validate_worker_spm(country_id, gateway=gateway) is None + with pytest.raises(SPMValidationError) as error: + validate_worker_spm(country_id, {"geography_kind": "national"}) + assert error.value.code == "SPM_SETTINGS_UNSUPPORTED" + gateway.get_spm_capability.assert_not_called() + + +@pytest.mark.parametrize("budget_window", [False, True]) +@pytest.mark.parametrize("options", [{}, {"spm": {"geography_kind": "national"}}]) +def test_canonical_economy_request_fails_before_cache_lookup(budget_window, options): + service = EconomyService( + budget_window_cache_=Mock(), + reform_impacts_service_=Mock(), + simulation_entrypoint_=Mock(), + ) + arguments = { + "country_id": "us", + "policy_id": 1, + "baseline_policy_id": 2, + "region": "us", + "dataset": "default", + "options": options, + "api_version": "test", + } + if budget_window: + call = service.get_budget_window_economic_impact + arguments.update(start_year="2026", window_size=2) + else: + call = service.get_economic_impact + arguments["time_period"] = "2026" + + with patch( + "policyengine_api.worker_spm.normalize_spm_selection", + return_value={"geography_kind": "county"}, + ): + with pytest.raises(SPMValidationError): + call(**arguments) + + assert not service._injected_budget_window_cache.mock_calls + assert not service._injected_reform_impacts_service.mock_calls + service._injected_simulation_entrypoint.run.assert_not_called() + service._injected_simulation_entrypoint.run_budget_window_batch.assert_not_called() + + +@pytest.mark.parametrize("method", ["run", "run_budget_window_batch"]) +@pytest.mark.parametrize("selection", [None, {"geography_kind": "national"}]) +def test_canonical_direct_submission_fails_before_transport(method, selection): + client = object.__new__(SimulationEntrypointClient) + client.client = Mock() + client.get_spm_capability = Mock(return_value=None) + payload = {"country": "us"} + if selection is not None: + payload["spm"] = selection + + with patch( + "policyengine_api.worker_spm.normalize_spm_selection", + return_value={"geography_kind": "county"}, + ): + with pytest.raises(SPMValidationError): + getattr(client, method)(payload) + + client.client.post.assert_not_called() + + +@pytest.mark.parametrize( + "path", + [ + "/us/economy/1/over/2?region=us&time_period=2026", + "/us/economy/1/over/2/budget-window?region=us&start_year=2026&window_size=2", + ], +) +def test_canonical_economy_errors_use_existing_validation_envelope(path): + app = Flask(__name__) + app.register_blueprint(economy_bp) + app.config["TESTING"] = True + with patch( + "policyengine_api.worker_spm.normalize_spm_selection", + return_value={"geography_kind": "county"}, + ): + with patch.object( + SimulationEntrypointClient, "get_spm_capability", return_value=None + ): + response = app.test_client().get(path) + + assert response.status_code == 400 + payload = response.get_json() + assert payload["status"] == "error" + assert payload["result"] is None + assert payload["errors"] == [ + { + "code": "SPM_CONFIGURATION_UNAVAILABLE", + "message": payload["message"], + } + ] + + +SELECTION = { + "forecast_content_sha256": "a" * 64, + "scenario": "ce_trend", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, +} +CAPABILITY = {"contract_version": "canonical-spm-v1", "defaults": SELECTION} + + +def test_certified_worker_selection_enters_identity_and_submission(): + gateway = Mock() + gateway.get_spm_capability.return_value = CAPABILITY + service = EconomyService(simulation_entrypoint_=gateway) + with patch( + "policyengine_api.worker_spm.normalize_spm_selection", return_value=SELECTION + ): + setup = service._build_economic_impact_setup_options( + country_id="us", + policy_id=1, + baseline_policy_id=2, + region="us", + dataset="default", + time_period="2026", + options={}, + api_version="test", + ) + assert setup.options["spm"] == SELECTION + changed = {**SELECTION, "scenario": "zero_real"} + assert service._build_options_hash( + options={"spm": SELECTION}, model_version="test", dataset="default" + ) != service._build_options_hash( + options={"spm": changed}, model_version="test", dataset="default" + ) + options = service._setup_sim_options("us", {}, {}, "us", "2026", spm=SELECTION) + assert options.model_dump(mode="json")["spm"] == SELECTION + + +@pytest.mark.parametrize( + "capability", + [ + None, + {**CAPABILITY, "contract_version": "future-unsupported"}, + { + "contract_version": "canonical-spm-v1", + "defaults": {**SELECTION, "forecast_content_sha256": "b" * 64}, + }, + ], +) +def test_uncertified_or_mismatching_worker_is_rejected(capability): + gateway = Mock() + gateway.get_spm_capability.return_value = capability + with patch( + "policyengine_api.worker_spm.normalize_spm_selection", return_value=SELECTION + ): + with pytest.raises(SPMValidationError): + validate_worker_spm("us", gateway=gateway, policyengine_version="test-only") + + +@pytest.mark.parametrize( + "method,path", + [ + ("run", "/simulate/economy/comparison"), + ("run_budget_window_batch", "/simulate/economy/budget-window"), + ], +) +def test_actual_entrypoint_http_preserves_selection_and_data_version(method, path): + import httpx + import json + + requests = [] + + def handle(request): + requests.append(request) + if request.url.path == "/versions": + return httpx.Response( + 200, + json={ + "policyengine": {"test-only": "test-app"}, + "spm_capabilities": {"test-only": CAPABILITY}, + }, + ) + assert request.url.path == path + body = json.loads(request.content) + assert body["spm"] == SELECTION + assert body["data_version"] == "selected-data" + return httpx.Response( + 200, json={"job_id": "job", "batch_job_id": "batch", "status": "submitted"} + ) + + client = object.__new__(SimulationEntrypointClient) + client.base_url = "http://test" + client.client = httpx.Client(transport=httpx.MockTransport(handle)) + with patch( + "policyengine_api.worker_spm.normalize_spm_selection", return_value=SELECTION + ): + getattr(client, method)( + { + "country": "us", + "policyengine_version": "test-only", + "data_version": "selected-data", + "spm": {"geography_kind": "national"}, + } + ) + assert [request.method for request in requests] == ["GET", "POST"] + + +@pytest.mark.parametrize( + "kwargs", + [ + {"policyengine_version": "test-only"}, + {"policyengine_version": "latest"}, + {"version": "country-test"}, + {"version": "latest"}, + {}, + ], +) +def test_worker_capability_uses_bundle_version_for_actual_registry_shape(kwargs): + import httpx + + versions = { + "policyengine": {"test-only": "test-app", "latest": "test-only"}, + "us": {"country-test": "test-app", "latest": "country-test"}, + "spm_capabilities": {"test-only": CAPABILITY}, + } + client = object.__new__(SimulationEntrypointClient) + client.base_url = "http://test" + client.client = httpx.Client( + transport=httpx.MockTransport(lambda _: httpx.Response(200, json=versions)) + ) + assert client.get_spm_capability("us", **kwargs) == CAPABILITY + + +@pytest.mark.parametrize("other_app", ["test-app", "unrelated-app"]) +def test_country_capability_requires_an_unambiguous_bundle_route(other_app): + import httpx + + versions = { + "policyengine": {"first": other_app, "second": other_app}, + "us": {"country-test": "test-app"}, + "spm_capabilities": {"first": CAPABILITY, "second": CAPABILITY}, + } + client = object.__new__(SimulationEntrypointClient) + client.base_url = "http://test" + client.client = httpx.Client( + transport=httpx.MockTransport(lambda _: httpx.Response(200, json=versions)) + ) + with pytest.raises(SPMValidationError) as error: + client.get_spm_capability("us", "country-test") + assert error.value.code == "SPM_CONFIGURATION_UNAVAILABLE" + + +def test_explicit_bundle_capability_does_not_inherit_another_bundles_capability(): + import httpx + + versions = { + "policyengine": {"first": "test-app", "second": "test-app"}, + "spm_capabilities": {"second": CAPABILITY}, + } + client = object.__new__(SimulationEntrypointClient) + client.base_url = "http://test" + client.client = httpx.Client( + transport=httpx.MockTransport(lambda _: httpx.Response(200, json=versions)) + ) + assert client.get_spm_capability("us", policyengine_version="first") is None + + +@pytest.mark.parametrize("method", ["run", "run_budget_window_batch"]) +def test_canonical_direct_submission_forwards_the_validated_default_bundle(method): + import httpx + import json + from policyengine_api.constants import POLICYENGINE_VERSION + + def handle(request): + body = json.loads(request.content) + assert body["policyengine_version"] == POLICYENGINE_VERSION + return httpx.Response( + 200, json={"job_id": "job", "batch_job_id": "batch", "status": "submitted"} + ) + + client = object.__new__(SimulationEntrypointClient) + client.base_url = "http://test" + client.client = httpx.Client(transport=httpx.MockTransport(handle)) + with patch( + "policyengine_api.libs.simulation_entrypoint.validate_worker_spm", + return_value=SELECTION, + ) as validate: + getattr(client, method)({"country": "us"}) + assert validate.call_args.kwargs["policyengine_version"] == POLICYENGINE_VERSION + + +def test_canonical_submission_rejects_mutable_latest_bundle_alias(): + client = object.__new__(SimulationEntrypointClient) + with patch( + "policyengine_api.libs.simulation_entrypoint.validate_worker_spm", + return_value=SELECTION, + ): + with pytest.raises(SPMValidationError, match="exact PolicyEngine version"): + client._normalize_submission_payload( + {"country": "us", "policyengine_version": "latest"} + ) + + +@pytest.mark.parametrize( + "code", + ["SPM_GEOGRAPHY_REQUIRED", "SPM_GEOGRAPHY_UNAVAILABLE", "SPM_COMPOSITION_REQUIRED"], +) +@pytest.mark.parametrize( + "method", ["get_execution_by_id", "get_budget_window_batch_by_id"] +) +def test_typed_worker_poll_errors_reach_api_validation(code, method): + import httpx + + client = object.__new__(SimulationEntrypointClient) + client.base_url = "http://test" + client.client = httpx.Client( + transport=httpx.MockTransport( + lambda request: httpx.Response( + 400, + json={ + "status": "failed", + "result": None, + "error": "Explicit input required", + "errors": [{"code": code, "message": "Explicit input required"}], + }, + ) + ) + ) + with pytest.raises(SPMValidationError) as error: + getattr(client, method)("job") + assert error.value.to_dict() == {"code": code, "message": "Explicit input required"} + + +def test_worker_cache_receipts_are_required_and_json_roundtrip(): + import json + from policyengine_api.worker_spm import validate_worker_result + + receipt = dict( + forecast_id="test-only", + forecast_sha256=SELECTION["forecast_content_sha256"], + scenario="ce_trend", + geography_kind="national", + runtime_versions={}, + years={"2026": {}}, + geographies=[], + composition_method="classified-inputs", + storage_method="formula", + ) + output = { + "spm_config": SELECTION, + "spm_provenance": {"baseline": [receipt], "reform": [receipt]}, + } + validate_worker_result(json.loads(json.dumps(output)), SELECTION) + validate_worker_result( + { + **output, + "spm_config": { + key: value for key, value in SELECTION.items() if value is not None + }, + }, + SELECTION, + ) + validate_worker_result( + { + "kind": "budgetWindow", + "windowSize": 1, + "annualImpacts": [{"year": "2026", **output}], + }, + SELECTION, + ) + for incomplete in ( + {}, + {"spm_config": SELECTION}, + {**output, "spm_config": {**SELECTION, "scenario": "zero_real"}}, + ): + with pytest.raises(SPMValidationError): + validate_worker_result(incomplete, SELECTION) + + +@pytest.mark.parametrize( + "missing", + ["forecast_content_sha256", "scenario", "geography_kind", "county_vintage"], +) +def test_worker_receipt_cannot_inherit_nonnull_settings(missing): + from policyengine_api.worker_spm import validate_worker_result + + config = {key: value for key, value in SELECTION.items() if key != missing} + with pytest.raises(SPMValidationError, match="incomplete resolved"): + validate_worker_result({"spm_config": config}, SELECTION) + + +@pytest.mark.parametrize("side", ["baseline", "reform"]) +def test_worker_receipt_must_cover_requested_year(side): + from policyengine_api.worker_spm import validate_worker_result + + receipt = { + "forecast_id": "test-only", + "forecast_sha256": SELECTION["forecast_content_sha256"], + "scenario": "ce_trend", + "geography_kind": "national", + "runtime_versions": {}, + "years": {"2026": {}}, + "geographies": [], + "composition_method": "classified", + "storage_method": "formula", + } + result = { + "spm_config": SELECTION, + "spm_provenance": { + "baseline": [dict(receipt)], + "reform": [dict(receipt)], + }, + } + validate_worker_result(result, SELECTION, expected_year="2026") + result["spm_provenance"][side][0]["years"] = {"2025": {}} + with pytest.raises(SPMValidationError, match="requested year"): + validate_worker_result(result, SELECTION, expected_year="2026") + + +@pytest.mark.parametrize( + "years", [["2025", "2026"], ["2026", "2026"], ["2027", "2026"]] +) +def test_budget_result_cannot_swap_or_duplicate_requested_years(years): + from policyengine_api.worker_spm import validate_worker_result + + output = { + "kind": "budgetWindow", + "windowSize": 2, + "annualImpacts": [{"year": year} for year in years], + } + with pytest.raises(SPMValidationError, match="years differ"): + validate_worker_result(output, SELECTION, expected_years=["2026", "2027"]) + + +@pytest.mark.parametrize("result", [None, [], "invalid", 1]) +def test_worker_result_requires_an_object(result): + from policyengine_api.worker_spm import validate_worker_result + + with pytest.raises(SPMValidationError): + validate_worker_result(result, SELECTION, expected_year="2026") + + +@pytest.mark.parametrize("rows", [[], [{"year": "2026"}]]) +def test_annual_result_cannot_be_a_budget_window(rows): + from policyengine_api.worker_spm import validate_worker_result + + with pytest.raises(SPMValidationError): + validate_worker_result( + {"kind": "budgetWindow", "windowSize": len(rows), "annualImpacts": rows}, + SELECTION, + expected_year="2026", + ) + + +@pytest.mark.parametrize("rows", [None, {}, "x", [None], [[]], ["bad"]]) +def test_budget_result_requires_annual_row_objects(rows): + from policyengine_api.worker_spm import validate_worker_result + + with pytest.raises(SPMValidationError): + validate_worker_result( + {"kind": "budgetWindow", "windowSize": 1, "annualImpacts": rows}, + SELECTION, + expected_years=["2026"], + ) + + +@pytest.mark.parametrize("size", [0, -1, True, "1", 1.0]) +def test_budget_result_requires_positive_integer_window_size(size): + from policyengine_api.worker_spm import validate_worker_result + + with pytest.raises(SPMValidationError): + validate_worker_result( + { + "kind": "budgetWindow", + "windowSize": size, + "annualImpacts": [] if size == 0 else [annual_shape_result()], + }, + SELECTION, + ) + + +def test_budget_result_cannot_nest_another_budget_window(): + from policyengine_api.worker_spm import validate_worker_result + + with pytest.raises(SPMValidationError): + validate_worker_result( + { + "kind": "budgetWindow", + "windowSize": 1, + "annualImpacts": [ + {"year": "2026", "kind": "budgetWindow", "windowSize": 0}, + ], + }, + SELECTION, + expected_years=["2026"], + ) + + +def annual_shape_result(year="2026"): + receipt = { + "forecast_id": "test-only", + "forecast_sha256": SELECTION["forecast_content_sha256"], + "scenario": SELECTION["scenario"], + "geography_kind": SELECTION["geography_kind"], + "runtime_versions": {}, + "years": {"2026": {}}, + "geographies": [], + "composition_method": "classified", + "storage_method": "formula", + } + return { + "year": year, + "spm_config": SELECTION, + "spm_provenance": {"baseline": [receipt], "reform": [receipt]}, + } + + +@pytest.mark.parametrize("year", [None, {}, [], True, "", "2026.0"]) +def test_budget_rows_require_calendar_year_even_without_requested_years(year): + from policyengine_api.worker_spm import validate_worker_result + + with pytest.raises(SPMValidationError): + validate_worker_result( + { + "kind": "budgetWindow", + "windowSize": 1, + "annualImpacts": [annual_shape_result(year)], + }, + SELECTION, + ) + + +@pytest.mark.parametrize("year", ["2026", 2026]) +def test_worker_valid_annual_and_window_shapes_cover_requested_year(year): + from policyengine_api.worker_spm import validate_worker_result + + annual = annual_shape_result(year) + validate_worker_result(annual, SELECTION, expected_year="2026") + validate_worker_result( + {"kind": "budgetWindow", "windowSize": 1, "annualImpacts": [annual]}, + SELECTION, + expected_years=["2026"], + ) + + +PYDANTIC_INTERNALS = ("errors.pydantic.dev", "input_value", "validation error for") + +WORKER_RECEIPT = { + "forecast_id": "test-artifact", + "forecast_sha256": SELECTION["forecast_content_sha256"], + "scenario": SELECTION["scenario"], + "geography_kind": SELECTION["geography_kind"], + "runtime_versions": {"policyengine-us": "test"}, + "years": {"2026": {}}, + "geographies": [], + "composition_method": "test composition", + "storage_method": "test storage", +} + + +@pytest.mark.parametrize( + "capability", + [ + {"contract_version": "canonical-spm-v1", "defaults": {"geography_kind": "x"}}, + {"contract_version": "canonical-spm-v1", "defaults": {"unreviewed": True}}, + ], + ids=["invalid-value", "unknown-field"], +) +def test_a_worker_capability_failure_quotes_no_validator_internals(capability): + """A worker's advertised defaults are validated by the caller's own model. + + Its rejection reaches the client in the same 400 body a bad request does, so + it must read the same way: the offending field and reason, nothing else. + """ + gateway = Mock() + gateway.get_spm_capability.return_value = capability + with patch( + "policyengine_api.worker_spm.normalize_spm_selection", return_value=SELECTION + ): + with pytest.raises(SPMValidationError) as caught: + validate_worker_spm("us", gateway=gateway, policyengine_version="test-only") + assert caught.value.code == "SPM_CONFIGURATION_UNAVAILABLE" + assert not any(part in caught.value.message for part in PYDANTIC_INTERNALS) + assert caught.value.message + + +@pytest.mark.parametrize( + "receipt", + [ + {**WORKER_RECEIPT, "unreviewed_field": True}, + {key: value for key, value in WORKER_RECEIPT.items() if key != "scenario"}, + ], + ids=["extra-field", "missing-field"], +) +def test_a_worker_receipt_failure_quotes_no_validator_internals(receipt): + """The receipt shape a canonical worker returns is validated the same way.""" + from policyengine_api.worker_spm import validate_worker_result + + with pytest.raises(SPMValidationError) as caught: + validate_worker_result( + { + "spm_config": SELECTION, + "spm_provenance": { + "baseline": [WORKER_RECEIPT], + "reform": [receipt], + }, + }, + SELECTION, + expected_year="2026", + ) + assert caught.value.code == "SPM_CONFIGURATION_UNAVAILABLE" + assert not any(part in caught.value.message for part in PYDANTIC_INTERNALS) + assert caught.value.message diff --git a/tests/unit/test_alembic_workflows.py b/tests/unit/test_alembic_workflows.py index 4ae614d30..f57516988 100644 --- a/tests/unit/test_alembic_workflows.py +++ b/tests/unit/test_alembic_workflows.py @@ -181,10 +181,12 @@ def test_reusable_v2_integration_check_uses_disposable_databases_and_coverage(): assert "test_v2_metadata_routes.py" in workflow assert "test_v2_policy_persistence.py" in workflow assert "test_v1_policy_dual_write.py" in workflow + assert "test_mysql_policy_dual_write.py" in workflow assert "test_v2_user_policy_mirroring.py" in workflow assert "test_v1_user_policy_dual_write.py" in workflow assert "test_v2_household_persistence.py" in workflow assert "test_v1_household_dual_write.py" in workflow + assert "test_mysql_household_spm_dual_write.py" in workflow assert "test_v2_catalog_publication_qualification.py" in workflow assert "RUN_V2_CATALOG_PUBLICATION_QUALIFICATION" in workflow assert "test_runtime_cache_redis.py" in workflow diff --git a/tests/unit/test_country.py b/tests/unit/test_country.py index 704bca0c5..34561eac7 100644 --- a/tests/unit/test_country.py +++ b/tests/unit/test_country.py @@ -58,7 +58,7 @@ def get_population(self, entity_plural): monkeypatch.setattr( country, "_create_simulation", - lambda household, reform: (SimulationWithoutTracing(), system), + lambda household, reform, **kwargs: (SimulationWithoutTracing(), system), ) result = country.calculate( @@ -166,7 +166,7 @@ def get_population(self, entity_plural): monkeypatch.setattr( country, "_create_simulation", - lambda household, reform: (BrokenSimulation(), system), + lambda household, reform, **kwargs: (BrokenSimulation(), system), ) household = { "people": {"you": {"employment_income": {"2025": None}}}, diff --git a/tests/unit/test_country_spm.py b/tests/unit/test_country_spm.py new file mode 100644 index 000000000..a36d9f8a0 --- /dev/null +++ b/tests/unit/test_country_spm.py @@ -0,0 +1,641 @@ +"""Exercise the API country adapter, including optional real canonical runtime.""" + +from copy import deepcopy +import json +from types import SimpleNamespace + +import numpy as np +import pytest + +from policyengine_api import country as country_module +from policyengine_api import spm +from policyengine_api.country import COUNTRIES, PolicyEngineCountry + + +# An axes request expands every recognized variable, so a stub system must name +# the ones these households carry; anything else (entity membership) is skipped. +HOUSEHOLD_VARIABLES = { + "age", + "employment_income", + "state_code", + "spm_unit_federal_tax", + "spm_unit_net_income", + "spm_unit_spm_threshold", +} +AXIS_POINTS = 2 + + +def requested_household(variable="spm_unit_spm_threshold", *, axes=False): + household = { + "people": {"you": {"age": {"2024": 40}}}, + "households": {"household": {"members": ["you"], "state_code": {"2024": "CA"}}}, + "spm_units": {"spm_unit": {"members": ["you"], variable: {"2024": None}}}, + } + if axes: + household["axes"] = [ + [ + { + "name": "employment_income", + "period": "2024", + "count": AXIS_POINTS, + "min": 0, + "max": 100, + } + ] + ] + return household + + +def _country_raising(monkeypatch, error): + """A country whose every variable calculation raises `error`.""" + + def calculate(variable, period): + # This also represents a requested resource with a nested SPM dependency. + raise error + + simulation = SimpleNamespace( + calculate=calculate, + get_population=lambda entity: SimpleNamespace( + get_index=lambda entity_id: 0, count=AXIS_POINTS + ), + ) + system = SimpleNamespace( + get_variable=lambda name: object(), variables=HOUSEHOLD_VARIABLES + ) + country = PolicyEngineCountry.__new__(PolicyEngineCountry) + monkeypatch.setattr( + country, "_create_simulation", lambda *args, **kwargs: (simulation, system) + ) + return country + + +def _failing_country(monkeypatch, code): + error = ValueError("Missing or unavailable explicit SPM input") + error.code = code + return _country_raising(monkeypatch, error), error + + +@pytest.mark.parametrize("code", sorted(spm.SPM_INPUT_ERROR_CODES)) +@pytest.mark.parametrize("axes", [False, True]) +def test_country_never_swallows_spm_input_errors_for_a_chosen_measurement( + monkeypatch, code, axes +): + country, error = _failing_country(monkeypatch, code) + with pytest.raises(ValueError) as caught: + country.calculate( + requested_household("spm_unit_net_income", axes=axes), + None, + spm_requested=True, + ) + assert caught.value is error + + +@pytest.mark.parametrize("code", sorted(spm.SPM_INPUT_ERROR_CODES)) +@pytest.mark.parametrize("axes", [False, True]) +def test_inherited_measurement_leaves_dependent_variables_unavailable( + monkeypatch, code, axes +): + """Nobody chose this measurement, so its dependants behave like any other + variable the model cannot compute: null, not a rejected request.""" + country, _ = _failing_country(monkeypatch, code) + # The requested cell is seeded null, so returning at all is the observable + # change; the sibling test below pins that the rest of the result survives. + result = country.calculate( + requested_household("spm_unit_net_income", axes=axes), None + ) + unavailable = result.household["spm_units"]["spm_unit"]["spm_unit_net_income"][ + "2024" + ] + # An axes request spells one unavailable cell as a correctly sized null array. + assert unavailable == ([None] * AXIS_POINTS if axes else None) + assert any("spm_unit_net_income" in warning for warning in result.warnings) is axes + + +def test_an_inherited_measurement_still_returns_the_rest_of_the_calculation( + monkeypatch, +): + """A missing primitive costs its dependants, not the whole calculation.""" + error = ValueError("Missing or unavailable explicit SPM input") + error.code = "SPM_GEOGRAPHY_REQUIRED" + + def calculate(variable, period): + if variable == "spm_unit_net_income": + raise error + return np.array([1234.0]) + + simulation = SimpleNamespace( + calculate=calculate, + get_population=lambda entity: SimpleNamespace(get_index=lambda entity_id: 0), + ) + system = SimpleNamespace( + get_variable=lambda name: SimpleNamespace(value_type=float) + ) + country = PolicyEngineCountry.__new__(PolicyEngineCountry) + monkeypatch.setattr( + country, "_create_simulation", lambda *args, **kwargs: (simulation, system) + ) + + household = requested_household("spm_unit_net_income") + household["spm_units"]["spm_unit"]["spm_unit_federal_tax"] = {"2024": None} + result = country.calculate(household, None) + + unit = result.household["spm_units"]["spm_unit"] + assert unit["spm_unit_net_income"]["2024"] is None + assert unit["spm_unit_federal_tax"]["2024"] == 1234.0 + + +@pytest.mark.parametrize( + "code", + [ + "SPM_CONFIGURATION_UNAVAILABLE", + "SPM_SETTINGS_INVALID", + "SPM_SETTINGS_UNSUPPORTED", + ], +) +@pytest.mark.parametrize("axes", [False, True]) +def test_a_configuration_failure_is_never_left_as_a_null_cell(monkeypatch, code, axes): + """Nulling this would publish "cannot certify" as "computed nothing". + + Leaving a dependant unavailable is only ever right for a missing primitive. + A build that cannot certify the measurement at all has not computed a null. + """ + error = spm.SPMValidationError(code, "This build cannot certify the measurement") + country = _country_raising(monkeypatch, error) + with pytest.raises(spm.SPMValidationError) as caught: + country.calculate(requested_household("spm_unit_net_income", axes=axes), None) + assert caught.value is error + + +def test_country_passes_resolved_spm_and_uses_the_simulations_private_system( + monkeypatch, +): + selection = spm.SPMSelection(geography_kind="national").model_dump() + monkeypatch.setattr( + country_module, "normalize_spm_selection", lambda country, chosen: selection + ) + + class Simulation: + def __init__(self, *, tax_benefit_system, situation, spm): + self.tax_benefit_system = object() + self.spm_config = spm + + country = PolicyEngineCountry.__new__(PolicyEngineCountry) + country.country_id = "us" + country.country_package = SimpleNamespace(Simulation=Simulation) + country.tax_benefit_system = object() + simulation, system = country._create_simulation({}, None, spm=selection) + assert simulation.spm_config == selection + assert system is simulation.tax_benefit_system + assert system is not country.tax_benefit_system + + +def test_tax_only_result_reads_provenance_without_calculating_spm(monkeypatch): + calculations = [] + selection = spm.SPMSelection(geography_kind="national").model_dump() + receipt = { + "forecast_id": "test-artifact", + "forecast_sha256": "a" * 64, + "scenario": "baseline", + "geography_kind": "national", + "runtime_versions": {"policyengine-us": "test"}, + "years": {}, + "geographies": [], + "composition_method": "test adult classification", + "storage_method": "test final cast", + } + + def calculate(variable, period): + calculations.append(variable) + assert variable == "spm_unit_federal_tax" + return np.array([123.0]) + + simulation = SimpleNamespace( + calculate=calculate, + get_population=lambda entity: SimpleNamespace(get_index=lambda entity_id: 0), + spm_config=selection, + spm_provenance=lambda: receipt, + ) + system = SimpleNamespace( + get_variable=lambda name: SimpleNamespace(value_type=float) + ) + country = PolicyEngineCountry.__new__(PolicyEngineCountry) + monkeypatch.setattr( + country, "_create_simulation", lambda *args, **kwargs: (simulation, system) + ) + result = country.calculate(requested_household("spm_unit_federal_tax"), None) + assert calculations == ["spm_unit_federal_tax"] + assert result.spm_provenance["years"] == {} + assert ( + result.household["spm_units"]["spm_unit"]["spm_unit_federal_tax"]["2024"] == 123 + ) + assert json.loads(json.dumps(result.spm_config)) == selection + assert json.loads(json.dumps(result.spm_provenance)) == receipt + + +@pytest.fixture +def real_canonical_country(monkeypatch): + """Test-only certification projection, never a release/published bundle claim.""" + country = COUNTRIES["us"] + if not spm.simulation_supports_spm(country.country_package.Simulation): + pytest.skip("Requires the coordinated canonical US model and calculator source") + from spm_calculator.rolling_forecast import load_forecast + + forecast = load_forecast() + bundle = { + "measurements": { + "spm": { + "forecast_content_sha256": forecast.content_sha256, + "scenario": forecast.default_scenario, + } + } + } + monkeypatch.setattr(spm, "_current_bundle", lambda: bundle) + return country + + +def test_real_country_state_only_tax_succeeds_and_receipt_stays_empty( + real_canonical_country, +): + result = real_canonical_country.calculate( + requested_household("spm_unit_federal_tax"), None + ) + assert ( + result.household["spm_units"]["spm_unit"]["spm_unit_federal_tax"]["2024"] + is not None + ) + assert result.spm_provenance["years"] == {} + json.dumps(result.spm_provenance) + + +@pytest.mark.parametrize( + "variable", + [ + "spm_unit_spm_threshold", + "spm_unit_capped_housing_subsidy", + "spm_unit_net_income", + "spm_unit_is_in_spm_poverty", + ], +) +def test_real_country_state_only_spm_dependency_requires_geography( + real_canonical_country, variable +): + """A household that chose this measurement is told what it is missing.""" + with pytest.raises(ValueError) as caught: + real_canonical_country.calculate( + requested_household(variable), None, spm_requested=True + ) + assert spm.spm_error_detail(caught.value)["code"] == "SPM_GEOGRAPHY_REQUIRED" + + +@pytest.mark.parametrize( + "variable", + [ + "spm_unit_spm_threshold", + "spm_unit_capped_housing_subsidy", + "spm_unit_net_income", + "spm_unit_is_in_spm_poverty", + ], +) +def test_real_country_state_only_dependency_is_null_when_nothing_was_chosen( + real_canonical_country, variable +): + """The commitment in docs/canonical-spm.md, against the real model. + + Certifying a bundle must not make a state-only household uncalculable. The + inherited default was not a choice, so the dependant is unavailable rather + than the request rejected. + """ + result = real_canonical_country.calculate(requested_household(variable), None) + assert result.household["spm_units"]["spm_unit"][variable]["2024"] is None + assert result.spm_config["geography_kind"] == "county" + + +@pytest.mark.parametrize("county", ["99999", "malformed"]) +def test_real_country_unknown_county_is_structured(real_canonical_country, county): + household = requested_household() + household["households"]["household"]["county_fips"] = {"2024": county} + with pytest.raises(ValueError) as caught: + real_canonical_country.calculate(household, None, spm_requested=True) + assert spm.spm_error_detail(caught.value)["code"] == "SPM_GEOGRAPHY_UNAVAILABLE" + + +def test_real_country_unknown_area_is_structured(real_canonical_country): + with pytest.raises(ValueError) as caught: + real_canonical_country.calculate( + requested_household(), + None, + spm={"geography_kind": "metro", "geography_id": "unknown-area"}, + spm_requested=True, + ) + assert spm.spm_error_detail(caught.value)["code"] == "SPM_GEOGRAPHY_UNAVAILABLE" + + +def test_real_country_unclassified_composition_is_structured(real_canonical_country): + household = requested_household() + household["people"]["you"]["age"] = {"2024": 14} + with pytest.raises(ValueError) as caught: + real_canonical_country.calculate( + household, None, spm={"geography_kind": "national"}, spm_requested=True + ) + assert spm.spm_error_detail(caught.value)["code"] == "SPM_COMPOSITION_REQUIRED" + + +@pytest.mark.parametrize("geography", ["national", "county", "metro"]) +def test_real_country_explicit_geography_yields_json_receipt( + real_canonical_country, geography +): + household = requested_household() + selection = {"geography_kind": geography} + if geography == "county": + household["households"]["household"]["county_fips"] = {"2024": "06037"} + elif geography == "metro": + resolved = spm.normalize_spm_selection("us", None) + forecast = spm._selected_forecast(resolved["forecast_content_sha256"]) + selection["geography_id"] = forecast.resolve_county(2024, "06037")["area_id"] + original = deepcopy(household) + result = real_canonical_country.calculate( + household, None, spm=selection, spm_requested=True + ) + assert household == original + assert ( + result.household["spm_units"]["spm_unit"]["spm_unit_spm_threshold"]["2024"] > 0 + ) + assert result.spm_config["geography_kind"] == geography + assert ( + result.spm_provenance["forecast_sha256"] + == result.spm_config["forecast_content_sha256"] + ) + assert "2024" in result.spm_provenance["years"] + json.dumps(result.spm_provenance) + + +@pytest.fixture +def real_http_client(real_canonical_country, monkeypatch, orm_session_factory): + from flask import Flask + + from policyengine_api.extensions import cache + from policyengine_api.data.v1_models import Policy + from policyengine_api.routes import household_routes + from policyengine_api.runtime_cache.core import CacheNamespace + from policyengine_api.runtime_cache.fake import InMemoryCacheBackend + from policyengine_api.runtime_cache.household_calculations import ( + HouseholdCalculationCache, + ) + from policyengine_api.services.household_calculation_service import ( + HouseholdCalculationService, + ) + from policyengine_api.services.household_service import HouseholdService + + service = HouseholdCalculationService( + primary_session_factory=orm_session_factory, + cache=HouseholdCalculationCache( + InMemoryCacheBackend(), CacheNamespace("test", "real-spm") + ), + country_provider=lambda: {"us": real_canonical_country}, + ) + monkeypatch.setattr(household_routes, "household_calculation_service", service) + monkeypatch.setattr( + household_routes, "household_service", HouseholdService(orm_session_factory) + ) + with orm_session_factory.begin() as session: + session.add( + Policy( + id=2, + country_id="us", + policy_json={}, + policy_hash="baseline", + api_version="test", + ) + ) + app = Flask(__name__) + app.config.update(TESTING=True, CACHE_TYPE="SimpleCache") + cache.init_app(app) + app.register_blueprint(household_routes.household_bp) + with app.app_context(): + cache.clear() + return app.test_client() + + +def test_real_http_tax_only_succeeds_without_geography(real_http_client): + response = real_http_client.post( + "/us/calculate", + json={"household": requested_household("spm_unit_federal_tax")}, + ) + assert response.status_code == 200, response.json + assert response.json["spm_provenance"]["years"] == {} + + +@pytest.mark.parametrize( + "kind,code", + [ + ("state-only", "SPM_GEOGRAPHY_REQUIRED"), + ("axes", "SPM_GEOGRAPHY_REQUIRED"), + ("county", "SPM_GEOGRAPHY_UNAVAILABLE"), + ("metro", "SPM_GEOGRAPHY_UNAVAILABLE"), + ("composition", "SPM_COMPOSITION_REQUIRED"), + ], +) +def test_real_http_spm_errors_use_validation_response(real_http_client, kind, code): + """Every case here sends `spm`: the caller chose the measurement it asked for.""" + household = requested_household(axes=kind == "axes") + payload = {"household": household, "spm": {"geography_kind": "county"}} + if kind == "county": + household["households"]["household"]["county_fips"] = {"2024": "99999"} + elif kind == "metro": + payload["spm"] = {"geography_kind": "metro", "geography_id": "unknown-area"} + elif kind == "composition": + household["people"]["you"]["age"] = {"2024": 14} + payload["spm"] = {"geography_kind": "national"} + response = real_http_client.post("/us/calculate", json=payload) + assert response.status_code == 400, response.json + assert response.json["status"] == "error" + assert response.json["result"] is None + assert response.json["errors"][0]["code"] == code + assert response.json["message"] == response.json["errors"][0]["message"] + + +@pytest.mark.parametrize("kind", ["state-only", "axes", "county", "composition"]) +def test_real_http_missing_primitives_are_null_when_nothing_was_chosen( + real_http_client, kind +): + """The same households, with no `spm`, are calculated rather than rejected. + + This is the caller-visible half of the commitment: a request that never + chose a measurement keeps its HTTP 200 and loses only the cells that needed + the missing primitive. + """ + household = requested_household(axes=kind == "axes") + if kind == "county": + household["households"]["household"]["county_fips"] = {"2024": "99999"} + elif kind == "composition": + household["people"]["you"]["age"] = {"2024": 14} + response = real_http_client.post("/us/calculate", json={"household": household}) + assert response.status_code == 200, response.json + assert response.json["status"] == "ok" + assert response.json["spm_config"]["geography_kind"] == "county" + if kind != "axes": + threshold = response.json["result"]["spm_units"]["spm_unit"][ + "spm_unit_spm_threshold" + ]["2024"] + assert threshold is None + + +def test_real_http_national_result_and_receipt_survive_response_cache(real_http_client): + payload = { + "household": requested_household(), + "spm": {"geography_kind": "national"}, + } + response = real_http_client.post("/us/calculate", json=payload) + cached = real_http_client.post("/us/calculate", json=payload) + assert response.status_code == 200, response.json + assert cached.status_code == 200 + assert cached.json == response.json + assert response.json["spm_config"]["geography_kind"] == "national" + assert "2024" in response.json["spm_provenance"]["years"] + + +def test_real_http_full_national_calculation_keeps_threshold_and_provenance( + real_http_client, +): + response = real_http_client.post( + "/us/calculate-full", + json={ + "household": requested_household(), + "spm": {"geography_kind": "national"}, + }, + ) + assert response.status_code == 200, response.json + assert ( + response.json["result"]["spm_units"]["spm_unit"]["spm_unit_spm_threshold"][ + "2024" + ] + > 0 + ) + assert response.json["spm_config"]["geography_kind"] == "national" + assert "2024" in response.json["spm_provenance"]["years"] + + +def test_real_http_stored_national_replay_preserves_threshold_and_cached_receipt( + real_http_client, real_canonical_country, monkeypatch +): + calls = [] + calculate = real_canonical_country.calculate + + def observed_calculate(*args, **kwargs): + calls.append(kwargs.get("spm")) + return calculate(*args, **kwargs) + + monkeypatch.setattr(real_canonical_country, "calculate", observed_calculate) + created = real_http_client.post( + "/us/household", + json={ + "data": requested_household(), + "spm": {"geography_kind": "national"}, + }, + ) + assert created.status_code == 201, created.json + household_id = created.json["result"]["household_id"] + stored = real_http_client.get(f"/us/household/{household_id}") + assert stored.json["result"]["spm"]["geography_kind"] == "national" + url = f"/us/household/{household_id}/policy/2" + response = real_http_client.get(url) + cached = real_http_client.get(url) + assert response.status_code == 200, response.json + assert cached.status_code == 200 + assert cached.json == response.json + assert len(calls) == 1 + assert calls[0]["geography_kind"] == "national" + assert ( + response.json["result"]["spm_units"]["spm_unit"]["spm_unit_spm_threshold"][ + "2024" + ] + > 0 + ) + assert "2024" in response.json["spm_provenance"]["years"] + + +def household_in_year(variable, year, *, axes=False): + """Move every input and requested output to the regression's annual period.""" + return json.loads( + json.dumps(requested_household(variable, axes=axes)).replace( + '"2024"', f'"{year}"' + ) + ) + + +def selection_for_geography(geography): + selection = {"geography_kind": geography} + if geography == "metro": + resolved = spm.normalize_spm_selection("us", None) + forecast = spm._selected_forecast(resolved["forecast_content_sha256"]) + selection["geography_id"] = forecast.resolve_county(2024, "06037")["area_id"] + return selection + + +@pytest.mark.parametrize("geography", ["national", "metro"]) +@pytest.mark.parametrize("year", [2021, 2036]) +def test_real_http_tax_only_outside_artifact_years_is_lazy( + real_http_client, geography, year +): + response = real_http_client.post( + "/us/calculate", + json={ + "household": household_in_year("spm_unit_federal_tax", year), + "spm": selection_for_geography(geography), + }, + ) + assert response.status_code == 200, response.json + assert response.json["status"] == "ok" + assert ( + response.json["result"]["spm_units"]["spm_unit"]["spm_unit_federal_tax"][ + str(year) + ] + is not None + ) + assert response.json["spm_provenance"]["years"] == {} + + +@pytest.mark.parametrize("geography", ["national", "metro"]) +@pytest.mark.parametrize("variable", ["spm_unit_spm_threshold", "spm_unit_net_income"]) +@pytest.mark.parametrize("axes", [False, True]) +def test_real_http_unsupported_spm_year_is_structured_only_when_calculated( + real_http_client, geography, variable, axes +): + response = real_http_client.post( + "/us/calculate", + json={ + "household": household_in_year(variable, 2036, axes=axes), + "spm": selection_for_geography(geography), + }, + ) + assert response.status_code == 400, response.json + assert response.json["status"] == "error" + assert response.json["result"] is None + assert response.json["errors"][0]["code"] == "SPM_YEAR_UNAVAILABLE" + assert "2036" in response.json["errors"][0]["message"] + + +def test_uncertifiable_country_receipt_does_not_become_an_internal_failure(monkeypatch): + """A receipt shape this API cannot read is a typed 400, not a 500. + + The calculation itself succeeded, so the per-variable fallback never runs; + the receipt is read once at the end and must carry a public SPM code. + """ + selection = spm.SPMSelection(geography_kind="national").model_dump() + simulation = SimpleNamespace( + calculate=lambda variable, period: np.array([123.0]), + get_population=lambda entity: SimpleNamespace(get_index=lambda entity_id: 0), + spm_config={**selection, "threshold_method": "unreviewed"}, + spm_provenance=lambda: {}, + ) + system = SimpleNamespace( + get_variable=lambda name: SimpleNamespace(value_type=float) + ) + country = PolicyEngineCountry.__new__(PolicyEngineCountry) + monkeypatch.setattr( + country, "_create_simulation", lambda *args, **kwargs: (simulation, system) + ) + with pytest.raises(spm.SPMValidationError) as caught: + country.calculate(requested_household("spm_unit_federal_tax"), None) + assert caught.value.code == "SPM_CONFIGURATION_UNAVAILABLE" + assert spm.spm_error_detail(caught.value) == caught.value.to_dict() diff --git a/tests/unit/test_openapi_simulation.py b/tests/unit/test_openapi_simulation.py new file mode 100644 index 000000000..ff4fd6c38 --- /dev/null +++ b/tests/unit/test_openapi_simulation.py @@ -0,0 +1,311 @@ +"""Served simulation schemas agree with real Flask persistence envelopes.""" + +from copy import deepcopy +import json + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from flask import Flask +import jsonschema +import pytest + +from policyengine_api.data.v1_models import Simulation +from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies +from policyengine_api.fastapi_routes.specification import build_specification_router +from policyengine_api.routes import simulation_routes +from policyengine_api.routes.error_routes import error_bp +from policyengine_api.routes.system_routes import system_bp +from policyengine_api.services.simulation_service import SimulationService + + +@pytest.fixture +def simulation_http(orm_session_factory, monkeypatch): + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(error_bp) + app.register_blueprint(system_bp) + app.register_blueprint(simulation_routes.simulation_bp) + monkeypatch.setattr( + simulation_routes, + "simulation_service", + SimulationService(orm_session_factory), + ) + return app.test_client() + + +def _json_schema(value): + """Translate OpenAPI 3.0 nullable for the installed JSON Schema validator.""" + if isinstance(value, list): + return [_json_schema(item) for item in value] + if not isinstance(value, dict): + return value + result = {name: _json_schema(item) for name, item in value.items()} + if result.pop("nullable", False): + return {"anyOf": [result, {"type": "null"}]} + return result + + +def _validate(spec, schema, payload): + document = _json_schema({**deepcopy(schema), "components": spec["components"]}) + jsonschema.Draft7Validator(document).validate(payload) + + +def _response_schema(spec, path, method, status): + return spec["paths"][path][method]["responses"][str(status)]["content"][ + "application/json" + ]["schema"] + + +def test_actual_flask_and_native_specification_publish_simulation_operations( + simulation_http, +): + flask_response = simulation_http.get("/specification") + app = FastAPI() + app.include_router(build_specification_router(NativeRouteDependencies.defaults())) + native_response = TestClient(app).get("/specification") + assert flask_response.status_code == native_response.status_code == 200 + spec = flask_response.get_json() + assert spec == native_response.json() + collection = spec["paths"]["/{country_id}/simulation"] + detail = spec["paths"]["/{country_id}/simulation/{simulation_id}"] + assert set(collection) == {"post", "patch"} + assert set(detail) == {"get"} + assert collection["post"]["operationId"] == "create_simulation" + assert collection["patch"]["operationId"] == "update_simulation" + assert detail["get"]["operationId"] == "get_simulation" + for method in ("post", "patch"): + assert "linked household" in collection[method]["description"] + assert "SPM_SETTINGS_UNSUPPORTED" in collection[method]["description"] + assert collection[method]["requestBody"]["required"] is True + simulation_id = next( + item for item in detail["get"]["parameters"] if item["name"] == "simulation_id" + ) + assert simulation_id["in"] == "path" + assert simulation_id["required"] is True + assert simulation_id["schema"] == {"type": "integer", "minimum": 1} + + +@pytest.mark.parametrize("method", ["post", "patch"]) +@pytest.mark.parametrize("selection", [None, {}, {"geography_kind": "national"}]) +def test_simulation_schema_and_http_reject_independent_spm_selection( + simulation_http, method, selection +): + spec = simulation_http.get("/specification").get_json() + path = "/{country_id}/simulation" + operation = spec["paths"][path][method] + request_schema = operation["requestBody"]["content"]["application/json"]["schema"] + payload = ( + {"population_id": "1", "population_type": "household", "policy_id": 1} + if method == "post" + else {"id": 1, "status": "complete", "output": {}} + ) + _validate(spec, request_schema, payload) + payload["spm"] = selection + with pytest.raises(jsonschema.ValidationError): + _validate(spec, request_schema, payload) + response = simulation_http.open( + "/us/simulation", method=method.upper(), json=payload + ) + assert response.status_code == 400 + assert response.get_json()["errors"][0]["code"] == "SPM_SETTINGS_UNSUPPORTED" + _validate(spec, _response_schema(spec, path, method, 400), response.get_json()) + + +@pytest.mark.parametrize("encode_output", [False, True], ids=["object", "json-string"]) +def test_simulation_persistence_responses_preserve_spm_output_and_run_fields( + simulation_http, + encode_output, +): + spec = simulation_http.get("/specification").get_json() + path = "/{country_id}/simulation" + create_payload = { + "population_id": "1", + "population_type": "household", + "policy_id": 1, + } + created = simulation_http.post("/us/simulation", json=create_payload) + assert created.status_code == 201 + pending = created.get_json()["result"] + assert pending["status"] == "pending" + assert pending["output"] is None + assert pending["error_message"] is None + assert pending["latest_successful_run_id"] is None + assert pending["active_run_id"] is not None + assert pending["simulation_spec_schema_version"] == 1 + assert isinstance(pending["simulation_spec_json"], str) + assert set(pending) == {column.name for column in Simulation.__table__.columns} + record_schema = spec["components"]["schemas"]["SimulationRecord"] + assert set(record_schema["properties"]) == set(pending) + assert set(record_schema["required"]) == set(pending) + _validate(spec, _response_schema(spec, path, "post", 201), created.get_json()) + repeated = simulation_http.post("/us/simulation", json=create_payload) + assert repeated.status_code == 200 + assert repeated.get_json()["result"] == pending + _validate(spec, _response_schema(spec, path, "post", 200), repeated.get_json()) + + output = { + "status": "ok", + "message": None, + "result": {"people": {}}, + "spm_config": {"geography_kind": "national"}, + "spm_provenance": { + "forecast_id": "test-forecast", + "forecast_sha256": "a" * 64, + "scenario": "baseline", + "geography_kind": "national", + "runtime_versions": {"policyengine-us": "test"}, + "years": {}, + "geographies": [], + "composition_method": "test", + "storage_method": "test", + }, + } + update_payload = { + "id": pending["id"], + "status": "complete", + "output": json.dumps(output) if encode_output else output, + } + _validate(spec, {"$ref": "#/components/schemas/SimulationUpdate"}, update_payload) + updated = simulation_http.patch("/us/simulation", json=update_payload) + assert updated.status_code == 200 + complete = updated.get_json()["result"] + assert complete["active_run_id"] is None + assert complete["latest_successful_run_id"] == pending["active_run_id"] + assert isinstance(complete["output"], str) + assert json.loads(complete["output"]) == output + _validate(spec, _response_schema(spec, path, "patch", 200), updated.get_json()) + _validate( + spec, {"$ref": "#/components/schemas/StoredHouseholdOutputEnvelope"}, output + ) + fetched = simulation_http.get(f"/us/simulation/{pending['id']}") + assert fetched.status_code == 200 + assert fetched.get_json()["message"] is None + assert fetched.get_json()["result"] == complete + _validate( + spec, + _response_schema(spec, path + "/{simulation_id}", "get", 200), + fetched.get_json(), + ) + + +def test_simulation_schema_covers_existing_null_metadata_and_error_state( + simulation_http, orm_session_factory +): + with orm_session_factory.begin() as session: + historical = Simulation( + country_id="us", + api_version="test", + population_id="1", + population_type="household", + policy_id=1, + status="error", + error_message="Calculation failed", + ) + session.add(historical) + session.flush() + simulation_id = historical.id + response = simulation_http.get(f"/us/simulation/{simulation_id}") + assert response.status_code == 200 + result = response.get_json()["result"] + for field in ( + "output", + "simulation_spec_json", + "simulation_spec_schema_version", + "active_run_id", + "latest_successful_run_id", + ): + assert result[field] is None + spec = simulation_http.get("/specification").get_json() + _validate( + spec, + _response_schema(spec, "/{country_id}/simulation/{simulation_id}", "get", 200), + response.get_json(), + ) + + +@pytest.mark.parametrize( + "payload", + [ + {"id": 1}, + {"id": 1, "api_version": "ignored"}, + {"id": 1, "status": None, "output": None, "error_message": None}, + {"id": 1, "status": "complete"}, + {"id": 1, "status": "complete", "output": None}, + {"id": 1, "status": "running"}, + ], +) +def test_update_schema_matches_http_required_fields_and_status_checks( + simulation_http, payload +): + spec = simulation_http.get("/specification").get_json() + with pytest.raises(jsonschema.ValidationError): + _validate(spec, {"$ref": "#/components/schemas/SimulationUpdate"}, payload) + response = simulation_http.patch("/us/simulation", json=payload) + assert response.status_code == 400 + _validate( + spec, + _response_schema(spec, "/{country_id}/simulation", "patch", 400), + response.get_json(), + ) + + +@pytest.mark.parametrize("method", ["get", "patch"]) +def test_documented_simulation_not_found_envelope_matches_http(simulation_http, method): + response = ( + simulation_http.get("/us/simulation/999") + if method == "get" + else simulation_http.patch( + "/us/simulation", json={"id": 999, "status": "error"} + ) + ) + assert response.status_code == 404 + spec = simulation_http.get("/specification").get_json() + path = "/{country_id}/simulation" + ("/{simulation_id}" if method == "get" else "") + _validate(spec, _response_schema(spec, path, method, 404), response.get_json()) + + +@pytest.mark.parametrize("ignored_version", [None, 42, {"ignored": True}]) +def test_update_schema_accepts_any_ignored_api_version_value( + simulation_http, ignored_version +): + created = simulation_http.post( + "/us/simulation", + json={"population_id": "1", "population_type": "household", "policy_id": 1}, + ) + assert created.status_code == 201 + record = created.get_json()["result"] + payload = {"id": record["id"], "status": "pending", "api_version": ignored_version} + response = simulation_http.patch("/us/simulation", json=payload) + assert response.status_code == 200 + assert response.get_json()["result"]["api_version"] == record["api_version"] + spec = simulation_http.get("/specification").get_json() + _validate(spec, {"$ref": "#/components/schemas/SimulationUpdate"}, payload) + + +def test_stored_scalar_string_output_is_returned_unchanged(simulation_http): + created = simulation_http.post( + "/us/simulation", + json={"population_id": "1", "population_type": "household", "policy_id": 1}, + ) + assert created.status_code == 201 + simulation_id = created.get_json()["result"]["id"] + response = simulation_http.patch( + "/us/simulation", + json={"id": simulation_id, "status": "complete", "output": json.dumps("hello")}, + ) + assert response.status_code == 200 + assert response.get_json()["result"]["output"] == "hello" + fetched = simulation_http.get(f"/us/simulation/{simulation_id}") + assert fetched.status_code == 200 + assert fetched.get_json()["result"]["output"] == "hello" + spec = simulation_http.get("/specification").get_json() + _validate( + spec, + _response_schema(spec, "/{country_id}/simulation/{simulation_id}", "get", 200), + fetched.get_json(), + ) + description = spec["components"]["schemas"]["SimulationRecord"]["properties"][ + "output" + ]["description"] + assert "scalar strings" in description + assert "unchanged" in description diff --git a/tests/unit/test_openapi_spm.py b/tests/unit/test_openapi_spm.py new file mode 100644 index 000000000..807af4d0e --- /dev/null +++ b/tests/unit/test_openapi_spm.py @@ -0,0 +1,182 @@ +"""The served legacy specification describes the canonical SPM HTTP contract.""" + +import json + +from flask import Flask + +from policyengine_api.routes.system_routes import system_bp +from policyengine_api.spm import SPMProvenance, SPMSelection +from policyengine_api.query_parameters import ( + AnnualEconomyQuery, + BudgetWindowEconomyQuery, +) +from policyengine_api.specification import load_specification + + +def test_economy_query_and_receipt_schemas_match_public_http_contract(): + spec = load_specification() + paths = spec["paths"] + base = "/{country_id}/economy/{policy_id}/over/{baseline_policy_id}" + for suffix, query_model in ( + ("", AnnualEconomyQuery), + ("/budget-window", BudgetWindowEconomyQuery), + ): + operation = paths[base + suffix]["get"] + parameters = { + item["name"]: item + for item in operation["parameters"] + if item["in"] == "query" + } + assert set(parameters) == set(query_model.model_fields) + for name, field in query_model.model_fields.items(): + assert parameters[name]["required"] is field.is_required() + assert parameters["spm"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/SPMSelection" + } + assert parameters["dataset"]["schema"]["default"] == "default" + assert parameters["include_district_breakdowns"]["deprecated"] is True + assert parameters["target"]["schema"]["enum"] == ( + ["general"] if suffix else ["general", "cliff"] + ) + if suffix: + assert parameters["window_size"]["schema"]["minimum"] == 1 + assert parameters["window_size"]["schema"]["maximum"] == 75 + result = operation["responses"]["200"]["content"]["application/json"]["schema"][ + "properties" + ]["result"] + if suffix: + result = result["properties"]["annualImpacts"]["items"] + assert result["properties"]["spm_config"] == { + "$ref": "#/components/schemas/SPMSelection" + } + assert result["properties"]["spm_provenance"] == { + "$ref": "#/components/schemas/SPMWorkerProvenance" + } + assert operation["responses"]["400"]["$ref"].endswith("SPMValidationError") + receipt = spec["components"]["schemas"]["SPMWorkerProvenance"] + assert receipt["required"] == ["baseline", "reform"] + for side in receipt["required"]: + assert receipt["properties"][side]["items"] == { + "$ref": "#/components/schemas/SPMProvenance" + } + + +def test_metadata_discovery_and_selection_constraints_are_published(): + spec = load_specification() + metadata = spec["paths"]["/{country_id}/metadata"]["get"]["responses"]["200"][ + "content" + ]["application/json"]["schema"]["properties"]["result"]["properties"] + assert metadata["spm"] == {"$ref": "#/components/schemas/SPMDiscovery"} + discovery = spec["components"]["schemas"]["SPMDiscovery"] + assert discovery["required"] == ["available"] + assert set(discovery["properties"]) == {"available", "settings_schema", "defaults"} + selection = spec["components"]["schemas"]["SPMSelection"] + assert selection["properties"]["as_of"]["format"] == "date" + assert "oneOf" in selection + assert selection["oneOf"][1]["required"] == ["geography_kind", "geography_id"] + + +def test_household_specification_exposes_creation_without_content_update(): + paths = load_specification()["paths"] + assert "put" not in paths["/{country_id}/household/{household_id}"] + description = " ".join( + paths["/{country_id}/household"]["post"]["description"].split() + ) + assert "immutable" in description.lower() + assert "new household" in description.lower() + simulation_description = " ".join( + paths["/{country_id}/simulation"]["post"]["description"].split() + ) + assert "All stored households" in simulation_description + assert "Household PUT is unsupported" in simulation_description + assert "label remains editable" not in simulation_description + + +def test_spm_documentation_uses_public_models_and_actual_route_envelopes(): + app = Flask(__name__) + app.register_blueprint(system_bp) + response = app.test_client().get("/specification") + assert response.status_code == 200 + spec = response.get_json() + assert spec["openapi"] == "3.0.0" + paths = spec["paths"] + schemas = spec["components"]["schemas"] + selection_ref = {"$ref": "#/components/schemas/SPMSelection"} + provenance_ref = {"$ref": "#/components/schemas/SPMProvenance"} + + for name, model in { + "SPMSelection": SPMSelection, + "SPMProvenance": SPMProvenance, + }.items(): + schema = schemas[name] + assert set(schema["properties"]) == set(model.model_fields) + assert schema["additionalProperties"] is False + assert '"type": "null"' not in json.dumps(schema) + + selection = schemas["SPMSelection"]["properties"] + assert selection["geography_kind"]["enum"] == ["county", "national", "metro"] + assert all("default" not in field for field in selection.values()) + for field in ("forecast_content_sha256", "scenario", "geography_id", "as_of"): + assert selection[field]["type"] == "string" + assert selection[field]["nullable"] is True + assert schemas["SPMProvenance"]["properties"]["runtime_versions"][ + "additionalProperties" + ] == { + "type": "string", + "nullable": True, + } + + for route in ("calculate", "calculate-full"): + operation = paths[f"/{{country_id}}/{route}"]["post"] + request = operation["requestBody"]["content"]["application/json"]["schema"] + assert set(request["properties"]) == {"household", "policy", "spm"} + assert request["properties"]["spm"] == selection_ref + assert ( + paths["/{country_id}/calculate-full"]["post"]["operationId"] + == "get_calculate_full" + ) + + for path, method in ( + ("/{country_id}/calculate", "post"), + ("/{country_id}/calculate-full", "post"), + ("/{country_id}/household/{household_id}/policy/{policy_id}", "get"), + ): + operation = paths[path][method] + result = operation["responses"]["200"]["content"]["application/json"]["schema"][ + "properties" + ] + assert {"status", "message", "result"} <= set(result) + assert result["spm_config"] == selection_ref + assert result["spm_provenance"] == provenance_ref + assert ( + operation["responses"]["400"]["$ref"] + == "#/components/responses/SPMValidationError" + ) + + for path, method in (("/{country_id}/household", "post"),): + operation = paths[path][method] + request = operation["requestBody"]["content"]["application/json"]["schema"] + assert set(request["properties"]) == {"data", "label", "spm"} + assert request["required"] == ["data"] + assert request["properties"]["spm"] == selection_ref + assert ( + operation["responses"]["400"]["$ref"] + == "#/components/responses/SPMValidationError" + ) + + for method in ("get",): + operation = paths["/{country_id}/household/{household_id}"][method] + result = operation["responses"]["200"]["content"]["application/json"]["schema"][ + "properties" + ]["result"] + assert result["properties"]["spm"] == selection_ref + assert result["properties"]["household_json"]["type"] == "object" + + error = spec["components"]["responses"]["SPMValidationError"]["content"][ + "application/json" + ]["schema"] + assert set(error["required"]) == {"status", "message"} + assert {"result", "errors"} <= set(error["properties"]) + assert error["properties"]["errors"]["items"]["required"] == ["message"] + assert "code" in error["properties"]["errors"]["items"]["properties"] + assert error["properties"]["result"]["nullable"] is True diff --git a/tests/unit/test_population_identity.py b/tests/unit/test_population_identity.py new file mode 100644 index 000000000..aa9b78ec8 --- /dev/null +++ b/tests/unit/test_population_identity.py @@ -0,0 +1,30 @@ +"""Numeric household identity must not inherit permissive text equality.""" + +import pytest +from sqlalchemy import literal, select + +from policyengine_api.utils.population_identity import population_id_matches + + +@pytest.mark.parametrize( + "stored,requested,expected", + [ + ("1 ", "1", False), + ("1", "1 ", False), + ("1 ", "1 ", True), + ("00001", "1", True), + ("1\n", "1", False), + ("1", "1", False), + ("1", "1", False), + ], +) +def test_sql_alias_matching_is_strict_even_with_permissive_text_collation( + orm_session, stored, requested, expected +): + # SQLite's RTRIM gives a real permissive-equality control without claiming + # MySQL qualification or depending on that independently owned database. + assert orm_session.scalar(select(literal("1 ").collate("RTRIM") == "1")) is True + matches = population_id_matches( + literal(stored).collate("RTRIM"), "us", requested, "household" + ) + assert orm_session.scalar(select(matches)) is expected diff --git a/tests/unit/test_readiness.py b/tests/unit/test_readiness.py index 3103fc7b5..b0fe57bad 100644 --- a/tests/unit/test_readiness.py +++ b/tests/unit/test_readiness.py @@ -103,3 +103,55 @@ def test_dual_write_households_require_v2_settings( ) as load_settings: assert readiness.is_ready() is True load_settings.assert_called_once_with() + + +def _ready_with_bundle(monkeypatch, bundle, *, implements_spm): + from policyengine_api import spm + + monkeypatch.setattr(spm, "_current_bundle", lambda: bundle) + monkeypatch.setattr(spm, "simulation_supports_spm", lambda _: implements_spm) + readiness.mark_ready() + with patch( + "policyengine_api.data.v2.settings.load_v2_runtime_database_settings", + return_value=object(), + ): + return readiness.is_ready() + + +LEGACY_BUNDLE = { + "policyengine_version": "5.2.0", + "packages": {"policyengine-us": {"version": "1.764.6"}}, +} + + +def test_readiness_accepts_a_bundle_without_canonical_measurements(monkeypatch): + assert _ready_with_bundle(monkeypatch, LEGACY_BUNDLE, implements_spm=False) is True + + +def test_readiness_rejects_a_canonical_model_without_certified_measurements( + monkeypatch, +): + """An uncertified canonical bundle rejects every request, so gate the deploy.""" + assert _ready_with_bundle(monkeypatch, LEGACY_BUNDLE, implements_spm=True) is False + + +def test_readiness_rejects_an_invalid_bundle_manifest(monkeypatch): + assert _ready_with_bundle(monkeypatch, None, implements_spm=False) is False + + +def test_readiness_accepts_a_certified_measurement_configuration(monkeypatch): + from types import SimpleNamespace + + from policyengine_api import spm + + monkeypatch.setattr( + spm, + "_selected_forecast", + lambda _: SimpleNamespace(years=[2026], entry=lambda *a, **k: {}), + ) + bundle = { + "measurements": { + "spm": {"forecast_content_sha256": "a" * 64, "scenario": "baseline"} + } + } + assert _ready_with_bundle(monkeypatch, bundle, implements_spm=True) is True diff --git a/tests/unit/test_spm_settings.py b/tests/unit/test_spm_settings.py new file mode 100644 index 000000000..615fba9d2 --- /dev/null +++ b/tests/unit/test_spm_settings.py @@ -0,0 +1,580 @@ +import json +import sys +from copy import deepcopy +from datetime import date +from types import ModuleType, SimpleNamespace + +import pytest +from pydantic import BaseModel, ValidationError + +from policyengine_api import spm + + +ARTIFACT_HASH = "a" * 64 +LEGACY_BUNDLE = { + "policyengine_version": "5.2.0", + "packages": {"policyengine-us": {"version": "1.764.6"}}, +} +CERTIFIED_SETTINGS = { + "forecast_content_sha256": ARTIFACT_HASH, + "scenario": "baseline", +} + + +@pytest.fixture +def certified_bundle(monkeypatch): + bundle = {"measurements": {"spm": deepcopy(CERTIFIED_SETTINGS)}} + monkeypatch.setattr(spm, "_current_bundle", lambda: bundle) + monkeypatch.setattr(spm, "simulation_supports_spm", lambda _: True) + + def entry(year, *, scenario, as_of): + if scenario not in {"baseline", "alternative"}: + raise ValueError("Unknown forecast scenario") + if as_of is not None and date.fromisoformat(as_of) < date(2026, 1, 1): + raise ValueError("Forecast information date follows requested as_of") + return {} + + def geography_factor(year, tenure, *, geoid, **kwargs): + if geoid != "31080": + raise ValueError("Forecast geography unavailable") + return {} + + forecast = SimpleNamespace( + years=(2022, 2035), entry=entry, geography_factor=geography_factor + ) + monkeypatch.setattr(spm, "_selected_forecast", lambda expected: forecast) + return bundle + + +@pytest.mark.parametrize( + "selection", + [ + {"unknown": True}, + {"forecast_content_sha256": "not-a-hash"}, + {"geography_kind": "state"}, + {"geography_kind": "national", "geography_id": "US"}, + {"geography_kind": "county", "geography_id": "06037"}, + {"geography_kind": "metro"}, + {"county_vintage": 2020}, + [], + ], +) +def test_public_settings_reject_invalid_values(selection): + with pytest.raises(ValidationError): + spm.SPMSelection.model_validate(selection) + + +def test_public_settings_schema_forbids_extra_fields(): + assert spm.SPMSelection.model_json_schema()["additionalProperties"] is False + + +@pytest.mark.parametrize("country_id", ["uk", "ca", "ng", "il"]) +def test_other_countries_omit_spm_without_reading_us_bundle(monkeypatch, country_id): + def must_not_load(): + raise AssertionError("Non-US request must not read SPM configuration") + + monkeypatch.setattr(spm, "_current_bundle", must_not_load) + assert spm.normalize_spm_selection(country_id, None) is None + assert spm.spm_metadata(country_id) == {"available": False} + with pytest.raises(spm.SPMValidationError) as caught: + spm.normalize_spm_selection(country_id, {"geography_kind": "national"}) + assert caught.value.code == "SPM_SETTINGS_UNSUPPORTED" + + +@pytest.mark.parametrize("version", ["5.2.0", "5.3.0"]) +def test_current_legacy_bundle_retains_omitted_selection(monkeypatch, version): + bundle = {**LEGACY_BUNDLE, "policyengine_version": version} + monkeypatch.setattr(spm, "_current_bundle", lambda: bundle) + monkeypatch.setattr(spm, "simulation_supports_spm", lambda _: False) + assert spm.normalize_spm_selection("us", None) is None + assert spm.spm_metadata("us") == {"available": False} + with pytest.raises(spm.SPMValidationError) as caught: + spm.normalize_spm_selection("us", {}) + assert caught.value.to_dict()["code"] == "SPM_SETTINGS_UNSUPPORTED" + + +@pytest.mark.parametrize("version", ["5.3.1", "6.0.0", "5.2.1"]) +@pytest.mark.parametrize("model", ["1.764.6", "1.824.7"]) +def test_unconfigured_bundle_version_does_not_reject_every_request( + monkeypatch, version, model +): + """A version bump alone must not 400 every US request. + + Bundle updates move these strings for unrelated countries and patch + releases. A model that cannot run canonical SPM keeps its existing + behavior whatever the bundle calls itself, and still refuses settings. + """ + bundle = { + "policyengine_version": version, + "packages": {"policyengine-us": {"version": model}}, + } + monkeypatch.setattr(spm, "_current_bundle", lambda: bundle) + monkeypatch.setattr(spm, "simulation_supports_spm", lambda _: False) + assert spm.normalize_spm_selection("us", None) is None + assert spm.spm_metadata("us") == {"available": False} + with pytest.raises(spm.SPMValidationError) as caught: + spm.normalize_spm_selection("us", {"geography_kind": "national"}) + assert caught.value.code == "SPM_SETTINGS_UNSUPPORTED" + + +@pytest.mark.parametrize("selection", [None, {}, {"geography_kind": "national"}]) +def test_canonical_model_without_a_certified_configuration_fails_closed( + monkeypatch, selection +): + """Capability without certification is the case that must stay closed.""" + monkeypatch.setattr(spm, "_current_bundle", lambda: deepcopy(LEGACY_BUNDLE)) + monkeypatch.setattr(spm, "simulation_supports_spm", lambda _: True) + with pytest.raises(spm.SPMValidationError) as caught: + spm.normalize_spm_selection("us", selection) + assert caught.value.code == "SPM_CONFIGURATION_UNAVAILABLE" + assert spm.spm_metadata("us") == {"available": False} + + +@pytest.mark.parametrize("measurements", [None, {}, {"spm": None}, {"spm": []}]) +def test_measurementless_bundle_shapes_read_as_unconfigured(monkeypatch, measurements): + bundle = {**deepcopy(LEGACY_BUNDLE), "measurements": measurements} + monkeypatch.setattr(spm, "_current_bundle", lambda: bundle) + monkeypatch.setattr(spm, "simulation_supports_spm", lambda _: False) + assert spm.normalize_spm_selection("us", None) is None + monkeypatch.setattr(spm, "simulation_supports_spm", lambda _: True) + with pytest.raises(spm.SPMValidationError) as caught: + spm.normalize_spm_selection("us", None) + assert caught.value.code == "SPM_CONFIGURATION_UNAVAILABLE" + + +def test_default_and_explicit_default_have_identical_resolved_identity( + certified_bundle, +): + defaults = spm.normalize_spm_selection("us", None) + assert defaults == spm.normalize_spm_selection("us", {}) + assert defaults == spm.normalize_spm_selection( + "us", {"forecast_content_sha256": None, "scenario": None} + ) + assert defaults == { + "forecast_content_sha256": ARTIFACT_HASH, + "scenario": "baseline", + "geography_kind": "county", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + } + + +@pytest.mark.parametrize( + "selection", + [ + {"geography_kind": "national"}, + {"geography_kind": "metro", "geography_id": "31080"}, + {"scenario": "alternative"}, + {"as_of": "2026-09-09"}, + ], +) +def test_selection_changes_resolved_request_identity(certified_bundle, selection): + default = spm.normalize_spm_selection("us", None) + chosen = spm.normalize_spm_selection("us", selection) + assert chosen != default + for key, value in selection.items(): + assert chosen[key] == value + + +@pytest.mark.parametrize( + "selection", + [ + {"forecast_content_sha256": "b" * 64}, + {"scenario": "unknown-scenario"}, + {"as_of": "2020-01-01"}, + {"as_of": "yesterday"}, + {"county_vintage": "2010"}, + {"geography_kind": "national", "geography_id": "US"}, + {"unreviewed_setting": True}, + ], +) +def test_invalid_selected_artifact_settings_have_structured_error( + certified_bundle, selection +): + with pytest.raises(spm.SPMValidationError) as caught: + spm.normalize_spm_selection("us", selection) + assert caught.value.code == "SPM_SETTINGS_INVALID" + assert caught.value.to_dict()["message"] + + +def test_certification_requires_pinned_artifact_hash(certified_bundle): + certified_bundle["measurements"]["spm"].pop("forecast_content_sha256") + with pytest.raises(spm.SPMValidationError) as caught: + spm.normalize_spm_selection("us", None) + assert caught.value.code == "SPM_CONFIGURATION_UNAVAILABLE" + + +def test_mismatched_installed_artifact_cannot_enable_metadata( + certified_bundle, monkeypatch +): + def mismatched_artifact(expected): + assert expected == ARTIFACT_HASH + raise ValueError("Forecast content SHA256 mismatch") + + monkeypatch.setattr(spm, "_selected_forecast", mismatched_artifact) + with pytest.raises(spm.SPMValidationError) as caught: + spm.normalize_spm_selection("us", {"geography_kind": "national"}) + assert caught.value.code == "SPM_CONFIGURATION_UNAVAILABLE" + assert spm.spm_metadata("us") == {"available": False} + + +def test_legacy_country_cannot_enable_certified_settings(certified_bundle, monkeypatch): + monkeypatch.setattr(spm, "simulation_supports_spm", lambda _: False) + with pytest.raises(spm.SPMValidationError) as caught: + spm.normalize_spm_selection("us", None) + assert caught.value.code == "SPM_CONFIGURATION_UNAVAILABLE" + + +def test_metadata_exposes_typed_defaults_only_when_available(certified_bundle): + result = spm.spm_metadata("us") + assert result["available"] is True + assert result["defaults"] == spm.normalize_spm_selection("us", None) + assert result["settings_schema"] == spm.SPMSelection.model_json_schema() + + +def test_selected_area_must_exist_in_artifact(certified_bundle): + with pytest.raises(spm.SPMValidationError) as caught: + spm.normalize_spm_selection( + "us", {"geography_kind": "metro", "geography_id": "unknown-area"} + ) + assert caught.value.code == "SPM_GEOGRAPHY_UNAVAILABLE" + + +def test_area_available_in_later_year_can_be_selected(certified_bundle, monkeypatch): + def geography_factor(year, tenure, **kwargs): + if year < 2025: + raise ValueError("Area unavailable before the boundary changed") + return {} + + forecast = SimpleNamespace( + years=(2022, 2035), + entry=lambda *args, **kwargs: {}, + geography_factor=geography_factor, + ) + monkeypatch.setattr(spm, "_selected_forecast", lambda _: forecast) + assert ( + spm.normalize_spm_selection( + "us", {"geography_kind": "metro", "geography_id": "later-area"} + )["geography_id"] + == "later-area" + ) + + +@pytest.mark.parametrize("wrapped", [False, True]) +def test_missing_forecast_year_message_alone_does_not_reclassify_errors(wrapped): + try: + raise ValueError("Forecast has no entry for 2036") + except ValueError as error: + if wrapped: + outer = RuntimeError("An unrelated dependency failed") + outer.__cause__ = error + error = outer + assert spm.spm_error_detail(error) is None + + +def test_plain_forecast_missing_year_is_not_reclassified(): + forecast_module = pytest.importorskip("spm_calculator.rolling_forecast") + forecast = forecast_module.load_forecast() + year = max(forecast.years) + 1 + with pytest.raises(ValueError) as caught: + forecast.entry(year) + assert spm.spm_error_detail(caught.value) is None + + +def test_other_real_forecast_errors_are_not_reclassified(): + forecast_module = pytest.importorskip("spm_calculator.rolling_forecast") + forecast = forecast_module.load_forecast() + with pytest.raises(ValueError) as caught: + forecast.entry(forecast.years[0], scenario="unknown-scenario") + assert spm.spm_error_detail(caught.value) is None + + +@pytest.mark.parametrize("status", [400, 422]) +def test_typed_worker_year_error_is_preserved(status): + import httpx + + from policyengine_api.worker_spm import raise_worker_spm_error + + detail = { + "code": "SPM_YEAR_UNAVAILABLE", + "message": "Forecast has no entry for 2036", + } + with pytest.raises(spm.SPMValidationError) as caught: + raise_worker_spm_error(httpx.Response(status, json={"errors": [detail]})) + assert caught.value.to_dict() == detail + + +def test_variadic_constructor_alone_does_not_claim_support(): + class Legacy: + def __init__(self, **kwargs): + pass + + class Canonical(Legacy): + spm_config = property(lambda self: {}) + + def spm_provenance(self): + return {} + + assert spm.simulation_supports_spm(Legacy) is False + assert spm.simulation_supports_spm(Canonical) is True + + +@pytest.mark.parametrize("code", sorted(spm.SPM_INPUT_ERROR_CODES)) +def test_error_recognition_includes_wrapped_country_dependencies(code): + error = ValueError("Explicit household input required") + error.code = code + wrapped = RuntimeError("Error calculating dependent variable") + wrapped.__cause__ = error + assert spm.spm_error_detail(wrapped) == { + "code": code, + "message": "Explicit household input required", + } + assert spm.spm_error_detail(ValueError("unrelated failure")) is None + + +@pytest.mark.parametrize( + "geography", + [ + {"geography_kind": "national"}, + {"geography_kind": "metro", "geography_id": "31080"}, + ], +) +@pytest.mark.parametrize( + "selection", [{}, {"scenario": "alternative"}, {"as_of": None}] +) +def test_partial_selection_preserves_bundle_defaults_across_json( + certified_bundle, geography, selection +): + class Request(BaseModel): + spm: spm.SPMSelection + + certified_bundle["measurements"]["spm"].update(geography) + chosen = spm.SPMSelection.model_validate(selection) + wire = json.loads(Request(spm=chosen).model_dump_json())["spm"] + assert wire == selection + before = spm.normalize_spm_selection("us", chosen) + after = spm.normalize_spm_selection("us", wire) + assert before == after + assert all(after[key] == value for key, value in geography.items()) + assert set(after) == set(spm.SPMSelection.model_fields) + assert json.loads(spm.SPMSelection.model_validate(after).model_dump_json()) == after + + +@pytest.mark.parametrize("kind", ["national", "county"]) +def test_explicit_geography_change_clears_inherited_metro_id(certified_bundle, kind): + certified_bundle["measurements"]["spm"].update( + geography_kind="metro", geography_id="31080" + ) + resolved = spm.normalize_spm_selection("us", {"geography_kind": kind}) + assert resolved["geography_kind"] == kind + assert resolved["geography_id"] is None + + +def test_selection_schema_does_not_force_inherited_options(): + schema = spm.SPMSelection.model_json_schema() + assert "required" not in schema + assert all("default" not in prop for prop in schema["properties"].values()) + + +@pytest.mark.parametrize("version", ["5.2.0", "5.3.0", "6.1.0"]) +def test_canonical_country_cannot_inherit_the_legacy_exception(monkeypatch, version): + """Whatever the bundle declares, a canonical model needs certification.""" + monkeypatch.setattr( + spm, + "_current_bundle", + lambda: { + "policyengine_version": version, + "packages": {"policyengine-us": {"version": "1.824.7"}}, + }, + ) + monkeypatch.setattr(spm, "simulation_supports_spm", lambda _: True) + with pytest.raises(spm.SPMValidationError, match="no certified"): + spm.normalize_spm_selection("us", None) + + +RESOLVED_SELECTION = { + "forecast_content_sha256": ARTIFACT_HASH, + "scenario": "baseline", + "geography_kind": "county", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, +} + + +def test_resolved_settings_recover_receipt_omitted_null_values(certified_bundle): + """A receipt that omits its null values still names the same selection.""" + assert spm.resolved_spm_settings(RESOLVED_SELECTION) == RESOLVED_SELECTION + omitted = { + key: value for key, value in RESOLVED_SELECTION.items() if value is not None + } + assert spm.resolved_spm_settings(omitted) == RESOLVED_SELECTION + assert spm.resolved_spm_settings(spm.normalize_spm_selection("us", None)) == ( + RESOLVED_SELECTION + ) + + +@pytest.mark.parametrize("omitted", sorted(spm.REQUIRED_RESOLVED_SPM_FIELDS)) +def test_resolved_settings_never_infer_an_omitted_nonnull_field(omitted): + settings = { + key: value + for key, value in RESOLVED_SELECTION.items() + if value is not None and key != omitted + } + assert spm.resolved_spm_settings(settings) is None + + +@pytest.mark.parametrize( + "settings", [None, {}, [], "county", {"unexpected": True}, {"scenario": ""}] +) +def test_resolved_settings_reject_unreadable_receipt_settings(settings): + assert spm.resolved_spm_settings(settings) is None + + +def test_receipt_keeps_the_countrys_own_omissions(certified_bundle): + """A published receipt repeats the country's wire shape, nulls and all.""" + omitted = { + key: value for key, value in RESOLVED_SELECTION.items() if value is not None + } + simulation = SimpleNamespace( + spm_config=omitted, spm_provenance=lambda: deepcopy(COUNTRY_RECEIPT) + ) + receipt = spm.calculation_spm_receipt(simulation) + assert receipt["spm_config"] == omitted + assert receipt["spm_provenance"] == COUNTRY_RECEIPT + + +COUNTRY_RECEIPT = { + "forecast_id": "test-artifact", + "forecast_sha256": ARTIFACT_HASH, + "scenario": "baseline", + "geography_kind": "county", + "runtime_versions": {"policyengine-us": "test"}, + "years": {}, + "geographies": [], + "composition_method": "test composition", + "storage_method": "test storage", +} + + +@pytest.mark.parametrize( + "simulation", + [ + SimpleNamespace( + spm_config={**RESOLVED_SELECTION, "threshold_method": "canonical"}, + spm_provenance=lambda: COUNTRY_RECEIPT, + ), + SimpleNamespace( + spm_config=RESOLVED_SELECTION, + spm_provenance=lambda: {**COUNTRY_RECEIPT, "unreviewed_field": True}, + ), + SimpleNamespace(spm_config=RESOLVED_SELECTION, spm_provenance=lambda: {}), + ], + ids=["extra-setting", "extra-receipt-field", "incomplete-receipt"], +) +def test_uncertifiable_country_receipt_is_typed_not_an_internal_failure(simulation): + """An unreadable receipt is an uncertified country contract, never a 500.""" + with pytest.raises(spm.SPMValidationError) as caught: + spm.calculation_spm_receipt(simulation) + assert caught.value.code == "SPM_CONFIGURATION_UNAVAILABLE" + assert spm.spm_error_detail(caught.value) == caught.value.to_dict() + + +def test_receipt_is_absent_for_a_legacy_simulation(): + assert spm.calculation_spm_receipt(SimpleNamespace()) == {} + + +PYDANTIC_INTERNALS = ("errors.pydantic.dev", "input_value", "validation error for") + + +@pytest.mark.parametrize( + "configured", + [ + {**CERTIFIED_SETTINGS, "geography_kind": "nowhere"}, + {**CERTIFIED_SETTINGS, "unreviewed_option": True}, + {**CERTIFIED_SETTINGS, "as_of": "2026-02-30"}, + ], + ids=["invalid-value", "unknown-field", "impossible-date"], +) +def test_a_bundle_this_build_cannot_read_reports_no_validator_internals( + monkeypatch, configured +): + """Settings fail validation from four sources; one message rule covers them. + + The caller sees this message whichever of them failed, so a bundle manifest + this build cannot read must not publish pydantic's report any more than a + rejected request does. + """ + monkeypatch.setattr( + spm, "_current_bundle", lambda: {"measurements": {"spm": configured}} + ) + monkeypatch.setattr(spm, "simulation_supports_spm", lambda _: True) + with pytest.raises(spm.SPMValidationError) as caught: + spm.normalize_spm_selection("us", None) + assert caught.value.code == "SPM_CONFIGURATION_UNAVAILABLE" + assert not any(part in caught.value.message for part in PYDANTIC_INTERNALS) + assert caught.value.message + + +def test_a_country_package_without_a_simulation_is_typed_not_internal( + monkeypatch, certified_bundle +): + """A partially installed model must not make /readiness-check raise. + + `is_ready` recovers from RuntimeError and ValueError only, so an escaping + AttributeError turns the health check into a 500 and every US request with + it, instead of reporting the deployment as not ready. + """ + monkeypatch.setitem(sys.modules, "policyengine_us", ModuleType("policyengine_us")) + with pytest.raises(spm.SPMValidationError) as caught: + spm.normalize_spm_selection("us", None) + assert caught.value.code == "SPM_CONFIGURATION_UNAVAILABLE" + assert spm.spm_metadata("us") == {"available": False} + + +@pytest.mark.parametrize( + "failure", + [ + ImportError("no distribution"), + AttributeError("no Simulation"), + OSError("a data file will not open"), + TypeError("an extension will not initialize"), + ValueError("a module refused its own configuration"), + ], + ids=["import", "attribute", "os", "type", "value"], +) +def test_a_model_this_build_cannot_load_reads_as_no_canonical_model( + monkeypatch, failure +): + """Whatever stopped the import, the answer is "no canonical constructor". + + Letting one escape would turn every US request on an uncertified bundle into + a 500 rather than the legacy behaviour that bundle actually has. + """ + + def refuse(name): + raise failure + + monkeypatch.setattr(spm.importlib, "import_module", refuse) + + assert spm._installed_country_implements_spm("us") is False + assert spm.normalize_spm_selection("us", None) is None + + +def test_capability_probe_resolves_the_installed_package_by_name(monkeypatch): + """Country ids and package names are not the same string for every country.""" + from policyengine_api.constants import COUNTRIES, COUNTRY_PACKAGE_NAMES + + imported = [] + + def record(name): + imported.append(name) + raise ImportError(name) + + monkeypatch.setattr(spm.importlib, "import_module", record) + for country_id, package_name in zip(COUNTRIES, COUNTRY_PACKAGE_NAMES): + assert spm._installed_country_implements_spm(country_id) is False + assert imported[-1] == package_name + assert spm._installed_country_implements_spm("nowhere") is False + assert imported[-1] == COUNTRY_PACKAGE_NAMES[-1] diff --git a/tests/unit/v2/test_household_canonicalization.py b/tests/unit/v2/test_household_canonicalization.py index cb57229d3..13a4eca6b 100644 --- a/tests/unit/v2/test_household_canonicalization.py +++ b/tests/unit/v2/test_household_canonicalization.py @@ -108,3 +108,47 @@ def test_digest_is_sha256_of_exact_canonical_bytes() -> None: assert content.version == HOUSEHOLD_CANONICALIZATION_VERSION assert content.content_hash == hashlib.sha256(content.document).hexdigest() assert len(content.content_hash) == 64 + + +def test_omitted_spm_preserves_stage11_canonical_bytes() -> None: + expected = ( + b'{"canonicalization_version":1,"country_id":"us","default_year":2026,' + b'"household_data":{"family":[],"household":[],"marital_unit":[],"people":[' + b'{"id":"person-1","memberships":{},"source_name":"First","values":{"enabled":true}},' + b'{"id":"person-2","memberships":{},"source_name":"Second","values":{"array":[3,2,1],"rate":1}}' + b'],"spm_unit":[],"tax_unit":[]}}' + ) + assert canonical_household_document(_command()) == expected + + +def test_saved_spm_fields_change_identity_without_sorting_selection_as_entities() -> ( + None +): + from copy import deepcopy + + base = _command() + selected = deepcopy(base) + selected.household_data["spm"] = { + "forecast_content_sha256": "a" * 64, + "scenario": "baseline", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + } + content = canonicalize_household(selected) + assert content != canonicalize_household(base) + reordered = deepcopy(selected) + reordered.household_data["spm"] = dict( + reversed(list(selected.household_data["spm"].items())) + ) + assert canonicalize_household(reordered) == content + for key, value in ( + ("forecast_content_sha256", "b" * 64), + ("scenario", "alternative"), + ("geography_kind", "county"), + ("as_of", "2026-09-09"), + ): + changed = deepcopy(selected) + changed.household_data["spm"][key] = value + assert canonicalize_household(changed) != content diff --git a/tests/unit/v2/test_household_inputs.py b/tests/unit/v2/test_household_inputs.py index e66f9e5b8..36a7b1a08 100644 --- a/tests/unit/v2/test_household_inputs.py +++ b/tests/unit/v2/test_household_inputs.py @@ -204,3 +204,55 @@ def test_variable_names_are_not_checked_against_a_model_catalog() -> None: parsed = HouseholdCreationInput.model_validate(_native(household_data=document)) assert normalize_creation_input(parsed).household_data == document + + +@pytest.mark.parametrize( + "selection", + [ + {"geography_kind": "national"}, + { + "forecast_content_sha256": "a" * 64, + "scenario": "baseline", + "geography_kind": "metro", + "geography_id": "31080", + "county_vintage": "2020", + "as_of": "2026-09-09", + }, + ], +) +def test_spm_input_is_structurally_validated_without_resolving_omissions( + selection, +) -> None: + document = _us_document() + document["spm"] = selection + parsed = HouseholdCreationInput.model_validate(_native(household_data=document)) + assert normalize_creation_input(parsed).household_data == document + + +@pytest.mark.parametrize( + "selection", + [None, [], "national", {"unexpected": True}, {"geography_kind": "metro"}], +) +def test_invalid_spm_input_is_rejected(selection) -> None: + document = _us_document() + document["spm"] = selection + parsed = HouseholdCreationInput.model_validate(_native(household_data=document)) + with pytest.raises(HouseholdValidationError): + normalize_creation_input(parsed) + + +def test_uk_input_rejects_us_only_spm() -> None: + parsed = HouseholdCreationInput.model_validate( + { + "country_id": "uk", + "default_year": 2026, + "household_data": { + "people": [], + "household": [], + "benunit": [], + "spm": {"geography_kind": "national"}, + }, + } + ) + with pytest.raises(HouseholdValidationError): + normalize_creation_input(parsed) diff --git a/tests/unit/v2/test_household_routes.py b/tests/unit/v2/test_household_routes.py index 48e2e6ab3..df8e3f7ec 100644 --- a/tests/unit/v2/test_household_routes.py +++ b/tests/unit/v2/test_household_routes.py @@ -382,7 +382,17 @@ def test_openapi_publishes_query_body_item_page_and_error_schemas() -> None: request_schema = schema["components"]["schemas"][request_ref.rsplit("/", 1)[-1]] assert request_schema["additionalProperties"] is False assert request_schema["properties"]["default_year"]["minimum"] == 1900 - us_document = schema["components"]["schemas"]["USHouseholdDocument"] + document_refs = request_schema["properties"]["household_data"]["anyOf"] + us_document = next( + schema["components"]["schemas"][item["$ref"].rsplit("/", 1)[-1]] + for item in document_refs + if item["$ref"].rsplit("/", 1)[-1].startswith("USHouseholdDocument") + ) + spm_schema = us_document["properties"]["spm"] + assert spm_schema["$ref"].endswith("/SPMSelection") + assert "default" not in spm_schema + assert "anyOf" not in spm_schema + assert "spm" not in us_document["required"] assert us_document["properties"]["people"]["maxItems"] == 1000 entity_record = schema["components"]["schemas"]["HouseholdEntityRecord"] assert entity_record["properties"]["values"]["maxProperties"] == 500 @@ -446,3 +456,73 @@ def reject_cloud_sql(): assert client.get(f"/v2/households/{HOUSEHOLD_ID}?country_id=us").status_code == 200 assert service.calls[0][0] == "get_household" assert flask_calls["count"] == 0 + + +@pytest.mark.parametrize( + "selection", + [ + None, + {"geography_kind": "national"}, + { + "forecast_content_sha256": "a" * 64, + "scenario": "baseline", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + }, + ], +) +def test_native_household_http_preserves_spm_and_historical_omission( + monkeypatch, selection +) -> None: + from copy import deepcopy + + service = FakeHouseholdService() + saved = _household_read() + if selection is not None: + saved.household_data["spm"] = deepcopy(selection) + monkeypatch.setattr(service, "get_household", lambda **_filters: saved) + client, _ = _client(service) + response = client.get(f"/v2/households/{HOUSEHOLD_ID}?country_id=us") + assert response.status_code == 200 + returned = response.json()["result"]["item"]["household_data"] + if selection is None: + assert "spm" not in returned + else: + assert returned["spm"] == selection + + created = client.post( + "/v2/households?country_id=us", + json={ + "country_id": "us", + "default_year": 2026, + "household_data": returned, + }, + ) + assert created.status_code == 201 + supplied = service.calls[-1][1].household_data + if selection is None: + assert "spm" not in supplied + else: + assert supplied["spm"] == selection + + +@pytest.mark.parametrize( + "selection", [None, [], {"unexpected": True}, {"geography_kind": "metro"}] +) +def test_native_household_invalid_spm_is_rejected_before_service(selection) -> None: + service = FakeHouseholdService() + client, _ = _client(service) + document = _document() + document["spm"] = selection + response = client.post( + "/v2/households?country_id=us", + json={ + "country_id": "us", + "default_year": 2026, + "household_data": document, + }, + ) + assert response.status_code == 422 + assert service.calls == [] diff --git a/tests/unit/v2/test_household_translation.py b/tests/unit/v2/test_household_translation.py index 73fd90359..9b12fd92a 100644 --- a/tests/unit/v2/test_household_translation.py +++ b/tests/unit/v2/test_household_translation.py @@ -172,3 +172,62 @@ def test_snapshot_rejects_incomplete_or_non_json_fields( ) -> None: with pytest.raises(ValidationError): _snapshot(**changes) + + +SAVED_SPM = { + "forecast_content_sha256": "a" * 64, + "scenario": "baseline", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, +} + + +def test_saved_spm_translation_preserves_selection_without_current_bundle_resolution( + monkeypatch, +) -> None: + from copy import deepcopy + from unittest.mock import Mock + from policyengine_api import spm + from policyengine_api.services.v2.households.transformations import ( + canonicalize_household, + ) + + resolve = Mock( + side_effect=AssertionError("translation must not resolve current defaults") + ) + monkeypatch.setattr(spm, "normalize_spm_selection", resolve) + historical = _snapshot() + selected_json = deepcopy(historical.household_json) + selected_json["spm"] = deepcopy(SAVED_SPM) + selected = _snapshot(household_json=selected_json) + + translated = translate_legacy_household(selected) + legacy = translate_legacy_household(historical) + assert translated.household_data["spm"] == SAVED_SPM + assert "spm" not in legacy.household_data + assert canonicalize_household(translated) != canonicalize_household(legacy) + assert legacy_household_fingerprint(selected) != legacy_household_fingerprint( + historical + ) + resolve.assert_not_called() + selected_json["spm"]["scenario"] = "changed-later" + assert translated.household_data["spm"] == SAVED_SPM + + +@pytest.mark.parametrize( + "selection", [None, [], {"unexpected": True}, {"geography_kind": "metro"}] +) +def test_invalid_saved_spm_cannot_be_dropped_during_translation(selection) -> None: + source = _snapshot().household_json + source["spm"] = selection + with pytest.raises(LegacyHouseholdTranslationError): + translate_legacy_household(_snapshot(household_json=source)) + + +def test_uk_translation_rejects_us_only_spm() -> None: + source = json.loads((DATA / "uk_household.json").read_text()) + source["spm"] = SAVED_SPM + with pytest.raises(LegacyHouseholdTranslationError): + translate_legacy_household(_snapshot("uk", household_json=source)) diff --git a/tests/unit/v2/test_policy_canonicalization.py b/tests/unit/v2/test_policy_canonicalization.py index 3b750456b..8d0e9ffcd 100644 --- a/tests/unit/v2/test_policy_canonicalization.py +++ b/tests/unit/v2/test_policy_canonicalization.py @@ -4,8 +4,11 @@ from datetime import datetime, timezone import hashlib +import math from uuid import UUID, uuid4 +import pytest + from policyengine_api.services.v2.policies.transformations import ( POLICY_CANONICALIZATION_VERSION, canonical_policy_document, @@ -104,6 +107,49 @@ def test_equivalent_json_numbers_and_utc_instants_have_one_encoding() -> None: assert canonicalize_policy(integer) == canonicalize_policy(floating) +@pytest.mark.parametrize( + ("first_value", "second_value"), + [ + pytest.param(0.04, math.nextafter(0.04, math.inf), id="live-probe-lower-rate"), + pytest.param( + 0.044999, + math.nextafter(0.044999, math.inf), + id="live-probe-upper-rate", + ), + pytest.param(1.0, math.nextafter(1.0, math.inf), id="adjacent-to-integer"), + pytest.param(1e-12, math.nextafter(1e-12, math.inf), id="small-float"), + pytest.param(True, 1, id="true-is-not-one"), + pytest.param(False, 0, id="false-is-not-zero"), + pytest.param(2**53, 2**53 + 1, id="integers-beyond-float-precision"), + pytest.param( + {"rates": [0.04, {"enabled": True}]}, + {"rates": [math.nextafter(0.04, math.inf), {"enabled": True}]}, + id="nested-adjacent-floats", + ), + ], +) +def test_distinct_numeric_content_retains_distinct_identity( + first_value: object, second_value: object +) -> None: + contents = [ + canonicalize_policy( + _command( + values=[ + { + "parameter_id": FIRST_PARAMETER_ID, + "value": value, + "start_date": "2026-01-01T00:00:00Z", + } + ] + ) + ) + for value in (first_value, second_value) + ] + + assert contents[0].document != contents[1].document + assert contents[0].content_hash != contents[1].content_hash + + def test_material_content_changes_produce_distinct_documents() -> None: original = canonical_policy_document(_command()) alternatives = [ diff --git a/tests/unit/v2/test_policy_legacy_translation.py b/tests/unit/v2/test_policy_legacy_translation.py index bf569616e..8bb58e663 100644 --- a/tests/unit/v2/test_policy_legacy_translation.py +++ b/tests/unit/v2/test_policy_legacy_translation.py @@ -3,8 +3,10 @@ from __future__ import annotations from datetime import datetime, timezone +import json from pydantic import ValidationError +from sqlalchemy.orm import sessionmaker from sqlmodel import Session, create_engine import pytest @@ -19,6 +21,7 @@ read_policy_catalog, ) from policyengine_api.services.v2.policies.transformations import ( + canonicalize_policy, parse_legacy_period, translate_legacy_policy, ) @@ -26,6 +29,12 @@ from policyengine_api.services.v2.policies.validators import ( LegacyPolicyTranslationError, ) +from policyengine_api.services.policy_service import PolicyService +from policyengine_api.services.policy_mirroring import ( + PolicyMirrorUnavailableError, + mirror_policy_after_commit, +) +from tests.fixtures.local_v1_database import create_test_v1_schema def _session_and_catalog(): @@ -137,6 +146,95 @@ def test_label_does_not_change_translated_core_content() -> None: engine.dispose() +@pytest.mark.parametrize( + ("request_value", "stored_value"), + [ + (0.04 + 3 / 1_000_000, 0.040003), + (1.4334335999999999, 1.4334336), + (-5.684341886080803e-14, -5.684341886080804e-14), + ], +) +def test_new_retry_and_equivalent_label_mirror_the_same_stored_json( + monkeypatch, request_value: float, stored_value: float +) -> None: + # MySQL's default RapidJSON parser can move a double by one ULP: + # https://bugs.mysql.com/bug.php?id=112904 + # The first pair also comes from the live Phase 10 probe's value formula. + # Model those specific storage round trips at the database boundary. Plain + # SQLite JSON would preserve the request float and hide this regression. + def serialize_stored_json(value): + return json.dumps( + json.loads( + json.dumps(value), + parse_float=lambda number: ( + stored_value if number == repr(request_value) else float(number) + ), + ) + ) + + v1_engine = create_engine("sqlite://", json_serializer=serialize_stored_json) + create_test_v1_schema(v1_engine) + service = PolicyService(sessionmaker(v1_engine, expire_on_commit=False)) + monkeypatch.setattr( + "policyengine_api.services.policy_service.COUNTRY_PACKAGE_VERSIONS", + {"us": "1.0.0"}, + ) + engine, session, _model, _version, _first, _second = _session_and_catalog() + request = { + "gov.example.rate": {"2026-01-01.2100-12-31": request_value}, + "gov.example.amount": {"2026": 100}, + } + try: + first = service.set_policy( + "us", "First label", request, prepare_for_mirroring=True + ) + + def unavailable_mirror(): + raise RuntimeError("controlled destination failure") + + with pytest.raises(PolicyMirrorUnavailableError): + mirror_policy_after_commit( + first.snapshot, mirror_factory=unavailable_mirror + ) + assert service.get_policy("us", first.policy_id) is not None + # Retry loads the committed row; the equivalent-label request creates + # a different legacy row. + retry = service.set_policy( + "us", "First label", request, prepare_for_mirroring=True + ) + equivalent = service.set_policy( + "us", "Equivalent label", request, prepare_for_mirroring=True + ) + assert first.is_existing_policy is False + assert retry.is_existing_policy is True + assert equivalent.is_existing_policy is False + assert retry.policy_id == first.policy_id + assert equivalent.policy_id != first.policy_id + assert equivalent.snapshot.label != first.snapshot.label + assert ( + equivalent.snapshot.source_policy_hash == first.snapshot.source_policy_hash + ) + contents = [ + canonicalize_policy(_translate(session, result.snapshot)) + for result in (first, retry, equivalent) + ] + assert contents[2] == contents[1] + assert contents[0] == contents[1] + stored = service.get_policy_snapshot("us", first.policy_id) + assert stored.policy_json["gov.example.rate"] == { + "2026-01-01.2100-12-31": stored_value + } + assert all( + result.snapshot.policy_json == stored.policy_json + for result in (first, retry, equivalent) + ) + assert request["gov.example.rate"]["2026-01-01.2100-12-31"] == request_value + finally: + session.close() + engine.dispose() + v1_engine.dispose() + + @pytest.mark.parametrize( "policy_json", [