From bdc75bd5aa2ee2e9a2525d047b3ae33e896e35b8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 3 Nov 2026 05:48:57 -0500 Subject: [PATCH 1/4] Prove extension calibration nodes' measurement producers in compose The extension pass of compose_transport_graph compiled the extended graph and reran the ancestry guard, but never ran _check_measurement_proofs. So a calibration-tier scenario's calibrate.* node could calibrate against a money-scaled measurement that no proof covered: measurement_proofs reads only the declared graph's rows and never names those nodes. The extension pass now runs the check after the ancestry guard, as the skeleton pass does. For a calibration node that an extension adds, the compiled graph proves the producer under R2C-1's rule: exactly one compiled ancestor declares the measurement, and that ancestor outputs every proof column. R2C-15's only-writer rule applies as before. A skeleton calibration node keeps the declared comparison, which the pass now also runs against the extended graph. Tests: unit cases on the compiled toy; a differential property that the declared proof and the compiled extension proof agree, refusal for refusal, on chains of declarers and writers; and NZ's synthetic graph with four V3 mutants (M1, M2, M4 and the NZ hub's G7), each of which composes without this change. Co-Authored-By: Claude Opus 5.5 --- ...port-extension-measurement-proofs.fixed.md | 1 + .../src/microcosm/build/transport/compose.py | 72 +++- .../test_transport_measurement_proof.py | 378 +++++++++++++++++- 3 files changed, 428 insertions(+), 23 deletions(-) create mode 100644 changelog.d/transport-extension-measurement-proofs.fixed.md diff --git a/changelog.d/transport-extension-measurement-proofs.fixed.md b/changelog.d/transport-extension-measurement-proofs.fixed.md new file mode 100644 index 0000000000..646f8cb585 --- /dev/null +++ b/changelog.d/transport-extension-measurement-proofs.fixed.md @@ -0,0 +1 @@ +Composition now proves the measurement producer for each calibration node that a transport extension adds, such as a calibration-tier scenario's `calibrate.*` node. The declared proof (G8-r2c ruling R2C-1) reads only the declared graph's rows, so it never names those nodes, and the extension pass of `compose_transport_graph` never ran the compiled check. Now the extension pass runs it after the ancestry guard, as the skeleton pass does. For an extension's calibration node, exactly one compiled ancestor must declare the measurement, that ancestor must output every proof column, and no other ancestor may write one by output or EXPAND cell (R2C-15). The refusal names the node as an extension calibration node with no proved producer. Each skeleton calibration node keeps the declared comparison, which the extension pass now also runs on the extended graph. diff --git a/packages/microcosm-build/src/microcosm/build/transport/compose.py b/packages/microcosm-build/src/microcosm/build/transport/compose.py index 620bbd4794..25a3e4dcba 100644 --- a/packages/microcosm-build/src/microcosm/build/transport/compose.py +++ b/packages/microcosm-build/src/microcosm/build/transport/compose.py @@ -621,7 +621,9 @@ def measurement_proofs( columns. "Upstream" is ancestry in the declared graph under the S5 guard's rule (:func:`_declared_calibration_predecessors`); composition then checks the same producers against the compiled graph's - exact predecessors (:func:`_check_measurement_proofs`). Returns the + exact predecessors (:func:`_check_measurement_proofs`). A calibration node + that a transport extension adds has no row here, so that check proves its + producer from the compiled graph under the same two rules. Returns the proofs by measurement id and, for each measurement without one, the reason it stays parked. """ @@ -714,20 +716,30 @@ def measurement_proofs( def _check_measurement_proofs( - spec: Mapping[str, object], graph: Graph, compiled: CompiledGraph + spec: Mapping[str, object], + graph: Graph, + compiled: CompiledGraph, + *, + extension_nodes: Iterable[str] = (), ) -> None: - """Refuse a declared proof the compiled graph's ancestry contradicts. + """Refuse a composed graph whose calibration ancestry breaks a proof. For each proved measurement and each calibration node of the composed graph, the compiled ancestors of that node must hold exactly one node - whose params declare the measurement, and it must be the proof's - producer for that node. That producer must also be the only compiled + whose params declare the measurement. For a node of the declared graph, + it must be the proof's producer for that node. The declaration never + names a calibration node that a transport extension adds + (``extension_nodes``), so for one of those the compiled graph proves the + producer under R2C-1's rule: the one declarer must output each of the + proof's columns. Either way, the producer must also be the only compiled ancestor whose outputs or EXPAND cells hold each of the proof's columns - (R2C-15). + (R2C-15). Composition runs this check on the skeleton and again on the + extended graph. """ proofs, _ = measurement_proofs(spec) if not proofs: return + extension_nodes = frozenset(extension_nodes) roots = {node.id for node in graph.nodes if node.kernel.startswith("calibrate.")} endpoint = transport_endpoints(spec).get("calibration") if isinstance(endpoint, str): @@ -741,21 +753,49 @@ def _check_measurement_proofs( _, expand_cells = _precalibration_expansions(graph) for binding, proof in sorted(proofs.items()): named = sorted(name for name, value in declared.items() if value == binding) + columns = [tuple(name.split(".", 1)) for name in proof["columns"]] for root in sorted(roots): closure = _ancestor_closure(compiled.predecessors, {root}) found = [name for name in named if name in closure] - if found != [proof["producers"].get(root)]: - raise ValueError( + if root in extension_nodes: + prefix = ( + f"Measurement {binding!r}: the composed graph proves no " + f"producer for extension calibration node {root!r}; " + ) + if len(found) != 1: + raise ValueError( + prefix + _measurement_refusal(binding, root, found, named) + "." + ) + (producer,) = found + outputs = { + (output.entity, output.column) for output in by_id[producer].outputs + } + missing = [ + f"{entity}.{column}" + for entity, column in columns + if (entity, column) not in outputs + ] + if missing: + raise ValueError( + prefix + f"its one declarer {producer!r} does not write " + f"{missing}." + ) + else: + prefix = ( f"Measurement {binding!r}: the compiled graph contradicts " "the declared proof; " - + _measurement_refusal(binding, root, found, named) - + f", and the declaration proved {proof['producers'].get(root)!r}." ) - columns = [tuple(name.split(".", 1)) for name in proof["columns"]] + producer = proof["producers"].get(root) + if found != [producer]: + raise ValueError( + prefix + + _measurement_refusal(binding, root, found, named) + + f", and the declaration proved {producer!r}." + ) refusal = _second_writer_refusal( binding, root, - proof["producers"][root], + producer, { (entity, column): sorted( name @@ -770,10 +810,7 @@ def _check_measurement_proofs( }, ) if refusal is not None: - raise ValueError( - f"Measurement {binding!r}: the compiled graph contradicts " - f"the declared proof; {refusal}." - ) + raise ValueError(prefix + f"{refusal}.") def reference_profile_view( @@ -4537,6 +4574,9 @@ def compose_transport_graph( extended = compile_graph(graph) _check_extension_checkpoints(spec, config, graph, extended) validate_transport_calibration_ancestry(spec, graph=graph, config=config) + _check_measurement_proofs( + spec, graph, extended, extension_nodes=(node.id for node in additional) + ) # Money ownership is checked after each graph pass of the ancestry guard. _check_single_money_normalization(spec, graph, extended) _check_money_bridge_owners(graph, extended) diff --git a/packages/microcosm-build/tests/engine_free/shared/test_transport_measurement_proof.py b/packages/microcosm-build/tests/engine_free/shared/test_transport_measurement_proof.py index de485f7674..acadb3de96 100644 --- a/packages/microcosm-build/tests/engine_free/shared/test_transport_measurement_proof.py +++ b/packages/microcosm-build/tests/engine_free/shared/test_transport_measurement_proof.py @@ -5,13 +5,16 @@ ``params.measurement_id: `` and writes the scaled interval table's income and universe columns on that table's entity. Each mutant below breaks one clause and must leave the rows parked, with the activation gap naming the -measurement. +measurement. Composition checks the same rule against the compiled graph, and +for a calibration node that a transport extension adds, which no declared row +names, the compiled graph alone proves the producer. """ from __future__ import annotations import copy import json +import re import pytest from hypothesis import HealthCheck, example, given, settings @@ -398,10 +401,13 @@ def test_proof_result_is_bound_into_the_selection_identity(): assert parked != proved -def _compiled_toy(*, second_producer=False, second_writer=False, expand_cells=None): +def _compiled_toy( + *, declared=True, second_producer=False, second_writer=False, expand_cells=None +): """A compiled graph: CREATE, one or two producers, preparation, calibration. - With ``second_writer``, a ``filter`` version holds an undeclared rewrite + Without ``declared``, the first producer declares no measurement. With + ``second_writer``, a ``filter`` version holds an undeclared rewrite of the income column, and preparation and calibration live in it. With ``expand_cells``, preparation and calibration live in an EXPAND version that declares those cells. @@ -416,12 +422,12 @@ def _compiled_toy(*, second_producer=False, second_writer=False, expand_cells=No compile_graph, ) - def producer(name, column): + def producer(name, column, declares=True): return Node( id=name, kernel="measure.combine@1", population="toy.create", - params={"measurement_id": SUMMED_MEASUREMENT}, + params={"measurement_id": SUMMED_MEASUREMENT} if declares else {}, outputs=(Owned("person", column, "int64"),), ) @@ -433,7 +439,7 @@ def producer(name, column): sources=("toy_donor",), outputs=(Owned("person", "person_id", "int64"),), ), - producer("toy.measure", INCOME_COLUMN), + producer("toy.measure", INCOME_COLUMN, declared), ] read = [INCOME_COLUMN] if second_producer: @@ -492,12 +498,13 @@ def producer(name, column): def _forced_proof(monkeypatch, producer, columns=(f"person.{INCOME_COLUMN}",)): + """Patch in a proof; with no producer it names no calibration node.""" from microcosm.build.transport import compose as compose_module proof = { SUMMED_MEASUREMENT: { "measurement_id": SUMMED_MEASUREMENT, - "producers": {"toy.calibrate": producer}, + "producers": {} if producer is None else {"toy.calibrate": producer}, "columns": list(columns), } } @@ -843,3 +850,360 @@ def test_composition_refuses_an_expand_of_a_measured_column(composed_toy): f"calibration node {calibration!r} has writers ['toy.expand', " f"'toy.measure'] of person.{INCOME_COLUMN} upstream" ) in message + + +# --------------------------------------------------------------------------- +# A calibration node that a transport extension adds +# --------------------------------------------------------------------------- + +EXTENSION_PREFIX = ( + f"Measurement {SUMMED_MEASUREMENT!r}: the composed graph proves no producer " + "for extension calibration node 'toy.calibrate'; " +) + + +@pytest.mark.parametrize( + "shape", [{}, {"expand_cells": (("person", "toy_other", "int64"),)}] +) +def test_the_compiled_graph_proves_an_extension_calibration_producer( + monkeypatch, shape +): + check = _forced_proof(monkeypatch, None) + graph, compiled = _compiled_toy(**shape) + check({}, graph, compiled, extension_nodes={"toy.calibrate"}) + + +def test_a_skeleton_calibration_node_still_needs_its_declared_producer(monkeypatch): + """The extension rule applies only to the nodes an extension adds.""" + check = _forced_proof(monkeypatch, None) + graph, compiled = _compiled_toy() + with pytest.raises(ValueError, match="and the declaration proved None"): + check({}, graph, compiled) + + +def test_only_an_extension_calibration_node_leaves_the_declared_comparison( + monkeypatch, +): + """Extension ids of other nodes leave the skeleton's checks as they were.""" + check = _forced_proof(monkeypatch, "toy.measure") + graph, compiled = _compiled_toy(second_writer=True) + with pytest.raises(ValueError) as refused: + check({}, graph, compiled, extension_nodes={"toy.filter", "toy.overwrite"}) + assert str(refused.value).startswith( + f"Measurement {SUMMED_MEASUREMENT!r}: the compiled graph contradicts the " + "declared proof; " + ) + assert "writers ['toy.measure', 'toy.overwrite']" in str(refused.value) + + +@pytest.mark.parametrize( + ("shape", "columns", "reason"), + [ + ({"declared": False}, [INCOME_COLUMN], "0 nodes declaring"), + ({"second_producer": True}, [INCOME_COLUMN], "2 nodes declaring"), + ( + {}, + [INCOME_COLUMN, UNIVERSE_COLUMN], + "its one declarer 'toy.measure' does not write " + f"['person.{UNIVERSE_COLUMN}']", + ), + ( + {"expand_cells": (("person", INCOME_COLUMN, "int64"),)}, + [INCOME_COLUMN], + f"writers ['toy.expand', 'toy.measure'] of person.{INCOME_COLUMN}", + ), + ], +) +def test_composition_refuses_an_extension_calibration_without_one_writer( + monkeypatch, shape, columns, reason +): + check = _forced_proof(monkeypatch, None, [f"person.{name}" for name in columns]) + graph, compiled = _compiled_toy(**shape) + with pytest.raises(ValueError) as refused: + check({}, graph, compiled, extension_nodes={"toy.calibrate"}) + assert str(refused.value).startswith(EXTENSION_PREFIX) + assert reason in str(refused.value) + + +def test_an_extension_calibration_refuses_a_later_version_rewrite(monkeypatch): + """The NZ hub's G7 in miniature: a filter version rewrites the income.""" + check = _forced_proof(monkeypatch, None) + graph, compiled = _compiled_toy(second_writer=True) + with pytest.raises(ValueError) as refused: + check({}, graph, compiled, extension_nodes={"toy.calibrate"}) + assert str(refused.value) == ( + f"{EXTENSION_PREFIX}measurement {SUMMED_MEASUREMENT!r}: calibration node " + "'toy.calibrate' has writers ['toy.measure', 'toy.overwrite'] of " + f"person.{INCOME_COLUMN} upstream; only its producer 'toy.measure' may " + "write it." + ) + + +def _compiled_chain(draws): + """:func:`_chain`'s draws as a compiled graph. + + A version holds one owner per column, so a node that writes a column an + earlier node wrote opens a ``filter`` version and rewrites it there. + """ + from microcosm.graph import ( + Graph, + Node, + Owned, + Slice, + SourceRef, + StructuralDelta, + compile_graph, + ) + + nodes = [ + Node( + id="toy.create", + kernel="transport.create@1", + structural=StructuralDelta.CREATE, + sources=("toy_donor",), + outputs=(Owned("person", "person_id", "int64"),), + ) + ] + population, written, previous = "toy.create", set(), None + for index, (declares, income, universe) in enumerate(draws): + measured = [ + (column, dtype) + for column, dtype, wanted in ( + (INCOME_COLUMN, "int64", income), + (UNIVERSE_COLUMN, "string", universe), + ) + if wanted + ] + if written & {column for column, _ in measured}: + nodes.append( + Node( + id=f"toy.v{index}", + kernel="transport.boundary@1", + structural=StructuralDelta.FILTER, + base=population, + ) + ) + population = f"toy.v{index}" + nodes.append( + Node( + id=f"toy.n{index}", + kernel="toy.chain@1", + population=population, + params={"measurement_id": SUMMED_MEASUREMENT} if declares else {}, + inputs=() if previous is None else (Slice("person", (previous,)),), + outputs=( + *( + Owned("person", column, dtype, rewrite=column in written) + for column, dtype in measured + ), + Owned("person", f"toy_step_{index}", "bool"), + ), + ) + ) + written |= {column for column, _ in measured} + previous = f"toy_step_{index}" + nodes.append( + Node( + id="toy.calibrate", + kernel="calibrate.ordered_adam@1", + population=population, + inputs=(Slice("person", (previous,)),), + ) + ) + graph = Graph( + country="toy", + sources=(SourceRef("toy_donor", "toy-codec-v1"),), + nodes=tuple(nodes), + ) + return graph, compile_graph(graph) + + +@given( + draws=st.lists( + st.tuples(st.booleans(), st.booleans(), st.booleans()), + min_size=1, + max_size=4, + ) +) +@settings( + max_examples=60, + database=None, + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) +# One declarer alone; without the universe; with a second writer after it and +# before it; with two declarers. +@example(draws=[(True, True, True)]) +@example(draws=[(True, True, False)]) +@example(draws=[(True, True, True), (False, True, False)]) +@example(draws=[(False, False, True), (True, True, True)]) +@example(draws=[(True, True, False), (True, False, True)]) +def test_an_extension_chain_proves_exactly_when_the_declared_chain_does(draws): + """The declared proof and the compiled extension proof agree, refusal for refusal. + + The declared check reads :func:`_chain`'s rows. The compiled check reads + the same draws compiled, as for a calibration node that an extension adds. + """ + proofs, refusals = measurement_proofs(_spec(_chain(draws))) + graph, compiled = _compiled_chain(draws) + columns = [f"person.{INCOME_COLUMN}", f"person.{UNIVERSE_COLUMN}"] + with pytest.MonkeyPatch.context() as patch: + check = _forced_proof(patch, None, columns) + try: + check({}, graph, compiled, extension_nodes={"toy.calibrate"}) + except ValueError as error: + refusal = str(error) + else: + refusal = None + assert (refusal is None) is (SUMMED_MEASUREMENT in proofs) + if refusal is not None: + reason = refusals[SUMMED_MEASUREMENT] + missing = re.fullmatch( + r"producer ('[^']+') (does not write \[.*\]) on the interval " + "table's entity", + reason, + ) + if missing is not None: + reason = f"its one declarer {missing[1]} {missing[2]}" + assert refusal == f"{EXTENSION_PREFIX}{reason}." + + +@pytest.fixture(scope="module") +def nz_toy(tmp_path_factory): + """NZ's synthetic graph, whose V1, V3 and VHF scenarios each calibrate.""" + from test_support.microcosm_build.transport_nz_toy import make_nz_toy_fixture + + return make_nz_toy_fixture(tmp_path_factory.mktemp("nz-toy") / "fixture") + + +def _nz_proof(nz_toy): + proofs, refusals = measurement_proofs(nz_toy.spec) + assert refusals == {} + ((binding, proof),) = proofs.items() + return binding, proof + + +def _v3_without_declaration(rows, nodes, binding, columns): + """M1: V3's stand-in keeps its outputs and drops the measurement id.""" + del rows["nz.v.V3.income.standin"]["params"]["measurement_id"] + + +def _v3_second_declarer(rows, nodes, binding, columns): + """M2: V3's earnings node declares the measurement too.""" + rows["nz.v.V3.earnings.cents"]["params"]["measurement_id"] = binding + + +def _v3_universe_elsewhere(rows, nodes, binding, columns): + """M4: V3's universe column moves from the stand-in to the earnings node.""" + standin = rows["nz.v.V3.income.standin"] + (moved,) = [row for row in standin["outputs"] if row["column"] == columns[1]] + standin["outputs"].remove(moved) + rows["nz.v.V3.earnings.cents"]["outputs"].append(moved) + + +def _v3_later_version_rewrite(rows, nodes, binding, columns): + """G7: a filter version inside V3 rewrites both measured columns. + + Preparation, the compiled targets and the problem move into it, and V3's + calibration opens from it. + """ + standin = rows["nz.v.V3.income.standin"] + dtypes = {row["column"]: row["dtype"] for row in standin["outputs"]} + moved = [ + rows[name] + for name in ( + "nz.v.V3.calibration.prepare", + "nz.v.V3.targets.compile", + "nz.v.V3.targets.problem", + ) + ] + for row in moved: + nodes.remove(row) + row["population"] = "nz.v.V3.review" + rows["nz.v.V3.calibrate"]["base"] = "nz.v.V3.review" + position = nodes.index(standin) + 1 + nodes[position:position] = [ + { + "id": "nz.v.V3.review", + "kernel": "transport.boundary@1", + "structural": "filter", + "base": "nz.v.V3", + }, + { + "id": "nz.v.V3.review.rewrite", + "kernel": "transport.currency@1", + "population": "nz.v.V3.review", + "inputs": [{"entity": "person", "columns": list(columns)}], + "outputs": [ + { + "entity": "person", + "column": column, + "dtype": dtypes[column], + "rewrite": True, + } + for column in columns + ], + }, + *moved, + ] + + +def test_composition_proves_each_extension_calibration_producer(nz_toy): + """The declaration names the skeleton's calibration node; compose proves V3's.""" + _, proof = _nz_proof(nz_toy) + graph = compose_transport_graph(copy.deepcopy(nz_toy.spec), nz_toy.config) + calibration = { + node.id for node in graph.nodes if node.kernel.startswith("calibrate.") + } + assert "nz.v.V3.calibrate" in calibration - set(proof["producers"]) + + +@pytest.mark.parametrize( + ("mutant", "reason"), + [ + ( + _v3_without_declaration, + "0 nodes declaring measurement_id {binding!r} sit upstream of " + "calibration node 'nz.v.V3.calibrate'", + ), + ( + _v3_second_declarer, + "2 nodes declaring measurement_id {binding!r} sit upstream of " + "calibration node 'nz.v.V3.calibrate'", + ), + ( + _v3_universe_elsewhere, + "its one declarer 'nz.v.V3.income.standin' does not write " + "['person.{universe}']", + ), + ( + _v3_later_version_rewrite, + "calibration node 'nz.v.V3.calibrate' has writers " + "['nz.v.V3.income.standin', 'nz.v.V3.review.rewrite'] of " + "person.{income} upstream; only its producer 'nz.v.V3.income.standin' " + "may write it", + ), + ], +) +def test_composition_refuses_a_scenario_calibration_the_declaration_never_sees( + nz_toy, mutant, reason +): + binding, proof = _nz_proof(nz_toy) + income, universe = (name.split(".", 1)[1] for name in proof["columns"]) + spec = copy.deepcopy(nz_toy.spec) + (scenario,) = ( + row for row in spec["resources"]["scenarios"]["scenarios"] if row["id"] == "V3" + ) + rows = {row["id"]: row for row in scenario["nodes"]} + mutant(rows, scenario["nodes"], binding, (income, universe)) + # Scenario rows sit outside the declared graph, so the declaration still + # proves the measurement and only the extension pass can refuse. + assert binding in measurement_proofs(spec)[0] + with pytest.raises(ValueError) as refused: + compose_transport_graph(spec, nz_toy.config) + message = str(refused.value) + assert message.startswith( + f"Measurement {binding!r}: the composed graph proves no producer for " + "extension calibration node 'nz.v.V3.calibrate'; " + ) + assert reason.format(binding=binding, income=income, universe=universe) in message From 3196273cf7abc9f58871830a0d61395441bb111a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 3 Nov 2026 07:26:19 -0500 Subject: [PATCH 2/4] Move the NZ-toy measurement-proof tests into NZ's toy-graph file The five tests that run the extension proof on NZ's synthetic graph name the toy's V3 nodes, and the NZ hub owns the toy and its node ids. They move, with their helpers, from test_transport_measurement_proof.py into test_nz_transport_toy_graph.py and use that file's module `toy` fixture, so a change to the toy edits only NZ's files. The shared file keeps the unit tests on the compiled toy and the differential property. The move adds G7 in the NZ hub's placement. V3's calibration keeps its base on V3, while preparation, the compiled targets and the problem move into the review version that rewrites both measured columns. The problem reads the rewritten columns, so only the calibration's artifact edge from the problem puts the rewrite among its compiled ancestors. The earlier G7 opens the calibration from the review version, where the rewrite joins its base, and doesn't need that edge. Co-Authored-By: Claude Opus 5.5 --- .../shared/test_nz_transport_toy_graph.py | 158 ++++++++++++++++++ .../test_transport_measurement_proof.py | 143 +--------------- 2 files changed, 160 insertions(+), 141 deletions(-) diff --git a/packages/microcosm-build/tests/engine_free/shared/test_nz_transport_toy_graph.py b/packages/microcosm-build/tests/engine_free/shared/test_nz_transport_toy_graph.py index 5d07658f56..c96af0b03f 100644 --- a/packages/microcosm-build/tests/engine_free/shared/test_nz_transport_toy_graph.py +++ b/packages/microcosm-build/tests/engine_free/shared/test_nz_transport_toy_graph.py @@ -19,6 +19,7 @@ from microcosm.build.transport.cli import through from microcosm.build.transport.compose import ( compose_transport_graph, + measurement_proofs, transport_resource, validate_transport_activation, ) @@ -1026,3 +1027,160 @@ def test_without_the_toy_standin_its_six_rows_refuse_activation_by_name(toy): for role in ("count_reference", "amount_reference"): assert f"target_references.{band[role]}; awaits" in message assert f"measurement:{TOY_MEASUREMENT}" in message + + +# --------------------------------------------------------------------------- +# G8-r2c A on the toy: compose proves each scenario calibration's producer +# --------------------------------------------------------------------------- + + +def _toy_measurement_proof(fixture): + proofs, refusals = measurement_proofs(fixture.spec) + assert refusals == {} + ((binding, proof),) = proofs.items() + return binding, proof + + +def test_composition_proves_each_extension_calibration_producer(toy): + """The declaration names the skeleton's calibration node; compose proves V3's.""" + fixture, _ = toy + _, proof = _toy_measurement_proof(fixture) + graph = compose_transport_graph(copy.deepcopy(fixture.spec), fixture.config) + calibration = { + node.id for node in graph.nodes if node.kernel.startswith("calibrate.") + } + assert "nz.v.V3.calibrate" in calibration - set(proof["producers"]) + + +def _v3_without_declaration(rows, nodes, binding, columns): + """M1: V3's stand-in keeps its outputs and drops the measurement id.""" + del rows["nz.v.V3.income.standin"]["params"]["measurement_id"] + + +def _v3_second_declarer(rows, nodes, binding, columns): + """M2: V3's earnings node declares the measurement too.""" + rows["nz.v.V3.earnings.cents"]["params"]["measurement_id"] = binding + + +def _v3_universe_elsewhere(rows, nodes, binding, columns): + """M4: V3's universe column moves from the stand-in to the earnings node.""" + standin = rows["nz.v.V3.income.standin"] + (moved,) = [row for row in standin["outputs"] if row["column"] == columns[1]] + standin["outputs"].remove(moved) + rows["nz.v.V3.earnings.cents"]["outputs"].append(moved) + + +def _v3_review_version(rows, nodes, columns): + """Open a filter version inside V3 whose one node rewrites both columns. + + Preparation, the compiled targets and the problem move into it. + """ + standin = rows["nz.v.V3.income.standin"] + dtypes = {row["column"]: row["dtype"] for row in standin["outputs"]} + moved = [ + rows[name] + for name in ( + "nz.v.V3.calibration.prepare", + "nz.v.V3.targets.compile", + "nz.v.V3.targets.problem", + ) + ] + for row in moved: + nodes.remove(row) + row["population"] = "nz.v.V3.review" + position = nodes.index(standin) + 1 + nodes[position:position] = [ + { + "id": "nz.v.V3.review", + "kernel": "transport.boundary@1", + "structural": "filter", + "base": "nz.v.V3", + }, + { + "id": "nz.v.V3.review.rewrite", + "kernel": "transport.currency@1", + "population": "nz.v.V3.review", + "inputs": [{"entity": "person", "columns": list(columns)}], + "outputs": [ + { + "entity": "person", + "column": column, + "dtype": dtypes[column], + "rewrite": True, + } + for column in columns + ], + }, + *moved, + ] + + +def _v3_rewrite_in_the_calibration_base(rows, nodes, binding, columns): + """G7: V3's calibration opens from the review version, rewrite and all.""" + _v3_review_version(rows, nodes, columns) + rows["nz.v.V3.calibrate"]["base"] = "nz.v.V3.review" + + +def _v3_rewrite_behind_the_problem(rows, nodes, binding, columns): + """G7 in NZ's placement: V3's calibration keeps its base on V3. + + The problem moves into the review version and reads the rewritten + columns, so only the calibration's artifact edge from the problem puts + the rewrite among its compiled ancestors. + """ + _v3_review_version(rows, nodes, columns) + + +_V3_SECOND_WRITER = ( + "calibration node 'nz.v.V3.calibrate' has writers " + "['nz.v.V3.income.standin', 'nz.v.V3.review.rewrite'] of " + "person.{income} upstream; only its producer 'nz.v.V3.income.standin' " + "may write it" +) + + +@pytest.mark.parametrize( + ("mutant", "reason"), + [ + ( + _v3_without_declaration, + "0 nodes declaring measurement_id {binding!r} sit upstream of " + "calibration node 'nz.v.V3.calibrate'", + ), + ( + _v3_second_declarer, + "2 nodes declaring measurement_id {binding!r} sit upstream of " + "calibration node 'nz.v.V3.calibrate'", + ), + ( + _v3_universe_elsewhere, + "its one declarer 'nz.v.V3.income.standin' does not write " + "['person.{universe}']", + ), + (_v3_rewrite_in_the_calibration_base, _V3_SECOND_WRITER), + (_v3_rewrite_behind_the_problem, _V3_SECOND_WRITER), + ], +) +def test_composition_refuses_a_scenario_calibration_the_declaration_never_sees( + toy, mutant, reason +): + fixture, _ = toy + binding, proof = _toy_measurement_proof(fixture) + income, universe = (name.split(".", 1)[1] for name in proof["columns"]) + spec = copy.deepcopy(fixture.spec) + (scenario,) = ( + row for row in spec["resources"]["scenarios"]["scenarios"] if row["id"] == "V3" + ) + rows = {row["id"]: row for row in scenario["nodes"]} + mutant(rows, scenario["nodes"], binding, (income, universe)) + # Scenario rows sit outside the declared graph, so the declaration still + # proves the measurement and only the extension pass can refuse. + assert binding in measurement_proofs(spec)[0] + with pytest.raises(ValueError) as refused: + compose_transport_graph(spec, fixture.config) + message = str(refused.value) + assert message.startswith( + f"Measurement {binding!r}: the composed graph proves no producer for " + "extension calibration node 'nz.v.V3.calibrate'; " + ) + assert reason.format(binding=binding, income=income, universe=universe) in message diff --git a/packages/microcosm-build/tests/engine_free/shared/test_transport_measurement_proof.py b/packages/microcosm-build/tests/engine_free/shared/test_transport_measurement_proof.py index acadb3de96..eb3b09b197 100644 --- a/packages/microcosm-build/tests/engine_free/shared/test_transport_measurement_proof.py +++ b/packages/microcosm-build/tests/engine_free/shared/test_transport_measurement_proof.py @@ -8,6 +8,8 @@ measurement. Composition checks the same rule against the compiled graph, and for a calibration node that a transport extension adds, which no declared row names, the compiled graph alone proves the producer. +``test_nz_transport_toy_graph.py`` runs that path on the calibration nodes of +NZ's toy scenarios. """ from __future__ import annotations @@ -1066,144 +1068,3 @@ def test_an_extension_chain_proves_exactly_when_the_declared_chain_does(draws): if missing is not None: reason = f"its one declarer {missing[1]} {missing[2]}" assert refusal == f"{EXTENSION_PREFIX}{reason}." - - -@pytest.fixture(scope="module") -def nz_toy(tmp_path_factory): - """NZ's synthetic graph, whose V1, V3 and VHF scenarios each calibrate.""" - from test_support.microcosm_build.transport_nz_toy import make_nz_toy_fixture - - return make_nz_toy_fixture(tmp_path_factory.mktemp("nz-toy") / "fixture") - - -def _nz_proof(nz_toy): - proofs, refusals = measurement_proofs(nz_toy.spec) - assert refusals == {} - ((binding, proof),) = proofs.items() - return binding, proof - - -def _v3_without_declaration(rows, nodes, binding, columns): - """M1: V3's stand-in keeps its outputs and drops the measurement id.""" - del rows["nz.v.V3.income.standin"]["params"]["measurement_id"] - - -def _v3_second_declarer(rows, nodes, binding, columns): - """M2: V3's earnings node declares the measurement too.""" - rows["nz.v.V3.earnings.cents"]["params"]["measurement_id"] = binding - - -def _v3_universe_elsewhere(rows, nodes, binding, columns): - """M4: V3's universe column moves from the stand-in to the earnings node.""" - standin = rows["nz.v.V3.income.standin"] - (moved,) = [row for row in standin["outputs"] if row["column"] == columns[1]] - standin["outputs"].remove(moved) - rows["nz.v.V3.earnings.cents"]["outputs"].append(moved) - - -def _v3_later_version_rewrite(rows, nodes, binding, columns): - """G7: a filter version inside V3 rewrites both measured columns. - - Preparation, the compiled targets and the problem move into it, and V3's - calibration opens from it. - """ - standin = rows["nz.v.V3.income.standin"] - dtypes = {row["column"]: row["dtype"] for row in standin["outputs"]} - moved = [ - rows[name] - for name in ( - "nz.v.V3.calibration.prepare", - "nz.v.V3.targets.compile", - "nz.v.V3.targets.problem", - ) - ] - for row in moved: - nodes.remove(row) - row["population"] = "nz.v.V3.review" - rows["nz.v.V3.calibrate"]["base"] = "nz.v.V3.review" - position = nodes.index(standin) + 1 - nodes[position:position] = [ - { - "id": "nz.v.V3.review", - "kernel": "transport.boundary@1", - "structural": "filter", - "base": "nz.v.V3", - }, - { - "id": "nz.v.V3.review.rewrite", - "kernel": "transport.currency@1", - "population": "nz.v.V3.review", - "inputs": [{"entity": "person", "columns": list(columns)}], - "outputs": [ - { - "entity": "person", - "column": column, - "dtype": dtypes[column], - "rewrite": True, - } - for column in columns - ], - }, - *moved, - ] - - -def test_composition_proves_each_extension_calibration_producer(nz_toy): - """The declaration names the skeleton's calibration node; compose proves V3's.""" - _, proof = _nz_proof(nz_toy) - graph = compose_transport_graph(copy.deepcopy(nz_toy.spec), nz_toy.config) - calibration = { - node.id for node in graph.nodes if node.kernel.startswith("calibrate.") - } - assert "nz.v.V3.calibrate" in calibration - set(proof["producers"]) - - -@pytest.mark.parametrize( - ("mutant", "reason"), - [ - ( - _v3_without_declaration, - "0 nodes declaring measurement_id {binding!r} sit upstream of " - "calibration node 'nz.v.V3.calibrate'", - ), - ( - _v3_second_declarer, - "2 nodes declaring measurement_id {binding!r} sit upstream of " - "calibration node 'nz.v.V3.calibrate'", - ), - ( - _v3_universe_elsewhere, - "its one declarer 'nz.v.V3.income.standin' does not write " - "['person.{universe}']", - ), - ( - _v3_later_version_rewrite, - "calibration node 'nz.v.V3.calibrate' has writers " - "['nz.v.V3.income.standin', 'nz.v.V3.review.rewrite'] of " - "person.{income} upstream; only its producer 'nz.v.V3.income.standin' " - "may write it", - ), - ], -) -def test_composition_refuses_a_scenario_calibration_the_declaration_never_sees( - nz_toy, mutant, reason -): - binding, proof = _nz_proof(nz_toy) - income, universe = (name.split(".", 1)[1] for name in proof["columns"]) - spec = copy.deepcopy(nz_toy.spec) - (scenario,) = ( - row for row in spec["resources"]["scenarios"]["scenarios"] if row["id"] == "V3" - ) - rows = {row["id"]: row for row in scenario["nodes"]} - mutant(rows, scenario["nodes"], binding, (income, universe)) - # Scenario rows sit outside the declared graph, so the declaration still - # proves the measurement and only the extension pass can refuse. - assert binding in measurement_proofs(spec)[0] - with pytest.raises(ValueError) as refused: - compose_transport_graph(spec, nz_toy.config) - message = str(refused.value) - assert message.startswith( - f"Measurement {binding!r}: the composed graph proves no producer for " - "extension calibration node 'nz.v.V3.calibrate'; " - ) - assert reason.format(binding=binding, income=income, universe=universe) in message From b64eb6c2b49561a8aae838739835648c1f34c6d5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 3 Nov 2026 16:59:21 -0500 Subject: [PATCH 3/4] Refuse a malformed measurement_id in the compiled proof check Review r2 found that the compiled check skipped a node whose measurement_id was neither None nor a nonempty string, while the declared check refuses it. With the string producer still in place, an extra upstream node declaring measurement_id 7 composed, though the same param on a declared row parks every measurement. r2 showed it on NZ's toy graph, where V3's earnings.cents with id 7 composed. _check_measurement_proofs now refuses such a node with the declared check's reason before it proves any producer, and reads an explicit None as no declaration, as measurement_proofs does. The new test gives a node upstream of calibration a measurement_id of 7, "" or None, and runs the declared check and the compiled check on a calibration node that an extension adds and on a skeleton one. Both checks refuse 7 and "" with the same reason, and both ignore None. Without the fix, the compiled check accepts the four 7 and "" cases, and those cases fail. The commit also fixes three docstrings that r2 flagged: the test module's account of the compiled tests, which patch in a proof; _chain's, whose nodes chain by step columns; and compose's module docstring, which now names gate_inputs, the other explicit way an extension may add a skeleton predecessor. Co-Authored-By: Claude Opus 5.5 --- .../src/microcosm/build/transport/compose.py | 18 +++- .../test_transport_measurement_proof.py | 95 +++++++++++++++++-- 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/packages/microcosm-build/src/microcosm/build/transport/compose.py b/packages/microcosm-build/src/microcosm/build/transport/compose.py index 25a3e4dcba..d07d917d98 100644 --- a/packages/microcosm-build/src/microcosm/build/transport/compose.py +++ b/packages/microcosm-build/src/microcosm/build/transport/compose.py @@ -50,7 +50,8 @@ Composition refuses an extension whose nodes change any skeleton node's compiled predecessors (for example a new member of a calibration base version). Explicit ``package_inputs`` may add extension artifacts to the final -receipt; every other skeleton predecessor set remains fixed. +receipt, and explicit ``gate_inputs`` may add extension evidence to a skeleton +gate battery; every other skeleton predecessor set remains fixed. Composition reads no donor, no facts and no engine, and writes nothing. Activation checks selected resource paths, non-null selected roots and encoding @@ -733,8 +734,9 @@ def _check_measurement_proofs( producer under R2C-1's rule: the one declarer must output each of the proof's columns. Either way, the producer must also be the only compiled ancestor whose outputs or EXPAND cells hold each of the proof's columns - (R2C-15). Composition runs this check on the skeleton and again on the - extended graph. + (R2C-15). As the declared check does, it refuses a node whose + ``measurement_id`` is neither ``None`` nor a nonempty string. Composition + runs this check on the skeleton and again on the extended graph. """ proofs, _ = measurement_proofs(spec) if not proofs: @@ -749,6 +751,16 @@ def _check_measurement_proofs( for node in graph.nodes if isinstance(node.params, Mapping) and MEASUREMENT_PARAM in node.params } + malformed = sorted( + name + for name, value in declared.items() + if value is not None and not _is_text(value) + ) + if malformed: + raise ValueError( + f"Composition refuses: nodes {malformed} declare a measurement_id " + "that is not a string." + ) by_id = {node.id: node for node in graph.nodes} _, expand_cells = _precalibration_expansions(graph) for binding, proof in sorted(proofs.items()): diff --git a/packages/microcosm-build/tests/engine_free/shared/test_transport_measurement_proof.py b/packages/microcosm-build/tests/engine_free/shared/test_transport_measurement_proof.py index eb3b09b197..d2b294741e 100644 --- a/packages/microcosm-build/tests/engine_free/shared/test_transport_measurement_proof.py +++ b/packages/microcosm-build/tests/engine_free/shared/test_transport_measurement_proof.py @@ -3,11 +3,13 @@ ``compose.measurement_proofs`` satisfies ``measurement:`` only when, for every calibration node, exactly one node upstream of it declares ``params.measurement_id: `` and writes the scaled interval table's income and -universe columns on that table's entity. Each mutant below breaks one clause -and must leave the rows parked, with the activation gap naming the -measurement. Composition checks the same rule against the compiled graph, and -for a calibration node that a transport extension adds, which no declared row -names, the compiled graph alone proves the producer. +universe columns on that table's entity. Each declared mutant below breaks one +clause and must leave the rows parked, with the activation gap naming the +measurement. Composition checks the same rule against the compiled graph; those +tests patch in a proof and read composition's verdict. For a calibration node +that a transport extension adds, which no declared row names, the compiled +graph alone proves the producer. Both checks refuse a ``measurement_id`` that +is neither ``None`` nor a nonempty string. ``test_nz_transport_toy_graph.py`` runs that path on the calibration nodes of NZ's toy scenarios. """ @@ -663,7 +665,7 @@ def test_an_expand_proves_exactly_when_its_cells_avoid_the_measured_columns(cell def _chain(draws): - """CREATE, then nodes chained by artifacts, all upstream of calibration. + """CREATE, then nodes chained by step columns, all upstream of calibration. Each draw is (declares the id, writes the income, writes the universe). """ @@ -1068,3 +1070,84 @@ def test_an_extension_chain_proves_exactly_when_the_declared_chain_does(draws): if missing is not None: reason = f"its one declarer {missing[1]} {missing[2]}" assert refusal == f"{EXTENSION_PREFIX}{reason}." + + +def _chain_with_another_node(value): + """One declarer's chain, plus a node whose ``measurement_id`` is ``value``. + + Calibration reads the extra node's column, so the node sits upstream of + calibration in both :func:`_chain`'s rows and :func:`_compiled_chain`'s graph. + """ + from dataclasses import replace + + from microcosm.graph import Graph, Node, Owned, Slice, compile_graph + + draws = [(True, True, True)] + document = _chain(draws) + document["nodes"].insert( + -1, + { + "id": "toy.other", + "kernel": "toy.chain@1", + "population": "toy.create", + "params": {"measurement_id": value}, + "outputs": [{"entity": "person", "column": "toy_other", "dtype": "bool"}], + }, + ) + document["nodes"][-1]["inputs"][0]["columns"].append("toy_other") + graph, _ = _compiled_chain(draws) + *nodes, calibrate = graph.nodes + other = Node( + id="toy.other", + kernel="toy.chain@1", + population="toy.create", + params={"measurement_id": value}, + outputs=(Owned("person", "toy_other", "bool"),), + ) + (read,) = calibrate.inputs + calibrate = replace( + calibrate, inputs=(Slice("person", (*read.columns, "toy_other")),) + ) + graph = Graph( + country=graph.country, sources=graph.sources, nodes=(*nodes, other, calibrate) + ) + return _spec(document), graph, compile_graph(graph) + + +@pytest.mark.parametrize("root", ["extension", "skeleton"]) +@pytest.mark.parametrize("value", [7, "", None], ids=["int", "empty", "null"]) +def test_both_checks_refuse_a_malformed_measurement_id_and_ignore_a_null(value, root): + """The declared and compiled checks agree on another node's measurement_id. + + Both refuse ``7`` and ``""`` with the declared check's reason, and both read + an explicit ``None`` as no declaration, for a calibration node that an + extension adds and for a skeleton one. + """ + spec, graph, compiled = _chain_with_another_node(value) + proofs, refusals = measurement_proofs(spec) + extension = root == "extension" + columns = [f"person.{INCOME_COLUMN}", f"person.{UNIVERSE_COLUMN}"] + with pytest.MonkeyPatch.context() as patch: + check = _forced_proof(patch, None if extension else "toy.n0", columns) + try: + check( + {}, + graph, + compiled, + extension_nodes={"toy.calibrate"} if extension else (), + ) + except ValueError as error: + refusal = str(error) + else: + refusal = None + if value is None: + assert refusals == {} + assert proofs[SUMMED_MEASUREMENT]["producers"] == {"toy.calibrate": "toy.n0"} + assert refusal is None + else: + reason = refusals[SUMMED_MEASUREMENT] + assert reason == ( + "nodes ['toy.other'] declare a measurement_id that is not a string" + ) + assert SUMMED_MEASUREMENT not in proofs + assert refusal == f"Composition refuses: {reason}." From 325ee9a8178c3c9f5cc4c40094973fb5a1a20855 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 4 Nov 2026 09:55:09 -0500 Subject: [PATCH 4/4] Say the compiled proof check scans malformed ids only under a proof _check_measurement_proofs returns before its malformed-id scan when the declaration proves no measurement, and its docstring now says so. The independent review of b64eb6c2 noted that the sentence read as unconditional. Co-Authored-By: Claude Opus 5.5 --- .../microcosm-build/src/microcosm/build/transport/compose.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/microcosm-build/src/microcosm/build/transport/compose.py b/packages/microcosm-build/src/microcosm/build/transport/compose.py index f6a7a9f948..d84167e60f 100644 --- a/packages/microcosm-build/src/microcosm/build/transport/compose.py +++ b/packages/microcosm-build/src/microcosm/build/transport/compose.py @@ -734,7 +734,8 @@ def _check_measurement_proofs( producer under R2C-1's rule: the one declarer must output each of the proof's columns. Either way, the producer must also be the only compiled ancestor whose outputs or EXPAND cells hold each of the proof's columns - (R2C-15). As the declared check does, it refuses a node whose + (R2C-15). It returns at once when the declaration proves no measurement. + Otherwise, as the declared check does, it also refuses a node whose ``measurement_id`` is neither ``None`` nor a nonempty string. Composition runs this check on the skeleton and again on the extended graph. """