diff --git a/packages/microcosm-build/src/microcosm/build/transport/takeup_kernels.py b/packages/microcosm-build/src/microcosm/build/transport/takeup_kernels.py index 89a0f7a8a8..e1a4658a75 100644 --- a/packages/microcosm-build/src/microcosm/build/transport/takeup_kernels.py +++ b/packages/microcosm-build/src/microcosm/build/transport/takeup_kernels.py @@ -252,6 +252,7 @@ class TakeupGapKernel(KernelBase): Determinism.DETERMINISTIC, numeric=Numeric.BITWISE, seed_source=SeedSource.NONE, + dependencies=("numpy", "pandas", "scipy"), ) _required = frozenset( { @@ -376,6 +377,7 @@ class TakeupBandsKernel(KernelBase): """``takeup.bands@1``: labelled envelopes of matching scenario tables.""" ref = "takeup.bands@1" + # Bounds select decoded Python numbers; no numerical library computes them. capabilities = Capabilities( Determinism.DETERMINISTIC, numeric=Numeric.BITWISE, diff --git a/packages/microcosm-build/tests/engine_free/shared/test_transport_entitlement_graph.py b/packages/microcosm-build/tests/engine_free/shared/test_transport_entitlement_graph.py index 0cc5c07c74..8043e17a36 100644 --- a/packages/microcosm-build/tests/engine_free/shared/test_transport_entitlement_graph.py +++ b/packages/microcosm-build/tests/engine_free/shared/test_transport_entitlement_graph.py @@ -41,6 +41,10 @@ NONNEGATIVE = st.floats( min_value=0, max_value=1500, allow_nan=False, allow_infinity=False ) +# A unit increase is representable throughout NONNEGATIVE's float64 range. +POSITIVE_INCREMENT = st.floats( + min_value=1, max_value=1500, allow_nan=False, allow_infinity=False +) def _case_root(tmp_path): @@ -240,7 +244,8 @@ def test_as_is_zero_for_excess_assets_and_each_statutory_exclusion(tmp_path, exc @EXECUTED -@given(income=NONNEGATIVE, increment=NONNEGATIVE) +@example(income=80, increment=10) +@given(income=NONNEGATIVE, increment=POSITIVE_INCREMENT) def test_nonbeneficiary_as_is_nonincreasing_in_income(tmp_path, income, increment): tmp_path = _case_root(tmp_path) fixture = make_entitlement_fixture( @@ -262,7 +267,8 @@ def test_nonbeneficiary_as_is_nonincreasing_in_income(tmp_path, income, incremen @EXECUTED -@given(costs=NONNEGATIVE, increment=NONNEGATIVE) +@example(costs=40, increment=20) +@given(costs=NONNEGATIVE, increment=POSITIVE_INCREMENT) def test_as_is_nondecreasing_in_qualifying_costs_until_the_cap( tmp_path, costs, increment ): diff --git a/packages/microcosm-build/tests/engine_free/shared/test_transport_takeup_kernels.py b/packages/microcosm-build/tests/engine_free/shared/test_transport_takeup_kernels.py index bd7010a2c5..4cef3bbdc0 100644 --- a/packages/microcosm-build/tests/engine_free/shared/test_transport_takeup_kernels.py +++ b/packages/microcosm-build/tests/engine_free/shared/test_transport_takeup_kernels.py @@ -41,6 +41,7 @@ NumericScope, Slice, ) +from microcosm.graph import kernel as graph_kernel from microcosm.graph.canonical import canonical_json from test_support.microcosm_build.transport_graph import ( COUNTRY, @@ -211,6 +212,57 @@ def test_neutral_kernel_contracts_and_idempotent_registration() -> None: assert kernel.capabilities.consumes_se is False +@pytest.mark.parametrize("distribution", ("numpy", "pandas", "scipy")) +def test_gap_identity_changes_with_numerical_dependency_version( + monkeypatch, distribution +) -> None: + before = TAKEUP_GAP.implementation_hash() + installed_version = graph_kernel.importlib_metadata.version + monkeypatch.setattr( + graph_kernel.importlib_metadata, + "version", + lambda name: ( + installed_version(name) + ".changed" + if name == distribution + else installed_version(name) + ), + ) + assert TAKEUP_GAP.implementation_hash() != before + + +@pytest.mark.parametrize("kernel", (TAKEUP_GAP, TAKEUP_BANDS), ids=lambda k: k.ref) +def test_takeup_identity_is_unchanged_with_same_dependency_versions( + monkeypatch, kernel +) -> None: + before = kernel.implementation_hash() + installed_version = graph_kernel.importlib_metadata.version + monkeypatch.setattr( + graph_kernel.importlib_metadata, + "version", + lambda name: installed_version(name), + ) + assert kernel.implementation_hash() == before + assert type(kernel)().implementation_hash() == before + + +@pytest.mark.parametrize("distribution", ("numpy", "pandas", "scipy")) +def test_bands_identity_ignores_numerical_dependency_versions( + monkeypatch, distribution +) -> None: + before = TAKEUP_BANDS.implementation_hash() + installed_version = graph_kernel.importlib_metadata.version + monkeypatch.setattr( + graph_kernel.importlib_metadata, + "version", + lambda name: ( + installed_version(name) + ".changed" + if name == distribution + else installed_version(name) + ), + ) + assert TAKEUP_BANDS.implementation_hash() == before + + @settings( max_examples=24, deadline=None, diff --git a/packages/microcosm-frame/src/microcosm/frame/rules_kernels.py b/packages/microcosm-frame/src/microcosm/frame/rules_kernels.py index 9c7ad185c3..7453c0d1e4 100644 --- a/packages/microcosm-frame/src/microcosm/frame/rules_kernels.py +++ b/packages/microcosm-frame/src/microcosm/frame/rules_kernels.py @@ -21,6 +21,7 @@ import json import math from collections.abc import Mapping +from dataclasses import replace from types import MappingProxyType import numpy as np @@ -204,10 +205,27 @@ class SimulateCounterfactualKernel(SimulateRulesByRefKernel): contain all policy coefficients and overrides; ``resource_sha256`` may bind the declaring spec resource. Neither engine bindings nor spec fingerprints enter the implementation hash. + + NumPy and pandas versions enter the implementation hash because bridge + arithmetic and person/group reductions use their numerical operations. + Caller-supplied dependencies also bind the engines' behavior. """ ref = "simulate.counterfactual@1" + def __init__( + self, + engines: Mapping[str, RulesEngine], + dependencies: tuple[str, ...] = (), + ) -> None: + super().__init__(engines, dependencies) + self.capabilities = replace( + self.capabilities, + dependencies=tuple( + dict.fromkeys((*self.capabilities.dependencies, "numpy", "pandas")) + ), + ) + def run(self, context: KernelContext) -> KernelResult: components, terms, frame = self._prepare(context) values = self._evaluate(frame, components, terms) diff --git a/packages/microcosm-frame/tests/engine_free/shared/test_counterfactual_kernels.py b/packages/microcosm-frame/tests/engine_free/shared/test_counterfactual_kernels.py index 581e80ae7c..7a4bfc7d7f 100644 --- a/packages/microcosm-frame/tests/engine_free/shared/test_counterfactual_kernels.py +++ b/packages/microcosm-frame/tests/engine_free/shared/test_counterfactual_kernels.py @@ -24,6 +24,7 @@ SimulateSolveZeroKernel, ) from microcosm.graph import KernelRegistry, Node, Owned, Slice +from microcosm.graph import kernel as graph_kernel from test_support.microcosm_frame.kernels import _context from tools import transport_bridge_differential as differential_tool @@ -474,6 +475,69 @@ def test_adding_same_adapter_binding_does_not_change_bridge_implementation_hash( assert first.implementation_hash() == second.implementation_hash() +@pytest.mark.parametrize("cls", [SimulateCounterfactualKernel, SimulateSolveZeroKernel]) +@pytest.mark.parametrize("distribution", ["numpy", "pandas"]) +def test_bridge_numerical_dependency_version_changes_implementation_hash( + monkeypatch, cls, distribution +): + kernel = cls({RATE_REF: RatesEngine()}) + before = kernel.implementation_hash() + installed_version = graph_kernel.importlib_metadata.version + monkeypatch.setattr( + graph_kernel.importlib_metadata, + "version", + lambda name: ( + installed_version(name) + (".changed" if name == distribution else "") + ), + ) + assert kernel.implementation_hash() != before + + +@pytest.mark.parametrize("cls", [SimulateCounterfactualKernel, SimulateSolveZeroKernel]) +def test_bridge_implementation_hash_is_stable_for_unchanged_dependency_versions( + monkeypatch, cls +): + kernel = cls({RATE_REF: RatesEngine()}) + before = kernel.implementation_hash() + versions = { + name: graph_kernel.importlib_metadata.version(name) + for name in kernel.capabilities.dependencies + } + monkeypatch.setattr( + graph_kernel.importlib_metadata, "version", versions.__getitem__ + ) + assert kernel.implementation_hash() == before + assert cls({RATE_REF: RatesEngine()}).implementation_hash() == before + + +@pytest.mark.parametrize("cls", [SimulateCounterfactualKernel, SimulateSolveZeroKernel]) +def test_bridge_implementation_hash_ignores_unused_scipy_version(monkeypatch, cls): + kernel = cls({RATE_REF: RatesEngine()}) + before = kernel.implementation_hash() + installed_version = graph_kernel.importlib_metadata.version + monkeypatch.setattr( + graph_kernel.importlib_metadata, + "version", + lambda name: installed_version(name) + (".changed" if name == "scipy" else ""), + ) + assert kernel.implementation_hash() == before + + +@pytest.mark.parametrize("cls", [SimulateCounterfactualKernel, SimulateSolveZeroKernel]) +def test_bridge_declared_engine_dependency_version_changes_implementation_hash( + monkeypatch, cls +): + kernel = cls({RATE_REF: RatesEngine()}, dependencies=("scipy",)) + before = kernel.implementation_hash() + installed_version = graph_kernel.importlib_metadata.version + monkeypatch.setattr( + graph_kernel.importlib_metadata, + "version", + lambda name: installed_version(name) + (".changed" if name == "scipy" else ""), + ) + assert kernel.implementation_hash() != before + + @pytest.mark.parametrize( "returned,match", [([1], "wrong shape"), ([np.nan, 1], "finite")] )