Skip to content

Fix UK /calculate crashing on the policyengine-uk wrapper Simulation#1632

Merged
anth-volk merged 8 commits into
mainfrom
fix/uk-calculate-new-simulation-wrapper
Jul 16, 2026
Merged

Fix UK /calculate crashing on the policyengine-uk wrapper Simulation#1632
anth-volk merged 8 commits into
mainfrom
fix/uk-calculate-new-simulation-wrapper

Conversation

@anth-volk

Copy link
Copy Markdown
Collaborator

Fixes #1631

What broke

policyengine-uk 2.43.0 replaced its core Simulation subclass with a wrapper whose constructor is (scenario, situation, dataset, trace, reform) and which builds its own tax-benefit system internally. PolicyEngineCountry.calculate still passed tax_benefit_system=, so every UK /calculate request has returned a 500 since the UK pin crossed that boundary (2.65.0, December 2025). No test lane sent a UK household through the endpoint, so nothing caught it.

What this PR does

  • Routes simulation construction by country id, chosen once in PolicyEngineCountry.__init__: UK calculations go through a wrapper-style builder; US/CA/NG/IL keep the injected-system path byte-for-byte. Enum serialization (scalar and under axes) is routed the same way, since the wrapper decodes enums to string arrays while core returns EnumArrays.
  • Applies UK reforms via Scenario with applied_before_data_load=True. The wrapper's own reform= argument applies parameter changes only after it creates structural reforms from baseline parameters, so reforms on structural-trigger parameters (gov.contrib.*) changed the parameter but never activated the reform — HTTP 200 with baseline numbers. Upstream: Structural-trigger parameters are sampled at a fixed instant, so reforms gating structural changes can silently fail to activate policyengine-uk#1800.
  • Validates policy period keys ("start.stop" instant pairs) as a single grammar owned by country.py and enforced on every calculate(); the endpoint maps failures to a descriptive 500 after all household validation, preserving historical error precedence. The UK wrapper otherwise silently applies bare-instant keys to a single day. Moving to 400 + docs: Malformed policy period keys should return 400 Bad Request, not 500, and be documented #1628.
  • Casts reform values explicitly to the parameter's type. String "false" previously enabled boolean parameters (bool("false") is True); accepted boolean forms are now true/false, "true"/"false", "1"/"0", and the numbers 0/1, with ambiguous forms raising. The parameter's type is read from its most recent non-null value instead of its oldest.
  • Adds UK coverage to every test lane: unit (UC calculation, enum decoding, parametric + structural-trigger reforms, axes, Scenario interface pin), endpoint-integration (pinned UC value of £5,079.13 for a standard-allowance-only claimant), and deployed smoke tests with per-country version resolution. Also pins the previously untested US reform and axes paths.
  • Deletes the dead create_policy_reform, updates the OpenAPI policy description, and adds three changelog fragments.

Verification

Related

#1628 (400 migration), #1629 (UK app extrication), #1630 (drop CA/NG/IL)

🤖 Generated with Claude Code

anth-volk and others added 8 commits July 16, 2026 00:14
policyengine-uk 2.43 replaced its core Simulation subclass with a
wrapper that builds its own tax-benefit system and no longer accepts
one from outside, so every UK calculation has returned a 500 since the
pin first crossed that boundary (2.65.0, December 2025). Detect which
constructor shape each country package exposes, pass situations and
reforms through the wrapper's own arguments (preserving the API's
reform value casting), and serialize the wrapper's string-array enum
results alongside core-style EnumArrays.

Adds UK coverage to the unit and deployed test lanes, which previously
never sent a UK household through /calculate — the gap that let this
ship broken.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The integration_with_auth lane never sent a UK household through the
local Flask endpoint, so a UK regression there would only surface at
deploy time. Cover it with the most hand-verifiable case: a single
unemployed adult aged 25+ with no housing costs, whose Universal Credit
is exactly the 12-month standard allowance (including the Universal
Credit Act 2025 uplift the model applies from April 2026), pinned at
£5,079.13 for 2026.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…orm handling

policyengine-us's Simulation.__init__ is (*args, **kwargs), so probing
the signature for an explicit tax_benefit_system parameter misclassified
it as wrapper-style: no-reform US requests worked by accident through
core's default-system fallback, but any US reform request would have
handed the policy dict to core's `reform` argument, which expects a
Reform class. Treat a **kwargs-forwarding constructor as core-style and
pin the US reform path with a unit test.

Also consolidate reform handling found in review: one _cast_reform_values
step now feeds both simulation paths (the injected-system path applies it
to a cloned system; the wrapper path passes it through), calculate()
checks the path flag once, emptiness checks use plain truthiness, and the
dead create_policy_reform helper (zero call sites, third copy of the
casting logic) is deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s; validate policy period keys

Replace signature sniffing with explicit routing chosen once in
PolicyEngineCountry.__init__: UK calculations go through a dedicated
wrapper-style builder, all other countries through the standard
core-style builder.

The UK builder applies reforms through a Scenario with
applied_before_data_load=True. The wrapper's own reform argument applies
parameter changes only after it has created structural reforms from
baseline parameter values, so reforms on structural-trigger parameters
(gov.contrib marriage-tax, disable_simulated_benefits, Scottish child
payment, household HITC) changed the parameter but never activated the
reform, returning baseline numbers with a 200. Pinned by a
marriage-allowance unit test; note the wrapper samples trigger
parameters at its default_input_period, so reforms must cover that
instant.

/calculate now validates policy period keys up front: every key must be
two dot-separated instants. This closes a silent divergence where the UK
wrapper path accepted a bare instant and applied the change to a single
day, returning near-baseline numbers with a 200 while the core path
500'd. The check returns a descriptive 500 to match the endpoint's
long-standing status for bad policy input; issue #1628 tracks moving it
to a 400 and documenting the grammar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Enum outputs requested together with axes returned silent nulls on the
UK path: the wrapper decodes enums to string arrays, the shared
astype(float) reshaping raised, and the axes error handler swallowed it.
Axes reshaping and scalar enum decoding are now country strategy
functions bound alongside the simulation builder; the UK variants return
decoded names, the standard variants are unchanged (US enum-under-axes
still returns numeric indices, tracked upstream).

_cast_reform_value now takes the parameter's type from its most recent
non-null value (values_list is newest-first, so the old [-1] read the
oldest — a null placeholder there crashed valid reforms) and casts
booleans explicitly: bool("false") is True in Python, so string "false"
used to enable the flag it was meant to disable. Unrecognized boolean
strings now raise instead of silently coercing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move the policy period-key check after all household validation in
get_calculate, restoring main's error precedence (mixed-invalid payloads
returned 400 "Invalid household variables." before the policy 500; the
first placement flipped that). The grammar now lives in country.py next
to the code that consumes it — validate_policy_periods runs inside
_cast_reform_values on every calculate() regardless of caller, so future
endpoints can't reintroduce the silently-accepted bare-instant bug, and
the endpoint calls the same helper for its explicit-500 mapping.

Pin the deliberate _cast_bool narrowing with tests: string "2"/"1.0" and
null — accepted by accident before the rewrite — now raise, alongside
the intended "false" fix. Add a changelog fragment for the casting
rewrite, fix the stale _cast_reform_values docstring that still
described the reform= argument, document the policy grammar in the
OpenAPI spec, and pin the Scenario interface the UK builder relies on so
a policyengine-uk rename fails in CI rather than per-request in prod.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_cast_bool accepted any number via bool() while rejecting the same
digits as strings — JSON 2 silently enabled a flag that "2" 500'd on.
Only 0 and 1 now coerce; other numbers raise like ambiguous strings do.
Pinned by rejection tests (2, -3, 2.5) and acceptance tests (0.0, 1.0),
with the changelog fragment and OpenAPI policy description updated to
match.

Also fix the stale "up front" wording (the policy check deliberately
runs after household validation) in the changelog fragment and the
validate_policy_periods docstring, and tighten the endpoint 500 test to
startswith so dropping the endpoint's own validation call — which exists
solely for the unwrapped message — fails a test instead of silently
degrading the contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@anth-volk
anth-volk marked this pull request as ready for review July 16, 2026 14:38
@anth-volk
anth-volk merged commit 2ee8622 into main Jul 16, 2026
6 checks passed
@anth-volk
anth-volk deleted the fix/uk-calculate-new-simulation-wrapper branch July 16, 2026 14:38
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.

UK /calculate returns 500 on every request since the policyengine-uk 2.43 Simulation rewrite

1 participant