Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2d8b627
Add binding-local views to the Axiom adapter
claude Nov 3, 2026
a210845
Partition binding-local view roots in the input closure
claude Nov 3, 2026
f5a0670
Parse binding-local views in the registry and check them in compose
claude Nov 3, 2026
c04320d
Pin NZ-shaped and relation engine references to goldens from the base
claude Nov 3, 2026
446e031
Add the changelog fragment for binding-local views
claude Nov 3, 2026
5933b6f
Test that every rules kernel requires a view's whole selector slice
claude Nov 3, 2026
4d4a99b
Pin view code to axiom.py and the no-view outputs to the base path
claude Nov 3, 2026
881d78a
Class a view's defaulted roots as person columns in the support roster
claude Nov 3, 2026
6872f50
Freeze NZ's real-tree engine references at the base
MaxGhenis Nov 3, 2026
ef39861
Document binding-local views in the declared-units guide
MaxGhenis Nov 3, 2026
b31d4b6
Union shared-path closure roots to remove binding-order dependence
MaxGhenis Nov 4, 2026
679f21f
Cover both logical views to catch dropped input roots
MaxGhenis Nov 4, 2026
9470935
Validate complete outside declarations before pinning bindings
MaxGhenis Nov 4, 2026
959ea22
Preserve outside value types in view equality and hashing
MaxGhenis Nov 4, 2026
ad854d2
Exercise view property edges across every graph dtype
MaxGhenis Nov 4, 2026
0d136ed
Type and stabilize real Child differential inputs
MaxGhenis Nov 4, 2026
1cf06b5
Freeze CI reference bindings to isolate NZ edits
MaxGhenis Nov 4, 2026
0d6201a
Clarify view evidence, serialization and selector refusals
MaxGhenis Nov 4, 2026
02265bb
Merge main (875506e8) into be/s2b-views
MaxGhenis Nov 4, 2026
f1d48f7
Accept overrides of a view selector that a program reads
MaxGhenis Nov 4, 2026
760b0b4
Correct the two docstrings that review B flagged
MaxGhenis Nov 4, 2026
36800b1
Merge main 3e7a7045 into be/s2b-views
MaxGhenis Nov 4, 2026
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
3 changes: 3 additions & 0 deletions changelog.d/259-binding-local-views.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Rules bindings can declare binding-local views (S2b PR 1, microcosm#259). A view names an engine entity no frame entity maps to (rulespec-be's `Child`), one bool person selector column, and an outside value for each of its outputs: `{"logical_entity": "Child", "selector": {"column": "is_child"}, "outside": {"child_benefit": 0.0}}`. The Axiom adapter (`AxiomView`) gathers the selected person rows in table order, runs the module's program on the logical entity, and writes one value to every person row, the declared outside value on each row the selector leaves out. View outputs resolve to `person`, so composition, export, holdout and precalibration read them unedited.

The registry refuses an empty list, views beside relations, a logical entity that names a mapped entity, two views on one logical entity, a selector with any field but `column`, and outside keys that differ from the binding's variables on the logical entity. Compose requires each selector on a whole person slice, and a view node's slice must carry every input the closure encodes or defaults on the logical entity. The input closure partitions each view's roots on its logical entity instead of refusing the binding. An absent `views` block adds no key to `axiom_engine_ref`, so no existing reference moves; a golden of NZ-shaped and relation references from the base checks that byte for byte.
63 changes: 62 additions & 1 deletion docs/transport-declared-units.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ relation declaration. The expected related input map must match the union of
compiled schemas sharing that key. `one` means exactly one selected person per
root; `many` permits zero. A role selector requires both its column and distinct
role values. This interface supports person members of genuine group partitions;
binding-local views and arbitrary links remain separate work.
arbitrary links remain separate work. Binding-local views (below) run an engine
entity that has no frame table.

Rules nodes declare whole-table slices for selector columns and every mapped
related input. Tables always carry their IDs and memberships, and no slice can
Expand All @@ -104,3 +105,63 @@ declaration remains absent; an explicitly supplied empty declaration is distinct
Bindings without relations retain their existing engine-reference serialization
and native execution call shape. Calibration roster counts use the same frame
unit framework, with frozen-kernel differential tests preserving NZ results.

## Binding-local views

A binding on the person entity may run its module's program on an engine
entity that no frame entity maps to, such as rulespec-be's `Child`, by
declaring `views`:

```json
"views": [
{
"logical_entity": "Child",
"selector": {"column": "is_child"},
"outside": {"child_benefit": 0.0}
}
]
```

The adapter (`AxiomView`) gathers the person rows whose bool `selector`
column holds true, in table order, runs the program on `logical_entity` over
them, and scatters each output back through the same mask. Every other row
gets the view's `outside` value for that output. Each binding variable on the
logical entity needs an outside value, with no implicit fill, and the adapter
refuses one that doesn't cast to the output's graph dtype without loss. A
view's root inputs read the person columns of the same names, and its outputs
resolve to the person entity, so downstream kernels read them as person
columns. An empty selection makes no engine call, and a null or non-bool
selector refuses. An input override of a selector changes which rows each
view selects; the adapter accepts one only when a program reads the selector
as a root input, as bridges require of any overridden column, and otherwise
refuses it by the selector's name. Gather and scatter assume that the engine computes each
selected row from that row's inputs alone. Views refuse programs with dense
relations, and a real-engine differential, skip-gated on the dense extension and a
rulespec-be tree, is written to test the assumption.

The registry refuses `views: []`, views beside `relations`, views on a binding
on another entity than the person entity, a logical entity that a frame entity
maps to, two views on one logical entity, a selector that names a structural
column or a binding variable, and an outside key that names no binding
variable. Once the adapter pins its RuleSpec bytes, the registry also refuses
dense relations on a view's program or on the binding entity's program, outside
keys that differ from the binding's variables on the logical entity, and a
view input that shares its name with a root input of the binding entity's
program, since both would read one person column. Views need
`output_dtypes="graph"`, and an injected Python adapter can't run them.

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.

A binding without `views` keeps its semantics, engine reference and native
execution call. The engine reference carries `views` only when the binding
declares them, with the views list in declaration order and each
`outside` mapping in sorted key order.
119 changes: 99 additions & 20 deletions packages/microcosm-build/src/microcosm/build/transport/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,14 +134,15 @@
WEIGHTS_AUDIT_REF,
WEIGHTS_AUDIT_TYPE,
binding_relation_inputs,
binding_view_entities,
)
from .money_kernels import (
MONEY_ANNUAL_TRANSFERS_REF,
MONEY_MINOR_UNITS_REF,
MONEY_NORMALIZE_REF,
)
from .reference_selection import selection_resources, validate_exemptions
from .registry import binding_relation_dependencies
from .registry import binding_relation_dependencies, binding_view_dependencies
from .target_kernels import TARGET_COMPILATION_KERNELS
from .unit_declarations import (
CREATE_REFS,
Expand Down Expand Up @@ -2274,6 +2275,41 @@ def _source_codecs(spec: Mapping[str, object]) -> dict[str, object]:
return out


def _closure_defaults(spec: Mapping[str, object]) -> dict[tuple, str]:
"""(frame entity, input) -> the closure entry of each defaulted root.

A defaulted root on a binding-local view's logical entity is a person
column, because the view reads the person table; every other entity
maps through ``entity_names``.
"""
defaults: dict[tuple, str] = {}
if not (
_has_resource(spec, _CLOSURE_RESOURCE)
and _has_resource(spec, _BINDINGS_RESOURCE)
):
return defaults
names = transport_resource(spec, _BINDINGS_RESOURCE).get("entity_names", {})
to_frame = {engine: frame for frame, engine in names.items()}
view_entities = {
(row.get("rulespec_path"), entity)
for row in _binding_modules(spec).values()
for entity in binding_view_entities(row)
}
person = _person_entity(spec) if view_entities else None
for entry in transport_resource(spec, _CLOSURE_RESOURCE).get("entries", ()):
if isinstance(entry, Mapping) and entry.get("class") == "defaulted":
frame_entity = (
person
if (entry.get("rulespec_path"), entry.get("entity")) in view_entities
else to_frame.get(entry.get("entity"))
)
defaults[(frame_entity, entry.get("input"))] = (
f"{entry.get('rulespec_path')} {entry.get('entity')}."
f"{entry.get('input')}"
)
return defaults


def _support_roster(spec: Mapping[str, object]) -> dict[str, object]:
"""Every column the support gate covers, classed by its producer in lineage.

Expand Down Expand Up @@ -2321,19 +2357,7 @@ def lineage(version: object) -> list[str]:
_raw_transport_resource(spec, MONEY_NORMALIZATION_RESOURCE)
)
)
defaults: dict[tuple, str] = {}
if _has_resource(spec, _CLOSURE_RESOURCE) and _has_resource(
spec, _BINDINGS_RESOURCE
):
names = transport_resource(spec, _BINDINGS_RESOURCE).get("entity_names", {})
to_frame = {engine: frame for frame, engine in names.items()}
for entry in transport_resource(spec, _CLOSURE_RESOURCE).get("entries", ()):
if isinstance(entry, Mapping) and entry.get("class") == "defaulted":
frame_entity = to_frame.get(entry.get("entity"))
defaults[(frame_entity, entry.get("input"))] = (
f"{entry.get('rulespec_path')} {entry.get('entity')}."
f"{entry.get('input')}"
)
defaults = _closure_defaults(spec)
reserved = []
for row in rows:
for item in _sequence(
Expand Down Expand Up @@ -2510,13 +2534,13 @@ def _person_entity(spec: Mapping[str, object]) -> str:


def _check_closure_relation_slices(spec: Mapping[str, object], graph: Graph) -> None:
"""A closure producer's person slice carries every declared relation column.
"""A closure producer's person slice carries every relation and view column.

A ``Slice`` delivers only the columns it names, and
``transport.input_closure@1`` reads each relation input's physical
``column`` and declared ``role_column`` on the person entity. A slice that
omits one would report a ``gap`` the frame does not have, so composition
refuses it by name.
refuses it by name. Each binding-local view's selector is required too.
"""

nodes = [node for node in graph.nodes if node.kernel == INPUT_CLOSURE_REF]
Expand All @@ -2538,6 +2562,15 @@ def _check_closure_relation_slices(spec: Mapping[str, object], graph: Graph) ->
for column in (item.column, item.role_column)
if column is not None
}
| {
view["selector"]["column"]
for row in (rows if isinstance(rows, list) else ())
if isinstance(row, Mapping) and isinstance(row.get("views"), list)
for view in row["views"]
if isinstance(view, Mapping)
and isinstance(view.get("selector"), Mapping)
and isinstance(view["selector"].get("column"), str)
}
)
if not required:
continue
Expand Down Expand Up @@ -4222,26 +4255,72 @@ 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.
"""
document = transport_resource(spec, "axiom_rules_bindings")
bindings = [
row for row in document.get("bindings", ()) if row.get("id") == binding_id
]
if len(bindings) != 1 or "relations" not in bindings[0]:
if len(bindings) != 1 or (
"relations" not in bindings[0] and "views" not in bindings[0]
):
return
declaration = _versioned_create_units(spec)
if declaration is None:
declaration = create_unit_declaration(
CREATE_V1_REF, transport_resource(spec, "benefit_unit_rule")
)
schema = declaration.entity_schema()[0]
dependencies = binding_relation_dependencies(
bindings[0], schema=schema, entity_names=document["entity_names"]
)
dependencies = {
*binding_relation_dependencies(
bindings[0], schema=schema, entity_names=document["entity_names"]
),
*binding_view_dependencies(
bindings[0], schema=schema, entity_names=document["entity_names"]
),
}
missing = missing_relation_inputs(dependencies, inputs, schema)
if missing:
raise ValueError(
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):
return
path = bindings[0].get("rulespec_path")
closed = sorted(
{
entry.get("input")
for entry in transport_resource(spec, _CLOSURE_RESOURCE).get("entries", ())
if isinstance(entry, Mapping)
and entry.get("rulespec_path") == path
and entry.get("entity") in logical
and (
entry.get("class") == "defaulted"
or (
entry.get("class") == "encoded"
and entry.get("encoded_by") != "graph_node"
)
)
}
)
sliced = {
column
for item in inputs
if item.entity == schema.person_entity
for column in item.columns
}
absent = [name for name in closed if name not in sliced]
if absent:
raise ValueError(
f"Rules binding {binding_id!r} runs views on {sorted(logical)} whose "
f"closed inputs {absent} its {schema.person_entity!r} slice omits."
)


def _check_extension_checkpoints(spec, config, graph, compiled) -> None:
Expand Down
Loading
Loading