stackformers 3.9.1__tar.gz → 4.2.0__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 (109) hide show
  1. stackformers-4.2.0/CHANGELOG.md +320 -0
  2. {stackformers-3.9.1 → stackformers-4.2.0}/PKG-INFO +1 -1
  3. {stackformers-3.9.1 → stackformers-4.2.0}/pyproject.toml +1 -1
  4. stackformers-4.2.0/run-remote-tests.sh +24 -0
  5. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/attention/README.md +8 -0
  6. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/attention/__init__.py +14 -1
  7. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/attention/config.py +43 -0
  8. stackformers-4.2.0/stackformers/attention/distance_bias.py +49 -0
  9. stackformers-4.2.0/stackformers/attention/factory.py +20 -0
  10. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/attention/ops.py +13 -5
  11. stackformers-4.2.0/stackformers/positional/README.md +28 -0
  12. stackformers-4.2.0/stackformers/positional/config.py +126 -0
  13. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/positional/factory.py +4 -0
  14. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/positional/rope2d.py +15 -5
  15. stackformers-4.2.0/stackformers/positional/rope_nd.py +99 -0
  16. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/presets/README.md +1 -3
  17. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/presets/encoder.py +63 -6
  18. stackformers-4.2.0/tests/attention/test_distance_bias.py +123 -0
  19. {stackformers-3.9.1 → stackformers-4.2.0}/tests/conftest.py +16 -2
  20. {stackformers-3.9.1 → stackformers-4.2.0}/tests/positional/test_rope.py +163 -8
  21. stackformers-4.2.0/tests/positional/test_rope_nd.py +214 -0
  22. {stackformers-3.9.1 → stackformers-4.2.0}/uv.lock +1 -1
  23. stackformers-3.9.1/stackformers/positional/README.md +0 -13
  24. stackformers-3.9.1/stackformers/positional/config.py +0 -65
  25. stackformers-3.9.1/stackformers/presets/pyramid_vision.py +0 -173
  26. stackformers-3.9.1/stackformers/spatial/README.md +0 -49
  27. stackformers-3.9.1/stackformers/spatial/__init__.py +0 -45
  28. stackformers-3.9.1/stackformers/spatial/config.py +0 -138
  29. stackformers-3.9.1/stackformers/spatial/factory.py +0 -49
  30. stackformers-3.9.1/stackformers/spatial/input.py +0 -53
  31. stackformers-3.9.1/stackformers/spatial/kv_reduction.py +0 -69
  32. stackformers-3.9.1/stackformers/spatial/layer.py +0 -42
  33. stackformers-3.9.1/stackformers/spatial/patch_merging.py +0 -54
  34. stackformers-3.9.1/stackformers/spatial/protocols.py +0 -33
  35. stackformers-3.9.1/stackformers/spatial/spatial_reduction.py +0 -70
  36. stackformers-3.9.1/stackformers/spatial/window.py +0 -101
  37. stackformers-3.9.1/tests/presets/test_pyramid_vision.py +0 -71
  38. stackformers-3.9.1/tests/spatial/__init__.py +0 -0
  39. stackformers-3.9.1/tests/spatial/test_input.py +0 -41
  40. stackformers-3.9.1/tests/spatial/test_kv_reduction.py +0 -66
  41. stackformers-3.9.1/tests/spatial/test_layer.py +0 -45
  42. stackformers-3.9.1/tests/spatial/test_patch_merging.py +0 -37
  43. stackformers-3.9.1/tests/spatial/test_spatial_reduction.py +0 -81
  44. stackformers-3.9.1/tests/spatial/test_window.py +0 -75
  45. {stackformers-3.9.1 → stackformers-4.2.0}/.claudeignore +0 -0
  46. {stackformers-3.9.1 → stackformers-4.2.0}/.gitignore +0 -0
  47. {stackformers-3.9.1 → stackformers-4.2.0}/.python-version +0 -0
  48. {stackformers-3.9.1 → stackformers-4.2.0}/.vscode/settings.json +0 -0
  49. {stackformers-3.9.1 → stackformers-4.2.0}/CLAUDE.md +0 -0
  50. {stackformers-3.9.1 → stackformers-4.2.0}/Justfile +0 -0
  51. {stackformers-3.9.1 → stackformers-4.2.0}/LICENSE +0 -0
  52. {stackformers-3.9.1 → stackformers-4.2.0}/PLAN.md +0 -0
  53. {stackformers-3.9.1 → stackformers-4.2.0}/README.md +0 -0
  54. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/__init__.py +0 -0
  55. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/attention/bias.py +0 -0
  56. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/attention/cross_attn.py +0 -0
  57. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/attention/protocols.py +0 -0
  58. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/attention/self_attn.py +0 -0
  59. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/attention/varlen_backend.py +0 -0
  60. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/config.py +0 -0
  61. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/cross_attender.py +0 -0
  62. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/decoder.py +0 -0
  63. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/encoder.py +0 -0
  64. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/feedforward/README.md +0 -0
  65. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/feedforward/__init__.py +0 -0
  66. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/feedforward/config.py +0 -0
  67. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/feedforward/factory.py +0 -0
  68. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/feedforward/geglu.py +0 -0
  69. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/feedforward/protocols.py +0 -0
  70. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/feedforward/relu_squared.py +0 -0
  71. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/feedforward/swiglu.py +0 -0
  72. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/layers.py +0 -0
  73. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/norm/README.md +0 -0
  74. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/norm/__init__.py +0 -0
  75. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/norm/config.py +0 -0
  76. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/norm/factory.py +0 -0
  77. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/norm/protocols.py +0 -0
  78. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/positional/__init__.py +0 -0
  79. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/positional/learned.py +0 -0
  80. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/positional/none.py +0 -0
  81. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/positional/protocols.py +0 -0
  82. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/positional/rope1d.py +0 -0
  83. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/presets/__init__.py +0 -0
  84. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/presets/cross_attender.py +0 -0
  85. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/presets/decoder.py +0 -0
  86. {stackformers-3.9.1 → stackformers-4.2.0}/stackformers/sequence.py +0 -0
  87. {stackformers-3.9.1 → stackformers-4.2.0}/tests/__init__.py +0 -0
  88. {stackformers-3.9.1 → stackformers-4.2.0}/tests/attention/__init__.py +0 -0
  89. {stackformers-3.9.1 → stackformers-4.2.0}/tests/attention/test_cross_attn.py +0 -0
  90. {stackformers-3.9.1 → stackformers-4.2.0}/tests/attention/test_kernels.py +0 -0
  91. {stackformers-3.9.1 → stackformers-4.2.0}/tests/attention/test_ops.py +0 -0
  92. {stackformers-3.9.1 → stackformers-4.2.0}/tests/attention/test_self_attn.py +0 -0
  93. {stackformers-3.9.1 → stackformers-4.2.0}/tests/attention/test_varlen_backend.py +0 -0
  94. {stackformers-3.9.1 → stackformers-4.2.0}/tests/feedforward/__init__.py +0 -0
  95. {stackformers-3.9.1 → stackformers-4.2.0}/tests/feedforward/test_geglu.py +0 -0
  96. {stackformers-3.9.1 → stackformers-4.2.0}/tests/feedforward/test_relu_squared.py +0 -0
  97. {stackformers-3.9.1 → stackformers-4.2.0}/tests/feedforward/test_swiglu.py +0 -0
  98. {stackformers-3.9.1 → stackformers-4.2.0}/tests/positional/__init__.py +0 -0
  99. {stackformers-3.9.1 → stackformers-4.2.0}/tests/positional/test_learned_pos.py +0 -0
  100. {stackformers-3.9.1 → stackformers-4.2.0}/tests/presets/__init__.py +0 -0
  101. {stackformers-3.9.1 → stackformers-4.2.0}/tests/presets/test_cross_attender.py +0 -0
  102. {stackformers-3.9.1 → stackformers-4.2.0}/tests/presets/test_decoder.py +0 -0
  103. {stackformers-3.9.1 → stackformers-4.2.0}/tests/presets/test_encoder.py +0 -0
  104. {stackformers-3.9.1 → stackformers-4.2.0}/tests/test_cross_attender.py +0 -0
  105. {stackformers-3.9.1 → stackformers-4.2.0}/tests/test_decoder.py +0 -0
  106. {stackformers-3.9.1 → stackformers-4.2.0}/tests/test_encoder.py +0 -0
  107. {stackformers-3.9.1 → stackformers-4.2.0}/tests/test_layers.py +0 -0
  108. {stackformers-3.9.1 → stackformers-4.2.0}/tests/test_norm.py +0 -0
  109. {stackformers-3.9.1 → stackformers-4.2.0}/tests/test_sequence.py +0 -0
@@ -0,0 +1,320 @@
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
+ ## [4.2.0] — 2026-07-14
10
+
11
+ ### Added
12
+
13
+ - **`RotaryEmbeddingND`** (`positional/rope_nd.py`) — rotary position encoding over `c` spatial
14
+ dimensions, splitting `dim_head` into `c` per-axis blocks. `c=1` and `c=2` reduce to the
15
+ existing 1-D and 2-D encodings; 3-D and beyond now work with the same class.
16
+ - `RoPENDConfig`, parameterised by **`r_min` / `r_max` instead of `base`**.
17
+
18
+ ### Notes
19
+
20
+ - **`base` is wrong for continuous coordinates**, and `RoPENDConfig` therefore does not have
21
+ it. RoPE's fastest band is pinned at `ω = 1` — a wavelength of exactly 2π *coordinate units* —
22
+ whatever the base; `base` can only stretch the slow end. So the ladder lands correctly only
23
+ when tokens happen to sit one unit apart. That is true of text and of a patch grid, and of
24
+ nothing else: `base=10000` on a 14×14 patch grid leaves 14 of its 16 bands frozen, and
25
+ coordinates normalised to `[0, 1]` leave every band longer than the whole domain.
26
+
27
+ The ladder is built from the data instead — `ω_hi = π / r_min` (Nyquist on the finest
28
+ separation the model must resolve) down to `ω_lo = 2π / (headroom · r_max)` (the longest
29
+ wavelength spans the domain). It then depends only on the *dynamic range* `r_max / r_min`, so
30
+ metres, pixels and millimetres give the identical encoding — the property `base` never had,
31
+ and the one that makes the setting measurable from the data rather than tuned.
32
+
33
+ - `RoPE1DConfig` and `RoPE2DConfig` keep `base` and are unchanged. Use them for text and grids.
34
+
35
+ - **Centre your coordinates.** Not for invariance — RoPE is translation-invariant exactly, since
36
+ the rotations cancel into `ω·(pᵢ − pⱼ)`. For float32: the angle is `ω·p`, and coordinates far
37
+ from the origin push it into the hundreds of radians and spend the mantissa before the cosine
38
+ is taken. A 1e5 offset costs three orders of magnitude of accuracy.
39
+
40
+ - **It encodes direction, not just distance**, so it is not rotation invariant — by design, as
41
+ the relative offset is strictly more information than the distance. Where the global frame is
42
+ arbitrary, train with rotation augmentation. Exact rotation invariance in the architecture
43
+ costs either an O(n²) bias (`RelativeDistanceBias`, above) or a canonicalisation of the input
44
+ frame, which is inherently discontinuous: a near-isotropic point set flips frames under a 1%
45
+ perturbation, measured on 1–2% of samples.
46
+
47
+ ## [4.1.0] — 2026-07-14
48
+
49
+ ### Added
50
+
51
+ - **`RelativeDistanceBias`** (`attention/distance_bias.py`) — an additive attention bias read
52
+ off the Euclidean distance between node positions: pairwise distance → Gaussian radial
53
+ shells → a learned per-head profile. Only `‖pᵢ − pⱼ‖` enters the logit, so attention is
54
+ invariant to any global translation *or rotation* of the node set. For 2-D/N-D node sets
55
+ whose coordinate frame is arbitrary.
56
+ - `DistanceBiasConfig`, `NoAttnBiasConfig`, and the `AttnBiasConfig` discriminated union;
57
+ `attention/factory.py` with `build_attn_bias`; a `node_encoder_config` preset pairing the
58
+ bias with `NoPosEncoding`.
59
+
60
+ ### Notes
61
+
62
+ - **A rotary encoding cannot express this.** RoPE rotates by `ω·p`, which is linear in position
63
+ by construction — and that linearity is exactly what makes the query and key rotations cancel
64
+ into a relative offset. Distance is not linear in position, so it can only enter as a bias.
65
+ Concretely, RoPE-2D scores two neighbours *the same distance away* 2.7× differently depending
66
+ on whether the offset is axis-aligned or diagonal, and rotating a node set by 30° moves its
67
+ attention logits by 83%.
68
+ - **Opt-in, and it has a ceiling.** `NoAttnBias` remains the default and returns `None`, which
69
+ keeps `varlen_attn` available, so the bias costs nothing when unused. When used it costs a
70
+ great deal: a bias has no varlen slot, so it forces padded SDPA and an O(n²) tensor, and the
71
+ `(b, n, s, num_rbf)` shell intermediate — retained for backward, and `num_rbf/h` times larger
72
+ than the bias itself — dominates activation memory. Measured OOM past roughly 2 000 nodes on
73
+ 16 GiB with 4 layers. Where that does not fit, `RotaryEmbeddingND` plus rotation augmentation
74
+ is the cheaper trade.
75
+
76
+ ## [4.0.1] — 2026-07-14
77
+
78
+ ### Fixed
79
+
80
+ - **Causal and windowed attention crashed on CUDA in half precision.** `window_mask` built its
81
+ mask at `torch.float` while `padding_mask` used the query's dtype; adding them promoted the
82
+ sum to float32, and CUDA's SDPA rejects a mask whose dtype differs from the query
83
+ (`RuntimeError: invalid dtype for bias`). Every causal or sliding-window call in
84
+ `float16`/`bfloat16` was affected — that is, the configuration a model actually trains in.
85
+ `window_mask` now takes an explicit `dtype` and `padded_sdpa` passes `q.dtype`.
86
+
87
+ It went unnoticed because CPU's SDPA math backend tolerates the mismatch, and the suite had
88
+ only ever run on CPU — half precision exists solely in the CUDA half of the device matrix.
89
+ Running the untouched tree on a GPU fails 30 tests that pass locally.
90
+
91
+ ## [4.0.0] — 2026-07-14
92
+
93
+ ### Removed
94
+
95
+ - **Breaking: the 2-D vision modules.** The `spatial` package (`WindowAttention2D`,
96
+ `SpatialReductionAttention`, `ConvKVReduction`/`NoKVReduction`, `PatchMerging`,
97
+ `SpatialInput`, `SpatialTransformerLayer`) and the `PyramidVisionBackbone` preset with
98
+ `pyramid_vision_config` are gone. The library returns to sequence models only.
99
+ - `RotaryEmbedding2D` and `RoPE2DConfig` are **kept** — they are independent of `spatial` and
100
+ encode any two-coordinate position (grid row/col, or point x/y).
101
+
102
+ ### Notes
103
+
104
+ - A coordinate-frame bug in `ConvKVReduction` (pooled keys were positioned in the reduced
105
+ grid's index space while queries used full-grid coordinates, so a relative encoding saw an
106
+ offset that grew with absolute position) was found but not fixed, as the module is removed
107
+ here. Anyone reviving this code from history must fix it first: pooled tokens belong at their
108
+ block centre, `i·r + (r − 1)/2`, in the original grid's frame.
109
+
110
+ ## [3.9.2] — 2026-07-14
111
+
112
+ ### Fixed
113
+
114
+ - **`RotaryEmbedding2D` was not computing a rotation.** The frequency vector was laid out as
115
+ `[row | row | col | col]`, but `_rotate_half` pairs channel `i` with channel `i + dh/2`, so a
116
+ row angle was paired against a column angle. The resulting 2×2 map had
117
+ `det = cos((row − col) · ωᵢ)` and singular values `√(1 ± |sin D|)` — a squeeze, not a rotation.
118
+ It was therefore non-orthogonal, annihilated high-frequency channels wherever the determinant
119
+ reached zero, and produced attention scores that leaked absolute position instead of depending
120
+ only on relative offset. Frequencies are now built as `[row | col | row | col]`.
121
+ - `RotaryEmbedding2D` now casts positions and `inv_freq` to float32 for the outer product,
122
+ matching `RotaryEmbedding1D`. A `.half()`-ed module previously lost position precision.
123
+
124
+ ### Changed
125
+
126
+ - **Numerics of `RotaryEmbedding2D` change.** Any checkpoint trained against the previous
127
+ encoding is tuned to the broken position code and requires retraining or finetuning.
128
+
129
+ ### Added
130
+
131
+ - Math tests for `RotaryEmbedding2D` on a genuine row-major grid: a definition-first reference
132
+ oracle, orthogonality (norms and inner products), relative-offset invariance, per-axis
133
+ sensitivity, distinct-offset separation, and packed/padded agreement. The previous test used a
134
+ `row == col` position grid — the main diagonal, the one locus where the row and column angles
135
+ coincide and the broken layout is indistinguishable from a correct one.
136
+
137
+ ## [3.9.1] — 2026-06-30
138
+
139
+ ### Fixed
140
+
141
+ - Make the experimental `varlen_attn` import and call robust across PyTorch versions.
142
+
143
+ ## [3.9.0] — 2026-06-17
144
+
145
+ ### Added
146
+
147
+ - 2-D grid attention (`spatial`) and the pyramid vision backbone preset.
148
+
149
+ ## [3.8.2] — 2026-05-29
150
+
151
+ ### Fixed
152
+
153
+ - Force absolute positions to float32 in `sequence`.
154
+
155
+ ## [3.8.1] — 2026-05-29
156
+
157
+ ### Fixed
158
+
159
+ - Keep batch size as a `SymInt` in the packed attention fallback, preserving `torch.export`
160
+ compatibility.
161
+
162
+ ## [3.8.0] — 2026-05-27
163
+
164
+ ### Fixed
165
+
166
+ - Replace `.tolist()` loops in `sequence` and `ops` with tensor operations, making them
167
+ `torch.export`-compatible.
168
+
169
+ ### Added
170
+
171
+ - PyPI publish metadata in `pyproject.toml`.
172
+
173
+ ## [3.7.0] — 2026-05-21
174
+
175
+ ### Changed
176
+
177
+ - Introduce abstract generic base classes for all presets.
178
+
179
+ ## [3.6.0] — 2026-05-21
180
+
181
+ ### Added
182
+
183
+ - `AttnBias` protocol and the `NoAttnBias` null object on `SelfAttention`.
184
+
185
+ ## [3.5.2] — 2026-05-21
186
+
187
+ ### Fixed
188
+
189
+ - Warn and fall back to SDPA when `varlen_attn` raises.
190
+
191
+ ## [3.5.1] — 2026-05-18
192
+
193
+ ### Changed
194
+
195
+ - Move attention dropout to the output projection.
196
+
197
+ ## [3.5.0] — 2026-05-18
198
+
199
+ ### Added
200
+
201
+ - Packed→padded SDPA fallback; extract `ops.py`.
202
+
203
+ ## [3.4.2] — 2026-05-18
204
+
205
+ ### Added
206
+
207
+ - `padded_to_packed` and `packed_to_padded` helpers.
208
+
209
+ ## [3.4.1] — 2026-05-18
210
+
211
+ ### Removed
212
+
213
+ - Packed-attention CPU fallback; warn on dropout instead.
214
+
215
+ ## [3.4.0] — 2026-05-17
216
+
217
+ ### Added
218
+
219
+ - ReLU² feed-forward network.
220
+
221
+ ## [3.3.0] — 2026-05-17
222
+
223
+ ### Added
224
+
225
+ - QK-norm in attention; all config fields documented.
226
+
227
+ ## [3.2.0] — 2026-05-17
228
+
229
+ ### Changed
230
+
231
+ - Eliminate layout-specific duplicates in positional encoding implementations.
232
+
233
+ ## [3.1.0] — 2026-05-17
234
+
235
+ ### Changed
236
+
237
+ - Replace `forward()` on positional encodings with `forward_padded` / `forward_packed`, so
238
+ callers pick the path for the layout they already know.
239
+
240
+ ## [3.0.1] — 2026-05-17
241
+
242
+ ### Fixed
243
+
244
+ - Use the GELU tanh approximation in GEGLU.
245
+
246
+ ## [3.0.0] — 2026-05-16
247
+
248
+ ### Changed
249
+
250
+ - **Breaking:** remove the kernel abstraction; padded and packed dispatch are unified.
251
+
252
+ ## [2.0.2] — 2026-05-16
253
+
254
+ ### Removed
255
+
256
+ - The unused local-attention package.
257
+
258
+ ## [2.0.1] — 2026-05-16
259
+
260
+ ### Changed
261
+
262
+ - Store feed-forward activations as `nn.Module` attributes.
263
+
264
+ ## [2.0.0] — 2026-05-16
265
+
266
+ ### Removed
267
+
268
+ - **Breaking:** the `attn_bias` / ALiBi system.
269
+
270
+ ## [1.1.0] — 2026-05-16
271
+
272
+ ### Added
273
+
274
+ - `LearnedPosEncoding` with absolute position embeddings.
275
+
276
+ ## [1.0.1] — 2026-05-16
277
+
278
+ ### Added
279
+
280
+ - `LayerNorm` wrapper.
281
+
282
+ ## [1.0.0] — 2026-05-16
283
+
284
+ ### Added
285
+
286
+ - GEGLU feed-forward variant.
287
+
288
+ ### Changed
289
+
290
+ - **Breaking:** feed-forward config becomes a discriminated union.
291
+
292
+ ## [0.5.0] — 2026-05-16
293
+
294
+ ### Added
295
+
296
+ - Unfold mode for the windowed SDPA kernel.
297
+
298
+ ## [0.4.0] — 2026-05-15
299
+
300
+ ### Added
301
+
302
+ - `RotaryEmbedding2D` and the packed cross-attender.
303
+
304
+ ## [0.3.0] — 2026-05-14
305
+
306
+ ### Changed
307
+
308
+ - Establish the semantic versioning convention.
309
+
310
+ ## [0.2.0] — 2026-05-13
311
+
312
+ ### Changed
313
+
314
+ - Remove the `v1` namespace; add the `presets` package.
315
+
316
+ ## [0.1.0] — 2026-05-13
317
+
318
+ ### Added
319
+
320
+ - Initial library scaffold.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackformers
3
- Version: 3.9.1
3
+ Version: 4.2.0
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 = "4.2.0"
8
8
  description = "Typed, composable, SOLID transformer library for PyTorch"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bash
2
+ # Run the test suite on a remote CUDA box.
3
+ #
4
+ # flex_attention's backward is CUDA-only, so the tests that cover it are skipped on this
5
+ # Mac and only ever execute here.
6
+ #
7
+ # ./run-remote-tests.sh # whole suite
8
+ # ./run-remote-tests.sh tests/attention -k flex # anything after the script is passed to pytest
9
+ # REMOTE=other-box ./run-remote-tests.sh
10
+ set -euo pipefail
11
+
12
+ REMOTE="${REMOTE:-srukr-redeyed-pc}"
13
+ REMOTE_DIR="${REMOTE_DIR:-/tmp/sf-cuda/repo}"
14
+ UV="${UV:-\$HOME/.local/bin/uv}" # resolved on the remote, not here
15
+ LOCAL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
+
17
+ echo "==> syncing $LOCAL_DIR -> $REMOTE:$REMOTE_DIR"
18
+ rsync -az --delete \
19
+ --exclude '.venv' --exclude '.git' --exclude 'x-transformers' \
20
+ --exclude '__pycache__' --exclude '.pytest_cache' --exclude '.ruff_cache' \
21
+ "$LOCAL_DIR/" "$REMOTE:$REMOTE_DIR/"
22
+
23
+ echo "==> uv sync + pytest on $REMOTE"
24
+ ssh "$REMOTE" "cd '$REMOTE_DIR' && $UV sync --quiet && $UV run pytest ${*:-tests/} -q"
@@ -18,6 +18,14 @@ Multi-head self-attention and cross-attention.
18
18
 
19
19
  **GQA / MQA.** Set `kv_heads < heads` in either config. Key and value heads are repeated via `einops.repeat` before the dot-product — no attention-specific changes needed.
20
20
 
21
+ **Geometry a rotary encoding cannot carry.** `RelativeDistanceBias` adds a learned function of the Euclidean distance between node positions to the logits, making attention invariant to any global translation or rotation of the input. It lives here rather than in `positional/` because it *cannot* live there: RoPE rotates by `ω·p`, which is linear in position by construction — and that linearity is exactly what makes the query and key rotations cancel into a relative offset. Distance is not linear in position, so no rotary encoding, axial or otherwise, can express it; a bias is the only route. Pair it with `NoPosEncoding` — a rotary encoding alongside it would reintroduce the preferred frame it exists to remove. Use it when node coordinates have no meaningful axes; when up and right *do* mean something, RoPE-2D keeps direction and stays varlen-compatible.
22
+
23
+ **The bias costs the varlen path, by construction.** `varlen_attn` has no bias slot, so any non-`None` `AttnBias` forces the padded SDPA path and materialises a `(b, h, n, s)` tensor. The `(b, n, s, num_rbf)` shell intermediate is retained for backward and is `num_rbf/h` times larger still — it, not the bias, dominates activation memory. `NoAttnBias` returns `None` precisely so the common case keeps the kernel. `TransformerEncoder` shares one bias *module* across layers, but each layer still *calls* it, so the tensor is recomputed and retained per layer; at large node counts the levers are a smaller `num_rbf`, checkpointing the bias, or hoisting the call out of the layer loop.
24
+
25
+ **Why not `flex_attention`.** It fuses the bias into the kernel and would make it O(n) instead of O(n²) — attempted, and parked on the `flex-attention` branch. It needs `dim_head >= 16`; its `score_mod` must lower as a *pointwise* subgraph, which forbids both a `vector_norm` over coordinates and a sum over shells; a captured tensor requiring grad may be indexed exactly once, which forbids unrolling that sum instead; called eagerly it is unfused and catastrophic (measured 64 s/step at n=2048, OOM at n=4096, against ~40 ms for SDPA), so it is viable only inside `torch.compile`; and under `torch.compile` with *dynamic* shapes it bails back to SDPA anyway. With node counts that vary per batch, the fused kernel would rarely be the one that runs.
26
+
21
27
  ## Extending
22
28
 
23
29
  To add a new attention variant, create a new `nn.Module` that satisfies the `SelfAttn` or `CrossAttn` protocol. No kernel registration or factory changes required.
30
+
31
+ To add a new attention bias, write an `nn.Module` satisfying `AttnBias` (take `PaddedInput`, return a broadcastable `(b, h, n, s)` tensor or `None`), give it a config with a `kind` discriminator, join it to the `AttnBiasConfig` union, and add one `match` arm to `build_attn_bias`.
@@ -1,16 +1,29 @@
1
1
  from stackformers.attention.bias import NoAttnBias
2
- from stackformers.attention.config import CrossAttentionConfig, SelfAttentionConfig
2
+ from stackformers.attention.config import (
3
+ AttnBiasConfig,
4
+ CrossAttentionConfig,
5
+ DistanceBiasConfig,
6
+ NoAttnBiasConfig,
7
+ SelfAttentionConfig,
8
+ )
3
9
  from stackformers.attention.cross_attn import CrossAttention
10
+ from stackformers.attention.distance_bias import RelativeDistanceBias
11
+ from stackformers.attention.factory import build_attn_bias
4
12
  from stackformers.attention.protocols import AttnBias, CrossAttn, SelfAttn
5
13
  from stackformers.attention.self_attn import SelfAttention
6
14
 
7
15
  __all__ = [
8
16
  "SelfAttentionConfig",
9
17
  "CrossAttentionConfig",
18
+ "AttnBiasConfig",
19
+ "NoAttnBiasConfig",
20
+ "DistanceBiasConfig",
10
21
  "SelfAttn",
11
22
  "CrossAttn",
12
23
  "AttnBias",
13
24
  "SelfAttention",
14
25
  "CrossAttention",
15
26
  "NoAttnBias",
27
+ "RelativeDistanceBias",
28
+ "build_attn_bias",
16
29
  ]
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import warnings
4
+ from typing import Annotated, Literal
4
5
 
5
6
  from pydantic import BaseModel, Field, model_validator
6
7
 
@@ -96,6 +97,48 @@ class SelfAttentionConfig(BaseModel):
96
97
  return self.heads // self.effective_kv_heads
97
98
 
98
99
 
100
+ class NoAttnBiasConfig(BaseModel):
101
+ """No attention bias — leaves varlen_attn available on the packed path."""
102
+
103
+ kind: Literal["none"] = "none"
104
+
105
+
106
+ class DistanceBiasConfig(BaseModel):
107
+ """Config for RelativeDistanceBias.
108
+
109
+ Attention logits gain a learned function of the Euclidean distance between node
110
+ positions, making them invariant to global translation and rotation of the node set.
111
+ Use with NoPosEncodingConfig: a rotary encoding would reintroduce a preferred frame.
112
+ """
113
+
114
+ kind: Literal["distance"] = "distance"
115
+ heads: int = Field(gt=0, description="Number of query heads. Must match the attention config.")
116
+ r_max: float = Field(
117
+ gt=0.0,
118
+ description=(
119
+ "Largest pairwise node distance to resolve. Radial-basis shells are spread over"
120
+ " [0, r_max]; pairs further apart fall in the tail of the outermost shell and become"
121
+ " indistinguishable. Measure it from the data — a high percentile of the pairwise"
122
+ " distance distribution, not the maximum, which is an outlier."
123
+ ),
124
+ )
125
+ num_rbf: int = Field(
126
+ default=32,
127
+ gt=1,
128
+ description=(
129
+ "Number of Gaussian radial-basis shells. Distance resolution is r_max / (num_rbf - 1);"
130
+ " raising it sharpens the profile a head can learn but costs a (b, n, s, num_rbf)"
131
+ " intermediate, which dominates activation memory at large node counts."
132
+ ),
133
+ )
134
+
135
+
136
+ AttnBiasConfig = Annotated[
137
+ NoAttnBiasConfig | DistanceBiasConfig,
138
+ Field(discriminator="kind"),
139
+ ]
140
+
141
+
99
142
  class CrossAttentionConfig(BaseModel):
100
143
  dim: int = Field(
101
144
  gt=0,
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ import torch.nn as nn
5
+ from einops import rearrange
6
+ from jaxtyping import Float
7
+ from torch import Tensor
8
+
9
+ from stackformers.attention.config import DistanceBiasConfig
10
+ from stackformers.sequence import PaddedInput
11
+
12
+
13
+ class RelativeDistanceBias(nn.Module):
14
+ """Additive attention bias read off the Euclidean distance between node positions.
15
+
16
+ Only ‖p_i − p_j‖ enters the logit, so attention is invariant to any global translation
17
+ or rotation of the node set: the encoding privileges no axis and no origin. Each head
18
+ mixes a shared bank of Gaussian radial-basis shells into its own profile over distance,
19
+ so some heads can go local and others long-range.
20
+
21
+ A rotary encoding cannot express this. RoPE rotates by ω·p, which is linear in position
22
+ by construction — that linearity is exactly what makes the query and key rotations cancel
23
+ into a relative offset. Distance is not linear in position, so it can only enter as a bias.
24
+
25
+ Costs one (b, h, n, s) tensor, which rules out varlen_attn (it has no bias slot) and forces
26
+ the padded SDPA path. Affordable for node counts in the low thousands; beyond that the
27
+ (b, n, s, num_rbf) shell intermediate, not the bias, is what dominates activation memory.
28
+ """
29
+
30
+ def __init__(self, config: DistanceBiasConfig) -> None:
31
+ super().__init__()
32
+ centres = torch.linspace(0.0, config.r_max, config.num_rbf)
33
+ self.register_buffer("centres", centres, persistent=False)
34
+ self.width = config.r_max / (config.num_rbf - 1) # neighbouring shells cross at 1/e
35
+ self.to_bias = nn.Linear(config.num_rbf, config.heads, bias=False)
36
+ nn.init.zeros_(self.to_bias.weight) # start at content-only attention, learn the profile
37
+
38
+ def _shell_weights(self, dist: Float[Tensor, "b n s"]) -> Float[Tensor, "b n s k"]:
39
+ centres: Tensor = self.centres # type: ignore[assignment]
40
+ return torch.exp(-(((dist.unsqueeze(-1) - centres) / self.width) ** 2))
41
+
42
+ def forward(self, input: PaddedInput) -> Float[Tensor, "b h n s"]:
43
+ """float32 distances keep half-precision coordinates from collapsing nearby nodes."""
44
+ pos = input.abs_positions.float()
45
+ delta = pos.unsqueeze(2) - pos.unsqueeze(1) # b n s c
46
+ dist = torch.linalg.vector_norm(delta, dim=-1) # b n s
47
+ shells = self._shell_weights(dist).to(self.to_bias.weight.dtype)
48
+ bias = rearrange(self.to_bias(shells), "b n s h -> b h n s")
49
+ return bias.to(input.x.dtype)
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from stackformers.attention.bias import NoAttnBias
4
+ from stackformers.attention.config import (
5
+ AttnBiasConfig,
6
+ DistanceBiasConfig,
7
+ NoAttnBiasConfig,
8
+ )
9
+ from stackformers.attention.distance_bias import RelativeDistanceBias
10
+ from stackformers.attention.protocols import AttnBias
11
+
12
+
13
+ def build_attn_bias(config: AttnBiasConfig) -> AttnBias:
14
+ match config:
15
+ case NoAttnBiasConfig():
16
+ return NoAttnBias()
17
+ case DistanceBiasConfig():
18
+ return RelativeDistanceBias(config)
19
+ case _:
20
+ raise AssertionError(f"Unhandled attn bias config: {type(config)}")
@@ -14,8 +14,16 @@ def padding_mask(mask: Tensor, dtype: torch.dtype) -> Tensor:
14
14
  return bias.view(mask.shape[0], 1, 1, mask.shape[1])
15
15
 
16
16
 
17
- def window_mask(n: int, s: int, window_size: int, causal: bool, device: torch.device) -> Tensor:
18
- """Additive sliding-window mask (0 = attend, -inf = ignore): (1, 1, n, s)."""
17
+ def window_mask(
18
+ n: int, s: int, window_size: int, causal: bool, device: torch.device, dtype: torch.dtype
19
+ ) -> Tensor:
20
+ """Additive sliding-window mask (0 = attend, -inf = ignore): (1, 1, n, s).
21
+
22
+ ``dtype`` must be the query's. CUDA's SDPA rejects a mask whose dtype differs from the
23
+ query ("invalid dtype for bias"), and a float32 mask added to a float16 padding mask
24
+ promotes the sum to float32 — which broke every causal and windowed call in half
25
+ precision on GPU, while passing on CPU, whose math backend tolerates the mismatch.
26
+ """
19
27
  q_pos = torch.arange(n, device=device).unsqueeze(1)
20
28
  k_pos = torch.arange(s, device=device).unsqueeze(0)
21
29
  if causal:
@@ -23,7 +31,7 @@ def window_mask(n: int, s: int, window_size: int, causal: bool, device: torch.de
23
31
  else:
24
32
  half = window_size // 2
25
33
  allowed = (k_pos >= q_pos - half) & (k_pos <= q_pos + half)
26
- mask = torch.zeros(1, 1, n, s, dtype=torch.float, device=device)
34
+ mask = torch.zeros(1, 1, n, s, dtype=dtype, device=device)
27
35
  mask.masked_fill_(~allowed.unsqueeze(0).unsqueeze(0), float("-inf"))
28
36
  return mask
29
37
 
@@ -44,11 +52,11 @@ def padded_sdpa(
44
52
  attn_mask = attn_mask + bias
45
53
  if window_size is None:
46
54
  if causal:
47
- attn_mask = attn_mask + window_mask(n, s, s, causal=True, device=q.device)
55
+ attn_mask = attn_mask + window_mask(n, s, s, True, q.device, q.dtype)
48
56
  # is_causal=False: causal constraint already encoded in attn_mask above
49
57
  return F.scaled_dot_product_attention(q, k, v, attn_mask=attn_mask, is_causal=False)
50
58
  else:
51
- win_mask = window_mask(n, s, window_size, causal, q.device)
59
+ win_mask = window_mask(n, s, window_size, causal, q.device, q.dtype)
52
60
  return F.scaled_dot_product_attention(q, k, v, attn_mask=attn_mask + win_mask)
53
61
 
54
62
 
@@ -0,0 +1,28 @@
1
+ # positional
2
+
3
+ Positional encodings applied to Q and K tensors inside attention — not to the residual stream.
4
+
5
+ `RotaryEmbedding1D` is the standard choice. It supports YaRN context extension via `RoPE1DConfig(yarn=YaRNConfig(...))`. `RotaryEmbedding2D` takes two coordinates per token (a grid's row/col, or a point's x/y — the two axes are treated symmetrically, so the order is yours to pick as long as it is consistent) and splits the head dimension between them. `NoPosEncoding` is a null object for cross-attention paths that need no positional information.
6
+
7
+ ## `base` is for grids. `RotaryEmbeddingND` is for continuous coordinates.
8
+
9
+ RoPE's shortest wavelength is fixed at 2π coordinate units, and `base` only stretches the long end of the band range. So `base` lands the ladder correctly only when tokens sit *one unit apart* — true for text, true for a patch grid, meaningless for scattered continuous coordinates. Positions normalised to `[0, 1]` leave every band longer than the whole domain, so the encoding barely rotates and carries almost no signal; and `base=10000` on a 14×14 patch grid leaves 14 of 16 bands frozen.
10
+
11
+ `RotaryEmbeddingND` (`RoPENDConfig`) drops `base` entirely and builds the ladder from the two numbers that actually mean something, in any number of dimensions:
12
+
13
+ - `r_min` — the finest separation the model must resolve. Sets the shortest wavelength to `2·r_min`, the Nyquist limit. Measure it as a low percentile of the nearest-neighbour distance.
14
+ - `r_max` — the domain diameter. Sets the longest wavelength (times `headroom`, so the slowest band stays monotone rather than wrapping). Measure it as a high percentile of the pairwise distance distribution.
15
+
16
+ The resulting ladder depends only on the *dynamic range* `r_max / r_min`, so metres, pixels and millimetres all give the identical encoding — the property `base` never had. `RoPE1DConfig` and `RoPE2DConfig` keep `base` and are unchanged; use them for text and grids.
17
+
18
+ **Centre your coordinates.** Not for invariance — RoPE is translation-invariant exactly, since the query and key rotations cancel into `ω·(pᵢ − pⱼ)`. It is for float32: the angle is `ω·p`, and coordinates far from the origin push it into the hundreds of radians and spend the mantissa before the cosine is taken. A 1e5 offset costs three orders of magnitude of accuracy. This cannot be done inside the module — cross-attention must subtract the *same* constant from query and key positions, and the module sees them separately — so subtract the centroid at the input boundary.
19
+
20
+ **RoPE encodes direction, not just distance.** It is not rotation invariant, by design: it gives the model the relative *offset*, which is strictly more information than the distance. Where the global frame is arbitrary, train with rotation augmentation. Buying exact rotation invariance in the architecture costs either an O(n²) attention bias (see `attention/RelativeDistanceBias`, which OOMs past a few thousand nodes) or a canonicalisation of the input frame, which is inherently discontinuous — a near-isotropic point set flips frames under a 1% perturbation.
21
+
22
+ The `PosEncoding` protocol exposes two methods — `forward_padded` and `forward_packed` — so callers (which already know their layout) pick the right path directly without internal dispatch. All implementations share the same computation via layout-agnostic helpers, since positional encoding is a per-token operation that does not depend on sequence boundaries.
23
+
24
+ ## Adding a new encoding
25
+
26
+ 1. Add a config class with a `kind: Literal[...]` discriminator to `config.py` and include it in the `PosEncodingConfig` union.
27
+ 2. Implement the class satisfying `PosEncoding` (and optionally `PackedPosEncoding`) structurally.
28
+ 3. Add a `case` branch in `factory.py::build_pos_encoding`.