ferrox_models/rope_layers.rs
1//! **Which layers rotate.** llama.cpp's per-layer `use_rope` gate, as
2//! one rule with a table, rather than one branch per architecture.
3//!
4//! Every architecture ferrox had audited rotates Q and K on every
5//! layer, and that is llama.cpp's default too. Six architectures
6//! upstream do not, and until 2026-09-10 ferrox had no way to say so:
7//! three of them were REFUSED for it, one ran WRONG, and two were
8//! latent behind other refusals.
9//!
10//! # The six, transcribed
11//!
12//! | arch | llama.cpp | line |
13//! |---|---|---|
14//! | `exaone4` | `is_swa(il) \|\| swa_type == NONE` | `exaone4.cpp:116` |
15//! | `exaone-moe` | `is_swa(il)` | `exaone-moe.cpp:136,155` |
16//! | `smollm3` | `(il + 1) % 4 != 0` | `smollm3.cpp:5,69` |
17//! | `smallthinker` | `step == n_layer \|\| il % step != 0` | `smallthinker.cpp:18,108-109` |
18//! | `afmoe` | `step > 0 && (il + 1) % step != 0` | `afmoe.cpp:137-138` |
19//! | `llama4` | `step > 0 && (il + 1) % step != 0` | `llama4.cpp:11,145-146` |
20//!
21//! **`exaone-moe` and `exaone4` are ONE rule, not two that look
22//! alike.** `exaone-moe.cpp:4` sets `swa_type = LLAMA_SWA_TYPE_STANDARD`
23//! unconditionally, so its `is_swa(il)` is exactly `exaone4`'s
24//! `is_swa(il) || swa_type == NONE` with the second disjunct nailed to
25//! false. Read side by side the two `if` bodies are the same two
26//! `ggml_rope_ext` calls guarded by the same predicate; the only
27//! difference is that `exaone4.cpp:4` reaches that predicate solely when
28//! `n_layer() == 64`.
29//!
30//! `smallthinker` collapses onto the same shape ONLY BY COINCIDENCE and
31//! is deliberately NOT written that way here. With a window it takes
32//! `set_swa_pattern(4, dense_first = true)`, so `is_swa(il)` is
33//! `il % 4 != 0`, which happens to equal its `use_rope`; llama.cpp's own
34//! comment at `smallthinker.cpp:107` says "this overlaps with SWA layers
35//! in current models". They are two independent constants -- the SWA
36//! period comes from `{arch}.attention.sliding_window_pattern` and the
37//! no-RoPE step never does -- so a file overriding the period to 2 would
38//! separate them, and collapsing the two would rope such a file wrong in
39//! precisely the way this module exists to stop.
40//!
41//! # Why a step is never a GGUF key
42//!
43//! `hparams.n_no_rope_layer_step` is set from a literal in every one of
44//! the four architectures that use it and read from no key anywhere in
45//! llama.cpp (`grep -rn n_no_rope_layer_step src/`). `llama-hparams.h:203`
46//! defaults it to **4**, which is why `afmoe` and `llama4` are in the
47//! table despite never assigning it: they inherit the default and their
48//! graphs consult it. So there is nothing for a metadata gate to test.
49//! A checkpoint of any of these six loads clean, carries the generic
50//! tensor set, runs at full speed and answers from positions it never
51//! encodes that way.
52
53use std::num::NonZeroUsize;
54
55/// Which of a period's layers is the one that does NOT rotate.
56///
57/// Two spellings, both live upstream, and they differ by one layer of
58/// phase. Getting it wrong is not a near miss: on a 36-layer step-4
59/// model the two phases disagree about EIGHTEEN layers.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum NoRopePhase {
62 /// `(il + 1) % step == 0` -- the LAST layer of each period skips
63 /// rotation. `smollm3.cpp:69`, `afmoe.cpp:138`, `llama4.cpp:146`.
64 LastOfPeriod,
65 /// `il % step == 0` -- the FIRST layer of each period skips
66 /// rotation. `smallthinker.cpp:109`.
67 FirstOfPeriod,
68}
69
70/// llama.cpp's per-layer `use_rope`, as a value a `ModelConfig` can
71/// carry.
72///
73/// The default is [`RopeLayers::All`] and it is what every audited
74/// architecture but four gets. A variant is only ever added by reading
75/// a `use_rope` in `src/models/`.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
77pub enum RopeLayers {
78 /// Every layer rotates: llama.cpp writes no gate at all.
79 #[default]
80 All,
81 /// Only the SLIDING-WINDOW layers rotate; the full-attention layers
82 /// get no rotation. `exaone4` (when its SWA is on) and `exaone-moe`.
83 ///
84 /// Note what this means when a model's SWA pattern windows nothing
85 /// (llama.cpp's degenerate `set_swa_pattern(1)`): NO layer rotates.
86 /// That is upstream's answer too -- `is_swa(il)` is false everywhere
87 /// while `swa_type` is still `STANDARD` -- and it is why this is not
88 /// written as "sliding layers, or everything if nothing slides".
89 SlidingOnly,
90 /// One layer in every `step` does not rotate,
91 /// `hparams.n_no_rope_layer_step`.
92 NoRopeEvery {
93 /// llama.cpp's `n_no_rope_layer_step`. Never zero here: the two
94 /// architectures that guard on `step > 0` inherit the
95 /// `llama-hparams.h:203` default of 4 and never assign it.
96 step: NonZeroUsize,
97 /// Which layer of the period is skipped.
98 phase: NoRopePhase,
99 },
100}
101
102impl RopeLayers {
103 /// Does layer `layer_idx` rotate?
104 ///
105 /// `layer_slides` is `ModelConfig::layer_sliding_window(il).is_some()`,
106 /// i.e. llama.cpp's `hparams.is_swa(il)`. It is a parameter rather
107 /// than something this enum works out, because the SWA layout is
108 /// the `ModelConfig`'s answer and restating it here would be two
109 /// structures that must agree about one thing.
110 #[inline]
111 pub fn rotates(self, layer_idx: usize, layer_slides: bool) -> bool {
112 match self {
113 Self::All => true,
114 Self::SlidingOnly => layer_slides,
115 Self::NoRopeEvery { step, phase } => {
116 let step = step.get();
117 match phase {
118 NoRopePhase::LastOfPeriod => !(layer_idx + 1).is_multiple_of(step),
119 NoRopePhase::FirstOfPeriod => !layer_idx.is_multiple_of(step),
120 }
121 }
122 }
123 }
124
125 /// True when this rule leaves at least one layer of an `n_layers`
126 /// model unrotated, i.e. when it is anything other than "rotate
127 /// everything" IN PRACTICE rather than in name.
128 ///
129 /// The distinction matters: `NoRopeEvery { step: 64, .. }` on a
130 /// 30-layer model is [`Self::All`] by any observable test, and a
131 /// caller asking "must I implement this?" wants the observable
132 /// answer.
133 pub fn any_layer_unrotated(self, n_layers: usize, slides: impl Fn(usize) -> bool) -> bool {
134 (0..n_layers).any(|il| !self.rotates(il, slides(il)))
135 }
136}
137
138/// llama.cpp's `n_no_rope_layer_step` default (`llama-hparams.h:203`).
139///
140/// Not a ferrox choice and not a tunable: `afmoe` and `llama4` never
141/// assign the field and their graphs still read it, so this literal is
142/// load-bearing for two of the six rows.
143const LLAMA_CPP_DEFAULT_NO_ROPE_STEP: usize = 4;
144
145const fn step(n: usize) -> NonZeroUsize {
146 match NonZeroUsize::new(n) {
147 Some(n) => n,
148 // A zero step would make `il % step` a division by zero; no row
149 // has one and none can be added without tripping this.
150 None => panic!("a no-RoPE step of 0 is not a rule"),
151 }
152}
153
154/// THE table. Which layers of `arch` rotate.
155///
156/// `has_sliding_window` is llama.cpp's `swa_type != LLAMA_SWA_TYPE_NONE`
157/// for this file -- ferrox's post-gate answer, so
158/// [`crate::capability::honours_sliding_window`] has already applied any
159/// architecture rule that switches SWA off wholesale. Passing the raw
160/// presence of `{arch}.attention.sliding_window` instead would rope
161/// EXAONE-4 1.2B as if it were the 32B.
162///
163/// `n_layers` is here for exactly one row: `smallthinker.cpp:108`
164/// rotates everything when its step equals the layer count.
165pub fn rope_layers(arch: &str, n_layers: usize, has_sliding_window: bool) -> RopeLayers {
166 let no_rope_every = |phase| RopeLayers::NoRopeEvery {
167 step: step(LLAMA_CPP_DEFAULT_NO_ROPE_STEP),
168 phase,
169 };
170 match arch {
171 // `exaone4.cpp:4-9` switches the whole SWA machinery on inside
172 // `if (hparams.n_layer() == 64)` and :116 then gates RoPE on it,
173 // so EXAONE-4 32B gives its full-attention layers no rotation
174 // and EXAONE-4 1.2B rotates everything. `exaone-moe.cpp:4` turns
175 // SWA on unconditionally and :13 reads the window as a REQUIRED
176 // key, so its `has_sliding_window` is always true and the second
177 // arm here is unreachable for it -- which is the whole reason
178 // the two rows are one rule.
179 "exaone4" | "exaone-moe" => {
180 if has_sliding_window {
181 RopeLayers::SlidingOnly
182 } else {
183 RopeLayers::All
184 }
185 }
186 // `smollm3.cpp:5` assigns the step unconditionally, so this is
187 // every SmolLM3 file: 9 of a 36-layer SmolLM3-3B's layers get no
188 // rotation.
189 "smollm3" => no_rope_every(NoRopePhase::LastOfPeriod),
190 // `smallthinker.cpp:16-18`: the step is set to `n_layer` (i.e.
191 // "always rope") ONLY on the no-window branch. With a window the
192 // field keeps the `llama-hparams.h:203` default of 4 and
193 // :108-109 skips `il % 4 == 0`. LIVE, and it was WRONG: ferrox
194 // has `smallthinker` on the audited generic path and rotated
195 // every layer of it.
196 "smallthinker" if has_sliding_window && n_layers != LLAMA_CPP_DEFAULT_NO_ROPE_STEP => {
197 no_rope_every(NoRopePhase::FirstOfPeriod)
198 }
199 // `afmoe.cpp:137-138` reads the step and never assigns it, so it
200 // is the default 4. LATENT: `afmoe` refuses today for its
201 // gated-attention topology, and this row is here so it stays
202 // right if that changes.
203 "afmoe" => no_rope_every(NoRopePhase::LastOfPeriod),
204 // `llama4.cpp:11` sets the step to `n_layer` ("always use rope",
205 // its own comment) only when the file declares a window of ZERO;
206 // every other Llama-4 keeps the default 4 and :145-146 skips
207 // `(il + 1) % 4 == 0`. LATENT on the generic path (`llama4` has
208 // a dedicated engine), and `llama4`'s chunked attention is not
209 // ferrox's `sliding_window` either.
210 "llama4" if has_sliding_window => no_rope_every(NoRopePhase::LastOfPeriod),
211 _ => RopeLayers::All,
212 }
213}
214
215/// Every architecture llama.cpp gates RoPE on per layer, with the line
216/// that decides it.
217///
218/// The table above is the implementation and this is the CENSUS, and
219/// they are checked against each other rather than maintained side by
220/// side: `every_gated_architecture_is_in_the_table` walks this list and
221/// requires [`rope_layers`] to answer something other than
222/// [`RopeLayers::All`] for each, under the conditions named. A name
223/// added here with no arm, or an arm added with no name, fails.
224pub const PER_LAYER_ROPE_GATES: &[(&str, &str)] = &[
225 ("exaone4", "src/models/exaone4.cpp:116"),
226 ("exaone-moe", "src/models/exaone-moe.cpp:136,155"),
227 ("smollm3", "src/models/smollm3.cpp:5,69"),
228 ("smallthinker", "src/models/smallthinker.cpp:18,108-109"),
229 ("afmoe", "src/models/afmoe.cpp:137-138"),
230 ("llama4", "src/models/llama4.cpp:11,145-146"),
231];
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 /// The claim the whole module rests on: `exaone-moe`'s
238 /// `is_swa(il)` and `exaone4`'s `is_swa(il) || swa_type == NONE` are
239 /// ONE rule, because `exaone-moe.cpp:4` pins `swa_type` to
240 /// `STANDARD`. If they ever needed different answers for the same
241 /// `(layer, slides)` pair they would need different implementations.
242 #[test]
243 fn exaone4_and_exaone_moe_are_one_rule() {
244 for slides in [true, false] {
245 for il in 0..8 {
246 assert_eq!(
247 rope_layers("exaone4", 64, true).rotates(il, slides),
248 rope_layers("exaone-moe", 48, true).rotates(il, slides),
249 "layer {il}, slides={slides}"
250 );
251 }
252 }
253 // And the disjunct that separates them: with no window at all
254 // EXAONE-4 rotates everything, which is the 1.2B.
255 assert_eq!(rope_layers("exaone4", 30, false), RopeLayers::All);
256 }
257
258 /// EXAONE-4 32B, layer by layer: `set_swa_pattern(4)` last-dense
259 /// makes layers 0,1,2 slide and layer 3 dense, and only the sliding
260 /// ones rotate.
261 #[test]
262 fn exaone4_32b_rotates_three_layers_in_four() {
263 let rule = rope_layers("exaone4", 64, true);
264 let slides = |il: usize| il % 4 < 3;
265 for il in 0..64 {
266 assert_eq!(
267 rule.rotates(il, slides(il)),
268 il % 4 != 3,
269 "layer {il} of EXAONE-4 32B"
270 );
271 }
272 assert!(rule.any_layer_unrotated(64, slides));
273 }
274
275 /// `smollm3` skips the LAST layer of each period and `smallthinker`
276 /// the FIRST. One phase for both would rope 18 of a 36-layer
277 /// SmolLM3-3B's layers at the wrong positions.
278 #[test]
279 fn the_two_no_rope_phases_disagree_about_every_layer_they_name() {
280 let smollm3 = rope_layers("smollm3", 36, false);
281 let smallthinker = rope_layers("smallthinker", 32, true);
282 for il in 0..36 {
283 assert_eq!(smollm3.rotates(il, false), (il + 1) % 4 != 0);
284 }
285 for il in 0..32 {
286 assert_eq!(smallthinker.rotates(il, true), il % 4 != 0);
287 }
288 // Not vacuous: the two rules must actually name different
289 // layers, or the phase distinction proves nothing.
290 assert_ne!(smollm3, smallthinker);
291 assert!(smollm3.rotates(0, false) && !smallthinker.rotates(0, true));
292 assert!(!smollm3.rotates(3, false) && smallthinker.rotates(3, true));
293 }
294
295 /// `smallthinker` WITHOUT a window sets the step to `n_layer`
296 /// (`smallthinker.cpp:18`), which is llama.cpp's spelling of "always
297 /// rope". A SmallThinker with no window must not lose a layer.
298 #[test]
299 fn smallthinker_without_a_window_rotates_everything() {
300 assert_eq!(rope_layers("smallthinker", 32, false), RopeLayers::All);
301 }
302
303 /// The census and the table are checked against each other, so a
304 /// name in one and not the other cannot ship.
305 #[test]
306 fn every_gated_architecture_is_in_the_table() {
307 for (arch, line) in PER_LAYER_ROPE_GATES {
308 // 32 layers and a window is the shape that makes every one
309 // of the six gates fire; the two conditional rows
310 // (`smallthinker`, `llama4`) need the window and the other
311 // four ignore it.
312 let rule = rope_layers(arch, 32, true);
313 assert_ne!(
314 rule,
315 RopeLayers::All,
316 "{arch} is listed as gated at {line} but the table rotates every layer"
317 );
318 assert!(
319 rule.any_layer_unrotated(32, |il| il % 4 < 3),
320 "{arch}'s rule must actually leave a layer unrotated"
321 );
322 }
323 }
324
325 /// The other direction: an architecture llama.cpp does NOT gate must
326 /// not pick up a gate here. `llama` is the whole generic path.
327 #[test]
328 fn an_ungated_architecture_rotates_every_layer() {
329 for arch in ["llama", "qwen3", "gemma3", "olmo2", "exaone", "granite"] {
330 assert_eq!(
331 rope_layers(arch, 32, true),
332 RopeLayers::All,
333 "{arch} has no `use_rope` in src/models/"
334 );
335 }
336 }
337}