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 d7f1dea0..d9cdbf06 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -107,6 +107,21 @@ 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, 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 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. +- A member with a live re-check handle (from `scenario_reform_identity`, or a composition) is recomputed before composing and refused if it changed. The package's identity keeps a handle that recomposes from those members, so both receipt builders re-check every member, nested compositions included, after the run. An identity loaded from JSON has no handle, as for any identity. +- `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. @@ -140,10 +155,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 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). | +| `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` 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/data/schemas/execution_receipt.v1.schema.json b/src/policyengine/data/schemas/execution_receipt.v1.schema.json index d10e4555..e70ef27f 100644 --- a/src/policyengine/data/schemas/execution_receipt.v1.schema.json +++ b/src/policyengine/data/schemas/execution_receipt.v1.schema.json @@ -438,7 +438,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" }, @@ -700,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)" + "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/__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 2ccb9345..95ca3c3a 100644 --- a/src/policyengine/provenance/receipt.py +++ b/src/policyengine/provenance/receipt.py @@ -51,6 +51,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. @@ -141,6 +143,7 @@ "build_execution_receipt", "build_execution_receipt_for_microsimulation", "certificate_sha256", + "composed_reform_identity", "content_sha256", "fiscal_year_label", "installed_package_identity", @@ -183,8 +186,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).", @@ -207,7 +218,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 {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", } @@ -806,6 +817,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 @@ -2705,6 +2718,127 @@ def checked(identity: ReformIdentity) -> ReformIdentity: ) +#: 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], + *, + 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 ``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. + - A member with a re-check handle (from :func:`scenario_reform_identity`, + or a composition) is recomputed first and refused if it changed, and + the result keeps a handle that recomposes from those members, so the + receipt builders re-check every member again. + - ``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 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.") + # The members as given, handles included; a later edit to the caller's + # list does not change what the handle recomposes. + members = tuple(members) + + def checked(identity: ReformIdentity) -> ReformIdentity: + object.__setattr__( + identity, + "_recompute_identity", + lambda: composed_reform_identity( + members, + applied_before_data_load=applied_before_data_load, + name=name, + ), + ) + return identity + + 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." + ) + try: + member._recheck() + member = ReformIdentity.model_validate(member.model_dump()) + except ReceiptError as exc: + raise ReceiptError(f"Member {index}: {exc}") from exc + 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( + { + "representation": member.representation, + "reform_sha256": member.reform_sha256, + "binding": member.binding, + } + ) + if member.binding == "code_revision": + binding = "code_revision" + if not bound: + return checked(ReformIdentity(kind="current_law", name=name)) + digest = _payload_sha256( + { + "representation": COMPOSITION_REPRESENTATION, + "payload": { + "applied_before_data_load": applied_before_data_load, + "application": COMPOSITION_APPLICATION, + "members": bound, + }, + } + ) + return checked( + ReformIdentity( + kind="reform", + representation=COMPOSITION_REPRESENTATION, + reform_sha256=digest, + binding=binding, + name=name, + ) + ) + + def _distribution_metadata_directory(name: str) -> Path: """Locate dist-info through the distribution's public metadata API.""" diff --git a/tests/test_execution_receipt.py b/tests/test_execution_receipt.py index e9a96ba5..f071238b 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). """ @@ -62,6 +66,7 @@ build_execution_receipt, build_execution_receipt_for_microsimulation, certificate_sha256, + composed_reform_identity, content_sha256, fiscal_year_label, installed_package_identity, @@ -1511,6 +1516,264 @@ 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 _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 canonical_sha256( + { + "representation": "policyengine-uk.scenario_composition/v1", + "payload": { + "applied_before_data_load": early, + "application": "parameter_changes_then_modifiers", + "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", + name="basic rate", + ) + structural = scenario_reform_identity( + _scenario(simulation_modifier=receipt_modifiers.raise_higher_rate), + code_revision=REVISION, + name="higher rate", + ) + 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 == _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 == _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) +@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), + early=st.booleans(), +) +def test_composition_order_matters_and_current_law_and_labels_do_not( + indices, code, random, gaps, labels, early +): + members = [_member(i, code=code[n]) for n, i in enumerate(indices)] + 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" + ) + shuffled = list(members) + random.shuffle(shuffled) + same_order = shuffled == members + assert ( + _compose(shuffled, early=early).reform_sha256 == composed.reform_sha256 + ) == same_order + padded = list(members) + for gap in gaps: + 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 ( + _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 _compose(members[1:], early=early).reform_sha256 != composed.reform_sha256 + assert ( + _compose([*members, members[0]], early=early).reform_sha256 + != composed.reform_sha256 + ) + + +@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( + "members", + [ + {"gov.x": {"2027": 1}}, + "abc", + [{"gov.x": {"2027": 1}}], + [_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", + ], +) +def test_compositions_take_only_bound_identities(members): + with pytest.raises(ReceiptError): + _compose(members) + + +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]]) +@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") + + +def test_a_composed_reform_makes_a_valid_receipt(dataset_file, schema_validator): + 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=_compose([_member(2, code=True), _member(1)])) + assert other.receipt_sha256 != receipt.receipt_sha256 + assert other.execution_identity_sha256 == receipt.execution_identity_sha256 + + # --- Scenario binding ------------------------------------------------------------- @@ -1744,6 +2007,73 @@ def test_builder_rechecks_modifier_after_the_run( sys.modules.pop(name, None) +@pytest.mark.parametrize("nested", [False, True]) +@pytest.mark.parametrize("managed", [False, True]) +def test_builder_rechecks_each_composition_member_after_the_run( + dataset_file, tmp_path, nested, managed +): + """A composition keeps its members' re-check handles: a member whose + module changed after composing is refused when the receipt is built.""" + + name = "w4_receipt_compose_recheck" + try: + module, _ = _import_source(tmp_path, name, MEASURE_SOURCE.format(rate=0.21)) + member = _measure_identity(module) + other = _member(7) + identity = composed_reform_identity( + [other, member], applied_before_data_load=False + ) + if nested: + identity = composed_reform_identity( + [identity], applied_before_data_load=False + ) + module.RATE = 0.30 + with pytest.raises( + ReceiptError, match="modifier changed after its identity was computed" + ) as refused: + if managed: + bundle = _bundle_for(dataset_file.read_bytes()) + build_execution_receipt_for_microsimulation( + _managed_simulation(dataset_file, bundle), + years=[2026], + bundle=bundle, + reform=identity, + baseline=CURRENT_LAW, + ) + else: + _build(dataset_file, reform=identity, baseline=CURRENT_LAW) + assert "RATE" in str(refused.value) + finally: + sys.modules.pop(name, None) + + +def test_composing_refuses_a_member_that_changed_since_it_was_bound(tmp_path): + name = "w4_receipt_compose_stale_member" + try: + module, _ = _import_source(tmp_path, name, MEASURE_SOURCE.format(rate=0.21)) + member = _measure_identity(module) + module.RATE = 0.30 + with pytest.raises(ReceiptError, match="Member 0: The modifier changed"): + composed_reform_identity([member], applied_before_data_load=False) + finally: + sys.modules.pop(name, None) + + +def test_an_unchanged_composition_still_builds_and_ignores_later_list_edits( + dataset_file, +): + scenario = _scenario(simulation_modifier=receipt_modifiers.raise_basic_rate_to) + member = scenario_reform_identity(scenario, code_revision=REVISION) + members = [member] + identity = composed_reform_identity(members, applied_before_data_load=False) + members.append(_member(7)) + receipt = _build(dataset_file, reform=identity, baseline=CURRENT_LAW) + assert receipt.reform == identity + # The handle is process-local: it is not part of equality or the dump. + loaded = type(identity).model_validate(identity.model_dump()) + assert loaded == identity and loaded._recompute_identity is None + + def test_unchanged_scenario_still_builds_after_a_run(dataset_file): scenario = _scenario(simulation_modifier=receipt_modifiers.raise_basic_rate_to) identity = scenario_reform_identity(scenario, code_revision=REVISION)