stackformers 3.9.1__tar.gz → 3.9.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. stackformers-3.9.2/CHANGELOG.md +219 -0
  2. {stackformers-3.9.1 → stackformers-3.9.2}/PKG-INFO +1 -1
  3. {stackformers-3.9.1 → stackformers-3.9.2}/pyproject.toml +1 -1
  4. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/positional/rope2d.py +15 -5
  5. {stackformers-3.9.1 → stackformers-3.9.2}/tests/positional/test_rope.py +163 -8
  6. {stackformers-3.9.1 → stackformers-3.9.2}/uv.lock +1 -1
  7. {stackformers-3.9.1 → stackformers-3.9.2}/.claudeignore +0 -0
  8. {stackformers-3.9.1 → stackformers-3.9.2}/.gitignore +0 -0
  9. {stackformers-3.9.1 → stackformers-3.9.2}/.python-version +0 -0
  10. {stackformers-3.9.1 → stackformers-3.9.2}/.vscode/settings.json +0 -0
  11. {stackformers-3.9.1 → stackformers-3.9.2}/CLAUDE.md +0 -0
  12. {stackformers-3.9.1 → stackformers-3.9.2}/Justfile +0 -0
  13. {stackformers-3.9.1 → stackformers-3.9.2}/LICENSE +0 -0
  14. {stackformers-3.9.1 → stackformers-3.9.2}/PLAN.md +0 -0
  15. {stackformers-3.9.1 → stackformers-3.9.2}/README.md +0 -0
  16. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/__init__.py +0 -0
  17. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/attention/README.md +0 -0
  18. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/attention/__init__.py +0 -0
  19. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/attention/bias.py +0 -0
  20. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/attention/config.py +0 -0
  21. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/attention/cross_attn.py +0 -0
  22. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/attention/ops.py +0 -0
  23. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/attention/protocols.py +0 -0
  24. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/attention/self_attn.py +0 -0
  25. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/attention/varlen_backend.py +0 -0
  26. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/config.py +0 -0
  27. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/cross_attender.py +0 -0
  28. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/decoder.py +0 -0
  29. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/encoder.py +0 -0
  30. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/feedforward/README.md +0 -0
  31. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/feedforward/__init__.py +0 -0
  32. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/feedforward/config.py +0 -0
  33. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/feedforward/factory.py +0 -0
  34. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/feedforward/geglu.py +0 -0
  35. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/feedforward/protocols.py +0 -0
  36. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/feedforward/relu_squared.py +0 -0
  37. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/feedforward/swiglu.py +0 -0
  38. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/layers.py +0 -0
  39. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/norm/README.md +0 -0
  40. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/norm/__init__.py +0 -0
  41. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/norm/config.py +0 -0
  42. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/norm/factory.py +0 -0
  43. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/norm/protocols.py +0 -0
  44. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/positional/README.md +0 -0
  45. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/positional/__init__.py +0 -0
  46. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/positional/config.py +0 -0
  47. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/positional/factory.py +0 -0
  48. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/positional/learned.py +0 -0
  49. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/positional/none.py +0 -0
  50. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/positional/protocols.py +0 -0
  51. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/positional/rope1d.py +0 -0
  52. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/presets/README.md +0 -0
  53. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/presets/__init__.py +0 -0
  54. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/presets/cross_attender.py +0 -0
  55. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/presets/decoder.py +0 -0
  56. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/presets/encoder.py +0 -0
  57. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/presets/pyramid_vision.py +0 -0
  58. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/sequence.py +0 -0
  59. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/spatial/README.md +0 -0
  60. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/spatial/__init__.py +0 -0
  61. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/spatial/config.py +0 -0
  62. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/spatial/factory.py +0 -0
  63. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/spatial/input.py +0 -0
  64. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/spatial/kv_reduction.py +0 -0
  65. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/spatial/layer.py +0 -0
  66. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/spatial/patch_merging.py +0 -0
  67. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/spatial/protocols.py +0 -0
  68. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/spatial/spatial_reduction.py +0 -0
  69. {stackformers-3.9.1 → stackformers-3.9.2}/stackformers/spatial/window.py +0 -0
  70. {stackformers-3.9.1 → stackformers-3.9.2}/tests/__init__.py +0 -0
  71. {stackformers-3.9.1 → stackformers-3.9.2}/tests/attention/__init__.py +0 -0
  72. {stackformers-3.9.1 → stackformers-3.9.2}/tests/attention/test_cross_attn.py +0 -0
  73. {stackformers-3.9.1 → stackformers-3.9.2}/tests/attention/test_kernels.py +0 -0
  74. {stackformers-3.9.1 → stackformers-3.9.2}/tests/attention/test_ops.py +0 -0
  75. {stackformers-3.9.1 → stackformers-3.9.2}/tests/attention/test_self_attn.py +0 -0
  76. {stackformers-3.9.1 → stackformers-3.9.2}/tests/attention/test_varlen_backend.py +0 -0
  77. {stackformers-3.9.1 → stackformers-3.9.2}/tests/conftest.py +0 -0
  78. {stackformers-3.9.1 → stackformers-3.9.2}/tests/feedforward/__init__.py +0 -0
  79. {stackformers-3.9.1 → stackformers-3.9.2}/tests/feedforward/test_geglu.py +0 -0
  80. {stackformers-3.9.1 → stackformers-3.9.2}/tests/feedforward/test_relu_squared.py +0 -0
  81. {stackformers-3.9.1 → stackformers-3.9.2}/tests/feedforward/test_swiglu.py +0 -0
  82. {stackformers-3.9.1 → stackformers-3.9.2}/tests/positional/__init__.py +0 -0
  83. {stackformers-3.9.1 → stackformers-3.9.2}/tests/positional/test_learned_pos.py +0 -0
  84. {stackformers-3.9.1 → stackformers-3.9.2}/tests/presets/__init__.py +0 -0
  85. {stackformers-3.9.1 → stackformers-3.9.2}/tests/presets/test_cross_attender.py +0 -0
  86. {stackformers-3.9.1 → stackformers-3.9.2}/tests/presets/test_decoder.py +0 -0
  87. {stackformers-3.9.1 → stackformers-3.9.2}/tests/presets/test_encoder.py +0 -0
  88. {stackformers-3.9.1 → stackformers-3.9.2}/tests/presets/test_pyramid_vision.py +0 -0
  89. {stackformers-3.9.1 → stackformers-3.9.2}/tests/spatial/__init__.py +0 -0
  90. {stackformers-3.9.1 → stackformers-3.9.2}/tests/spatial/test_input.py +0 -0
  91. {stackformers-3.9.1 → stackformers-3.9.2}/tests/spatial/test_kv_reduction.py +0 -0
  92. {stackformers-3.9.1 → stackformers-3.9.2}/tests/spatial/test_layer.py +0 -0
  93. {stackformers-3.9.1 → stackformers-3.9.2}/tests/spatial/test_patch_merging.py +0 -0
  94. {stackformers-3.9.1 → stackformers-3.9.2}/tests/spatial/test_spatial_reduction.py +0 -0
  95. {stackformers-3.9.1 → stackformers-3.9.2}/tests/spatial/test_window.py +0 -0
  96. {stackformers-3.9.1 → stackformers-3.9.2}/tests/test_cross_attender.py +0 -0
  97. {stackformers-3.9.1 → stackformers-3.9.2}/tests/test_decoder.py +0 -0
  98. {stackformers-3.9.1 → stackformers-3.9.2}/tests/test_encoder.py +0 -0
  99. {stackformers-3.9.1 → stackformers-3.9.2}/tests/test_layers.py +0 -0
  100. {stackformers-3.9.1 → stackformers-3.9.2}/tests/test_norm.py +0 -0
  101. {stackformers-3.9.1 → stackformers-3.9.2}/tests/test_sequence.py +0 -0
@@ -0,0 +1,219 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
6
+ adheres to [Semantic Versioning](https://semver.org/): MAJOR for breaking public API changes,
7
+ MINOR for backwards-compatible features, PATCH for bug fixes and internal changes.
8
+
9
+ ## [3.9.2] — 2026-07-14
10
+
11
+ ### Fixed
12
+
13
+ - **`RotaryEmbedding2D` was not computing a rotation.** The frequency vector was laid out as
14
+ `[row | row | col | col]`, but `_rotate_half` pairs channel `i` with channel `i + dh/2`, so a
15
+ row angle was paired against a column angle. The resulting 2×2 map had
16
+ `det = cos((row − col) · ωᵢ)` and singular values `√(1 ± |sin D|)` — a squeeze, not a rotation.
17
+ It was therefore non-orthogonal, annihilated high-frequency channels wherever the determinant
18
+ reached zero, and produced attention scores that leaked absolute position instead of depending
19
+ only on relative offset. Frequencies are now built as `[row | col | row | col]`.
20
+ - `RotaryEmbedding2D` now casts positions and `inv_freq` to float32 for the outer product,
21
+ matching `RotaryEmbedding1D`. A `.half()`-ed module previously lost position precision.
22
+
23
+ ### Changed
24
+
25
+ - **Numerics of `RotaryEmbedding2D` change.** Any checkpoint trained against the previous
26
+ encoding is tuned to the broken position code and requires retraining or finetuning.
27
+
28
+ ### Added
29
+
30
+ - Math tests for `RotaryEmbedding2D` on a genuine row-major grid: a definition-first reference
31
+ oracle, orthogonality (norms and inner products), relative-offset invariance, per-axis
32
+ sensitivity, distinct-offset separation, and packed/padded agreement. The previous test used a
33
+ `row == col` position grid — the main diagonal, the one locus where the row and column angles
34
+ coincide and the broken layout is indistinguishable from a correct one.
35
+
36
+ ## [3.9.1] — 2026-06-30
37
+
38
+ ### Fixed
39
+
40
+ - Make the experimental `varlen_attn` import and call robust across PyTorch versions.
41
+
42
+ ## [3.9.0] — 2026-06-17
43
+
44
+ ### Added
45
+
46
+ - 2-D grid attention (`spatial`) and the pyramid vision backbone preset.
47
+
48
+ ## [3.8.2] — 2026-05-29
49
+
50
+ ### Fixed
51
+
52
+ - Force absolute positions to float32 in `sequence`.
53
+
54
+ ## [3.8.1] — 2026-05-29
55
+
56
+ ### Fixed
57
+
58
+ - Keep batch size as a `SymInt` in the packed attention fallback, preserving `torch.export`
59
+ compatibility.
60
+
61
+ ## [3.8.0] — 2026-05-27
62
+
63
+ ### Fixed
64
+
65
+ - Replace `.tolist()` loops in `sequence` and `ops` with tensor operations, making them
66
+ `torch.export`-compatible.
67
+
68
+ ### Added
69
+
70
+ - PyPI publish metadata in `pyproject.toml`.
71
+
72
+ ## [3.7.0] — 2026-05-21
73
+
74
+ ### Changed
75
+
76
+ - Introduce abstract generic base classes for all presets.
77
+
78
+ ## [3.6.0] — 2026-05-21
79
+
80
+ ### Added
81
+
82
+ - `AttnBias` protocol and the `NoAttnBias` null object on `SelfAttention`.
83
+
84
+ ## [3.5.2] — 2026-05-21
85
+
86
+ ### Fixed
87
+
88
+ - Warn and fall back to SDPA when `varlen_attn` raises.
89
+
90
+ ## [3.5.1] — 2026-05-18
91
+
92
+ ### Changed
93
+
94
+ - Move attention dropout to the output projection.
95
+
96
+ ## [3.5.0] — 2026-05-18
97
+
98
+ ### Added
99
+
100
+ - Packed→padded SDPA fallback; extract `ops.py`.
101
+
102
+ ## [3.4.2] — 2026-05-18
103
+
104
+ ### Added
105
+
106
+ - `padded_to_packed` and `packed_to_padded` helpers.
107
+
108
+ ## [3.4.1] — 2026-05-18
109
+
110
+ ### Removed
111
+
112
+ - Packed-attention CPU fallback; warn on dropout instead.
113
+
114
+ ## [3.4.0] — 2026-05-17
115
+
116
+ ### Added
117
+
118
+ - ReLU² feed-forward network.
119
+
120
+ ## [3.3.0] — 2026-05-17
121
+
122
+ ### Added
123
+
124
+ - QK-norm in attention; all config fields documented.
125
+
126
+ ## [3.2.0] — 2026-05-17
127
+
128
+ ### Changed
129
+
130
+ - Eliminate layout-specific duplicates in positional encoding implementations.
131
+
132
+ ## [3.1.0] — 2026-05-17
133
+
134
+ ### Changed
135
+
136
+ - Replace `forward()` on positional encodings with `forward_padded` / `forward_packed`, so
137
+ callers pick the path for the layout they already know.
138
+
139
+ ## [3.0.1] — 2026-05-17
140
+
141
+ ### Fixed
142
+
143
+ - Use the GELU tanh approximation in GEGLU.
144
+
145
+ ## [3.0.0] — 2026-05-16
146
+
147
+ ### Changed
148
+
149
+ - **Breaking:** remove the kernel abstraction; padded and packed dispatch are unified.
150
+
151
+ ## [2.0.2] — 2026-05-16
152
+
153
+ ### Removed
154
+
155
+ - The unused local-attention package.
156
+
157
+ ## [2.0.1] — 2026-05-16
158
+
159
+ ### Changed
160
+
161
+ - Store feed-forward activations as `nn.Module` attributes.
162
+
163
+ ## [2.0.0] — 2026-05-16
164
+
165
+ ### Removed
166
+
167
+ - **Breaking:** the `attn_bias` / ALiBi system.
168
+
169
+ ## [1.1.0] — 2026-05-16
170
+
171
+ ### Added
172
+
173
+ - `LearnedPosEncoding` with absolute position embeddings.
174
+
175
+ ## [1.0.1] — 2026-05-16
176
+
177
+ ### Added
178
+
179
+ - `LayerNorm` wrapper.
180
+
181
+ ## [1.0.0] — 2026-05-16
182
+
183
+ ### Added
184
+
185
+ - GEGLU feed-forward variant.
186
+
187
+ ### Changed
188
+
189
+ - **Breaking:** feed-forward config becomes a discriminated union.
190
+
191
+ ## [0.5.0] — 2026-05-16
192
+
193
+ ### Added
194
+
195
+ - Unfold mode for the windowed SDPA kernel.
196
+
197
+ ## [0.4.0] — 2026-05-15
198
+
199
+ ### Added
200
+
201
+ - `RotaryEmbedding2D` and the packed cross-attender.
202
+
203
+ ## [0.3.0] — 2026-05-14
204
+
205
+ ### Changed
206
+
207
+ - Establish the semantic versioning convention.
208
+
209
+ ## [0.2.0] — 2026-05-13
210
+
211
+ ### Changed
212
+
213
+ - Remove the `v1` namespace; add the `presets` package.
214
+
215
+ ## [0.1.0] — 2026-05-13
216
+
217
+ ### Added
218
+
219
+ - Initial library scaffold.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackformers
3
- Version: 3.9.1
3
+ Version: 3.9.2
4
4
  Summary: Typed, composable, SOLID transformer library for PyTorch
5
5
  Project-URL: Homepage, https://github.com/Red-Eyed/stackformers
6
6
  Project-URL: Repository, https://github.com/Red-Eyed/stackformers
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "stackformers"
7
- version = "3.9.1"
7
+ version = "3.9.2"
8
8
  description = "Typed, composable, SOLID transformer library for PyTorch"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -25,12 +25,22 @@ class RotaryEmbedding2D(nn.Module):
25
25
 
26
26
  @torch.no_grad()
27
27
  def _freqs_from_positions(self, positions: Tensor) -> Tensor:
28
- """positions: (..., 2) → freqs: (..., dh)"""
28
+ """positions: (..., 2) → freqs: (..., dh), laid out as [row | col | row | col].
29
+
30
+ _rotate_half pairs channel i with channel i + dh/2, so both members of a pair must
31
+ carry the same angle for the map to be a rotation. Duplicating the *concatenated*
32
+ half-vector satisfies that; concatenating the *duplicated* row and col blocks
33
+ ([row | row | col | col]) would pair a row angle against a col angle and yield a
34
+ squeeze instead — non-orthogonal, and not a function of relative position.
35
+
36
+ float32 cast ensures half-precision inputs don't lose precision in the outer product.
37
+ """
29
38
  inv: Tensor = self.inv_freq # type: ignore[assignment]
30
- row = positions[..., 0].to(dtype=inv.dtype).unsqueeze(-1) * inv # (..., dh//4)
31
- col = positions[..., 1].to(dtype=inv.dtype).unsqueeze(-1) * inv
32
- half = lambda f: torch.cat([f, f], dim=-1) # noqa: E731
33
- return torch.cat([half(row), half(col)], dim=-1) # (..., dh)
39
+ pos = positions.to(dtype=torch.float32)
40
+ row = pos[..., 0].unsqueeze(-1) * inv.float() # (..., dh//4)
41
+ col = pos[..., 1].unsqueeze(-1) * inv.float() # (..., dh//4)
42
+ half = torch.cat([row, col], dim=-1) # (..., dh//2)
43
+ return torch.cat([half, half], dim=-1) # (..., dh)
34
44
 
35
45
  def _encode(
36
46
  self, q: Tensor, k: Tensor, q_positions: Tensor, k_positions: Tensor
@@ -125,21 +125,176 @@ def test_rope1d_preserves_norms(
125
125
  assert torch.allclose(k.norm(dim=-1), k_out.norm(dim=-1), atol=tol)
126
126
 
127
127
 
128
+ # --- RoPE-2D ---
129
+ #
130
+ # A row-major GH x GW grid, never row == col. On the diagonal (row == col) the row and column
131
+ # angles coincide, which makes a broken frequency layout indistinguishable from a correct one.
132
+
133
+ GH, GW = 3, 5
134
+ GN = GH * GW
135
+
136
+
137
+ def _grid_positions(b: int, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
138
+ """Row-major (row, col) positions over a GH x GW grid, as (b, GN, 2)."""
139
+ rows = torch.arange(GH, device=device, dtype=dtype).repeat_interleave(GW)
140
+ cols = torch.arange(GW, device=device, dtype=dtype).repeat(GH)
141
+ return torch.stack([rows, cols], dim=-1).unsqueeze(0).expand(b, -1, -1).clone()
142
+
143
+
144
+ def _reference_rope2d(t: torch.Tensor, positions: torch.Tensor, base: int = 10_000) -> torch.Tensor:
145
+ """Axial 2-D RoPE written directly from the definition, independent of the implementation.
146
+
147
+ Rotates channel pair (i, i + dh/2) by row * w_i for the first dh/4 pairs and by col * w_i
148
+ for the rest. t: (b, h, n, dh), positions: (b, n, 2).
149
+ """
150
+ dh = t.shape[-1]
151
+ half, quarter = dh // 2, dh // 4
152
+ inv = 1.0 / (base ** (torch.arange(0, half, 2, dtype=torch.float32) / half))
153
+ inv = inv.to(device=t.device)
154
+
155
+ out = torch.zeros_like(t, dtype=torch.float32)
156
+ src = t.float()
157
+ for i in range(half):
158
+ axis = 0 if i < quarter else 1
159
+ freq = inv[i if i < quarter else i - quarter]
160
+ angle = positions[..., axis].float() * freq # (b, n)
161
+ cos, sin = angle.cos().unsqueeze(1), angle.sin().unsqueeze(1) # (b, 1, n)
162
+ x, y = src[..., i], src[..., i + half]
163
+ out[..., i] = x * cos - y * sin
164
+ out[..., i + half] = x * sin + y * cos
165
+ return out.to(t.dtype)
166
+
167
+
168
+ @pytest.fixture
169
+ def grid_qk(device_dtype: tuple[torch.device, torch.dtype]) -> tuple[torch.Tensor, torch.Tensor]:
170
+ device, dtype = device_dtype
171
+ return (
172
+ torch.randn(B, H, GN, DH, device=device, dtype=dtype),
173
+ torch.randn(B, H, GN, DH, device=device, dtype=dtype),
174
+ )
175
+
176
+
177
+ @pytest.fixture
178
+ def grid_pos(device_dtype: tuple[torch.device, torch.dtype]) -> torch.Tensor:
179
+ device, dtype = device_dtype
180
+ return _grid_positions(B, device, dtype)
181
+
182
+
128
183
  def test_rope2d_output_shape(
129
184
  rope2d: RotaryEmbedding2D,
130
- device_dtype: tuple[torch.device, torch.dtype],
185
+ grid_qk: tuple[torch.Tensor, torch.Tensor],
186
+ grid_pos: torch.Tensor,
131
187
  ) -> None:
132
- device, dtype = device_dtype
133
- q = torch.randn(B, H, N, DH, device=device, dtype=dtype)
134
- k = torch.randn(B, H, N, DH, device=device, dtype=dtype)
135
- row_ids = torch.arange(N, dtype=dtype, device=device)
136
- col_ids = torch.arange(N, dtype=dtype, device=device)
137
- pos = torch.stack([row_ids, col_ids], dim=-1).unsqueeze(0).expand(B, -1, -1) # b n 2
138
- q_out, k_out = rope2d.forward_padded(q, k, pos, pos)
188
+ q, k = grid_qk
189
+ q_out, k_out = rope2d.forward_padded(q, k, grid_pos, grid_pos)
139
190
  assert q_out.shape == q.shape
140
191
  assert k_out.shape == k.shape
141
192
 
142
193
 
194
+ def test_rope2d_matches_reference_rotation(
195
+ rope2d: RotaryEmbedding2D,
196
+ grid_qk: tuple[torch.Tensor, torch.Tensor],
197
+ grid_pos: torch.Tensor,
198
+ device_dtype: tuple[torch.device, torch.dtype],
199
+ ) -> None:
200
+ """Pins the exact rotation against a definition-first oracle, not just its properties."""
201
+ _, dtype = device_dtype
202
+ q, k = grid_qk
203
+ q_out, k_out = rope2d.forward_padded(q, k, grid_pos, grid_pos)
204
+ assert torch.allclose(q_out, _reference_rope2d(q, grid_pos), atol=atol(dtype))
205
+ assert torch.allclose(k_out, _reference_rope2d(k, grid_pos), atol=atol(dtype))
206
+
207
+
208
+ def test_rope2d_is_orthogonal(
209
+ rope2d: RotaryEmbedding2D,
210
+ grid_qk: tuple[torch.Tensor, torch.Tensor],
211
+ grid_pos: torch.Tensor,
212
+ device_dtype: tuple[torch.device, torch.dtype],
213
+ ) -> None:
214
+ """A rotation preserves norms and inner products; a squeeze or reflection does not."""
215
+ _, dtype = device_dtype
216
+ q, k = grid_qk
217
+ q_out, k_out = rope2d.forward_padded(q, k, grid_pos, grid_pos)
218
+ tol = atol(dtype)
219
+ assert torch.allclose(q.norm(dim=-1), q_out.norm(dim=-1), atol=tol)
220
+ assert torch.allclose(k.norm(dim=-1), k_out.norm(dim=-1), atol=tol)
221
+ # Same position for q and k, so the shared rotation must cancel in the inner product.
222
+ before = (q.float() * k.float()).sum(dim=-1)
223
+ after = (q_out.float() * k_out.float()).sum(dim=-1)
224
+ assert torch.allclose(before, after, atol=tol * DH)
225
+
226
+
227
+ @pytest.mark.parametrize("offset", [(0, 1), (1, 0), (2, 3), (-1, 2)], ids=str)
228
+ def test_rope2d_score_depends_only_on_relative_offset(
229
+ device: torch.device,
230
+ offset: tuple[int, int],
231
+ ) -> None:
232
+ """The defining property: sliding a query/key pair across the grid must not move the score."""
233
+ rope = RotaryEmbedding2D(RoPE2DConfig(dim_head=DH)).to(device=device)
234
+ q = torch.randn(1, H, 1, DH, device=device)
235
+ k = torch.randn(1, H, 1, DH, device=device)
236
+ d_row, d_col = offset
237
+
238
+ def score(row: int, col: int) -> torch.Tensor:
239
+ q_pos = torch.tensor([[[float(row), float(col)]]], device=device)
240
+ k_pos = torch.tensor([[[float(row + d_row), float(col + d_col)]]], device=device)
241
+ q_out, k_out = rope.forward_padded(q, k, q_pos, k_pos)
242
+ return (q_out @ k_out.transpose(-1, -2))[0, :, 0, 0]
243
+
244
+ anchor = score(0, 0)
245
+ for row, col in [(2, 3), (5, 9), (11, 4), (7, 7)]:
246
+ assert torch.allclose(anchor, score(row, col), atol=atol(torch.float32) * 10)
247
+
248
+
249
+ def test_rope2d_distinct_offsets_give_distinct_scores(device: torch.device) -> None:
250
+ """Guards the degenerate fix where every offset collapses to the same score."""
251
+ rope = RotaryEmbedding2D(RoPE2DConfig(dim_head=DH)).to(device=device)
252
+ q = torch.randn(1, H, 1, DH, device=device)
253
+ k = torch.randn(1, H, 1, DH, device=device)
254
+
255
+ def score(k_row: float, k_col: float) -> torch.Tensor:
256
+ q_pos = torch.zeros(1, 1, 2, device=device)
257
+ k_pos = torch.tensor([[[k_row, k_col]]], device=device)
258
+ q_out, k_out = rope.forward_padded(q, k, q_pos, k_pos)
259
+ return (q_out @ k_out.transpose(-1, -2))[0, :, 0, 0]
260
+
261
+ # A row-offset and the matching col-offset must not be conflated: the axes are separate.
262
+ assert not torch.allclose(score(2.0, 0.0), score(0.0, 2.0))
263
+ assert not torch.allclose(score(0.0, 0.0), score(1.0, 1.0))
264
+
265
+
266
+ @pytest.mark.parametrize("axis", [0, 1], ids=["row", "col"])
267
+ def test_rope2d_each_axis_changes_the_encoding(
268
+ rope2d: RotaryEmbedding2D,
269
+ device_dtype: tuple[torch.device, torch.dtype],
270
+ axis: int,
271
+ ) -> None:
272
+ """Moving along either axis alone must change the output — neither axis may be ignored."""
273
+ device, dtype = device_dtype
274
+ q = torch.randn(1, H, 1, DH, device=device, dtype=dtype)
275
+ base_pos = torch.full((1, 1, 2), 2.0, device=device, dtype=dtype)
276
+ moved_pos = base_pos.clone()
277
+ moved_pos[..., axis] += 3.0
278
+ base_out, _ = rope2d.forward_padded(q, q, base_pos, base_pos)
279
+ moved_out, _ = rope2d.forward_padded(q, q, moved_pos, moved_pos)
280
+ assert not torch.allclose(base_out, moved_out, atol=atol(dtype))
281
+
282
+
283
+ def test_rope2d_packed_matches_padded(
284
+ rope2d: RotaryEmbedding2D,
285
+ device_dtype: tuple[torch.device, torch.dtype],
286
+ ) -> None:
287
+ """Both layouts are the same per-token operation and must agree token for token."""
288
+ device, dtype = device_dtype
289
+ q = torch.randn(1, H, GN, DH, device=device, dtype=dtype)
290
+ pos = _grid_positions(1, device, dtype)
291
+ padded, _ = rope2d.forward_padded(q, q, pos, pos)
292
+
293
+ packed_q = q[0].transpose(0, 1).contiguous() # (nt, h, dh)
294
+ packed_out, _ = rope2d.forward_packed(packed_q, packed_q, pos[0], pos[0])
295
+ assert torch.allclose(packed_out, padded[0].transpose(0, 1), atol=atol(dtype))
296
+
297
+
143
298
  # --- YaRN scaling ---
144
299
 
145
300
  _YARN = YaRNConfig(scale=4.0, original_max_seq_len=512)
@@ -811,7 +811,7 @@ wheels = [
811
811
 
812
812
  [[package]]
813
813
  name = "stackformers"
814
- version = "3.9.1"
814
+ version = "3.9.2"
815
815
  source = { editable = "." }
816
816
  dependencies = [
817
817
  { name = "beartype" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes