From bcf5b8ea3f1b7ab949fa7e11670c0d79f5c66630 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 29 Aug 2026 18:51:53 -0400 Subject: [PATCH] Fix Axiom adapter RuleSpec root authority --- .../axiom-explicit-rulespec-roots.fixed.md | 7 + packages/microcosm-frame/README.md | 24 ++- .../src/microcosm/frame/adapters/axiom.py | 49 +++++- .../zz/policies/tests}/axiom_toy_country.yaml | 4 +- .../tests/test_axiom_adapter.py | 166 ++++++++++++++---- .../tests/test_rules_engine_contract.py | 8 +- 6 files changed, 216 insertions(+), 42 deletions(-) create mode 100644 changelog.d/axiom-explicit-rulespec-roots.fixed.md rename packages/microcosm-frame/tests/fixtures/{ => rulespec-zz/zz/policies/tests}/axiom_toy_country.yaml (93%) diff --git a/changelog.d/axiom-explicit-rulespec-roots.fixed.md b/changelog.d/axiom-explicit-rulespec-roots.fixed.md new file mode 100644 index 000000000..8a6d1a4c3 --- /dev/null +++ b/changelog.d/axiom-explicit-rulespec-roots.fixed.md @@ -0,0 +1,7 @@ +Require every `AxiomEngine` caller to provide a non-empty explicit sequence of +canonical RuleSpec roots and forward that exact authority boundary to the +current Axiom dense loader. The adapter no longer relies on the retired +implicit-root interface, and its canonical-layout fixture plus engine-free +regressions pin missing, empty, scalar, and forwarded-root behavior. Root and +module validation errors also propagate instead of being mistaken for an +optional entity with no derived rules. diff --git a/packages/microcosm-frame/README.md b/packages/microcosm-frame/README.md index 77e668ad3..062137103 100644 --- a/packages/microcosm-frame/README.md +++ b/packages/microcosm-frame/README.md @@ -24,8 +24,28 @@ no operator ever re-derives structure or silently corrupts weights: - **US unit structure**: `assign_us_unit_structure` builds the PolicyEngine entity systems (tax units delegated to `microunit`, install via `microcosm-frame[us]`) and returns a validated frame. -- **The `RulesEngine` protocol** plus a lazy `policyengine_us` adapter - (install via `microcosm-frame[policyengine]`). +- **The `RulesEngine` protocol** plus lazy `policyengine_us` (install via + `microcosm-frame[policyengine]`) and Axiom adapters. The Axiom adapter's HDF5 + support installs via `microcosm-frame[axiom]`; the engine itself currently + installs from an `axiom-rules-engine` checkout. + +The Axiom adapter requires callers to declare the exact RuleSpec authority +roots used for compilation. It does not infer them from the module path, +environment, current directory, or sibling checkouts: + +```python +from pathlib import Path + +from microcosm.frame.adapters.axiom import AxiomEngine + +rulespec_be = Path("/absolute/path/to/rulespec-be") +module = rulespec_be / "be/statutes/income_tax/individual/rate_scale.yaml" +engine = AxiomEngine(module, rulespec_roots=(rulespec_be,)) +``` + +The sequence must be non-empty. On first engine-backed use, Axiom validates +that every supplied root is an absolute, canonical `rulespec-` +checkout and resolves imports only through those roots. See the repository `DESIGN.md` for the charter and `tests/test_contracts.py` for the behavioral guarantees the kernel makes. diff --git a/packages/microcosm-frame/src/microcosm/frame/adapters/axiom.py b/packages/microcosm-frame/src/microcosm/frame/adapters/axiom.py index 623639f88..dec26045a 100644 --- a/packages/microcosm-frame/src/microcosm/frame/adapters/axiom.py +++ b/packages/microcosm-frame/src/microcosm/frame/adapters/axiom.py @@ -27,6 +27,15 @@ ``Child``, ``Vehicle``, ...) are invisible to the kernel: their variables resolve and materialize only once a frame entity is mapped to them. +RuleSpec authority roots +------------------------ +Filesystem compilation requires a non-empty, explicit sequence of canonical +``rulespec-`` roots. The adapter forwards exactly the caller-supplied +roots to Axiom; it never searches the working directory, environment, module +ancestors, or sibling checkouts. Axiom remains the authority for validating +that each root is absolute, canonical, and structurally valid when the module +is compiled lazily. + Inputs are declared by usage, not typed --------------------------------------- The dense surface enumerates input *names* per entity but carries no input @@ -97,8 +106,11 @@ class AxiomEngine: Args: module: Path to the RuleSpec module to compile (e.g. ``rulespec-be/be/statutes/income_tax/individual/rate_scale.yaml``). - Imports resolve through the country-monorepo layout the file - lives in; compilation happens in-process on first engine use. + Compilation happens in-process on first engine use. + rulespec_roots: Non-empty explicit sequence of canonical + ``rulespec-`` checkout roots. These are forwarded exactly + to Axiom as its filesystem authority boundary; the adapter never + infers roots from the module, environment, or working directory. schema: The frame-side entity structure (:data:`BE_SCHEMA` for the Belgian pilot). contract: Column-parity contract for :meth:`write_dataset` exports. @@ -122,6 +134,7 @@ def __init__( module: str | Path, schema: EntitySchema = BE_SCHEMA, *, + rulespec_roots: Sequence[str | Path], contract: ExportContract | None = None, defaults: Mapping[str, object] | None = None, entity_names: Mapping[str, str] | None = None, @@ -131,7 +144,22 @@ def __init__( raise ValueError( f"arithmetic must be 'decimal' or 'f64', got {arithmetic!r}." ) + if isinstance(rulespec_roots, (str, Path)): + raise TypeError( + "rulespec_roots must be a non-empty sequence of explicit " + "rulespec- root paths, not a scalar path." + ) + roots = tuple(rulespec_roots) + if not roots: + raise ValueError( + "at least one explicit rulespec- root is required" + ) + if not all(isinstance(root, (str, Path)) for root in roots): + raise TypeError( + "rulespec_roots entries must each be a str or pathlib.Path." + ) self._module = Path(module) + self._rulespec_roots = tuple(Path(root) for root in roots) self._schema = schema self._contract = contract if contract is not None else ExportContract.empty() self._defaults = dict(defaults or {}) @@ -431,9 +459,22 @@ def _program(self, frame_entity: str, *, missing_ok: bool = False) -> Any: engine = self._import_engine() try: program = engine.CompiledDenseProgram.from_file( - self._module, entity=engine_entity + self._module, + rulespec_roots=self._rulespec_roots, + entity=engine_entity, + ) + except ValueError as exc: + missing_entity = ( + "dense compilation could not find derived outputs for entity " + f"`{engine_entity}`" ) - except ValueError: + if str(exc) != missing_entity: + # The native surface currently reports both an entity with no + # derived outputs and authority-root/module validation failures + # as ValueError. Only the exact former condition is optional; + # root or module failures must propagate instead of becoming a + # silently absent program under missing_ok=True. + raise self._programs[frame_entity] = None if missing_ok: return None diff --git a/packages/microcosm-frame/tests/fixtures/axiom_toy_country.yaml b/packages/microcosm-frame/tests/fixtures/rulespec-zz/zz/policies/tests/axiom_toy_country.yaml similarity index 93% rename from packages/microcosm-frame/tests/fixtures/axiom_toy_country.yaml rename to packages/microcosm-frame/tests/fixtures/rulespec-zz/zz/policies/tests/axiom_toy_country.yaml index 6ee7f94fa..5c19615ee 100644 --- a/packages/microcosm-frame/tests/fixtures/axiom_toy_country.yaml +++ b/packages/microcosm-frame/tests/fixtures/rulespec-zz/zz/policies/tests/axiom_toy_country.yaml @@ -1,4 +1,4 @@ -# Self-contained RuleSpec module for the Axiom adapter's behavioral tests: +# Self-contained canonical-root RuleSpec module for the Axiom adapter's tests: # a two-bracket personal income tax with a boolean exemption predicate and a # monthly benefit on the person, plus a household-entity housing allowance — # small enough to hand-verify, shaped like the Belgian pilot (person + @@ -6,7 +6,7 @@ format: rulespec/v1 module: summary: |- - populace-frame test fixture: person-scope two-bracket income tax with an + microcosm-frame test fixture: person-scope two-bracket income tax with an exemption predicate and a monthly benefit, and a household-scope housing allowance. Values are round numbers chosen for hand verification. units: diff --git a/packages/microcosm-frame/tests/test_axiom_adapter.py b/packages/microcosm-frame/tests/test_axiom_adapter.py index 51379a32d..cd9c07f32 100644 --- a/packages/microcosm-frame/tests/test_axiom_adapter.py +++ b/packages/microcosm-frame/tests/test_axiom_adapter.py @@ -54,7 +54,9 @@ reason="pytables (microcosm-frame[axiom]) is not installed", ) -FIXTURE_MODULE = Path(__file__).parent / "fixtures" / "axiom_toy_country.yaml" +FIXTURE_RULESPEC_ROOT = Path(__file__).parent / "fixtures" / "rulespec-zz" +FIXTURE_MODULE = FIXTURE_RULESPEC_ROOT / "zz/policies/tests/axiom_toy_country.yaml" +FIXTURE_RULESPEC_ROOTS = (FIXTURE_RULESPEC_ROOT,) RULESPEC_BE = os.environ.get("POPULACE_RULESPEC_BE") @@ -85,11 +87,16 @@ def _toy_bundle( class TestLazyImport: def test_adapter_constructs_without_the_engine(self) -> None: - adapter = AxiomEngine(FIXTURE_MODULE) + adapter = AxiomEngine(FIXTURE_MODULE, rulespec_roots=FIXTURE_RULESPEC_ROOTS) assert isinstance(adapter, RulesEngine) def test_entity_schema_needs_no_engine(self) -> None: - assert AxiomEngine(FIXTURE_MODULE).entity_schema() == BE_SCHEMA + assert ( + AxiomEngine( + FIXTURE_MODULE, rulespec_roots=FIXTURE_RULESPEC_ROOTS + ).entity_schema() + == BE_SCHEMA + ) def test_export_contract_needs_no_engine(self) -> None: contract = ExportContract( @@ -98,32 +105,106 @@ def test_export_contract_needs_no_engine(self) -> None: optional=(), formula_owned_excluded=(), ) - adapter = AxiomEngine(FIXTURE_MODULE, contract=contract) + adapter = AxiomEngine( + FIXTURE_MODULE, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, + contract=contract, + ) assert adapter.export_contract() is contract @pytest.mark.skipif(_ENGINE_INSTALLED, reason="axiom engine is installed here") def test_engine_methods_describe_installation_when_missing(self) -> None: - adapter = AxiomEngine(FIXTURE_MODULE) + adapter = AxiomEngine(FIXTURE_MODULE, rulespec_roots=FIXTURE_RULESPEC_ROOTS) with pytest.raises(ImportError, match="axiom-rules-engine"): adapter.variable_metadata("toy_income_tax") class TestConstruction: + def test_requires_explicit_rulespec_roots(self) -> None: + with pytest.raises(TypeError, match="rulespec_roots"): + AxiomEngine(FIXTURE_MODULE) # type: ignore[call-arg] + + def test_rejects_empty_rulespec_roots(self) -> None: + with pytest.raises(ValueError, match="at least one explicit rulespec"): + AxiomEngine(FIXTURE_MODULE, rulespec_roots=()) + + @pytest.mark.parametrize( + "roots", [FIXTURE_RULESPEC_ROOT, str(FIXTURE_RULESPEC_ROOT)] + ) + def test_rejects_a_scalar_rulespec_root(self, roots) -> None: + with pytest.raises(TypeError, match="non-empty sequence"): + AxiomEngine(FIXTURE_MODULE, rulespec_roots=roots) + def test_rejects_unknown_arithmetic(self) -> None: with pytest.raises(ValueError, match="arithmetic"): - AxiomEngine(FIXTURE_MODULE, arithmetic="float32") + AxiomEngine( + FIXTURE_MODULE, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, + arithmetic="float32", + ) def test_rejects_entity_names_outside_the_schema(self) -> None: with pytest.raises(ValueError, match="undeclared frame entit"): AxiomEngine( FIXTURE_MODULE, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, entity_names={"person": "Person", "tax_unit": "TaxUnit"}, ) def test_default_entity_names_capitalize(self) -> None: - adapter = AxiomEngine(FIXTURE_MODULE) + adapter = AxiomEngine(FIXTURE_MODULE, rulespec_roots=FIXTURE_RULESPEC_ROOTS) assert adapter._entity_names == {"person": "Person", "household": "Household"} + def test_forwards_exact_roots_and_entity_to_the_dense_loader( + self, monkeypatch + ) -> None: + calls: list[dict[str, object]] = [] + + class RecordingProgram: + derived_metadata: tuple[object, ...] = () + + @classmethod + def from_file(cls, path, *, rulespec_roots, entity): + calls.append( + { + "path": path, + "rulespec_roots": rulespec_roots, + "entity": entity, + } + ) + return cls() + + class RecordingEngine: + CompiledDenseProgram = RecordingProgram + + adapter = AxiomEngine(FIXTURE_MODULE, rulespec_roots=FIXTURE_RULESPEC_ROOTS) + monkeypatch.setattr(adapter, "_import_engine", lambda: RecordingEngine) + + assert adapter._program("person").derived_metadata == () + assert calls == [ + { + "path": FIXTURE_MODULE, + "rulespec_roots": FIXTURE_RULESPEC_ROOTS, + "entity": "Person", + } + ] + + def test_does_not_mask_rulespec_root_validation_errors(self, monkeypatch) -> None: + class RejectingProgram: + @classmethod + def from_file(cls, path, *, rulespec_roots, entity): + raise ValueError("repository root error: root must be canonical") + + class RejectingEngine: + CompiledDenseProgram = RejectingProgram + + adapter = AxiomEngine(FIXTURE_MODULE, rulespec_roots=FIXTURE_RULESPEC_ROOTS) + monkeypatch.setattr(adapter, "_import_engine", lambda: RejectingEngine) + + with pytest.raises(ValueError, match="root must be canonical"): + adapter.variables() + assert adapter._programs == {} + class TestPeriodBounds: def test_year_as_int_and_str(self) -> None: @@ -144,7 +225,7 @@ def test_rejects_other_shapes(self) -> None: class TestVariableMetadata: @pytest.fixture(scope="class") def adapter(self) -> AxiomEngine: - return AxiomEngine(FIXTURE_MODULE) + return AxiomEngine(FIXTURE_MODULE, rulespec_roots=FIXTURE_RULESPEC_ROOTS) def test_person_variable(self, adapter) -> None: meta = adapter.variable_metadata("toy_income_tax") @@ -181,16 +262,14 @@ def test_variables_lists_inputs_not_outputs(self, adapter) -> None: class TestMaterialize: @pytest.fixture(scope="class") def adapter(self) -> AxiomEngine: - return AxiomEngine(FIXTURE_MODULE) + return AxiomEngine(FIXTURE_MODULE, rulespec_roots=FIXTURE_RULESPEC_ROOTS) def test_person_values_row_aligned_and_hand_computed(self, adapter) -> None: bundle = _toy_bundle() results = adapter.materialize(bundle, ["toy_income_tax"], period=2025) # 5,000 * 10% = 500; 10,000 * 10% = 1,000; # 10,000 * 10% + 10,000 * 25% = 3,500. - np.testing.assert_allclose( - results["toy_income_tax"], [500.0, 1_000.0, 3_500.0] - ) + np.testing.assert_allclose(results["toy_income_tax"], [500.0, 1_000.0, 3_500.0]) def test_bool_column_drives_the_exemption_predicate(self, adapter) -> None: bundle = _toy_bundle(exempt=(True, False, True)) @@ -204,9 +283,7 @@ def test_household_values_align_to_the_household_table(self, adapter) -> None: ) assert results["toy_income_tax"].shape == (bundle.n("person"),) assert results["toy_housing_allowance"].shape == (bundle.n("household"),) - np.testing.assert_allclose( - results["toy_housing_allowance"], [1_200.0, 0.0] - ) + np.testing.assert_allclose(results["toy_housing_allowance"], [1_200.0, 0.0]) def test_integer_column_feeds_count_inputs(self, adapter) -> None: bundle = _toy_bundle(children=(0, 2, 1)) @@ -214,12 +291,14 @@ def test_integer_column_feeds_count_inputs(self, adapter) -> None: np.testing.assert_allclose(results["toy_monthly_benefit"], [0.0, 200.0, 100.0]) def test_f64_arithmetic_matches_decimal(self) -> None: - fast = AxiomEngine(FIXTURE_MODULE, arithmetic="f64") + fast = AxiomEngine( + FIXTURE_MODULE, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, + arithmetic="f64", + ) bundle = _toy_bundle() results = fast.materialize(bundle, ["toy_income_tax"], period=2025) - np.testing.assert_allclose( - results["toy_income_tax"], [500.0, 1_000.0, 3_500.0] - ) + np.testing.assert_allclose(results["toy_income_tax"], [500.0, 1_000.0, 3_500.0]) def test_wrong_bundle_entities_are_refused(self, adapter) -> None: person = pd.DataFrame( @@ -251,7 +330,7 @@ def test_object_column_is_refused_with_the_column_named(self, adapter) -> None: @needs_tables class TestWriteDataset: def test_round_trips_and_carries_household_weight(self, tmp_path) -> None: - adapter = AxiomEngine(FIXTURE_MODULE) + adapter = AxiomEngine(FIXTURE_MODULE, rulespec_roots=FIXTURE_RULESPEC_ROOTS) bundle = _toy_bundle() path = tmp_path / "toy.h5" adapter.write_dataset(bundle, path, period=2025) @@ -265,7 +344,7 @@ def test_round_trips_and_carries_household_weight(self, tmp_path) -> None: ] def test_typed_weights_overwrite_a_stale_weight_column(self, tmp_path) -> None: - adapter = AxiomEngine(FIXTURE_MODULE) + adapter = AxiomEngine(FIXTURE_MODULE, rulespec_roots=FIXTURE_RULESPEC_ROOTS) bundle = _toy_bundle() household = bundle.table("household").copy() household["household_weight"] = [999.0, 999.0] @@ -280,7 +359,7 @@ def test_typed_weights_overwrite_a_stale_weight_column(self, tmp_path) -> None: assert reloaded.household["household_weight"].tolist() == [1500.0, 900.0] def test_formula_owned_column_blocks_the_write(self, tmp_path) -> None: - adapter = AxiomEngine(FIXTURE_MODULE) + adapter = AxiomEngine(FIXTURE_MODULE, rulespec_roots=FIXTURE_RULESPEC_ROOTS) bundle = _toy_bundle() person = bundle.table("person").copy() person["toy_income_tax"] = [0.0, 0.0, 0.0] # persisted engine output @@ -301,7 +380,11 @@ def test_missing_required_column_blocks_the_write(self, tmp_path) -> None: optional=(), formula_owned_excluded=(), ) - adapter = AxiomEngine(FIXTURE_MODULE, contract=contract) + adapter = AxiomEngine( + FIXTURE_MODULE, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, + contract=contract, + ) path = tmp_path / "missing.h5" with pytest.raises(ValueError, match="definitely_absent"): adapter.write_dataset(_toy_bundle(), path, period=2025) @@ -315,7 +398,10 @@ def test_defaults_broadcast_onto_the_owning_table(self, tmp_path) -> None: formula_owned_excluded=(), ) adapter = AxiomEngine( - FIXTURE_MODULE, contract=contract, defaults={"toy_default_flag": 0.0} + FIXTURE_MODULE, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, + contract=contract, + defaults={"toy_default_flag": 0.0}, ) path = tmp_path / "defaulted.h5" adapter.write_dataset(_toy_bundle(), path, period=2025) @@ -330,7 +416,11 @@ def test_closed_contract_rejects_unexpected_columns(self, tmp_path) -> None: formula_owned_excluded=(), closed=True, ) - adapter = AxiomEngine(FIXTURE_MODULE, contract=contract) + adapter = AxiomEngine( + FIXTURE_MODULE, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, + contract=contract, + ) path = tmp_path / "closed.h5" with pytest.raises(ValueError, match="unexpected"): adapter.write_dataset(_toy_bundle(), path, period=2025) @@ -343,7 +433,11 @@ def test_forbidden_column_blocks_the_write(self, tmp_path) -> None: optional=(), formula_owned_excluded=(), ) - adapter = AxiomEngine(FIXTURE_MODULE, contract=contract) + adapter = AxiomEngine( + FIXTURE_MODULE, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, + contract=contract, + ) path = tmp_path / "forbidden.h5" with pytest.raises(ValueError, match="toy_child_count"): adapter.write_dataset(_toy_bundle(), path, period=2025) @@ -369,7 +463,11 @@ def test_contract_formula_owned_exclusion_blocks_non_engine_column( optional=(), formula_owned_excluded=("legacy_output",), ) - adapter = AxiomEngine(FIXTURE_MODULE, contract=contract) + adapter = AxiomEngine( + FIXTURE_MODULE, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, + contract=contract, + ) path = tmp_path / "contract_formula_blocked.h5" with pytest.raises(ValueError, match="legacy_output"): adapter.write_dataset(rebuilt, path, period=2025) @@ -401,7 +499,11 @@ def test_formula_owned_exclusion_applies_under_a_closed_contract( formula_owned_excluded=("legacy_output",), closed=True, ) - adapter = AxiomEngine(FIXTURE_MODULE, contract=contract) + adapter = AxiomEngine( + FIXTURE_MODULE, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, + contract=contract, + ) path = tmp_path / "closed_formula_blocked.h5" with pytest.raises(ValueError, match="legacy_output"): adapter.write_dataset(rebuilt, path, period=2025) @@ -463,11 +565,11 @@ class TestBelgianPilotSlice: @pytest.fixture(scope="class") def adapter(self) -> AxiomEngine: - module = ( - Path(RULESPEC_BE) - / "be/statutes/income_tax/individual/rate_scale.yaml" + module = Path(RULESPEC_BE) / "be/statutes/income_tax/individual/rate_scale.yaml" + return AxiomEngine( + module, + rulespec_roots=(Path(RULESPEC_BE),), ) - return AxiomEngine(module) def _be_bundle(self) -> Frame: person = pd.DataFrame( diff --git a/packages/microcosm-frame/tests/test_rules_engine_contract.py b/packages/microcosm-frame/tests/test_rules_engine_contract.py index 5e33340cb..3cfe8c976 100644 --- a/packages/microcosm-frame/tests/test_rules_engine_contract.py +++ b/packages/microcosm-frame/tests/test_rules_engine_contract.py @@ -47,7 +47,8 @@ else: _AXIOM_DENSE = False -_FIXTURE_MODULE = Path(__file__).parent / "fixtures" / "axiom_toy_country.yaml" +_FIXTURE_RULESPEC_ROOT = Path(__file__).parent / "fixtures" / "rulespec-zz" +_FIXTURE_MODULE = _FIXTURE_RULESPEC_ROOT / "zz/policies/tests/axiom_toy_country.yaml" @dataclass(frozen=True) @@ -153,7 +154,10 @@ def _axiom_reload(path: Path) -> Mapping[str, pd.DataFrame]: ), pytest.param( AdapterCase( - make_adapter=lambda: AxiomEngine(_FIXTURE_MODULE), + make_adapter=lambda: AxiomEngine( + _FIXTURE_MODULE, + rulespec_roots=(_FIXTURE_RULESPEC_ROOT,), + ), make_bundle=_axiom_bundle, computed_variables=("toy_income_tax", "toy_housing_allowance"), known_input="toy_taxable_income",