Skip to content

Encode person_role bindings and their closure defaults on the person table (S2b PR 2) - #1480

Merged
MaxGhenis merged 7 commits into
mainfrom
be/s2b-person-role
Nov 4, 2026
Merged

MaxGhenis merged 7 commits into
mainfrom
be/s2b-person-role

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

S2b PR 2 from Belgium's port (d048). It runs person_role bindings in encode and writes their entities' closure defaults on the person table, so each Child input gets a column. It follows the S2b synthesis, which combines the two designs on #259 (comments 6594895348 and 6594940534). It branched from main 3995d92d8, where #1477 merged, and lands before G8-x. Of the PRs merged since, only F1 (#1481) touches its files, and 7a3e756a merges main e2918f4f, F1 included, into it. The NZ hub cleared commits 1 to 3 at f905863c and OK'd commit 4 at 8a4e7a47.

Commits

Four commits. The first three make one change each, as the NZ hub asked, and the fourth applies the review's four optional findings:

  1. 2981316a Encode person_role bindings as each person's own value.
  2. 9128040a Refuse bindings that compute one person column differently.
  3. f905863c Write person_role entities' closure defaults on the person table.
  4. 8a4e7a47 Refuse a person_role entity in the group kernels and test the Child gather.

F/ = packages/microcosm-frame/src/microcosm/frame/ and T/ = packages/microcosm-build/src/microcosm/build/transport/. Line numbers refer to main 3995d92d8.

What it changes

1. person_role bindings run

A person_role binding feeds an engine entity with one row per person in a role, such as Axiom's Child. On main, encode defers every binding with a group rule (is_executable, F/concept_mapping.py:1740-1742), and encode_groups defers person_role (:1948-1950). So no step writes these inputs. Belgium's 35 Child bindings have no column: 27 identities on age and 8 predicates on education enrolment, across 11 modules.

  • is_executable admits person_role. encode writes each such binding through its existing line, out[binding.concept_entity][binding.engine_input] = values, one value per person on the person table.
  • encode_groups skips executable bindings, person_role among them. It still defers bindings on an entity with no built units.
  • Validation already requires a person_role binding to read person concepts (:1078-1090).

2. One column per table and name

encode writes a person_role binding to the person table, beside the engine's Person bindings. On main, _check_shared_names (:1552-1572) compares bindings per engine entity. So it admits a Child binding and a Person binding that share a name but compute it differently. With commit 1 alone, encode then kept whichever came last [executed: an age identity and an enrolment predicate named x gave [False, True, False, True] in one order and [40, 38, 10, 30] in the other].

The check now compares bindings per table they write: executable bindings per concept entity, group bindings per engine entity as before. No packaged mapping shares a name across engine entities [executed, all four mappings], so the check refuses none of them.

3. Closure defaults for person_role entities

The S2b synthesis review (brief 6035) found that concepts.encode@1 gathers closure defaults only for the engine entities in the mapping's entity_correspondence (T/column_kernels.py:407-413). So a defaulted root on Child reaches no table.

  • The kernel adds the mapping's person_role entities to the person table's (ConceptMapping.person_role_entities). It reads the whole mapping, not only the selected paths, since a selected module may default every input of an entity that only other modules bind. Belgium's be-dg/statutes/family_benefits/amounts.yaml holds 12 Child roots and no binding [executed].
  • _defaults merges every engine entity's defaults for one table before apply_defaults runs. Two defaults that agree in type and value share one column, and the kernel refuses two that differ ("conflicting defaults"). It also refuses a default on a column that a binding encodes, as it did for one entity ("already encoded", F/input_closure.py:834-836).
  • A selected path may name a module that the closure lists and the mapping doesn't bind (_full_mapping).
  • Validation refuses an engine entity whose bindings mix person_role with another group rule (_check_person_role_entities), since encode writes persons in a role on the person table and encode_groups writes other group bindings on built units. No packaged mapping mixes them [executed, all four].
  • Scenario knobs and state bindings still reach group inputs only. concepts.encode@1 takes no knobs param (T/column_kernels.py:355-362) and passes no state bindings to encode [read]. So neither path supports a knob-targeted or state-bound Child root, as neither supports a Person one.

4. The review's four findings

Review brief 6036 approved commits 1 to 3 at f905863c and raised four optional findings (#1480 (comment)). Commit 4 applies all four. It edits only T/column_kernels.py and its test file.

  • The group kernels refuse a person_role entity. concepts.encode@1 writes a person_role entity's bindings and defaults on the person table. concepts.encode_groups@1 and transport.scenario_override@1 still took such an entity as their engine_entity and wrote its closure defaults on a unit table as well. In the review's run, concepts.encode_groups@1 with engine_entity="Child" wrote child_in_care on family and refused nothing. _group_inputs now refuses an engine_entity that the mapping's person_role bindings name. It reads the whole mapping, as the defaults gather does.
  • A property covers the Child side of the gather. The old-gather differential holds no defaulted Child root, so it checks only the gather's non-Child part. The new property, test_person_and_child_defaults_merge_on_the_person_table_or_refuse, draws Person and Child defaults and the gather order.
  • The docstrings state the path rule as _closure enforces it. With a closure, each selected path names a module the closure lists, which may bind nothing; without one, each names a module the mapping binds.
  • Only person_role bindings add an entity to the person table's defaults. A comment in ConceptsEncodeKernel.run now says so: the kernel writes no defaults for an engine entity outside the correspondence that no person_role binding names, even when the closure defaults its inputs.

Invariants and tests

  • Each person's own value (P7, a Hypothesis property): for any concept frame, a person_role binding's column equals, row by row, its transform of that person's own concepts. The property runs an identity and a predicate, the two transforms Belgium uses.

  • No deferral: neither encoder lists a person_role binding as deferred.

  • No interference: adding person_role bindings changes no other binding's output, in encode or encode_groups, and no deferred list.

  • Row-by-row reference: test_encode_matches_a_row_by_row_reference now counts Belgium's 35 bindings among the executable ones it compares.

  • Decoders: no concept's decoder changes in any packaged mapping [executed: old rule vs new, per concept, all four mappings]. So the invertible_concepts pins hold.

  • Pins: two tests pinned the old deferral and now pin the new one: test_only_bindings_without_built_units_are_deferred (test_group_encode.py) and test_group_bindings_other_than_person_role_are_deferred (test_concept_mapping.py).

  • One encoded column per table and name, in any order (commit 2). Validation refuses two bindings that write the same table and name unless they compute it alike. It counts two bindings as alike when they share concepts and transform, and, for group bindings, the group rule. An executable binding, with no rule or person_role, computes each row from that person's own concepts, so its rule doesn't change the value. So validation doesn't require one rule throughout: a Person binding and a Child binding that compute a column alike write the same values, and it admits them.

    • In test_bindings_that_write_one_person_column_compute_it_alike, validation refuses an age identity and an enrolment predicate that share a name, in both orders, for Child beside Person and beside Dependant, and admits a Person and a Child binding that compute the column alike.
    • test_one_person_column_passes_in_any_order_only_when_computed_alike (Hypothesis) draws up to five bindings over Person, Child and Dependant, each computing x one of two ways, in any order. Validation admits them if and only if they all compute x alike.
  • Defaults on the person table (commit 3):

    • test_person_role_bindings_and_defaults_go_on_the_person_table (Hypothesis): a Child binding and two Child defaults, one from a module that binds nothing, land on the person table beside the Person inputs.
    • test_person_role_defaults_share_the_person_table_with_person_inputs: in either gather order, the kernel writes agreeing Person and Child defaults as one column and refuses disagreeing ones. It refuses a Child default on an encoded column. When a run selects only a module that defaults and binds nothing, the kernel writes that module's defaults. It refuses a path that the mapping doesn't bind and the closure doesn't list, and a mapping path the closure doesn't list.
    • test_without_person_role_defaults_encoding_matches_the_old_gather (Hypothesis, a differential): with no closure root on a person_role entity, the kernel's columns equal, byte for byte, those of the same run after a patch makes person_role_entities return nothing, which reproduces main's gather. It draws the mapping with or without a Child binding, and the selected paths and their order. It holds no defaulted Child root, so commit 4's property covers that side.
    • test_an_entity_holds_persons_in_a_role_or_built_units: person_role_entities lists Child once, and validation refuses Child with both person_role and reference_member.
  • Person and Child defaults share one person column, or the kernel refuses (commit 4). test_person_and_child_defaults_merge_on_the_person_table_or_refuse (Hypothesis) draws Person and Child defaults over three names, with values from False, True, 0, 1, 0.0 and 0.5, and the gather order. When both entities default a name and the two defaults differ in type or value, concepts.encode@1 refuses with "conflicting defaults". Otherwise each name gets one person column that holds its default.

  • The group kernels refuse a person_role entity (commit 4). test_group_kernels_refuse_a_person_role_entity (Hypothesis) runs concepts.encode_groups@1 and transport.scenario_override@1 on the role mapping: each runs for Family and refuses Child.

  • Mutants [executed, each on a clean copy, over test_concept_mapping.py, test_group_encode.py, test_axiom_concept_mapping.py and test_transport_column_kernels.py]:

    Commit Mutant Tests that fail
    1 reverts is_executable 3 (at 0165b3d7, over the three concept-mapping files)
    1 reverts only the encode_groups hunk 2 (same)
    2 keys the check per engine entity, as main does (the NZ hub's mutant) test_bindings_that_write_one_person_column_compute_it_alike and test_one_person_column_passes_in_any_order_only_when_computed_alike
    2 compares the group rule for executable bindings too the same two
    3 gathers defaults for entity_correspondence only, as main does test_person_role_bindings_and_defaults_go_on_the_person_table and test_person_role_defaults_share_the_person_table_with_person_inputs
    3 takes person_role entities from the selected bindings, not the whole mapping test_person_role_defaults_share_the_person_table_with_person_inputs
    3 puts person_role entities in place of Person on the person table 5, among them the differential and test_person_encoding_equals_direct_mapping_and_defaults
    3 applies each entity's defaults on its own, without merging test_person_role_defaults_share_the_person_table_with_person_inputs
    3 restores the old path check, modules the mapping binds only test_person_role_bindings_and_defaults_go_on_the_person_table and test_person_role_defaults_share_the_person_table_with_person_inputs
    3 drops the mixed-rule check test_an_entity_holds_persons_in_a_role_or_built_units

    With no mutant, the four files pass: 379 tests at f905863c, and 375 at 9128040a alone.

    At 8a4e7a47, over test_transport_column_kernels.py, which passes 36 tests with no mutant [executed]:

    Mutant Tests that fail
    drops the commit 4 guard test_group_kernels_refuse_a_person_role_entity
    compares values only in the conflict check, ignoring type test_person_and_child_defaults_merge_on_the_person_table_or_refuse
    lets a disagreeing default overwrite the other the merge property and test_person_role_defaults_share_the_person_table_with_person_inputs
    gathers defaults for entity_correspondence only, as main does those two and test_person_role_bindings_and_defaults_go_on_the_person_table
    takes person_role entities from the selected bindings the merge property and test_person_role_defaults_share_the_person_table_with_person_inputs
    applies each entity's defaults on its own, without merging the same two

Reach

  • NZ, US and UK mappings hold no person_role binding [executed], so their encoded tables keep their values. For each of them person_role_entities() returns nothing, so concepts.encode@1 gathers the same defaults as on main [read], and commit 4's guard refuses nothing they declare. The differential above checks that equality on generated frames.

  • Kernel hashes, per commit [executed: the 42 kernels build_transport_registry installs besides its three simulate.*, at main 3995d92d8 and at each of commits 1 to 3; with those three, the registry holds 45, the NZ hub's count. For commit 4, all 45 of make_composed_fixture's registry, and the three simulate.* again under AxiomEngine]:

    Commit Kernels that move Which
    1 2981316a 8, from main concepts.encode@1, concepts.encode_groups@1, gates.battery@1, transport.create@1, transport.create@2, transport.input_closure@1, transport.scenario_override@1, transport.unit_attributes@1
    2 9128040a the same 8 again as above
    3 f905863c 10 the 8, plus calibration.prepare@1 and takeup.assign@1
    4 8a4e7a47 6, from commit 3 calibration.prepare@1, concepts.encode@1, concepts.encode_groups@1, takeup.assign@1, transport.scenario_override@1, transport.unit_attributes@1
    main to head 10 the 8, plus calibration.prepare@1 and takeup.assign@1

    The 8 hash F/concept_mapping.py, which commits 1 to 3 edit; gates.battery@1 reaches it through its binding sources. Commit 3 also edits T/column_kernels.py. takeup.assign@1 lives in that file, and calibration.prepare@1 hashes it through _columns [read, source-hash map]. Commit 4 edits only T/column_kernels.py, so it moves the six kernels that hash that file, all among the 10. Add a person-level coresident_adult transform kind #1477 moved the same 8.

    simulate.* hash the adapter classes, not these files [read, source-hash map]. No US or UK runtime kernel names F/concept_mapping.py or T/column_kernels.py in its source_hash [read, source-hash map]. For Add a person-level coresident_adult transform kind #1477's edit to F/concept_mapping.py, the NZ hub hashed six of them and none moved [NZ, executed].

  • NZ's node keys: commits 1 to 3 move transport.create@1 and @2, so all of NZ's nodes re-key. Their population identity changes, and their output bytes don't. S2D-AGREED item 6 asks for a stated re-run of such a change once NZ's M4, its first calibrated population, has passed, and M4 hasn't passed [NZ, ask 7]. On main, NZ's rules bindings hold no engine pin, and build_transport_registry raises without one (T/registry.py:126-129, :405-412) [read], so NZ can't build its registry from them yet. G8-x, NZ's first real-data run, sets both pins [NZ].

  • US primary-QRF worker identity: F/concept_mapping.py sits in the worker's static import closure, through the US adapter (F/adapters/policyengine_us.py:46). So the PR moves worker_module.transitive_imports_sha256 and transitive_environment_code_sha256 [executed on main with a one-line comment; the same line in F/adapters/axiom.py moves nothing]. stacked_spine.py:5681-5694 refuses a pool that a run recorded under the old identity. Add a person-level coresident_adult transform kind #1477 did the same. Route A has the details; the running US release keeps its pin at 9434c504b. T/column_kernels.py doesn't sit in that closure [executed: appending a comment to it at f905863c leaves the worker binding semantically equal, with the same import and environment hashes]. Commit 4 edits only that file, and the binding at 8a4e7a47 equals the one at f905863c, byte for byte [executed, fresh processes].

Belgium's Child roots and the closure gate

The NZ hub asked for the closure gate to pass for all 277 of Belgium's Child roots. That evidence belongs to PR 4, Belgium's activation, which writes the closure rows for those roots. Two things keep it out of this PR:

  • Belgium has no rules-bindings document or input closure in the tree yet. PR 4 writes both.
  • Before PR 1's views (Add binding-local views to the Axiom adapter, registry, compose and closure (S2b PR 1) #1482), the closure partition can't see Child roots. closure_partition reports a root on an engine entity outside the bindings' entity_names as unmapped_entity, not live (T/evidence_contract.py:999-1000 and :1046-1051; T/evidence_kernels.py:432 passes entity_names.values()) [read]. Belgium's frame has no Child entity, so a gate run before PR 1 would pass these roots without checking them.

Belgium's surface fixture, be.json, holds 277 Child roots in 12 modules [executed, child_roots.py, with Belgium's mapping]. Belgium's 35 Child bindings bind 35 of them, which close as concept-encoded once this PR lands. PR 4 must close the other 242, among them all 12 in be-dg/statutes/family_benefits/amounts.yaml.

  • Once PR 1 maps Child to the person table, the partition puts a defaulted root whose column the person slice lacks in frame_missing (T/evidence_contract.py:1201-1212) [read], and the gate fails it. Commit 3 writes that column; this PR's tests show a defaulted Child root reaching the person table.
  • Neither path supports knob-targeted or state-bound Child roots (section 3 above), the gate fails a gap, and an engine-optional root passes only where Belgium's gate sets allow_engine_optional. So PR 4 closes each of the 242 as defaulted, or as engine-optional where the gate allows it.

Checks

🤖 Generated with Claude Code

A person_role binding feeds an engine entity with one row per person in a
role, such as Axiom's Child. `encode` deferred every binding with a group
rule, and `encode_groups` deferred person_role, so no step ever wrote
these inputs. Belgium's 35 Child bindings (27 identities on age and 8
predicates on education enrolment, across 11 modules) had no column.

`is_executable` now admits person_role. `encode` writes each such binding
on the person table through its existing line, one value per person, and
`encode_groups` skips it. Validation already requires a person_role
binding to read person concepts. Bindings on entities without built units
stay deferred.

Two tests pinned the old deferral and now pin the new one. A new property
checks each person's own value for an identity and a predicate, that
neither encoder defers them, and that adding them changes no other
binding's output. The row-by-row reference differential now counts
Belgium's 35 bindings. No decoder changes in any packaged mapping, and
the NZ, US and UK mappings hold no person_role binding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 2 commits November 3, 2026 01:38
encode writes each person_role binding to the person table, beside the
engine's Person bindings. _check_shared_names compared shared names per
engine entity, so it admitted a Child binding and a Person binding, or
two person_role entities, that share a name but compute it differently,
and encode kept whichever came last. It now compares per table written:
executable bindings per concept entity, group bindings per engine
entity as before. Bindings that compute a shared column alike still
pass.

A test refuses an age identity and an enrolment predicate that share a
person column, in both orders. A property checks that bindings writing
one person column pass, in any order, exactly when they compute it
alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
concepts.encode@1 gathered closure defaults only for the engine entities
in the mapping's entity_correspondence, so a defaulted root on a
person_role entity such as Axiom's Child reached no table. It now adds
the whole mapping's person_role entities to the person table's, beside
their bindings. It merges every entity's defaults for one table before
it applies them, so two that agree share one column and two that
disagree refuse.

A selected path may now name a module that the closure lists and the
mapping doesn't bind, since a module may default every input it reads.
Validation refuses an engine entity that mixes person_role with other
group rules: its rows are persons in a role or built units, not both.

Tests cover a Child default that reaches the person table, agreeing and
disagreeing Person and Child defaults in both orders, a Child default on
an encoded column, and a module that only defaults. A property checks
that, without person_role defaults, the kernel writes what the old
gather wrote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis MaxGhenis changed the title Encode person_role bindings as each person's own value (S2b PR 2) Encode person_role bindings and their closure defaults on the person table (S2b PR 2) Nov 3, 2026
@MaxGhenis

MaxGhenis commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor Author

S2b PR 2 review (brief 6036): APPROVE

Reviewed head f905863c (open), based on main 3995d92d8. Main has since moved to 4a36edcf (#1463, Belgium data only). git merge-tree --write-tree origin/main f905863c writes the merged tree with no conflicts. I ran everything on Python 3.13.18 after uv sync --all-packages --locked. Mutants ran in a separate worktree with its own venv, and every patched file was restored. I found nothing that blocks landing and nothing to fix first. There are four optional P3s below.

Findings

P3-1. A graph can still put a person_role entity on built units. _check_person_role_entities refuses a mapping that mixes person_role with another rule on one entity. It doesn't stop a graph from doing the same. _group_inputs (T/column_kernels.py:455-498) takes engine_entity as a free param. So concepts.encode_groups@1 with engine_entity="Child" and a unit rule runs without refusal. It writes Child's closure defaults (and any Child state bindings) on the unit table, while concepts.encode@1 writes the same defaults on person.
Ran: a scratch pytest with the PR's ROLE_MAPPING and ROLE_CLOSURE, paths (xx/child.yaml,). concepts.encode@1 gave ('person','child_in_care'), and concepts.encode_groups@1 with engine_entity="Child" gave ('family','child_in_care') with receipt n_deferred: 0. Neither refused.
No packaged graph does this. I didn't check whether compose would refuse such a node. Optional fix: refuse in _group_inputs when entity in mapping.person_role_entities().

P3-2. The differential property tests only the non-Child part of the gather. test_without_person_role_defaults_encoding_matches_the_old_gather holds no defaulted Child root (only CHILD_ENCODED). So closure.defaults(path, "Child") is {} on every draw, and the property can't fail for a mutant confined to the Child part of _defaults. It isn't vacuous: it does catch "person_role entities replace Person". Its "old gather" also matches main's ConceptsEncodeKernel.run (main used mapping.entity_correspondence of the restricted mapping, which equals full.entity_correspondence, one entity per table), and the mock.patch.object takes effect. But it compares the gather only. Its "old" side still runs head's encode and head's path check, not main's. The Child side is covered by the two example tests instead (see the mutant table).

P3-3. The path rule is worded loosely in two docstrings. The module docstring and _full_mapping say a selected path is one "the mapping binds or, with a closure, … the closure lists". With a closure, _closure still refuses any path the closure lacks, so the rule is: with a closure, the closure lists it; without one, the mapping binds it. The test comment at test_person_role_defaults_share_the_person_table_with_person_inputs states this correctly.

P3-4. person_role_entities() reads only bindings. An engine entity that has closure roots but no person_role binding anywhere in the mapping never joins the person table's entities. Its defaults are then dropped without a refusal. This doesn't bite Belgium: in be.json's surface the only person-role-like entity with roots is Child, and it has 35 bindings. It's worth one sentence in the docstring or a check when PR 1 adds the views.

Claims I tried to break

  • Commit 1, own value per row. My Hypothesis probe (300 concept_frames, ≤4 households × 5 members, 2,261 rows) took every transform validation admits for person_role: identity, predicate, recode, sum, scaled_sum, signed_part, product, positive, scale, fraction, relationship_role, coresident_child_count, coresident_adult and take_up_threshold. Share needs its complement pair and computes through the same _apply. For each one, it built Person, Child and Dependant bindings and asserted the person_role columns equal the Person column row by row. It also permuted person rows and checked that the values follow person_id. No person_role binding was deferred. Holds.
  • Commit 1, encode_groups. At head the loop skips is_executable bindings, and the only change from main is that person_role is skipped instead of deferred. The module filter (keep) and the deferral of entities with no membership are unchanged. concept_names still holds person_role refs, so a state binding on a Child input still refuses, as on main. A knob naming a person_role input still refuses as "does not produce", as on main. Holds.
  • Commit 1, no module filter in encode. The only production callers of encode and encode_groups are the three column kernels (grep over packages/*/src). concepts.encode@1 encodes with _mapping(...), already restricted to the selected paths, and _columns projects only declared outputs. On main, encode_groups deferred every person_role binding, so no caller could have relied on it to choose them. Holds.
  • Commit 2. I tried to build two admitted bindings that encode writes to one column with different values. _apply reads only concepts and transform (plus concept_entity, which is the key), shares and rates. So equal (concepts, transform) give equal values whatever the rule. Person beside Child and two person_role entities are refused unless they compute alike, which the test and property cover. A group binding always writes a unit table, never person or household, so it can't collide with an executable binding. Could not break.
  • The order property hits both outcomes. I re-ran its strategy (100 examples) with counters: 44 admitted (23 spanning more than one entity) and 56 refused, of which 27 were refused only across entities. Those 27 are the cases main's per-engine-entity key admits.
  • Commit 3. Defaults are gathered only over the selected paths, the correspondence entities and the mapping's person_role entities, never group entities. The new tests show agreeing defaults merging, disagreeing ones refusing in both orders, a default on an encoded column refusing, and a default-only module writing. In be.json, no Child root name equals a Person root or Person-bound name (125 distinct Child names), so the shared person table forces no collision for PR 4.
  • The widened path check. It only adds paths the closure lists, and _closure still checks closure–mapping identity and that every path is in the closure, so a typo still refuses. In encode_groups@1 and scenario_override@1, a closure-only module now encodes nothing and writes its unit defaults instead of refusing. A knob on such an input still refuses. I found no case that turns an old refusal into a wrong result.
  • _check_person_role_entities. No packaged mapping trips it. Belgium has Child with person_role only, and NZ, US and UK have no person_role. The graph route in P3-1 bypasses it.

Mutants

Over 7 files, 624 tests: test_group_encode, test_concept_mapping, test_axiom_concept_mapping, test_unit_construction, test_declared_units, test_transport_column_kernels and test_transport_evidence_kernels.

Commit Mutant Fail Caught by
1 is_executable reverted 7 both person_role kernel tests (incl. the differential), both commit-2 tests, both deferral pins, test_person_role_encodes_each_persons_own_value
1 only the encode_groups hunk reverted 2 test_only_bindings_without_built_units_are_deferred, test_person_role_encodes_each_persons_own_value
1 encode writes the household reference person's value 4 test_encode_matches_a_row_by_row_reference, test_person_role_encodes_each_persons_own_value, test_only_bindings_without_built_units_are_deferred, test_person_role_bindings_and_defaults_go_on_the_person_table
1 person_role column shifted one row (np.roll) 4 same four
2 key per engine entity, as on main 2 test_bindings_that_write_one_person_column_compute_it_alike, test_one_person_column_passes_in_any_order_only_when_computed_alike
3 defaults applied per engine entity, not merged 1 test_person_role_defaults_share_the_person_table_with_person_inputs
3 person_role entities from the selected bindings 1 test_person_role_defaults_share_the_person_table_with_person_inputs
3 old path check 2 test_person_role_bindings_and_defaults_go_on_the_person_table, test_person_role_defaults_share_the_person_table_with_person_inputs
3 no mixed-rule check 1 test_an_entity_holds_persons_in_a_role_or_built_units

No mutant survived.

Other claims confirmed

  • Kernel hashes. I registered the kernels exactly as build_transport_registry does (the three simulate.* bound to an AxiomEngine instance), 45 in all, and hashed them with implementation_hash at main, 2981316a, 9128040a and f905863c. The same 8 moved at commit 1 and again at commit 2: concepts.encode@1, concepts.encode_groups@1, gates.battery@1, transport.create@1, transport.create@2, transport.input_closure@1, transport.scenario_override@1 and transport.unit_attributes@1. Commit 3 moved those 8 plus calibration.prepare@1 and takeup.assign@1, 10 in all, and main to head is the same 10. No simulate.* moved. Those hash the adapter class's module plus bundle, rules and schema, and source_hash follows no imports. No source_hash call in us_runtime/ or uk_runtime/ names concept_mapping, column_kernels or a class from them (grep).
  • US worker identity. I computed primary_qrf_worker_execution_binding() in fresh processes and compared each with primary_qrf_worker_bindings_semantically_equal. Head vs main: not equal, with worker_module.transitive_imports_sha256 and transitive_environment_code_sha256 moved. A comment appended to F/concept_mapping.py on main: same two move. One appended to T/column_kernels.py on main: equal, nothing moved, and the same holds at head. Control, one appended to F/adapters/axiom.py on main: equal. No "Duplicate source modules".
  • Decoders. For each of the four mappings from concept_mappings(), _decoder per concept under group_rule is None and under head's rule are identical. In Belgium, fact:person.age still decodes from the Person binding brussels_apa_age_years, and education_enrollment has no decoder.
  • Belgium's Child roots. From tests/fixtures/axiom_input_surfaces/be.json and the BE mapping: 277 Child roots in 12 modules. The 35 Child bindings (27 identity, 8 predicate, all person_role, in 11 modules) bind 35 of them, leaving 242 unbound. be-dg/statutes/family_benefits/amounts.yaml has 12 roots and 0 bound. The reading also holds: evidence_kernels.py:432 passes entity_names.values(), closure_partition puts roots on other entities in unmapped_entity (evidence_contract.py:1044-1053), and gate_bindings.py:1458 only reports that list, never fails on it.
  • Tests. The 22 files the brief lists pass: 2,983 passed and 3 skipped, run with -n 4. The skips are the Axiom dense extension (test_axiom_relations.py:798) and two NZ feed round-trips gated by NZ_CHRONICLE_ARTIFACT.
  • Body file:line references. All hold at main 3995d92d8: F/concept_mapping.py:1740-1742, :1948-1950, :1078-1090, :1552-1572, F/input_closure.py:834-836, T/column_kernels.py:407-413, :355-362, F/adapters/policyengine_us.py:46, stacked_spine.py:5681-5694, T/evidence_contract.py:999-1000, :1201-1212 and T/evidence_kernels.py:432. :1046-1051 lands in the right block (the unmapped_roots comprehension is 1048-1053).
  • Docstrings. The touched docstrings in concept_mapping.py (module, GroupRule, EncodedInputs.deferred, EncodedGroupInputs.deferred, _check_shared_names, is_executable, encode, encode_groups) are accurate after the change, apart from P3-3. No other doc in the repo describes person_role as deferred (grep).

Not run

CI; the engine-us and engine-uk suites; the full engine-free suite (704 files); the US worker pool itself, since I only computed the identity; NZ output bytes before vs after (the NZ hub's ask 6); and any compose-level check of P3-1.


Generated by Claude Code

…ather

Applies the four optional P3s from the S2b PR 2 review (brief 6036):

- P3-1: concepts.encode@1 writes a person_role entity's bindings and
  defaults on the person table, but concepts.encode_groups@1 and
  transport.scenario_override@1 still took one as their engine_entity
  and wrote its closure defaults on a unit table too. _group_inputs now
  refuses it. test_group_kernels_refuse_a_person_role_entity checks both
  kernels, with Family on the same mapping as the control.
- P3-2: the differential property holds no defaulted Child root, so it
  checks only the gather's non-Child part.
  test_person_and_child_defaults_merge_on_the_person_table_or_refuse
  draws Person and Child defaults and the gather order. Agreeing
  defaults share one person column; ones that differ in type or value
  refuse.
- P3-3: the module docstring and _full_mapping now state the path rule
  exactly. With a closure, each selected path names a module the closure
  lists; without one, a module the mapping binds.
- P3-4: a comment in ConceptsEncodeKernel.run records that only
  person_role bindings add an entity to the person table's defaults.

Only column_kernels.py and its tests change, so the kernels that move
are the ones that already move from main, and the US worker identity
stays put.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis

MaxGhenis commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor Author

S2b PR 2 delta review (brief 6038), draft

Reviewing head 8a4e7a4719dc7a20ae4e6afa3d02a7a85312e0f6 (open), the one commit after 6036's approval at f905863c. Still running: mutants, merge-property statistics and catch rates, kernel hashes, the US worker identity and NZ bytes. What has firmed up so far:

  • Setup. Python 3.13.18, uv sync --all-packages --locked. Separate worktrees, each with its own venv, at main 3995d92d8, f905863c and two scratch copies of the head. git merge-tree --write-tree origin/main HEAD against main 4a36edcf exits 0 and writes tree aa9d936a, with no conflicts. All 10 CI check runs on the head succeeded. ruff check and ruff format --check pass on both changed files.
  • Tests (brief item 8). The eight listed files, run with -n 4 at the head: 935 passed and 2 skipped. The skips are test_nz_transport_declarations.py:281, "Pinned feed not staged". Collected per file: column_kernels 36, composed_graph 469, graph 15, population_kernels 38, declared_units 18, nz_transport_declarations 31, group_encode 82, concept_mapping 248.
  • Guard, 6036's repro. At f905863c, concepts.encode_groups@1 and transport.scenario_override@1 (knobs {}) with engine_entity="Child" both return ('family','child_in_care') for (xx/child.yaml,) and ('family','child_rate') for the closure-only (xx/child_defaults.yaml,). At the head, all four refuse with the guard's message.
  • Docstrings. The path rule holds for all three kernels, checked by running them. With a closure, a path the mapping binds but the closure lacks refuses ("closure has no paths"), and a closure-only path is accepted. Without a closure, a closure-only path refuses ("unknown mapping paths"). scenario_override@1 always needs a closure.
  • P3, confirmed by an independent refuter. The guard covers person_role entities, but not the correspondence's own Person and Household. With engine_entity="Person" and (xx/person.yaml,), both group kernels write Person's default eligibility_code=1 on the family table. Household does the same, its state bindings included. Compose accepts such a node and run_graph runs it. The gap predates this PR (it is on main).

Generated by Claude Code

@MaxGhenis

MaxGhenis commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor Author

S2b PR 2 delta review, restart (brief 6038R): APPROVE

Restart of brief 6038, finishing its review of head 8a4e7a4719dc7a20ae4e6afa3d02a7a85312e0f6 (open), the one commit after 6036's approval at f905863c. Late hand-off check at 19:13Z: comment 6603468489's first line still read **S2b PR 2 delta review** (brief 6038), draft, updated_at 2026-11-03T13:03:57Z. I checked again at 20:21Z, before this final edit: unchanged, so the first session has not come back. I didn't redo the checks 6038's draft reports done (ruff, item 8 at head, 6036's repro, the path-rule docstrings), apart from re-running its P3. I found nothing that blocks landing and nothing to fix first. There are three optional P3s: two new ones about the tests, and 6038's P3, which I re-ran and agree with.

Merges into current main. git merge-tree --write-tree origin/main 8a4e7a47 against main 31c89390, which contains c8984c44, exits 0 and writes tree 6396cc6d, with no conflicts. Between 3995d92d8 and 31c89390, main's only change under T/, F/concept_mapping.py, F/input_closure.py and the column-kernel test is T/terminal_kernels.py. GitHub reports mergeable: true, clean. The eight test files from item 8, run on that merged tree with -n 4, give 935 passed and 2 skipped (test_nz_transport_declarations.py:281, "Pinned feed not staged"), the counts 6038 reported at head. Main was still 31c89390 at 20:21Z. The 10 check runs on the head are all success (re-read at 19:57Z).

Findings

P3-1. The merge property pins the type check only now and then. Only test_person_and_child_defaults_merge_on_the_person_table_or_refuse catches a weakened type check, and only when it draws a clash on type alone. At its 35 examples, about one run in five draws none.
Ran: the property, unchanged, in one process under 100 seeds, with shrinking off and _defaults patched in memory at head. Unmutated, 0/100 runs fail. values_only (type check dropped) is caught in 82/100 runs. isinstance_types is caught in 28/100. That is my mutant: not isinstance(value, type(defaults[name])) in place of type(...) is not type(...), a plausible cleanup that wrongly admits an int gathered before an equal bool, since bool subclasses int. Its shrunk counterexample is child={'role_flag': 0}, person={'role_flag': False}, child_first=True. The example tests catch neither mutant, because their disagreeing defaults differ in value. CI's two engine-free jobs (3.13 and 3.14) draw independently, so a pipeline would miss values_only about 3% of the time (0.18²) and isinstance_types about half the time (0.72²).
Optional fix: a small parametrized example test, or @examples, with each type-only pair (0/False, 0/0.0, False/0.0, 1/True) in both gather orders, expecting "conflicting defaults".

P3-2. No test pins the guard's whole-mapping read. guard_selected_paths survives all 36 tests in test_transport_column_kernels.py. That mutant has the guard read _mapping(context, ref, mapping).person_role_entities(), the selected paths' bindings. The new test's Child case selects only xx/child.yaml, which binds Child, so a selected-paths guard still fires there. The case that tells the two apart is the closure-only xx/child_defaults.yaml. With that guard patched in memory at head, both group kernels write family.child_rate = 0.5 for it, while concepts.encode@1 on that path writes person.child_rate = 0.5: the double write the commit removes. No other file of the eight can catch it (grep). The three other transport files that name a group kernel (composed_graph, population_kernels, nz_transport_declarations) have no person_role entity. test_transport_graph.py and test_transport_declared_units.py name no group kernel, and the two frame files never call the column kernels.
Optional fix: a second Child case with rulespec_paths=(CHILD_DEFAULTS_PATH,) in test_group_kernels_refuse_a_person_role_entity; 6038's draft ran exactly this repro by hand. Lower value: guard_hardcoded_child (if entity == "Child":) also survives, because Child is the only person_role entity any column-kernel test uses. A second one in ROLE_MAPPING would pin it.

P3-3 (6038's P3, re-run; I agree). The guard covers person_role entities, not the correspondence's own Person and Household.
Ran, at head and at main 3995d92d8: concepts.encode_groups@1 and transport.scenario_override@1 with engine_entity="Person" and (xx/person.yaml,) both write Person's closure default on the family table, family.eligibility_code = 1. concepts.encode@1 on the same path writes person.eligibility_code = 1. With engine_entity="Household", a Household default and a Household state binding that I added to the closure on xx/person.yaml (a module the mapping binds) both land on the family table (hh_default = True, hh_state_flag ∈ {False, True}), at head and on main. Head also accepts them from a closure-only module, which main refuses as "unknown mapping paths". That comes from commit 3's widened path check, not from this commit.
compose.py reads no group node's engine_entity; its only engine_entity reads are the closure-supply match in _supplies. I didn't re-run the draft's compose and run_graph step. No packaged or test graph passes a group kernel Person or Household (grep). I agree it predates the PR and is P3: it puts inputs of a non-unit entity on a unit table, the same class as 6036's P3-1, and no packaged graph reaches it.
Optional fix: also refuse mapping.entity_correspondence.values() in _group_inputs. As a probe, that guard passes all 36 column-kernel tests.

The commit message's claims

  • P3-1, the guard. no_guard fails only test_group_kernels_refuse_a_person_role_entity, as claimed. Moving the guard into ConceptsEncodeGroupsKernel.run alone fails the same test's scenario_override@1 arm ("DID NOT RAISE"), since ScenarioOverrideKernel.run calls _group_inputs directly. The Family control on the same mapping encodes family.input_assets.
    • Other routes. At the merged tree, _group_inputs is the only caller of encode_groups in packages/*/src, test_support and tools. _defaults, apply_defaults and InputClosure.defaults are called only from column_kernels.py. engine_entity is a kernel param only in _group_inputs. evidence_contract.py reads closure.entries_for only to report. concepts.encode@1 can't reach a unit table: entity_correspondence maps exactly person and household, and _check_binding puts every group-rule binding off those two entities, so correspondence entities and person_role entities are disjoint.
    • Over-refusal. Of the four packaged mappings, only axiom-be has a person_role entity (Child, 35 bindings). Of the packaged specs (am, be, nz, uk, us, at head and at the merged tree), only NZ has group-kernel nodes. All 9 of them name Family: nz.as.inputs, nz.scn.S1–S3.inputs, nz.v.V1–V4.inputs and nz.v.VHF.inputs. BE has none.
    • Whole mapping. concepts.encode@1 writes on the person table the defaults of every entity in full.person_role_entities(), whatever paths it selects, closure-only ones included. So the group kernels must refuse that same set, as the P3-2 probe shows. Reading the whole mapping refuses nothing a group kernel could legitimately encode: _check_person_role_entities makes "persons in a role or built units" a mapping-wide property, so a person_role entity has no unit binding in any module.
  • P3-2, the merge property. The oracle matches _defaults and apply_defaults:
    • On the person table, _defaults gathers closure.defaults(path, entity) over the selected paths in order and over ("Person", "Child"). It refuses a name gathered twice with a different type or value. apply_defaults then writes one constant column per name, typed by the value (bool, int64 or float64).
    • The three merge names collide with no fixture entry or bound input. The closure's canonical JSON keeps false, 0 and 0.0 distinct, so type-only clashes reach the kernel.
    • Branches at 35 examples. On an instrumented copy (restored), --hypothesis-show-statistics at --hypothesis-seed=1 gave 17/35 refuse, 5 merge a shared agreeing name, 7 clash on type alone and 23 gather Child first. Over 60 seeded runs, the mean per run was: refuse 11.1 (min 5), merge with a shared agreeing name 8.4 (min 1), Child first 15.8 (min 6), and clash on type alone 2.5 (min 0, and 0 in 13 of 60 runs). That last branch is P3-1.
    • Vacuity. _assert_column compares tolist() values, which is type-blind ([False] == [0] == [0.0]). Only _columns pins the dtype: it casts to the oracle's declared token, refusing non-bool values for bool and casting int64 and float64 losslessly. Every packaged NZ encode-family output declares one of those three dtypes, so production sees the same projection. The merge branch isn't vacuous for this code, because the values gathered for one name agree in type there. The refusal branch is type-strict when it is drawn.
  • P3-3 and P3-4, the docstrings and comment. 6038's draft checked the path rule by running all three kernels. By reading, the new comment in ConceptsEncodeKernel.run matches the code: entities is the correspondence plus full.person_role_entities().
  • "Only column_kernels.py and its tests change." git show --stat 8a4e7a47 lists two files, 109 insertions and 8 deletions.
  • "The kernels that move are the ones that already move from main."
    • I registered 45 kernels by copying build_transport_registry's registration block; registry.py is identical in all three trees. The three simulate.* are bound to an AxiomEngine, and each tree was hashed with implementation_hash.
    • main → f905863c moves 10, and main → 8a4e7a47 moves the same 10. f905863c → 8a4e7a47 moves 6: calibration.prepare@1, concepts.encode@1, concepts.encode_groups@1, takeup.assign@1, transport.scenario_override@1 and transport.unit_attributes@1. That set is a subset of the 10.
    • The 6 are the five kernels register_column_kernels registers, plus calibration.prepare@1, whose implementation_hash imports column_kernels._columns (target_kernels.py:603). No simulate.* kernel moves, and a second fresh-process run at head gives identical hashes.
  • "The US worker identity stays put." I computed primary_qrf_worker_execution_binding() in fresh processes, with no "Duplicate source modules" error. f905863c vs head: primary_qrf_worker_bindings_semantically_equal is True, and semantic_identity_sha256 is identical. Only audit_aliases (sys_executable, sys_prefix, argv_template_0) differ, because the venvs live at different paths. Control: main vs head is not equal, with worker_module.transitive_imports_sha256 and transitive_environment_code_sha256 moved, from commits 1–3's change to concept_mapping.py.
  • NZ (item 7). NZ's mapping has no person_role binding, so person_role_entities() is empty and the guard can't fire for any engine_entity. I compared bytes at f905863c and head in two ways:
    • (a) The NZ toy graph (make_nz_toy_fixture with run_entitlement_graph): 136 nodes and 1,368 declared output columns, every one identical in dtype, ids and values. That includes nz.encode.person, nz.as.inputs and the V1, V3 and VHF encode nodes. Fourteen receipts differ, but only in content digests and store keys: nz.evidence.donor_receipt's donor_sha256, the preparation_artifact_keys, and the problem, result, solution, diagnostics, evidence, export, gate and package digests that follow from them. Each run built its own fixture. The synthetic donor H5 embeds its write time (two builds 2.5 s apart at head differ in bytes), so these receipt differences come from the two fixture builds, not from the commit.
    • (b) NZ's packaged nodes. The toy graph uses a toy mapping and has no scenario_override@1 node, so I also ran all 15 of NZ's packaged encode-family nodes: nz.encode.person, nz.as.inputs, nz.scn.S1–S3.inputs, and the V1–V4 and VHF encode.person and inputs. They used NZ's packaged mapping, closure, unit rule, as_encoding and each scenario's encoding_knobs, on 33 synthetic concept frames (309 persons, with NZ benefit units and NZ state columns). All 495 runs completed, and every column digest and receipt is identical between the two commits, with 19–33 distinct output sets per node.
  • The six mutants behave as claimed, with P3-1's caveat for values_only; see the table.

Mutants

On test_transport_column_kernels.py (36 tests), with -n 4 and pytest's random Hypothesis seed, in a scratch worktree of the head. Every patched file was restored. Baseline: 36 passed. The rates are the in-memory seeded runs from P3-1.

Mutant Fail Caught by
no_guard 1 test_group_kernels_refuse_a_person_role_entity
correspondence_only 3 the merge property, test_person_role_bindings_and_defaults_go_on_the_person_table, test_person_role_defaults_share_the_person_table_with_person_inputs
restricted_mapping 2 the merge property, …_share_the_person_table_…
separate_entities 2 the merge property, …_share_the_person_table_…
values_only 1 the merge property only, in 82/100 runs
last_wins 2 the merge property (100/100), …_share_the_person_table_…
mine: first_wins (a disagreeing default keeps the first) 2 the merge property (100/100), …_share_the_person_table_…
mine: isinstance_types 1 the merge property only, in 28/100 runs (P3-1)
mine: guard only in ConceptsEncodeGroupsKernel.run 1 test_group_kernels_refuse_a_person_role_entity, its scenario_override@1 arm
mine: guard_selected_paths 0 survives (P3-2)
mine: guard_hardcoded_child 0 survives (P3-2)
probe: the guard also refuses correspondence entities (P3-3's fix) 0 breaks no test in the file

Not run

  • I didn't redo 6038's done items: ruff, item 8 at head (935 passed, 2 skipped), 6036's repro, the path-rule docstrings, or its compose and run_graph step for P3-3.
  • No new CI run (I read the 10 check runs); no engine-us or engine-uk suites; no full engine-free suite.
  • No real NZ feed (NZ_CHRONICLE_ARTIFACT is not staged here), so NZ bytes come from the toy graph and synthetic frames.
  • In the toy graph I compared declared output columns and receipts, not weights or opaque artifacts byte for byte.
  • The US worker pool itself: I computed only its identity.
  • No Workflow runs.

Generated by Claude Code

MaxGhenis and others added 3 commits November 3, 2026 19:37
G landed on main as f8f543a after this PR's last CI run, which tested 8a4e7a4 merged into main 79a533c. GitHub's merge ref still has 79a533c as its first parent, so this merge starts CI on main as it now stands. G and this PR change no file in common, and git merge-tree writes tree 5c0a6b9 with no conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seven PRs reached main after this PR's last CI run, which tested 52fc34c on main f8f543a: #1345, #1493, #1467, #1426, #1465, #1391 and #1473. None of them changes a file this PR changes, and git merge-tree writes tree ed52268 with no conflict. This merge starts CI on main as it now stands, including the new installed-emitter job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three PRs reached main after this PR's last CI run, which tested 17e2643 on main 0f0e777: #1506, #1485 (H2) and #1501. None of them changes a file this PR changes, and git merge-tree writes tree 79ee630 with no conflict. H2 adds an NZ coresident binding and teaches test_support's reference concepts the coresident_adult transform, which the concept-mapping differential beside this PR's encode change reads, so this merge has CI test the two together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review November 4, 2026 19:55
@MaxGhenis
MaxGhenis merged commit 6448792 into main Nov 4, 2026
11 checks passed
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Landed as squash 6448792c on main, from head 7a3e756a (gh pr merge --squash --match-head-commit).

At merge time:

@MaxGhenis
MaxGhenis deleted the be/s2b-person-role branch November 4, 2026 20:37
MaxGhenis added a commit that referenced this pull request Nov 4, 2026
Since the merge base 875506e, main gained #1473, #1506, H2 (#1485),
#1501, #1480 and #1488. The merge is clean. Two files changed on both
sides, in separate hunks: compose.py (#1488 on main's side) and
test_transport_evidence_kernels.py (H2 on main's side).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Nov 5, 2026
…losure (S2b PR 1) (#1482)

* Add binding-local views to the Axiom adapter

AxiomEngine takes an optional views sequence of AxiomView declarations:
a logical engine entity no frame entity maps to (rulespec-be's Child), one
bool person selector column, and an outside value per output. The adapter
gathers the selected person rows in table order, runs the module's program
on the logical entity, checks the engine's array against the selected count,
and scatters it back through the same mask; every other row holds the
outside value, cast through the output's graph dtype. An empty selection
makes no engine call, and a null or non-bool selector refuses.

View outputs resolve to the person entity, so callers that key on
(binding entity, variable) read them unedited. Each view's program compiles
under its own cache key, re-checks the pinned RuleSpec tree first, refuses
dense relations, and contributes its derived metadata, so a Child-only
module has metadata. The selector joins the person entity's
relation_dependencies; view_root_inputs() reports each view's roots.
axiom_engine_ref writes views only when present, so every existing
reference is unchanged. The view code stays in axiom.py, because
source_hash hashes defining files and does not follow imports.

* Partition binding-local view roots in the input closure

closure_partition no longer refuses a binding that declares views, and
BINDING_LOCAL_VIEW_MESSAGE is gone. Per binding, each view's logical entity
(Child) joins the live entities and maps to the person table, so a view's
roots partition against the closure's Child entries and the existing frame
check reads them on the person slice. Roots on that entity stay unmapped on
every other binding, and unreached_roots still reads only the binding's
engine entity, so it cannot excuse a view root.

engine_root_inputs adds each view's roots under its logical entity through
AxiomEngine.view_root_inputs(), and refuses a view binding on an injected
adapter. binding_view_entities reads a binding's logical entities for the
closure and compose.

* Parse binding-local views in the registry and check them in compose

The registry parses a binding's views block into AxiomView declarations
(logical_entity, selector {column}, outside {name: value}) and builds the
adapter with them. It refuses an empty list, views beside relations, a
binding off the person entity, a logical entity that names a mapped engine
entity, a repeated logical entity, a selector with any field but column, a
structural selector, a selector that names a binding output, and an outside
key that names no binding variable. Once the adapter is pinned, it checks
each view's program for relations in place of the binding entity's program,
requires each view's outside keys to equal the binding's variables on its
logical entity, and refuses a view input named like a root input of the
binding entity's program. _check_engine compares an injected adapter's views
with the binding's and refuses views on an injected Python adapter.

binding_view_dependencies reports each selector as a whole-table person
slice, the same columns AxiomEngine.relation_dependencies reports to the
rules kernels. Compose unions them with the relation dependencies for every
rules node, refuses a view node whose person slice omits an input the
closure encodes (other than through a graph node) or defaults on the view's
logical entity, and requires each selector on the closure producer's person
slice.

* Pin NZ-shaped and relation engine references to goldens from the base

No NZ CI test builds an axiom_engine_ref for NZ's rows: NZ's engine-free
graph injects Python toy engines. The new golden holds references generated
at main 4a36edc for NZ's six binding rows (unreached_roots included) over a
synthetic tree, and for the registry and adapter relation fixtures, all
built engine-free through build_transport_registry and axiom_engine_ref.
The test compares them byte for byte, so a reference that gains
"views": [] or "views": null without a declared view fails.

A second test, gated on POPULACE_RULESPEC_NZ, builds NZ's six real
references from the tree alone and compares them with a frozen copy the NZ
hub writes at the base.

* Add the changelog fragment for binding-local views

* Test that every rules kernel requires a view's whole selector slice

SimulateRulesKernel._require_relation_inputs reads the adapter's
relation_dependencies, which now carry each view's selector, so a node
whose person slice omits or masks the selector refuses before the engine
runs. The test pins that path for a view adapter.

* Pin view code to axiom.py and the no-view outputs to the base path

The placement lint now walks every import node in axiom.py, relative and
function-body imports included, and a second test requires AxiomView and
every view helper to carry axiom.py's module name. A helper moved into a
module axiom.py already imports (materialize, concept_mapping) would leave
the simulate.* hashes unmoved by an edit to it; the module-name test
refuses that move, which the import pin alone passes.

P4 gains an output clause: with views=None, materialize returns the same
names, order, dtypes and values, through the same engine calls, as a
reference copy of main 4a36edc's no-view path kept in the test. NZ's CI
graph injects toy engines and never runs AxiomEngine.materialize, so this
is the executable guard on view-free outputs.

* Class a view's defaulted roots as person columns in the support roster

_support_roster mapped each defaulted closure entry to a frame entity
through entity_names alone, so a defaulted Child root, which PR 2 writes on
the person table, mapped to no entity and missed the closure_default class.
_closure_defaults now maps a binding-local view's logical entity to the
person entity for entries of that binding's module. Without views the
mapping is unchanged.

* Freeze NZ's real-tree engine references at the base

test_nz_real_refs_match_their_frozen_copy compares NZ's six real
references with a frozen copy, and wrote none without a rulespec-nz
tree. This adds the copy, generated at PR 1's base (main 4a36edc) from
a git archive of rulespec-nz 8dc2507d, the commit NZ's bindings pin.

The base run froze the file, and a second base run passed against it.
At this branch's head the test passes too, so the six references keep
their bytes with views in place. The CI golden test passed at the base
as well, which checks that axiom_engine_refs.json came from the base.

* Document binding-local views in the declared-units guide

The guide said binding-local views remained separate work. It now
describes the declaration, the gather and scatter, the outside values,
the registry's refusals, the selector slice, the closure's treatment of
view roots, and the row-locality assumption that the skip-gated
real-engine differential tests.

* Union shared-path closure roots to remove binding-order dependence

* Cover both logical views to catch dropped input roots

* Validate complete outside declarations before pinning bindings

* Preserve outside value types in view equality and hashing

* Exercise view property edges across every graph dtype

* Type and stabilize real Child differential inputs

* Freeze CI reference bindings to isolate NZ edits

* Clarify view evidence, serialization and selector refusals

* Accept overrides of a view selector that a program reads

The first review of the fix round (P2) found that 0d6201a's selector
refusal also refused an override of a selector that a Person program
reads as a root input. The registry accepts that shape, variables()
lists the selector, and before 0d6201a the counterfactual bridge
applied such an override. validate_input_override now names a selector
only when no program reads it, which is exactly when the bridge refuses
the override as outside the engine's inputs, so the check changes no
override the bridge applies.

test_a_selector_that_a_program_reads_stays_overridable drives the
counterfactual bridge with a Person program that reads is_child: the
override reaches that program's input and the rows the view selects. It
fails on 0d6201a's check. The refusal test now also runs through the
bridge, which calls no program. The guide states the rule.

* Correct the two docstrings that review B flagged

`_real_inputs` said the pinned engine refuses a wrong-typed input by name.
At axiom-rules-engine 753ce7d, `src/dense.rs` reports a bool, text or date
column as a type mismatch when a program needs a number from it ("expected
decimal-compatible dense column" for an arithmetic operand, "{side} side of
comparison is not numeric" for a comparison), and neither message names the
input. The docstring now says that.

`axiom_ref_goldens.py`'s module docstring wrote `"\n"` in a non-raw string,
so it rendered a line break where the regeneration recipe needs the two
characters. It now escapes the backslash.

Two merges of main bring this branch up to date: `02265bb5` merges main `875506e8`, and `36800b14` merges main `3e7a7045` (#1473, #1506, H2 #1485, #1501, #1480 and #1488). git merged both without conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant