Mount-edge index-set renaming
An opt-in `index_set_rename` on a component mount edge, so one document can mount two components that use the same axis name at different lengths.
RFC — Mount-edge index-set renaming (index_set_rename)
Status: Draft (proposed changeset for review)
Issue: EarthSciML/EarthSciAST#198 item 4 — “Document-scoped index sets forbid two column
components with different NLEV”
Affects spec version: 1.0.0 (purely additive; no bump, no compat shims — a document that
does not use the field parses, resolves and emits byte-identically)
Scope: esm-spec.md §4.7 (“Index-set merge”) + §9.7.5 + the §9.6.6 diagnostics table;
esm-schema.json $defs/SubsystemRef (+ the four binding schema mirrors and the TS embedded
schema); conformance fixtures; binding loaders.
Depends on: template-library-imports.md (§9.7 as shipped) and
template-import-renaming.md (§9.7.7). This RFC closes that RFC’s §11 open question —
“Renaming at §4.7 subsystem edges — not needed so far (mount names already namespace),
revisit only on a concrete case.” #198 item 4 is the concrete case, and the parenthetical is
a false premise (§1.3).
1. Motivation
1.1 The reported case
EqWeFiC assembles WRF-in-.esm column physics by mounting components into one document and
wiring them with document-level coupling.variable_map. Two of those components are columns
over the same grid family — column_nonuniform_1d, whose axis is named lev — at different
lengths: a 59-layer atmospheric column and a 4-layer slab soil column. Each component closes
the grid library’s NLEV at its own import edge, so each contributes lev to the document
registry, one as {interval, 59} and one as {interval, 4}.
index_sets is a document-scoped registry, and the merge rule at both mount forms is
deep-equal-or-error, so the load fails:
[subsystem_index_set_conflict] index set 'lev' from subsystem ref './soil_column.esm'
(kind=interval, size=4) collides with a non-deep-equal declaration in the importing
document (kind=interval, size=59).
The reporter’s surface-layer + slab + PBL chain therefore has to be split into two separate assemblies, which defeats the point of the assembly.
1.2 The scoping itself is correct and must not change
Document scope for index_sets is load-bearing in at least six places, and each of them is
a deliberate, documented decision:
| Where | Why document scope |
|---|---|
ModelVariable.shape / Parameter.shape | “ordered list of index-set names drawn from the document-scoped index_sets registry” — how every array extent is resolved. |
aggregate ranges.<sym> = {"from": …} | The only declaration site for an iteration domain; a typo must be undefined_index_set, never a silent empty set. |
IndexSet.from_faq (kind: "derived") | §4.7 states explicitly that from_faq MUST resolve against the whole document’s expression nodes, and that node id is document-unique — a phase-6 correction made precisely because a per-model registry made a cross-model derived index set unresolvable (tests/valid/wildfire_atmosphere_ocean.esm). |
| §11.2 model dimensionality | Derived from how many index sets a model’s unknowns are shaped over. |
§9.6.1 where shape constraints | Named “as spelled in the CONSUMING document’s merged index_sets registry”. |
§2.1 coordinates | CF role follows the source array’s declared shape, matched by dimension identity, not by length, so that “two distinct same-length axes (e.g. atm_lat and ocn_lat in a coupled run) never cross-attach”. |
The deep-equal-or-error merge is equally load-bearing: tests/invalid/template_imports/
subsystem_index_set_conflict.esm exists precisely because the pre-pin behavior — importer
silently wins, mounted mesh’s own axis size discarded — was a real, silent corruption.
So the fix is not to re-scope index sets or to loosen the merge. It is to give the
assembler a way to say “this mount’s lev is not that mount’s lev”.
1.3 Why mount names do not already solve it
template-import-renaming.md §11 deferred subsystem-edge renaming on the grounds that
“mount names already namespace”. They do — for variables: after §4.7 resolution,
Parent.RefSubsystem.variable is a scoped reference and two mounts of one leaf keep their
unknowns apart. They do not for index sets, which are the one declaration kind that
deliberately escapes the mount and lands in a single document-wide registry. The premise was
false; #198 item 4 is the case §11 asked to be revisited on.
1.4 What is already possible, and what is not
There is a workaround, and it is worth stating because it covers a real slice of the problem (verified against the Python binding on the #198 reproducer):
The leaf may rename the library axis at its own §9.7.7 import edge —
{"ref": "./column_grid.esm", "bindings": {"NLEV": 4}, "rename": {"lev": "soil_lev"}}— and writesoil_levin its ownshapes and ranges. The assembly then loads, with{lev: 59, soil_lev: 4}in the document registry.
It does not cover the two cases that matter:
- A leaf you do not own. §9.7.1’s no-edit bar is about libraries, but the same logic applies to a third-party component: forking a component to rename an axis forfeits upstream fixes. Worse, it pushes the burden the wrong way — every reusable component would have to pre-namespace its axes on the guess that some future assembly will collide, which is exactly the “generic names are the point” argument §9.7.7 was written to answer for libraries.
- One component mounted twice. Two soil columns at two resolutions, a nested-domain pair, a convergence pair in one assembly: no leaf-side edit can express this, because both mounts read the same file. This is §9.7.7’s “one family, two instances” motivation, one level up the reference DAG, and it is inexpressible today.
2. Summary of the change
| # | Change | Where |
|---|---|---|
| A | index_set_rename on a mount edge — an optional {old: new} map on $defs/SubsystemRef, which is the schema shape of both the §4.7 subsystems.<k> = {ref} edge and the top-level models.<k> / reaction_systems.<k> {ref} mount. Applied at load to the fully-resolved mounted document, before its index_sets merge into the mounting document’s registry. Normative and implemented at the §4.7 subsystem edge in all five bindings; at a top-level model-ref mount it follows that form’s index-set merge, which is not yet uniform (§4.11). | schema + §4.7 |
| B | Transitivity list (normative occurrence sites) for a mount-edge index-set rename — a superset of §9.7.7’s, because a mount carries a whole component and not just declarations: it adds shape, Assertion.coords keys, and DataSourceSelectAxis.gated_by. | §4.7 |
| C | One new diagnostic, subsystem_index_set_rename_unknown_name; the three §9.7.7 rename diagnostics (template_import_rename_invalid, template_import_rename_collision) are reused verbatim. | §9.6.6 |
| D | Sharper subsystem_index_set_conflict / template_import_index_set_conflict text: name both contributors, both definitions, and the remedy. Independent of A–C and useful even if A–C never ship. | all bindings |
Explicitly NOT done (§5): component-scoped index sets; automatic namespacing of a mount’s
axes; a prefix counterpart; renaming anything other than index sets at a mount edge;
loosening the deep-equal merge.
3. Design
3.1 The field
"models": {
"Atm": { "ref": "./atm_column.esm" },
"Soil": { "ref": "./soil_column.esm", "index_set_rename": { "lev": "soil_lev" } }
}
and identically on a §4.7 subsystem edge:
"subsystems": {
"Soil": { "ref": "./soil_column.esm", "index_set_rename": { "lev": "soil_lev" } }
}
index_set_rename is an object mapping an index-set name as the mounted document sees it
to the name it takes in the mounting document. It composes with the edge’s existing fields:
bindings still closes the referenced document’s metaparameters (§9.7.6 site 3),
expression_template_imports still injects a discretization into the referenced component’s
scope (§9.7.10), and model / reaction_system still select among several components.
3.2 Edge pipeline (normative)
For one mount edge, in order — the §9.7.7 edge pipeline with the target kind changed from “library” to “component”:
- The target resolves in its OWN scope. The referenced document is loaded and resolved
as a complete document: its own subsystem refs, its own
expression_template_imports(with their §9.7.7 renames), this edge’sbindingsandexpression_template_importsinjection, its metaparameter close and fold, its §9.7.5 index-set merge, and the §9.6.3 fixpoint. This is §9.7.6 site 3’s rule verbatim (“a subsystem ref is resolved as a complete document and folded to concrete integers at the mount”). index_set_renameapplies to that resolved document, as ONE simultaneous substitution (swaps are well-defined; chains do not cascade), over the occurrence sites of §3.4.- The renamed document’s
index_setsmerge into the mounting document’s registry under their post-rename names, deep-equal-or-error exactly as today (§4.7 / §9.7.5), and the component splices in.
Consequently the map’s keys speak the mounted document’s own post-resolution vocabulary —
the names you would see if you loaded the leaf standalone and read its index_sets. If the
leaf itself imported a grid library under prefix: "g", its document-visible axis is g.lev
and the mount writes {"g.lev": "soil.lev"}. This is the same “target’s export vocabulary”
rule §9.7.7 states for import edges, and it is the only rule under which the mount edge is
writable without reading the leaf’s transitive import closure.
Renaming is per edge: step 2 covers what this referenced document declares and imports,
and an axis reaching the registry through a mount nested inside the referenced document is
renamed (or not) at that nested edge, by its own index_set_rename. This is not a limitation
dressed up as a rule — it is what keeps the semantics binding-independent. The bindings
already disagree about whether a nested mount’s axes land in the leaf’s registry (Rust, Go,
Julia) or go straight to the root’s (Python); making an outer rename responsible for inner
contributions would inherit that disagreement. Per-edge renaming composes down the reference
DAG by composition of the maps, never by cascade, and no binding has to decide whose edge a
nested contribution belongs to.
3.3 Domain: index sets only
A mount edge renames index sets and nothing else, because index sets are the only declaration kind that crosses a mount into document scope:
- Templates are component-local (§9.6.3 constraint 4); two mounts never see each other’s.
- Metaparameters are closed at the mount by
bindings/ defaults (§9.7.6 site 3); a mount is a closed build boundary, so there is nothing to re-export and nothing to rename. - Variables, parameters, species are namespaced by the mount key (§4.6).
- Node
ids are document-unique but are identities, not names in a vocabulary; see §6 open questions.
prefix is deliberately omitted (§5.4).
3.4 Transitivity — normative occurrence sites
A mount-edge index-set rename rewrites the declaration key and every reference to the old name inside the mounted document. The list is §9.7.7’s, plus the three sites that only exist because a mount carries a whole component rather than a set of declarations. Site 6b is a §9.7.7 site the pre-existing declaration walk did not cover; it is added to both walks here, so import-edge and mount-edge renaming stay one rule.
| # | Site | Note |
|---|---|---|
| 1 | index_sets registry key | The declaration itself. |
| 2 | IndexSet.of entries (ragged parent list) | §9.7.7 site; not to be confused with ranges.<sym>.of, which is a list of bound index symbols and is never rewritten. |
| 3 | aggregate ranges.<sym> → {"from": <name>} | §9.7.7 site. |
| 4 | Expression axis scalars wrt, dim, integral’s var | §9.7.7 site (§4.2). |
| 5 | integral lower / upper when the value is a bare string naming a renamed index set | §9.7.7 site; any other bound is an ordinary expression position and is left alone. |
| 6 | ExpressionTemplate.where.<param>.shape entries | §9.7.7 site (§9.6.1). Constraint keys are param names and are never rewritten. |
| 6b | aggregate join.<i>.on key-column entries (§4.9.5) | §9.7.7 site, added in this change and applied to §9.7.7’s own walk too. An on name resolves as a loop symbol, then the index set one of the node’s ranges draws {from}, then a data column (CONFORMANCE_SPEC §5.5.8); only the middle class is an axis, so an entry is rewritten iff it is a key of the rename map. A clause’s syms are bound symbols and are never rewritten. |
| 7 | ModelVariable.shape / Parameter.shape entries | New. The site that makes the whole mechanism work — a mounted component’s arrays are declared over the axis by name. |
| 8 | Assertion.coords KEYS (§6.6.5 PDE-aware assertions) | New. The keys are spatial index-set / dimension names. |
| 9 | DataSourceSelectAxis.gated_by (§8.9.2) | New. Names a kind: "derived" index set. |
Never rewritten, for the avoidance of doubt: IndexSet.from_faq and ExpressionNode.id (node
identities, not axis names); IndexSet.offsets / IndexSet.values / IndexSet.member_factor
(keyed-factor variable names — §9.7.7’s rebind domain, not rename’s);
aggregate.output_idx, ranges keys and a join clause’s syms (bound index symbols); coordinates.<k>.source
(a data-array name); every structural scalar in the §9.7.7 protected set (op, reduce,
semiring, manifold, fn, table, side, attrs, members).
3.5 Grammar and checks
- A key MUST name an index set of the resolved mounted document, else
subsystem_index_set_rename_unknown_name. Renames never invent names, matching §9.7.7’stemplate_import_rename_unknown_namerule andonly/bindingsbefore it. A silent no-op for an unknown key was rejected for the same reason it was rejected in §9.7.7: a misspelled axis would then reappear as the original collision, one layer removed from its cause. - A target MUST be a dotted identifier (segments
[A-Za-z_][A-Za-z0-9_]*joined by single dots), elsetemplate_import_rename_invalid— the §9.7.7 grammar, reused verbatim soatm.levandsoil.levare spellable. - Post-rename names MUST be distinct within the edge, else
template_import_rename_collision. - Identity entries are no-ops. An absent or empty map is the identity and MUST leave the mounted document byte-identical to today’s resolution.
- The map is not required to be total: axes the mount does not name pass through
unrenamed, which is what makes a deliberately shared axis (a common
cellsmesh, a commontime) still merge deep-equal across two mounts.
3.6 Round-trip
index_set_rename rides on a mount edge, and a mount edge is a load-time construct: after
resolution “the in-memory representation is identical to a file with all subsystems defined
inline” (§4.7). Like bindings and the §9.7.10 injection on the same edge, the field is
consumed at load and does not survive parse → emit; the emitted document carries the
inlined component with its axes already spelled under the post-rename names, and a registry
that already holds them. emit ∘ load remains a byte-wise fixed point, because the second
load has no edge left to rename.
4. Side effects — what this does and does not disturb
This is the section #198 asks for. Each row states the effect and the reason it is contained.
4.1 Documents that do not use the field
Unchanged, byte-for-byte, at every stage: the field is optional, absent everywhere in
tests/, and the loader’s behavior when it is absent (or empty) is the identity. No existing
document changes meaning; no golden moves. This is the single strongest argument for the
design over every alternative in §5.
4.2 Round trip (parse → emit)
See §3.6. Two second-order notes:
- The mounted component’s own
expression_templatesregistry survivesparse → emit(§9.6.4 rule 5) and is materialized into the spliced component. Under a rename it is materialized with renamedwrt/where.shape/{"from"}occurrences. That is correct and required — a rule instance that still matched the pre-rename axis would silently stop firing — but it does mean the emitted registry is not byte-identical to the leaf file’s, which is already true of any mount that binds metaparameters. - A template-library file is never mountable (
subsystem_ref_is_template_library), so the §9.6.4-rule-5 generic-load round-trip is untouched.
4.3 Interaction with expression_template_imports (§9.7.2 / §9.7.10)
Both directions were checked:
- The leaf’s own imports are resolved before the mount rename (pipeline step 1), so a leaf that already renames the library axis at its own edge is renamed again, from its post-rename name. Renames compose by composition of the two maps, never by cascade.
- The mount edge’s §9.7.10 injection merges the injected library’s
index_setsinto the document’s registry. Under this RFC those axes are part of the resolved mounted document at step 2, so they are renamed too. That is the behavior an assembler wants: injecting a stencil library at two mounts with two different gridbindingsis exactly the case that collides today, and the mount rename is what separates them. - Ordering hazard, flagged: if a binding ever moved the §9.7.10 injection’s index-set
merge to after the mount (rather than through the leaf’s resolution), the injected axes
would escape the rename. The pipeline in §3.2 pins the order to prevent it. No fixture
covers a mount that renames and injects —
mount_rename_two_columns.esmrenames only — so the order is pinned by prose alone; a mount edge carrying bothexpression_template_importsandindex_set_renameis the regression test this RFC still owes.
4.4 Interaction with §9.7.6 metaparameter substitution
None, by construction: the rename runs strictly after the mounted document’s metaparameters are closed and folded (§9.7.6 site 3, “resolved as a complete document and folded to concrete integers at the mount”), and it renames axes, not metaparameters. Two consequences worth stating:
- Index-set sizes are concrete integers by the time the rename runs, so a renamed axis can never carry a symbolic size into the mounting registry.
- §9.7.6’s
metaparameter_name_conflictcheck (“a metaparameter name MUST NOT collide with any variable, parameter, species, or index-set name visible in the document”) is evaluated on the mounting document’s post-merge registry, i.e. against the renamed names. A rename can therefore create a collision (index_set_rename: {"lev": "NLEV"}against a declared metaparameterNLEV) — which is correct: it fails loudly with the existing code, and the author picks another name.
4.5 Interaction with variable_map endpoints that name shaped variables
coupling.variable_map endpoints name variables (Atm.T, Soil.Tsoil), and this RFC
renames axes, so no endpoint spelling changes. Three real interactions:
- The document-level coupling is authored by the same assembler that wrote the rename, in the
post-rename vocabulary. An entry that still names
levafter both mounts renamed it away fails cleanly withundefined_index_setat the site that names it, not silently. - §10.5 regridding / lifting between differently-shaped endpoints is expressed as an ordinary
aggregatecoupling expression; its{"from": …}ranges name the merged registry, so they name post-rename axes. This is the interaction that makes the mechanism useful rather than merely legal:Atm.T[atm.lev] → Soil.Tsoil[soil.lev]needs both axes to exist simultaneously in one registry, which is precisely what is impossible today. - A
variable_mapwhose two endpoints are shaped over what were the same axis name and are now two distinct axes stops being a same-shape copy and becomes a shape mismatch. That is the truth being surfaced, not a regression — the two arrays were never the same length.
4.6 from_faq, node ids, and mounting one file twice
IndexSet.from_faq names an expression node by id, and id is document-unique (§4.7). A
kind: "derived" index set inside a mounted leaf is renamed at site 1; its from_faq is
not rewritten, because it is a node identity. That is right for a single mount.
Mounting the same file twice, however, splices the same node ids into one document twice
and is a duplicate-id load error today, independently of this RFC. So §1.4’s “one
component mounted twice” case is unblocked by this RFC only for leaves that assign no ids
(the overwhelming majority — id is optional and used only as a from_faq referent). Making
ids mount-relative is a separate change and is listed in §6.
4.7 Ragged index sets and keyed factors
A kind: "ragged" index set names its keyed factors (offsets, values) by bare variable
name, resolved out of a component. Two mounts of one mesh family therefore still collide on
those factor names even under an axis rename — the §9.7.7 rebind mechanism exists for
exactly this at import edges and has no mount-edge counterpart here. Deliberate: the reported
case is kind: "interval" columns, rebind’s cross-component story is unsettled (a mount’s
factors are component-scoped, so the target of a rebind would have to be a §4.6 scoped
reference into a sibling mount), and adding it speculatively is how §9.7 fields acquire
semantics nobody can state. Listed in §6.
4.8 Coordinates (§2.1)
coordinates entries name a data array, and their CF role is derived from that array’s
shape. Renaming an axis therefore changes a coordinate’s dimension identity, which is what
“matched by dimension identity, not by length” was written to want: after the rename,
atm.lev and soil.lev no longer cross-attach. No coordinates field names an index set
directly, so nothing in §2.1 needs a rewrite rule.
4.9 Diagnostics and existing invalid fixtures
tests/invalid/template_imports/subsystem_index_set_conflict.esm and
index_set_conflict.esm keep their codes: the field is absent there, so the collision still
fires. Only the message text changes (change D), and expected_errors.json pins codes,
not messages.
4.10 What could not be fully ruled out locally
Stated plainly, because the change touches the load pipeline:
- Only targeted tests were run. The shared fixtures and the five per-binding regression
tests pass, and Go
build+vet, Rustcargo check --liband TStsc --noEmitare clean, but neitherscripts/test-conformance.shnor any binding’s full suite was run locally (CI does that). The most likely place for a surprise is a fixture that already carried ashape/coordskey the new walk now visits — it should be inert there (the walk only rewrites names present in a non-empty rename map, and every existing fixture’s map is absent), but “should be inert” is an argument, not a test run. - A rename walk that misses an occurrence site fails loudly, not silently. Sites 7–9 are
new work in all five bindings. A miss surfaces as
undefined_index_set/array_shape_mismatchat the site that names the stale axis, never as a silently mis-sized array — but two bindings could still disagree about which loud failure they give for the same document. - Resolved (§4.11). This entry recorded that the top-level
models.<k>{ref}form did not resolve the leaf as a complete document in Julia or Rust, so the field was refused there. Every binding now runs one edge pipeline at both attachment points, so the leaf is a resolved document at either, and the refusal is gone at bothmodelsforms. What the entry was really about survives one form further out: the top-levelreaction_systems.<k>inliner, which only Julia has and which is still that raw pre-pass. - Interaction with an inline test’s ephemeral build (§9.7.10 timing regime 2). A test’s injection rebuilds the enclosing component per test; a mount rename belongs to the composed document and should be invisible to a leaf’s own tests run standalone. Believed fine (the ephemeral build starts from the persisted component, which the mount never mutates on disk) but not exercised by a fixture here, and #198 item 2 is separately re-litigating when a mounted leaf’s tests run at all.
4.11 Where the field reaches today, per binding
$defs/SubsystemRef is the schema shape of both mount forms, so the field is normative at
both (esm-spec §4.7 “Where it applies”). What differs is whether a binding can apply it at
the top-level models.<k> / reaction_systems.<k> {ref} form, and that follows how each
binding inlines that form:
| Binding | §4.7 subsystem edge | Top-level models.<k> {ref} mount |
|---|---|---|
| Julia | implemented | implemented (2026-09-11) — _inline_toplevel_model_refs! runs the same §4.7 edge pipeline _resolve_subsystem_ref runs, so the leaf resolves in its own scope and there is a resolved mounted document to rename |
| Rust | implemented | implemented (2026-09-10) — inline_toplevel_model_refs runs the same §4.7 edge pipeline resolve_value runs at a subsystems.<k> edge |
| Python | implemented | implemented — both forms share _load_ref_data, which resolves the leaf fully at the mount |
| TypeScript | implemented | implemented — inlineTopLevelModelRef calls the same resolveRefEdge / resolveRefDocument the subsystem walk calls; previously a bare {ref} stub was left in place and the edge silently ignored |
| Go | implemented | implemented — inlineTopLevelModelRefs hands the edge to resolveSubsystemMap, the one edge-pipeline implementation in that binding, through a one-entry holder map. ESMFile.Models is a map[string]Model, so previously the mount decoded to an EMPTY model and the ref, bindings and index_set_rename were all discarded with no diagnostic |
One attachment point still refuses: Julia’s top-level reaction_systems.<k> inliner, which no
other binding mounts at all. It is still a raw pre-pass that defers the leaf’s §9.7 resolution
to the root, so there is no resolved mounted document to rename and the edge raises
subsystem_index_set_rename_unsupported_mount_form.
Refusing rather than ignoring is the load-bearing part, and it became load-bearing when #198
item 3 landed: that change makes the top-level form MERGE the leaf’s index_sets. An ignored
index_set_rename there would merge the leaf under its pre-rename axis names — silently, in
the one place the field exists to make loud. That is what the refusal was holding shut, and
applying the field at both forms is what opened it properly. The four bindings that do not mount
a top-level reaction_systems.<k> {ref} at all — Rust, TypeScript, Go and Python — refuse the
whole entry with mount_form_unsupported at /reaction_systems/<k> (esm-spec §4.7 “Two mount
forms, one mechanism”, §9.6.6), so the field is not silently ignored there either. Implementing
that form in those four is follow-up work.
4.12 Mounting an assembly — the mount form composes with itself
Found reviewing the top-level-form port, and fixed with it. A mounted document may itself be an
assembly, whose own models.<k> entries are {ref} mount edges. The top-level form lands its
component as a top-level system, and a top-level system is exactly what that form mounts, so
the composition is not an extra feature — it is what the form already claims to do.
Measured on main before the fix, with a three-file chain (root mounts an assembly, the
assembly mounts a component):
| Binding | chained top-level mount | mount cycle |
|---|---|---|
| Rust | resolves | circular top-level model reference detected |
| Julia | resolves | SubsystemRefError |
| Python | AttributeError: 'dict' object has no attribute 'name' | same |
| TypeScript | loads clean, splicing the leaf’s unresolved {ref} edge in as the component | same, no error |
| Go | ambiguous_subsystem_ref — “resolves to a component that is not a model”, which it is | same |
The TypeScript row is the one that matters: a document that mounts an assembly loaded with a bare mount edge sitting where a model belongs, and a mount cycle came back as a resolved document pointing at itself. That is the silent-data-loss shape the top-level port exists to remove, one level deeper.
The same gap existed at the subsystems.<k> form in four of five — only Rust composed there
— because which attachment point mounted a file cannot change what that file is. Go and
TypeScript answered it by splicing in the unresolved edge (silent); Python and Julia by failing.
Both forms are fixed in all five, and the rule is now normative in esm-spec §4.7 (“Mounting an
assembly”). Cycle detection is path-scoped everywhere, so one component file may still be
mounted under several keys.
Fixtures: tests/valid/mount_chain_inner.esm (a one-model assembly, mountable), and the two
that mount it — mount_chain_outer.esm at the top-level form, mount_chain_via_subsystem.esm
at the subsystem form. Cycles are pinned per binding rather than in the shared corpus, because
the bindings’ circular-reference diagnostics do not share a code.
One limit stands: Julia’s top-level inliner reads its targets with isfile, so an assembly
reached over http(s):// does not compose there. No fixture, and no binding disagrees about a
document — only about how far a remote chain reaches.
5. Alternatives considered and rejected
5.1 Component-scoped index sets (the issue’s other suggestion)
Give every component its own registry, with the document registry as a fallback. Rejected.
It contradicts six documented decisions at once (§1.2 table): from_faq would become
unresolvable across models again — undoing the phase-6 correction that made
wildfire_atmosphere_ocean.esm loadable — §9.6.1 where constraints would lose their
stated referent (“the CONSUMING document’s merged registry”), §2.1’s dimension-identity
matching would need a scope qualifier, and every shape resolution in every binding, every
runner, and every flatten path would need a scope parameter threaded through it. It is also
strictly more than the reported case needs: the assembler wants two axes to coexist and be
wireable to each other, not to be invisible to each other. And a fallback rule (“look in the
component, then the document”) reintroduces shadowing, which §9.7.6 explicitly bans
(“there is no shadowing”).
5.2 Automatic namespacing at the mount (<mountkey>.<axis>)
Rename every mounted axis silently. Rejected. It breaks the mounted-mesh pattern that
§4.7 was written to make sound — an importer deliberately shaping its own variables over a
mounted mesh file’s cells — and it would break tests/valid/subsystem_index_set_merge.esm,
whose entire point is that the host’s diag is shaped over the mesh file’s axis. Silent
namespacing also turns a deliberate shared axis into two axes with no diagnostic at all,
which is the failure mode §1.2’s conflict pin was introduced to eliminate.
5.3 Namespace only on collision
Auto-rename only when a collision would otherwise fire. Rejected. The resulting name depends on mount order, which makes it non-deterministic under a reordering that is otherwise semantically inert, and the assembler has to discover the invented name to wire coupling to it. §9.7 has consistently chosen “loud and explicit” over “clever and implicit”.
5.4 A prefix counterpart to §9.7.2’s
"prefix": "soil" renaming every surviving axis to soil.<axis>. Deferred, not rejected
— it is a strict superset of the mechanism and can be added later without changing anything
here. Left out of v1 because at a mount (unlike a library import) the axes are usually
mostly-shared and only one or two collide: a blanket prefix would silently detach the
deliberately-shared ones (cells, a common vertical coordinate), which is §5.2’s failure mode
wearing an opt-in hat. Explicit rename makes the author say which axes are private.
5.5 Loosen the merge to allow same-name-different-definition
Rejected outright. It is the exact silent corruption
tests/invalid/template_imports/subsystem_index_set_conflict.esm was added to pin: the
importer’s declaration wins and the mounted component’s arrays are silently allocated at the
wrong length.
5.6 Status quo — leaf-side rename only
Rejected as insufficient, see §1.4: it cannot express one component mounted twice, and it requires every reusable component to pre-namespace defensively.
5.7 A rename spelled inside the ref string
"ref": "./soil.esm as soil". Rejected for the reason §9.7.7’s own alternatives section
gives: string micro-syntax inside a field that is otherwise a verbatim §4.7 reference, which
breaks URL refs and offers no per-name control.
6. Open questions for a human to settle
- Field name.
index_set_renameis explicit about its domain, at the cost of not matching §9.7.2’s barerename. ReusingrenameonSubsystemRefwould read symmetrically but would imply the §9.7.7 domain (templates ∪ index sets ∪ open metaparameters), only one third of which crosses a mount. Recommendation: keepindex_set_rename. - How does the top-level
models.<k>{ref}mount get to APPLYING the field? (§4.11.) Resolved for Rust (2026-09-10): Rust took the first horn — a mounted leaf is a closed build boundary at BOTH forms — and the cost item 3 named is smaller than it looked. The loader-API metaparameters still reach the leaf, by the backfill Python already used: the LOADER-API bindings (§9.7.6 site 4) seed the leaf’s close for the names the LEAF declares, and explicit edgebindings(site 3) win over them. The mounting document’s own declared defaults are NOT forwarded — they are its site-5 close, not a binding on anything it mounts, and forwarding them would let an assembler’s unrelated name silently resize a leaf axis and make the two mount forms disagree. What is genuinely lost is an axis sized by a name only the ASSEMBLER declares — with no leaf machinery it merges symbolically and the root’s close resolves it, but a leaf that has any §9.7 machinery folds strictly and rejects it withmetaparameter_unbound. The strictness half is now SETTLED, in the permissive direction. A mount edge is not the last scope that could close a name — the leaf’sindex_setsmerge into the mounting registry immediately afterwards — so an axis the leaf cannot size stays symbolic and travels up, at both mount forms, whatever machinery the leaf carries (§4.7, §9.7.6 site 5). That also retires the footgun this item named: strictness was a whole-document boolean, so a component’s acceptance flipped on anexpression_template_importsentry unrelated to the axis in question, and factoring a shared expression into a library could stop a document’s shape from resolving. A ROOT document has no enclosing scope and stays strict. A second consequence of merging a symbolic size is worth settling with it: on the no-machinery path the registry merge compares declarations structurally, so a mount that restates the leaf’s axis VERBATIM (size: "n_rows") is idempotent while one that restates it with the concrete number the name folds to (size: 7) is asubsystem_index_set_conflict. Python agrees, so it is convergent behaviour, but the asymmetry is surprising to an author. Julia took the same horn on 2026-09-11, porting the Rust pipeline verbatim including the backfill precedence. TypeScript and Go implemented the form on 2026-09-12 (#198 item 4), each by routing a top-level edge into the edge-pipeline implementation it already had rather than writing a second one. Status is now: honoured at the top-levelmodels.<k>form by ALL FIVE; refused withsubsystem_index_set_rename_unsupported_mount_formonly at Julia’s top-levelreaction_systems.<k>mount, which no other binding implements. Porting it surfaced a THIRD consequence that is not convergent, and it is a verdict split rather than a value one: when an assembly both declares the metaparameter AND restates the leaf’s symbolic axis verbatim, Rust and Julia merge on the raw document — before their own root close — so the two declarations are deep-equal and the document loads, while Python merges after its root close and seessize: 7against the leaf’ssize: "n_rows", which issubsystem_index_set_conflict. Deleting the restatement loads in all three. The split PREDATES this change — Rust accepted that document onmaintoo, for a different reason (the top-level form merged no leaf axes at all, so nothing could collide), and Python has always refused it — so nothing regressed here; the port merely made the two bindings agree with each other and put the disagreement with Python in plain sight. Where the §4.7 merge sits relative to the mounting document’s own §9.7.6 close is the question to settle, and it belongs with this item. Two further things are true of that split and worth stating plainly, because they bear on how much the open question costs in practice. First, it makes “drop the leaf’s declaration and let the assembler declare the name” a non-portable workaround rather than a remedy: the same two documents give a concrete axis in Rust and Julia and an unfolded one in Python. Second, Python does not so much ACCEPT that document as decline to decide it — the load succeeds with the merged axis still carrying its symbolicsize, because nothing foldsindex_setsafter the merge on that path. A reader comparing verdicts sees “passes” in all three and a different resolved document in one, which is the worst shape a divergence can take. Settling where the §4.7 merge sits relative to the mounting document’s own §9.7.6 close is what closes both. TypeScript and Go landed on Python’s side, and by construction rather than by choice (2026-09-12). Both already merged asubsystems.<k>mount’s axes AFTER their own root close — TypeScript becauseloadStringfolds the document beforeresolveSubsystemRefsruns, Go because its typedESMFile.IndexSetsis decoded from the post-close text — so putting the top-level form at the same pipeline point is what “a binding MUST NOT make the two forms differ” required, and it carried the merge order with it. The count is therefore three-to-two, and the three are the side on which the #198 fold-before-merge report does NOT reproduce: on the pre-close side a leaf that DOES close (any leaf with §9.7 machinery) contributessize: 40against an importer’s still-symbolicsize: "NLEV", which issubsystem_index_set_conflictfor two identical declarations. That is the same document the reporter filed, and it still fails in Rust at both forms and in Julia at the top-level form. Settling the merge order settles that report too; they are one question. A THIRD answer exists and is not a side at all: Julia at asubsystems.<k>mount, and Go at either mount form, cannot represent a symbolicsize— both index-set types hold an integer — so they abort the mount with a rawMethodError(Int64, ("n_rows",))/json: cannot unmarshal string into ... IndexSet.sizerather than a diagnostic. Whichever side wins, those two need the representation before they can implement it. SETTLED (2026-09-13): post-close, and the contribution FOLDS as it merges. Neither side in the field was right, and the majority was not the tell — it was three copies of the same omission. The specification says post-close twice and normatively (§9.7.6 site 3, “the mounting document closes its own metaparameters before its refs resolve”; “Ordering within load”, “subsystem refs resolve post-close”), and the step list that appears to say otherwise is §9.7.5’s IMPORT merge, whose neighbour in that list is “resolve imports”. But post-close ALONE is what Python, TypeScript and Go were doing, and it published a resolved document whose axis still carried a metaparameter name — contradicting the same paragraph’s “the mounted form is fully concrete when it splices in” and site 5’s “closed at some enclosing document’s close”. So the answer is post-close PLUS a fold of the contribution against the mounting document’s closed environment, before the deep-equal comparison. It is the only one of the three behaviours that is right on both halves. Measured across all five bindings on the reporter’s own shape — an assembly and its leaf both declaringNLEV: 40andlev: {size: "NLEV"}— the pre-close side raisedsubsystem_index_set_conflictfor two IDENTICAL declarations (Rust at both mount forms, Julia at the top-level form only, which was itself a §4.7 “two mount forms” violation), while the post-close side loaded it at 40. On the assembler-scoped shape (tests/fixtures/data_source_extent_scope/assembler_root_{no,with}_import.esm) the post-close-without-fold side publishedsize: "N_REC"unfolded. Both now resolve to the one answer in all five. Blast radius, measured before implementing: of 671.esmdocuments undertests/, exactly 2 have the precondition that distinguishes the orders — those two fixtures, added by the same change — and all 112tests/validdocuments resolved identically under a pre-close and a post-close binding, so no document that loaded stopped loading. The asymmetry this creates with the RENAME is deliberate and is now stated in §4.7: an enclosing edge may not rename an axis only a nested mount contributed, but it does close such an axis’s size. A name is authored vocabulary; a size is a value §9.7.6 site 5 chains upward. Implementation note. Inlining and merging had to be split, because they were one step in every binding: the referenced CONTENT must be spliced in early enough for the rewrite-rule fixpoint to lower through it, while theindex_setsit contributes must wait for the close. Rust and Julia stage the root’s contributions during ref resolution and apply them immediately after the root’sresolve_template_machinery; nested scopes had already closed by the time they merged, so only the root’s merge moved. This matters for the reporter: EqWeFiC’s assemblies use top-levelrefmounts, which now apply the field in all three bindings that implement the form. (An earlier draft said they were pushed to the top-level form becausevariable_mapcannot reach into a subsystem. That is wrong, and #198 item 1 established why:variable_mapresolves subsystem endpoints in all five bindings — into a nested parameter, out of a nested unknown, between two subsystems of one wrapper, with the subsystems mounted by{"ref": …}— verified end-to-end. The real defect was that an endpoint resolving to nothing was silently dropped rather than diagnosed. So the top-level form is a choice here, not a forced move, andsubsystems.<k>is available.) - Mount-relative node
ids (§4.6) — required before “one component mounted twice” works for a leaf that assignsids. Separate change, separate RFC. - A
rebindcounterpart for ragged keyed factors at a mount edge (§4.7). Needs a motivating case and a decision on whether a mount’s rebind target may be a §4.6 scoped reference into a sibling mount. - Should
prefix(§5.4) ship at all, or is explicit-only the permanent answer?
7. Diagnostics
| Code | Meaning |
|---|---|
subsystem_index_set_rename_unknown_name | A mount edge’s index_set_rename key names an index set the resolved mounted document does not declare (§4.7). The mount-edge mirror of template_import_rename_unknown_name. |
template_import_rename_invalid (reused) | An index_set_rename target is not a valid dotted identifier, or the map is not a string→string object. |
template_import_rename_collision (reused) | Two keys of one edge map onto one target name. |
subsystem_index_set_conflict (text sharpened) | Now names both contributors (the mounting document, or the earlier mount, and this mount), both definitions, and points at index_set_rename. |
8. Fixtures
Shared corpus (every .esm under tests/valid/** and tests/invalid/** is swept by
scripts/conformance_corpus.py in all five bindings):
tests/valid/mount_rename_atm_column.esm(lev= interval/59) andtests/valid/mount_rename_soil_column.esm(lev= interval/4) — two components of the samecolumn_nonuniform_1dfamily that independently spell their axislevat different lengths; each loads standalone.Each declares
levin its own top-levelindex_setsrather than importing it from a shared grid library. That is a corpus constraint, not a design one: four bindings (Julia, Go, TypeScript, and the Python sweep) run a reference-resolution pass over everytests/valid/**fixture from the raw document, where an axis contributed by an unresolvedexpression_template_importsedge is invisible and anaggregaterange over it is a falseE_REF_UNDECLARED_INDEX_SET. Renaming an axis that arrives through a template import is normative (§4.7 edge pipeline step 1), but it currently has no regression fixture; covering it needs either an inline per-binding test or a corpus sweep that loads before it resolves.tests/valid/mount_rename_two_columns.esm— the #198 item 4 reproducer with the fix applied: the soil mount carriesindex_set_rename: {"lev": "soil_lev"}and the merged registry holds bothlev(59) andsoil_lev(4).tests/invalid/template_imports/mount_rename_unknown_index_set.esm(+expected_errors.json,resolver_only) —subsystem_index_set_rename_unknown_name.tests/valid/mount_rename_two_columns_toplevel.esmandtests/invalid/template_imports/mount_rename_unknown_index_set_toplevel.esm— the same two assemblies written at the TOP-LEVELmodels.<k>attachment point. They are the differential test §4.7 “Two mount forms, one mechanism” asks for: a binding that applies the field at one form and ignores it at the other passes the first pair and fails this one. Each mounts the same leaves as its sibling, so the two pairs differ in exactly the attachment point.
Per-binding regression tests (mount_index_set_rename / mount-index-set-rename in each
package) pin two things in all five: the rename rewrites the registry key and the mounted
component’s variable shape and aggregate {"from"} range while leaving the sibling mount
untouched, and an unknown key is a loud load error. Two more are pinned only in Python:
that the same pair still collides without the field
(test_without_the_rename_the_two_columns_still_collide), and that two keys onto one target
is template_import_rename_collision (test_two_rename_keys_onto_one_target_is_a_collision).
Porting those two to the other four bindings is left open.
A post-lowering expanded.esm golden under tests/conformance/ was deliberately not
added: the fixture’s value is the registry and the mounted component’s spelling, both of which
the per-binding tests assert directly, and a new golden would need the generator script run
across bindings this PR does not otherwise touch.
9. Porting checklist (done in all five)
Per binding, at the single point where a mount edge merges the referenced document’s
top-level index_sets into the mounting registry:
- Read and validate
index_set_renameoff the edge (string→string object; dotted-identifier targets; distinct targets) — reuse the existing §9.7.7name_mapso the grammar diagnostics are shared. - Check every key against the resolved mounted document’s
index_setskeys. - Apply one simultaneous substitution over the §3.4 sites of the resolved mounted document.
Note the walk is a NEW one, not the §9.7.7 declaration walk: in a whole component
fromalso names a data source (Parameter.update.from), a coupling endpoint (variable_map.from) and a connector endpoint, so the mount walk keys off position (anop-bearing node’sranges) rather than the barefromkey, and never rewrites a bare string on its own account. - Merge under the post-rename names, unchanged.
- Drive the shared fixtures.
Landing points: Rust template_imports.rs::apply_mount_index_set_rename called from
ref_loading.rs::resolve_value; Python template_imports.apply_mount_index_set_rename
called from parse._load_ref_data; Julia template_imports.jl::apply_mount_index_set_rename
called from resolve.jl::_lower_and_coerce; TypeScript
template-imports.ts::applyMountIndexSetRename called from ref-loading.ts::resolveRefDocument;
Go template_rename.go::applyMountIndexSetRename called from
subsystem_ref.go::resolveSubsystemMap.
Every one of those is reached from BOTH attachment points, which is the property that makes
step 1’s “the target resolves in its OWN scope” mean the same thing at either: Rust’s
inline_toplevel_model_refs and Julia’s _inline_toplevel_model_refs! each inline the same
pipeline their subsystem edge runs, Python’s two forms share _load_ref_data outright, and
TypeScript’s inlineTopLevelModelRef and Go’s inlineTopLevelModelRefs route a top-level edge
into the existing subsystem-edge code rather than duplicating it. A second implementation of the
pipeline is the thing to avoid here — not because it would be more code, but because “the two
forms differ only in where the mounted component lands” then holds by construction instead of by
two files agreeing.