From f07b79fb3a3ff54f76eb9049e9202dd6eb2557e3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 30 Aug 2026 20:33:44 -0400 Subject: [PATCH 1/3] Add authenticated Axiom relation materialization --- changelog.d/axiom-relation-bindings.added.md | 4 + .../src/microcosm/frame/adapters/axiom.py | 937 +++++++++++++++++- .../zz/policies/tests/axiom_toy_relation.yaml | 23 + .../tests/test_axiom_adapter.py | 572 +++++++++++ 4 files changed, 1520 insertions(+), 16 deletions(-) create mode 100644 changelog.d/axiom-relation-bindings.added.md create mode 100644 packages/microcosm-frame/tests/fixtures/rulespec-zz/zz/policies/tests/axiom_toy_relation.yaml diff --git a/changelog.d/axiom-relation-bindings.added.md b/changelog.d/axiom-relation-bindings.added.md new file mode 100644 index 000000000..29ea5913e --- /dev/null +++ b/changelog.d/axiom-relation-bindings.added.md @@ -0,0 +1,4 @@ +Add explicit, fail-closed Axiom dense-relation bindings and deterministic +relation-batch receipts to the Microcosm frame adapter. Cross-entity rules now +execute only when callers bind both frame entities and the exact membership +column; the adapter never infers direction from a relation name. diff --git a/packages/microcosm-frame/src/microcosm/frame/adapters/axiom.py b/packages/microcosm-frame/src/microcosm/frame/adapters/axiom.py index dec26045a..960909ba6 100644 --- a/packages/microcosm-frame/src/microcosm/frame/adapters/axiom.py +++ b/packages/microcosm-frame/src/microcosm/frame/adapters/axiom.py @@ -59,7 +59,10 @@ compiled upstream, not behind a protocol change. """ +import hashlib +import json from collections.abc import Mapping, Sequence +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -71,7 +74,13 @@ from microcosm.frame.rules import ExportContract from microcosm.frame.schema import EntitySchema, VariableMetadata -__all__ = ["AxiomEngine", "AxiomEntityTableDataset", "BE_SCHEMA"] +__all__ = [ + "AxiomEngine", + "AxiomEntityTableDataset", + "AxiomRelationBinding", + "BE_SCHEMA", + "verify_axiom_materialization_receipt", +] #: The Belgian frame schema for the populace-be pilot: persons in households. #: Belgian PIT is individual with household-level elements (joint assessment, @@ -100,6 +109,51 @@ _WEIGHT_COLUMN_SUFFIX = "_weight" +@dataclass(frozen=True) +class AxiomRelationBinding: + """Explicit frame-side edge projection for one Axiom dense relation. + + Axiom's dense schema exposes a relation key and its runtime slots, but it + deliberately does not claim which Microcosm entity tables those slots + represent. Callers therefore bind both sides and the exact edge columns. + The adapter never infers orientation from a relation name such as + ``member_of_household``. + + ``edge_table`` may be either an entity table or a provided link table. One + row names a current id and a related id. This represents both common group + directions without duplicating entities: household aggregation uses the + person table with ``person_household_id -> person_id``; person lookup of a + household value uses the same table with ``person_id -> + person_household_id``. Repeated related ids and current ids with no edge + are valid dense-relation shapes. + + Attributes: + current_entity: Frame entity on which the dense program executes. + related_entity: Frame entity supplying the relation's rows and inputs. + edge_table: Entity or link table carrying the relation edges. + edge_current_id_column: Edge column containing ``current_entity`` ids. + edge_related_id_column: Edge column containing ``related_entity`` ids. + """ + + current_entity: str + related_entity: str + edge_table: str + edge_current_id_column: str + edge_related_id_column: str + + def __post_init__(self) -> None: + for name in ( + "current_entity", + "related_entity", + "edge_table", + "edge_current_id_column", + "edge_related_id_column", + ): + value = getattr(self, name) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"AxiomRelationBinding.{name} must be non-empty.") + + class AxiomEngine: """RulesEngine adapter backed by the Axiom dense vectorized surface. @@ -120,6 +174,11 @@ class AxiomEngine: contract-required columns no bundle table provides. entity_names: Frame entity -> engine entity mapping. Defaults to capitalizing the frame name (``person`` -> ``Person``). + relation_bindings: Explicit frame orientation keyed by the exact Axiom + dense relation key. Each value names the current and related frame + entities plus an explicit edge table and its current/related id + columns. The set must match every executed program's distinct + relation-batch keys. arithmetic: ``"decimal"`` (exact, canonical) or ``"f64"`` (faster, floating-point rounding) — which dense execution mode :meth:`materialize` uses. @@ -138,6 +197,7 @@ def __init__( contract: ExportContract | None = None, defaults: Mapping[str, object] | None = None, entity_names: Mapping[str, str] | None = None, + relation_bindings: Mapping[str, AxiomRelationBinding] | None = None, arithmetic: str = "decimal", ) -> None: if arithmetic not in ("decimal", "f64"): @@ -178,6 +238,36 @@ def __init__( self._frame_entity_by_engine = { engine: frame for frame, engine in self._entity_names.items() } + if relation_bindings is not None and not isinstance(relation_bindings, Mapping): + raise TypeError( + "relation_bindings must map exact Axiom dense relation keys to " + "AxiomRelationBinding values." + ) + self._relation_bindings: dict[str, AxiomRelationBinding] = {} + for key, binding in (relation_bindings or {}).items(): + if not isinstance(key, str) or not key.strip(): + raise ValueError("relation_bindings keys must be non-empty strings.") + if not isinstance(binding, AxiomRelationBinding): + raise TypeError( + f"relation_bindings[{key!r}] must be an AxiomRelationBinding." + ) + for side in (binding.current_entity, binding.related_entity): + if side not in schema.entities: + raise ValueError( + f"Relation binding {key!r} names undeclared frame entity " + f"{side!r}; schema declares {list(schema.entities)}." + ) + declared_edge_tables = set(schema.entities) | { + link.name for link in schema.links + } + if binding.edge_table not in declared_edge_tables: + raise ValueError( + f"Relation binding {key!r} names undeclared edge table " + f"{binding.edge_table!r}; schema declares entity tables " + f"{list(schema.entities)} and link tables " + f"{[link.name for link in schema.links]}." + ) + self._relation_bindings[key] = binding self._programs: dict[str, Any] = {} self._metadata: dict[str, Any] | None = None @@ -228,8 +318,9 @@ def variable_metadata(self, name: str) -> VariableMetadata: def variables(self) -> list[str]: """Return the input variables the engine accepts on a dataset. - The union of the dense root inputs across every mapped engine - entity, sorted. Computed (derived) outputs are not included. + The union of dense root inputs and relation-side inputs across every + mapped engine entity, sorted. Computed (derived) outputs are not + included. Raises: ImportError: If ``axiom_rules_engine`` is not installed. @@ -239,6 +330,8 @@ def variables(self) -> list[str]: program = self._program(frame_entity, missing_ok=True) if program is not None: names.update(program.root_inputs) + for relation in program.relations: + names.update(relation.related_inputs) return sorted(names) def entity_schema(self) -> EntitySchema: @@ -276,30 +369,84 @@ def materialize( ImportError: If ``axiom_rules_engine`` is not installed. ValueError: If the bundle's entities do not match the schema, a requested variable is unknown or an input, or a computed - array's length does not match its entity table. - NotImplementedError: If the compiled module declares relations - (cross-entity aggregation batches; not yet wired — the BE - pilot slice declares none). + array's length does not match its entity table, or a declared + dense relation lacks an exact explicit frame-side binding. + """ + results, _ = self._materialize_with_receipt(bundle, variables, period) + return results + + def materialize_with_receipt( + self, + bundle: Frame, + variables: Sequence[str], + period: int | str, + ) -> tuple[Mapping[str, np.ndarray], Mapping[str, object]]: + """Materialize values and return the exact dense execution receipt. + + The ordinary :meth:`materialize` protocol remains unchanged. Policy + outputs used as calibration measures call this surface so an outer + signed build manifest can authenticate every supplied root input, + relation edge and ordered related input, requested output, entity-row + identity, period, arithmetic mode, and explicit frame/engine mapping. + + The receipt hashes detect drift; authenticity belongs to the signed + outer build manifest that carries the complete receipt. """ + return self._materialize_with_receipt(bundle, variables, period) + + def _materialize_with_receipt( + self, + bundle: Frame, + variables: Sequence[str], + period: int | str, + ) -> tuple[dict[str, np.ndarray], dict[str, object]]: + bundle.revalidate() self._require_schema(bundle) start, end, period_kind = _period_bounds(period) + requested = tuple(variables) + if not requested: + raise ValueError("Axiom materialization requires at least one variable.") + if len(set(requested)) != len(requested): + raise ValueError("Axiom materialization variables must be unique.") by_entity: dict[str, list[str]] = {} - for name in variables: + metadata_by_name: dict[str, VariableMetadata] = {} + for name in requested: metadata = self.variable_metadata(name) + metadata_by_name[name] = metadata by_entity.setdefault(metadata.entity, []).append(name) results: dict[str, np.ndarray] = {} - for frame_entity, names in by_entity.items(): + entity_receipts: dict[str, object] = {} + for frame_entity in sorted(by_entity): + names = sorted(by_entity[frame_entity]) program = self._program(frame_entity) - if program.relations: - raise NotImplementedError( - f"Module {self._module.name!r} declares dense relations " - f"{[item.name for item in program.relations]}; relation " - "batches from frame membership are not wired yet." + expected_root = self._entity_names[frame_entity] + if program.root_entity != expected_root: + raise ValueError( + f"Dense program root {program.root_entity!r} does not match " + f"frame entity {frame_entity!r}'s explicit engine mapping " + f"{expected_root!r}." ) table = bundle.table(frame_entity) - inputs = _batch_from_table(table, program.root_inputs) + current_id_column = self._schema.entity_id_column(frame_entity) + current_ids = _integer_id_vector( + table[current_id_column], + f"{frame_entity}.{current_id_column}", + unique=True, + ) + declared_root_inputs = tuple(program.root_inputs) + if len(set(declared_root_inputs)) != len(declared_root_inputs): + raise ValueError( + f"Dense program for {frame_entity!r} repeats root input names." + ) + inputs = _batch_from_table(table, declared_root_inputs) + relations, relation_receipts = self._relation_batches( + bundle, + frame_entity=frame_entity, + program=program, + current_ids=current_ids, + ) execute = ( program.execute_f64 if self._arithmetic == "f64" else program.execute ) @@ -308,9 +455,11 @@ def materialize( start=start, end=end, inputs=inputs, + relations=relations or None, outputs=list(names), )["outputs"] expected = bundle.n(frame_entity) + output_receipts: dict[str, object] = {} for name in names: values = np.asarray(outputs[name]) if values.shape != (expected,): @@ -320,7 +469,98 @@ def materialize( f"{expected} row(s)." ) results[name] = values - return results + metadata = metadata_by_name[name] + authored = (self._metadata or {}).get(name) + output_receipts[name] = { + "declared_engine_dtype": ( + authored.dtype if authored is not None else metadata.dtype + ), + "declared_kernel_dtype": metadata.dtype, + "declared_period": metadata.period, + "values": _typed_array_identity(values), + } + + entity_receipts[frame_entity] = { + "frame_entity": frame_entity, + "engine_entity": program.root_entity, + "current_id_column": current_id_column, + "current_ids": _array_identity(current_ids), + "declared_root_inputs": list(declared_root_inputs), + "provided_root_inputs": { + name: _array_identity(inputs[name]) for name in sorted(inputs) + }, + "relations": relation_receipts, + "requested_outputs": output_receipts, + } + + period_receipt = {"kind": period_kind, "start": start, "end": end} + input_projection = _input_projection_receipt( + period=period_receipt, + arithmetic=self._arithmetic, + entities=entity_receipts, + ) + receipt: dict[str, object] = { + "schema_version": 2, + "receipt_kind": "axiom_dense_materialization", + "period": period_receipt, + "arithmetic": self._arithmetic, + "entities": entity_receipts, + "input_frame_sha256": _canonical_digest(input_projection), + } + return results, { + **receipt, + "receipt_sha256": _canonical_digest(receipt), + } + + def _relation_batches( + self, + bundle: Frame, + *, + frame_entity: str, + program: Any, + current_ids: np.ndarray, + ) -> tuple[dict[str, Any], dict[str, object]]: + """Build exact dense relation batches and their drift receipt.""" + declared = _group_relation_declarations(program.relations) + configured = { + key: binding + for key, binding in self._relation_bindings.items() + if binding.current_entity == frame_entity + } + missing = sorted(set(declared) - set(configured)) + extra = sorted(set(configured) - set(declared)) + if missing or extra: + raise ValueError( + f"Dense relations for frame entity {frame_entity!r} require an " + "exact explicit binding set; " + f"missing={missing}, extra={extra}." + ) + if not declared: + return {}, {} + + engine = self._import_engine() + batches: dict[str, Any] = {} + receipts: dict[str, object] = {} + + for key in sorted(declared): + binding = configured[key] + offsets, related_inputs, relation_receipt = _relation_projection( + bundle, + relation_key=key, + declarations=declared[key], + binding=binding, + current_ids=current_ids, + ) + + batches[key] = engine.DenseRelationBatch( + offsets=offsets, + inputs=related_inputs, + ) + receipts[key] = { + **relation_receipt, + "receipt_sha256": _canonical_digest(relation_receipt), + } + return batches, receipts # ------------------------------------------------------------------ # Export @@ -741,3 +981,668 @@ def _batch_from_table( "be bool, integer, or float columns." ) return batch + + +def _group_relation_declarations( + relations: Sequence[Any], +) -> dict[str, list[dict[str, object]]]: + """Group Axiom relation schemas by their shared runtime batch key. + + Filtered or composed derived relations legitimately produce more than one + schema declaration backed by the same raw dense-relation batch. The batch + must be supplied once with the union of all declaration inputs. + """ + + grouped: dict[str, list[dict[str, object]]] = {} + for relation in relations: + key = relation.key + if not isinstance(key, str) or not key: + raise ValueError("Dense relation keys must be non-empty strings.") + related_inputs = tuple(relation.related_inputs) + if len(set(related_inputs)) != len(related_inputs) or any( + not isinstance(name, str) or not name for name in related_inputs + ): + raise ValueError( + f"Dense relation {key!r} has invalid/repeated related inputs." + ) + grouped.setdefault(key, []).append( + { + "relation_key": key, + "relation_name": relation.name, + "current_slot": relation.current_slot, + "related_slot": relation.related_slot, + "related_inputs": sorted(related_inputs), + } + ) + for declarations in grouped.values(): + declarations.sort(key=_canonical_json) + return grouped + + +def _edge_table(bundle: Frame, binding: AxiomRelationBinding) -> pd.DataFrame: + if binding.edge_table in bundle.entities: + return bundle.table(binding.edge_table) + if binding.edge_table in bundle.links: + return bundle.link(binding.edge_table) + raise ValueError( + f"Relation edge table {binding.edge_table!r} is not present on the frame; " + f"entity tables={list(bundle.entities)}, provided links={list(bundle.links)}." + ) + + +def _relation_projection( + bundle: Frame, + *, + relation_key: str, + declarations: Sequence[Mapping[str, object]], + binding: AxiomRelationBinding, + current_ids: np.ndarray, +) -> tuple[np.ndarray, dict[str, np.ndarray], dict[str, object]]: + """Project one explicit edge table into an exact Axiom dense batch.""" + + if binding.current_entity not in bundle.entities: + raise ValueError( + f"Relation binding {relation_key!r} current entity " + f"{binding.current_entity!r} is absent from the frame." + ) + related_table = bundle.table(binding.related_entity) + related_id_column = bundle.schema.entity_id_column(binding.related_entity) + related_entity_ids = _integer_id_vector( + related_table[related_id_column], + f"{binding.related_entity}.{related_id_column}", + unique=True, + ) + edge_table = _edge_table(bundle, binding) + for column in ( + binding.edge_current_id_column, + binding.edge_related_id_column, + ): + if column not in edge_table.columns: + raise ValueError( + f"Relation binding {relation_key!r} requires edge column " + f"{column!r} on table {binding.edge_table!r}." + ) + edge_current_ids = _integer_id_vector( + edge_table[binding.edge_current_id_column], + f"{binding.edge_table}.{binding.edge_current_id_column}", + unique=False, + ) + edge_related_ids = _integer_id_vector( + edge_table[binding.edge_related_id_column], + f"{binding.edge_table}.{binding.edge_related_id_column}", + unique=False, + ) + if edge_current_ids.shape != edge_related_ids.shape: + raise ValueError( + f"Relation binding {relation_key!r} edge id columns do not align." + ) + + current_positions = {int(value): i for i, value in enumerate(current_ids)} + related_positions = {int(value): i for i, value in enumerate(related_entity_ids)} + unknown_current = sorted( + set(int(value) for value in edge_current_ids) - current_positions.keys() + ) + if unknown_current: + raise ValueError( + f"Relation binding {relation_key!r} edge references current ids absent " + f"from {binding.current_entity!r}: {unknown_current[:5]}." + ) + unknown_related = sorted( + set(int(value) for value in edge_related_ids) - related_positions.keys() + ) + if unknown_related: + raise ValueError( + f"Relation binding {relation_key!r} edge references related ids absent " + f"from {binding.related_entity!r}: {unknown_related[:5]}." + ) + + positions = np.fromiter( + (current_positions[int(value)] for value in edge_current_ids), + dtype=np.int64, + count=len(edge_current_ids), + ) + counts = np.bincount(positions, minlength=len(current_ids)) + order = np.argsort(positions, kind="stable") + offsets = np.concatenate( + (np.array([0], dtype=np.int64), np.cumsum(counts, dtype=np.int64)) + ) + ordered_edge_current_ids = edge_current_ids[order] + ordered_edge_related_ids = edge_related_ids[order] + related_row_order = np.fromiter( + (related_positions[int(value)] for value in ordered_edge_related_ids), + dtype=np.int64, + count=len(ordered_edge_related_ids), + ) + ordered_related_table = related_table.iloc[related_row_order] + + declared_related_inputs = sorted( + { + name + for declaration in declarations + for name in _declaration_inputs(declaration, relation_key) + } + ) + related_inputs = _batch_from_table(ordered_related_table, declared_related_inputs) + for name, values in related_inputs.items(): + if np.asarray(values).shape != (len(ordered_edge_related_ids),): + raise ValueError( + f"Relation binding {relation_key!r} input {name!r} is not " + "row-aligned to its ordered edge rows." + ) + + normalized_declarations = [dict(item) for item in declarations] + normalized_declarations.sort(key=_canonical_json) + relation_receipt: dict[str, object] = { + "schema_version": 2, + "relation_key": relation_key, + "declarations": normalized_declarations, + "binding": { + "current_entity": binding.current_entity, + "related_entity": binding.related_entity, + "edge_table": binding.edge_table, + "edge_current_id_column": binding.edge_current_id_column, + "edge_related_id_column": binding.edge_related_id_column, + }, + "related_id_column": related_id_column, + "source_related_entity_ids": _array_identity(related_entity_ids), + "source_edge_current_ids": _array_identity(edge_current_ids), + "source_edge_related_ids": _array_identity(edge_related_ids), + "ordered_edge_current_ids": _array_identity(ordered_edge_current_ids), + "ordered_edge_related_ids": _array_identity(ordered_edge_related_ids), + "offsets": _array_identity(offsets), + "declared_related_inputs": declared_related_inputs, + "provided_related_inputs": { + name: _array_identity(related_inputs[name]) + for name in sorted(related_inputs) + }, + } + return offsets, related_inputs, relation_receipt + + +def _declaration_inputs( + declaration: Mapping[str, object], relation_key: str +) -> list[str]: + value = declaration.get("related_inputs") + if not isinstance(value, list) or any( + not isinstance(item, str) or not item for item in value + ): + raise ValueError( + f"Dense relation declaration {relation_key!r} has invalid inputs." + ) + if value != sorted(set(value)): + raise ValueError( + f"Dense relation declaration {relation_key!r} inputs must be " + "unique and sorted." + ) + return value + + +def _input_projection_receipt( + *, + period: Mapping[str, object], + arithmetic: str, + entities: Mapping[str, object], +) -> dict[str, object]: + projected_entities: dict[str, object] = {} + for entity, raw in entities.items(): + if not isinstance(raw, Mapping): + raise ValueError(f"Materialization entity {entity!r} must be an object.") + projected_entities[entity] = { + key: value for key, value in raw.items() if key != "requested_outputs" + } + return { + "schema_version": 2, + "receipt_kind": "axiom_dense_input_projection", + "period": dict(period), + "arithmetic": arithmetic, + "entities": projected_entities, + } + + +def _integer_id_vector( + values: pd.Series, + label: str, + *, + unique: bool, +) -> np.ndarray: + """Return a canonical signed-int64 id vector for a relation receipt.""" + if values.isna().any(): + raise ValueError(f"{label} must not contain missing ids.") + kind = values.dtype.kind + if kind not in ("i", "u"): + raise ValueError( + f"{label} must use an integer dtype for Axiom relation binding, " + f"got dtype kind {kind!r}." + ) + raw = values.to_numpy() + if kind == "u" and raw.size and raw.max() > np.iinfo(np.int64).max: + raise ValueError(f"{label} contains an id outside signed int64 range.") + result = raw.astype(" dict[str, object]: + """Canonical identity for a numeric Axiom input or structural vector.""" + return _typed_array_identity(values) + + +def _typed_array_identity(values: object) -> dict[str, object]: + """Hash a one-dimensional native value vector without object pointers. + + Numeric values use explicit little-endian bytes. Text/date values use + canonical JSON UTF-8, including when NumPy represents them as ``object``. + The semantic dtype name is recorded separately from the canonical storage + encoding so receipts are stable across native byte order. + """ + + array = np.asarray(values) + if array.ndim != 1: + raise ValueError( + f"Axiom receipt vectors must be one-dimensional, got {array.shape}." + ) + kind = array.dtype.kind + if kind in ("b", "i", "u", "f"): + dtype = array.dtype.newbyteorder("<") + canonical = np.ascontiguousarray(array.astype(dtype, copy=False)) + encoding = "little_endian_raw_v1" + semantic_dtype = array.dtype.name + payload = canonical.tobytes() + storage_dtype = canonical.dtype.str + elif kind in ("U", "S", "O"): + items = array.tolist() + if any(not isinstance(item, str) for item in items): + raise ValueError( + "Axiom text/date receipt vectors must contain only strings." + ) + encoding = "canonical_json_utf8_v1" + semantic_dtype = "string" + storage_dtype = "utf8" + payload = json.dumps( + items, + ensure_ascii=True, + separators=(",", ":"), + ).encode("utf-8") + else: + raise ValueError( + f"Axiom receipt vectors cannot canonicalize dtype {array.dtype!s}." + ) + header = { + "dtype": semantic_dtype, + "storage_dtype": storage_dtype, + "encoding": encoding, + "shape": list(array.shape), + } + return { + **header, + "sha256": hashlib.sha256( + _canonical_json(header).encode("utf-8") + b"\n" + payload + ).hexdigest(), + } + + +def verify_axiom_materialization_receipt( + frame: Frame, + receipt: Mapping[str, object], +) -> None: + """Verify a schema-v2 Axiom receipt against the live input frame. + + This verifier does not import or execute Axiom. It revalidates the Frame, + authenticates the closed receipt/hash structure, and reconstructs every + exact root-input and relation-edge projection from the live tables. Output + identities are structurally and cryptographically bound by the receipt; + the signed outer manifest supplies authenticity for those hashes and the + RuleSpec/runtime parameter world. + """ + + frame.revalidate() + top = _exact_mapping( + receipt, + { + "schema_version", + "receipt_kind", + "period", + "arithmetic", + "entities", + "input_frame_sha256", + "receipt_sha256", + }, + "Axiom materialization receipt", + ) + if top["schema_version"] != 2: + raise ValueError("Unsupported Axiom materialization receipt version.") + if top["receipt_kind"] != "axiom_dense_materialization": + raise ValueError("Invalid Axiom materialization receipt kind.") + _require_sha256(top["input_frame_sha256"], "input_frame_sha256") + _require_sha256(top["receipt_sha256"], "receipt_sha256") + unsigned = {key: value for key, value in top.items() if key != "receipt_sha256"} + if _canonical_digest(unsigned) != top["receipt_sha256"]: + raise ValueError("Axiom materialization receipt digest differs.") + arithmetic = top["arithmetic"] + if arithmetic not in ("decimal", "f64"): + raise ValueError("Invalid Axiom materialization arithmetic mode.") + period = _verify_period_receipt(top["period"]) + + raw_entities = top["entities"] + if not isinstance(raw_entities, Mapping) or not raw_entities: + raise ValueError("Axiom materialization receipt needs executed entities.") + recomputed_entities: dict[str, object] = {} + for entity_key in sorted(raw_entities): + if not isinstance(entity_key, str) or not entity_key: + raise ValueError("Axiom receipt entity keys must be non-empty strings.") + entity = _verify_entity_receipt( + frame, + frame_entity=entity_key, + value=raw_entities[entity_key], + ) + recomputed_entities[entity_key] = entity + + input_projection = _input_projection_receipt( + period=period, + arithmetic=arithmetic, + entities=recomputed_entities, + ) + if _canonical_digest(input_projection) != top["input_frame_sha256"]: + raise ValueError("Axiom materialization input-frame projection differs.") + + +def _verify_entity_receipt( + frame: Frame, + *, + frame_entity: str, + value: object, +) -> dict[str, object]: + entity = _exact_mapping( + value, + { + "frame_entity", + "engine_entity", + "current_id_column", + "current_ids", + "declared_root_inputs", + "provided_root_inputs", + "relations", + "requested_outputs", + }, + f"Axiom entity receipt {frame_entity!r}", + ) + if entity["frame_entity"] != frame_entity or frame_entity not in frame.entities: + raise ValueError(f"Axiom receipt frame entity {frame_entity!r} differs.") + if not isinstance(entity["engine_entity"], str) or not entity["engine_entity"]: + raise ValueError( + f"Axiom receipt engine entity for {frame_entity!r} is invalid." + ) + expected_id_column = frame.schema.entity_id_column(frame_entity) + if entity["current_id_column"] != expected_id_column: + raise ValueError(f"Axiom receipt id column for {frame_entity!r} differs.") + current_ids = _integer_id_vector( + frame.table(frame_entity)[expected_id_column], + f"{frame_entity}.{expected_id_column}", + unique=True, + ) + expected_current_identity = _array_identity(current_ids) + _verify_array_identity(entity["current_ids"], f"{frame_entity} current ids") + if entity["current_ids"] != expected_current_identity: + raise ValueError(f"Axiom receipt current ids for {frame_entity!r} differ.") + + declared = entity["declared_root_inputs"] + if not isinstance(declared, list) or any( + not isinstance(name, str) or not name for name in declared + ): + raise ValueError(f"Axiom declared inputs for {frame_entity!r} are invalid.") + if len(set(declared)) != len(declared): + raise ValueError(f"Axiom declared inputs for {frame_entity!r} repeat.") + provided = entity["provided_root_inputs"] + if not isinstance(provided, Mapping): + raise ValueError(f"Axiom provided inputs for {frame_entity!r} are invalid.") + live_inputs = _batch_from_table(frame.table(frame_entity), declared) + expected_inputs = { + name: _array_identity(live_inputs[name]) for name in sorted(live_inputs) + } + for name, identity in provided.items(): + if not isinstance(name, str) or not name: + raise ValueError("Axiom provided input names must be non-empty strings.") + _verify_array_identity(identity, f"root input {name!r}") + if dict(provided) != expected_inputs: + raise ValueError(f"Axiom provided inputs for {frame_entity!r} differ.") + + raw_relations = entity["relations"] + if not isinstance(raw_relations, Mapping): + raise ValueError(f"Axiom relations for {frame_entity!r} are invalid.") + relations: dict[str, object] = {} + for key in sorted(raw_relations): + relation = _verify_relation_receipt( + frame, + frame_entity=frame_entity, + relation_key=key, + current_ids=current_ids, + value=raw_relations[key], + ) + relations[key] = relation + + outputs = entity["requested_outputs"] + if not isinstance(outputs, Mapping) or not outputs: + raise ValueError(f"Axiom outputs for {frame_entity!r} are invalid.") + normalized_outputs: dict[str, object] = {} + for name in sorted(outputs): + if not isinstance(name, str) or not name: + raise ValueError("Axiom output names must be non-empty strings.") + output = _exact_mapping( + outputs[name], + { + "declared_engine_dtype", + "declared_kernel_dtype", + "declared_period", + "values", + }, + f"Axiom output {name!r}", + ) + for metadata_key in ( + "declared_engine_dtype", + "declared_kernel_dtype", + "declared_period", + ): + if not isinstance(output[metadata_key], str) or not output[metadata_key]: + raise ValueError(f"Axiom output {name!r} metadata is invalid.") + _verify_array_identity(output["values"], f"output {name!r}") + normalized_outputs[name] = output + + return { + "frame_entity": frame_entity, + "engine_entity": entity["engine_entity"], + "current_id_column": expected_id_column, + "current_ids": expected_current_identity, + "declared_root_inputs": declared, + "provided_root_inputs": expected_inputs, + "relations": relations, + "requested_outputs": normalized_outputs, + } + + +def _verify_relation_receipt( + frame: Frame, + *, + frame_entity: str, + relation_key: object, + current_ids: np.ndarray, + value: object, +) -> dict[str, object]: + if not isinstance(relation_key, str) or not relation_key: + raise ValueError("Axiom relation receipt keys must be non-empty strings.") + relation = _exact_mapping( + value, + { + "schema_version", + "relation_key", + "declarations", + "binding", + "related_id_column", + "source_related_entity_ids", + "source_edge_current_ids", + "source_edge_related_ids", + "ordered_edge_current_ids", + "ordered_edge_related_ids", + "offsets", + "declared_related_inputs", + "provided_related_inputs", + "receipt_sha256", + }, + f"Axiom relation receipt {relation_key!r}", + ) + if relation["schema_version"] != 2 or relation["relation_key"] != relation_key: + raise ValueError(f"Axiom relation receipt {relation_key!r} identity differs.") + _require_sha256(relation["receipt_sha256"], "relation receipt_sha256") + unsigned = {key: item for key, item in relation.items() if key != "receipt_sha256"} + if _canonical_digest(unsigned) != relation["receipt_sha256"]: + raise ValueError(f"Axiom relation receipt {relation_key!r} digest differs.") + raw_declarations = relation["declarations"] + if not isinstance(raw_declarations, list) or not raw_declarations: + raise ValueError(f"Axiom relation {relation_key!r} needs declarations.") + declarations: list[dict[str, object]] = [] + for raw in raw_declarations: + declaration = _exact_mapping( + raw, + { + "relation_key", + "relation_name", + "current_slot", + "related_slot", + "related_inputs", + }, + f"Axiom relation declaration {relation_key!r}", + ) + if declaration["relation_key"] != relation_key: + raise ValueError(f"Axiom relation declaration {relation_key!r} differs.") + if ( + not isinstance(declaration["relation_name"], str) + or not declaration["relation_name"] + ): + raise ValueError(f"Axiom relation {relation_key!r} name is invalid.") + for slot in ("current_slot", "related_slot"): + if type(declaration[slot]) is not int or declaration[slot] < 0: + raise ValueError(f"Axiom relation {relation_key!r} slot is invalid.") + _declaration_inputs(declaration, relation_key) + declarations.append(declaration) + declarations.sort(key=_canonical_json) + if declarations != raw_declarations: + raise ValueError(f"Axiom relation {relation_key!r} declarations are unsorted.") + + binding_data = _exact_mapping( + relation["binding"], + { + "current_entity", + "related_entity", + "edge_table", + "edge_current_id_column", + "edge_related_id_column", + }, + f"Axiom relation binding {relation_key!r}", + ) + binding = AxiomRelationBinding(**binding_data) + if binding.current_entity != frame_entity: + raise ValueError(f"Axiom relation {relation_key!r} current entity differs.") + _, _, projected = _relation_projection( + frame, + relation_key=relation_key, + declarations=declarations, + binding=binding, + current_ids=current_ids, + ) + expected = { + **projected, + "receipt_sha256": _canonical_digest(projected), + } + for identity_key in ( + "source_related_entity_ids", + "source_edge_current_ids", + "source_edge_related_ids", + "ordered_edge_current_ids", + "ordered_edge_related_ids", + "offsets", + ): + _verify_array_identity(relation[identity_key], identity_key) + provided = relation["provided_related_inputs"] + if not isinstance(provided, Mapping): + raise ValueError(f"Axiom relation {relation_key!r} inputs are invalid.") + for name, identity in provided.items(): + _verify_array_identity(identity, f"related input {name!r}") + if relation != expected: + raise ValueError(f"Axiom relation receipt {relation_key!r} differs live.") + return expected + + +def _verify_period_receipt(value: object) -> dict[str, object]: + period = _exact_mapping(value, {"kind", "start", "end"}, "Axiom period") + kind, start, end = period["kind"], period["start"], period["end"] + if not all(isinstance(item, str) and item for item in (kind, start, end)): + raise ValueError("Axiom period fields must be non-empty strings.") + if kind == "calendar_year" and len(start) == 10: + year = start[:4] + expected = _period_bounds(year) + elif kind == "month" and len(start) == 10: + expected = _period_bounds(start[:7]) + else: + raise ValueError("Axiom receipt period kind is invalid.") + if (start, end, kind) != expected: + raise ValueError("Axiom receipt period bounds differ.") + return period + + +def _verify_array_identity(value: object, label: str) -> None: + identity = _exact_mapping( + value, + {"dtype", "storage_dtype", "encoding", "shape", "sha256"}, + f"Axiom array identity {label}", + ) + for key in ("dtype", "storage_dtype", "encoding"): + if not isinstance(identity[key], str) or not identity[key]: + raise ValueError(f"Axiom array identity {label} {key} is invalid.") + shape = identity["shape"] + if ( + not isinstance(shape, list) + or len(shape) != 1 + or type(shape[0]) is not int + or shape[0] < 0 + ): + raise ValueError(f"Axiom array identity {label} shape is invalid.") + _require_sha256(identity["sha256"], f"array identity {label}") + + +def _exact_mapping( + value: object, + keys: set[str], + label: str, +) -> dict[str, object]: + if not isinstance(value, Mapping): + raise ValueError(f"{label} must be an object.") + result = dict(value) + if set(result) != keys: + raise ValueError( + f"{label} keys differ: expected {sorted(keys)}, got {sorted(result)}." + ) + return result + + +def _require_sha256(value: object, label: str) -> None: + if ( + not isinstance(value, str) + or len(value) != 64 + or any(character not in "0123456789abcdef" for character in value) + ): + raise ValueError(f"{label} must be a lowercase SHA-256 identity.") + + +def _canonical_json(value: object) -> str: + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ) + + +def _canonical_digest(value: Mapping[str, object]) -> str: + """SHA-256 of canonical JSON receipt data.""" + payload = _canonical_json(value).encode("utf-8") + return hashlib.sha256(payload).hexdigest() diff --git a/packages/microcosm-frame/tests/fixtures/rulespec-zz/zz/policies/tests/axiom_toy_relation.yaml b/packages/microcosm-frame/tests/fixtures/rulespec-zz/zz/policies/tests/axiom_toy_relation.yaml new file mode 100644 index 000000000..6787ab79a --- /dev/null +++ b/packages/microcosm-frame/tests/fixtures/rulespec-zz/zz/policies/tests/axiom_toy_relation.yaml @@ -0,0 +1,23 @@ +# Self-contained relation fixture for the Axiom adapter. The household rule +# executes in the real Axiom dense runtime and sums a person-table input through +# the explicitly bound member_of_household relation. +format: rulespec/v1 +module: + summary: |- + microcosm-frame relation fixture: household income is the exact sum of the + related persons' toy taxable incomes. +units: + - name: EUR + kind: currency + minor_units: 2 +rules: + - name: toy_household_income + kind: derived + entity: Household + dtype: Money + period: Year + unit: EUR + source: populace-frame relation fixture + versions: + - effective_from: '2025-01-01' + formula: sum(member_of_household.toy_taxable_income) diff --git a/packages/microcosm-frame/tests/test_axiom_adapter.py b/packages/microcosm-frame/tests/test_axiom_adapter.py index cd9c07f32..a4316a2c7 100644 --- a/packages/microcosm-frame/tests/test_axiom_adapter.py +++ b/packages/microcosm-frame/tests/test_axiom_adapter.py @@ -14,8 +14,10 @@ """ import importlib.util +import json import os from pathlib import Path +from types import SimpleNamespace import numpy as np import pandas as pd @@ -25,7 +27,9 @@ EntitySchema, ExportContract, Frame, + LinkSpec, RulesEngine, + VariableMetadata, WeightKind, Weights, ) @@ -33,7 +37,9 @@ BE_SCHEMA, AxiomEngine, AxiomEntityTableDataset, + AxiomRelationBinding, _period_bounds, + verify_axiom_materialization_receipt, ) _ENGINE_INSTALLED = importlib.util.find_spec("axiom_rules_engine") is not None @@ -56,6 +62,9 @@ FIXTURE_RULESPEC_ROOT = Path(__file__).parent / "fixtures" / "rulespec-zz" FIXTURE_MODULE = FIXTURE_RULESPEC_ROOT / "zz/policies/tests/axiom_toy_country.yaml" +FIXTURE_RELATION_MODULE = ( + FIXTURE_RULESPEC_ROOT / "zz/policies/tests/axiom_toy_relation.yaml" +) FIXTURE_RULESPEC_ROOTS = (FIXTURE_RULESPEC_ROOT,) RULESPEC_BE = os.environ.get("POPULACE_RULESPEC_BE") @@ -155,6 +164,35 @@ def test_default_entity_names_capitalize(self) -> None: adapter = AxiomEngine(FIXTURE_MODULE, rulespec_roots=FIXTURE_RULESPEC_ROOTS) assert adapter._entity_names == {"person": "Person", "household": "Household"} + def test_relation_bindings_require_declared_entities(self) -> None: + with pytest.raises(ValueError, match="undeclared frame entity"): + AxiomEngine( + FIXTURE_MODULE, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, + relation_bindings={ + "member_of_household:1:0": AxiomRelationBinding( + current_entity="tax_unit", + related_entity="person", + edge_table="person", + edge_current_id_column="person_tax_unit_id", + edge_related_id_column="person_id", + ) + }, + ) + + def test_relation_bindings_require_typed_values(self) -> None: + with pytest.raises(TypeError, match="AxiomRelationBinding"): + AxiomEngine( + FIXTURE_MODULE, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, + relation_bindings={ + "member_of_household:1:0": { + "current_entity": "household", + "related_entity": "person", + } + }, # type: ignore[dict-item] + ) + def test_forwards_exact_roots_and_entity_to_the_dense_loader( self, monkeypatch ) -> None: @@ -206,6 +244,540 @@ class RejectingEngine: assert adapter._programs == {} +class _FakeDenseRelationBatch: + def __init__(self, *, offsets, inputs) -> None: + self.offsets = offsets + self.inputs = inputs + + +class _RecordingRelationProgram: + root_entity = "Household" + root_inputs: tuple[str, ...] = () + + def __init__(self, *, related_inputs=("toy_taxable_income",)) -> None: + self.relations = [ + SimpleNamespace( + key="member_of_household:1:0", + name="member_of_household", + current_slot=1, + related_slot=0, + related_inputs=tuple(related_inputs), + ) + ] + self.last_relations = None + + def execute(self, *, relations, outputs, **_kwargs): + self.last_relations = relations + relation = relations["member_of_household:1:0"] + missing = { + name + for declaration in self.relations + for name in declaration.related_inputs + } - set(relation.inputs) + if missing: + raise ValueError(f"missing dense relation input(s): {sorted(missing)}") + incomes = relation.inputs["toy_taxable_income"] + totals = np.array( + [ + incomes[relation.offsets[i] : relation.offsets[i + 1]].sum() + for i in range(len(relation.offsets) - 1) + ] + ) + assert outputs == ["toy_household_income"] + return {"outputs": {"toy_household_income": totals}} + + execute_f64 = execute + + +class _RecordingLookupProgram: + root_entity = "Person" + root_inputs: tuple[str, ...] = () + relations = [ + SimpleNamespace( + key="member_of_household:0:1", + name="member_of_household", + current_slot=0, + related_slot=1, + related_inputs=("toy_household_rent",), + ) + ] + + def execute(self, *, relations, outputs, **_kwargs): + relation = relations["member_of_household:0:1"] + assert relation.offsets.tolist() == [0, 1, 2, 3] + assert outputs == ["toy_person_household_rent"] + return { + "outputs": { + "toy_person_household_rent": relation.inputs["toy_household_rent"] + } + } + + execute_f64 = execute + + +def _relation_bundle(*, alternate_membership=None) -> Frame: + # Interleave households so relation construction must perform a stable + # group without assuming person-table order already matches the root. + person = pd.DataFrame( + { + "person_id": [3, 1, 2], + "person_household_id": [2, 1, 1], + "toy_taxable_income": [20_000.0, 5_000.0, 10_000.0], + "toy_is_eligible": [True, False, True], + } + ) + if alternate_membership is not None: + person["explicit_relation_household_id"] = alternate_membership + household = pd.DataFrame( + {"household_id": [1, 2], "toy_household_rent": [100.0, 200.0]} + ) + return Frame( + {"person": person, "household": household}, + BE_SCHEMA, + {"household": Weights(values=np.array([1.0, 1.0]), kind=WeightKind.DESIGN)}, + ) + + +def _relation_adapter(monkeypatch, *, binding=None, program=None) -> AxiomEngine: + if binding is None: + binding = AxiomRelationBinding( + current_entity="household", + related_entity="person", + edge_table="person", + edge_current_id_column="person_household_id", + edge_related_id_column="person_id", + ) + adapter = AxiomEngine( + FIXTURE_MODULE, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, + relation_bindings={"member_of_household:1:0": binding}, + ) + relation_program = program or _RecordingRelationProgram() + monkeypatch.setattr(adapter, "_program", lambda _entity: relation_program) + monkeypatch.setattr( + adapter, + "variable_metadata", + lambda name: VariableMetadata( + name=name, entity="household", dtype="float", period="year" + ), + ) + monkeypatch.setattr( + adapter, + "_import_engine", + lambda: SimpleNamespace(DenseRelationBatch=_FakeDenseRelationBatch), + ) + return adapter + + +class TestRelationBindings: + def test_materializes_stable_dense_batch_and_receipts_exact_order( + self, monkeypatch + ) -> None: + program = _RecordingRelationProgram() + adapter = _relation_adapter(monkeypatch, program=program) + outputs, receipt = adapter.materialize_with_receipt( + _relation_bundle(), ["toy_household_income"], period=2025 + ) + + np.testing.assert_allclose(outputs["toy_household_income"], [15_000, 20_000]) + relation = program.last_relations["member_of_household:1:0"] + assert relation.offsets.tolist() == [0, 2, 3] + assert relation.inputs["toy_taxable_income"].tolist() == [ + 5_000.0, + 10_000.0, + 20_000.0, + ] + entity = receipt["entities"]["household"] + evidence = entity["relations"]["member_of_household:1:0"] + assert evidence["binding"]["current_entity"] == "household" + assert evidence["binding"]["related_entity"] == "person" + assert evidence["binding"]["edge_table"] == "person" + assert evidence["binding"]["edge_current_id_column"] == "person_household_id" + assert evidence["offsets"]["shape"] == [3] + assert len(evidence["receipt_sha256"]) == 64 + assert len(receipt["input_frame_sha256"]) == 64 + assert len(receipt["receipt_sha256"]) == 64 + verify_axiom_materialization_receipt(_relation_bundle(), receipt) + + _, repeated = adapter.materialize_with_receipt( + _relation_bundle(), ["toy_household_income"], period=2025 + ) + assert repeated == receipt + + def test_declared_relation_without_binding_fails_closed(self, monkeypatch) -> None: + adapter = AxiomEngine( + FIXTURE_MODULE, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, + ) + program = _RecordingRelationProgram() + monkeypatch.setattr(adapter, "_program", lambda _entity: program) + monkeypatch.setattr( + adapter, + "variable_metadata", + lambda name: VariableMetadata( + name=name, entity="household", dtype="float", period="year" + ), + ) + with pytest.raises(ValueError, match="missing=.*member_of_household"): + adapter.materialize( + _relation_bundle(), ["toy_household_income"], period=2025 + ) + + def test_binding_for_undeclared_program_relation_fails_closed( + self, monkeypatch + ) -> None: + program = _RecordingRelationProgram() + program.relations = [] + adapter = _relation_adapter(monkeypatch, program=program) + with pytest.raises(ValueError, match="extra=.*member_of_household"): + adapter.materialize( + _relation_bundle(), ["toy_household_income"], period=2025 + ) + + def test_duplicate_runtime_key_unions_declaration_inputs(self, monkeypatch) -> None: + program = _RecordingRelationProgram() + program.relations.append( + SimpleNamespace( + key="member_of_household:1:0", + name="member_of_household", + current_slot=1, + related_slot=0, + related_inputs=("toy_is_eligible",), + ) + ) + adapter = _relation_adapter(monkeypatch, program=program) + _, receipt = adapter.materialize_with_receipt( + _relation_bundle(), ["toy_household_income"], period=2025 + ) + batch = program.last_relations["member_of_household:1:0"] + assert set(batch.inputs) == {"toy_taxable_income", "toy_is_eligible"} + relation = receipt["entities"]["household"]["relations"][ + "member_of_household:1:0" + ] + assert len(relation["declarations"]) == 2 + assert set(relation["provided_related_inputs"]) == { + "toy_taxable_income", + "toy_is_eligible", + } + + def test_current_to_related_lookup_uses_explicit_edge(self, monkeypatch) -> None: + program = _RecordingLookupProgram() + adapter = AxiomEngine( + FIXTURE_MODULE, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, + relation_bindings={ + "member_of_household:0:1": AxiomRelationBinding( + current_entity="person", + related_entity="household", + edge_table="person", + edge_current_id_column="person_id", + edge_related_id_column="person_household_id", + ) + }, + ) + monkeypatch.setattr(adapter, "_program", lambda _entity: program) + monkeypatch.setattr( + adapter, + "variable_metadata", + lambda name: VariableMetadata( + name=name, entity="person", dtype="float", period="year" + ), + ) + monkeypatch.setattr( + adapter, + "_import_engine", + lambda: SimpleNamespace(DenseRelationBatch=_FakeDenseRelationBatch), + ) + outputs, receipt = adapter.materialize_with_receipt( + _relation_bundle(), ["toy_person_household_rent"], period=2025 + ) + np.testing.assert_allclose( + outputs["toy_person_household_rent"], [200.0, 100.0, 100.0] + ) + verify_axiom_materialization_receipt(_relation_bundle(), receipt) + + def test_declared_link_table_can_supply_explicit_edges(self, monkeypatch) -> None: + schema = EntitySchema( + group_entities=("household",), + links=( + LinkSpec( + name="household_members", + left_entity="household", + right_entity="person", + ), + ), + ) + source = _relation_bundle() + frame = Frame( + { + "person": source.table("person"), + "household": source.table("household"), + "household_members": pd.DataFrame( + { + "household_id": [1, 1, 2], + "person_id": [1, 2, 3], + } + ), + }, + schema, + {"household": source.weights_for("household")}, + ) + adapter = AxiomEngine( + FIXTURE_MODULE, + schema=schema, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, + relation_bindings={ + "member_of_household:1:0": AxiomRelationBinding( + current_entity="household", + related_entity="person", + edge_table="household_members", + edge_current_id_column="household_id", + edge_related_id_column="person_id", + ) + }, + ) + program = _RecordingRelationProgram() + monkeypatch.setattr(adapter, "_program", lambda _entity: program) + monkeypatch.setattr( + adapter, + "variable_metadata", + lambda name: VariableMetadata( + name=name, entity="household", dtype="float", period="year" + ), + ) + monkeypatch.setattr( + adapter, + "_import_engine", + lambda: SimpleNamespace(DenseRelationBatch=_FakeDenseRelationBatch), + ) + + outputs, receipt = adapter.materialize_with_receipt( + frame, ["toy_household_income"], period=2025 + ) + np.testing.assert_allclose(outputs["toy_household_income"], [15_000, 20_000]) + relation = receipt["entities"]["household"]["relations"][ + "member_of_household:1:0" + ] + assert relation["binding"]["edge_table"] == "household_members" + verify_axiom_materialization_receipt(frame, receipt) + + frame.link("household_members").loc[0, "person_id"] = 3 + with pytest.raises(ValueError, match="relation receipt"): + verify_axiom_materialization_receipt(frame, receipt) + + @pytest.mark.parametrize( + ("memberships", "message"), + [ + ([1, 1, 999], "current ids absent from 'household'"), + ([1.0, 1.0, 2.0], "integer dtype"), + ], + ) + def test_invalid_explicit_membership_fails_closed( + self, monkeypatch, memberships, message + ) -> None: + binding = AxiomRelationBinding( + current_entity="household", + related_entity="person", + edge_table="person", + edge_current_id_column="explicit_relation_household_id", + edge_related_id_column="person_id", + ) + adapter = _relation_adapter(monkeypatch, binding=binding) + with pytest.raises(ValueError, match=message): + adapter.materialize( + _relation_bundle(alternate_membership=memberships), + ["toy_household_income"], + period=2025, + ) + + def test_current_row_with_no_related_edge_is_valid(self, monkeypatch) -> None: + binding = AxiomRelationBinding( + current_entity="household", + related_entity="person", + edge_table="person", + edge_current_id_column="explicit_relation_household_id", + edge_related_id_column="person_id", + ) + adapter = _relation_adapter(monkeypatch, binding=binding) + outputs, receipt = adapter.materialize_with_receipt( + _relation_bundle(alternate_membership=[1, 1, 1]), + ["toy_household_income"], + period=2025, + ) + np.testing.assert_allclose(outputs["toy_household_income"], [35_000, 0]) + relation = receipt["entities"]["household"]["relations"][ + "member_of_household:1:0" + ] + assert relation["offsets"]["shape"] == [3] + + def test_missing_related_input_fails_closed(self, monkeypatch) -> None: + adapter = _relation_adapter( + monkeypatch, + program=_RecordingRelationProgram(related_inputs=("missing_income",)), + ) + with pytest.raises(ValueError, match="missing_income"): + adapter.materialize( + _relation_bundle(), ["toy_household_income"], period=2025 + ) + + def test_live_verifier_detects_input_and_receipt_tampering( + self, monkeypatch + ) -> None: + adapter = _relation_adapter(monkeypatch) + frame = _relation_bundle() + _, receipt = adapter.materialize_with_receipt( + frame, ["toy_household_income"], period=2025 + ) + verify_axiom_materialization_receipt(frame, receipt) + + frame.table("person").loc[0, "toy_taxable_income"] = 99_999.0 + with pytest.raises(ValueError, match="provided inputs|relation receipt"): + verify_axiom_materialization_receipt(frame, receipt) + + clean_frame = _relation_bundle() + forged = json.loads(json.dumps(receipt)) + forged["entities"]["household"]["requested_outputs"]["toy_household_income"][ + "values" + ]["sha256"] = "0" * 64 + with pytest.raises(ValueError, match="receipt digest differs"): + verify_axiom_materialization_receipt(clean_frame, forged) + + forged = json.loads(json.dumps(receipt)) + forged["period"]["end"] = "2025-12-30" + with pytest.raises(ValueError, match="receipt digest differs"): + verify_axiom_materialization_receipt(clean_frame, forged) + + def test_materialize_revalidates_mutated_frame(self, monkeypatch) -> None: + adapter = _relation_adapter(monkeypatch) + frame = _relation_bundle() + frame.table("household").iloc[:] = ( + frame.table("household").iloc[::-1].to_numpy() + ) + with pytest.raises(ValueError, match="must be sorted ascending"): + adapter.materialize(frame, ["toy_household_income"], period=2025) + + def test_text_output_receipt_uses_canonical_values(self, monkeypatch) -> None: + program = SimpleNamespace( + root_entity="Person", + root_inputs=(), + relations=[], + execute=lambda **_kwargs: { + "outputs": {"toy_status": ["eligible", "ineligible", "eligible"]} + }, + ) + program.execute_f64 = program.execute + adapter = AxiomEngine( + FIXTURE_MODULE, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, + ) + monkeypatch.setattr(adapter, "_program", lambda _entity: program) + monkeypatch.setattr( + adapter, + "variable_metadata", + lambda name: VariableMetadata( + name=name, entity="person", dtype="str", period="point" + ), + ) + outputs, receipt = adapter.materialize_with_receipt( + _relation_bundle(), ["toy_status"], period=2025 + ) + assert outputs["toy_status"].tolist() == [ + "eligible", + "ineligible", + "eligible", + ] + identity = receipt["entities"]["person"]["requested_outputs"]["toy_status"][ + "values" + ] + assert identity["encoding"] == "canonical_json_utf8_v1" + assert identity["dtype"] == "string" + verify_axiom_materialization_receipt(_relation_bundle(), receipt) + + +@needs_engine +class TestRelationBindingsWithRealAxiom: + def test_root_input_and_output_change_materialization_receipt(self) -> None: + adapter = AxiomEngine( + FIXTURE_MODULE, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, + ) + baseline = _toy_bundle() + changed = _toy_bundle(incomes=(9_000.0, 10_000.0, 20_000.0)) + baseline_outputs, baseline_receipt = adapter.materialize_with_receipt( + baseline, ["toy_income_tax"], period=2025 + ) + changed_outputs, changed_receipt = adapter.materialize_with_receipt( + changed, ["toy_income_tax"], period=2025 + ) + assert baseline_outputs["toy_income_tax"][0] == 500.0 + assert changed_outputs["toy_income_tax"][0] == 900.0 + assert ( + baseline_receipt["input_frame_sha256"] + != changed_receipt["input_frame_sha256"] + ) + assert baseline_receipt["receipt_sha256"] != changed_receipt["receipt_sha256"] + verify_axiom_materialization_receipt(baseline, baseline_receipt) + verify_axiom_materialization_receipt(changed, changed_receipt) + + def test_household_sum_executes_in_the_real_dense_runtime(self) -> None: + adapter = AxiomEngine( + FIXTURE_RELATION_MODULE, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, + relation_bindings={ + "member_of_household:1:0": AxiomRelationBinding( + current_entity="household", + related_entity="person", + edge_table="person", + edge_current_id_column="person_household_id", + edge_related_id_column="person_id", + ) + }, + ) + outputs, receipt = adapter.materialize_with_receipt( + _relation_bundle(), ["toy_household_income"], period=2025 + ) + np.testing.assert_allclose(outputs["toy_household_income"], [15_000, 20_000]) + assert receipt["entities"]["household"]["relations"] + verify_axiom_materialization_receipt(_relation_bundle(), receipt) + + def test_real_dense_runtime_accepts_zero_cardinality_current_row(self) -> None: + adapter = AxiomEngine( + FIXTURE_RELATION_MODULE, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, + relation_bindings={ + "member_of_household:1:0": AxiomRelationBinding( + current_entity="household", + related_entity="person", + edge_table="person", + edge_current_id_column="explicit_relation_household_id", + edge_related_id_column="person_id", + ) + }, + ) + frame = _relation_bundle(alternate_membership=[1, 1, 1]) + outputs, receipt = adapter.materialize_with_receipt( + frame, ["toy_household_income"], period=2025 + ) + np.testing.assert_allclose(outputs["toy_household_income"], [35_000, 0]) + verify_axiom_materialization_receipt(frame, receipt) + + def test_relation_side_input_is_part_of_dataset_input_surface(self) -> None: + adapter = AxiomEngine( + FIXTURE_RELATION_MODULE, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, + relation_bindings={ + "member_of_household:1:0": AxiomRelationBinding( + current_entity="household", + related_entity="person", + edge_table="person", + edge_current_id_column="person_household_id", + edge_related_id_column="person_id", + ) + }, + ) + assert adapter.variables() == ["toy_taxable_income"] + + class TestPeriodBounds: def test_year_as_int_and_str(self) -> None: assert _period_bounds(2025) == ("2025-01-01", "2025-12-31", "calendar_year") From ce503836e07d573f2abeb2ea2ff238af6e95d9fa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 30 Aug 2026 21:19:48 -0400 Subject: [PATCH 2/3] Initialize Axiom relation repair journal --- PROGRESS.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/PROGRESS.md b/PROGRESS.md index c6c9f696b..e49c5851d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,3 +1,34 @@ +# Axiom relation-adapter review repair + +## State + +In progress on 2026-08-30 from exact commit `f07b79fb`. This branch is being +repaired locally only; no push, PR, merge, publication, or external Axiom +repository change is authorized. + +## Done + +- Read `AGENTS.md`, `CLAUDE.md`, and the GitNexus debugging workflow. +- Confirmed the worktree was clean at `f07b79fb` on + `feature/axiom-relation-bindings`, one commit above `origin/main`. +- Read `/tmp/armenia-relation-review.md` and recorded all six required repair + areas: native duplicate-key behavior, owned execution snapshots and live + revalidation, general receipt IDs, naturally empty link tables, live output + authentication, and ordinary materialize sequence compatibility. +- Confirmed GitNexus graph tools are unavailable in this session; source and + call-site tracing is the documented fallback. + +## Next + +- Reproduce each finding in focused tests and inspect the installed Axiom + runtime's actual dense-relation conversion boundary. +- Implement fail-closed native capability handling plus the five remaining + compatibility/authentication repairs without changing the Axiom repository. +- Add adversarial and native integration coverage, then run focused relation + tests, real-Axiom cases, Ruff, and `git diff --check`. + +--- + # ACS predictor release join > **Historical note (2026-08-28).** This journal describes the From 952952a1240c127d5c5d2c6458e3a69ca95dd3b5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 31 Aug 2026 06:24:33 -0400 Subject: [PATCH 3/3] Harden authenticated Axiom relation materialization --- PROGRESS.md | 49 ++- changelog.d/axiom-relation-bindings.added.md | 2 + .../src/microcosm/frame/adapters/axiom.py | 293 +++++++++++++-- .../tests/axiom_toy_duplicate_relation.yaml | 52 +++ .../tests/test_axiom_adapter.py | 355 ++++++++++++++++-- 5 files changed, 673 insertions(+), 78 deletions(-) create mode 100644 packages/microcosm-frame/tests/fixtures/rulespec-zz/zz/policies/tests/axiom_toy_duplicate_relation.yaml diff --git a/PROGRESS.md b/PROGRESS.md index e49c5851d..d79d410d1 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,9 +2,9 @@ ## State -In progress on 2026-08-30 from exact commit `f07b79fb`. This branch is being -repaired locally only; no push, PR, merge, publication, or external Axiom -repository change is authorized. +Complete locally on 2026-08-31 from exact adapter commit `f07b79fb`. All six +review findings are repaired or fail closed at their true owner boundary. No +push, PR, merge, publication, or external Axiom repository change occurred. ## Done @@ -17,15 +17,46 @@ repository change is authorized. authentication, and ordinary materialize sequence compatibility. - Confirmed GitNexus graph tools are unavailable in this session; source and call-site tracing is the documented fallback. +- Confirmed the live branch remained based on current `origin/main` at + `d1e3e397`; the only intervening commit was this lane's repair journal. +- Reproduced the native defect in an actual compiled RuleSpec: a filtered + derived relation and its source relation expose two PyO3 schemas with the + same batch key and disjoint inputs. The current Rust conversion overwrites + one schema. Microcosm now rejects this exact unsafe shape before execution, + with a native regression, until Axiom exposes a safe union capability. +- Moved all root and related dense inputs onto owned NumPy snapshots, hashed + them before execution, and re-hashes them afterward. The adapter revalidates + the live Frame after execution and verifies the complete live input/output + receipt before returning. Adversarial tests cover root-snapshot mutation, + relation-snapshot mutation, and concurrent live-frame mutation. +- Generalized receipt and relation IDs from signed-int64-only to homogeneous + integer or string IDs. Integers retain the existing canonical int64 bytes; + strings use canonical JSON UTF-8. Relation matching uses internal positions, + so relation-free and relation-backed string-keyed Frames both materialize. +- Canonicalized naturally empty object-typed edge columns against the linked + entity ID family. Both fake and real Axiom executions accept an empty link + table and return a valid all-zero relation result. +- Made live output arrays mandatory for receipt verification. The verifier now + checks exact output names, entity cardinality, declared dtype/period + coherence, canonical encoding/storage pairs, and recomputed live identities. +- Restored ordinary `materialize` compatibility: empty requests validate and + return `{}`, and duplicate variable names remain harmless. The stricter + receipted surface still requires a nonempty unique request. +- Focused adapter verification passes with the native extension: 69 passed, + 4 skipped (only the expected missing rulespec-be pilot and installed-engine + inverse skip). The complete frame shard passes 303 tests with 25 dependency + skips. Repository Ruff, focused Ruff format, `git diff --check`, and the + 317-file CI inventory verifier all pass. ## Next -- Reproduce each finding in focused tests and inspect the installed Axiom - runtime's actual dense-relation conversion boundary. -- Implement fail-closed native capability handling plus the five remaining - compatibility/authentication repairs without changing the Axiom repository. -- Add adversarial and native integration coverage, then run focused relation - tests, real-Axiom cases, Ruff, and `git diff --check`. +- Dispatcher review, rebase of the policy-materialization lane onto this + repaired adapter, then PR/Fable gating. +- Filtered/composed relations that emit a repeated native batch key remain + deliberately unavailable until Axiom fixes or advertises safe union + conversion; ordinary one-schema relations execute normally. +- Related-side engine entity names remain an outer signed-manifest/runtime-pin + responsibility because Axiom's current relation schema does not expose them. --- diff --git a/changelog.d/axiom-relation-bindings.added.md b/changelog.d/axiom-relation-bindings.added.md index 29ea5913e..944a2d851 100644 --- a/changelog.d/axiom-relation-bindings.added.md +++ b/changelog.d/axiom-relation-bindings.added.md @@ -2,3 +2,5 @@ Add explicit, fail-closed Axiom dense-relation bindings and deterministic relation-batch receipts to the Microcosm frame adapter. Cross-entity rules now execute only when callers bind both frame entities and the exact membership column; the adapter never infers direction from a relation name. +Receipts authenticate owned execution snapshots plus live outputs, preserve +integer or string entity IDs, and fail closed on unsafe repeated native keys. diff --git a/packages/microcosm-frame/src/microcosm/frame/adapters/axiom.py b/packages/microcosm-frame/src/microcosm/frame/adapters/axiom.py index 960909ba6..3f81173e7 100644 --- a/packages/microcosm-frame/src/microcosm/frame/adapters/axiom.py +++ b/packages/microcosm-frame/src/microcosm/frame/adapters/axiom.py @@ -372,7 +372,16 @@ def materialize( array's length does not match its entity table, or a declared dense relation lacks an exact explicit frame-side binding. """ - results, _ = self._materialize_with_receipt(bundle, variables, period) + # Preserve the pre-receipt RulesEngine contract: an empty request + # validates the frame/period and returns no columns, while duplicate + # names are harmless because the result is a name-keyed mapping. + requested = tuple(dict.fromkeys(variables)) + if not requested: + bundle.revalidate() + self._require_schema(bundle) + _period_bounds(period) + return {} + results, _ = self._materialize_with_receipt(bundle, requested, period) return results def materialize_with_receipt( @@ -430,7 +439,7 @@ def _materialize_with_receipt( ) table = bundle.table(frame_entity) current_id_column = self._schema.entity_id_column(frame_entity) - current_ids = _integer_id_vector( + current_ids = _id_vector( table[current_id_column], f"{frame_entity}.{current_id_column}", unique=True, @@ -447,6 +456,10 @@ def _materialize_with_receipt( program=program, current_ids=current_ids, ) + current_id_receipt = _array_identity(current_ids) + root_input_receipts = { + name: _array_identity(inputs[name]) for name in sorted(inputs) + } execute = ( program.execute_f64 if self._arithmetic == "f64" else program.execute ) @@ -458,10 +471,17 @@ def _materialize_with_receipt( relations=relations or None, outputs=list(names), )["outputs"] + bundle.revalidate() + _verify_execution_snapshots( + inputs=inputs, + input_receipts=root_input_receipts, + relations=relations, + relation_receipts=relation_receipts, + ) expected = bundle.n(frame_entity) output_receipts: dict[str, object] = {} for name in names: - values = np.asarray(outputs[name]) + values = np.array(outputs[name], copy=True) if values.shape != (expected,): raise ValueError( f"Materialized variable {name!r} has shape " @@ -484,11 +504,9 @@ def _materialize_with_receipt( "frame_entity": frame_entity, "engine_entity": program.root_entity, "current_id_column": current_id_column, - "current_ids": _array_identity(current_ids), + "current_ids": current_id_receipt, "declared_root_inputs": list(declared_root_inputs), - "provided_root_inputs": { - name: _array_identity(inputs[name]) for name in sorted(inputs) - }, + "provided_root_inputs": root_input_receipts, "relations": relation_receipts, "requested_outputs": output_receipts, } @@ -507,10 +525,15 @@ def _materialize_with_receipt( "entities": entity_receipts, "input_frame_sha256": _canonical_digest(input_projection), } - return results, { + complete_receipt = { **receipt, "receipt_sha256": _canonical_digest(receipt), } + # Close the execution/receipt time-of-check gap against both an + # executor mutating its owned input snapshots and a concurrent alias + # mutating the live frame while Axiom runs. + verify_axiom_materialization_receipt(bundle, complete_receipt, results) + return results, complete_receipt def _relation_batches( self, @@ -522,6 +545,15 @@ def _relation_batches( ) -> tuple[dict[str, Any], dict[str, object]]: """Build exact dense relation batches and their drift receipt.""" declared = _group_relation_declarations(program.relations) + repeated = sorted(key for key, items in declared.items() if len(items) > 1) + if repeated: + raise ValueError( + "The installed Axiom dense bridge cannot safely bind repeated " + "relation batch keys: its PyO3 conversion currently overwrites " + f"earlier schemas for {repeated}. Upgrade to a runtime that " + "exposes an authenticated relation-input union capability " + "before materializing this module." + ) configured = { key: binding for key, binding in self._relation_bindings.items() @@ -970,11 +1002,11 @@ def _batch_from_table( column = table[name] kind = column.dtype.kind if kind == "b": - batch[name] = column.to_numpy(dtype=bool) + batch[name] = column.to_numpy(dtype=bool, copy=True) elif kind in ("i", "u"): - batch[name] = column.to_numpy(dtype=np.int64) + batch[name] = column.to_numpy(dtype=np.int64, copy=True) elif kind == "f": - batch[name] = column.to_numpy(dtype=np.float64) + batch[name] = column.to_numpy(dtype=np.float64, copy=True) else: raise ValueError( f"Column {name!r} has dtype kind {kind!r}; dense inputs must " @@ -989,8 +1021,8 @@ def _group_relation_declarations( """Group Axiom relation schemas by their shared runtime batch key. Filtered or composed derived relations legitimately produce more than one - schema declaration backed by the same raw dense-relation batch. The batch - must be supplied once with the union of all declaration inputs. + schema declaration backed by the same raw dense-relation batch. Grouping + makes that native capability hazard explicit before batch conversion. """ grouped: dict[str, list[dict[str, object]]] = {} @@ -1047,7 +1079,7 @@ def _relation_projection( ) related_table = bundle.table(binding.related_entity) related_id_column = bundle.schema.entity_id_column(binding.related_entity) - related_entity_ids = _integer_id_vector( + related_entity_ids = _id_vector( related_table[related_id_column], f"{binding.related_entity}.{related_id_column}", unique=True, @@ -1062,25 +1094,30 @@ def _relation_projection( f"Relation binding {relation_key!r} requires edge column " f"{column!r} on table {binding.edge_table!r}." ) - edge_current_ids = _integer_id_vector( + edge_current_ids = _id_vector( edge_table[binding.edge_current_id_column], f"{binding.edge_table}.{binding.edge_current_id_column}", unique=False, + empty_like=current_ids, ) - edge_related_ids = _integer_id_vector( + edge_related_ids = _id_vector( edge_table[binding.edge_related_id_column], f"{binding.edge_table}.{binding.edge_related_id_column}", unique=False, + empty_like=related_entity_ids, ) if edge_current_ids.shape != edge_related_ids.shape: raise ValueError( f"Relation binding {relation_key!r} edge id columns do not align." ) - current_positions = {int(value): i for i, value in enumerate(current_ids)} - related_positions = {int(value): i for i, value in enumerate(related_entity_ids)} + current_positions = {_id_key(value): i for i, value in enumerate(current_ids)} + related_positions = { + _id_key(value): i for i, value in enumerate(related_entity_ids) + } unknown_current = sorted( - set(int(value) for value in edge_current_ids) - current_positions.keys() + set(_id_key(value) for value in edge_current_ids) - current_positions.keys(), + key=_id_sort_key, ) if unknown_current: raise ValueError( @@ -1088,7 +1125,8 @@ def _relation_projection( f"from {binding.current_entity!r}: {unknown_current[:5]}." ) unknown_related = sorted( - set(int(value) for value in edge_related_ids) - related_positions.keys() + set(_id_key(value) for value in edge_related_ids) - related_positions.keys(), + key=_id_sort_key, ) if unknown_related: raise ValueError( @@ -1097,7 +1135,7 @@ def _relation_projection( ) positions = np.fromiter( - (current_positions[int(value)] for value in edge_current_ids), + (current_positions[_id_key(value)] for value in edge_current_ids), dtype=np.int64, count=len(edge_current_ids), ) @@ -1109,7 +1147,7 @@ def _relation_projection( ordered_edge_current_ids = edge_current_ids[order] ordered_edge_related_ids = edge_related_ids[order] related_row_order = np.fromiter( - (related_positions[int(value)] for value in ordered_edge_related_ids), + (related_positions[_id_key(value)] for value in ordered_edge_related_ids), dtype=np.int64, count=len(ordered_edge_related_ids), ) @@ -1199,30 +1237,117 @@ def _input_projection_receipt( } -def _integer_id_vector( +def _id_vector( values: pd.Series, label: str, *, unique: bool, + empty_like: np.ndarray | None = None, ) -> np.ndarray: - """Return a canonical signed-int64 id vector for a relation receipt.""" + """Return canonical integer or text IDs for projection and receipts. + + Entity IDs are never supplied to Axiom itself; relation batches use their + positions. Preserve text IDs as text for receipts, canonicalize every + supported integer representation to signed int64, and use the referenced + entity's semantic dtype for an otherwise ambiguous empty object column. + """ + if values.isna().any(): raise ValueError(f"{label} must not contain missing ids.") - kind = values.dtype.kind - if kind not in ("i", "u"): + raw = values.to_numpy(copy=True) + kind = raw.dtype.kind + if not raw.size and empty_like is not None: + if empty_like.dtype.kind in ("O", "U", "S"): + result = np.array([], dtype=object) + else: + result = np.array([], dtype=" np.iinfo(np.int64).max: + raise ValueError(f"{label} contains an id outside signed int64 range.") + result = np.array(raw, dtype=" np.iinfo(np.int64).max + for item in items + ): + raise ValueError(f"{label} contains an id outside signed int64 range.") + result = np.array([int(item) for item in items], dtype=" np.iinfo(np.int64).max: - raise ValueError(f"{label} contains an id outside signed int64 range.") - result = raw.astype(" object: + """Convert a NumPy scalar ID to its stable Python dictionary key.""" + + return value.item() if isinstance(value, np.generic) else value + + +def _id_sort_key(value: object) -> tuple[str, str]: + """Order diagnostic IDs even if a malformed edge mixes Python types.""" + + return type(value).__name__, repr(value) + + +def _verify_execution_snapshots( + *, + inputs: Mapping[str, np.ndarray], + input_receipts: Mapping[str, object], + relations: Mapping[str, Any], + relation_receipts: Mapping[str, object], +) -> None: + """Refuse an executor that mutates any owned dense input snapshot.""" + + recomputed_inputs = { + name: _array_identity(values) for name, values in sorted(inputs.items()) + } + if recomputed_inputs != dict(input_receipts): + raise ValueError("Axiom execution mutated an owned root-input snapshot.") + if set(relations) != set(relation_receipts): + raise ValueError("Axiom execution changed the relation batch set.") + for key, batch in relations.items(): + receipt = relation_receipts[key] + if not isinstance(receipt, Mapping): + raise ValueError(f"Axiom relation receipt {key!r} is invalid.") + if _array_identity(batch.offsets) != receipt.get("offsets"): + raise ValueError( + f"Axiom execution mutated relation {key!r}'s offsets snapshot." + ) + expected_inputs = receipt.get("provided_related_inputs") + live_inputs = { + name: _array_identity(values) + for name, values in sorted(batch.inputs.items()) + } + if live_inputs != expected_inputs: + raise ValueError( + f"Axiom execution mutated relation {key!r}'s input snapshot." + ) + + def _array_identity(values: np.ndarray) -> dict[str, object]: """Canonical identity for a numeric Axiom input or structural vector.""" return _typed_array_identity(values) @@ -1285,18 +1410,24 @@ def _typed_array_identity(values: object) -> dict[str, object]: def verify_axiom_materialization_receipt( frame: Frame, receipt: Mapping[str, object], + outputs: Mapping[str, object], ) -> None: - """Verify a schema-v2 Axiom receipt against the live input frame. + """Verify a schema-v2 Axiom receipt against live inputs and outputs. This verifier does not import or execute Axiom. It revalidates the Frame, authenticates the closed receipt/hash structure, and reconstructs every - exact root-input and relation-edge projection from the live tables. Output - identities are structurally and cryptographically bound by the receipt; - the signed outer manifest supplies authenticity for those hashes and the + exact root-input and relation-edge projection from the live tables. It + also recomputes every output identity from the caller's live materialized + arrays and requires exact name/cardinality agreement. The signed outer + manifest supplies authenticity for the resulting hashes and the RuleSpec/runtime parameter world. """ frame.revalidate() + if not isinstance(outputs, Mapping): + raise ValueError("Axiom live outputs must be a name-keyed mapping.") + if any(not isinstance(name, str) or not name for name in outputs): + raise ValueError("Axiom live output names must be non-empty strings.") top = _exact_mapping( receipt, { @@ -1328,6 +1459,7 @@ def verify_axiom_materialization_receipt( if not isinstance(raw_entities, Mapping) or not raw_entities: raise ValueError("Axiom materialization receipt needs executed entities.") recomputed_entities: dict[str, object] = {} + verified_output_names: set[str] = set() for entity_key in sorted(raw_entities): if not isinstance(entity_key, str) or not entity_key: raise ValueError("Axiom receipt entity keys must be non-empty strings.") @@ -1335,8 +1467,16 @@ def verify_axiom_materialization_receipt( frame, frame_entity=entity_key, value=raw_entities[entity_key], + live_outputs=outputs, ) recomputed_entities[entity_key] = entity + verified_output_names.update(entity["requested_outputs"]) + + if set(outputs) != verified_output_names: + raise ValueError( + "Axiom live output names differ from the receipt: " + f"expected={sorted(verified_output_names)}, got={sorted(outputs)}." + ) input_projection = _input_projection_receipt( period=period, @@ -1352,6 +1492,7 @@ def _verify_entity_receipt( *, frame_entity: str, value: object, + live_outputs: Mapping[str, object], ) -> dict[str, object]: entity = _exact_mapping( value, @@ -1376,7 +1517,7 @@ def _verify_entity_receipt( expected_id_column = frame.schema.entity_id_column(frame_entity) if entity["current_id_column"] != expected_id_column: raise ValueError(f"Axiom receipt id column for {frame_entity!r} differs.") - current_ids = _integer_id_vector( + current_ids = _id_vector( frame.table(frame_entity)[expected_id_column], f"{frame_entity}.{expected_id_column}", unique=True, @@ -1445,7 +1586,45 @@ def _verify_entity_receipt( ): if not isinstance(output[metadata_key], str) or not output[metadata_key]: raise ValueError(f"Axiom output {name!r} metadata is invalid.") - _verify_array_identity(output["values"], f"output {name!r}") + kernel_dtype = output["declared_kernel_dtype"] + engine_dtype = output["declared_engine_dtype"] + period = output["declared_period"] + if kernel_dtype not in {"bool", "int", "float", "str"}: + raise ValueError(f"Axiom output {name!r} kernel dtype is invalid.") + if _DTYPE_KIND_BY_ENGINE.get(engine_dtype, engine_dtype) != kernel_dtype: + raise ValueError(f"Axiom output {name!r} dtype declarations differ.") + if period not in {"year", "month", "point"}: + raise ValueError(f"Axiom output {name!r} period is invalid.") + _verify_array_identity( + output["values"], + f"output {name!r}", + expected_length=frame.n(frame_entity), + ) + values_identity = output["values"] + identity_dtype = values_identity["dtype"] + if kernel_dtype == "str": + dtype_matches = identity_dtype == "string" + else: + try: + identity_kind = np.dtype(identity_dtype).kind + except (TypeError, ValueError): + dtype_matches = False + else: + expected_kinds = { + "bool": {"b"}, + "int": {"i", "u"}, + "float": {"f"}, + } + dtype_matches = identity_kind in expected_kinds[kernel_dtype] + if not dtype_matches: + raise ValueError( + f"Axiom output {name!r} values do not match its declared dtype." + ) + if name not in live_outputs: + raise ValueError(f"Axiom live outputs omit {name!r}.") + expected_identity = _typed_array_identity(live_outputs[name]) + if output["values"] != expected_identity: + raise ValueError(f"Axiom live output {name!r} differs from its receipt.") normalized_outputs[name] = output return { @@ -1589,7 +1768,12 @@ def _verify_period_receipt(value: object) -> dict[str, object]: return period -def _verify_array_identity(value: object, label: str) -> None: +def _verify_array_identity( + value: object, + label: str, + *, + expected_length: int | None = None, +) -> None: identity = _exact_mapping( value, {"dtype", "storage_dtype", "encoding", "shape", "sha256"}, @@ -1606,6 +1790,35 @@ def _verify_array_identity(value: object, label: str) -> None: or shape[0] < 0 ): raise ValueError(f"Axiom array identity {label} shape is invalid.") + if expected_length is not None and shape != [expected_length]: + raise ValueError( + f"Axiom array identity {label} cardinality differs: expected " + f"{expected_length}, got {shape[0]}." + ) + encoding = identity["encoding"] + if encoding == "canonical_json_utf8_v1": + if identity["dtype"] != "string" or identity["storage_dtype"] != "utf8": + raise ValueError( + f"Axiom array identity {label} text encoding is not canonical." + ) + elif encoding == "little_endian_raw_v1": + try: + dtype = np.dtype(identity["dtype"]) + except (TypeError, ValueError) as exc: + raise ValueError( + f"Axiom array identity {label} numeric dtype is invalid." + ) from exc + if dtype.kind not in ("b", "i", "u", "f"): + raise ValueError( + f"Axiom array identity {label} raw encoding is not numeric." + ) + expected_storage = dtype.newbyteorder("<").str + if identity["storage_dtype"] != expected_storage: + raise ValueError( + f"Axiom array identity {label} storage dtype is not canonical." + ) + else: + raise ValueError(f"Axiom array identity {label} encoding is unsupported.") _require_sha256(identity["sha256"], f"array identity {label}") diff --git a/packages/microcosm-frame/tests/fixtures/rulespec-zz/zz/policies/tests/axiom_toy_duplicate_relation.yaml b/packages/microcosm-frame/tests/fixtures/rulespec-zz/zz/policies/tests/axiom_toy_duplicate_relation.yaml new file mode 100644 index 000000000..f8e0efd87 --- /dev/null +++ b/packages/microcosm-frame/tests/fixtures/rulespec-zz/zz/policies/tests/axiom_toy_duplicate_relation.yaml @@ -0,0 +1,52 @@ +# Native-compiler regression for two declarations that lower to the same +# dense relation key with disjoint related inputs. The current PyO3 batch +# conversion overwrites one declaration; Microcosm must refuse this module +# before execution until Axiom exposes a safe union capability. +format: rulespec/v1 +module: + summary: |- + microcosm-frame duplicate dense-relation-key capability fixture. +units: + - name: EUR + kind: currency + minor_units: 2 +rules: + - name: member_of_household + kind: data_relation + data_relation: + arity: 2 + - name: toy_member_is_eligible + kind: derived + entity: Person + dtype: Judgment + versions: + - effective_from: '2025-01-01' + formula: toy_is_eligible + - name: eligible_member_of_household + kind: derived_relation + derived_relation: + arity: 2 + source_relation: zz:policies/tests/axiom_toy_duplicate_relation#relation.member_of_household + versions: + - effective_from: '2025-01-01' + formula: toy_member_is_eligible + - name: toy_household_income + kind: derived + entity: Household + dtype: Money + period: Year + unit: EUR + source: populace-frame relation fixture + versions: + - effective_from: '2025-01-01' + formula: sum(member_of_household.toy_taxable_income) + - name: toy_eligible_household_income + kind: derived + entity: Household + dtype: Money + period: Year + unit: EUR + source: populace-frame relation fixture + versions: + - effective_from: '2025-01-01' + formula: sum(eligible_member_of_household.toy_other_income) diff --git a/packages/microcosm-frame/tests/test_axiom_adapter.py b/packages/microcosm-frame/tests/test_axiom_adapter.py index a4316a2c7..198d5b97b 100644 --- a/packages/microcosm-frame/tests/test_axiom_adapter.py +++ b/packages/microcosm-frame/tests/test_axiom_adapter.py @@ -38,6 +38,7 @@ AxiomEngine, AxiomEntityTableDataset, AxiomRelationBinding, + _canonical_digest, _period_bounds, verify_axiom_materialization_receipt, ) @@ -65,6 +66,12 @@ FIXTURE_RELATION_MODULE = ( FIXTURE_RULESPEC_ROOT / "zz/policies/tests/axiom_toy_relation.yaml" ) +FIXTURE_DUPLICATE_RELATION_MODULE = ( + FIXTURE_RULESPEC_ROOT / "zz/policies/tests/axiom_toy_duplicate_relation.yaml" +) +FIXTURE_DUPLICATE_RELATION_KEY = ( + "zz:policies/tests/axiom_toy_duplicate_relation#relation.member_of_household:1:0" +) FIXTURE_RULESPEC_ROOTS = (FIXTURE_RULESPEC_ROOT,) RULESPEC_BE = os.environ.get("POPULACE_RULESPEC_BE") @@ -338,6 +345,68 @@ def _relation_bundle(*, alternate_membership=None) -> Frame: ) +def _string_relation_bundle() -> Frame: + person = pd.DataFrame( + { + "person_id": ["p3", "p1", "p2"], + "person_household_id": ["h2", "h1", "h1"], + "toy_taxable_income": [20_000.0, 5_000.0, 10_000.0], + "toy_is_eligible": [True, False, True], + } + ) + household = pd.DataFrame( + { + "household_id": ["h1", "h2"], + "toy_household_rent": [100.0, 200.0], + } + ) + return Frame( + {"person": person, "household": household}, + BE_SCHEMA, + {"household": Weights(values=np.ones(2), kind=WeightKind.DESIGN)}, + ) + + +def _string_toy_bundle() -> Frame: + source = _toy_bundle() + person = source.table("person").copy() + person["person_id"] = ["p1", "p2", "p3"] + person["person_household_id"] = ["h1", "h1", "h2"] + household = source.table("household").copy() + household["household_id"] = ["h1", "h2"] + return Frame( + {"person": person, "household": household}, + BE_SCHEMA, + {"household": source.weights_for("household")}, + ) + + +def _empty_link_bundle() -> tuple[Frame, EntitySchema]: + schema = EntitySchema( + group_entities=("household",), + links=( + LinkSpec( + name="household_members", + left_entity="household", + right_entity="person", + ), + ), + ) + source = _relation_bundle() + frame = Frame( + { + "person": source.table("person"), + "household": source.table("household"), + # This is pandas' natural construction for an empty link table: + # both ID columns infer object rather than the entity ID dtype. + "household_members": pd.DataFrame(columns=["household_id", "person_id"]), + }, + schema, + {"household": source.weights_for("household")}, + ) + return frame, schema + + def _relation_adapter(monkeypatch, *, binding=None, program=None) -> AxiomEngine: if binding is None: binding = AxiomRelationBinding( @@ -370,6 +439,21 @@ def _relation_adapter(monkeypatch, *, binding=None, program=None) -> AxiomEngine class TestRelationBindings: + def test_ordinary_materialize_preserves_empty_and_duplicate_requests( + self, monkeypatch + ) -> None: + program = _RecordingRelationProgram() + adapter = _relation_adapter(monkeypatch, program=program) + + assert adapter.materialize(_relation_bundle(), [], period=2025) == {} + outputs = adapter.materialize( + _relation_bundle(), + ["toy_household_income", "toy_household_income"], + period=2025, + ) + assert list(outputs) == ["toy_household_income"] + np.testing.assert_allclose(outputs["toy_household_income"], [15_000, 20_000]) + def test_materializes_stable_dense_batch_and_receipts_exact_order( self, monkeypatch ) -> None: @@ -397,7 +481,7 @@ def test_materializes_stable_dense_batch_and_receipts_exact_order( assert len(evidence["receipt_sha256"]) == 64 assert len(receipt["input_frame_sha256"]) == 64 assert len(receipt["receipt_sha256"]) == 64 - verify_axiom_materialization_receipt(_relation_bundle(), receipt) + verify_axiom_materialization_receipt(_relation_bundle(), receipt, outputs) _, repeated = adapter.materialize_with_receipt( _relation_bundle(), ["toy_household_income"], period=2025 @@ -434,7 +518,9 @@ def test_binding_for_undeclared_program_relation_fails_closed( _relation_bundle(), ["toy_household_income"], period=2025 ) - def test_duplicate_runtime_key_unions_declaration_inputs(self, monkeypatch) -> None: + def test_duplicate_runtime_key_fails_closed_before_the_native_boundary( + self, monkeypatch + ) -> None: program = _RecordingRelationProgram() program.relations.append( SimpleNamespace( @@ -446,19 +532,11 @@ def test_duplicate_runtime_key_unions_declaration_inputs(self, monkeypatch) -> N ) ) adapter = _relation_adapter(monkeypatch, program=program) - _, receipt = adapter.materialize_with_receipt( - _relation_bundle(), ["toy_household_income"], period=2025 - ) - batch = program.last_relations["member_of_household:1:0"] - assert set(batch.inputs) == {"toy_taxable_income", "toy_is_eligible"} - relation = receipt["entities"]["household"]["relations"][ - "member_of_household:1:0" - ] - assert len(relation["declarations"]) == 2 - assert set(relation["provided_related_inputs"]) == { - "toy_taxable_income", - "toy_is_eligible", - } + with pytest.raises(ValueError, match="cannot safely bind repeated relation"): + adapter.materialize_with_receipt( + _relation_bundle(), ["toy_household_income"], period=2025 + ) + assert program.last_relations is None def test_current_to_related_lookup_uses_explicit_edge(self, monkeypatch) -> None: program = _RecordingLookupProgram() @@ -494,7 +572,26 @@ def test_current_to_related_lookup_uses_explicit_edge(self, monkeypatch) -> None np.testing.assert_allclose( outputs["toy_person_household_rent"], [200.0, 100.0, 100.0] ) - verify_axiom_materialization_receipt(_relation_bundle(), receipt) + verify_axiom_materialization_receipt(_relation_bundle(), receipt, outputs) + + def test_string_entity_ids_project_by_position_and_receipt_as_text( + self, monkeypatch + ) -> None: + program = _RecordingRelationProgram() + adapter = _relation_adapter(monkeypatch, program=program) + frame = _string_relation_bundle() + + outputs, receipt = adapter.materialize_with_receipt( + frame, ["toy_household_income"], period=2025 + ) + + np.testing.assert_allclose(outputs["toy_household_income"], [15_000, 20_000]) + entity = receipt["entities"]["household"] + relation = entity["relations"]["member_of_household:1:0"] + assert entity["current_ids"]["dtype"] == "string" + assert relation["source_related_entity_ids"]["dtype"] == "string" + assert relation["source_edge_current_ids"]["dtype"] == "string" + verify_axiom_materialization_receipt(frame, receipt, outputs) def test_declared_link_table_can_supply_explicit_edges(self, monkeypatch) -> None: schema = EntitySchema( @@ -559,17 +656,62 @@ def test_declared_link_table_can_supply_explicit_edges(self, monkeypatch) -> Non "member_of_household:1:0" ] assert relation["binding"]["edge_table"] == "household_members" - verify_axiom_materialization_receipt(frame, receipt) + verify_axiom_materialization_receipt(frame, receipt, outputs) frame.link("household_members").loc[0, "person_id"] = 3 with pytest.raises(ValueError, match="relation receipt"): - verify_axiom_materialization_receipt(frame, receipt) + verify_axiom_materialization_receipt(frame, receipt, outputs) + + def test_natural_empty_object_link_table_projects_zero_edges( + self, monkeypatch + ) -> None: + frame, schema = _empty_link_bundle() + adapter = AxiomEngine( + FIXTURE_MODULE, + schema=schema, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, + relation_bindings={ + "member_of_household:1:0": AxiomRelationBinding( + current_entity="household", + related_entity="person", + edge_table="household_members", + edge_current_id_column="household_id", + edge_related_id_column="person_id", + ) + }, + ) + program = _RecordingRelationProgram() + monkeypatch.setattr(adapter, "_program", lambda _entity: program) + monkeypatch.setattr( + adapter, + "variable_metadata", + lambda name: VariableMetadata( + name=name, entity="household", dtype="float", period="year" + ), + ) + monkeypatch.setattr( + adapter, + "_import_engine", + lambda: SimpleNamespace(DenseRelationBatch=_FakeDenseRelationBatch), + ) + + outputs, receipt = adapter.materialize_with_receipt( + frame, ["toy_household_income"], period=2025 + ) + + np.testing.assert_allclose(outputs["toy_household_income"], [0, 0]) + relation = receipt["entities"]["household"]["relations"][ + "member_of_household:1:0" + ] + assert relation["source_edge_current_ids"]["dtype"] == "int64" + assert relation["source_edge_current_ids"]["shape"] == [0] + verify_axiom_materialization_receipt(frame, receipt, outputs) @pytest.mark.parametrize( ("memberships", "message"), [ ([1, 1, 999], "current ids absent from 'household'"), - ([1.0, 1.0, 2.0], "integer dtype"), + ([1.0, 1.0, 2.0], "integer or string dtype"), ], ) def test_invalid_explicit_membership_fails_closed( @@ -625,14 +767,14 @@ def test_live_verifier_detects_input_and_receipt_tampering( ) -> None: adapter = _relation_adapter(monkeypatch) frame = _relation_bundle() - _, receipt = adapter.materialize_with_receipt( + outputs, receipt = adapter.materialize_with_receipt( frame, ["toy_household_income"], period=2025 ) - verify_axiom_materialization_receipt(frame, receipt) + verify_axiom_materialization_receipt(frame, receipt, outputs) frame.table("person").loc[0, "toy_taxable_income"] = 99_999.0 with pytest.raises(ValueError, match="provided inputs|relation receipt"): - verify_axiom_materialization_receipt(frame, receipt) + verify_axiom_materialization_receipt(frame, receipt, outputs) clean_frame = _relation_bundle() forged = json.loads(json.dumps(receipt)) @@ -640,12 +782,104 @@ def test_live_verifier_detects_input_and_receipt_tampering( "values" ]["sha256"] = "0" * 64 with pytest.raises(ValueError, match="receipt digest differs"): - verify_axiom_materialization_receipt(clean_frame, forged) + verify_axiom_materialization_receipt(clean_frame, forged, outputs) + + changed_outputs = { + "toy_household_income": outputs["toy_household_income"].copy() + } + changed_outputs["toy_household_income"][0] += 1 + with pytest.raises(ValueError, match="live output.*differs"): + verify_axiom_materialization_receipt(clean_frame, receipt, changed_outputs) + + forged = json.loads(json.dumps(receipt)) + forged["entities"]["household"]["requested_outputs"]["toy_household_income"][ + "values" + ]["shape"] = [999] + unsigned = { + key: value for key, value in forged.items() if key != "receipt_sha256" + } + forged["receipt_sha256"] = _canonical_digest(unsigned) + with pytest.raises(ValueError, match="cardinality differs"): + verify_axiom_materialization_receipt(clean_frame, forged, outputs) forged = json.loads(json.dumps(receipt)) forged["period"]["end"] = "2025-12-30" with pytest.raises(ValueError, match="receipt digest differs"): - verify_axiom_materialization_receipt(clean_frame, forged) + verify_axiom_materialization_receipt(clean_frame, forged, outputs) + + def test_executor_cannot_mutate_owned_root_snapshot(self, monkeypatch) -> None: + class MutatingProgram: + root_entity = "Person" + root_inputs = ("toy_taxable_income",) + relations = () + + @staticmethod + def execute(*, inputs, **_kwargs): + inputs["toy_taxable_income"][0] = 999_999.0 + return {"outputs": {"toy_income_tax": np.zeros(3)}} + + execute_f64 = execute + + adapter = AxiomEngine(FIXTURE_MODULE, rulespec_roots=FIXTURE_RULESPEC_ROOTS) + monkeypatch.setattr(adapter, "_program", lambda _entity: MutatingProgram()) + monkeypatch.setattr( + adapter, + "variable_metadata", + lambda name: VariableMetadata( + name=name, entity="person", dtype="float", period="year" + ), + ) + + frame = _toy_bundle() + with pytest.raises(ValueError, match="mutated an owned root-input"): + adapter.materialize_with_receipt(frame, ["toy_income_tax"], period=2025) + assert frame.table("person").loc[0, "toy_taxable_income"] == 5_000.0 + + def test_executor_cannot_mutate_owned_relation_snapshot(self, monkeypatch) -> None: + class MutatingRelationProgram(_RecordingRelationProgram): + def execute(self, *, relations, **_kwargs): + relation = relations["member_of_household:1:0"] + relation.inputs["toy_taxable_income"][0] = 999_999.0 + return {"outputs": {"toy_household_income": np.zeros(2)}} + + execute_f64 = execute + + adapter = _relation_adapter(monkeypatch, program=MutatingRelationProgram()) + frame = _relation_bundle() + with pytest.raises(ValueError, match="mutated relation.*input snapshot"): + adapter.materialize_with_receipt( + frame, ["toy_household_income"], period=2025 + ) + assert frame.table("person").loc[0, "toy_taxable_income"] == 20_000.0 + + def test_live_frame_mutation_during_execution_is_refused(self, monkeypatch) -> None: + frame = _toy_bundle() + + class AliasingProgram: + root_entity = "Person" + root_inputs = ("toy_taxable_income",) + relations = () + + @staticmethod + def execute(*, inputs, **_kwargs): + result = inputs["toy_taxable_income"] * 0.1 + frame.table("person").loc[0, "toy_taxable_income"] = 999_999.0 + return {"outputs": {"toy_income_tax": result}} + + execute_f64 = execute + + adapter = AxiomEngine(FIXTURE_MODULE, rulespec_roots=FIXTURE_RULESPEC_ROOTS) + monkeypatch.setattr(adapter, "_program", lambda _entity: AliasingProgram()) + monkeypatch.setattr( + adapter, + "variable_metadata", + lambda name: VariableMetadata( + name=name, entity="person", dtype="float", period="year" + ), + ) + + with pytest.raises(ValueError, match="provided inputs.*differ"): + adapter.materialize_with_receipt(frame, ["toy_income_tax"], period=2025) def test_materialize_revalidates_mutated_frame(self, monkeypatch) -> None: adapter = _relation_adapter(monkeypatch) @@ -691,11 +925,38 @@ def test_text_output_receipt_uses_canonical_values(self, monkeypatch) -> None: ] assert identity["encoding"] == "canonical_json_utf8_v1" assert identity["dtype"] == "string" - verify_axiom_materialization_receipt(_relation_bundle(), receipt) + verify_axiom_materialization_receipt(_relation_bundle(), receipt, outputs) @needs_engine class TestRelationBindingsWithRealAxiom: + def test_native_duplicate_relation_key_fails_before_pyo3_conversion( + self, + ) -> None: + adapter = AxiomEngine( + FIXTURE_DUPLICATE_RELATION_MODULE, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, + relation_bindings={ + FIXTURE_DUPLICATE_RELATION_KEY: AxiomRelationBinding( + current_entity="household", + related_entity="person", + edge_table="person", + edge_current_id_column="person_household_id", + edge_related_id_column="person_id", + ) + }, + ) + program = adapter._program("household") + assert [item.key for item in program.relations] == [ + FIXTURE_DUPLICATE_RELATION_KEY, + FIXTURE_DUPLICATE_RELATION_KEY, + ] + + with pytest.raises(ValueError, match="cannot safely bind repeated relation"): + adapter.materialize( + _relation_bundle(), ["toy_household_income"], period=2025 + ) + def test_root_input_and_output_change_materialization_receipt(self) -> None: adapter = AxiomEngine( FIXTURE_MODULE, @@ -716,8 +977,10 @@ def test_root_input_and_output_change_materialization_receipt(self) -> None: != changed_receipt["input_frame_sha256"] ) assert baseline_receipt["receipt_sha256"] != changed_receipt["receipt_sha256"] - verify_axiom_materialization_receipt(baseline, baseline_receipt) - verify_axiom_materialization_receipt(changed, changed_receipt) + verify_axiom_materialization_receipt( + baseline, baseline_receipt, baseline_outputs + ) + verify_axiom_materialization_receipt(changed, changed_receipt, changed_outputs) def test_household_sum_executes_in_the_real_dense_runtime(self) -> None: adapter = AxiomEngine( @@ -738,7 +1001,7 @@ def test_household_sum_executes_in_the_real_dense_runtime(self) -> None: ) np.testing.assert_allclose(outputs["toy_household_income"], [15_000, 20_000]) assert receipt["entities"]["household"]["relations"] - verify_axiom_materialization_receipt(_relation_bundle(), receipt) + verify_axiom_materialization_receipt(_relation_bundle(), receipt, outputs) def test_real_dense_runtime_accepts_zero_cardinality_current_row(self) -> None: adapter = AxiomEngine( @@ -759,7 +1022,31 @@ def test_real_dense_runtime_accepts_zero_cardinality_current_row(self) -> None: frame, ["toy_household_income"], period=2025 ) np.testing.assert_allclose(outputs["toy_household_income"], [35_000, 0]) - verify_axiom_materialization_receipt(frame, receipt) + verify_axiom_materialization_receipt(frame, receipt, outputs) + + def test_real_dense_runtime_accepts_natural_empty_link_table(self) -> None: + frame, schema = _empty_link_bundle() + adapter = AxiomEngine( + FIXTURE_RELATION_MODULE, + schema=schema, + rulespec_roots=FIXTURE_RULESPEC_ROOTS, + relation_bindings={ + "member_of_household:1:0": AxiomRelationBinding( + current_entity="household", + related_entity="person", + edge_table="household_members", + edge_current_id_column="household_id", + edge_related_id_column="person_id", + ) + }, + ) + + outputs, receipt = adapter.materialize_with_receipt( + frame, ["toy_household_income"], period=2025 + ) + + np.testing.assert_allclose(outputs["toy_household_income"], [0, 0]) + verify_axiom_materialization_receipt(frame, receipt, outputs) def test_relation_side_input_is_part_of_dataset_input_surface(self) -> None: adapter = AxiomEngine( @@ -843,6 +1130,16 @@ def test_person_values_row_aligned_and_hand_computed(self, adapter) -> None: # 10,000 * 10% + 10,000 * 25% = 3,500. np.testing.assert_allclose(results["toy_income_tax"], [500.0, 1_000.0, 3_500.0]) + def test_relation_free_string_ids_materialize_and_verify(self, adapter) -> None: + bundle = _string_toy_bundle() + outputs, receipt = adapter.materialize_with_receipt( + bundle, ["toy_income_tax"], period=2025 + ) + + np.testing.assert_allclose(outputs["toy_income_tax"], [500.0, 1_000.0, 3_500.0]) + assert receipt["entities"]["person"]["current_ids"]["dtype"] == "string" + verify_axiom_materialization_receipt(bundle, receipt, outputs) + def test_bool_column_drives_the_exemption_predicate(self, adapter) -> None: bundle = _toy_bundle(exempt=(True, False, True)) results = adapter.materialize(bundle, ["toy_income_tax"], period=2025)