Skip to content

Bind combined runs as an ordered composition of reform identities - #579

Draft
MaxGhenis wants to merge 4 commits into
w4/receipt-schema-v1from
w4/receipt-composition
Draft

MaxGhenis wants to merge 4 commits into
w4/receipt-schema-v1from
w4/receipt-composition

Conversation

@MaxGhenis

Copy link
Copy Markdown
Contributor

Fixes #578

Draft, stacked on #571 (receipt schema v1); review origin/w4/receipt-schema-v1...HEAD. I'll retarget it to main when #571 merges.

Why

The UK Budget dashboard's combined simulation applies several measures in one Scenario. The engine takes one simulation_modifier, so the combined Scenario's modifier composes the members' module-level modifiers. scenario_reform_identity rightly refuses that composed closure. That leaves a combined run with any structural member without a valid reform identity, and reform is required in schema v1.

What this adds

composed_reform_identity(members: Sequence[ReformIdentity], *, name=None) -> ReformIdentity

  • representation: policyengine-uk.scenario_composition/v1;
  • payload: {"members": [{"representation", "reform_sha256", "binding"}, ...]} in application order;
  • binding: code_revision if any member's is, else content.

Each member is still bound on its own with reform_identity or scenario_reform_identity, so every modifier remains a module-level function bound by source and revision.

What the representation means: one policyengine-uk Scenario whose parameter_changes are the members' changes merged, and whose simulation_modifier applies the members' modifiers in the listed order, after the parameter changes. The composer must check that no two members set one parameter to different values; this function sees only identities and cannot. The docs state that as a condition of using the representation.

reform_identity refuses the composition representation, as it refuses the Scenario one, so a composition can only be built from bound identities.

Invariants (each tested)

  1. The digest is exactly the ordered member identities. It equals content_sha256 of the stated payload (example test).
  2. Order matters; current law and labels do not (Hypothesis). Reordering distinct members changes the digest. Inserting current_law members anywhere, or relabelling members, does not. Dropping or duplicating a member changes it.
  3. Binding. The composition is code-bound exactly when a member is.
  4. Nothing is current law. An empty or all-current-law composition is current_law. A single member keeps the composition representation, so its digest differs from the member's own.
  5. Only bound identities compose. Dicts, strings, raw payloads and non-identity members are refused.
  6. Model and schema agree. A receipt with a composed reform validates against the regenerated JSON Schema and round-trips.

Compatibility

This adds one value to the representation enum in the v1 schema. v1 is unreleased, so no published schema changes.

Checks run locally

  • 159 tests pass in tests/test_execution_receipt.py.
  • ruff and mypy are clean; generate_receipt_schemas.py regenerated the schema.

axiom: n/a: provenance/infrastructure contract, no policy change.

🤖 Generated with Claude Code

MaxGhenis and others added 4 commits October 12, 2026 07:05
composed_reform_identity(members) binds several reforms applied in one
policyengine-uk Scenario under policyengine-uk.scenario_composition/v1.
The digest covers each member's representation, digest and binding in
application order: reordering distinct members changes it, current-law
members and labels do not, and the binding is code_revision exactly when
a member's is. reform_identity refuses the composition representation.

Fixes #578

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- composed_reform_identity takes a required applied_before_data_load and
  binds it in the payload: Scenario.__add__ resets the phase to False,
  and structural reforms run between the two phases, so the same members
  early and late are different runs.
- The documented semantics now match policyengine-uk 2.125.1:
  Simulation.__init__ runs a Scenario's modifier and then its parameter
  changes, in the stated phase; the receipt's model.version names the
  policyengine-uk that ran.
- Each member is revalidated, so an identity edited past its rules with
  model_copy is refused instead of silently dropped or hashed.
- Tests restate the exact preimage (nested compositions included), check
  that changing any member's digest, representation or binding changes
  the composition, and pin named current-law behaviour.

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

- policyengine-uk.scenario_composition/v1 now means one simulation that
  applies every member's parameter changes and then each member's
  modifier in member order, in the stated phase, whatever the
  policyengine-uk version. The payload states the order
  ("application": "parameter_changes_then_modifiers"). The dashboard's
  composer runs exactly this; Scenario.__add__ does not (its merge
  depends on grouping, and 2.125.1 runs the modifier first).
- Members must change disjoint parameters, in any form: with disjoint
  changes the merged result cannot depend on grouping, which closes the
  scalar versus period-map ambiguity the review reproduced.
- Empty containers, bytes, generators and sets are refused as members.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Compositions use the null-preserving payload digest of #571's round 5,
  as every reform digest now does (composition payloads hold no nulls,
  so existing composition digests are unchanged).
- Tests: a tuple and a list of members give the same composition, and an
  invalid phase is refused even when no member changes anything.
- Docs: a baseline shared by several members (parameter changes only)
  appears once, at the first sharing member's position, which cannot
  affect the run (from the dashboard's composer, workstream W5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis force-pushed the w4/receipt-composition branch from 69390c5 to 887084a Compare October 12, 2026 11:07

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant