Skip to content

fix(envoy): reject a rename alias that would shadow something - #701

Open
Hotragn wants to merge 1 commit into
ndif-team:0.8from
Hotragn:fix/rename-alias-collisions
Open

fix(envoy): reject a rename alias that would shadow something#701
Hotragn wants to merge 1 commit into
ndif-team:0.8from
Hotragn:fix/rename-alias-collisions

Conversation

@Hotragn

@Hotragn Hotragn commented Aug 27, 2026

Copy link
Copy Markdown

Based on 0.8. Found while checking whether #535 still reproduces there — it mostly doesn't (cache navigation resolves against the envoy tree now, so Butanium's minimal repro works), but the rename binding underneath it has three silent failure modes.

Summary

_bind_aliases binds each alias with object.__setattr__, which overwrites whatever name is already there without a word:

for alias in [aliases] if isinstance(aliases, str) else aliases:
    object.__setattr__(self, alias, target)
    self._aliases[alias] = path

Three things can be underneath.

1. An Envoy attribute — breaks the library's entry point

Envoy(Net(), rename={"b": "trace"})

model.trace is now an envoy, so opening a trace fails with a message about tensors:

TypeError: 'torch.Tensor' object does not support the context manager protocol
(missed __exit__ method)

rename={"b": "output"} did raise, but accidentally and from the wrong place — ValueError: Cannot access 'model.output' outside of interleaving, which never mentions rename.

2. A sibling module — silently wrong activations

e = Envoy(Net(), rename={"head": "b"})
e.b.path   # 'model.head'      <- the real `b` is still in the tree, unreachable by name

No error at any point. An intervention written against b lands on head. This is the one that concerns me most — it produces wrong numbers rather than a traceback.

3. An alias an earlier key already claimed

e = Envoy(Net(), rename={"b": "dup", "head": "dup"})
e._aliases     # {'dup': 'head'}   <- 'b' lost, by dict insertion order

docs/usage/rename-modules.md only advised around case 1 — "Avoid alias names that collide with Envoy attributes ... They will shadow or be shadowed by those attributes" — and said nothing about 2 or 3.

Fix

Raise at construction, naming the alias, the key it came from, and what it would displace:

ValueError: `rename` alias 'embed' for 'head' would shadow a child module of that
name on `model`. Aliases are bound as plain attributes, so this would make the
original unreachable by name while leaving it in the tree. Pick a different alias.

_alias_conflict finds an eproperty by scanning the MRO's own namespaces rather than via hasattr, so output's descriptor is detected without its __get__ running (reading it outside interleaving raises).

What is deliberately not changed

A key that resolves to nothing is still skipped silently. One rename is meant to be reusable across architectures that spell a module differently — {"attn": "att", "self_attn": "att"} binds whichever exists — and that's the nnterp usage behind #535. Breaking it would be worse than the bug.

Two related no-ops, each with a test, so that pattern keeps working:

  • Aliasing a key to its own name ({"attn": "attn"}) — how the cross-architecture form is normally written. Treated as a no-op, not a self-collision.
  • Two keys reaching one module through tied weights — the second binds the same object, so it's a no-op too.

The rule is identity-based: if the name already points at this envoy, nothing is displaced.

Verification

Collection vs 0.8 853 = 853 (identical; nothing dropped)
Full CPU suite 856 passed, 7 skipped, 1 xfailed, 0 failed
New tests failing without the fix 7 of 10

The 10 new tests in TestRename cover each collision (sibling child, Envoy.trace, the four eproperties, duplicate alias) and each non-collision (self-alias, unresolvable key, tied weights).

Checked against every existing rename= usage in the suite first — none of the aliases in use (my_mlp, blocks, layers, first_layer, second_layer, denoiser, e, emb, zero, m, first, block_mlp, first_mlp, second, model) collide, so this is not a breaking change for anything in tree. Worth a note in release notes regardless, since it turns a previously-silent case into an error.

0.8 @ 8f7546c, Python 3.14.3, transformers 5.15.1, torch 2.9.1, CPU.

`_bind_aliases` binds each alias with `object.__setattr__`, which overwrites
whatever name is already there without a word. Three things can be underneath,
and all three fail silently or far from the cause:

* **An `Envoy` attribute.** `rename={"b": "trace"}` replaces the `trace`
  method with an envoy, so `model.trace(x)` no longer opens a trace:

      TypeError: 'torch.Tensor' object does not support the context manager
      protocol (missed __exit__ method)

  `rename={"b": "output"}` did raise, but accidentally and from the wrong
  place -- `ValueError: Cannot access 'model.output' outside of interleaving`,
  which never mentions `rename`.

* **A sibling module.** `rename={"head": "b"}` leaves the real `b` in the tree
  but makes `model.b` read as `head`, so an intervention written against `b`
  lands on `head` and nothing says so. This is the one that worries me: no
  error at any point, just wrong activations.

* **An alias an earlier key already claimed.** `{"b": "dup", "head": "dup"}`
  resolves both, and one wins by dict insertion order.

`docs/usage/rename-modules.md` only advised around this ("Avoid alias names
that collide with Envoy attributes ... They will shadow or be shadowed"), and
said nothing about the sibling-module or duplicate-alias cases.

Raise at construction instead, naming the alias, the key it came from, and
what it would displace. `_alias_conflict` finds an eproperty by scanning the
MRO's own namespaces rather than via `hasattr`, so `output`'s descriptor is
detected without its `__get__` running.

Deliberately unchanged: a key that resolves to nothing is still skipped
silently. One `rename` is meant to be reusable across architectures that spell
a module differently (`{"attn": "att", "self_attn": "att"}`), and on any given
envoy only one spelling exists. Aliasing a key to its own name
(`{"attn": "attn"}`) is how that pattern is normally written, so it is treated
as a no-op rather than a self-collision -- as are two keys reaching one module
through tied weights. Tests cover all three.

Suite is unchanged apart from the additions: collection is identical to
`0.8` (853 both ways), and the full CPU run is 856 passed / 7 skipped /
0 failed. 7 of the 10 new tests fail without this change.
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