burn_mamba/mamba3/rotation/mod.rs
1//! # Quaternion (k=4) rotational state — the non-abelian generalisation of RoPE
2//!
3//! Mamba-3's data-dependent RoPE realises a **complex-valued** SSM: the state
4//! transition factors as a per-head scalar decay times a block-diagonal of
5//! `2×2` rotations (paper Prop. *Complex-to-Real SSM Equivalence*), and because
6//! `SO(2) ≅ U(1)` is **abelian** the cumulative rotation collapses to a
7//! `cumsum` of angles and is absorbed into `B`/`C` (the "RoPE trick", Prop.
8//! *Complex SSM, Data-Dependent RoPE Equivalence*). See
9//! [`crate::mamba3::rotation::rope::apply_rope`].
10//!
11//! This module implements the next rung of the ladder: a **quaternion**
12//! (`k = 4`) rotational state, i.e. the transition's rotation lives in the
13//! left-isoclinic subgroup `SU(2) ⊂ SO(4)` instead of `SO(2)`. Unit
14//! quaternions under multiplication are `SU(2)`, which is **non-abelian** and
15//! contains non-solvable finite subgroups (the binary icosahedral group
16//! `2I = SL(2,5)`, a double cover of `A₅`). By Barrington's theorem this lifts
17//! the layer's reachable state-tracking from the solvable/`TC⁰` regime (parity,
18//! mod-k) toward `NC¹`, which abelian rotations provably cannot reach.
19//!
20//! ## What survives, what changes
21//!
22//! The key fact (derivable purely from telescoping + orthogonality, **without**
23//! commutativity — see the crate discussion) is that the RoPE *factoring*
24//! survives intact: with the **ordered** cumulative rotation
25//! `Pₜ = Rₜ Rₜ₋₁ ⋯ R₁`,
26//!
27//! ```text
28//! Cₜᵀ (Rₜ⋯Rᵢ₊₁) Bᵢ = (Pₜᵀ Cₜ)ᵀ (Pᵢᵀ Bᵢ) = C̄ₜᵀ B̄ᵢ ,
29//! ```
30//!
31//! so the scalar-decay SSD core (`L ⊙ C̄B̄ᵀ`) is **unchanged** — only the
32//! projections `B̄ᵢ = Pᵢᵀ Bᵢ`, `C̄ₜ = Pₜᵀ Cₜ` are rotated. What is lost is the
33//! closed-form `cumsum`: the cumulative rotation must be built by an
34//! **associative scan over the per-step quaternions** ([`quat_cumprod`]) rather
35//! than a sum of angles. Because a product of unit quaternions is again a unit
36//! quaternion, the scan stays exactly orthogonal (no drift, no `wrap_angle`
37//! needed), and the cross-chunk carry is a single quaternion per block/head —
38//! the exact analogue of `cum_angle` in the existing caches.
39//!
40//! `SO(2)` (today's `apply_rope`) is the abelian collapse: restricting each
41//! quaternion to a single fixed axis makes them commute and reduces
42//! [`quat_cumprod`] to a `cumsum` of half-angles (asserted in the tests).
43//!
44//! ## Pipeline (the `k = 4` instantiation of the rotation block)
45//!
46//! ```text
47//! per-step unit quaternion qₜ (materialise from the in-projection; caller)
48//! │ quat_cumprod (assoc. scan, + cross-chunk carry)
49//! ▼
50//! cumulative rotation Qₜ
51//! │ rotate_state_rank_blocks(B, conj(Qₜ)) , rotate_state_rank_blocks(C, conj(Qₜ))
52//! ▼
53//! B̄, C̄ ──► standard scalar-decay SSD (unchanged)
54//! ```
55//!
56//! For [`RotationKind::Rotor4D`] the same pipeline runs with the two factors
57//! stacked along the block axis, and the last step becomes the two-sided
58//! `rotate_state_rank_blocks_two_sided(B, conj(Qₜ), Tₜ)`.
59//!
60//! ## `SO(4)`: the whole rotation group of a block ([`RotationKind::Rotor4D`])
61//!
62//! `SU(2)` is only half of what a 4-block can turn by. The general element of
63//! `SO(4) ≅ (SU(2)×SU(2))/±1` is the **two-sided** product
64//!
65//! ```text
66//! Rₜ(v) = qₜ ⊗ v ⊗ p̄ₜ (a rotor; left factor q, right factor p)
67//! ```
68//!
69//! and everything above survives it, because the factoring never used more
70//! than "the per-step maps compose, and each is orthogonal". Composing,
71//!
72//! ```text
73//! Pₜ(v) = Qₜ ⊗ v ⊗ T̄ₜ , Qₜ = qₜ⊗⋯⊗q₁ , Tₜ = pₜ⊗⋯⊗p₁
74//! Pₜ⁻¹(v) = Qₜ* ⊗ v ⊗ Tₜ ⇒ B̄ᵢ = Qᵢ* ⊗ Bᵢ ⊗ Tᵢ , C̄ₜ likewise
75//! ```
76//!
77//! — note the conjugation reverses the right-hand order **twice**, so `T`
78//! accumulates by the *same* left fold as `Q`: one [`quat_cumprod`] over a
79//! doubled block axis, not a second, reversed scan. The cost over
80//! [`Quaternion4D`](RotationKind::Quaternion4D) is twice the generator
81//! channels, twice the scan's block axis, and one extra [`quat_mul`] per
82//! `B`/`C` application; the SSD core is still untouched.
83//!
84//! Why bother, when left and right factors *commute* with each other and so add
85//! no "more non-abelianness": left multiplication is **isoclinic** — `L_q` turns
86//! both invariant planes of the block by the same angle — so `SU(2)` cannot
87//! produce two independent plane angles, and in particular does not contain the
88//! abelian `SO(2)²` rotation it was introduced to generalise. The right factor
89//! is exactly what opens the maximal torus (plane angles `a−b`, `a+b` for the
90//! half-angles of `q`, `p`), making the ladder
91//! `Complex2D ⊂ Rotor4D ⊃ Quaternion4D` a real one. It also contains the
92//! adjoint action `v ↦ q ⊗ v ⊗ q̄`, i.e. a faithful `SO(3)` on the block's
93//! imaginary part — so a group like `A₅` can be tracked as itself rather than
94//! through its double cover `2I`, where `±g` denote one element but two
95//! different states. And as a representation of `SU(2)×SU(2)`, `ℍ` is the
96//! irreducible tensor product `(½,½)`, not `(½,0) ⊕ (0,½)`: no arrangement of
97//! left-only blocks reproduces it.
98//!
99//! `SO(4)` is the ceiling for `k = 4` (the largest norm-preserving transition
100//! group of a block), and `k = 4` is the last rung with a cheap closed form —
101//! at `k = 8` the octonions are non-associative and the scan itself breaks.
102//!
103//! ## The bottom rung: [`RotationKind::Real1D`]
104//!
105//! `k = 1`, the trivial group: no rotation, a purely real transition. It is the
106//! ablation the ladder is measured against, and it is *structural* rather than a
107//! zeroed knob — the in-projection spends no channels on rotation, the cache
108//! carries no accumulator ([`RotationState::Real`]), and `B`/`C` reach the SSD
109//! core untouched. Switching the rotation off is therefore a choice of *kind*;
110//! `rope_fraction` only ever narrows a rotation that exists.
111//!
112//! Quaternion layout: the last axis has size 4 and holds `(w, x, y, z)` with
113//! `w` the real part. A `state_rank` of `r = 4·J` is treated as `J` independent
114//! quaternion blocks; the rotation acts within each block, exactly as RoPE acts
115//! within each `2`-pair. [`Mamba3`](crate::mamba3::mamba3::Mamba3) selects it
116//! with [`RotationKind::Quaternion4D`] and drives it through one
117//! [`RotationSpec`] (the SSD kernels themselves need no edits).
118
119/// Rotary (RoPE) application: the mechanical pairwise rotation the abelian
120/// pathway factors into B/C.
121pub mod rope;
122
123use crate::mamba3::rotation::rope::{apply_rope_partial, wrap_angle};
124use burn::module::Module;
125use burn::prelude::*;
126
127// ---------------------------------------------------------------------------
128// Rotation kind (config switch) and cache accumulator variant
129// ---------------------------------------------------------------------------
130
131/// Which rotational-state algebra the block uses for the data-dependent
132/// transition rotation absorbed into `B`/`C`.
133///
134/// - [`Real1D`](RotationKind::Real1D) — the trivial group: no rotation at all.
135/// The transition is the plain scalar decay, i.e. a **real** SSM (Mamba-2's
136/// transition under Mamba-3's trapezoid). The in-projection spends *no*
137/// channels on rotation and the cache carries no accumulator
138/// ([`RotationState::Real`]) — this is the rotation ablation, which is why
139/// `rope_fraction` has no `0.0` setting.
140/// - [`Complex2D`](RotationKind::Complex2D) — the abelian `SO(2)`/complex RoPE
141/// that Mamba-3 ships: cumulative *angles* via `cumsum`, applied by
142/// [`apply_rope`](crate::mamba3::rotation::rope::apply_rope). The default; behaviourally unchanged.
143/// - [`Quaternion4D`](RotationKind::Quaternion4D) — the non-abelian
144/// `SU(2) ⊂ SO(4)` quaternion rotation of this module: cumulative *product*
145/// via [`quat_cumprod`], applied by [`rotate_state_rank_blocks`]. Richer
146/// state-tracking; selects the [`RotationState::Quaternion`] cache accumulator.
147/// - [`Rotor4D`](RotationKind::Rotor4D) — the **whole** rotation group of a
148/// 4-block, `SO(4) ≅ (SU(2)×SU(2))/±1`: a *two-sided* quaternion product
149/// `v ↦ q ⊗ v ⊗ p̄`. Two per-step quaternions instead of one (twice the
150/// generator channels, one scan over a doubled block axis); selects the
151/// [`RotationState::Rotor`] accumulator.
152///
153/// The kinds are a ladder — `Real1D ⊂ Complex2D ⊂ Rotor4D` and
154/// `Real1D ⊂ Quaternion4D ⊂ Rotor4D` — but the middle two are
155/// **incomparable**, which is why `Rotor4D` exists.
156/// Left multiplication is *isoclinic*: `L_q` turns both invariant planes of the
157/// block by the same angle (`L_i` sends `1↦i` **and** `j↦k`), so
158/// `Quaternion4D` cannot express two independent per-pair angles — it does not
159/// contain the abelian rotation it generalises. The right factor opens the full
160/// maximal torus (plane angles `a−b` and `a+b` for half-angles `a`, `b`), and
161/// with it every element of `SO(4)`.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
163pub enum RotationKind {
164 /// No rotation: a real transition (the trivial group). No rotation
165 /// channels, no cumulative accumulator, `B`/`C` pass through untouched.
166 Real1D,
167 /// Abelian complex (`SO(2)`) RoPE — the current default behaviour.
168 #[default]
169 Complex2D,
170 /// Non-abelian quaternion (`SU(2)`, left-isoclinic) rotation.
171 Quaternion4D,
172 /// The full `SO(4)` rotation of each 4-block: two-sided `v ↦ q ⊗ v ⊗ p̄`.
173 Rotor4D,
174}
175
176impl RotationKind {
177 /// How many cumulative-rotation quaternions the accumulator carries per
178 /// (head, 4-block): one for [`Quaternion4D`](RotationKind::Quaternion4D),
179 /// **two** for [`Rotor4D`](RotationKind::Rotor4D) (the left and right
180 /// factors). Meaningless for the non-quaternion kinds
181 /// ([`Complex2D`](RotationKind::Complex2D) accumulates angles,
182 /// [`Real1D`](RotationKind::Real1D) accumulates nothing) — reported as 1.
183 ///
184 /// The factors are stacked along one block axis, so every quaternion
185 /// primitive — the generator split, [`quat_from_scaled_axis`], the
186 /// [`quat_cumprod`] scan, [`quat_normalize`] — runs **once** over
187 /// `quat_factors · blocks` blocks and needs no `Rotor4D` branch of its own.
188 /// Only the *application* to `B`/`C` differs.
189 pub fn quat_factors(self) -> usize {
190 match self {
191 RotationKind::Rotor4D => 2,
192 RotationKind::Real1D | RotationKind::Complex2D | RotationKind::Quaternion4D => 1,
193 }
194 }
195}
196
197/// Everything the rotation needs from the block: which algebra, how much of
198/// `state_rank` it turns, and how far one step may turn it.
199///
200/// Carried by [`Mamba3`](crate::mamba3::mamba3::Mamba3) and handed to
201/// [`rotate_bc_forward`] / [`rotate_bc_step`] (and to the constant-input
202/// shortcut) so that every site derives the per-step rotation from **one**
203/// definition — the three used to spell the formula out separately, which is
204/// exactly the kind of duplication that lets `forward`, `step` and
205/// `step_infinite` drift apart.
206#[derive(Debug, Clone, Copy, PartialEq)]
207pub struct RotationSpec {
208 /// Which rotational-state algebra ([`RotationKind`]).
209 pub kind: RotationKind,
210 /// How many leading `state_rank` entries are rotated (see
211 /// [`Mamba3Config::rope_fraction`](crate::mamba3::mamba3::Mamba3Config::rope_fraction)).
212 /// For [`RotationKind::Quaternion4D`] this is a multiple of 4; for
213 /// [`RotationKind::Real1D`] it is `0` and nothing reads it.
214 pub rope_dim: usize,
215 /// The per-step rotation bound in half-turns per unit `Δ`
216 /// ([`Mamba3Config::rotation_range`](crate::mamba3::mamba3::Mamba3Config::rotation_range)):
217 /// one step turns by at most `range · π · Δ`.
218 pub range: f64,
219}
220
221/// The stateless payload of [`RotationState::Real`] — a [`Module`] holding no
222/// tensors, so a real transition's cache slot allocates nothing and converts
223/// between backends by `Clone`.
224#[derive(Module, Debug, Default)]
225pub struct NoRotation;
226
227/// The cumulative-rotation accumulator carried between calls in a Mamba-3 cache
228/// — the variant matching the block's [`RotationKind`].
229///
230/// - [`Real`](RotationState::Real) — nothing at all: a real transition has no
231/// cumulative rotation to carry.
232/// - [`Angle`](RotationState::Angle) — abelian per-pair cumulative RoPE angle,
233/// shape `[batch, nheads, num_rope_angles]` (today's `cum_angle`).
234/// - [`Quaternion`](RotationState::Quaternion) — per-block cumulative unit
235/// quaternion, shape `[batch, nheads, blocks, 4]`, produced by
236/// [`quat_cumprod`].
237///
238/// This is the cache-level counterpart of [`RotationKind`]. It is defined here
239/// (the rotation module owns the accumulator type); substituting it for the
240/// pathway caches' `cum_angle_bha` field happens together with the forward/step
241/// wiring that consumes it.
242#[derive(Module, Debug)]
243pub enum RotationState {
244 /// [`RotationKind::Real1D`]'s empty accumulator: a real transition composes
245 /// nothing between calls. It carries a [`NoRotation`] only because Burn's
246 /// `Module` derive takes exactly one field per enum variant.
247 Real(NoRotation),
248 /// Abelian RoPE cumulative angle, shape `[batch, nheads, num_rope_angles]`.
249 Angle(Tensor<3>),
250 /// Quaternion cumulative rotation, shape `[batch, nheads, blocks, 4]`.
251 Quaternion(Tensor<4>),
252 /// `SO(4)` cumulative rotation, shape `[batch, nheads, 2·blocks, 4]`: the
253 /// left factors `Qₜ = qₜ⊗⋯⊗q₁` in the first `blocks` entries of the block
254 /// axis, the right factors `Tₜ = pₜ⊗⋯⊗p₁` in the second (see
255 /// [`split_rotor`]).
256 ///
257 /// One tensor rather than two so the scan, the normalisation and the cache
258 /// plumbing stay single-call; the conjugation in `v ↦ q v p̄` reverses the
259 /// right-hand order **twice**, so `T` accumulates with the very same
260 /// left-fold as `Q` and no reversed scan is needed.
261 Rotor(Tensor<4>),
262}
263
264impl RotationState {
265 /// The empty accumulator of [`RotationKind::Real1D`].
266 pub fn real() -> Self {
267 RotationState::Real(NoRotation)
268 }
269
270 /// Zero-initialised abelian angle accumulator `[batch, nheads, num_rope_angles]`.
271 pub fn zeros_angle(
272 batch: usize,
273 nheads: usize,
274 num_rope_angles: usize,
275 device: &Device,
276 ) -> Self {
277 RotationState::Angle(Tensor::zeros([batch, nheads, num_rope_angles], device))
278 }
279
280 /// Identity-initialised quaternion accumulator `[batch, nheads, blocks, 4]`
281 /// (every block is the identity quaternion `(1, 0, 0, 0)`).
282 pub fn identity_quaternion(
283 batch: usize,
284 nheads: usize,
285 blocks: usize,
286 device: &Device,
287 ) -> Self {
288 let w = Tensor::ones([batch, nheads, blocks, 1], device);
289 let xyz = Tensor::zeros([batch, nheads, blocks, 3], device);
290 RotationState::Quaternion(Tensor::cat(vec![w, xyz], 3))
291 }
292
293 /// The variant's name, for assertion messages.
294 fn variant(&self) -> &'static str {
295 match self {
296 RotationState::Real(_) => "Real",
297 RotationState::Angle(_) => "Angle",
298 RotationState::Quaternion(_) => "Quaternion",
299 RotationState::Rotor(_) => "Rotor",
300 }
301 }
302
303 /// Check this is the empty [`Real`](RotationState::Real) accumulator and
304 /// hand it back; panics on any other variant, so a cache built for a
305 /// rotating kind cannot be fed to a [`Real1D`](RotationKind::Real1D) block.
306 pub fn expect_real(self) -> Self {
307 match self {
308 RotationState::Real(_) => self,
309 other => panic!("RotationState is {}, expected Real", other.variant()),
310 }
311 }
312
313 /// Unwrap the abelian angle accumulator; panics if this is a quaternion.
314 pub fn angle(self) -> Tensor<3> {
315 match self {
316 RotationState::Angle(a) => a,
317 other => panic!("RotationState is {}, expected Angle", other.variant()),
318 }
319 }
320
321 /// Identity-initialised `SO(4)` accumulator `[batch, nheads, 2·blocks, 4]`
322 /// (both factors of every block are the identity quaternion `(1, 0, 0, 0)`).
323 pub fn identity_rotor(batch: usize, nheads: usize, blocks: usize, device: &Device) -> Self {
324 let w = Tensor::ones([batch, nheads, 2 * blocks, 1], device);
325 let xyz = Tensor::zeros([batch, nheads, 2 * blocks, 3], device);
326 RotationState::Rotor(Tensor::cat(vec![w, xyz], 3))
327 }
328
329 /// The identity accumulator for `kind` — the one place a fresh cache's
330 /// rotation state is built, for every pathway and both cache types.
331 pub fn identity(
332 kind: RotationKind,
333 batch: usize,
334 nheads: usize,
335 num_rope_angles: usize,
336 num_quat_blocks: usize,
337 device: &Device,
338 ) -> Self {
339 match kind {
340 RotationKind::Real1D => RotationState::real(),
341 RotationKind::Complex2D => {
342 RotationState::zeros_angle(batch, nheads, num_rope_angles, device)
343 }
344 RotationKind::Quaternion4D => {
345 RotationState::identity_quaternion(batch, nheads, num_quat_blocks, device)
346 }
347 RotationKind::Rotor4D => {
348 RotationState::identity_rotor(batch, nheads, num_quat_blocks, device)
349 }
350 }
351 }
352
353 /// Unwrap the quaternion accumulator; panics if this is an angle.
354 pub fn quaternion(self) -> Tensor<4> {
355 match self {
356 RotationState::Quaternion(q) => q,
357 other => panic!("RotationState is {}, expected Quaternion", other.variant()),
358 }
359 }
360
361 /// Unwrap the `SO(4)` accumulator (`[batch, nheads, 2·blocks, 4]`, both
362 /// factors stacked); panics for any other variant.
363 pub fn rotor(self) -> Tensor<4> {
364 match self {
365 RotationState::Rotor(q) => q,
366 other => panic!("RotationState is {}, expected Rotor", other.variant()),
367 }
368 }
369
370 /// The stacked quaternion accumulator for `kind`, together with the number
371 /// of **state** 4-blocks it covers — half its block axis for
372 /// [`RotationKind::Rotor4D`], which stacks two factors there.
373 ///
374 /// Panics on a variant the kind does not use: the two are the same rank and
375 /// differ only in the length of one axis, so a mismatched cache would
376 /// otherwise be reinterpreted rather than rejected.
377 fn quat_stack(self, kind: RotationKind) -> (Tensor<4>, usize) {
378 match (kind, self) {
379 (RotationKind::Quaternion4D, RotationState::Quaternion(q)) => {
380 let blocks = q.dims()[2];
381 (q, blocks)
382 }
383 (RotationKind::Rotor4D, RotationState::Rotor(q)) => {
384 let stacked = q.dims()[2];
385 assert_eq!(stacked % 2, 0, "a Rotor accumulator stacks two factors");
386 (q, stacked / 2)
387 }
388 (kind, other) => panic!(
389 "RotationState is {}, which is not {kind:?}'s accumulator",
390 other.variant()
391 ),
392 }
393 }
394
395 /// Run the [`NaN`/`Inf` guards](burn_stack::modules::misc::sanity) on the held tensor.
396 pub fn sanity(&self) {
397 match self {
398 RotationState::Real(_) => {}
399 RotationState::Angle(a) => burn_stack::modules::sanity(a),
400 RotationState::Quaternion(q) | RotationState::Rotor(q) => burn_stack::modules::sanity(q),
401 }
402 }
403}
404
405// ---------------------------------------------------------------------------
406// Quaternion algebra on the trailing `(w, x, y, z)` axis
407// ---------------------------------------------------------------------------
408
409/// Hamilton product `a ⊗ b` of two quaternion tensors.
410///
411/// Both inputs have shape `[..., 4]` with the last axis ordered `(w, x, y, z)`;
412/// the product is computed component-wise and broadcasts over the leading dims.
413/// Quaternion multiplication is **non-commutative** (`a ⊗ b ≠ b ⊗ a` in
414/// general) but associative.
415///
416/// Identifying `ℝ⁴` with the quaternions, left-multiplication `v ↦ a ⊗ v` is
417/// exactly the action of the `4×4` rotation matrix [`quat_to_rot4`]`(a)`, so
418/// this is also how a rotation is *applied* to a state/`B`/`C` block (see
419/// [`rotate_state_rank_blocks`]).
420pub fn quat_mul<const D: usize>(a: Tensor<D>, b: Tensor<D>) -> Tensor<D> {
421 let n = D - 1;
422 let aw = a.clone().narrow(n, 0, 1);
423 let ax = a.clone().narrow(n, 1, 1);
424 let ay = a.clone().narrow(n, 2, 1);
425 let az = a.narrow(n, 3, 1);
426 let bw = b.clone().narrow(n, 0, 1);
427 let bx = b.clone().narrow(n, 1, 1);
428 let by = b.clone().narrow(n, 2, 1);
429 let bz = b.narrow(n, 3, 1);
430
431 // Hamilton product (each term is shape [..., 1]).
432 let w = aw.clone() * bw.clone()
433 - ax.clone() * bx.clone()
434 - ay.clone() * by.clone()
435 - az.clone() * bz.clone();
436 let x = aw.clone() * bx.clone() + ax.clone() * bw.clone() + ay.clone() * bz.clone()
437 - az.clone() * by.clone();
438 let y = aw.clone() * by.clone() - ax.clone() * bz.clone()
439 + ay.clone() * bw.clone()
440 + az.clone() * bx.clone();
441 let z = aw * bz + ax * by - ay * bx + az * bw;
442
443 Tensor::cat(vec![w, x, y, z], n)
444}
445
446/// Quaternion conjugate `q* = (w, −x, −y, −z)` (shape `[..., 4]`).
447///
448/// For a **unit** quaternion `q* = q⁻¹`, and the corresponding rotation matrix
449/// satisfies `Lₚ⋆ = Lₚᵀ = Lₚ⁻¹`. Hence rotating by the *inverse* cumulative
450/// rotation (`B̄ = Pᵀ B`) is `rotate_state_rank_blocks(B, conj(Q))`.
451pub fn quat_conj<const D: usize>(q: Tensor<D>) -> Tensor<D> {
452 let n = D - 1;
453 let w = q.clone().narrow(n, 0, 1);
454 let xyz = q.narrow(n, 1, 3);
455 Tensor::cat(vec![w, -xyz], n)
456}
457
458/// Normalise quaternions to unit norm along the last axis (shape `[..., 4]`).
459///
460/// The per-step rotation is materialised from a raw, unconstrained projection
461/// and normalised here so it is a genuine unit quaternion (an element of
462/// `SU(2)`), the analogue of `tanh(θ)·π` bounding the RoPE angle. A tiny floor
463/// guards the zero-quaternion.
464pub fn quat_normalize<const D: usize>(q: Tensor<D>) -> Tensor<D> {
465 let n = D - 1;
466 // Clamp the sum-of-squares *before* `sqrt`: at a zero quaternion the forward
467 // `sqrt(0)=0` is fine, but `sqrt`'s backward is `1/(2·0)=∞`, and `∞·(2·0)=NaN`.
468 // Clamping pre-`sqrt` puts the degenerate point in `clamp_min`'s flat region,
469 // so its gradient is a finite 0 (and a genuine unit quaternion, sumsq=1, is
470 // untouched). The floor also keeps `norm` away from 0 for the division.
471 //
472 // The floor is the dtype-aware `div_eps` applied to the *sum-of-squares*
473 // (giving a norm floor of `√div_eps`). It must engage as a representable
474 // normal in the working dtype: in f16 a `div_eps²`-sized floor (~5e-7) would
475 // underflow below the min-normal (~6.1e-5) and silently no-op, so we floor
476 // the squared quantity at `div_eps` itself, which sits above each format's
477 // denormal floor by construction.
478 let eps = burn_stack::utils::div_eps(q.dtype());
479 let norm = (q.clone() * q.clone()).sum_dim(n).clamp_min(eps).sqrt();
480 q / norm
481}
482
483/// Euclidean norm over the last axis, formed **scale-free** so it cannot
484/// overflow: the components are divided by their (detached) largest magnitude
485/// before squaring, and the result is scaled back.
486///
487/// Squaring the raw components is the obvious way and the wrong one here,
488/// because the inputs are raw in-projection channels: `‖r‖²` overflows f32 at
489/// `|r| ≈ 2e19` and **f16 at `|r| ≈ 250`**, which is an ordinary activation.
490/// The overflow does not announce itself — `∞` divides back to `0`, so a very
491/// large generator would silently produce *no* rotation, the exact opposite of
492/// the intended "turn as far as the bound allows". Same trick, same reason, as
493/// [`RmsNorm`](burn_stack::modules::RmsNorm)'s fp16 path.
494///
495/// The sum of squares is floored by `div_eps` before the `sqrt`, so a zero
496/// vector lands in `clamp_min`'s flat region and backprops to a finite `0`
497/// instead of `sqrt`'s singular `1/(2·0)`.
498///
499/// # Shapes
500/// - `t` : `[..., n]`
501/// - out : `[..., 1]`
502pub(crate) fn safe_norm<const D: usize>(t: Tensor<D>) -> Tensor<D> {
503 let n = D - 1;
504 let eps = burn_stack::utils::div_eps(t.dtype());
505 // Detached: the rescaling is a numerical device, not part of the function
506 // being differentiated (`d‖t‖/dt = t̂` either way).
507 let scale = t.clone().abs().max_dim(n).detach() + eps; // [..., 1]
508 let unit = t / scale.clone(); // components ≤ 1
509 (unit.clone() * unit).sum_dim(n).clamp_min(eps).sqrt() * scale
510}
511
512/// Materialise a unit quaternion from a **scaled rotation vector** `g ∈ ℝ³`
513/// (axis · angle) via the exponential map — the data-dependent "materialise
514/// `Rₜ`" step, analogous to RoPE's `Δₜ · π · tanh(θₜ)` angle.
515///
516/// With `‖g‖ = angle` and `ĝ = g / angle` the axis, returns the unit quaternion
517/// `q = (cos(angle/2), sin(angle/2)·ĝ)`. A vanishing `g` maps to the identity
518/// `(1, 0, 0, 0)`, so scaling `g` by a small `Δₜ` (the discretisation step)
519/// yields a near-identity rotation — exactly the regime where a small step
520/// barely rotates the state. The `sin(angle/2)/angle` factor is the numerically
521/// stable form of the (otherwise `0/0`) per-component scale near `g = 0`.
522///
523/// # Shapes
524/// - `g` : `[..., 3]`
525/// - out : `[..., 4]` (ordered `(w, x, y, z)`), unit norm.
526pub fn quat_from_scaled_axis<const D: usize>(g: Tensor<D>) -> Tensor<D> {
527 let n = D - 1;
528 // `safe_norm` both guards the origin (a zero generator would otherwise hit
529 // `sqrt`'s singular backward and yield NaN — the FiLM-triggered decoder
530 // NaN) and keeps the sum of squares from overflowing at large `g`.
531 let angle = safe_norm(g.clone()); // [..., 1]
532 let half = angle.clone() * 0.5;
533 let w = half.clone().cos(); // [..., 1]
534 // sin(angle/2) / angle → 1/2 as angle → 0 (no rotation); `angle ≥ √div_eps`
535 // after the pre-`sqrt` clamp above, so the division is already guarded.
536 let scale = half.sin() / angle; // [..., 1]
537 let v = g * scale; // [..., 3]
538 quat_normalize(Tensor::cat(vec![w, v], n))
539}
540
541/// Bound a rotation vector's **magnitude**, leaving its direction alone:
542/// returns `max_angle · tanh(‖r‖) · r̂`.
543///
544/// This is the quaternion counterpart of the abelian path's `π·tanh(ϑ)`, and
545/// the difference is deliberate. Squashing each of the three raw channels
546/// *separately* would bound the rotation vector to a **cube**: the reachable
547/// angle would depend on the axis (`max_angle` about a coordinate axis but
548/// `√3·max_angle` about the diagonal), and — worse — `tanh` applied per
549/// component moves the *direction* too, so the axis a given projection selects
550/// would depend on how large the projection is. Bounding the norm keeps the
551/// axis exactly `r̂` and the angle a function of `‖r‖` alone, so axis and angle
552/// are independent knobs.
553///
554/// Near `r = 0` the map is `≈ max_angle · r` (the `tanh(n)/n → 1` limit); the
555/// `sum-of-squares` floor is the same pre-`sqrt` clamp
556/// [`quat_from_scaled_axis`] uses, and puts the degenerate point in the flat
557/// region of `clamp_min` with the correct finite gradient.
558///
559/// # Shapes
560/// - `r` : `[..., 3]`
561/// - out : `[..., 3]`, with `‖out‖ ≤ max_angle` (attained once `tanh` saturates).
562pub fn bound_rotation_vector<const D: usize>(r: Tensor<D>, max_angle: f64) -> Tensor<D> {
563 let norm = safe_norm(r.clone()); // [..., 1]
564 let scale = norm.clone().tanh() * max_angle / norm;
565 r * scale
566}
567
568/// Materialise the `4×4` orthogonal matrix of left-multiplication by `q`.
569///
570/// Maps `q` of shape `[..., 4]` to `[..., 4, 4]` such that, for `v` of shape
571/// `[..., 4]`, `Lq · v == quat_mul(q, v)`. Concretely (rows = output coords,
572/// cols = input coords, all in `(w, x, y, z)` order):
573///
574/// ```text
575/// ⎡ w -x -y -z ⎤
576/// ⎢ x w -z y ⎥
577/// ⎢ y z w -x ⎥
578/// ⎣ z -y x w ⎦
579/// ```
580///
581/// For a unit `q` this is orthogonal with `det = 1` (a left-isoclinic rotation).
582/// Provided mainly for the generic / verification path; the cheap way to apply a
583/// rotation is [`rotate_state_rank_blocks`] (a quaternion product, no `4×4`
584/// materialisation). `DR` must equal `D + 1`.
585pub fn quat_to_rot4<const D: usize, const DR: usize>(q: Tensor<D>) -> Tensor<DR> {
586 assert_eq!(D + 1, DR, "quat_to_rot4 maps rank D to rank D+1");
587 let n = D - 1;
588 let w = q.clone().narrow(n, 0, 1);
589 let x = q.clone().narrow(n, 1, 1);
590 let y = q.clone().narrow(n, 2, 1);
591 let z = q.narrow(n, 3, 1);
592
593 // Each row is a [..., 4] tensor (the four column entries).
594 let row0 = Tensor::cat(vec![w.clone(), -x.clone(), -y.clone(), -z.clone()], n);
595 let row1 = Tensor::cat(vec![x.clone(), w.clone(), -z.clone(), y.clone()], n);
596 let row2 = Tensor::cat(vec![y.clone(), z.clone(), w.clone(), -x.clone()], n);
597 let row3 = Tensor::cat(vec![z, -y, x, w], n);
598
599 // Stack the rows along a freshly inserted row axis → [..., 4, 4].
600 Tensor::cat(
601 vec![
602 row0.unsqueeze_dim::<DR>(n),
603 row1.unsqueeze_dim::<DR>(n),
604 row2.unsqueeze_dim::<DR>(n),
605 row3.unsqueeze_dim::<DR>(n),
606 ],
607 n,
608 )
609}
610
611// ---------------------------------------------------------------------------
612// Rotation application on the state_rank axis
613// ---------------------------------------------------------------------------
614
615/// Apply a per-block quaternion rotation to the `state_rank` axis of `v`.
616///
617/// `v` has shape `[..., state_rank]` with `state_rank = 4·J`, viewed as `J`
618/// independent quaternion blocks; `q` has shape `[..., J, 4]` (one unit
619/// quaternion per block, same leading dims as `v`). Returns `q ⊗ v` per block,
620/// i.e. the rotation `L_q` applied within each `4`-block, reshaped back to
621/// `[..., state_rank]`.
622///
623/// This is the generalisation of RoPE's per-pair `2×2` rotation to per-block
624/// `4×4`. To rotate by the *inverse* cumulative rotation when absorbing into
625/// `B`/`C` (`B̄ = Pᵀ B`), pass `q = conj(Qcum)`:
626/// `rotate_state_rank_blocks(b, conj(qcum))`.
627///
628/// `DB` must equal `D + 1` (the block-split inserts the `J` axis).
629pub fn rotate_state_rank_blocks<const D: usize, const DB: usize>(
630 v: Tensor<D>,
631 q: Tensor<DB>,
632) -> Tensor<D> {
633 assert_eq!(
634 D + 1,
635 DB,
636 "rotate_state_rank_blocks splits one axis into (J, 4)"
637 );
638 let dims = v.dims();
639 let state_rank = dims[D - 1];
640 assert_eq!(
641 state_rank % 4,
642 0,
643 "state_rank must be a multiple of 4 (quaternion blocks)"
644 );
645 let blocks = state_rank / 4;
646
647 // Build the block-split shape [..., J, 4] (rank DB) and the flat shape
648 // [..., state_rank] (rank D) for the round trip.
649 let mut split_shape = [0usize; DB];
650 split_shape[..D - 1].copy_from_slice(&dims[..D - 1]);
651 split_shape[DB - 2] = blocks;
652 split_shape[DB - 1] = 4;
653
654 let v_blocks = v.reshape(split_shape); // [..., J, 4]
655 let rotated = quat_mul(q, v_blocks); // L_q applied per block
656 rotated.reshape(dims) // [..., state_rank]
657}
658
659/// Apply a per-block **two-sided** quaternion rotation to the `state_rank` axis
660/// of `v`: `v ↦ ql ⊗ v ⊗ qr` per 4-block.
661///
662/// This is the general `SO(4)` element ([`RotationKind::Rotor4D`]); pass
663/// `qr = (1,0,0,0)` to recover [`rotate_state_rank_blocks`]. Absorbing the
664/// *inverse* cumulative rotation into `B`/`C` (`B̄ = P⁻¹B` with
665/// `P(v) = Q v T̄`) is `ql = conj(Q)`, `qr = T` — note the conjugate is on the
666/// **left** factor only.
667///
668/// Shapes as [`rotate_state_rank_blocks`]: `v` is `[..., state_rank]`, both
669/// quaternions `[..., J, 4]`, and `DB = D + 1`.
670pub fn rotate_state_rank_blocks_two_sided<const D: usize, const DB: usize>(
671 v: Tensor<D>,
672 ql: Tensor<DB>,
673 qr: Tensor<DB>,
674) -> Tensor<D> {
675 assert_eq!(
676 D + 1,
677 DB,
678 "rotate_state_rank_blocks_two_sided splits one axis into (J, 4)"
679 );
680 let dims = v.dims();
681 let state_rank = dims[D - 1];
682 assert_eq!(
683 state_rank % 4,
684 0,
685 "state_rank must be a multiple of 4 (quaternion blocks)"
686 );
687
688 let mut split_shape = [0usize; DB];
689 split_shape[..D - 1].copy_from_slice(&dims[..D - 1]);
690 split_shape[DB - 2] = state_rank / 4;
691 split_shape[DB - 1] = 4;
692
693 let v_blocks = v.reshape(split_shape); // [..., J, 4]
694 quat_mul(quat_mul(ql, v_blocks), qr).reshape(dims)
695}
696
697/// Split a stacked [`RotationState::Rotor`] accumulator `[..., 2·J, 4]` into
698/// its `(left, right)` factors, each `[..., J, 4]`.
699pub fn split_rotor<const D: usize>(q: Tensor<D>) -> (Tensor<D>, Tensor<D>) {
700 let stacked = q.dims()[D - 2];
701 assert_eq!(stacked % 2, 0, "a Rotor accumulator stacks two factors");
702 let blocks = stacked / 2;
703 (
704 q.clone().narrow(D - 2, 0, blocks),
705 q.narrow(D - 2, blocks, blocks),
706 )
707}
708
709// ---------------------------------------------------------------------------
710// Cumulative rotation scan (the associative, non-abelian replacement for cumsum)
711// ---------------------------------------------------------------------------
712
713/// Cumulative (ordered, left-accumulating) quaternion product along the
714/// sequence axis, with a cross-chunk carry.
715///
716/// This is the non-abelian analogue of the cumulative *sum of angles* used by
717/// RoPE: where complex rotations compose by adding angles (a `cumsum`),
718/// quaternions compose by multiplication, which is order-dependent, so a real
719/// scan is required.
720///
721/// # Shapes
722/// - `q_bshj4` : `[batch, sequence, nheads, J, 4]` per-step **unit** quaternions
723/// (block count `J = state_rank / 4`).
724/// - `init` : optional carry `[batch, nheads, J, 4]` — the cumulative
725/// rotation at the end of the previous chunk (identity `(1,0,0,0)` for a fresh
726/// start).
727/// - returns `(cum, final_carry)` where `cum` is `[batch, sequence, nheads, J, 4]`
728/// with `cum[:, t] = qₜ ⊗ qₜ₋₁ ⊗ ⋯ ⊗ q₀ ⊗ init` (newest on the left, matching
729/// `Pₜ = Rₜ ⋯ R₁`), and `final_carry` `[batch, nheads, J, 4]` is `cum[:, −1]`
730/// to thread into the next chunk.
731///
732/// Running this over a split sequence while threading `final_carry` is exactly
733/// equal to running it over the whole sequence (asserted in the tests) — the
734/// chunked-prefill / streaming guarantee, here for the rotation accumulator.
735///
736/// Implemented as a **Hillis–Steele** inclusive associative scan: the quaternion
737/// product is associative (just not commutative), so a log-depth scan applies as
738/// long as operand order is preserved (newest-on-left). Each doubling step is a
739/// single full-tensor [`quat_mul`] plus a sequence shift, so the *sequential
740/// dependency depth* is `O(log sequence)` rather than the `O(sequence)` of a
741/// token-by-token loop — the same values, but a handful of large batched kernels
742/// instead of thousands of serialized tiny ones (and a correspondingly shallow
743/// autodiff graph). The sequential reference it replaces is kept as a test oracle
744/// (`quat_cumprod_sequential` in the tests module) and asserted equal on values
745/// **and** gradients.
746pub fn quat_cumprod(q_bshj4: Tensor<5>, init: Option<Tensor<4>>) -> (Tensor<5>, Tensor<4>) {
747 let [batch, sequence, nheads, blocks, _four] = q_bshj4.dims();
748 let device = q_bshj4.device();
749
750 // Pure prefix product Pₜ = qₜ ⊗ qₜ₋₁ ⊗ ⋯ ⊗ q₀ by Hillis–Steele doubling.
751 // Invariant after each step with offset `d`: a[t] holds the product of the
752 // window [t .. max(t-2d+1, 0)] (newest on the left). After ⌈log₂ sequence⌉
753 // doublings the window covers [t .. 0], i.e. a[t] = Pₜ.
754 let mut a = q_bshj4;
755 let mut offset = 1usize;
756 while offset < sequence {
757 // shifted[t] = a[t-offset] for t ≥ offset, else the identity quaternion
758 // (1,0,0,0) — so the first `offset` prefixes pass through unchanged
759 // (a ⊗ identity = a).
760 let ident = {
761 let w = Tensor::ones([batch, offset, nheads, blocks, 1], &device);
762 let xyz = Tensor::zeros([batch, offset, nheads, blocks, 3], &device);
763 Tensor::cat(vec![w, xyz], 4)
764 };
765 let shifted = Tensor::cat(vec![ident, a.clone()], 1).narrow(1, 0, sequence);
766 // Recent block (a) on the left, older block (shifted) on the right.
767 a = quat_mul(a, shifted);
768 offset *= 2;
769 }
770
771 // Fold the cross-chunk carry once: cumₜ = Pₜ ⊗ init. `init` (the previous
772 // chunk's final cumulative rotation) is the oldest factor, hence on the
773 // right; a missing carry is the identity and needs no multiply.
774 let cum = match init {
775 Some(init_bhj4) => {
776 assert_eq!([batch, nheads, blocks, 4], init_bhj4.dims());
777 quat_mul(a, init_bhj4.unsqueeze_dim::<5>(1)) // [batch, 1, nheads, J, 4] broadcasts over seq
778 }
779 None => a,
780 };
781
782 let final_carry = cum.clone().narrow(1, sequence - 1, 1).squeeze_dim::<4>(1); // [batch, nheads, J, 4]
783 (cum, final_carry)
784}
785
786// ---------------------------------------------------------------------------
787// Partial block rotation (rope_fraction support)
788// ---------------------------------------------------------------------------
789
790/// Apply a per-block quaternion rotation to the first `rope_width` entries of
791/// the `state_rank` axis (a multiple of 4); the remainder passes through. The
792/// quaternion analogue of [`apply_rope_partial`].
793///
794/// `q` has one quaternion per rotated block (`rope_width / 4` of them). `DB`
795/// must equal `D + 1`.
796pub fn rotate_blocks_partial<const D: usize, const DB: usize>(
797 v: Tensor<D>,
798 q: Tensor<DB>,
799 rope_width: usize,
800) -> Tensor<D> {
801 let r = v.dims()[D - 1];
802 debug_assert!(
803 rope_width > 0,
804 "a quaternion kind always turns at least one block; a real transition is RotationKind::Real1D"
805 );
806 if rope_width == r {
807 rotate_state_rank_blocks::<D, DB>(v, q)
808 } else {
809 let head = v.clone().narrow(D - 1, 0, rope_width);
810 let tail = v.narrow(D - 1, rope_width, r - rope_width);
811 let head_rot = rotate_state_rank_blocks::<D, DB>(head, q);
812 Tensor::cat(vec![head_rot, tail], D - 1)
813 }
814}
815
816/// Two-sided counterpart of [`rotate_blocks_partial`]: rotates the first
817/// `rope_width` entries of the `state_rank` axis by `v ↦ ql ⊗ v ⊗ qr`, passing
818/// the remainder through.
819pub fn rotate_blocks_two_sided_partial<const D: usize, const DB: usize>(
820 v: Tensor<D>,
821 ql: Tensor<DB>,
822 qr: Tensor<DB>,
823 rope_width: usize,
824) -> Tensor<D> {
825 let r = v.dims()[D - 1];
826 debug_assert!(rope_width > 0, "see rotate_blocks_partial");
827 if rope_width == r {
828 rotate_state_rank_blocks_two_sided::<D, DB>(v, ql, qr)
829 } else {
830 let head = v.clone().narrow(D - 1, 0, rope_width);
831 let tail = v.narrow(D - 1, rope_width, r - rope_width);
832 let head_rot = rotate_state_rank_blocks_two_sided::<D, DB>(head, ql, qr);
833 Tensor::cat(vec![head_rot, tail], D - 1)
834 }
835}
836
837// ---------------------------------------------------------------------------
838// Per-step rotation increments (the single definition every site derives from)
839// ---------------------------------------------------------------------------
840
841/// The abelian per-step angle increment `θ̂ₜ = Δₜ · range·π·tanh(ϑₜ)`, one per
842/// head and rotation pair.
843///
844/// `DP1 = D + 1`: the head axis is inserted before the pair axis, so a
845/// sequence-shaped call (`rot [b, s, a]`, `dt [b, s, h]`) yields `[b, s, h, a]`
846/// and a single-token call (`rot [b, a]`, `dt [b, h]`) yields `[b, h, a]`.
847pub fn angle_increment<const D: usize, const DP1: usize>(
848 rot: Tensor<D>,
849 dt: Tensor<D>,
850 range: f64,
851) -> Tensor<DP1> {
852 assert_eq!(D + 1, DP1, "angle_increment inserts the head axis");
853 let bounded = rot.tanh() * (range * std::f64::consts::PI);
854 dt.unsqueeze_dim::<DP1>(D) * bounded.unsqueeze_dim::<DP1>(D - 1)
855}
856
857/// The quaternion per-step rotation vector `gₜ = Δₜ · range·π·tanh(‖r‖)·r̂`, one
858/// per head and quaternion block (feed it to [`quat_from_scaled_axis`]).
859///
860/// The magnitude — not each component — is bounded, so the axis is exactly the
861/// direction of the projection; see [`bound_rotation_vector`].
862///
863/// Unlike the abelian [`angle_increment`], the generators are projected **per
864/// head**: `rot` carries `nheads · 3 · J` channels, so every head turns about
865/// its own data-dependent axis rather than sharing one axis and differing only
866/// in `Δ`. Sharing would make the heads' rotations a one-parameter family of
867/// each other — the abelian path can afford that (its rotations commute, so a
868/// per-head angle is the only freedom there is), but for a non-abelian
869/// transition the axis *is* the expressive part: two heads turning about
870/// different axes track different words, which is the whole point of having
871/// more than one.
872///
873/// `DP1 = D + 1`, `DP2 = D + 2`: a sequence-shaped call (`rot [b, s, h·3·J]`,
874/// `dt [b, s, h]`) yields `[b, s, h, J, 3]` and a single-token call
875/// (`rot [b, h·3·J]`, `dt [b, h]`) yields `[b, h, J, 3]`.
876pub fn generator_increment<const D: usize, const DP1: usize, const DP2: usize>(
877 rot: Tensor<D>,
878 dt: Tensor<D>,
879 blocks: usize,
880 range: f64,
881) -> Tensor<DP2> {
882 assert_eq!(D + 1, DP1, "generator_increment splits (3·J) into (J, 3)");
883 assert_eq!(
884 D + 2,
885 DP2,
886 "generator_increment also splits off the head axis"
887 );
888 let dims = rot.dims();
889 let nheads = dt.dims()[D - 1];
890 assert_eq!(
891 dims[D - 1],
892 nheads * 3 * blocks,
893 "the rotation channels are three generators per (head, quaternion block)"
894 );
895 // [..., h·3·J] → [..., h, J, 3]
896 let mut split = [0usize; DP2];
897 split[..D - 1].copy_from_slice(&dims[..D - 1]);
898 split[DP2 - 3] = nheads;
899 split[DP2 - 2] = blocks;
900 split[DP2 - 1] = 3;
901 let bounded = bound_rotation_vector::<DP2>(rot.reshape(split), range * std::f64::consts::PI);
902 // Δ is per head: broadcast it over (J, 3)
903 bounded * dt.unsqueeze_dim::<DP1>(D).unsqueeze_dim::<DP2>(D + 1)
904}
905
906// ---------------------------------------------------------------------------
907// Forward / step rotation of B and C (shared by both SSD pathways)
908// ---------------------------------------------------------------------------
909
910/// Rotate `B`/`C` for a **full sequence** by the data-dependent transition
911/// rotation, returning the rotated projections and the new cumulative
912/// [`RotationState`] to store in the cache.
913///
914/// Branches on [`RotationKind`]:
915/// - [`Real1D`](RotationKind::Real1D): nothing happens — `B`/`C` pass through
916/// and the (empty) accumulator is handed back. `rot` is `None` there, since
917/// the block projects no rotation channels at all.
918/// - [`Complex2D`](RotationKind::Complex2D): the abelian RoPE — cumulative
919/// angle `cumsum` continued from `prev`, then [`apply_rope_partial`]. Exactly
920/// the original Mamba-3 behaviour.
921/// - [`Quaternion4D`](RotationKind::Quaternion4D): per-step unit quaternion
922/// [`quat_from_scaled_axis`] (the in-projection generators scaled per-head by
923/// `Δ`), composed by [`quat_cumprod`] continuing the cached quaternion, then
924/// applied to `B`/`C` as `rotate(·, conj(Qₜ))` over the first `4·blocks`
925/// state-rank entries.
926///
927/// # Shapes
928/// - `rot_bsa` : `[batch, sequence, num_rotation_channels]` — the in-projection
929/// rotation channels (angles for Complex2D, `3·blocks` quaternion generators
930/// for Quaternion4D), `None` for Real1D, which projects none.
931/// - `dt_bsh` : `[batch, sequence, nheads]` (`Δ`).
932/// - `b_bsmhr` / `c_bsmhr` : `[batch, sequence, mimo_rank, nheads, state_rank]`.
933pub fn rotate_bc_forward(
934 rot_bsa: Option<Tensor<3>>,
935 dt_bsh: Tensor<3>,
936 prev: RotationState,
937 b_bsmhr: Tensor<5>,
938 c_bsmhr: Tensor<5>,
939 spec: RotationSpec,
940) -> (Tensor<5>, Tensor<5>, RotationState) {
941 let [batch, sequence, mimo_rank, nheads, _state_rank] = b_bsmhr.dims();
942 let RotationSpec {
943 kind,
944 rope_dim,
945 range,
946 } = spec;
947 // Only `Real1D` projects no rotation channels, and it never reads them.
948 let rot = |r: Option<Tensor<3>>| r.expect("a rotating kind projects rotation channels");
949 match kind {
950 RotationKind::Real1D => (b_bsmhr, c_bsmhr, prev.expect_real()),
951 RotationKind::Complex2D => {
952 let rot_bsa = rot(rot_bsa);
953 let prev_angle_bha = prev.angle();
954 let num_rope_angles = prev_angle_bha.dims()[2];
955 let raw_angles_bsha = angle_increment::<3, 4>(rot_bsa, dt_bsh, range);
956 let cum_angles_bsha = prev_angle_bha.unsqueeze_dim::<4>(1) + raw_angles_bsha.cumsum(1);
957 let cum_angles_bsmha = cum_angles_bsha.clone().unsqueeze_dim::<5>(2).expand([
958 batch,
959 sequence,
960 mimo_rank,
961 nheads,
962 num_rope_angles,
963 ]);
964 let rotate_pairwise = mimo_rank == 1;
965 let b = apply_rope_partial::<5>(
966 b_bsmhr,
967 cum_angles_bsmha.clone(),
968 rope_dim,
969 rotate_pairwise,
970 );
971 let c = apply_rope_partial::<5>(c_bsmhr, cum_angles_bsmha, rope_dim, rotate_pairwise);
972 let last = wrap_angle(
973 cum_angles_bsha
974 .narrow(1, sequence - 1, 1)
975 .squeeze_dim::<3>(1),
976 );
977 (b, c, RotationState::Angle(last))
978 }
979 RotationKind::Quaternion4D | RotationKind::Rotor4D => {
980 // `stack` is the generator/scan block axis: `blocks` for
981 // Quaternion4D, `2·blocks` for Rotor4D (left factors, then right).
982 // Everything up to the *application* is factor-agnostic and runs
983 // once over the whole stack.
984 let (prev_q_bhk4, blocks) = prev.quat_stack(kind);
985 let stack = prev_q_bhk4.dims()[2];
986 // The rotated width is a whole number of quaternion blocks, and
987 // the cache was sized for exactly that many.
988 assert_eq!(rope_dim, blocks * 4, "cache/block rotation width mismatch");
989 let rope_width = rope_dim;
990 // Generators [b,s,h,stack,3]: the raw channels bounded to an angle
991 // of at most `range·π` (see `generator_increment`) and scaled
992 // per-head by Δ. The bound is load-bearing, not cosmetic: an
993 // unbounded `g = rot·Δ` overflows f32 to `inf` for a large
994 // in-projection activation, and `quat_from_scaled_axis`'s `cos(∞)`
995 // then yields a forward NaN.
996 let g_bshk3 = generator_increment::<3, 4, 5>(rot(rot_bsa), dt_bsh, stack, range);
997 let q_step_bshk4 = quat_from_scaled_axis::<5>(g_bshk3);
998 // Memory-efficient scan: a custom recompute backward (saves only the
999 // leaf inputs) instead of retaining the scan's intermediates. Equal
1000 // to [`quat_cumprod`] on values and gradients (asserted in tests).
1001 let (cum_bshk4, final_bhk4) = crate::mamba3::quat_scan::quat_cumprod_recalculated(
1002 q_step_bshk4,
1003 Some(prev_q_bhk4),
1004 );
1005 // Renormalise the prefixes. A product of unit quaternions is a unit
1006 // quaternion in exact arithmetic, but the scan composes each prefix
1007 // out of `⌈log₂ L⌉` multiplies, so the norm drifts — negligibly in
1008 // f32, by ~1% over a long f16 sequence, and a non-unit rotation
1009 // rescales B/C instead of only turning them. `step` already
1010 // normalises every step; this is the chunkwise counterpart, and it
1011 // keeps the two numerically alike as well as exactly orthogonal.
1012 let cum_bshk4 = quat_normalize(cum_bshk4);
1013 let over_mimo = |q_bshj4: Tensor<5>| {
1014 q_bshj4
1015 .unsqueeze_dim::<6>(2)
1016 .expand([batch, sequence, mimo_rank, nheads, blocks, 4])
1017 };
1018 // B̄ = P⁻¹B: rotate by the inverse cumulative rotation, per block,
1019 // broadcast over the mimo_rank axis.
1020 let (b, c) = match kind {
1021 RotationKind::Rotor4D => {
1022 // P(v) = Q v T̄ ⇒ P⁻¹(v) = Q* v T (conjugate on the left
1023 // factor only).
1024 let (left, right) = split_rotor(cum_bshk4);
1025 let ql = over_mimo(quat_conj(left));
1026 let qr = over_mimo(right);
1027 (
1028 rotate_blocks_two_sided_partial::<5, 6>(
1029 b_bsmhr,
1030 ql.clone(),
1031 qr.clone(),
1032 rope_width,
1033 ),
1034 rotate_blocks_two_sided_partial::<5, 6>(c_bsmhr, ql, qr, rope_width),
1035 )
1036 }
1037 _ => {
1038 let conj_bsmhj4 = over_mimo(quat_conj(cum_bshk4));
1039 (
1040 rotate_blocks_partial::<5, 6>(b_bsmhr, conj_bsmhj4.clone(), rope_width),
1041 rotate_blocks_partial::<5, 6>(c_bsmhr, conj_bsmhj4, rope_width),
1042 )
1043 }
1044 };
1045 let final_bhk4 = quat_normalize(final_bhk4);
1046 let state = match kind {
1047 RotationKind::Rotor4D => RotationState::Rotor(final_bhk4),
1048 _ => RotationState::Quaternion(final_bhk4),
1049 };
1050 (b, c, state)
1051 }
1052 }
1053}
1054
1055/// Single-token counterpart of [`rotate_bc_forward`] for the recurrent `step`.
1056///
1057/// # Shapes
1058/// - `rot_ba` : `[batch, num_rotation_channels]`, `None` for Real1D.
1059/// - `dt_bh` : `[batch, nheads]`.
1060/// - `b_bmhr` / `c_bmhr` : `[batch, mimo_rank, nheads, state_rank]`.
1061pub fn rotate_bc_step(
1062 rot_ba: Option<Tensor<2>>,
1063 dt_bh: Tensor<2>,
1064 prev: RotationState,
1065 b_bmhr: Tensor<4>,
1066 c_bmhr: Tensor<4>,
1067 spec: RotationSpec,
1068) -> (Tensor<4>, Tensor<4>, RotationState) {
1069 let [batch, mimo_rank, nheads, _state_rank] = b_bmhr.dims();
1070 let RotationSpec {
1071 kind,
1072 rope_dim,
1073 range,
1074 } = spec;
1075 let rot = |r: Option<Tensor<2>>| r.expect("a rotating kind projects rotation channels");
1076 match kind {
1077 RotationKind::Real1D => (b_bmhr, c_bmhr, prev.expect_real()),
1078 RotationKind::Complex2D => {
1079 let rot_ba = rot(rot_ba);
1080 let prev_angle_bha = prev.angle();
1081 let num_rope_angles = prev_angle_bha.dims()[2];
1082 let raw_angle_bha = angle_increment::<2, 3>(rot_ba, dt_bh, range);
1083 let new_cum_angle_bha = wrap_angle(prev_angle_bha + raw_angle_bha);
1084 let new_cum_angle_bmha = new_cum_angle_bha.clone().unsqueeze_dim::<4>(1).expand([
1085 batch,
1086 mimo_rank,
1087 nheads,
1088 num_rope_angles,
1089 ]);
1090 let rotate_pairwise = mimo_rank == 1;
1091 let b = apply_rope_partial::<4>(
1092 b_bmhr,
1093 new_cum_angle_bmha.clone(),
1094 rope_dim,
1095 rotate_pairwise,
1096 );
1097 let c = apply_rope_partial::<4>(c_bmhr, new_cum_angle_bmha, rope_dim, rotate_pairwise);
1098 (b, c, RotationState::Angle(new_cum_angle_bha))
1099 }
1100 RotationKind::Quaternion4D | RotationKind::Rotor4D => {
1101 let (prev_q_bhk4, blocks) = prev.quat_stack(kind);
1102 let stack = prev_q_bhk4.dims()[2];
1103 assert_eq!(rope_dim, blocks * 4, "cache/block rotation width mismatch");
1104 let rope_width = rope_dim;
1105 // The same bounded generator as `rotate_bc_forward` (see the note there).
1106 let g_bhk3 = generator_increment::<2, 3, 4>(rot(rot_ba), dt_bh, stack, range);
1107 let q_step_bhk4 = quat_from_scaled_axis::<4>(g_bhk3);
1108 // Single step: Qₜ = qₜ ⊗ Qₜ₋₁ — and, stacked alongside it for
1109 // Rotor4D, Tₜ = pₜ ⊗ Tₜ₋₁ (the same left fold, see [`RotationState::Rotor`]).
1110 let new_q_bhk4 = quat_normalize(quat_mul(q_step_bhk4, prev_q_bhk4));
1111 let over_mimo = |q_bhj4: Tensor<4>| {
1112 q_bhj4
1113 .unsqueeze_dim::<5>(1)
1114 .expand([batch, mimo_rank, nheads, blocks, 4])
1115 };
1116 match kind {
1117 RotationKind::Rotor4D => {
1118 let (left, right) = split_rotor(new_q_bhk4.clone());
1119 let ql = over_mimo(quat_conj(left));
1120 let qr = over_mimo(right);
1121 let b = rotate_blocks_two_sided_partial::<4, 5>(
1122 b_bmhr,
1123 ql.clone(),
1124 qr.clone(),
1125 rope_width,
1126 );
1127 let c = rotate_blocks_two_sided_partial::<4, 5>(c_bmhr, ql, qr, rope_width);
1128 (b, c, RotationState::Rotor(new_q_bhk4))
1129 }
1130 _ => {
1131 let conj_bmhj4 = over_mimo(quat_conj(new_q_bhk4.clone()));
1132 let b = rotate_blocks_partial::<4, 5>(b_bmhr, conj_bmhj4.clone(), rope_width);
1133 let c = rotate_blocks_partial::<4, 5>(c_bmhr, conj_bmhj4, rope_width);
1134 (b, c, RotationState::Quaternion(new_q_bhk4))
1135 }
1136 }
1137 }
1138 }
1139}
1140
1141#[cfg(all(test, feature = "_dev-test"))]
1142mod tests;