Skip to main content

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;