Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions docs/transport-declared-units.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,14 +152,15 @@ program, since both would read one person column. Views need

A rules node slices each view's selector over the whole person table, as it
does a relation's selector columns; composition and every rules kernel apply
the same check. When the spec carries an input closure, composition also
refuses a view node whose person slice omits an input that the closure encodes
(other than through a graph node) or defaults on the logical entity, and a
closure producer whose person slice omits a selector. The closure counts a
view's roots as live on its logical entity and checks them against the person
table; `unreached_roots` still excuses only roots on the binding's engine
entity. The support roster classes a defaulted root on a view's logical entity
as a person column.
the same check. Composition refuses a spec that declares a view binding but
carries no input closure, whether or not a node selects that binding. It also
refuses a rules node on a view binding whose person slice omits an input that
the closure encodes (other than through a graph node) or defaults on the
logical entity, and a closure producer whose person slice omits a selector. The
closure counts a view's roots as live on its logical entity and checks them
against the person table; `unreached_roots` still excuses only roots on the
binding's engine entity. The support roster classes a defaulted root on a
view's logical entity as a person column.

A binding without `views` keeps its semantics, engine reference and native
execution call. The engine reference carries `views` only when the binding
Expand Down
34 changes: 29 additions & 5 deletions packages/microcosm-build/src/microcosm/build/transport/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -2201,6 +2201,23 @@ def _check_structural_columns(spec: Mapping[str, object]) -> None:
)


def _check_view_bindings_have_a_closure(spec: Mapping[str, object]) -> None:
"""Every binding that declares views needs the spec's input closure."""

if _has_resource(spec, _CLOSURE_RESOURCE):
return
named = sorted(
binding
for binding, row in _binding_modules(spec).items()
if binding_view_entities(row)
)
if named:
raise ValueError(
f"Rules bindings {named} declare views, but the spec has no "
f"{_CLOSURE_RESOURCE!r} resource to close their inputs."
)


def _check_structural_rows_written(spec: Mapping[str, object], graph: Graph) -> None:
"""Every declared ``structural_columns`` row names a column the graph writes.

Expand Down Expand Up @@ -4257,10 +4274,11 @@ def transport_rules_node(
def _require_relation_inputs(spec, binding_id, inputs) -> None:
"""A rules node slices its binding's relation and view dependencies whole.

A view binding's node must also slice, on the person entity, every input
the reviewed closure encodes (other than through a graph node) or
defaults on the view's logical entity: the engine would otherwise run
the view without a column the closure says the frame carries.
A view binding needs the spec's input closure. Its node must also slice,
on the person entity, every input the reviewed closure encodes (other
than through a graph node) or defaults on the view's logical entity: the
engine would otherwise run the view without a column the closure says the
frame carries.
"""
document = transport_resource(spec, "axiom_rules_bindings")
bindings = [
Expand Down Expand Up @@ -4290,8 +4308,13 @@ def _require_relation_inputs(spec, binding_id, inputs) -> None:
f"Rules binding {binding_id!r} needs whole-table relation inputs {missing}."
)
logical = binding_view_entities(bindings[0])
if not logical or not _has_resource(spec, _CLOSURE_RESOURCE):
if not logical:
return
if not _has_resource(spec, _CLOSURE_RESOURCE):
raise ValueError(
f"Rules binding {binding_id!r} runs views on {sorted(logical)}, but "
f"the spec has no {_CLOSURE_RESOURCE!r} resource to close their inputs."
)
path = bindings[0].get("rulespec_path")
closed = sorted(
{
Expand Down Expand Up @@ -4567,6 +4590,7 @@ def compose_transport_graph(
validate_transport_activation(spec)
_check_unreached_requests(spec, config.engine_refs)
_check_structural_columns(spec)
_check_view_bindings_have_a_closure(spec)
from .entitlement import transport_extensions

config = replace(config, extensions=transport_extensions(spec, config.extensions))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from microcosm.frame.concepts import CONCEPT_ENTITIES, CONCEPTS
from microcosm.frame.input_closure import ClosureEntry, InputClosure, UndeterminedPolicy
from microcosm.graph import Node, Owned, Slice
from test_support.microcosm_build.transport_composed import make_composed_fixture
from test_support.microcosm_build.transport_population import UNIT_RULE

PERIOD = "2026-27"
Expand Down Expand Up @@ -576,6 +577,126 @@ def test_compose_leaves_graph_node_inputs_to_the_node_that_writes_them(
)


def test_compose_refuses_a_view_binding_in_a_spec_without_a_closure(
tmp_path, monkeypatch
):
registry, _, document, _ = _prepared(tmp_path, monkeypatch)
spec = _spec(document)
del spec["resources"]["axiom_input_closure"]
with pytest.raises(ValueError, match="no 'axiom_input_closure' resource"):
compose_module.transport_rules_node(
spec, _config(registry), "child_benefit", _rules_node()
)


def test_a_rules_binding_row_refuses_a_view_binding_without_a_closure(
tmp_path, monkeypatch
):
registry, _, document, _ = _prepared(tmp_path, monkeypatch)
spec = _spec(document)
del spec["resources"]["axiom_input_closure"]
row = {
"id": "child_benefit_node",
"kernel": "simulate.rules_by_ref@1",
"rules_binding": "child_benefit",
"population": "create",
"inputs": [
{"entity": "person", "columns": ["is_child", "age", "child_income"]}
],
"outputs": [
{"entity": "person", "column": name, "dtype": "float64"}
for name in CHILD_OUTPUTS
],
}
with pytest.raises(ValueError, match="no 'axiom_input_closure' resource"):
compose_module._node(spec, row, _config(registry), {})


_RELATION = {
"predicate": "toy_members",
"key": "toy_members:0:1",
"current_slot": 0,
"related_slot": 1,
"arguments": ["Family", "Person"],
"root_entity": "family",
"related_entity": "person",
"membership_column": "person_family_id",
"related_inputs": {"toy_income": "income"},
"cardinality": "many",
"period": PERIOD,
"role_column": "person_family_role",
"roles": ["head", "partner"],
}


@pytest.mark.parametrize(
("fields", "columns"),
(
({}, ("is_child", "age", "child_income")),
({"relations": []}, ("is_child", "age", "child_income")),
(
{"entity": "family", "engine_entity": "Family", "relations": [_RELATION]},
("income", "person_family_role"),
),
),
ids=("plain", "relations-only", "real-relation"),
)
def test_a_binding_without_views_needs_no_closure_at_compose(
tmp_path, monkeypatch, fields, columns
):
_, _, document, _ = _prepared(tmp_path, monkeypatch)
document["bindings"][0].pop("views")
document["bindings"][0].update(fields)
spec = _spec(document)
del spec["resources"]["axiom_input_closure"]
compose_module._require_relation_inputs(
spec, "child_benefit", _rules_node(columns).inputs
)


def _toy_spec_without_a_closure(tmp_path):
"""The composed toy spec, with its closure renamed out of ``_has_resource``'s reach.

A node selects the closure, so renaming keeps activation satisfied where
deleting the resource wouldn't. The rename matches the quoted name only: it
moves the resource key and the selections that name it, and leaves the
document's own ``format``, so the renamed document still parses.
"""

fixture = make_composed_fixture(tmp_path / "fixture")
text = json.dumps(fixture.spec).replace('"axiom_input_closure"', '"other_closure"')
spec = json.loads(text)
InputClosure.from_dict(spec["resources"]["other_closure"])
return spec, fixture.config


@pytest.mark.parametrize(
"binding",
("family_housing", "main_benefit"),
ids=("no-node-names-it", "a-rules-node-names-it"),
)
def test_composition_refuses_a_view_binding_in_a_spec_without_a_closure(
tmp_path, binding
):
spec, config = _toy_spec_without_a_closure(tmp_path)
row = next(
row
for row in spec["resources"]["axiom_rules_bindings"]["bindings"]
if row["id"] == binding
)
row["views"] = [_view()]
with pytest.raises(
ValueError,
match=rf"Rules bindings \['{binding}'\] declare views, but the spec has no",
):
compose_module.compose_transport_graph(spec, config)


def test_composition_needs_no_closure_when_no_binding_declares_views(tmp_path):
spec, config = _toy_spec_without_a_closure(tmp_path)
assert compose_module.compose_transport_graph(spec, config).nodes


def _closure_node(person_columns):
return Node(
"closure",
Expand Down
Loading