From 1f9be4f912957dc76d08a239bff4b83c87af911e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 19:19:30 -0400 Subject: [PATCH 1/5] Bind combined runs as an ordered composition of reform identities 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 --- changelog.d/578.added.md | 1 + docs/receipts.md | 15 ++- src/policyengine/provenance/__init__.py | 3 + src/policyengine/provenance/receipt.py | 77 ++++++++++++- tests/test_execution_receipt.py | 141 ++++++++++++++++++++++++ 5 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 changelog.d/578.added.md diff --git a/changelog.d/578.added.md b/changelog.d/578.added.md new file mode 100644 index 00000000..9898ff63 --- /dev/null +++ b/changelog.d/578.added.md @@ -0,0 +1 @@ +Added `composed_reform_identity` and the `policyengine-uk.scenario_composition/v1` representation, so an execution receipt can bind several reforms applied in one run, in order. diff --git a/docs/receipts.md b/docs/receipts.md index 02e91bda..2d3fb94d 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -92,6 +92,17 @@ A measures file edited after import, or a constant, default, import or function Some modifiers are refused outright because their behaviour depends on values the digest cannot bind: closures, lambdas, nested functions, methods, `functools.partial` and builtins. Anything that is not a `Scenario` is refused too, rather than read as current law. +### Several reforms in one run + +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)`, passing the members' identities in application order. Its representation is `policyengine-uk.scenario_composition/v1`, and its digest covers each member's representation, digest and binding, in that order. + +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. Three rules follow: +- The composer must check that no two members set one parameter to different values. `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. +- `current_law` members are dropped, and a composition with no remaining member is `current_law`. The binding is `code_revision` if any member's is. + +A reader who holds the members' identities can recompute the composition's digest and compare. + To check a certificate, call `verify_certificate(receipt, bundle)` with the policyengine manifest for `certificate.bundle_version`. It rebuilds the certificate from that manifest's certification record, the same way the builder does. Then it compares every identity member: the record digest, the basis, the certified model, core and dataset digest, the certifier and the bundle version. It also checks that the receipt's whole `data` identity is the certified dataset with the certified bytes. An empty list means the bundle certifies exactly what the receipt claims, and through the receipt's own rules that pins `model.version`, `core.version` and `wrapper.version` too. `certificate_source`, `qualification_receipt_sha256`, `tro_composition_fingerprint` and wheel hashes are not determined by the bundle, so it does not check them. Pass the release artifact that was loaded as `dataset_path`. A derived per-year file such as `enhanced_frs_2024_25_year_2026.h5` has different bytes from the artifact the bundle pins, so a receipt for it is refused. @@ -125,10 +136,10 @@ 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 ran on the same runtime and data under the same certificate, however the receipt was built. | | `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). | +| `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 `{"members": [{"representation", "reform_sha256", "binding"}, ...]}` 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` and `policyengine-sim-api.policy/v1`. +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`. ## Rules JSON Schema cannot express diff --git a/src/policyengine/provenance/__init__.py b/src/policyengine/provenance/__init__.py index 91d0bdca..deb2a8bc 100644 --- a/src/policyengine/provenance/__init__.py +++ b/src/policyengine/provenance/__init__.py @@ -99,6 +99,9 @@ from .receipt import ( build_execution_receipt as build_execution_receipt, ) +from .receipt import ( + composed_reform_identity as composed_reform_identity, +) from .receipt import ( reform_identity as reform_identity, ) diff --git a/src/policyengine/provenance/receipt.py b/src/policyengine/provenance/receipt.py index 25b0b65f..8c262949 100644 --- a/src/policyengine/provenance/receipt.py +++ b/src/policyengine/provenance/receipt.py @@ -48,6 +48,8 @@ caller's code revision, and says so (``binding="code_revision"``). The digest is the same wherever the code is checked out and whichever Python runs it. +- Several reforms applied in one run are bound as an ordered composition of + their identities (:func:`composed_reform_identity`). - A certificate is trusted only after :func:`verify_certificate` has checked it, and the receipt's data identity, against the published bundle manifest it names. ``certificate_source`` is the producer's statement, not evidence. @@ -112,6 +114,7 @@ "build_execution_receipt", "build_execution_receipt_for_microsimulation", "certificate_sha256", + "composed_reform_identity", "content_sha256", "fiscal_year_label", "installed_package_identity", @@ -154,8 +157,16 @@ # A policyengine-uk ``Scenario`` built from parameter changes and, at # most, a module-level modifier: see scenario_reform_identity(). "policyengine-uk.scenario/v1", + # Several reforms applied in one policyengine-uk ``Scenario``, in a stated + # order: see composed_reform_identity(). + "policyengine-uk.scenario_composition/v1", ] +#: Representation of a composition of member reforms. +COMPOSITION_REPRESENTATION: Literal["policyengine-uk.scenario_composition/v1"] = ( + "policyengine-uk.scenario_composition/v1" +) + #: v1 rules that JSON Schema cannot express. Published in the schema file. CROSS_FIELD_RULES: tuple[str, ...] = ( "years is sorted ascending with no duplicates, and each year is written as an integer (2027, not 2027.0).", @@ -177,7 +188,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", "receipt_sha256": "the whole receipt without runtime and without reform.name and baseline.name", "certificate.certificate_sha256": "the bundle manifest's data_releases. record", - "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)", + "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 {members: [{representation, reform_sha256, binding}, ...]} in application order", "outputs_sha256": "the canonical output payload the receipt binds", } @@ -731,6 +742,8 @@ def reform_identity( raise ReceiptError("A non-empty reform needs its representation.") if representation == "policyengine-uk.scenario/v1": raise ReceiptError("Bind a Scenario with scenario_reform_identity().") + if representation == COMPOSITION_REPRESENTATION: + raise ReceiptError("Bind a composition with composed_reform_identity().") digest = _payload_sha256({"representation": representation, "payload": payload}) return ReformIdentity( kind="reform", representation=representation, reform_sha256=digest, name=name @@ -1980,6 +1993,68 @@ def scenario_reform_identity( ) +def composed_reform_identity( + members: Sequence[ReformIdentity], + *, + name: Optional[str] = None, +) -> ReformIdentity: + """Bind several reforms applied together in one run, in a stated order. + + ``members`` are complete identities from :func:`reform_identity`, + :func:`scenario_reform_identity` or this function, in application order. + The digest covers each member's representation, digest and binding, in + that order, and nothing else: member labels are not part of it. + + What ``policyengine-uk.scenario_composition/v1`` 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 make sure + no two members set one parameter to different values; this function sees + only identities and cannot check that. + + - ``current_law`` members change nothing and are dropped; if none + remains the result is ``current_law``. + - A single remaining member still gets the composition representation, + so its digest differs from the member's own, as across any two + representations. + - The binding is ``code_revision`` if any member's is, else ``content``. + """ + + if isinstance(members, (str, bytes, Mapping)) or not isinstance(members, Sequence): + raise ReceiptError("A composition is a sequence of reform identities.") + bound: list[dict[str, Any]] = [] + binding: Literal["content", "code_revision"] = "content" + for index, member in enumerate(members): + if not isinstance(member, ReformIdentity): + raise ReceiptError( + f"Member {index} is {type(member).__name__}, not a ReformIdentity; " + "bind it with reform_identity() or scenario_reform_identity() first." + ) + if member.kind == "current_law": + continue + bound.append( + { + "representation": member.representation, + "reform_sha256": member.reform_sha256, + "binding": member.binding, + } + ) + if member.binding == "code_revision": + binding = "code_revision" + if not bound: + return ReformIdentity(kind="current_law", name=name) + digest = content_sha256( + {"representation": COMPOSITION_REPRESENTATION, "payload": {"members": bound}} + ) + return ReformIdentity( + kind="reform", + representation=COMPOSITION_REPRESENTATION, + reform_sha256=digest, + binding=binding, + name=name, + ) + + def installed_package_identity( name: str, *, diff --git a/tests/test_execution_receipt.py b/tests/test_execution_receipt.py index 51487716..5a52c25c 100644 --- a/tests/test_execution_receipt.py +++ b/tests/test_execution_receipt.py @@ -19,6 +19,10 @@ module source and defaults plus a revision, independent of where the code is checked out; code edited after import, closures, lambdas, partials, methods and non-Scenario objects are refused; +- a composition's digest covers its members' representation, digest and + binding in application order and nothing else: reordering distinct members + changes it, current-law members and labels do not, and it is code-bound + exactly when a member is; - the Pydantic model and the published JSON Schema accept the same valid receipts and reject the same malformed ones (differential test). """ @@ -57,6 +61,7 @@ build_execution_receipt, build_execution_receipt_for_microsimulation, certificate_sha256, + composed_reform_identity, content_sha256, fiscal_year_label, installed_package_identity, @@ -921,6 +926,142 @@ def test_reform_identity_accepts_only_json_payloads(): ReformIdentity(kind="current_law", binding="code_revision") +# --- Compositions ------------------------------------------------------------------ + + +def _member(index: int, *, code: bool = False, name=None) -> ReformIdentity: + return ReformIdentity( + kind="reform", + representation="policyengine-uk.scenario/v1", + reform_sha256=f"{index:064x}", + binding="code_revision" if code else "content", + name=name, + ) + + +def test_composition_digest_is_its_ordered_member_identities(): + basic = reform_identity( + {"gov.hmrc.income_tax.rates.uk[0].rate": {"2027-04-06": 0.21}}, + "policyengine-uk.reform_dict/v1", + name="basic rate", + ) + structural = scenario_reform_identity( + _scenario(simulation_modifier=receipt_modifiers.raise_higher_rate), + code_revision=REVISION, + name="higher rate", + ) + package = composed_reform_identity([basic, structural], name="Budget package") + assert package.representation == "policyengine-uk.scenario_composition/v1" + assert package.binding == "code_revision" + assert package.name == "Budget package" + assert package.reform_sha256 == content_sha256( + { + "representation": "policyengine-uk.scenario_composition/v1", + "payload": { + "members": [ + { + "representation": "policyengine-uk.reform_dict/v1", + "reform_sha256": basic.reform_sha256, + "binding": "content", + }, + { + "representation": "policyengine-uk.scenario/v1", + "reform_sha256": structural.reform_sha256, + "binding": "code_revision", + }, + ] + }, + } + ) + assert composed_reform_identity([basic]).binding == "content" + assert composed_reform_identity([basic]).reform_sha256 != basic.reform_sha256 + nested = composed_reform_identity([package, basic]) + assert nested.binding == "code_revision" + assert nested.reform_sha256 != package.reform_sha256 + + +@settings(max_examples=100, deadline=None) +@given( + indices=st.lists(st.integers(1, 50), min_size=2, max_size=6, unique=True), + code=st.lists(st.booleans(), min_size=6, max_size=6), + random=st.randoms(), + gaps=st.lists(st.integers(0, 6), max_size=4), + labels=st.lists(st.text(max_size=5), min_size=6, max_size=6), +) +def test_composition_order_matters_and_current_law_and_labels_do_not( + indices, code, random, gaps, labels +): + members = [_member(i, code=code[n]) for n, i in enumerate(indices)] + composed = composed_reform_identity(members) + assert composed.binding == ( + "code_revision" if any(code[: len(members)]) else "content" + ) + shuffled = list(members) + random.shuffle(shuffled) + same_order = shuffled == members + assert ( + composed_reform_identity(shuffled).reform_sha256 == composed.reform_sha256 + ) == same_order + padded = list(members) + for gap in gaps: + padded.insert(min(gap, len(padded)), CURRENT_LAW) + assert composed_reform_identity(padded).reform_sha256 == composed.reform_sha256 + relabelled = [ + member.model_copy(update={"name": labels[n] or None}) + for n, member in enumerate(members) + ] + assert ( + composed_reform_identity(relabelled, name="x").reform_sha256 + == composed.reform_sha256 + ) + # Dropping or duplicating a member changes what ran, so it changes the digest. + assert composed_reform_identity(members[1:]).reform_sha256 != composed.reform_sha256 + assert ( + composed_reform_identity([*members, members[0]]).reform_sha256 + != composed.reform_sha256 + ) + + +def test_a_composition_of_nothing_is_current_law(): + assert composed_reform_identity([]).kind == "current_law" + assert composed_reform_identity([CURRENT_LAW, CURRENT_LAW]).kind == "current_law" + + +@pytest.mark.parametrize( + "members", + [ + {"gov.x": {"2027": 1}}, + "abc", + [{"gov.x": {"2027": 1}}], + [_member(1), "member"], + [_member(1), None], + _member(1), + ], + ids=["dict", "string", "raw payload", "string member", "none member", "bare"], +) +def test_compositions_take_only_bound_identities(members): + with pytest.raises(ReceiptError): + composed_reform_identity(members) + + +def test_reform_identity_refuses_the_composition_representation(): + with pytest.raises(ReceiptError, match="composed_reform_identity"): + reform_identity({"members": []}, "policyengine-uk.scenario_composition/v1") + + +def test_a_composed_reform_makes_a_valid_receipt(dataset_file, schema_validator): + package = composed_reform_identity([_member(1), _member(2, code=True)]) + receipt = _build(dataset_file, reform=package) + schema_validator.validate(json.loads(receipt.model_dump_json())) + assert ExecutionReceipt.model_validate_json(receipt.to_canonical_json()) == receipt + other = _build( + dataset_file, + reform=composed_reform_identity([_member(2, code=True), _member(1)]), + ) + assert other.receipt_sha256 != receipt.receipt_sha256 + assert other.execution_identity_sha256 == receipt.execution_identity_sha256 + + # --- Scenario binding ------------------------------------------------------------- From 41fc1da0fefeb3a68afc70ad83641e89ff6daea1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 23:01:54 -0400 Subject: [PATCH 2/5] Bind the combined phase and revalidate composition members - 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 --- docs/receipts.md | 8 +- src/policyengine/provenance/receipt.py | 52 ++++++-- tests/test_execution_receipt.py | 169 +++++++++++++++++++------ 3 files changed, 172 insertions(+), 57 deletions(-) diff --git a/docs/receipts.md b/docs/receipts.md index 2d3fb94d..07d4769e 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -94,11 +94,13 @@ Some modifiers are refused outright because their behaviour depends on values th ### Several reforms in one run -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)`, passing the members' identities in application order. Its representation is `policyengine-uk.scenario_composition/v1`, and its digest covers each member's representation, digest and binding, in that order. +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 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. Three rules follow: +The representation means the members combined into one policyengine-uk `Scenario` that runs in the stated phase: its `parameter_changes` are the members' changes merged, and its `simulation_modifier` runs the members' modifiers in the listed order. In policyengine-uk 2.125.1, `Simulation.__init__` runs a Scenario's modifier and then its parameter changes, either before data load or after the baseline is built, according to `applied_before_data_load`; the receipt's `model.version` names the policyengine-uk that ran. The rules that follow: +- The phase has no default. The combined Scenario's phase is the one that runs, whatever phase a member scenario had on its own, and `Scenario.__add__` returns a Scenario with `applied_before_data_load=False`, so a composer that adds scenarios must set the phase on the result and pass the same value. - The composer must check that no two members set one parameter to different values. `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. +- Each member is revalidated, so an identity edited past its rules is refused rather than dropped or hashed. - `current_law` members are dropped, and a composition with no remaining member is `current_law`. The binding is `code_revision` if any member's is. A reader who holds the members' identities can recompute the composition's digest and compare. @@ -136,7 +138,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 ran on the same runtime and data under the same certificate, however the receipt was built. | | `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 `{"members": [{"representation", "reform_sha256", "binding"}, ...]}` in application order. | +| `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": ..., "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`. diff --git a/src/policyengine/provenance/receipt.py b/src/policyengine/provenance/receipt.py index 8c262949..d4b347de 100644 --- a/src/policyengine/provenance/receipt.py +++ b/src/policyengine/provenance/receipt.py @@ -188,7 +188,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", "receipt_sha256": "the whole receipt without runtime and without reform.name and baseline.name", "certificate.certificate_sha256": "the bundle manifest's data_releases. record", - "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 {members: [{representation, reform_sha256, binding}, ...]} 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, members: [{representation, reform_sha256, binding}, ...]} with members in application order", "outputs_sha256": "the canonical output payload the receipt binds", } @@ -1996,22 +1996,36 @@ def scenario_reform_identity( def composed_reform_identity( members: Sequence[ReformIdentity], *, + applied_before_data_load: bool, name: Optional[str] = None, ) -> ReformIdentity: """Bind several reforms applied together in one run, in a stated order. ``members`` are complete identities from :func:`reform_identity`, :func:`scenario_reform_identity` or this function, in application order. - The digest covers each member's representation, digest and binding, in - that order, and nothing else: member labels are not part of it. - - What ``policyengine-uk.scenario_composition/v1`` 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 make sure - no two members set one parameter to different values; this function sees - only identities and cannot check that. - + The digest covers ``applied_before_data_load`` and each member's + representation, digest and binding, in that order, and nothing else: + member labels are not part of it. + + What ``policyengine-uk.scenario_composition/v1`` means: the members + combined into one policyengine-uk ``Scenario`` that runs with + ``applied_before_data_load`` as given. Its ``parameter_changes`` are the + members' changes merged, and its ``simulation_modifier`` runs the + members' modifiers in the listed order. The combined Scenario's phase is + the one that runs, whatever phase a member scenario had on its own (a + member's digest names the measure, not the phase it ran in). In + policyengine-uk 2.125.1, ``Simulation.__init__`` runs a Scenario's + modifier and then its parameter changes, before data load or after the + baseline is built according to the phase; the receipt's + ``model.version`` names the policyengine-uk that ran. ``Scenario.__add__`` + returns a Scenario with ``applied_before_data_load=False``, so a composer + that adds scenarios must set the phase on the result and pass the same + value here. The composer must also make sure no two members set one + parameter to different values; this function sees only identities and + cannot check that. + + - Each member is revalidated, so an identity edited past its model's + rules (``model_copy(update=...)``) is refused rather than dropped. - ``current_law`` members change nothing and are dropped; if none remains the result is ``current_law``. - A single remaining member still gets the composition representation, @@ -2020,6 +2034,8 @@ def composed_reform_identity( - The binding is ``code_revision`` if any member's is, else ``content``. """ + if type(applied_before_data_load) is not bool: + raise ReceiptError("applied_before_data_load must be True or False.") if isinstance(members, (str, bytes, Mapping)) or not isinstance(members, Sequence): raise ReceiptError("A composition is a sequence of reform identities.") bound: list[dict[str, Any]] = [] @@ -2030,6 +2046,12 @@ def composed_reform_identity( f"Member {index} is {type(member).__name__}, not a ReformIdentity; " "bind it with reform_identity() or scenario_reform_identity() first." ) + try: + member = ReformIdentity.model_validate(member.model_dump()) + except ValueError as exc: # pydantic's ValidationError included + raise ReceiptError( + f"Member {index} is not a valid identity: {exc}" + ) from exc if member.kind == "current_law": continue bound.append( @@ -2044,7 +2066,13 @@ def composed_reform_identity( if not bound: return ReformIdentity(kind="current_law", name=name) digest = content_sha256( - {"representation": COMPOSITION_REPRESENTATION, "payload": {"members": bound}} + { + "representation": COMPOSITION_REPRESENTATION, + "payload": { + "applied_before_data_load": applied_before_data_load, + "members": bound, + }, + } ) return ReformIdentity( kind="reform", diff --git a/tests/test_execution_receipt.py b/tests/test_execution_receipt.py index 5a52c25c..c8afc9e6 100644 --- a/tests/test_execution_receipt.py +++ b/tests/test_execution_receipt.py @@ -939,7 +939,32 @@ def _member(index: int, *, code: bool = False, name=None) -> ReformIdentity: ) -def test_composition_digest_is_its_ordered_member_identities(): +def _compose(members, *, early: bool = False, name=None) -> ReformIdentity: + return composed_reform_identity(members, applied_before_data_load=early, name=name) + + +def _composition_digest(early: bool, *members: ReformIdentity) -> str: + """The published preimage, restated: phase plus ordered member identities.""" + + return content_sha256( + { + "representation": "policyengine-uk.scenario_composition/v1", + "payload": { + "applied_before_data_load": early, + "members": [ + { + "representation": member.representation, + "reform_sha256": member.reform_sha256, + "binding": member.binding, + } + for member in members + ], + }, + } + ) + + +def test_composition_digest_is_its_phase_and_ordered_member_identities(): basic = reform_identity( {"gov.hmrc.income_tax.rates.uk[0].rate": {"2027-04-06": 0.21}}, "policyengine-uk.reform_dict/v1", @@ -950,34 +975,32 @@ def test_composition_digest_is_its_ordered_member_identities(): code_revision=REVISION, name="higher rate", ) - package = composed_reform_identity([basic, structural], name="Budget package") + package = _compose([basic, structural], name="Budget package") assert package.representation == "policyengine-uk.scenario_composition/v1" assert package.binding == "code_revision" assert package.name == "Budget package" - assert package.reform_sha256 == content_sha256( - { - "representation": "policyengine-uk.scenario_composition/v1", - "payload": { - "members": [ - { - "representation": "policyengine-uk.reform_dict/v1", - "reform_sha256": basic.reform_sha256, - "binding": "content", - }, - { - "representation": "policyengine-uk.scenario/v1", - "reform_sha256": structural.reform_sha256, - "binding": "code_revision", - }, - ] - }, - } - ) - assert composed_reform_identity([basic]).binding == "content" - assert composed_reform_identity([basic]).reform_sha256 != basic.reform_sha256 - nested = composed_reform_identity([package, basic]) + assert package.reform_sha256 == _composition_digest(False, basic, structural) + assert _compose([basic]).binding == "content" + assert _compose([basic]).reform_sha256 == _composition_digest(False, basic) + assert _compose([basic]).reform_sha256 != basic.reform_sha256 + nested = _compose([package, basic], early=True) assert nested.binding == "code_revision" - assert nested.reform_sha256 != package.reform_sha256 + assert nested.reform_sha256 == _composition_digest(True, package, basic) + + +def test_the_phase_the_combined_scenario_runs_in_is_bound(): + """#579 review: Scenario.__add__ resets the phase, so it must be stated.""" + + members = [_member(1), _member(2)] + early = _compose(members, early=True) + late = _compose(members, early=False) + assert early.reform_sha256 != late.reform_sha256 + assert early.reform_sha256 == _composition_digest(True, *members) + with pytest.raises(TypeError): + composed_reform_identity(members) # the phase has no default + for phase in (0, 1, None, "False"): + with pytest.raises(ReceiptError, match="applied_before_data_load"): + composed_reform_identity(members, applied_before_data_load=phase) @settings(max_examples=100, deadline=None) @@ -987,12 +1010,14 @@ def test_composition_digest_is_its_ordered_member_identities(): random=st.randoms(), gaps=st.lists(st.integers(0, 6), max_size=4), labels=st.lists(st.text(max_size=5), min_size=6, max_size=6), + early=st.booleans(), ) def test_composition_order_matters_and_current_law_and_labels_do_not( - indices, code, random, gaps, labels + indices, code, random, gaps, labels, early ): members = [_member(i, code=code[n]) for n, i in enumerate(indices)] - composed = composed_reform_identity(members) + composed = _compose(members, early=early) + assert composed.reform_sha256 == _composition_digest(early, *members) assert composed.binding == ( "code_revision" if any(code[: len(members)]) else "content" ) @@ -1000,31 +1025,94 @@ def test_composition_order_matters_and_current_law_and_labels_do_not( random.shuffle(shuffled) same_order = shuffled == members assert ( - composed_reform_identity(shuffled).reform_sha256 == composed.reform_sha256 + _compose(shuffled, early=early).reform_sha256 == composed.reform_sha256 ) == same_order padded = list(members) for gap in gaps: - padded.insert(min(gap, len(padded)), CURRENT_LAW) - assert composed_reform_identity(padded).reform_sha256 == composed.reform_sha256 + padded.insert( + min(gap, len(padded)), + ReformIdentity(kind="current_law", name=labels[gap % 6] or None), + ) + assert _compose(padded, early=early).reform_sha256 == composed.reform_sha256 relabelled = [ member.model_copy(update={"name": labels[n] or None}) for n, member in enumerate(members) ] assert ( - composed_reform_identity(relabelled, name="x").reform_sha256 + _compose(relabelled, early=early, name="x").reform_sha256 == composed.reform_sha256 ) # Dropping or duplicating a member changes what ran, so it changes the digest. - assert composed_reform_identity(members[1:]).reform_sha256 != composed.reform_sha256 + assert _compose(members[1:], early=early).reform_sha256 != composed.reform_sha256 assert ( - composed_reform_identity([*members, members[0]]).reform_sha256 + _compose([*members, members[0]], early=early).reform_sha256 != composed.reform_sha256 ) -def test_a_composition_of_nothing_is_current_law(): - assert composed_reform_identity([]).kind == "current_law" - assert composed_reform_identity([CURRENT_LAW, CURRENT_LAW]).kind == "current_law" +@settings(max_examples=50, deadline=None) +@given( + indices=st.lists(st.integers(1, 50), min_size=1, max_size=5, unique=True), + position=st.integers(0, 4), + change=st.sampled_from(["digest", "representation", "binding"]), +) +def test_changing_any_one_member_changes_the_composition(indices, position, change): + """A nested identity's every bound member reaches the composition digest.""" + + members = [_member(i) for i in indices] + inner = _compose(members) + target = position % len(members) + edited = list(members) + if change == "digest": + edited[target] = _member(indices[target] + 100) + elif change == "representation": + edited[target] = edited[target].model_copy( + update={"representation": "policyengine-uk.reform_dict/v1"} + ) + else: + edited[target] = _member(indices[target], code=True) + assert _compose(edited).reform_sha256 != inner.reform_sha256 + # The same holds one level down, through a nested composition. + assert ( + _compose([_compose(edited), _member(99)]).reform_sha256 + != _compose([inner, _member(99)]).reform_sha256 + ) + + +def test_a_composition_of_nothing_is_current_law_with_its_own_name(): + named = ReformIdentity(kind="current_law", name="no change") + assert _compose([]).kind == "current_law" + assert _compose([CURRENT_LAW, named]) == ReformIdentity(kind="current_law") + assert _compose([named], name="package") == ReformIdentity( + kind="current_law", name="package" + ) + assert ( + _compose([named, _member(1)]).reform_sha256 + == _compose([_member(1)]).reform_sha256 + ) + + +@pytest.mark.parametrize( + "update", + [ + {"kind": "current_law"}, # a reform's digest kept under current law + {"reform_sha256": "XYZ"}, + {"reform_sha256": None}, + {"representation": "not-a-representation"}, + {"binding": "anything"}, + ], + ids=["kind", "digest", "no-digest", "representation", "binding"], +) +def test_members_edited_past_their_rules_are_refused(update): + """#579 review: an invalid member is refused, never dropped or hashed.""" + + real = reform_identity( + {"gov.hmrc.income_tax.rates.uk[0].rate": {"2027-04-06": 0.21}}, + "policyengine-uk.reform_dict/v1", + ) + forged = real.model_copy(update=update) + with pytest.raises(ReceiptError, match="Member 1 is not a valid identity"): + _compose([_member(1), forged]) @pytest.mark.parametrize( @@ -1041,7 +1129,7 @@ def test_a_composition_of_nothing_is_current_law(): ) def test_compositions_take_only_bound_identities(members): with pytest.raises(ReceiptError): - composed_reform_identity(members) + _compose(members) def test_reform_identity_refuses_the_composition_representation(): @@ -1050,14 +1138,11 @@ def test_reform_identity_refuses_the_composition_representation(): def test_a_composed_reform_makes_a_valid_receipt(dataset_file, schema_validator): - package = composed_reform_identity([_member(1), _member(2, code=True)]) + package = _compose([_member(1), _member(2, code=True)]) receipt = _build(dataset_file, reform=package) schema_validator.validate(json.loads(receipt.model_dump_json())) assert ExecutionReceipt.model_validate_json(receipt.to_canonical_json()) == receipt - other = _build( - dataset_file, - reform=composed_reform_identity([_member(2, code=True), _member(1)]), - ) + other = _build(dataset_file, reform=_compose([_member(2, code=True), _member(1)])) assert other.receipt_sha256 != receipt.receipt_sha256 assert other.execution_identity_sha256 == receipt.execution_identity_sha256 From cf04a4911f76eca1a3e15477bde3841f88eb666b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 05:14:03 -0400 Subject: [PATCH 3/5] Define composition order independently of the engine; require disjoint 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 --- docs/receipts.md | 10 +++--- src/policyengine/provenance/receipt.py | 48 +++++++++++++++----------- tests/test_execution_receipt.py | 20 ++++++++++- 3 files changed, 51 insertions(+), 27 deletions(-) diff --git a/docs/receipts.md b/docs/receipts.md index 07d4769e..1b335610 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -96,10 +96,10 @@ 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 the members combined into one policyengine-uk `Scenario` that runs in the stated phase: its `parameter_changes` are the members' changes merged, and its `simulation_modifier` runs the members' modifiers in the listed order. In policyengine-uk 2.125.1, `Simulation.__init__` runs a Scenario's modifier and then its parameter changes, either before data load or after the baseline is built, according to `applied_before_data_load`; the receipt's `model.version` names the policyengine-uk that ran. The rules that follow: -- The phase has no default. The combined Scenario's phase is the one that runs, whatever phase a member scenario had on its own, and `Scenario.__add__` returns a Scenario with `applied_before_data_load=False`, so a composer that adds scenarios must set the phase on the result and pass the same value. -- The composer must check that no two members set one parameter to different values. `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. +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 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. - Each member is revalidated, so an identity edited past its rules is refused rather than dropped or hashed. - `current_law` members are dropped, and a composition with no remaining member is `current_law`. The binding is `code_revision` if any member's is. @@ -138,7 +138,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 ran on the same runtime and data under the same certificate, however the receipt was built. | | `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": ..., "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, 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`. diff --git a/src/policyengine/provenance/receipt.py b/src/policyengine/provenance/receipt.py index d4b347de..b629770b 100644 --- a/src/policyengine/provenance/receipt.py +++ b/src/policyengine/provenance/receipt.py @@ -188,7 +188,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", "receipt_sha256": "the whole receipt without runtime and without reform.name and baseline.name", "certificate.certificate_sha256": "the bundle manifest's data_releases. record", - "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, 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", "outputs_sha256": "the canonical output payload the receipt binds", } @@ -1993,6 +1993,11 @@ def scenario_reform_identity( ) +#: The order a ``policyengine-uk.scenario_composition/v1`` run applies its +#: members in. It is part of the payload, so the digest states it. +COMPOSITION_APPLICATION = "parameter_changes_then_modifiers" + + def composed_reform_identity( members: Sequence[ReformIdentity], *, @@ -2003,26 +2008,26 @@ def composed_reform_identity( ``members`` are complete identities from :func:`reform_identity`, :func:`scenario_reform_identity` or this function, in application order. - The digest covers ``applied_before_data_load`` and each member's - representation, digest and binding, in that order, and nothing else: - member labels are not part of it. - - What ``policyengine-uk.scenario_composition/v1`` means: the members - combined into one policyengine-uk ``Scenario`` that runs with - ``applied_before_data_load`` as given. Its ``parameter_changes`` are the - members' changes merged, and its ``simulation_modifier`` runs the - members' modifiers in the listed order. The combined Scenario's phase is - the one that runs, whatever phase a member scenario had on its own (a - member's digest names the measure, not the phase it ran in). In - policyengine-uk 2.125.1, ``Simulation.__init__`` runs a Scenario's - modifier and then its parameter changes, before data load or after the - baseline is built according to the phase; the receipt's - ``model.version`` names the policyengine-uk that ran. ``Scenario.__add__`` - returns a Scenario with ``applied_before_data_load=False``, so a composer - that adds scenarios must set the phase on the result and pass the same - value here. The composer must also make sure no two members set one - parameter to different values; this function sees only identities and - cannot check that. + The digest covers ``applied_before_data_load``, the application order and + each member's representation, digest and binding, in member order, and + nothing else: member labels are not part of it. + + What ``policyengine-uk.scenario_composition/v1`` means, whatever the + policyengine-uk version: one simulation, in the phase given by + ``applied_before_data_load``, that applies every member's parameter + changes (a reform dictionary member's entries, a scenario member's + ``parameter_changes``) and then runs each member's simulation modifier in + member order. This is what the composer must run, for example as a + single simulation modifier; it is not ``Scenario.__add__``, whose merge + depends on how members are grouped and which leaves the order to the + engine (policyengine-uk 2.125.1 runs a Scenario's modifier before its + parameter changes). The composer must also make sure no two members + change the same parameter, in any form: with disjoint changes the merged + result does not depend on grouping or order. This function sees only + identities and cannot check either. + + A member's own phase is overridden by the composition's, and a nested + composition's members are applied as if listed in its place. - Each member is revalidated, so an identity edited past its model's rules (``model_copy(update=...)``) is refused rather than dropped. @@ -2070,6 +2075,7 @@ def composed_reform_identity( "representation": COMPOSITION_REPRESENTATION, "payload": { "applied_before_data_load": applied_before_data_load, + "application": COMPOSITION_APPLICATION, "members": bound, }, } diff --git a/tests/test_execution_receipt.py b/tests/test_execution_receipt.py index c8afc9e6..62f5fb44 100644 --- a/tests/test_execution_receipt.py +++ b/tests/test_execution_receipt.py @@ -951,6 +951,7 @@ def _composition_digest(early: bool, *members: ReformIdentity) -> str: "representation": "policyengine-uk.scenario_composition/v1", "payload": { "applied_before_data_load": early, + "application": "parameter_changes_then_modifiers", "members": [ { "representation": member.representation, @@ -1124,8 +1125,25 @@ def test_members_edited_past_their_rules_are_refused(update): [_member(1), "member"], [_member(1), None], _member(1), + {}, + "", + b"", + (member for member in [_member(1)]), + frozenset(), + ], + ids=[ + "dict", + "string", + "raw payload", + "string member", + "none member", + "bare", + "empty dict", + "empty string", + "empty bytes", + "generator", + "set", ], - ids=["dict", "string", "raw payload", "string member", "none member", "bare"], ) def test_compositions_take_only_bound_identities(members): with pytest.raises(ReceiptError): From 09fca3c5b486a8fbf6b3d4cd0eb68fcb63d90954 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 07:06:58 -0400 Subject: [PATCH 4/5] Close round-3 test gaps; say how a shared baseline is bound - 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 --- docs/receipts.md | 1 + .../schemas/execution_receipt.v1.schema.json | 5 +++-- src/policyengine/provenance/receipt.py | 2 +- tests/test_execution_receipt.py | 16 +++++++++++++++- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/receipts.md b/docs/receipts.md index 1b335610..944ef877 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -100,6 +100,7 @@ The representation means one simulation, in the stated phase, that applies every - 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. +- A baseline shared by several members (one that applies parameter changes only) appears once, at the position of the first member that shares it; being parameter changes, its position does not affect the run. - Each member is revalidated, so an identity edited past its rules is refused rather than dropped or hashed. - `current_law` members are dropped, and a composition with no remaining member is `current_law`. The binding is `code_revision` if any member's is. diff --git a/src/policyengine/data/schemas/execution_receipt.v1.schema.json b/src/policyengine/data/schemas/execution_receipt.v1.schema.json index 29f26921..31cb849a 100644 --- a/src/policyengine/data/schemas/execution_receipt.v1.schema.json +++ b/src/policyengine/data/schemas/execution_receipt.v1.schema.json @@ -400,7 +400,8 @@ "policyengine.reform_dict/v1", "policyengine-uk.reform_dict/v1", "policyengine-sim-api.policy/v1", - "policyengine-uk.scenario/v1" + "policyengine-uk.scenario/v1", + "policyengine-uk.scenario_composition/v1" ], "type": "string" }, @@ -643,7 +644,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)" + "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" }, "x-policyengine-rules": [ "years is sorted ascending with no duplicates, and each year is written as an integer (2027, not 2027.0).", diff --git a/src/policyengine/provenance/receipt.py b/src/policyengine/provenance/receipt.py index b629770b..b40e17ce 100644 --- a/src/policyengine/provenance/receipt.py +++ b/src/policyengine/provenance/receipt.py @@ -2070,7 +2070,7 @@ def composed_reform_identity( binding = "code_revision" if not bound: return ReformIdentity(kind="current_law", name=name) - digest = content_sha256( + digest = _payload_sha256( { "representation": COMPOSITION_REPRESENTATION, "payload": { diff --git a/tests/test_execution_receipt.py b/tests/test_execution_receipt.py index 62f5fb44..a4700a4a 100644 --- a/tests/test_execution_receipt.py +++ b/tests/test_execution_receipt.py @@ -946,7 +946,7 @@ def _compose(members, *, early: bool = False, name=None) -> ReformIdentity: def _composition_digest(early: bool, *members: ReformIdentity) -> str: """The published preimage, restated: phase plus ordered member identities.""" - return content_sha256( + return canonical_sha256( { "representation": "policyengine-uk.scenario_composition/v1", "payload": { @@ -1150,6 +1150,20 @@ def test_compositions_take_only_bound_identities(members): _compose(members) +def test_a_tuple_and_a_list_of_members_are_the_same_composition(): + members = [_member(1), _member(2, code=True)] + assert _compose(tuple(members)) == _compose(list(members)) + + +@pytest.mark.parametrize("members", [[], [CURRENT_LAW], [CURRENT_LAW, CURRENT_LAW]]) +@pytest.mark.parametrize("phase", [0, 1, None, "True"]) +def test_the_phase_is_checked_even_when_nothing_changes(members, phase): + """Round-3 review: an invalid phase is refused before members are read.""" + + with pytest.raises(ReceiptError, match="applied_before_data_load"): + composed_reform_identity(members, applied_before_data_load=phase) + + def test_reform_identity_refuses_the_composition_representation(): with pytest.raises(ReceiptError, match="composed_reform_identity"): reform_identity({"members": []}, "policyengine-uk.scenario_composition/v1") From b5d43d06578daa3ff87848278eb1ab1c38900385 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 11:51:38 -0400 Subject: [PATCH 5/5] Test that a UserList of members is the same composition Round-4 review: restricting members to list and tuple survived every composition test. A tuple, a list and a UserList now give one digest. Co-Authored-By: Claude Opus 5.5 --- tests/test_execution_receipt.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_execution_receipt.py b/tests/test_execution_receipt.py index a4700a4a..2106088b 100644 --- a/tests/test_execution_receipt.py +++ b/tests/test_execution_receipt.py @@ -1150,9 +1150,14 @@ def test_compositions_take_only_bound_identities(members): _compose(members) -def test_a_tuple_and_a_list_of_members_are_the_same_composition(): +def test_any_sequence_of_members_is_the_same_composition(): + """Round-4 review: a tuple, a list and a UserList are one composition.""" + + from collections import UserList + members = [_member(1), _member(2, code=True)] assert _compose(tuple(members)) == _compose(list(members)) + assert _compose(UserList(members)) == _compose(list(members)) @pytest.mark.parametrize("members", [[], [CURRENT_LAW], [CURRENT_LAW, CURRENT_LAW]])