Skip to content

fix(bridge): gate batched-list position_ids on the target model - #1627

Open
sohv wants to merge 2 commits into
TransformerLensOrg:dev-4.xfrom
sohv:fix/batched-list-position-ids-gate
Open

fix(bridge): gate batched-list position_ids on the target model#1627
sohv wants to merge 2 commits into
TransformerLensOrg:dev-4.xfrom
sohv:fix/batched-list-position-ids-gate

Conversation

@sohv

@sohv sohv commented Aug 8, 2026

Copy link
Copy Markdown

Fixes #1626.

Batched list input builds an attention_mask and position_ids itself so pad tokens don't contaminate the forward. The mask is safe for any model, but the position_ids were handed over unchecked — a forward taking neither position_ids nor **kwargs raises TypeError where it would otherwise have returned logits.

This is the gap that was raised while reviewing #1610. That PR added _accepts_derived_position_ids() and gated the main forward() derivation, but we left these two sites for a follow-up because I couldn't produce a model that demonstrably failed there. The LLaDA test harness builds a fixed-signature forward in-process, which reproduces it:

TypeError: TinyLLaDAModelLM.forward() got an unexpected keyword argument 'position_ids'
kwargs attempted: ['attention_mask', 'position_ids']

What I changed

Both sites now call the same _accepts_derived_position_ids() helper, so there's no new predicate. The attention_mask stays unconditional -it's safe for every model, and withholding it would reintroduce the padding contamination the branch exists to prevent.

Verification

One regression test in the LLaDA suite, red on dev-4.x with the TypeError above and green with the fix. It asserts both halves: no position_ids reaches the model, and the attention_mask still does. A single unbatched string was never affected and is covered as a control.

Full unit + integration + acceptance: 6594 passed, 1 failed. The failure is test_bridge_hooked_parity_multi_step_optimization, which fails identically on unmodified code and is --ignored on the macOS CI job. mypy clean.

@jlarson4 jlarson4 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @sohv! Sorry for the delayed review on this, I have been a bit under the weather.

This looks great, the forward portion is perfect. Just a couple notes on the _generate_tokens path below.

forward_kwargs["position_ids"] = position_ids
# Same target gate as the forward() path: the mask is safe
# for every model, the derived positions are not (#1626).
if self._accepts_derived_position_ids():

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a gate=False model that accepts position_ids (opt-125m), this gate makes cached steps fall into the torch.full(total_len - 1) fallback at line 2220, which counts pad slots, causing logit drift. Can you gate the continuation's derive/fallback injection starting at line 2213-2226 on the same helper?

torch.testing.assert_close(bridge_logits, reference_logits, rtol=1e-5, atol=1e-6)


def test_batched_list_input_does_not_inject_unsupported_position_ids() -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _generate_tokens portion of this fix is untested here and neither fixed-signature architecture can reach that site. Can you add a cached-vs-uncached batched-generate parity test on a gate=False model?

@sohv

sohv commented Aug 11, 2026

Copy link
Copy Markdown
Author

@jlarson4 A quick request to review this PR and merge it if you have no issues. I reviewed the PR again today but let me know if there is anything to be addressed here.

@jlarson4

Copy link
Copy Markdown
Collaborator

@jlarson4 A quick request to review this PR and merge it if you have no issues. I reviewed the PR again today but let me know if there is anything to be addressed here.

The review just posted! Sorry I didn't get it to you yesterday, was down with the flu.

@sohv

sohv commented Aug 11, 2026

Copy link
Copy Markdown
Author

@jlarson4 It's totally alright and thank you for highlighting these issues. I will work on fixing them and will update the PR soon.

sohv and others added 2 commits August 16, 2026 13:03
Batched list input builds an attention_mask and position_ids itself so pad
tokens don't contaminate the forward. The mask is safe for any model, but the
position_ids were handed over unchecked: a forward taking neither position_ids
nor **kwargs raises TypeError where it would have returned logits.

This is the gap jlarson4 raised while reviewing TransformerLensOrg#1610. That PR added
_accepts_derived_position_ids() and gated the main forward() derivation, but
these two sites were left for a follow-up because no model could be shown to
fail there. The LLaDA test harness builds a fixed-signature forward in process,
which reproduces it:

  TypeError: TinyLLaDAModelLM.forward() got an unexpected keyword argument
  'position_ids'

Gate both sites on the same helper. The attention_mask stays unconditional --
it is safe everywhere, and withholding it would reintroduce the padding
contamination this branch exists to prevent. A single unbatched string was
never affected, and the test asserts that alongside the batched case.

The regression test wraps its forward spy in functools.wraps: the gate reads
that forward's signature, so a bare (*args, **kwargs) wrapper would look like it
accepts position_ids and silently defeat the check under test.

Fixes TransformerLensOrg#1626

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review follow-up on TransformerLensOrg#1627. Gating only the batched-list prompt derivation
moved the injection rather than stopping it: every branch of the cached-step
block supplies position_ids, so a refused model fell through to the
torch.full(total_len - 1) fallback, which counts pad slots and is therefore
wrong per row for a left-padded batch.

Measured on hf-internal-testing/tiny-random-OPTForCausalLM, which the gate
refuses because OPTLearnedPositionalEmbedding consumes the mask and derives
its own positions, while its forward would accept the kwarg:

  before TransformerLensOrg#1627   cached steps [[9],[2]] [[10],[3]] [[11],[4]]  <- per row
  TransformerLensOrg#1627 as sent  cached steps [[9],[9]] [[10],[10]] [[11],[11]] <- pad slots
  now            cached steps None                              <- OPT derives

cached-vs-uncached max |logit diff| goes 7.45e-08 -> 2.98e-01 -> 7.45e-08,
so this was a regression the PR introduced and it is now removed.

Gate the whole three-way block rather than each branch, so a model that owns
its position derivation receives the mask alone, matching the uncached path.

Adds tests/integration/model_bridge/test_batched_generate_position_ids.py.
The _generate_tokens half of TransformerLensOrg#1627 had no coverage: neither fixed-signature
architecture in the suite can reach that site, since LLaDA raises
NotImplementedError on generate. A text-level parity test would not catch
this either, because greedy argmax absorbs the drift and the decoded strings
match in both states, so the tests compare logits and separately assert that
no position_ids reaches a refused model. Two of them are red on 15d9553.

Refs TransformerLensOrg#1626

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sohv
sohv force-pushed the fix/batched-list-position-ids-gate branch from 15d9553 to 6b509c3 Compare August 16, 2026 13:28
@sohv

sohv commented Aug 16, 2026

Copy link
Copy Markdown
Author

The cached-step fallback - Gating only the prompt derivation moved the injection instead of stopping it. Every branch of that block supplies position_ids, so a refused model fell straight through to torch.full(total_len - 1). Measured on facebook/opt-125m:

before this PR   cached steps [[9],[2]]  [[10],[3]]  [[11],[4]]   <- per row, correct
this PR as sent  cached steps [[9],[9]]  [[10],[10]] [[11],[11]]  <- pad slots counted
now              cached steps None                                <- OPT derives its own

Cached-vs-uncached max |logit diff| goes 9.5e-061.46e+009.5e-06, so the fix restores the pre-PR value exactly. I gated the whole three-way block rather than each branch, so a model that owns its position derivation gets the mask alone, matching the uncached path.

The test gap - LLaDA raises NotImplementedError on generate, so neither fixed-signature architecture can reach _generate_tokens at all and that half of the fix was entirely unverified.

I have added tests/integration/model_bridge/test_batched_generate_position_ids.py and it is to be noted that a cached-vs-uncached parity test on decoded text would not have caught this - greedy argmax absorbs the drift and the strings match in both states. The tests compare logits instead and separately assert the mechanism since the fallback supplies a per-batch constant that a coarser check can miss. Two of the five are red on the previous commit; distilgpt2 is the gate=True control and stays green throughout.

I also rebased onto current dev-4.x, since the branch was ~30 commits behind and re-ran everything afterwards so the numbers above reflect the pushed state. mypy clean. I couldn't run the full three-tier suite locally this time for environment reasons so the above is targeted verification and I'm leaning on CI for the wider check.

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.

[Bug Report] Batched list input injects position_ids into models whose forward cannot accept it

2 participants