Repository navigation
Conversation
The main deployment's beta job and the Stage 12 comparison path's staging deployment now pass promote_entrypoint: true, as production deployments do. Each staging deployment therefore moves the staging service's stable URL to the revision it tested, runs the same stable-URL checks as production, restores the previous revision if they fail, and then deletes the staging service's older revisions except the promoted one and the one it replaced. The new rule sim.deploy.staging-promotes-tested-revision requires it: a test reads every workflow, finds each job that runs the reusable deployment for staging, and fails unless it passes promote_entrypoint: true. Its proof fixtures are the beta job before and after this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…build The beta environment does not restrict which branch a run may come from, so a manual run of either deployment workflow from a branch other than main now moves the staging stable URL to that branch's build until the next deployment from main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The prod deployment needs the whole beta deployment, which now includes the revision clean-up that runs after a staging promotion, so a failed staging clean-up also stops the production deployment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e staging clean-up The repository README already says the prod deployment starts only after beta's revision clean-up succeeds; the entrypoint's DEPLOYMENT.md listed the beta jobs prod waits for without the clean-up job. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stage12InvocationContext gains result_delivery: stage12_database (the default, today's behavior) or return_to_caller, for a report coordinator that returns its result instead of recording it in the Stage 12 tables. A context that leaves the field out, as the Simulation Entrypoint's does, keeps the Stage 12 behavior. A returned report cannot name a production job to compare with, because that comparison is recorded on the Stage 12 report row. Nothing reads the field yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Stage 12 coordinator now leaves result_delivery out of the context it sends to its baseline and reform workers, so their payload has exactly the fields it had before the field existed. A coordinator whose report returns to its caller will send the field, so its workers record nothing in the Stage 12 tables either. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New policyengine_simulation_contract.report_execution: - ReportExecutionResult: the report's aggregate inline as a SingleYearMacroOutput, its SHA-256 (report_aggregate_sha256, over compact key-sorted JSON, rejecting NaN and infinity), the output plan, the baseline and reform artifact descriptors, and provenance (bundle, worker version, Modal environment, application, simulation function, version manifest digest and coordinator call ID). It rejects artifacts that do not form the report, a digest that does not match, and requested aggregates that differ from the ones the output plan was built for, so the two cannot disagree undetected. - ReportExecutionFailure and ReportExecutionError: the redacted error code and summary a Stage 12 row would record, under a fixed message. A test sends the error through Modal's own exception transport (pickle_exception in the container, _process_result in the caller) and gets the same type with the same failure back. Nothing produces these types yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A simulation worker given a context whose result_delivery is return_to_caller reads and writes no Stage 12 rows and never builds the database store. It checks the policy periods, writes the input and the Parquet artifact exactly as a Stage 12 worker does, and returns the artifact descriptor. A failure is reported once, recorded as the operation's cause, and raised as ReportExecutionError with the redacted code and summary a Stage 12 row would record, including a segment's failure and typed input failures such as invalid_policy_period. No record can tell a returned simulation to stop, so a segmented US run is never told to stop through the database; its coordinator cancels it through Modal instead. The Stage 12 worker is unchanged: the policy check, input write, calculation and artifact write move into _SimulationRun, which both modes use, and the failure detail into _simulation_failure_detail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New stage12_runtime.coordinate_report_for_caller runs a report whose context asks for return_to_caller and returns a ReportExecutionResult payload with the aggregate inline. It reads and writes no Stage 12 rows and never uses the database store: - It claims nothing, so a repeated dispatch runs the report again; the caller deduplicates. - It writes artifacts under the Stage 12 layout built from the context (environment, creation time, report), replacing any prefix the caller sent, and starts both workers with the same delivery. - It plans the output, starts both workers, waits for them within the shared window, and reads and checks their artifacts with the same code the Stage 12 coordinator runs. It then aggregates, applies the checks a Stage 12 aggregate artifact write applies, requires the aggregate to describe this report, and validates its result as SingleYearMacroOutput. No aggregate artifact is written and no production comparison runs. - A failure is reported once, recorded as the operation's cause, and raised as ReportExecutionError with the redacted code and summary a Stage 12 row would record. A worker's own ReportExecutionError takes precedence over its call error, as a Stage 12 worker's recorded failure does. A worker that must stop is cancelled through Modal, and a cancellation error is logged without replacing the failure. The shared steps move unchanged into ReportSimulationCoordination, whose subclasses decide what a failed or stopped child records. The Stage 12 coordinator keeps its behavior, and now refuses a context that asks for return_to_caller before claiming anything. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The v2 Modal application's coordinate_report function now takes its parent record as an optional argument. A context whose result_delivery is return_to_caller runs coordinate_report_for_caller, refuses a parent record, and labels its operation with result_delivery=return_to_caller so telemetry tells it apart from Stage 12 runs. Every other context runs the Stage 12 coordinator exactly as before, with the same operation attributes; the Simulation Entrypoint still passes all three arguments, and a Stage 12 report without a parent record is still rejected before anything is claimed or started. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Modal stops a cancelled synchronous input by raising InputCancellation in it. That is a BaseException, so the segment runner's except Exception handlers never ran, and the up to 20 region-group segment calls the worker had started kept running until they finished or reached their timeout. The segment runner now cancels every started segment call when dispatch or the wait stops on a BaseException that is not an Exception, then re-raises it. Failures that are Exceptions keep their existing handling, so a segment failure or a cancellation read from the Stage 12 record still cancels each segment exactly once. A returned report's coordinator stops its workers through Modal, so this is how a returned segmented run stops its segments. On the Stage 12 path the coordinator cancels a segmented worker through Modal only when it could not record the cancellation; that fallback now stops the segment calls too. Stage 12 records and results are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Modal stops a cancelled coordinator input by raising InputCancellation, a BaseException, in it, and does not cancel the calls that input spawned. The returned coordinator caught only Exceptions, so its baseline and reform workers kept running after a caller cancelled the report. coordinate_report_for_caller now cancels every started, unfinished worker through Modal when it stops on a BaseException that is not an Exception, then re-raises it. A cancellation is not a report failure, so nothing is reported or raised as ReportExecutionError. With the segment runner change, a cancelled segmented worker then cancels its segment calls. The Stage 12 coordinator is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test that sends ReportExecutionError through Modal's exception transport imports two private Modal client functions. It moves into its own module, so a Modal upgrade that moves them fails only that module instead of every report execution test. A new test checks that the Modal coordinate_report function raises a returned report's ReportExecutionError unchanged, as the same object, and still shuts its telemetry runtime down. Modal pickles exactly what the function raises, so with the transport test this covers both halves of the error's path to the caller; neither runs a real Modal container. Both tests cover existing behavior and passed on their first run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ings The module docstring described Stage 12 coordination only, and ReportSimulationCoordination._failure_detail still described Stage 12 steps although the returned coordinator shares it. Both now say what each coordinator records. Documentation only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two short windows remained in which Modal's InputCancellation left segment calls running: while a dispatch span was closing after its call had started, because the call was added to the list only after the span closed; and between the end of the dispatch phase and the start of the wait. The segment runner now adds each call to its list inside the dispatch span, and one handler covering both the dispatch and the wait cancels every started call on a BaseException that is not an Exception. Failures that are Exceptions keep their existing handling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two comments still said that cancelling a segmented worker through Modal leaves its region-group segment calls running. Since the segment runner answers Modal's InputCancellation by cancelling them, both now describe that. The Stage 12 coordinator still writes the cancellation to the worker's record first and uses Modal only when that write fails. Documentation only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The returned coordinator rejects an aggregate whose report ID, bundle or requested aggregates differ from the report, but only the report ID was tested. The test now covers all three. It covers existing behavior and passed on its first run; with the bundle and requested-aggregates comparisons removed from the coordinator, those two cases fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ReportSimulationCoordination._spawn_child returned a worker's call only after the dispatch span had closed, and its callers stored it after that. If Modal cancelled the returned coordinator in that short window, the coordinator could not see the call, and the started worker (with a segmented US worker's 20 segment calls) ran to the end for a report no caller would receive. _spawn_child now keeps the call in calls inside the dispatch span, and both coordinators rely on it instead of storing the call afterwards. On the Stage 12 path this also means that a failure while the span closes stops the worker that had just started, instead of leaving it running; Stage 12 records are otherwise unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The coordinator operation of a returned report carries result_delivery=return_to_caller, but its baseline and reform workers' operations did not, so their timings and failures were counted as Stage 12 worker data unless a query joined them through the trace. The US and UK worker functions now add the same attribute when their context asks for the report to return to its caller. Stage 12 workers keep exactly their current attributes (tested). The region-group segment function receives no dispatch context and is unchanged; its spans join the worker's trace. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The returned result crosses Modal as JSON values so that the container's and the caller's pydantic versions need not agree, but ReportExecutionError pickled its ReportExecutionFailure model itself, with pydantic's internal state. It now pickles the failure's JSON-mode values and rebuilds the model when it is unpickled. Modal's exception transport still raises it in the caller as the same type with the same failure (tested), including from Modal 1.4.2 in a worker to Modal 1.5.5 in the Simulation Entrypoint's environment (checked once by hand). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tore it When Modal pre-empts a call, it restarts it on another machine and the restarted call starts over. A returned report's worker wrote its Parquet output to a fixed key, and the bucket accepts a second write to a key only when the bytes are identical. A restarted calculation can differ from the first attempt's in its last bits, so the restarted worker failed with "immutable Stage 12 artifact path contains other data", and so did the workers a restarted coordinator starts, turning a pre-emption into a failed report. A returned simulation now stores its output under simulations/<role>-<sha256>.parquet (write_simulation_named_by_digest): an identical output reuses the earlier object, and a different one is stored beside it. Its input JSON keeps its key, because a restart plans the same input and writes the same bytes. The comparison path keeps its fixed keys. Tests: a restarted worker whose output differs by one bit stores it, one with the same output reuses the artifact, and a returned report whose coordinator is interrupted after the baseline stored its output finishes when restarted on another machine. The first and third failed with the collision before this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A restarted call can land on a redeployed copy of the same Modal application, whose output planning may differ, and a repeated dispatch can reuse a report ID with a different planned input. Either way the returned worker wrote its input JSON to the same fixed key with other bytes and failed with "immutable Stage 12 artifact path contains other data". A returned simulation now stores its planned input under inputs/<role>-<sha256>.json (write_input_named_by_digest), as it already stores its output. The comparison path keeps its fixed keys; a new test runs a comparison-path worker over the real artifact store and checks both keys, since the test fake gives both kinds of write one key per role. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merges v2plan/1c-109-typed-coordinator-result (#896) at 52f9be4 into the rebased result-tables branch, which is on main. Main has since split the modules #896 changes, so its changes move into main's structure: - the contract's ReportResultDelivery and the invocation context's result_delivery field go into stage12_execution/comparison_runs.py, and the package exports ReportResultDelivery; - ReportSimulationCoordination, the Stage 12 ReportCoordination subclass and _child_context_payload go into report_coordination.py; the child call cancellation and the child wait failure report go into coordinated_simulations.py as cancel_child_call and report_child_wait_failure, public because report_coordination uses them; coordinate_report keeps the refusal of a returned report, and coordination.py re-exports ReportSimulationCoordination and validate_dispatch_context for returned_coordination; - the tests use main's support modules: the digest-named writes are on FakeArtifacts, ContextRecordingInvoker and ReformDispatchFailureInvoker move to stage12_child_call_support, the context test joins test_stage12_coordinator.py, and the returned report and artifact tests import main's helper names. #896 makes stage12_runtime/simulation.py 830 lines, over main's limit, so its record handling (descriptor_from_record, the claim, the cancellation check and the recorded success and failure) moves unchanged to simulation_records.py; simulation.py re-exports descriptor_from_record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An economy simulation run's call names the run, its policy's parameter values, and the region and dataset its catalog version has: the dataset's name and the household column and value that select the region, with each state's filter for a US national run. The call returns the files it stored. A report run's comparison call names both runs' stored outputs and policies. A policy that changes a labour supply elasticity is recognised by the parameter paths policyengine.py itself checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A simulation that carries a catalog population in its options loads the dataset the catalog names and keeps the households its catalog filter selects: a stored value written as an integer, such as a state's FIPS number, is compared as one, and a list selects any of its values. A US national run's segments are scoped to their states' catalog filters, and every state the catalog filters is calculated. The installed bundle check requires the provenance of the catalog's dataset. Stage 12 runs carry no catalog population and resolve their regions as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The call checks that it runs on the PolicyEngine.py version and release its catalog version names, plans the columns any report comparing the run reads, and calculates it as a Stage 12 worker returning its result: no database rows, the catalog's dataset and filter, segments for a US national run, and input and output stored under keys naming their SHA-256. The labour supply response columns are planned only when the call asks for them, since in the UK they mean simulating again. A simulation run has no report role, so its Stage 12 input names it the baseline. The call returns the stored files' locations and digests, the output plan's digest and the bundle provenance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comparison reads both outputs, refuses one whose bytes differ from the digest recorded for it, requires both to hold the same households and the columns the pair's comparison reads, and aggregates them with the Stage 12 aggregation into a SingleYearMacroOutput. A run compared with itself is read for both sides. The call writes nothing, so a restarted call runs again. A failure reaches the caller as the typed, redacted error a returned Stage 12 report raises. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
run_economy_simulation_us and _uk calculate one simulation run on their country's worker image, with the Stage 12 workers' time limits, the US one in segments of this application; compare_economy_simulations compares a report run's two outputs on the coordinator image. Each starts report_call_ended with its call ID and run ID when it returns or raises. A pre-empted or cancelled call sends no report: its segment calls are cancelled, and Modal runs a pre-empted input again under the same call ID, which reports when it ends. The unit tests check that each function is declared under the name sim starts it by; nothing checks a decorated function's name at import, which the real Modal package does not allow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The runtime role reads, from the Public API's tables in the same database, a country's catalog version for a PolicyEngine.py version; a region and its default or named dataset in that version, with the household column and value that select it, and each state's filter for a US national run; a policy's and dynamic's parameter values by name and period, refusing the run when its catalog version lacks a parameter they change; and the UK area names a stored result is read back with. A region selected by anything but a row filter cannot run. The tests grant the runtime role SELECT on these tables, as the operator will. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A simulation with an existing definition is found rather than created again, so its runs can be reused. A report's runs are listed newest first. A comparison step is ready to start once both its runs succeeded, also when one run is compared with itself; the runs a finished call computed narrow the search. Runs left pending, whose submitter stopped before starting their call, are found after a while. A run's stored files are read by role while they are offered. A run its catalog version cannot run fails as invalid_input. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After recording due calls, each safety-check pass starts the comparison steps whose two runs have succeeded, which a lost call report or a stopped instance left pending, and starts the runs a submitter left pending for ten minutes, asking for labour supply columns when any report run comparing the run needs them. The safety-check job now also receives the serving revision's Modal environment, where it starts these calls. Each pass logs how many comparisons and runs it started. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Once the staging deployment has promoted the revision it tested, a new job submits a report on Wales, current law compared with itself, to the stable URL, runs it again with fresh runs so this deployment's Modal functions calculate it, waits up to twenty minutes for its comparison, and reads the result back. It uses the test client the deployed-auth checks use. The v2 economy path serves no user yet, so the job's failure shows in the run without holding back the production deployment. The test lives with the deployed-auth checks and is skipped wherever the job does not ask for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The deployment guide lists the economy report routes, the Modal functions and environment they start calls in, the Public API tables the runtime role must be granted SELECT on, the safety check's waiting work, and the staging check after each promotion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merges v2plan/1j-139-staging-promotion (#894) at 4f34bf0. The staging economy check runs after the staging deployment promotes the revision it tested, which #894 adds. Main has since split tests/test_deployment_assets.py, so #894's test changes move into main's structure: its path constants go into test_deployment_assets_support.py, and its staging promotion check and rule proof stay in test_deployment_assets.py, which the rule registry names, using the support module's workflow_jobs. The changelog keeps both sides' entries, and the generated rules document and AGENTS.md are rendered again from the merged registry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The economy check waits for the staging promotion; a test now requires both workflows that deploy staging to ask for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The catalog publishes some regions, such as US places, without a filter that selects their households. Such a region would have calculated the whole country's dataset and stored it as the region's result. The catalog reader now refuses a region that is not national and has no filter, and the call's population refuses one too. The workers look a catalog dataset up by its name alone, so a comparison no longer builds a population. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A labour supply report forces new runs and asks them for their labour supply columns. When its submitter stops before starting one, another report can reuse the pending run and start it; it now asks for those columns whenever any report run comparing the run needs them, as the safety check already did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The catalog population module now returns its own region and dataset values; the Stage 12 calculation, which already reads the main path's region and selection types, wraps them where it uses them. No behaviour changes. The dispatch module's docstring names the routes and the safety check that now start calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… own module `main` allows a source file at most 800 lines, and the economy service grows past that with the fixes that follow. Its requests, errors and store protocols, and the catalog population a call carries, move unchanged to `economy_report_types`; `economy_reports` keeps the service. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each safety-check pass now starts the waiting work item by item: a failure is logged and leaves that item for the next pass instead of stopping the others. It looks only at economy runs, so household runs are left to their own path. A pending run that no report run compares, or that its catalog version can no longer resolve, is failed, so it is not picked again. The pass counts only the runs and comparisons whose calls started. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The default pass now builds from the environment main receives, as the database URL already did, instead of reading the process environment again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The economy report routes read only economy reports; a household report has no comparison step, so it is not described there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A labour supply report creates its forced runs and records its report run in separate steps. A report reusing such a run in between could start it without the labour supply columns. A submission now starts only the runs it created. A reused run still pending is started by the submission that created it or, if that submitter stopped, by the safety check, which asks for the columns any report run comparing the run needs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The catalog population a simulation call carries has its own checks (a state code of two capital letters, state filters keyed by state, list values of text). A submission now builds it while checking the request, so such rows are refused with 422 before any write, and the safety check fails a waiting run whose rows cannot be sent instead of retrying it. Running a household report on the economy path answers as for any other report that is not an economy report: not found. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Public API stores each period date as midnight UTC. Formatted in a session whose time zone is behind UTC, they moved back a day; they are now converted to UTC first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The staging check ran its report again with fresh runs every time, so on a new catalog version both the submission and the re-run calculated it. It now runs it again only when the submission reused runs an earlier deployment finished, so each deployment costs one simulation call and one comparison call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Public API stores `regions.region_type` as the PostgreSQL enum `v2_region_type`. PostgreSQL has no `=` between that enum and a text value, so the state filters of a US national run and the UK area names a stored result is read with failed with "operator does not exist: v2_region_type = character varying". The catalog reads now compare the column cast to text, so they need neither the enum's values nor a change when the Public API adds one. The test copies of the Public API tables now use the real types: the enum for `region_type` (NOT NULL, as is `label`) and `json`, not `jsonb`, for `value_json`, and a test checks both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A forced start (a labour supply report's, or a forced re-run) that read the same next attempt as an unforced caller, and lost the insert to it, returned the other caller's run as not created. The forced caller then did not start it, and the run started without the labour supply columns its report needs, so that report run failed. A forced start now adds the attempt after the one it lost; an unforced start still returns the winner's run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ocument The routes answer sim's own refusals as 422 with a text `detail`, and an unknown record or an unavailable database as 404 or 503, but the OpenAPI document declared only FastAPI's validation 422, whose `detail` is a list. The generated client therefore raised a parse error on sim's own 422s and returned nothing for 404 and 503. Each route now declares its error answers: 404 and 503 as `EconomyErrorResponse` (`detail` as text), and 422 as `EconomyRefusalResponse`, whose `detail` is either sim's reason as text or FastAPI's list of refused fields. A test checks the declarations and that each answer's body matches them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A report read whose succeeded run's stored result could not be rebuilt, for example because it names a UK area the catalog version lacks, raised a LookupError that no route mapped, so it answered FastAPI's plain 500. The report reads now answer 500 with an `EconomyErrorResponse` body, declared in the OpenAPI document, and log the report run and the error type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A report migrated from v1 without a run is calculated when it is first opened, through a `system` run. Two first opens at the same time each recorded a report run, so the comparison ran twice, and a labour supply report calculated its simulations twice. A `system` run now returns the report's newest run when it has one. Report runs recorded for a `system` run go through `RunStore.create_first_report_run`, which takes a transaction-level advisory lock on the report, so simultaneous first opens record one report run and the others return it. A first open that loses still starts the runs it created that the recorded run compares, and fails the others (a labour supply report's own forced runs) instead of leaving them for the safety check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The staging check's docstring said it always costs one simulation call and one comparison call; when the submission reused a finished run, the submission compares that run at once and the check's fresh run adds a second comparison call. The deployment guide now says that a report reusing a run another submission created waits for that submission to start it, up to ten minutes plus one safety-check interval if it stopped first, and describes the system-run reuse and the declared error answers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When two first opens of a labour supply report race, each forces new simulation runs, and the one that loses the report run did not need its own. It failed them at once as abandoned. But in the moment between creating them and losing, another report sharing a simulation, usually the current-law baseline, could reuse one of them and wait for it to start; that report then failed for a reason that was false for it. The losing caller now leaves those runs pending. The safety check starts a pending run for every report run comparing it, or fails it when nothing does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rt limit The persistence library's readme said concurrent starts always settle on one row; a forced start now adds the attempt after one it lost, and gives up after 10 lost inserts in a row. It also describes RunStore.create_first_report_run and its transaction-level advisory lock. A test covers the forced start that loses every insert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The persistence tests create copies of the Public API tables sim reads and refers to, with only the columns sim uses. A copy that differs from the real table hides bugs, as a varchar copy of the enum column regions.region_type did. test_public_api_schema now compares, for every table the copies create, each column's type (an enum by name, and its values), whether it allows null, and each primary key and unique constraint by name and columns, with a database the Public API's own v2 migration chain built (PUBLIC_API_SCHEMA_DATABASE_URL). A column or table added to the copies is compared with no other change. Without that database the comparison skips, unless SIMULATION_REQUIRE_PUBLIC_API_SCHEMA is 1. Against the Public API's default branch it found the copies differed: parameter_values.value_json is jsonb there, not json; model_id and version of tax_benefit_model_versions, and tax_benefit_model_version_id of policies, refuse null; and the primary keys and the model name's unique constraint carry the Public API's names. The copies now match, and the test rows fill the columns that refuse null. A proof test checks the comparison on fixtures: the copies with regions.region_type as varchar(64) are refused, and the current copies are accepted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new pr.yml job, Public API schema, checks out PolicyEngine/policyengine-api (public, so without a secret or persisted credentials) at its default branch, builds its tables with its own v2 migration chain into the job's PostgreSQL (.github/scripts/public-api-schema-reference.sh, which prints the commit it checked), and runs the persistence tests marked public_api_schema, which fail rather than skip there. CI complete needs it. Following the default branch, rather than a pinned commit, makes a change there to a column sim reads fail sim's next CI run instead of waiting for someone to move a pin. The new rule sim.persistence.public-api-copies-match records this, with its proof, and the persistence readme describes the copies, the job and how to run it locally. The job reads no secret: its database URLs and switches are literals, and the script sets the Public API's migration settings from its input. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Enum columns are described by type name without their schema, so an enum the Public API moved out of its public schema would have passed with its values unchecked; the comparison now reports it. A test covers each kind of difference the comparison reports: a column's nullability, a missing column, table or constraint, a constraint with its columns in another order, an enum's values, and a missing enum. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reference script ran `git rev-parse` inside `echo`'s arguments, so a failure printed an empty commit and went on. It now stops, and on GitHub Actions passes the commit to the later steps as PUBLIC_API_SCHEMA_COMMIT, which a failed comparison names. The persistence readme lists the local steps, including checking out the commit a failed run names. A test now checks that a json copy of parameter_values.value_json, which the Public API's baseline revision created as json and a later revision made jsonb, is refused. The hand-written check of two column types in test_catalog_reads, which the comparison now covers, is removed, as is the unused JSON writer, and the copies' docstring says they also hold the columns the catalog reads select. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #973
Same-Repository Draft PR Check
PolicyEngine/policyengine-sim-api, not a personal fork.Summary
After this deploys, the Simulation Entrypoint runs and stores whole economy reports on v2 without the old gateway, and a small staging report runs after every staging promotion. No user reaches it yet: the Public API's
/v2/reports(a separate PR) is its first caller.It builds on #951 (stacked on #950, #900 and #898, all on
main) and merges two other unmerged PRs' branches with plain merge commits:a915e342.mainhas split the modules Let the v2 report coordinator return a typed result to its caller #896 changes, so its changes are placed inmain's modules:ReportResultDeliveryinstage12_execution/comparison_runs.py;ReportSimulationCoordinationand the Stage 12 coordinator inreport_coordination.py; the child call cancellation and wait-failure report incoordinated_simulations.py; its tests inmain's test support modules. Let the v2 report coordinator return a typed result to its caller #896 makesstage12_runtime/simulation.py830 lines, so that merge also moves the simulation's record handling, unchanged, tostage12_runtime/simulation_records.py.b65541ee. Its test changes are placed inmain's split deployment tests; the changelog keeps both entries, and the rules document is rendered again.One Modal call per simulation run, one per comparison
policyengine_simulation_contract.economy_runs):EconomySimulationCallnames one simulation run, its policy's parameter values by name and period, its year, the PolicyEngine.py version, and the region and dataset its catalog version has (CatalogPopulation: the dataset's name, the household column and stored value that select the region, and each state's filter for a US national run). The call returnsEconomySimulationOutput: its stored input and output files, the output plan's digest, row identity and bundle provenance.EconomyComparisonCallnames a report run and both runs' policies and stored outputs (URI and SHA-256); it returns aSingleYearMacroOutput.run_economy_simulation_usand_ukin the v2 Modal app): each checks that the call names the PolicyEngine.py version and a dataset the installed release has, plans the run's output columns (resolve_simulation_output_plan: the columns any report comparing it reads), and calculates it as a Stage 12 worker returning its result: no database rows, input and output stored under keys naming their SHA-256 in the Stage 12 artifact layout, a US national run in its 20 region-group segments. A simulation run has no report role; its Stage 12 input names it the baseline.compare_economy_simulations, on the coordinator image): read both outputs, refuse one whose bytes differ from the digest sim recorded, require both to hold the same households and the columns the pair's comparison reads, compute the comparison with the Stage 12 comparison code, and return the result. They write nothing.reporting_call_end, which startsreport_call_endedwith the call ID and the run ID when the work returns or raises anException. A run ID that can be read from an otherwise malformed payload is still reported, so sim records the failure without waiting for the safety check. The unit tests check that each function is declared under the name sim starts it by; nothing checks a decorated function's__name__at import, which the real Modal package does not allow.Regions and datasets from the catalog
The entrypoint reads the run's region and dataset rows of its catalog version (
CatalogReader, persistence library) and sends the dataset's name and the region's row filter. A simulation carrying a catalog population (options["catalog_population"]) loads that dataset and keeps the households its filter selects; the executor never calls policyengine.py'sget_regionor region registry for it. A stored value written as an integer (a state's FIPS number, a district's GEOID) is compared as one; a JSON list (England's nine regions) selects any of its values. A US national run's segments are scoped to their states' catalog filters, and every state the catalog filters is calculated. A region below the country without a household filter is refused. Stage 12 runs carry no catalog population and resolve their regions as before; no main-path code is used.The Public API stores
regions.region_typeas the PostgreSQL enumv2_region_type, which has no comparison with text values, so the catalog reads compare it cast to text. Policy period dates are read in UTC. A policy value with no end date becomes a period key of its start date alone, which the worker reads as open-ended (aParameterValuewith no end date).The tests' copies of the Public API's tables, checked in CI
The persistence tests create copies of the Public API tables sim reads and refers to, with only the columns sim uses. A copy that differs from the real table hides bugs: a
varcharcopy ofregions.region_typelet the comparison above, which PostgreSQL refuses, pass every test. So:tests/test_public_api_schema.pycompares, for every table the copies create, each column's type (an enum by name and values), whether it allows null, and each primary key and unique constraint by name and columns, with a database the Public API's own v2 migration chain built. A column or table added to the copies is compared with no other change, so coveringhouseholdsandpoliciesneeds only their columns intests/public_api_support.py.PolicyEngine/policyengine-apiat its default branch, builds its tables with its v2 Alembic chain (.github/scripts/public-api-schema-reference.sh, the same commands and settings as the Public API's own Alembic v2 job), and runs that comparison, which fails rather than skips there.CI completeneeds it. It follows the default branch rather than a pinned commit, so a change there to a column sim reads fails sim's next CI run instead of waiting for someone to move a pin; the cost is that such a change turns sim's CI red until the copies (and sim's code, if it reads the column) follow. The script prints the commit it migrated, and a failed comparison names it.parameter_values.value_jsonisjsonb(its baseline revision created it asjson, a later one changed it);tax_benefit_model_versions.model_idandversion, andpolicies.tax_benefit_model_version_id, refuse null; and the primary keys and the model name's unique constraint carry the Public API's names.sim.persistence.public-api-copies-matchrecords this, with a proof: the copies withregions.region_typeasvarchar(64)are refused, and the current ones accepted. It changes the rules directory, so it needs a core developer's approval.Runs, reuse and comparisons in the entrypoint
EconomyReports(economy_reports; its requests, errors and store protocols are ineconomy_report_types) finds or creates the report's two economy simulations (so their runs can be reused), creates the report and its run on the catalog version its PolicyEngine.py version names, finds or creates a run of each simulation (reusing one that is unfinished or succeeded within 28 days), and starts the calls of the runs it created. A policy saved on another catalog version is applied by parameter name; a run whose policy changes a parameter its version lacks fails asinvalid_input, naming the parameters. Catalog rows a call cannot carry are refused before anything is written. Once both runs have succeeded (at once when both were reused, or when a call's report is recorded) the comparison starts; outputs no longer offered fail it asresult_expired.systemrun. Asystemrun returns the report's newest run when it has one, andRunStore.create_first_report_runrecords a first report run under a transaction-level advisory lock on the report, so simultaneous first openings share one run. A losing caller starts the runs it created that the shared run compares and leaves its others pending for the safety check.economy_recorders): a simulation run's input and output files are linked to it (output_files) in the transaction that marks it succeeded; a comparison's result is stored as the result tables' rows.Routes
POST /v2/economy-reports,GET /v2/economy-reports/{report_id},GET /v2/economy-reports/by-legacy-id/{legacy_report_id},POSTandGET /v2/economy-reports/{report_id}/runs, andGET /v2/simulations/{simulation_id}(economy_routes). Submissions needsimulation:submitand readssimulation:read. A report reads with its newest run, the run's status (not_runfor a migrated report without one), its stage while it runs (queued,simulating,outputs, from the run rows) and its error, and, once compared, the result rebuilt from its rows in today's format with UK area names from the catalog. A household report is not found on these routes.Each route declares its error answers in the OpenAPI document, so the generated client parses them: 404 and 503 as
EconomyErrorResponse(detailas text), 422 asEconomyRefusalResponse, whosedetailis sim's reason as text or FastAPI's list of refused fields (EconomyRequestFieldError), and, on the two report reads, 500 asEconomyErrorResponsewhen a succeeded run's stored result cannot be rebuilt. Without the simulation database or the Modal environment the routes answer 503. The generated client gainssubmit_economy_report,get_economy_report,get_economy_report_by_legacy_id,run_economy_report,list_economy_report_runsandget_simulation; the comparison with the old gateway's contract leaves them out.Cancellation and pre-emption
Exception), and Modal's cancellation (InputCancellation) or pre-emption (KeyboardInterrupt), which Let the v2 report coordinator return a typed result to its caller #896's segment runner handles; the economy functions keep that behaviour, and a stopped call sends no report.Staging check
After each staging promotion, the
economy_staging_checkjob submits a report on Wales, current law compared with itself, to the stable URL. When the submission reused a run an earlier deployment finished, it runs the report again with a fresh run, so this deployment's functions calculate it (one simulation call and two comparison calls in that case, one of each otherwise). It waits up to twenty minutes and reads the result back. It needs a staging catalog version for the deployed PolicyEngine.py version. Its failure is shown in the run without holding back the production deployment (continue-on-error).Deliberate limits, for the reviewer
xandy.LocalAreaBreakdownOutput) is not stored by the result tables yet.app.py,simulation_database.py,run_dispatch.py, the v2 Modal app and its tests, the deployment workflow). Whichever merges second rebases. The OpenAPI changes here touch only the economy routes and add three schemas, none of which Calculate and store households through the v2 household functions #957 declares.Environment variables
SIMULATION_DATABASE_URL(entrypoint and safety-check job; read by the new routes, recorders and resumption): unchanged wiring from Give the Simulation Entrypoint its own simulation schema, migration chain and database connection #898 and Record simulation runs and report runs, and their outcomes when Modal reports a call ended #950. Provider: Secret Manager, named by thebeta/prodvariableSIMULATION_DATABASE_URL_SECRET_NAME, mounted bydeploy-cloud-run-simulation-entry.sh, and copied at the pinned version into the safety-check job. Needs a new database grant (operator step): the runtime role must readpublic.tax_benefit_models,public.tax_benefit_model_versions,public.regions,public.datasets,public.parametersandpublic.parameter_values. Not verified in a deployment.MODAL_TOKEN_ID,MODAL_TOKEN_SECRET(entrypoint and safety-check job; now also used to start economy calls): unchanged wiring from Record simulation runs and report runs, and their outcomes when Modal reports a call ended #950. Provider: Secret Manager, named byMODAL_TOKEN_ID_SECRET_NAMEandMODAL_TOKEN_SECRET_SECRET_NAME. Not verified in a deployment.SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT(entrypoint: now also the Modal environment economy calls start in; safety-check job: newly copied from the serving revision). Provider: the reusable workflow inputmodal_environment, written bydeploy-cloud-run-simulation-entry.shas a plain variable; the safety-check deploy script now copies it with the other plain variables. Verified in the scripts and their tests; not in a deployment.APP_ENVIRONMENT(now also names the deployment in economy artifact paths): unchanged; the workflow inputdeployment_environment, copied into the job as before.SIMULATION_CALL_REPORT_MODAL_WORKSPACE_ID: unchanged from Record simulation runs and report runs, and their outcomes when Modal reports a call ended #950; the reports the economy functions send are checked against it. Its operator step (settingMODAL_WORKSPACE_ID) is still open.STAGE12_ARTIFACT_BUCKETandGOOGLE_APPLICATION_CREDENTIALS_JSON(artifact writes and reads) andHUGGING_FACE_TOKEN(datasets) reach the new functions through the same Modal secrets the Stage 12 workers use (stage12-evaluation-runtime,stage12-evaluation-gcp-credentialsand the Hugging Face secret), attached asworker_secrets;MODAL_ENVIRONMENTlabels telemetry (set by Modal, with alocalfallback).report_call_endedgetsSIMULATION_ENTRY_URLfrom the v2 deploy job, as in Record simulation runs and report runs, and their outcomes when Modal reports a call ended #950. The secrets' contents are not verified here.SIMULATION_TEST_DATABASE_URL,PUBLIC_API_SCHEMA_DATABASE_URL,SIMULATION_REQUIRE_DATABASE_TESTSandSIMULATION_REQUIRE_PUBLIC_API_SCHEMAare literals in the job'senv:(the service container's disposable credentials), and the service getsPOSTGRES_USER,POSTGRES_PASSWORDandPOSTGRES_DBas literals. The script setsUV_PYTHON=3.12, andV2_MIGRATION_DATABASE_URLandV2_ALEMBIC_DISPOSABLE_TESTfor the Public API's Alembic command only, from its input; it writesPUBLIC_API_SCHEMA_COMMITtoGITHUB_ENV, which the runner provides. The checkout uses the automaticgithub.tokenunderpermissions: contents: read, withpersist-credentials: false; the Public API's repository is public.SIMULATION_ENTRYPOINT_TEST_AUTH_ISSUER,_AUDIENCE,_CLIENT_IDand_CLIENT_SECRETfrom thebetaenvironment secrets the deployed-auth checks already use (SIMULATION_ENTRYPOINT_AUTH_ISSUER,SIMULATION_ENTRYPOINT_AUTH_AUDIENCE,SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID,SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET), reaching the reusable workflow throughsecrets: inheritand the job'senvironment: beta; the stable URL and PolicyEngine.py version from thedeploy_entrypointandpreparejob outputs. That thebetasecrets exist is not verified here.Waits for
SELECTgrant above),MODAL_WORKSPACE_IDand the safety-check schedule from Record simulation runs and report runs, and their outcomes when Modal reports a call ended #950, and a published staging and production catalog version for the deployed PolicyEngine.py version./v2/reportsneeds a published release of the simulation client containing these routes; publishing follows the production deployment.Testing
On the head commit, locally:
7de28683f: the migration and the comparison pass. With the copies as they were, the comparison listed 14 differences; with the oldvarchar(64)copy ofregions.region_type, or ajsoncopy ofvalue_json, it fails on that column alone;scripts/run_quality_guards.py(including the file-length rule) and the rules tool's tests;The OpenAPI document was generated from this branch and the Python client generated from it with
openapi-python-client0.29.0. Driven against the app with in-memory stores, the client parses each declared error answer: sim's own 422s and FastAPI's, 404 on every read, and 503 on every route.An end-to-end run against a migrated PostgreSQL database with Modal replaced by a recording dispatcher submitted a report, recorded both simulation calls and the comparison, and read the result back rebuilt exactly from its rows; a re-run reused the runs.
Not run: anything against a deployed service, Modal app or database, including the staging check.
🤖 Generated with Claude Code