Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/587.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Execution receipt reform digests accept infinite parameter values (an uncapped threshold is `.inf`), encoding `inf` and `-inf` as `{"$nonfinite": "Infinity"}` and `{"$nonfinite": "-Infinity"}`. A payload that uses the key `$nonfinite` itself is refused, and finite payloads digest as before.
14 changes: 10 additions & 4 deletions docs/receipts.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ There are two ways to bind a policyengine-uk `Scenario`:
A scenario with a module-level `simulation_modifier` needs `code_revision={"repository": ..., "git_sha": <full commit sha>}`. The identity then says `binding: "code_revision"` and binds:
- the function's module and qualified name;
- the SHA-256 of its source and of its module's source file;
- the default arguments of every function in that module, encoded with their types and floats by their bits, so `1` and `1.0`, `0.0` and `-0.0`, a tuple and a list, or `None` and a missing key never share a digest (defaults with no exact encoding, such as objects, are refused, and so are two defaults that are one mutable object, such as one list set as both `first` and `second`, since the encoding cannot tell that from two equal lists);
- the default arguments of every function in that module, encoded with their types and floats by their bits (`float.hex()`, which writes infinities as `inf` and `-inf`; NaN is refused), so `1` and `1.0`, `0.0` and `-0.0`, a tuple and a list, or `None` and a missing key never share a digest (defaults with no exact encoding, such as objects, are refused, and so are two defaults that are one mutable object, such as one list set as both `first` and `second`, since the encoding cannot tell that from two equal lists);
- that revision.

None of these depends on where the code is checked out or which Python version runs it, so CI, a laptop and the server compute the same digest for the same code.
Expand Down Expand Up @@ -111,7 +111,13 @@ Some modifiers are refused outright because their behaviour depends on values th

A combined run, such as a whole Budget package, applies several measures in one simulation. Bind each measure on its own, then bind the package with `composed_reform_identity(members, applied_before_data_load=...)`, passing the members' identities in application order and the phase the combined `Scenario` runs in. Its representation is `policyengine-uk.scenario_composition/v1`, and its digest covers the phase and each member's representation, digest and binding, in that order.

The representation means one simulation, in the stated phase, that applies every member's parameter changes and then runs each member's simulation modifier in member order, whatever the policyengine-uk version. That order is part of the payload (`"application": "parameter_changes_then_modifiers"`). The composer must run exactly that, for example as a single simulation modifier. It is not `Scenario.__add__`: that merge depends on how members are grouped, and it leaves the order to the engine (policyengine-uk 2.125.1 runs a Scenario's modifier before its parameter changes). The rules that follow:
The representation means one simulation, in the stated phase, that applies every member's parameter changes and then runs each member's simulation modifier in member order, whatever the policyengine-uk version. That order is part of the payload (`"application": "parameter_changes_then_modifiers"`). The changes are applied where the engine applies a Scenario's own parameter changes, so each member's changes act as they do when that member runs alone. The composer must run exactly that. It is not `Scenario.__add__`: that merge depends on how members are grouped, and it leaves the order to the engine (policyengine-uk 2.125.1 runs a Scenario's modifier before its parameter changes).

On policyengine-uk 2.126.12, run a composition as one `Scenario` assembled from each member's own Scenario: `Scenario.from_reform(d)` for a reform dictionary member, the scenario itself for a scenario member. It carries the scenario members' merged `parameter_changes`, every member's `dated_reforms` in member order, and a modifier that runs every member's modifier in member order, including the one `from_reform` adds. Two shortcuts give a different run on that version:
- Merging a reform dictionary into `parameter_changes`. A scalar value in a reform dictionary applies from 2023; in `parameter_changes` it applies from 2000.
- Applying changes inside a modifier. When `applied_before_data_load` is false, that version applies a Scenario's own changes before the construction steps that read parameters (the Universal Credit rebalancing inputs and the structural reforms switched on by parameters) and runs its modifier after them, so a change made inside a modifier reaches neither step.

The rules that follow:
- The phase has no default. The composition's phase is the one that runs, whatever phase a member scenario had on its own.
- No two members may change the same parameter, in any form, not even to the same value. With disjoint changes the result does not depend on grouping or order. The composer must check this; `composed_reform_identity` sees only identities, so it cannot.
- Order is part of the digest: the same members in another order give a different digest. A nested composition's members are applied as if listed in its place.
Expand Down Expand Up @@ -146,7 +152,7 @@ const dropNulls = (v) =>
const digest = (value) => createHash("sha256").update(canonicalize(dropNulls(value)), "utf8").digest("hex");
```

Reform digests are the exception: a reform payload is digested with its nulls kept, because there a null can mean something. `Scenario.from_reform({"p": {"2027": None}})` removes a value that `{"p": {}}` keeps, so the two must not share a digest.
Reform digests are the exception: a reform payload is digested with its nulls kept, because there a null can mean something. `Scenario.from_reform({"p": {"2027": None}})` removes a value that `{"p": {}}` keeps, so the two must not share a digest. Reform payloads may also hold infinite numbers, which PolicyEngine parameters use for an uncapped threshold and JSON cannot hold. `inf` is digested as the object `{"$nonfinite": "Infinity"}` and `-inf` as `{"$nonfinite": "-Infinity"}`, and a payload that uses the key `$nonfinite` itself is refused, so no other payload digests like an infinity. The encoding happens inside strict conversion, so a payload it refuses without infinities, such as one with colliding keys, is refused with them too. NaN is refused, and a payload without infinities digests as before, except one whose strings, keys or dates are subclasses that override how `str` or `date` render: those now digest by their characters and their date, as content digests do. The JavaScript above covers content digests only: a JavaScript verifier of reform digests must keep nulls, replace `Infinity` and `-Infinity` with the reserved objects before canonicalizing, and refuse a payload with a `$nonfinite` key.

The pre-promotion replay interface with policyengine-scorecard follows a different rule, set by its own schemas: plain RFC 8785 over the document as sent, nulls included. `policyengine.provenance.canonical.canonical_sha256` computes that.

Expand All @@ -155,7 +161,7 @@ The pre-promotion replay interface with policyengine-scorecard follows a differe
| `execution_identity_sha256` | `schema_version`, `country_id`, `wrapper`, `model`, `core`, `data`, `certification_status`, and the certificate's content: bundle version, record digest, basis, certified versions, dataset digest and certifier. Results with the same execution identity recorded the same package identities (versions, install origins and wheel evidence) and the same data under the same certificate, however the receipt was built. For a certified receipt those identities name the code that ran. An uncertified one does not bind its packages' contents: two runs from an editable checkout edited in between share an execution identity. |
| `receipt_sha256` | The whole receipt except `runtime` and the reform and baseline `name` labels. Renaming a measure therefore does not change it. |
| `certificate.certificate_sha256` | The bundle manifest's `data_releases.uk` record. |
| `reform.reform_sha256` | `{"representation": ..., "payload": ...}`, with the payload exactly as given, nulls kept (strict JSON conversion, then RFC 8785, with no null removal). For a composition the payload is `{"applied_before_data_load": ..., "application": "parameter_changes_then_modifiers", "members": [{"representation", "reform_sha256", "binding"}, ...]}`, with members in application order. |
| `reform.reform_sha256` | `{"representation": ..., "payload": ...}`, with the payload exactly as given, nulls kept (strict JSON conversion with infinities as `{"$nonfinite": "Infinity"}` or `"-Infinity"`, then RFC 8785, with no null removal). For a composition the payload is `{"applied_before_data_load": ..., "application": "parameter_changes_then_modifiers", "members": [{"representation", "reform_sha256", "binding"}, ...]}`, with members in application order. |
| `outputs_sha256` | The output payload. |

Reform digests are never normalized across representations. The same keys can legitimately mean different things in different representations: for example, a bare year in a policyengine-uk country dictionary changes that one year only. So each digest is tied to the representation that was submitted. The v1 representations are `policyengine.parameter_values/v1`, `policyengine.reform_dict/v1`, `policyengine-uk.reform_dict/v1`, `policyengine-uk.scenario/v1`, `policyengine-uk.scenario_composition/v1` and `policyengine-sim-api.policy/v1`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -701,7 +701,7 @@
"execution_identity_sha256": "{schema_version, country_id, wrapper, model, core, data, certification_status, certificate?} where certificate keeps only bundle_version, country_id, certificate_sha256, compatibility_basis, certified_for_model_version, certified_for_core_version, certified_dataset_sha256 and certified_by",
"outputs_sha256": "the canonical output payload the receipt binds",
"receipt_sha256": "the whole receipt without runtime and without reform.name and baseline.name",
"reform.reform_sha256": "RFC 8785 over {representation, payload} where payload is the reform exactly as given in that representation, nulls kept (a null in a reform can mean something, unlike an absent receipt field); for policyengine-uk.scenario_composition/v1 the payload is {applied_before_data_load, application: parameter_changes_then_modifiers, members: [{representation, reform_sha256, binding}, ...]} with members in application order"
"reform.reform_sha256": "RFC 8785 over {representation, payload} where payload is the reform exactly as given in that representation, nulls kept (a null in a reform can mean something, unlike an absent receipt field); for policyengine-uk.scenario_composition/v1 the payload is {applied_before_data_load, application: parameter_changes_then_modifiers, members: [{representation, reform_sha256, binding}, ...]} with members in application order; an infinite number in a payload is the object {\"$nonfinite\": \"Infinity\"} or {\"$nonfinite\": \"-Infinity\"}, and a payload may not use the key $nonfinite itself"
},
"x-policyengine-rules": [
"years is sorted ascending with no duplicates, and each year is written as an integer (2027, not 2027.0).",
Expand Down
Loading
Loading