Skip to content

Calculate stored households as household jobs through /household-calculations - #1046

Merged
anth-volk merged 45 commits into
mainfrom
v2plan/c1-2-household-calculations
Nov 11, 2026
Merged

anth-volk merged 45 commits into
mainfrom
v2plan/c1-2-household-calculations

Conversation

@anth-volk

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

Copy link
Copy Markdown
Contributor

Fixes #1045

Summary

Adds POST /household-calculations and GET /household-calculations/{household_job_id} to the Simulation Entrypoint. They create and read household jobs, rows of the Public API's household_jobs table, and start the v2 Modal household functions (#903), which calculate one household for one year under one policy and record the result on the job. Replaces #957, which was built on a schema that is no longer used.

POST (permission simulation:submit) takes country, household_id, policy_id (omitted or null for current law), add_missing_variables, optional axes and optional spm settings. In order, it:

  1. finds the household function of the v2 manifest's default worker for the country, and the catalog version whose .py version matches that worker;
  2. reads the household, the policy and the policy's parameter values in one read transaction;
  3. builds the household contract's request for the household's default_year, with calculation_id set to the new job's ID;
  4. inserts the job pending, with no Modal call ID, dynamic_id null and request_data holding the request as received;
  5. starts the household function's call with spawn, passing the job's ID, and waits up to 10 seconds for Modal to accept it;
  6. records the call ID only while the job is still pending with none, and answers 202 {job_id, status}.

If Modal refuses the call, the job is failed at once under the same condition, with error_message and completed_at, and the answer is 503 modal_call_refused. If Modal accepts it late and the function has already claimed the job, the answer is 202 with the job's current status. If the job's state can't be recorded or read back, the answer is 202 pending: poll the job rather than submitting again. The stuck-run sweep fails a job no call ever started after about 10 minutes. Each submission creates a new job.

GET (permission simulation:read) answers 200 with job_id, status, country, household_id, policy_id, year, both package versions, result (the household function's result or failure document), error_message and the timestamps; 404 unknown_household_job.

Error answers

Status When
400 {code, detail} An unknown household, policy or catalog version; no household function for the country on the default worker; a foreign-key violation (SQLSTATE 23503) on insert, naming the missing row. Every referenced row is checked before the insert.
422 A malformed request; a household with no default_year or one that can't be read; a policy parameter missing from the catalog version; an explicit null spm; a request the household contract refuses, traced to the request field that set it. FastAPI's detail field list.
500 internal_error Any unexpected failure, as JSON, including a contract refusal of a field the service builds itself. The log is the fixed event household_route_failed with the route, the error's type and its stack frames as one string, innermost first, within the observability runtime's string length limit (stack_frames_omitted counts the frames left out). It never carries the error's message or any value.
503 {code, detail}, Retry-After: 10 not_configured, manifest_unavailable, database_unavailable, modal_call_refused (with household_job_id).

Policy periods. The policy reader in libs/policyengine-simulation-persistence (policy_reform.py, reusable by the economy routes) gives each stored parameter value the period .py's reform_dict_from_parameter_values gives it, in the same order, with one difference: the last open-ended value stops at 2200-12-31, where .py stops at 2100-12-31, because households can be calculated up to 2200. An open-ended value that .py would give a range ending before it starts (one followed the same day by another value, or one starting after 2200) is left out. policyengine-core applies such a range on no day, but policyengine-uk refuses it with "ends before it starts", so leaving it out also keeps those UK calculations from failing.

Also:

  • read-only models of the input tables' columns the service reads, compared column by column with the Public API's tables in the existing schema job;
  • every deploy now gives the entrypoint the Modal token and the v2 manifest's Modal environment, not only deploys with DEPLOY_STAGE12_V2;
  • a household calculation on every staging deploy, of the household the beta variable SIMULATION_STAGING_HOUSEHOLD_ID names;
  • the OpenAPI document declares every answer, and the generated clients are regenerated.

Operator steps before merging (done 2026-11-11)

Without them, every staging deploy fails at the new household check, and production doesn't deploy. Both were done and read back on 2026-11-11:

  1. A US household (one adult aged 40 with $30,000 of employment income in Harris County, Texas, default year 2026) was stored through the staging Public API's POST /v2/households?country_id=us, which answered 201 with ID b6604e4a-a531-404a-b839-42a3f78fc873. The row is deduplicated by content, so a repeat returns the same ID.
  2. The beta environment variable SIMULATION_STAGING_HOUSEHOLD_ID is set to that ID.

The staging catalog holds the default worker's .py version, 6.17.1 (checked through GET /v2/model-catalog?country_id=us&policyengine_version=6.17.1), so the check won't fail with unknown_catalog_version. Not verified: that staging sim's database URL names the same v2 database as the staging Public API; the first staging deploy's check confirms it.

The Public API's matching routes, PolicyEngine/policyengine-api#4274, merge after this is on staging.

Tests

  • Each test was run individually by node ID, and seen failing for the expected reason before its change, except the executor tests that pin .py's existing behaviour, which passed on their first run.

  • Persistence library, on PostgreSQL 17 built from the Public API's master with its privileges applied and verified: the policy reader, the household job records and store, the read-only input models and their comparison, and the bounded transactions.

  • Simulation Entrypoint: the service, routes, contract-field tracing, workers, household conversion, deploy wiring, the staging check, authorization and the OpenAPI document. The 500 log tests read the record the service's own observability runtime emits.

  • Executor: reform periods applied in 2200 with the real US and UK models, and .py's own reform_dict_from_parameter_values on same-day, bounded and open-ended values, applied with policyengine-core, including the case policyengine-uk refuses.

  • Generated client: submit and read, including the JSON 500.

  • Checks: CI's Ruff, the rules registry, the quality guards including file length, the lock checks and Pyright passed before each commit.

  • CI fixes:

    • test_deployment_assets.py::test_deployment_uses_gcloud_workflow_without_terraform counts the reusable deploy workflow's jobs that declare environment: ${{ inputs.release_environment }}. The staging household check is one of them, because it reads that environment's secrets and variable, so the expected count is now 18, not 17.
    • The two staging household scripts now start with #!/bin/bash, which the executor's test_modal_scripts.py::TestAllScriptsHaveShebang::test_all_scripts_have_shebang requires of every modal-*.sh and simulation-*.sh script.
  • Review: three rounds, and four more of the fix code.

    • Round 1: a call Modal accepted after the 10-second wait was answered as failed.
    • Round 2: a 500 declared on the POST broke the generated client for other errors.
    • Round 3: the same-day rule differed from .py when the earlier value had an end date.
    • Round 4: the observability runtime dropped the 500 log's stack frames.
    • Round 5: no bugs. Its reader comparison matched .py on 20,014 generated policies.
    • Rounds 6 and 7, of the two CI fixes: no bugs.

    All are fixed test-first, with each round's code-quality findings.

Environment variables and secrets

Value Provider How it reaches the code
MODAL_TOKEN_ID, MODAL_TOKEN_SECRET Secret Manager secrets named by the beta/prod variables MODAL_TOKEN_ID_SECRET_NAME, MODAL_TOKEN_SECRET_SECRET_NAME The deploy step's env: (job environment: inputs.release_environment), then the deploy script's --set-secrets with the newest version. Now mounted on every deploy; the Stage 12 block no longer mounts them separately.
SIMULATION_V2_MODAL_ENVIRONMENT The workflow input modal_environment The deploy script's environment file.
SIMULATION_V2_MANIFEST_NAME A literal in the deploy script The environment file.
SIMULATION_SERVICE_DATABASE_URL Secret Manager Unchanged. /ready already checks every grant these routes use.
SIMULATION_STAGING_HOUSEHOLD_ID New beta environment variable (not set yet) The staging check's STAGING_HOUSEHOLD_ID.
SIMULATION_ENTRYPOINT_AUTH_ISSUER, _AUDIENCE, SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID, _SECRET beta and prod environment secrets, with no repository-level copies The staging check job's environment: inputs.release_environment (beta on staging), then its step's env:. The callers' secrets: inherit passes only repository and organization secrets, so the job's environment: is what makes these available. They exist; authenticated_test already reads them.

Not verified:

  • the Modal token's permission to call the household functions (the same token already starts coordinators);
  • that the staging test client holds simulation:submit and simulation:read (this matters once enforcement is on);
  • that the staging Public API and the staging Simulation Entrypoint use the same database;
  • nothing ran against real Modal or staging.

🤖 Generated with Claude Code

anth-volk and others added 30 commits November 10, 2026 23:01
The simulation service will read households, policies, parameters,
parameter values and catalog versions. These tests require read-only
models of those tables, apart from the run models, and compare each
column they name with the Public API's own table: its type and whether
it allows null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Read-only models of households, policies, parameters, parameter values,
tax-benefit models and catalog versions, in their own metadata, name
only the columns the simulation service reads. The Public API schema
job now also requires each of those columns to exist in the Public
API's table with the same type and nullability, under rule
sim.persistence.models-match-public-api.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A stored policy value with no end date has to change every year a
household job can be calculated for, up to 2200. In a 2200 calculation
a range ending on 31 December 2200 applies in both countries, a range
ending in 2100 leaves current law, and a start date alone is read as
"from that day on" by policyengine-core (US) but as that single day by
policyengine-uk's reform dictionary. The simulation service therefore
writes an open-ended value as a range ending on 31 December 2200.

This test documents the household functions' existing behaviour and
changes no code, so it passed when first run.

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

A stored policy's parameter values become the reform values the
household functions take, by parameter name and period. A value with an
end date covers its start to its end; one without covers its start to
31 December 2200, the latest year a calculation can be for. The tests
also require the policy to belong to the country, every parameter it
sets to exist in the catalog version the calculation runs on, and no
two values of a parameter to fall on one period.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
read_policy_reform returns a policy's parameter values by name and
period, as the Public API's policy JSON writes them, after checking the
policy belongs to the country and every parameter it sets exists in the
catalog version the calculation runs on. A value without an end date is
written as a range ending on 31 December 2200, so it changes every year
a calculation can be for in both countries' models. It lives in the
persistence library so the economy routes can use it too.

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

The service finds the catalog version by country and .py version and
the stored household in its country, creates the job pending with no
Modal call ID, then records the call's ID, or fails the job when Modal
refuses the call, each only while the job is still pending with no call
ID. A job naming a row that does not exist is refused by its foreign
key, and the refusal names the column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The persistence library reads the stored household and the catalog
version a household calculation needs, creates its household_jobs row
pending with no Modal call ID and no dynamic, then records the call's
ID or fails the job when Modal refuses the call. Both updates apply only
while the job is still pending with no call ID, set only the columns the
role may update, and set updated_at themselves. A foreign-key refusal
names the job's column.

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

The store reads a household calculation's catalog version, household and
policy together, and creates, records, fails and reads its job, each in
a transaction of its own. Each transaction limits how long a statement
may run, for that transaction alone, and rolls back if its block raises.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HouseholdJobStore reads a household calculation's catalog version,
household and policy together, and creates, records, fails and reads its
job, each in a transaction of its own, as the Simulation Entrypoint
needs around the Modal call. bounded_transaction commits when its block
ends and limits each statement with set_config, local to the
transaction, as the readiness check already does.

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

The Simulation Entrypoint gets the v2 manifest's name and Modal
environment as settings of its own, apart from the Stage 12 settings,
and serves household calculations only with those and its database.
It uses the manifest's default worker only, and says when that worker
has no household function for the country or the manifest cannot be
read. A stored household becomes the situation the household functions
take, with its saved SPM selection kept apart (ported from sim #957).

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

SIMULATION_V2_MODAL_ENVIRONMENT and SIMULATION_V2_MANIFEST_NAME name
the Modal environment that holds the v2 workers and their manifest, apart
from the Stage 12 settings, whose resources stay all or nothing. With them
and the database URL the service can serve household calculations.
ManifestHouseholdWorkers finds the household function and bundle of the
manifest's default worker only. household_situation, ported from sim
#957, turns a stored household into the situation the household
functions take, and refuses a document it cannot read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The service reads the default worker, the catalog version for its .py
version, the stored household and the policy, then builds the household
contract's request with the new job's ID as its calculation ID, and
refuses before writing anything whatever is missing (400) or cannot be
calculated (422), naming why. It creates the job, starts the household
function's call with spawn and the job's ID, and records the call's ID;
when Modal refuses the call, or does not accept it in time, the job
fails at once. The household function is called by name in the v2
Modal environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HouseholdCalculationService finds the v2 manifest's default worker's
household function, reads the catalog version for its .py version, the
stored household and the policy in one transaction, and builds the
household contract's request for the household's default year with the
new job's ID as its calculation ID. Whatever is missing is refused
before anything is written, naming why; stored data that cannot be
calculated is refused with one reason per field. It then creates the
job, starts the call with spawn and the job's ID, and records the
call's ID; a call Modal refuses, or does not accept in ten seconds,
fails the job at once. The SPM settings are the request's, else the
household's saved selection.

How the request names the job's one policy is kept in
household_contract_fields, so a change to the contract's policy fields
changes that module alone.

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

POST /household-calculations (simulation:submit) answers 202 with the
job's ID and status, 400 or 422 with the reason a submission is refused,
and 503 with Retry-After when the service is not configured or a
dependency cannot be read. GET /household-calculations/{id}
(simulation:read) answers the job's status, result document and error
message, or 404, and never invents an observability ID. The OpenAPI
document declares every answer, and the route table and the comparison
with the old gateway's contract include the two routes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The routes are registered by _register_household_calculation_routes in
app.py, on a router of their own, with the simulation:submit and
simulation:read permissions. A submission binds the caller's
observability ID, or a new one, and passes it with the trace context
to the household function; a read binds none, since household_jobs
records none. Refusals answer 400 or 422 with their reasons, and an
unconfigured service, an unreadable manifest or database, or a refused
Modal call 503 with Retry-After. The OpenAPI document declares every
answer. The service is built when the database URL and the v2 Modal
environment are both set.

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

The household routes call the v2 workers' household functions, so the
Cloud Run deploy must mount MODAL_TOKEN_ID and MODAL_TOKEN_SECRET, each
once, and set SIMULATION_V2_MODAL_ENVIRONMENT and
SIMULATION_V2_MANIFEST_NAME on every deploy, not only when it deploys
Stage 12, and stop when a token's secret name is missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A staging deploy, after the v2 manifest's default moves to the
release's worker, calculates the stored staging household that the beta
variable SIMULATION_STAGING_HOUSEHOLD_ID names, through the candidate's
household routes with the test client's token, and requires the job to
succeed within ten minutes. The deployment is complete only when the
check passed in staging and was skipped elsewhere. No token or client
secret reaches a command line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…heck a staging household

The Cloud Run deploy now mounts MODAL_TOKEN_ID and MODAL_TOKEN_SECRET
and sets SIMULATION_V2_MODAL_ENVIRONMENT and SIMULATION_V2_MANIFEST_NAME
on every deploy, not only inside the DEPLOY_STAGE12_V2 block, which no
longer mounts the token itself. Every staging deployment then
calculates the stored staging household that the beta variable
SIMULATION_STAGING_HOUSEHOLD_ID names, through the candidate, after the
v2 default has moved, and deployment_ready requires that check to pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… into v2plan/c1-2-household-calculations

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

#903's contract takes the request's policy as one policy field with its
parameters, and returns one calculated household. The service sends the
job's policy there, with no policy key, and the 422 reasons trace the
contract's policy field to the request's policy_id. The executor's
reform period test calculates each period form in a request of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The generated simulation client leaves an omitted spm out of a
household request and keeps an explicit null, and reads a job's result
document whole. The database-access skill names the Public API's input
tables the service reads. Adds the changelog fragment.

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

The Public API's household calculation client names the new job
job_id, sends axes only when given, within the household contract's
limits, and sends SPM settings only as an object. The tests require
the request to accept axes and pass them into the household document,
to refuse an explicit null or non-object spm, and both answers to name
the job job_id, as must the staging check that reads them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The household request now takes axes, within the household contract's
limits, and passes them into the household document; they are stored
with the request. spm must be an object when given: omit it to use the
household's saved selection. Both answers name the job job_id, which is
what the Public API's client reads, and the staging check reads it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…2plan/c1-2-household-calculations

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.py's reform_dict_from_parameter_values stops a value without an end
date the day before the same parameter's next later value starts, and
otherwise runs it to 2100-12-31. The reader must do the same, but run
the last one to 2200-12-31, the latest year a calculation can be for:
a value followed by a bounded one then gives way to current law once
the bounded one ends, and values without end dates run one after
another. Two values of a parameter starting on the same day are
refused. The executor tests pin .py's own periods for the same cases;
they describe .py's existing behaviour, so they passed when first run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts, as .py does

A stored value without an end date now stops the day before the same
parameter's next later value starts, as .py's
reform_dict_from_parameter_values does, and otherwise runs to the end
of 2200 rather than .py's 2100. A value followed by a bounded one no
longer applies again after the bounded one ends. Two values of one
parameter starting on the same day are refused.

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

When Modal accepts the household function's call after the ten-second
wait and the function claims the job first, the failure write changes
nothing: the answer must be 202 with the job as it stands, not a 503
saying it failed. When the failure write itself fails, or the job then
can't be read, the answer is a 503 job_state_unknown naming the job, so
the caller reads it instead of submitting again. The fakes now model
the function's claim, a failing failure write and a late answer.

A refused field the service builds itself, such as the bundle, is an
internal error (500), and every other refused field traces to the
request field that set it. A job read declares only the answers it
gives, and both routes' 503s declare Retry-After.

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

When Modal doesn't confirm the household function's call within the
wait, the service fails the job only while it is still pending with no
call ID, and answers 503 modal_call_refused only when that write failed
the job. When it changed nothing, the function claimed the job because
Modal accepted the call after all, so the job is read and answered with
202 as it stands. When the failure write or that read fails, the answer
is 503 job_state_unknown naming the job, to be read before submitting
again.

A contract refusal now traces each field to the request field that set
it: the stored household and its year to household_id, the axes to
axes, the policy to policy_id, the SPM settings to spm or the saved
household. A refused field the service builds itself, such as the
bundle, answers 500 internal_error. The job read declares only the
answers it gives, and both routes' 503s declare Retry-After.

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

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

Every 500 of the household routes, an unexpected exception included,
must be the JSON HouseholdCalculationError with code internal_error,
naming the error's type in the log but never its values, as the
generated client expects for both routes. When Modal didn't confirm
the call and the job's state can't be recorded or read back, the job
exists, so the answer is 202 pending, read with GET, rather than a 503
that invites a second submission. Of a parameter's values starting on
the same UTC day, the latest takes that day over; the executor test
shows .py and policyengine-core calculating the same, so it passed
when first run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 12 commits November 11, 2026 00:50
…, and keep a day's latest value

Both household routes now answer any unexpected exception with the JSON
HouseholdCalculationError their OpenAPI answers declare, code
internal_error, logging the route and the error's type only; the GET
route declares its 500 too. No app-wide handler changes other routes.

When Modal didn't confirm the call and the failure write, or reading
the job back, fails, the job exists, so the answer is now 202 pending
for the caller to read, not a 503 that invites a second submission;
the stuck-run sweep fails a job no call ever started. The
job_state_unknown code is gone.

Of a parameter's values starting on the same UTC day, the policy
reader keeps the latest, as .py's own periods amount to, instead of
refusing the policy, and ConflictingPolicyValuesError is gone. The
error model's household_job_id and the contract fields module are
described as they now are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and the 500 log (failing)

.py's reform_dict_from_parameter_values keeps both of two bounded
values starting on the same day, in their order, so each holds part of
the year; an open-ended value after a bounded one the same day takes
over; and a start the Public API stored in UTC is read on its UTC day.
The reader must give the same periods, with 2200 where .py has 2100,
and leave out only an open-ended value whose range would end before it
starts. The executor tests pin .py's own output and apply it with
policyengine-core; they describe existing behaviour, so they passed
when first run.

The household routes' 500 log must be the fixed event
household_route_failed with error_type, route and stack frames, and
no exception message or values. The submission's 202 description must
say a pending job may be one whose call Modal didn't confirm.

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

The policy reader now gives each value the period .py's
reform_dict_from_parameter_values gives it, in .py's order for values
in start order, with 2200 where .py has 2100. Bounded values starting
the same day are all kept, so policyengine-core applies each to part of
the year as .py's dictionary does. Only an open-ended value whose range
would end before it starts, because a later value starts the same day
or because it starts after 2200, is left out; .py's range for it
applies on no day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…describe a pending 202

An unexpected failure in a household route is now logged as the fixed
event household_route_failed with the route, error_type and the stack
frames (file, line and function), never the exception's message or any
value. The fields also go in policyengine_attributes, the only extra
fields the observability runtime's log handler keeps.

The submission's 202 description, the routes module, the submit
docstring and the changelog fragment now say that a 202 pending may be a
job whose call Modal didn't confirm, to be polled rather than submitted
again, and list the 500.

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

The test now reads the record the observability runtime prints, not the
standard-library record, and a second test checks that the frames fit the
runtime's string limit, keeping the innermost ones.

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

The observability runtime keeps only strings, numbers and booleans as
attributes, so the list of frames never reached the emitted record. The
frames are now one string, innermost first, holding as many as fit the
runtime's string limit, with the number left out as `stack_frames_omitted`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g over the same day

The period tests now read a stored policy through `read_policy_reform`
instead of calling `reform_period`, and a new reader test covers an
open-ended value followed the same day by a bounded one. The executor pins
.py's dict for that case: policyengine-core applies the bounded value and
then current law, and policyengine-uk's reform dictionary reader refuses
.py's range that ends before it starts. These describe existing behaviour,
so they passed when first run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… refuses .py's empty range

`reform_period` was exported but used only by tests, and returned a range
ending before it starts for a value starting after 2200, which
policyengine-uk refuses. The reader now builds each period with one private
function that returns None for such a value, and the library no longer
exports `reform_period`. The module docstring and the readme now say that
.py's range ending before it starts makes a UK calculation fail, where
policyengine-core applies it on no day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ibrary's (failing)

A failure raised deep in a library, which the persistence library called,
both at the image's site-packages paths, must log the frame that raised it,
the persistence call and the route's handler, with each path starting after
site-packages/ or the checkout's src/. The fake can now raise its failure
through a given function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each frame's path now starts after site-packages/ (the image) or the
checkout's src/, so more frames fit the log's string limit. When they still
don't all fit, the frame that raised the error is kept, then the frames of
policyengine_simulation_entry and policyengine_simulation_persistence, then
other libraries' inner frames. The kept frames stay innermost first, and
stack_frames_omitted counts every frame left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test_py_reform_dicts docstring said "starting after the stop", which
could mean .py's 2100 or the reader's 2200. The reader leaves out a last
open-ended value starting in 2201 or later, and gives one starting in 2101
to 2200 a range to the end of 2200, where .py's range for it ends before it
starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 3 commits November 11, 2026 19:06
…ease environment

The new staging_household_check job declares the release environment,
because it reads the beta variable SIMULATION_STAGING_HOUSEHOLD_ID and the
SIMULATION_ENTRYPOINT_AUTH_* and SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_*
secrets, which exist only as beta and prod environment secrets. That raised
the number of jobs declaring it from 17 to 18, which the deployment-assets
test still expected to be 17.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…when they were added

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r simulation and Modal script

The executor's test_all_scripts_have_shebang requires every
.github/scripts/modal-*.sh and simulation-*.sh script to start with
#!/bin/bash; the two new staging household scripts started with
#!/usr/bin/env bash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk marked this pull request as ready for review November 11, 2026 17:25
@anth-volk
anth-volk requested a review from a team as a code owner November 11, 2026 17:25
@anth-volk
anth-volk merged commit b53a4f0 into main Nov 11, 2026
31 checks passed
@anth-volk
anth-volk deleted the v2plan/c1-2-household-calculations branch November 11, 2026 17:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Create and read household calculations in the Simulation Entrypoint on the Public API's household_jobs table

1 participant