ferrox_models/capability.rs
1//! Explicit architecture capability registry for the generic GGUF path.
2//!
3//! Mirrors the pinned llama.cpp `llm_arch` / `LLM_ARCH_NAMES` inventory
4//! (`.scratch/llama.cpp/src/llama-arch.{h,cpp}`) with Ferrox-side
5//! classification into decoder families, memory kinds, and scope.
6//! Unknown strings and detected-but-unimplemented features fail closed
7//! (`LoadError`) instead of silently defaulting into fluent-but-wrong
8//! logits.
9//!
10//! Architecture names are registry keys only. Hot-path kernels never
11//! branch on them; load-time resolution produces an [`ArchProfile`]
12//! whose fields the decoder reads as plain data.
13
14use crate::config::RopeLayout;
15
16/// How far this architecture is in Ferrox's delivery scope (plan:
17/// text-generation parity; encoder/multimodal/diffusion/audio deferred).
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ArchScope {
20 /// Autoregressive / encoder-decoder text generation -- in scope.
21 TextGeneration,
22 /// Encoder / embedding / pooling models -- deferred.
23 DeferredEncoderEmbedding,
24 /// Vision / multimodal projector paths -- deferred.
25 DeferredMultimodal,
26 /// Diffusion / masked-LM samplers -- deferred.
27 DeferredDiffusion,
28 /// Audio tokenizers / codecs -- deferred.
29 DeferredAudio,
30 /// Enum present in llama.cpp but not a real serve target here.
31 EnumOnly,
32}
33
34/// Shared execution family (maps many GGUF strings onto one engine path).
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum DecoderFamily {
37 /// Standard GQA (+ optional MoE) with whole-vector optional QK-norm.
38 StandardGqa,
39 /// Qwen3-style: explicit head_dim + per-head Q/K RMSNorm before RoPE.
40 Qwen3Family,
41 /// Gemma-family: embedding scale, post-norms, softcap, SWA pattern, GeGLU.
42 GemmaFamily,
43 /// Phi-family: fused QKV and/or fused gate+up SwiGLU.
44 PhiFamily,
45 /// DeepSeek-2 / Mistral4 MLA (not generic GQA).
46 Mla,
47 /// Attn + SSM / delta-net hybrids.
48 Hybrid,
49 /// Pure recurrent (Mamba / RWKV) -- no KV cache.
50 Recurrent,
51 /// T5-style encoder-decoder.
52 EncoderDecoder,
53 /// Dedicated Ferrox stacks (GLM DSA, DeepSeek V4, Kimi).
54 Dedicated,
55 /// In-repo synthetic fixtures.
56 TestFixture,
57}
58
59/// Memory / KV backend selected once at load (llama.cpp `create_memory`).
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum MemoryKind {
62 KvGqa,
63 KvIswa,
64 KvMla,
65 KvDsa,
66 KvDsv4,
67 Recurrent,
68 Hybrid,
69 None,
70}
71
72/// How `attn_q_norm` / `attn_k_norm` weights are applied (when present).
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74pub enum QkNormStyle {
75 /// OLMoE: one RMSNorm over the full Q/K projection width.
76 #[default]
77 WholeVector,
78 /// Qwen3 / Gemma3: RMSNorm per head with weight length `head_dim`.
79 PerHead,
80}
81
82/// How much work admitting one UNAUDITED architecture to the generic
83/// path would actually be.
84///
85/// Every architecture on the generic path that is not in
86/// [`AUDITED_GENERIC_GQA`] refuses with
87/// `LoadError::UnauditedArchitecture`, and that message used to say the
88/// same thing for all 47 of them. It hid a real difference:
89/// `bailingmoe2` needs a test fixture and nothing else, `deepseek` needs
90/// one name added to one list, and `olmo2` needs a decoder that can skip
91/// the two pre-norms it does not have. A user reading "nobody has
92/// checked this" cannot tell a one-line fix from a new attention
93/// implementation.
94///
95/// **A verdict here is a reading of BOTH trees, never a guess.** Every
96/// non-[`TriageClass::Unknown`] verdict names the `src/models/*.cpp`
97/// line that decides it and the ferrox file that would change.
98/// `Unknown` is a legitimate answer and says what would settle it. The
99/// precedent this rule exists for: four architectures in this very file
100/// once refused while naming a blocker that was not the real one --
101/// `glm4moe` was told it lacked an MLA hyper-parameter it must not have,
102/// and `minimax-m2` was blamed on MTP weights no converter can emit.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum TriageClass {
105 /// Ferrox already implements everything this architecture needs.
106 /// What is missing is EVIDENCE: a fixture, or a parity run against
107 /// llama.cpp on a real checkpoint.
108 FixtureAway,
109 /// One small, nameable piece is missing: an activation, a norm slot,
110 /// a routing flag, an ordering. Nameable is the bar -- if the blocker
111 /// cannot be written as a sentence naming the thing, it is not this
112 /// class.
113 OneMatchArm,
114 /// A different attention or residual structure: a norm the decoder
115 /// unconditionally applies and this model does not have, a scaled
116 /// residual, ALiBi, MLA, block-sparse, recurrent, hybrid.
117 NewCode,
118 /// Not decidable from reading the two trees. The blocker says what
119 /// would settle it.
120 Unknown,
121}
122
123impl TriageClass {
124 /// Short slug used in the refusal message.
125 pub fn label(self) -> &'static str {
126 match self {
127 TriageClass::FixtureAway => "FIXTURE-AWAY",
128 TriageClass::OneMatchArm => "ONE MATCH ARM",
129 TriageClass::NewCode => "NEW CODE",
130 TriageClass::Unknown => "UNKNOWN",
131 }
132 }
133
134 /// One sentence saying what the class means, so the message stands
135 /// alone without this doc comment.
136 pub fn headline(self) -> &'static str {
137 match self {
138 TriageClass::FixtureAway => {
139 "ferrox already implements everything this architecture needs; what is \
140 missing is EVIDENCE, not capability"
141 }
142 TriageClass::OneMatchArm => {
143 "one small, named piece is missing -- an activation, a norm slot, a \
144 routing flag or an ordering"
145 }
146 TriageClass::NewCode => {
147 "a different attention or residual structure than the generic decoder \
148 computes; this is not a fixture away"
149 }
150 TriageClass::Unknown => {
151 "reading both trees did not settle this one; the note below says what \
152 would"
153 }
154 }
155 }
156}
157
158/// One architecture's triage verdict, carried on its own catalog row.
159///
160/// Deliberately NOT a second table keyed by architecture name. This repo
161/// has fixed three separate bugs caused by two structures disagreeing
162/// about the same architecture, so the verdict lives on the
163/// [`ArchProfile`] the loader already resolves, and
164/// `every_unaudited_generic_architecture_is_triaged_or_listed_as_pending`
165/// pins that no generic row can exist without one or the other.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub struct UnauditedTriage {
168 pub class: TriageClass,
169 /// What is missing, with the llama.cpp `src/models/*.cpp` line that
170 /// decides it and the ferrox file that would change.
171 pub blocker: &'static str,
172}
173
174/// Unaudited generic-path architectures nobody has read against
175/// llama.cpp's graph yet.
176///
177/// This is a TO-DO, not cover. A name here means the refusal honestly
178/// says "not triaged" rather than inventing a class; a name leaves this
179/// list only by gaining an [`UnauditedTriage`] on its catalog row, and
180/// the two tests below make it impossible for a name to be on both or on
181/// neither.
182pub const TRIAGE_PENDING: &[&str] = &[
183 // Norm-RoPE group.
184 // NEOX-RoPE group.
185];
186
187/// This architecture's triage verdict, or `None` when it has not been
188/// triaged (see [`TRIAGE_PENDING`]) or does not need one.
189pub fn unaudited_triage(arch: &str) -> Option<UnauditedTriage> {
190 resolve_profile(arch).and_then(|p| p.triage)
191}
192
193/// The triage half of the `UnauditedArchitecture` refusal, rendered for
194/// the user.
195///
196/// Appended to the generic "nobody has verified this" sentence so the
197/// message says which of the three classes the architecture is in and
198/// what specifically is missing, rather than the same paragraph for all
199/// 47.
200pub fn unaudited_refusal_detail(arch: &str) -> String {
201 match unaudited_triage(arch) {
202 Some(t) => format!(
203 "TRIAGE ({}): {}. {}.",
204 t.class.label(),
205 t.class.headline(),
206 t.blocker
207 ),
208 None => format!(
209 "TRIAGE: not done for `{arch}` yet -- nobody has read llama.cpp's \
210 src/models/*.cpp for it against the generic decoder, so this refusal names \
211 no blocker and you should not read it as one. Triaging the remaining \
212 architectures is docs/plans/llama-cpp-gap-inventory.md section 8, item 6."
213 ),
214 }
215}
216
217/// Architectures on the shared generic-GQA path that somebody has
218/// actually PROVEN, and the evidence for each.
219///
220/// The generic path is a guess: it assumes an architecture is plain GQA
221/// because nothing said otherwise. That guess has already been wrong
222/// five times. `gpt2`, `mpt`, `refact`, `bloom` and `jais` all sat here
223/// computing ALiBi or learned absolute position embeddings as though
224/// they were NEOX RoPE, and every downstream guard missed them: two
225/// hardcode their ALiBi slope with no GGUF key, one leaves no unread
226/// tensor, and the RoPE pin excluded their group by construction.
227///
228/// So membership here is not "we think this works", it is "there is a
229/// benchmark row, a pinned logit comparison against llama.cpp, or a
230/// fixture". Everything else on the generic path is UNAUDITED and says
231/// so at load time rather than running and hoping.
232///
233/// Adding a name here without evidence defeats the entire point.
234pub const AUDITED_GENERIC_GQA: &[&str] = &[
235 // Bench rows in benchmarks/suite.json, measured against llama.cpp
236 // on the same host and file.
237 "llama", // TinyLlama, Mistral, Mixtral, SmolLM2, Llama-3.x all tag llama
238 "qwen2", // Qwen2.5-0.5B
239 "qwen2moe", // Qwen1.5-MoE-A2.7B
240 "qwen3", // Qwen3-0.6B
241 "olmoe", // OLMoE-1B-7B
242 "gemma2", // Gemma-2-2B
243 "gemma3", // Gemma-3-1B
244 "phi3", // Phi-4-mini tags phi3
245 // Pinned against real libllama logits in tests/.
246 "gpt-oss",
247 "dots1",
248 // tests/qwen3moe_graph.rs: a synthetic 2-layer fixture
249 // (scripts/make_qwen3moe_fixture.py) compared against llama.cpp's
250 // own qwen3moe graph via libllama, on all three forward paths.
251 // Carries per-head QK norm before RoPE, head_dim * n_head != n_embd,
252 // GQA, NEOX RoPE, softmax gating with renormalised top-k, and
253 // n_ff != n_ff_exp.
254 "qwen3moe",
255 // tests/one_match_arm_graphs.rs: five architectures that were
256 // triaged ONE MATCH ARM, each admitted with the same evidence
257 // qwen3moe has -- a synthetic fixture whose golden logits come from
258 // llama.cpp's own graph via libllama, checked on all three forward
259 // paths. The arm each one needed is named beside it; every fixture
260 // is built so that getting that arm wrong moves the logits by orders
261 // of magnitude more than the comparison tolerance.
262 //
263 // `deepseek` (V1, not the MLA deepseek2): top-k weights are NOT
264 // renormalised (deepseek.cpp:145-155 passes norm_w=false and no
265 // converter writes expert_weights_norm), so the fixture carries no
266 // such key and the answer has to come from
267 // NO_TOPK_RENORMALIZE_ARCHITECTURES.
268 "deepseek",
269 // `bailingmoe`: llama.cpp reads leading_dense_block_count and never
270 // branches on it (bailingmoe.cpp:5 vs :39-54). The fixture sets the
271 // key to 1 and ships NO dense FFN on layer 0.
272 "bailingmoe",
273 // `seed_oss`: the pre-FFN norm is stored as post_attention_norm and
274 // there is no ffn_norm (seed-oss.cpp:36-37,113-115) -- gpt-oss's
275 // slot, now a named list rather than an `arch == "gpt-oss"` flag.
276 "seed_oss",
277 // `maincoder` and `hunyuan-moe`: per-head QK norm applied AFTER RoPE
278 // (maincoder.cpp:78-95, hunyuan-moe.cpp:93-118). Both fixtures use
279 // QK-norm weights centred near 1.5 so the ordering is visible.
280 "maincoder",
281 "hunyuan-moe",
282 // `hunyuan-dense`: the same post-RoPE QK-norm order (it has no graph
283 // of its own -- models.h:1830-1834 derives it from
284 // llama_model_hunyuan_vl) PLUS the NTK-alpha RoPE base rescale at
285 // hunyuan-vl.cpp:8-12, which is now `rope_ntk_alpha`. Its fixture
286 // carries `hunyuan-dense.rope.scaling.alpha` explicitly, because the
287 // HUNYUAN_DENSE converter does that arithmetic in Python and writes
288 // the already-scaled base (conversion/hunyuan.py:254-281) -- the
289 // `add_rope_scaling_alpha` at :356 is HunyuanVLTextModel, i.e. the
290 // separate `hunyuan-vl` row. The triage verdict cited that line for
291 // this architecture and was wrong about it.
292 "hunyuan-dense",
293 // `ernie4_5-moe`: the MoE sibling of the audited `ernie4_5`. Its
294 // interleave step is a REFUSAL rather than an implementation, and
295 // that is the finding, not a shortcut: llama.cpp's tensor loader
296 // (ernie4-5.cpp:49) creates expert tensors for every layer past the
297 // leading-dense prefix with NO step in the condition, while its
298 // graph (ernie4-5-moe.cpp:64) takes the dense branch when
299 // `(il + 1) % step != 0`, so a checkpoint whose interleave really
300 // interleaves cannot be loaded by llama.cpp at all -- measured, on a
301 // two-step fixture, as `check_tensor_dims: tensor
302 // 'blk.2.ffn_gate_inp.weight' not found`. Both published ERNIE-4.5
303 // MoE checkpoints carry a step of 1, which is what the golden
304 // fixture pins; `moe_interleave` refuses anything else by name.
305 "ernie4_5-moe",
306 // tests/fixture_away_graphs.rs: architectures that were triaged
307 // FIXTURE-AWAY -- ferrox already built their graph, and only the
308 // evidence was missing. Same standard as the rows above: a synthetic
309 // fixture from `scripts/make_<arch>_fixture.py` whose golden values
310 // come from llama.cpp's own graph via libllama, compared on prefill,
311 // decode and continuous batching, with a sabotage test per row
312 // proving the fixture can SEE the fact its architecture turns on.
313 //
314 // Each was checked, against the C, on the six things this repo has
315 // lost at least once: RoPE variant, SWA pattern and phase,
316 // `attention_scale`, the two post-norm slots, and QK-norm ordering.
317 //
318 // `internlm2` (internlm2.cpp:3-11,25-33,59-122): plain llama, NORM
319 // RoPE, `1/sqrt(head_dim)` scale, no post-norms, no QK-norm, no SWA.
320 // Its fixture carries the OPTIONAL q/k/v projection biases real
321 // InternLM2 exports ship.
322 "internlm2",
323 // `xverse` (xverse.cpp:3-12,14-35,59-121): the same, with no biases.
324 "xverse",
325 // `gemma` (gemma.cpp:3-11,13-34,41-138): Gemma-1, the oldest row of
326 // the family and the last one that was not evidenced. Its three
327 // Gemma-specific pieces were already implemented for `GemmaFamily`
328 // and the fixture is what proves each of them: the sqrt(n_embd)
329 // embedding scale (:49), GeGLU rather than SwiGLU (:112,
330 // LLM_FFN_GELU) and a `1/sqrt(head_dim)` attention scale that
331 // llama.cpp reaches by scaling Q at :86 and passing kq_scale = 1.0f
332 // at :91, which is what leaving `attention_scale` as None already
333 // produces. Its lm_head is TIED with no fallback (:20), so the
334 // fixture ships no `output.weight` and the embedding scale is not
335 // cancelled downstream. Gemma-1 declares no softcap and no sliding
336 // window, so the Gemma-2/3 machinery must resolve to inert, and
337 // `tests/fixture_away_graphs.rs` asserts that rather than assuming
338 // it.
339 "gemma",
340 // `ernie4_5` DENSE (ernie4-5.cpp:36-69,95-149): NORM RoPE, head_dim
341 // decoupled from n_embd/n_head. `ernie4_5-moe` is a different row
342 // with its own fixture, above.
343 "ernie4_5",
344 // `baichuan` (baichuan.cpp:5-14,17-40,64-137): the 7B ONLY. The 13B
345 // is a different model under the same string and is refused by name
346 // on `block_count == 40` in loader.rs before this list is consulted,
347 // because llama.cpp picks ALiBi-and-no-RoPE off the layer count with
348 // no GGUF key to declare it. The fixture therefore has 32 layers: a
349 // 2-layer one would be LLM_TYPE_UNKNOWN and get no RoPE at all.
350 "baichuan",
351 // `exaone` (exaone.cpp:3-10,12-40,65-121): EXAONE 3.x, NEOX RoPE,
352 // tied lm_head. NOT `exaone4` (no pre-norms) and NOT `exaone-moe`
353 // (no RoPE on the full-attention layers); both stay refusing.
354 "exaone",
355 // `plamo3` (plamo3.cpp:3-60,91-193): the sandwich-norm row, and the
356 // only one here with a sliding window. Its verdict was FIXTURE-AWAY
357 // and was WRONG by one tensor name: plamo3 is the sole architecture
358 // upstream that creates ATTN_POST_NORM / FFN_POST_NORM through the
359 // two-argument `tn` overload (:52,55), so it asks for
360 // `blk.N.post_attention_norm` and `blk.N.post_ffw_norm` with NO
361 // `.weight`, and gguf-py emits exactly those names for it. ferrox
362 // read only the suffixed spelling; `load_norm_vec_either_spelling`
363 // in loader.rs now reads both, and says why.
364 //
365 // Its SWA is a real pattern with a real phase -- period from
366 // `attention.sliding_window_pattern`, `dense_first = false` from
367 // `set_swa_pattern`'s default -- and the fixture sets a window
368 // narrower than the prompt so the mask actually bites.
369 "plamo3",
370 // `bailingmoe2` (bailingmoe2.cpp:23-87,111-198): Ling-2.0. The one
371 // MoE row in this batch, so the two MoE facts do arise and both are
372 // asserted: SIGMOID gating, read from the file's REQUIRED
373 // `expert_gating_func` (:11) against ferrox's softmax default, and
374 // `expert_weights_norm` (:10), also read from the file. Its shared
375 // expert is `n_ff_shexp * n_expert_shared` wide (:58), not
376 // `n_ff_shexp`. Per-head QK norm BEFORE RoPE (:123-135), fused
377 // attn_qkv, leading dense layers that llama.cpp really does branch
378 // on (:57) -- unlike `bailingmoe`, which reads the same key and
379 // ignores it.
380 "bailingmoe2",
381];
382
383/// Is this architecture's use of the shared generic path backed by
384/// evidence?
385pub fn is_audited_generic(arch: &str) -> bool {
386 AUDITED_GENERIC_GQA.contains(&arch)
387}
388
389/// How the generic `Decoder` / `ModelConfig::from_gguf` path treats a
390/// GGUF architecture string.
391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
392pub enum ArchPath {
393 /// Standard GQA (+ optional MoE) decoder; RoPE layout is known.
394 GenericGqa { rope: RopeLayout },
395 /// In-repo test fixtures (`ferroxtest*`) -- not a real model family.
396 TestFixture { rope: RopeLayout },
397 /// Real architecture, but must not be loaded through the generic
398 /// GQA decoder (wrong attention / residual math).
399 DedicatedOnly { reason: &'static str },
400 /// In the llama.cpp inventory but out of Ferrox scope for now.
401 Deferred { reason: &'static str },
402}
403
404/// Load-time resolved profile for one GGUF `general.architecture` string.
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406pub struct ArchProfile {
407 pub gguf_name: &'static str,
408 pub scope: ArchScope,
409 pub family: DecoderFamily,
410 pub memory: MemoryKind,
411 pub rope: RopeLayout,
412 pub path: ArchPath,
413 /// Default QK-norm style when norm tensors are present; loader may
414 /// refine from tensor length.
415 pub qk_norm: QkNormStyle,
416 /// For an UNAUDITED [`ArchPath::GenericGqa`] row: how far it is from
417 /// running, read against llama.cpp's own graph. `None` on audited
418 /// rows (which run) and on rows still in [`TRIAGE_PENDING`].
419 pub triage: Option<UnauditedTriage>,
420}
421
422impl ArchProfile {
423 /// Attach a triage verdict to a catalog row. Private on purpose:
424 /// verdicts are data of the catalog, not something a caller supplies.
425 fn triaged(mut self, class: TriageClass, blocker: &'static str) -> Self {
426 self.triage = Some(UnauditedTriage { class, blocker });
427 self
428 }
429}
430
431fn prof(
432 name: &'static str,
433 scope: ArchScope,
434 fam: DecoderFamily,
435 mem: MemoryKind,
436 rope: RopeLayout,
437 path: ArchPath,
438 qk: QkNormStyle,
439) -> ArchProfile {
440 ArchProfile {
441 gguf_name: name,
442 scope,
443 family: fam,
444 memory: mem,
445 rope,
446 path,
447 qk_norm: qk,
448 triage: None,
449 }
450}
451
452fn gqa_norm(name: &'static str) -> ArchProfile {
453 prof(
454 name,
455 ArchScope::TextGeneration,
456 DecoderFamily::StandardGqa,
457 MemoryKind::KvGqa,
458 RopeLayout::Norm,
459 ArchPath::GenericGqa {
460 rope: RopeLayout::Norm,
461 },
462 QkNormStyle::WholeVector,
463 )
464}
465
466fn gqa_neox(name: &'static str) -> ArchProfile {
467 prof(
468 name,
469 ArchScope::TextGeneration,
470 DecoderFamily::StandardGqa,
471 MemoryKind::KvGqa,
472 RopeLayout::Neox,
473 ArchPath::GenericGqa {
474 rope: RopeLayout::Neox,
475 },
476 QkNormStyle::WholeVector,
477 )
478}
479
480fn dedicated(name: &'static str, reason: &'static str) -> ArchProfile {
481 prof(
482 name,
483 ArchScope::TextGeneration,
484 DecoderFamily::Dedicated,
485 MemoryKind::KvGqa,
486 RopeLayout::Norm,
487 ArchPath::DedicatedOnly { reason },
488 QkNormStyle::WholeVector,
489 )
490}
491
492fn deferred_scope(name: &'static str, scope: ArchScope, reason: &'static str) -> ArchProfile {
493 prof(
494 name,
495 scope,
496 DecoderFamily::StandardGqa,
497 MemoryKind::None,
498 RopeLayout::Neox,
499 ArchPath::Deferred { reason },
500 QkNormStyle::WholeVector,
501 )
502}
503
504/// Triaged rows of the generic **Norm**-RoPE group, with the llama.cpp
505/// line that decides each verdict. Consumed by
506/// [`architecture_catalog`]; a name here must not also appear in the
507/// untriaged list above it or in [`TRIAGE_PENDING`], which
508/// `catalog_has_unique_names` and
509/// `every_unaudited_generic_architecture_is_triaged_or_listed_as_pending`
510/// between them enforce.
511const NORM_ROPE_TRIAGED: &[(&str, TriageClass, &str)] = &[
512 // `ernie4_5-moe` was HERE, ONE MATCH ARM on
513 // `{arch}.interleave_moe_layer_step`. Building its fixture found the
514 // arm is not implementable against a reference: llama.cpp's tensor
515 // loader (ernie4-5.cpp:49) and its graph (ernie4-5-moe.cpp:64)
516 // disagree about which layers are MoE, and only the graph has the
517 // step, so a checkpoint whose interleave interleaves cannot be
518 // loaded by llama.cpp at all. The arm landed as a REFUSAL
519 // (`crate::moe_interleave`) and the step every real checkpoint
520 // carries is audited (`tests/one_match_arm_graphs.rs`), so the row
521 // is in AUDITED_GENERIC_GQA and carries no verdict.
522 ("granite", TriageClass::NewCode, GRANITE_MULTIPLIERS),
523 ("granitemoe", TriageClass::NewCode, GRANITE_MULTIPLIERS),
524 // ferrox-only alias row; no llama.cpp GGUF spells it this way, but
525 // it must not carry a different verdict from `granitemoe`.
526 ("granite-moe", TriageClass::NewCode, GRANITE_MULTIPLIERS),
527 (
528 "chatglm",
529 TriageClass::OneMatchArm,
530 "the FUSED `attn_qkv.bias`, which is the same arm `qwen` is refused by name for. \
531 This row said FIXTURE-AWAY until an attempt to build the fixture read the \
532 converter: `src/models/chatglm.cpp:42` calls create_tensor_qkv, which prefers a fused \
533 `wqkv` and then creates `wqkv_b` beside it (llama-model.cpp:2890-2892), and \
534 `build_qkv` adds that bias to the fused projection before splitting \
535 (llama-graph.cpp:1607-1610). Every real chatglm checkpoint carries it: ChatGLM2/3 \
536 set `add_qkv_bias: true`, and gguf-py maps \
537 `encoder.layers.{bid}.self_attention.query_key_value` \
538 (tensor_mapping.py:246) to `blk.N.attn_qkv`, so the file holds \
539 `blk.N.attn_qkv.weight` AND `blk.N.attn_qkv.bias`. \
540 `load_qkv_projections` (loader.rs) splits the fused WEIGHT but reads bias only \
541 under the split `attn_q.bias` / `attn_k.bias` / `attn_v.bias` names, so the bias \
542 is dropped and all three projections run unbiased -- the identical sentence this \
543 file already writes for `qwen` and `starcoder`, on an architecture that was not \
544 on that list. Splitting `attn_qkv.bias` by the same row ranges the weight split \
545 already computes closes chatglm and qwen together, and it is one function. \
546 Everything else really is generic and really was read: the FUSED gate+up SwiGLU \
547 (:48, :128-133, `LLM_FFN_SWIGLU, LLM_FFN_SEQ`) is the audited phi3 call shape \
548 (phi3.cpp:52, :144-149) and `load_dense_expert` already takes it; the graph \
549 (:75-145) is a sequential residual with `1/sqrt(n_embd_head)` (:108), NORM RoPE \
550 (llama-model.cpp:2593), no QK-norm, no post-norms and no window; and chatglm is \
551 PARTIAL-rope -- :59-61 asserts only that the K and V head dims agree, NOT that \
552 n_embd_head == n_rot, and `conversion/chatglm.py:151` writes \
553 rope_dimension_count as `head_dim * partial_rotary_factor` (0.5) -- which \
554 `ModelConfig::rope_dim` already implements",
555 ),
556 (
557 "deci",
558 TriageClass::NewCode,
559 "DeciLM / Llama-3.1-Nemotron layers are not all the same shape. \
560 src/models/deci.cpp:30-34 reads n_head(i), n_head_kv(i) and n_ff(i) PER LAYER, and \
561 the graph branches on them three ways: `n_head == 0` is an attention-free layer \
562 that passes the residual straight through (:107-109), `n_head_kv == 0` is a \
563 \"linear attention\" layer that applies only `wo` with no Q/K/V and no RoPE \
564 (:115-118), and `n_ff == 0` skips the FFN and the residual add entirely with a \
565 `continue` (:147-149). ferrox's ModelConfig carries n_heads, n_kv_heads and \
566 expert_ffn_dim as SCALARS and its decoder runs the same block on every layer, so \
567 there is nowhere to put any of the three. Same class as `openelm`, one step worse",
568 ),
569 (
570 "olmo",
571 TriageClass::NewCode,
572 "OLMo-1 has NO norm weights at all. src/models/olmo.cpp:27-35 creates Q/K/V, \
573 attn_output and gate/up/down and not one norm tensor, and the graph calls \
574 `build_norm(x, NULL, NULL, LLM_NORM, il)` at all three sites (:65-67, :104-106, \
575 :128-130) -- non-parametric LayerNorm: subtract the mean, divide by the standard \
576 deviation, no learned weight and no bias. ferrox has only `rms_norm(x, w, eps)` \
577 and requires `blk.N.attn_norm.weight`, so it is both a different function and a \
578 missing tensor. It also reads an optional {arch}.attention.clamp_kqv (:5) that \
579 nothing here applies. Note this is OLMo-1; `olmo2` is a separate row and a \
580 separate blocker",
581 ),
582 (
583 "arctic",
584 TriageClass::NewCode,
585 "a PARALLEL dense+MoE layer whose MoE branch reads the pre-attention residual. \
586 src/models/arctic.cpp:124-132 runs a dense SiLU FFN on `ffn_norm(ffn_inp)` and adds \
587 it back to ffn_inp, then :136-141 norms `inpSA` -- the layer INPUT, before \
588 attention -- through a second per-layer norm `ffn_norm_exps` (:45) and runs the MoE \
589 on that, and :154 sums the two. The generic decoder computes one FFN on the \
590 post-attention residual, so this is a different graph, not a wider one. The dense \
591 half is also sized `{n_embd, n_embd}` (:40-42) rather than n_ff. Same shape as \
592 `smallthinker`'s router: a branch fed from the raw layer input",
593 ),
594 (
595 "mistral3",
596 TriageClass::NewCode,
597 "per-position attention temperature tuning. src/models/mistral3.cpp:5,14-17 reads \
598 {arch}.attention.temperature_scale and seeds n_attn_temp_floor_scale from \
599 n_ctx_orig_yarn, and :109-111 builds a per-position Q scale that llama-graph.cpp \
600 computes as `log(floor(pos / floor_scale) + 1) * temp_scale + 1` (:159-167). ferrox \
601 has no per-position attention scale at all and no gate on that key, so a checkpoint \
602 carrying it would load and silently drop it -- the class of defect \
603 `unsupported_scaling_keys` exists for, on a key that list does not have. :9 also \
604 reads rope.scaling.yarn_log_multiplier, and loader.rs:588's own comment records \
605 that ferrox implements only YaRN's magnitude term. The rest (:46-83, :120-210) is \
606 leading-dense + MoE + shared expert on a sequential residual, which ferrox has",
607 ),
608 (
609 "nanbeige",
610 TriageClass::NewCode,
611 "nanbeige RUNS THE SAME PHYSICAL LAYERS MORE THAN ONCE. \
612 src/models/nanbeige.cpp:13-31 sets `n_layer_all = n_layer_phys * n_loops` and \
613 rewrites the per-layer head/ff/swa arrays so the graph walks n_layer_all steps over \
614 n_layer_phys sets of weights, and :167 applies `output_norm` to the running \
615 residual inside the loop at the end of each pass. ferrox's decoder walks its layer \
616 vector exactly once and has no concept of a loop count. Everything inside one pass \
617 (:52-63, :106-155) is plain llama, which is what makes this deceptive: the tensor \
618 set alone looks generic",
619 ),
620 ("arcee", TriageClass::NewCode, UNGATED_RELU_SQR),
621 ("plm", TriageClass::NewCode, UNGATED_RELU_SQR),
622];
623
624/// Shared by the three ferrox-only alias rows `mistral`, `mixtral` and
625/// `yi`, and the reason they are UNKNOWN rather than fixture-away.
626///
627/// The temptation is to call them "llama with a different name" and mark
628/// them fixture-away. That would be a guess about a file nobody has
629/// seen, and the RoPE hazard below is exactly why it would be an
630/// expensive one.
631const NO_UPSTREAM_ARCH: &str =
632 "there is no llama.cpp graph to diff against: none of `mistral`, `mixtral` or `yi` \
633 appears in LLM_ARCH_NAMES (src/llama-arch.cpp) or in gguf-py's MODEL_ARCH_NAMES, and \
634 every real Mistral, Mixtral and Yi checkpoint converts to `llama` (llama.cpp's own \
635 conversion scripts emit MODEL_ARCH.LLAMA for all three; only `mistral3` and `mistral4` \
636 exist as their own strings). So these are ferrox-only rows that no llama.cpp-produced \
637 file can carry. THE HAZARD, and why this is not marked fixture-away: the catalog gives \
638 all three NEOX RoPE, while `llama` -- the string these models really ship under, and \
639 the graph they really are -- is in `llama_model_rope_type`'s NORM group \
640 (llama-model.cpp, the `case LLM_ARCH_LLAMA:` arm). A file spelling `mistral` would \
641 therefore be rotated on the wrong pairs of every Q/K head, which is the exact defect \
642 that caused the Llama-3.1-8B wrong-logits bug. It is latent only because the row \
643 refuses. WHAT WOULD SETTLE IT: a real GGUF whose general.architecture is literally one \
644 of these three. Absent one, the honest options are to delete the rows or to move them \
645 to NORM to match the graph they claim to be";
646
647/// Shared by `arcee` and `plm`: an ungated ReLU-squared MLP.
648///
649/// Found by the activation audit the `deepseek` renormalisation bug
650/// prompted, not by reading these two files on purpose. Both were on the
651/// generic path with nothing recording that their FFN is neither SwiGLU
652/// nor GeGLU.
653const UNGATED_RELU_SQR: &str =
654 "an UNGATED ReLU-squared MLP, which is a different FFN shape and not only a different \
655 activation. src/models/arcee.cpp:39-40 and plm.cpp:39-40 create only `ffn_up` and \
656 `ffn_down` and no `ffn_gate` at all, and arcee.cpp:123-128 calls build_ffn with a NULL \
657 gate, `LLM_FFN_RELU_SQR` and `LLM_FFN_SEQ` -- i.e. `down(relu(up(x))^2)`, two matrices \
658 in sequence. ferrox's `ExpertWeights` has three required matrices and \
659 `FfnActivation` has only the gated Swiglu / SwigluFused / Gelu variants \
660 (config.rs:302-312), so there is no shape for this and no activation for it either. It \
661 fails closed rather than computing SwiGLU: `load_dense_expert` (loader.rs:1112-1136) \
662 finds no `ffn_gate`, falls to the Phi-3 fused path, and rejects an `ffn_up` that is \
663 `n_ff` rows rather than `2 * n_ff`";
664
665/// Shared by `granite`, `granitemoe` and the `granite-moe` alias: one
666/// blocker, one string, so the three rows cannot drift apart.
667const GRANITE_MULTIPLIERS: &str =
668 "Granite's four multipliers. src/models/granite.cpp:7 reads {arch}.logit_scale as \
669 REQUIRED (granite-moe.cpp:5 too) and :8-10 reads residual_scale / embedding_scale / \
670 attention.scale; the graph divides the final logits by f_logit_scale (:188) and scales \
671 BOTH branch outputs by f_residual_scale before every residual add (:241-242, :301-302). \
672 The generic decoder applies none of them, and residual_scale in particular touches \
673 every CPU and Metal residual path. In practice a real Granite checkpoint never reaches \
674 THIS message: capability::unsupported_scaling_keys already refuses it by name at \
675 loader.rs:191, which runs before the unaudited gate. Separately, granite.cpp:206 gates \
676 RoPE on `hparams.rope_finetuned`, so a Granite export with rope.finetuned=false gets NO \
677 rotation at all -- the ALiBi class of divergence, with no ferrox expression";
678
679/// Triaged rows of the generic **NEOX**-RoPE group. Same rules as
680/// [`NORM_ROPE_TRIAGED`].
681const NEOX_ROPE_TRIAGED: &[(&str, TriageClass, &str)] = &[
682 (
683 "olmo2",
684 TriageClass::NewCode,
685 "olmo2 has NO pre-attention norm and NO pre-FFN norm. load_arch_tensors creates \
686 attn_post_norm and ffn_post_norm (src/models/olmo2.cpp:47,52) and no attn_norm or \
687 ffn_norm at all; the graph projects Q/K/V straight off the residual (:92, \
688 `cur = inpL`) and runs build_ffn on the raw ffn_inp (:169). The generic decoder \
689 REQUIRES blk.N.attn_norm.weight and a pre-FFN norm and applies both on every layer, \
690 so this is a different residual topology, not a missing tensor. Its post-norms are \
691 NOT the blocker: ferrox applies post_attn_norm and post_ffn_norm in exactly \
692 llama.cpp's places already (:160-163,:178-180 vs decoder.rs:4274-4281,:4333-4341). \
693 olmo2 additionally runs its SWA layers' RoPE with YaRN disabled (freq_scale=1, \
694 ext_factor=0, attn_factor=1, :118-133), a second per-layer RoPE variant ferrox \
695 cannot express",
696 ),
697 (
698 "exaone4",
699 TriageClass::NewCode,
700 "same shape as olmo2: src/models/exaone4.cpp:60-67 creates attn_post_norm, per-head \
701 attn_q_norm/attn_k_norm and ffn_post_norm and NO attn_norm and NO ffn_norm, and the \
702 graph projects Q/K/V off the raw residual (:118) and runs build_ffn on the raw \
703 ffn_inp (:159). The generic decoder requires and applies both pre-norms, which is a \
704 different residual topology. Its optional NEXTN/MTP tensors (:69-73) are a separate \
705 matter and are refused by name by the unread-tensor gate",
706 ),
707 (
708 "mellum",
709 TriageClass::NewCode,
710 "two per-layer RoPE variants in one model. src/models/mellum.cpp:128-142 runs the \
711 SWA layers' RoPE with YaRN switched off -- freq_scale = 1.0, ext_factor = 0.0, \
712 attn_factor = 1.0 -- while the full-attention layers use the model's own YaRN \
713 (:143-154). ferrox carries one YaRN configuration for the whole model (it has \
714 `rope_theta_swa` for the BASE only) and cannot express a per-layer ext_factor. \
715 Second, smaller hazard on the same architecture: :12-17 accepts the sliding-window \
716 pattern as a scalar OR as a per-layer ARRAY, and ferrox reads it only as a scalar \
717 (`GgufValue::as_u64` returns None for an array), so an array-valued file falls back \
718 to `default_swa_layout`'s period of 4 with nothing saying it substituted its own \
719 layout for the file's. The tensor set and residual (:45-68, :169-197) are generic",
720 ),
721 (
722 "talkie",
723 TriageClass::NewCode,
724 "talkie has NO norm weights and a learned per-layer skip connection. In \
725 src/models/talkie.cpp every \
726 normalisation is `build_norm(x, nullptr, nullptr, LLM_NORM_RMS, ...)` -- \
727 non-parametric RMSNorm, no weight tensor -- at :50 (on the embeddings, before layer \
728 0), :68, :90, :110 and :137; the only norm weight in the file is `attn_q_norm`, and \
729 it is shaped {1, n_head} (:26), one SCALAR PER HEAD rather than a head_dim vector, \
730 which is neither of ferrox's two QkNormStyle variants. Each layer then adds \
731 `inp_skip * out_scale` (:123-126) with a per-layer learned scalar `out_scale` \
732 (:32), a second residual stream the generic decoder has no slot for, and :5 reads \
733 {arch}.logit_scale as REQUIRED",
734 ),
735 (
736 "mimo2",
737 TriageClass::NewCode,
738 "attention sinks on a non-gpt-oss architecture, plus per-layer shapes. \
739 src/models/mimo2.cpp:58 creates `attn_sinks` per layer; ferrox implements sinks \
740 only inside the gpt-oss path and, per docs/MODELS.md, on CPU only. :47-49 reads \
741 n_head and the KV widths PER LAYER, :16 and :181 scale the attention output by \
742 {arch}.attention.value_scale (a key ferrox neither reads nor gates), :6-12 makes \
743 SWA unconditional with a per-layer is_swa ARRAY rather than a period, and :19,:76-82 \
744 add NEXTN/MTP layers with a `layer_out_norm`. Any one of the first three would \
745 disqualify it; the dense-or-MoE-per-layer choice at :63-72 is the only part ferrox \
746 already has",
747 ),
748 (
749 "afmoe",
750 TriageClass::NewCode,
751 "gated attention plus NoPE layers. src/models/afmoe.cpp:73 creates `wqkv_gate` \
752 (LLM_TENSOR_ATTN_GATE), a learned gate applied to the attention output that the \
753 generic decoder has no slot for, and :137-138 skips RoPE where \
754 `(il + 1) % n_no_rope_layer_step == 0`, the smollm3 class with no GGUF key. It also \
755 scales the embeddings by sqrt(n_embd) at :120, which ferrox does only for the Gemma \
756 family. THIRD, and the quiet one: :8 reads expert_gating_func as OPTIONAL and \
757 :29-30 defaults it to SIGMOID when absent, while ferrox's fallback \
758 (loader.rs:375, SIGMOID_GATING_ARCHITECTURES) defaults to softmax for any \
759 architecture not on its list -- so a checkpoint omitting the key would be routed \
760 through the wrong scoring function. That last one is the `deepseek` shape and would \
761 need fixing even if the rest were free",
762 ),
763 (
764 "apertus",
765 TriageClass::NewCode,
766 "xIELU, with four PER-LAYER parameter arrays. src/models/apertus.cpp:6-9 reads \
767 xielu_alpha_n, xielu_alpha_p, xielu_beta and xielu_eps as n_layer-long arrays and \
768 :132-135 indexes them per layer; `FfnActivation` (config.rs:302-312) has three \
769 variants and no way to carry a per-layer parameter at all. The FFN is also UNGATED \
770 -- :45-46 creates only ffn_down and ffn_up, no ffn_gate -- so it is the same \
771 two-matrix shape as `arcee` and `plm` on top of the activation. It further requires \
772 optional attn_q_norm/attn_k_norm BIASES (:50,:52), and ferrox's norms take a weight \
773 only",
774 ),
775 (
776 "exaone-moe",
777 TriageClass::NewCode,
778 "the GLOBAL layers get no RoPE. src/models/exaone-moe.cpp:155-161 wraps both \
779 ggml_rope_ext calls in `if (is_local_layer)`, where is_local_layer is \
780 `hparams.is_swa(il)` (:136) -- so on the full-attention layers of every period Q \
781 and K are never rotated. ferrox rotates every layer, and there is no GGUF key that \
782 says otherwise: the SWA pattern implies it. Checked and CLEAN on the other axis: \
783 :5 seeds n_swa = 128 but :13 reads {arch}.attention.sliding_window as REQUIRED, so \
784 the window is always the file's own value and ferrox reads the same number, and \
785 `default_swa_layout` already carries exaone-moe as period 4. The MoE half (:72-93) \
786 -- leading dense, exp_probs_b, shared expert, gating from metadata -- ferrox has",
787 ),
788 (
789 "grovemoe",
790 TriageClass::NewCode,
791 "a SECOND bank of experts, not just a scale. src/models/grovemoe.cpp:57-59 creates \
792 `ffn_gate_chexps` / `ffn_down_chexps` / `ffn_up_chexps` -- `n_expert / \
793 n_group_experts` \"chunk\" experts with their own width n_ff_chexp -- and the graph \
794 runs build_moe_ffn TWICE (:137 over the ordinary experts, :153 over the chunk \
795 experts) before :167 adds `scale(moe_out, expert_group_scale)` to the residual. The \
796 inventory recorded only the post-sum group scale and called this small; the second \
797 expert bank with its own routing is the larger half and ferrox's MoE layer holds \
798 one bank. Both n_group_experts and expert_group_scale are REQUIRED keys (:6-7). \
799 QK-norm is before RoPE (:100-109), which is the one thing that would otherwise have \
800 been a blocker",
801 ),
802 // `hunyuan-dense` was HERE, ONE MATCH ARM on the NTK-alpha RoPE
803 // base rescale. The arm landed (`crate::rope_ntk_alpha`), the
804 // post-RoPE QK-norm half was already implemented, and both are
805 // evidenced against libllama in `tests/one_match_arm_graphs.rs`, so
806 // the row is audited and carries no verdict. Its verdict cited
807 // `conversion/hunyuan.py:356` as the line that writes
808 // `{arch}.rope.scaling.alpha` for this architecture; that line is in
809 // HunyuanVLTextModel, whose model_arch is HUNYUAN_VL. The
810 // HUNYUAN_DENSE converter (:254-281) does the same arithmetic in
811 // Python and writes the already-scaled base instead.
812 (
813 "laguna",
814 TriageClass::NewCode,
815 "per-layer head counts AND a second rotary width. conversion/laguna.py:79 calls \
816 `add_head_count(per_layer_heads)` with a LIST, so the array is really in the file, \
817 and src/models/laguna.cpp:87-88 and :176-177 read n_head(i) / n_head_kv(i) per \
818 layer while ferrox carries both as scalars. :50 then reads \
819 LLM_KV_ROPE_DIMENSION_COUNT_SWA into `n_rot_swa`, so the sliding-window layers \
820 rotate a DIFFERENT number of dimensions than the full-attention layers (its own \
821 comment at :43-45: full layers YaRN over 64 dims, SWA layers plain RoPE over 128); \
822 ferrox has one rotary_dim. It also creates `wqkv_gate` (:124), the gated-attention \
823 tensor afmoe has, and :55-56 defaults expert_gating_func to SIGMOID when the key is \
824 absent where ferrox would default to softmax. `default_swa_layout` already has \
825 laguna as dense_first period 4, which is correct and is not the blocker",
826 ),
827 (
828 "step35",
829 TriageClass::NewCode,
830 "a per-LAYER rotary width. src/models/step35.cpp:65-70 takes `n_rot_max` as the max \
831 of `hparams.n_rot(i)` over all layers -- because n_rot varies by layer -- and :9 \
832 first halves n_rot_full; ferrox has one rotary_dim for the model. On top of that: \
833 per-layer SwiGLU clamp arrays for the routed and shared experts (:28-29, \
834 LLM_KV_SWIGLU_CLAMP_EXP / _SHEXP), where ferrox's only clamp is the gpt-oss scalar; \
835 a `wqkv_gate` (:96); a per-layer is_swa ARRAY rather than a period (:26), which \
836 ferrox reads only as a scalar; NEXTN/MTP layers with trunk-only and MTP-only load \
837 modes (:32-49); and expert_gating_func defaulting to SIGMOID when absent (:19-20) \
838 where ferrox defaults to softmax. The inventory guessed this was \"probably \
839 parameterisable from the gpt-oss clamp\" -- the clamp is, the per-layer n_rot is \
840 not",
841 ),
842 ("mistral", TriageClass::Unknown, NO_UPSTREAM_ARCH),
843 ("mixtral", TriageClass::Unknown, NO_UPSTREAM_ARCH),
844 ("yi", TriageClass::Unknown, NO_UPSTREAM_ARCH),
845 (
846 "grok",
847 TriageClass::NewCode,
848 "grok-1 hardcodes five constants BEFORE letting an optional key override them \
849 (src/models/grok.cpp:5-21): logit_scale = 0.5773502691896257 (1/sqrt(3)), \
850 embedding_scale = 78.38367176906169, attn_out_scale = 0.08838834764831845 \
851 (1/sqrt(128)), and attn / router logit softcapping both 30.0. A GGUF omitting every \
852 key is still scaled by all five, so a key-presence gate such as \
853 `unsupported_scaling_keys` cannot see them -- the same blind spot `minicpm` is \
854 refused for. On top of that the graph is not the generic one: attention runs with \
855 kq_scale = 1.0f (:137) and folds the real scale into a tanh softcap instead \
856 (llama-graph.cpp:2579-2581), every layer computes BOTH a dense GELU FFN and a GELU \
857 MoE and sums them scaled by sqrt(2)/2 (:171-184), and `blk.N.attn_output_norm` \
858 (:62, LLM_TENSOR_ATTN_OUT_NORM = \"blk.%d.attn_output_norm\", llama-arch.cpp:423) is \
859 a tensor name ferrox never reads. Router logit softcapping has no ferrox concept at \
860 all. `uses_geglu` already covers grok's GELU, which is necessary and nowhere near \
861 sufficient",
862 ),
863 (
864 "dbrx",
865 TriageClass::NewCode,
866 "LayerNorm, not RMSNorm. src/models/dbrx.cpp:4 reads LLM_KV_ATTENTION_LAYERNORM_EPS \
867 (not the RMS one) and the graph normalises with `LLM_NORM` at all three sites -- \
868 :69-71 pre-attention, :110-112 pre-FFN, :140-142 final -- which subtracts the mean; \
869 ferrox has only `rms_norm(x, w, eps)`, a different function of the same tensors on \
870 every layer. Note this is NOT caught by the required-bias refusal group: dbrx \
871 creates no norm bias tensors at all, so the marker that group keys on is absent \
872 while the normalisation is still LayerNorm. It also requires \
873 {arch}.attention.clamp_kqv (:5, REQUIRED) and carries no `ffn_norm` -- \
874 `attn_out_norm` (:34) IS the pre-FFN norm (:110-113), the gpt-oss slot again but \
875 under the unread name `blk.%d.attn_output_norm`",
876 ),
877 (
878 "smallthinker",
879 TriageClass::NewCode,
880 "the MoE router reads a DIFFERENT tensor. src/models/smallthinker.cpp:111 computes \
881 the router logits from the raw layer input `inpL`, before the attention block, and \
882 passes them into build_moe_ffn as a precomputed `probs` with a NULL ffn_gate_inp \
883 (:151-161); every other MoE architecture routes on the normed FFN input, which is \
884 what ferrox computes. Two more, either of which alone would disqualify it: (1) NoPE \
885 layers with no GGUF key -- llama-hparams.h:203 defaults n_no_rope_layer_step to 4 \
886 and the SWA branch (:6-15) never overwrites it, so :108-109's \
887 `use_rope = n_no_rope_layer_step == n_layer || il % n_no_rope_layer_step != 0` \
888 leaves layers 0, 4, 8 ... unrotated, the `smollm3` class exactly, which ferrox \
889 refuses outright; (2) `LLM_FFN_RELU` experts (:158), and FfnActivation has no ReLU \
890 variant. :8 also pins n_swa to 4096 over whatever the file declares. \
891 `default_swa_layout` and `swa_rope_base_follows_model` already carry smallthinker \
892 correctly; they are not the blocker",
893 ),
894 (
895 "bitnet",
896 TriageClass::NewCode,
897 "two norms INSIDE the blocks, in slots ferrox does not have. \
898 src/models/bitnet.cpp:24,36 require `attn_sub_norm` and `ffn_sub_norm`, and the \
899 graph applies attn_sub_norm to the attention output BEFORE the output projection \
900 (:101-106 -- not after it, where ferrox's post_attn_norm sits) and ffn_sub_norm \
901 between the gate*up product and `ffn_down` (:135-140), inside the FFN. It also \
902 carries a per-tensor `scale` for every projection (:27-43, applied via \
903 build_lora_mm) and creates no `output` tensor at all, taking the LM head from \
904 `tok_embd` unconditionally (:164). ferrox refuses it by name today via the \
905 unread-tensor gate (`blk.N.attn_sub_norm`, llama-arch.cpp:510-511), which is the \
906 right outcome and not a small fix",
907 ),
908 (
909 "openelm",
910 TriageClass::NewCode,
911 "per-LAYER head counts and FFN width. src/models/openelm.cpp:26-28 reads \
912 `hparams.n_head(i)`, `n_head_kv(i)` and `n_ff(i)` per layer and sizes the fused \
913 `wqkv` as `n_embd x (2*n_head_kv(i) + n_head(i)) * n_embd_head_k` (:34), and the \
914 graph re-derives those widths for every layer (:67-69). ferrox's ModelConfig \
915 carries n_heads, n_kv_heads and expert_ffn_dim as SCALARS, and \
916 `load_qkv_projections` splits a fused QKV at offsets computed from those scalars, \
917 so there is nowhere to put this. It fails closed, but NOT with this message: \
918 conversion/openelm.py:57-59 writes head_count, head_count_kv and \
919 feed_forward_length as ARRAYS, and `GgufValue::as_u64` returns None for an array \
920 (ferrox-gguf/src/lib.rs:83-93), so the load dies on a missing-hparam error for keys \
921 the file does carry, before the unaudited gate is reached. That misleading message \
922 is the `glm4moe` shape and should be fixed alongside",
923 ),
924];
925
926/// Full inventory keyed by GGUF `general.architecture` string.
927/// Kept in sync with `.scratch/llama.cpp/src/llama-arch.cpp` `LLM_ARCH_NAMES`.
928pub fn architecture_catalog() -> &'static [ArchProfile] {
929 use std::sync::OnceLock;
930 use ArchScope::*;
931 use DecoderFamily::*;
932 use MemoryKind::*;
933 use QkNormStyle::*;
934 use RopeLayout::*;
935
936 static CAT: OnceLock<Vec<ArchProfile>> = OnceLock::new();
937 CAT.get_or_init(|| {
938 let mut v = Vec::with_capacity(160);
939 // --- Verified / standard GQA (Norm RoPE) ---
940 //
941 // `llama` is the only untriaged name left in this group: it is
942 // audited, so it runs and needs no verdict. Every other
943 // Norm-RoPE row moved into `NORM_ROPE_TRIAGED` below when it was
944 // read against llama.cpp's graph.
945 v.push(gqa_norm("llama"));
946 // Audited too, each by a libllama-golden fixture -- see
947 // `AUDITED_GENERIC_GQA` for the arm each one needed and
948 // `tests/one_match_arm_graphs.rs` for the evidence.
949 for n in ["bailingmoe", "deepseek", "maincoder"] {
950 v.push(gqa_norm(n));
951 }
952 // Were FIXTURE-AWAY in this group and now have the fixture:
953 // `tests/fixture_away_graphs.rs`, same evidence standard.
954 for n in ["baichuan", "ernie4_5", "internlm2", "xverse"] {
955 v.push(gqa_norm(n));
956 }
957 // `ernie4_5-moe` was ONE MATCH ARM in `NORM_ROPE_TRIAGED` and is
958 // audited now: the step every real checkpoint carries has a
959 // libllama-golden fixture (`tests/one_match_arm_graphs.rs`) and
960 // any other step is refused by name (`crate::moe_interleave`).
961 v.push(gqa_norm("ernie4_5-moe"));
962 // Same generic Norm-RoPE path, but READ against llama.cpp's own
963 // graph -- see [`TriageClass`]. Each row below refuses with its
964 // class and its blocker instead of the generic
965 // "nobody has checked this" paragraph.
966 for (n, class, blocker) in NORM_ROPE_TRIAGED {
967 v.push(gqa_norm(n).triaged(*class, blocker));
968 }
969 for n in [
970 "olmoe", "qwen2", "qwen2moe",
971 // llama-model.cpp `llama_model_rope_type`: LLM_ARCH_OPENAI_MOE
972 // falls in the `return LLAMA_ROPE_TYPE_NEOX` group, and a live
973 // load of a gpt-oss GGUF prints `rope type = 2` (= NEOX).
974 // ferrox had it on the interleaved (NORM) list, which rotates
975 // the wrong pairs of every Q/K head.
976 "gpt-oss",
977 // Same audit, run over every arch at once against
978 // `llama_model_rope_type`'s NEOX group
979 // (llama-model.cpp:2613-2683). These 24 were on ferrox's
980 // interleaved (NORM) list and reach the generic GQA decoder,
981 // so every one of them rotated the wrong pairs of every Q/K
982 // head and answered fluently and wrongly. Pinned by
983 // `rope_layout_matches_llama_cpp` below; dots1 additionally
984 // checked end-to-end against llama.cpp's own logits in
985 // `tests/moe_routing_bias.rs`.
986 "dots1",
987 // Audited by libllama-golden fixtures in
988 // `tests/one_match_arm_graphs.rs`: `hunyuan-moe` needed the
989 // post-RoPE QK-norm order, `seed_oss` the gpt-oss pre-FFN
990 // norm slot.
991 "hunyuan-moe",
992 "seed_oss",
993 // `hunyuan-dense` was ONE MATCH ARM in `NEOX_ROPE_TRIAGED`
994 // and is audited now: the NTK-alpha RoPE base rescale
995 // (`crate::rope_ntk_alpha`) plus the post-RoPE QK-norm order
996 // it shares with `hunyuan-moe`, both against libllama's own
997 // logits.
998 "hunyuan-dense",
999 // Were FIXTURE-AWAY and now have the fixture
1000 // (`tests/fixture_away_graphs.rs`). EXAONE 3.x only:
1001 // `exaone4` and `exaone-moe` are different graphs and stay
1002 // in `NEOX_ROPE_TRIAGED` below. `bailingmoe2` is Ling-2.0
1003 // and is unrelated to the NORM-RoPE `bailingmoe` row above.
1004 "exaone",
1005 "bailingmoe2",
1006 "plamo3",
1007 ] {
1008 v.push(gqa_neox(n));
1009 }
1010 // Triaged NEOX-RoPE rows; see `NORM_ROPE_TRIAGED` above.
1011 for (n, class, blocker) in NEOX_ROPE_TRIAGED {
1012 v.push(gqa_neox(n).triaged(*class, blocker));
1013 }
1014 // --- No RoPE at all: refused, not rotated ------------------
1015 //
1016 // `llama_model_rope_type` opens with a `LLAMA_ROPE_TYPE_NONE`
1017 // group, and these five sat on ferrox's NEOX list instead. The
1018 // generic decoder rotates every Q/K head of every layer, so each
1019 // of them loaded, ran at full speed, and answered fluently from
1020 // positions the checkpoint never encodes that way, the same
1021 // silent failure the 24-arch RoPE audit found, one level worse,
1022 // because here the right answer is *no rotation*.
1023 //
1024 // Worse still for a metadata gate: `bloom` and `refact` hardcode
1025 // `f_max_alibi_bias = 8.0f` in `load_arch_hparams` and carry no
1026 // key at all, so `unsupported_feature_keys` could never have seen
1027 // them. Only the registry can. `tests/rope_layout.rs`'s
1028 // `LLAMA_NO_ROPE` pins the group so a later edit cannot quietly
1029 // put one back on a rotating path.
1030 for (n, reason) in [
1031 (
1032 "smollm3",
1033 "a NoPE layer pattern: llama.cpp hardcodes \
1034 `hparams.n_no_rope_layer_step = 4` (src/models/smollm3.cpp:5) and \
1035 skips RoPE where `(il + 1) % 4 == 0` (:69), so 9 of a 36-layer \
1036 SmolLM3-3B's layers get NO rotation at all. There is NO GGUF key \
1037 for it, so no metadata gate could see it: the tensor set matches \
1038 the generic llama set exactly and the file loads clean. The \
1039 generic decoder rotates every layer, which is a different model. \
1040 Same shape as the ALiBi group below, and found the same way",
1041 ),
1042 (
1043 "gpt2",
1044 "learned absolute position embeddings (`position_embd.weight`, \
1045 src/models/gpt2.cpp:19,74) and no RoPE; the generic decoder has no \
1046 slot for them and rotates instead",
1047 ),
1048 (
1049 "mpt",
1050 "ALiBi attention bias (src/models/mpt.cpp:6), plus an optional \
1051 learned `position_embd` and an optional QKV clamp; the generic \
1052 decoder implements none of the three and applies RoPE instead",
1053 ),
1054 (
1055 "refact",
1056 "ALiBi attention bias, hardcoded `f_max_alibi_bias = 8.0f` with no \
1057 GGUF key to detect it (src/models/refact.cpp:12); the generic \
1058 decoder applies RoPE instead",
1059 ),
1060 (
1061 "bloom",
1062 "ALiBi attention bias, hardcoded `f_max_alibi_bias = 8.0f` with no \
1063 GGUF key (src/models/bloom.cpp:18), plus a `token_embd_norm` the \
1064 generic decoder never applies; RoPE is applied instead",
1065 ),
1066 (
1067 "jais",
1068 "ALiBi attention bias (src/models/jais.cpp:5); the generic decoder \
1069 applies RoPE instead",
1070 ),
1071 ] {
1072 v.push(prof(
1073 n,
1074 TextGeneration,
1075 StandardGqa,
1076 KvGqa,
1077 // No layout is right here. `Norm` is the struct's least
1078 // surprising filler and nothing reads it: the load
1079 // refuses in `ModelConfig::from_gguf` before any graph
1080 // asks. `rope_layout_matches_llama_cpp` skips
1081 // non-generic paths for exactly this reason.
1082 Norm,
1083 ArchPath::DedicatedOnly { reason },
1084 WholeVector,
1085 ));
1086 }
1087 // --- Required bias tensors the generic decoder has no slot for
1088 //
1089 // Found by transcribing every `create_tensor(tn(..., "bias"), ...)`
1090 // llama.cpp's per-architecture loaders create with flag `0`
1091 // (REQUIRED, as opposed to `TENSOR_NOT_REQUIRED`). Required means
1092 // every real checkpoint of that architecture carries it, so this
1093 // is not a "some files might" gate.
1094 //
1095 // `AttnWeights` carries exactly three of them -- `attn_q.bias`,
1096 // `attn_k.bias`, `attn_v.bias` -- and `GptOssWeights` carries
1097 // gpt-oss's `attn_output.bias` and `ffn_gate_inp.bias`. Nothing
1098 // else has anywhere to go:
1099 //
1100 // - `attn_output.bias`, `ffn_{up,down,gate}.bias` and the
1101 // `output.bias` on the LM head are read by no loader path, so
1102 // they are simply dropped: the projection runs unbiased.
1103 // - `attn_norm.bias` / `ffn_norm.bias` / `output_norm.bias` are
1104 // the marker of a real LayerNorm. The generic decoder only has
1105 // `rms_norm(x, w, eps)` -- no mean subtraction and no bias --
1106 // so it computes a different normalisation at every layer.
1107 // - `attn_qkv.bias` is the *fused* spelling.
1108 // `load_qkv_projections` splits a fused `attn_qkv.weight` but
1109 // looks for the bias only under the split `attn_q.bias` names,
1110 // finds nothing, and runs unbiased.
1111 //
1112 // Every one of these loads clean and answers fluently, which is
1113 // why they are refused here rather than left to a tensor gate.
1114 // Pinned by `tests/attn_bias.rs`.
1115 for (n, rope, reason) in [
1116 (
1117 "codeshell",
1118 Neox,
1119 "required bias tensors with no slot in the generic decoder: \
1120 `attn_output.bias`, `ffn_down.bias`, `ffn_up.bias` \
1121 (src/models/codeshell.cpp:36,42,45), plus the LayerNorm biases \
1122 `output_norm.bias`, `attn_norm.bias`, `ffn_norm.bias` (:24,31,39) \
1123 -- the generic decoder is RMSNorm-only and drops all six",
1124 ),
1125 (
1126 "jais2",
1127 Neox,
1128 "required bias tensors with no slot in the generic decoder: \
1129 `attn_output.bias`, `ffn_up.bias`, `ffn_down.bias` \
1130 (src/models/jais2.cpp:41,48,50), plus the LayerNorm biases \
1131 `output_norm.bias`, `attn_norm.bias`, `ffn_norm.bias` (:20,30,44). \
1132 Only its Q/K/V biases (:38-40) would have been applied",
1133 ),
1134 (
1135 "starcoder",
1136 Norm,
1137 "required bias tensors with no slot in the generic decoder: the \
1138 *fused* `attn_qkv.bias` (src/models/starcoder.cpp:40), which \
1139 `load_qkv_projections` never looks for because it reads bias only \
1140 under the split `attn_q.bias` names; `attn_output.bias`, \
1141 `ffn_down.bias`, `ffn_up.bias` (:43,49,52); and the LayerNorm \
1142 biases `output_norm.bias`, `attn_norm.bias`, `ffn_norm.bias` \
1143 (:24,37,46). It also adds a learned `position_embd` to the \
1144 embeddings (:75) that the generic decoder has no slot for",
1145 ),
1146 (
1147 "starcoder2",
1148 Neox,
1149 "required bias tensors with no slot in the generic decoder: \
1150 `attn_output.bias`, `ffn_down.bias`, `ffn_up.bias` \
1151 (src/models/starcoder2.cpp:41,50,51), plus the LayerNorm biases \
1152 `output_norm.bias`, `attn_norm.bias`, `ffn_norm.bias` (:23,35,44)",
1153 ),
1154 (
1155 "phimoe",
1156 Neox,
1157 "required bias tensors with no slot in the generic decoder: \
1158 `attn_output.bias` and an `output.bias` on the LM head \
1159 (src/models/phimoe.cpp:33,23), plus the LayerNorm biases \
1160 `output_norm.bias`, `attn_norm.bias`, `ffn_norm.bias` (:21,29,36). \
1161 `phi3` stays generic: it requires none of them",
1162 ),
1163 (
1164 "nemotron",
1165 Neox,
1166 "required LayerNorm biases `output_norm.bias`, `attn_norm.bias`, \
1167 `ffn_norm.bias` (src/models/nemotron.cpp:19,26,35). llama.cpp \
1168 normalises with `build_norm(..., LLM_NORM, ...)` and a bias; the \
1169 generic decoder applies RMSNorm with weight only, which is a \
1170 different function of the same tensors at every layer",
1171 ),
1172 (
1173 "orion",
1174 Neox,
1175 "required LayerNorm biases `output_norm.bias`, `attn_norm.bias`, \
1176 `ffn_norm.bias` (src/models/orion.cpp:18,25,31); the generic \
1177 decoder is RMSNorm-only and drops all three",
1178 ),
1179 (
1180 "stablelm",
1181 Neox,
1182 "required LayerNorm biases `output_norm.bias` and `attn_norm.bias` \
1183 (src/models/stablelm.cpp:20,28); the generic decoder is \
1184 RMSNorm-only and drops both",
1185 ),
1186 (
1187 "qwen",
1188 Neox,
1189 "a required *fused* `attn_qkv.bias` (src/models/qwen.cpp:28). \
1190 `load_qkv_projections` splits the fused `attn_qkv.weight` but reads \
1191 bias only under the split `attn_q.bias` / `attn_k.bias` / \
1192 `attn_v.bias` names, so Qwen-1's QKV bias is silently dropped and \
1193 every Q, K and V projection runs unbiased. Qwen-2 and later store \
1194 the split spelling and stay generic",
1195 ),
1196 ] {
1197 v.push(prof(
1198 n,
1199 TextGeneration,
1200 StandardGqa,
1201 KvGqa,
1202 // Unlike the no-RoPE group above, the layout here is
1203 // real and `rope_layout_matches_llama_cpp` still checks
1204 // it: refusing for a bias is not a licence to forget
1205 // what these rotate as.
1206 rope,
1207 ArchPath::DedicatedOnly { reason },
1208 WholeVector,
1209 ));
1210 }
1211 v.push(prof(
1212 "qwen3",
1213 TextGeneration,
1214 Qwen3Family,
1215 KvGqa,
1216 Neox,
1217 ArchPath::GenericGqa { rope: Neox },
1218 PerHead,
1219 ));
1220 v.push(prof(
1221 "qwen3moe",
1222 TextGeneration,
1223 Qwen3Family,
1224 KvGqa,
1225 Neox,
1226 ArchPath::GenericGqa { rope: Neox },
1227 PerHead,
1228 ));
1229 // `gemma` was FIXTURE-AWAY here until it got its fixture
1230 // (`tests/fixture_away_graphs.rs`); it is audited now and
1231 // carries no verdict at all.
1232 v.push(prof(
1233 "gemma",
1234 TextGeneration,
1235 GemmaFamily,
1236 KvGqa,
1237 Neox,
1238 ArchPath::GenericGqa { rope: Neox },
1239 PerHead,
1240 ));
1241 v.push(prof(
1242 "gemma2",
1243 TextGeneration,
1244 GemmaFamily,
1245 KvIswa,
1246 Neox,
1247 ArchPath::GenericGqa { rope: Neox },
1248 PerHead,
1249 ));
1250 v.push(prof(
1251 "gemma3",
1252 TextGeneration,
1253 GemmaFamily,
1254 KvIswa,
1255 Neox,
1256 ArchPath::GenericGqa { rope: Neox },
1257 PerHead,
1258 ));
1259 // Gemma-4 text GGUFs (E2B): per-layer embeddings, shared-KV
1260 // layers, and split SWA/full head dims -- dedicated
1261 // [`crate::gemma4_engine::Gemma4Engine`] (not GenericGqa).
1262 for n in ["gemma4", "gemma4-assistant"] {
1263 v.push(prof(
1264 n,
1265 TextGeneration,
1266 GemmaFamily,
1267 KvIswa,
1268 Neox,
1269 ArchPath::DedicatedOnly {
1270 reason: "use load_gemma4_engine_from_path / ServedEngine::Gemma4",
1271 },
1272 PerHead,
1273 ));
1274 }
1275 // Refused, not implemented: the generic decoder computes
1276 // `x + attn(norm(x))` then `y + ffn(norm(y))`, and every arch
1277 // here computes something else that no tensor and (for MiniCPM)
1278 // no metadata key makes visible. See
1279 // `unsupported_scaling_keys` for the metadata-visible half of
1280 // the same class.
1281 const PARALLEL_RESIDUAL: &str =
1282 "parallel attention+FFN residual -- llama.cpp feeds both branches the *same* \
1283 normed input and sums `inpL + attn_out + ffn_out` once; the generic decoder \
1284 computes the sequential form, which is a different graph";
1285 for (n, rope, fam) in [
1286 // src/models/cohere2.cpp:120-134, cohere2moe.cpp:222-266,
1287 // command-r.cpp:106-119. All three also carry a
1288 // `logit_scale` the generic decoder does not apply.
1289 ("command-r", Norm, StandardGqa),
1290 ("cohere2", Norm, StandardGqa),
1291 ("cohere2moe", Norm, StandardGqa),
1292 // src/models/falcon.cpp:121-135 (and an `attn_norm_2` the
1293 // generic decoder has no slot for).
1294 ("falcon", Neox, StandardGqa),
1295 // src/models/gptneox.cpp:147-195 -- parallel or sequential
1296 // per `use_par_res`, and the generic decoder implements
1297 // neither branch of that choice.
1298 ("gptneox", Neox, StandardGqa),
1299 // src/models/phi2.cpp:116-117, plamo.cpp:97-112.
1300 ("phi2", Neox, PhiFamily),
1301 ("plamo", Neox, StandardGqa),
1302 ] {
1303 v.push(prof(
1304 n,
1305 TextGeneration,
1306 fam,
1307 KvGqa,
1308 rope,
1309 ArchPath::DedicatedOnly {
1310 reason: PARALLEL_RESIDUAL,
1311 },
1312 WholeVector,
1313 ));
1314 }
1315 // MiniCPM is the case `unsupported_scaling_keys` cannot catch:
1316 // `src/models/minicpm.cpp:4-14` *hardcodes* an embedding
1317 // multiplier of 12.0, a residual multiplier of
1318 // `1.4/sqrt(n_layer)` and a logit multiplier of `256/n_embd`,
1319 // and only then lets the GGUF override them. An older MiniCPM
1320 // export carrying none of the three keys is still scaled by all
1321 // three, so a key-presence gate sees nothing and the generic
1322 // decoder computes an unscaled graph.
1323 v.push(prof(
1324 "minicpm",
1325 TextGeneration,
1326 StandardGqa,
1327 KvGqa,
1328 Norm,
1329 ArchPath::DedicatedOnly {
1330 reason: "unconditional embedding/residual/logit multipliers that llama.cpp \
1331 applies even when the GGUF omits every key; not applied by the \
1332 generic decoder",
1333 },
1334 WholeVector,
1335 ));
1336 v.push(prof(
1337 "phi3",
1338 TextGeneration,
1339 PhiFamily,
1340 KvGqa,
1341 Neox,
1342 ArchPath::GenericGqa { rope: Neox },
1343 WholeVector,
1344 ));
1345 // Phi-4 GGUFs share the phi3 fused-QKV / fused gate+up graph
1346 // (PhiFamily). Many community checkpoints still tag `phi3`; admit
1347 // `phi4` the same way so either string can load. Receipts / head-dim
1348 // FA-vec coverage remain P6 evidence work -- not a speed claim.
1349 v.push(
1350 prof(
1351 "phi4",
1352 TextGeneration,
1353 PhiFamily,
1354 KvGqa,
1355 Neox,
1356 ArchPath::GenericGqa { rope: Neox },
1357 WholeVector,
1358 )
1359 .triaged(
1360 TriageClass::Unknown,
1361 "there is no llama.cpp graph to diff against. `phi4` is NOT in LLM_ARCH_NAMES \
1362 -- src/llama-arch.cpp:44 lists \"phi3\" and there is no phi4 entry -- so this \
1363 row is a ferrox-only alias and no llama.cpp-produced GGUF can carry the \
1364 string. ferrox admits it as PhiFamily/NEOX, i.e. phi3's fused-QKV and fused \
1365 gate+up graph, on the assumption that a file spelling it means the same \
1366 thing. WHAT WOULD SETTLE IT: a real GGUF whose general.architecture is \
1367 literally `phi4`. If its blk.0 carries attn_qkv.weight it is phi3's graph \
1368 and this row is fixture-away behind an already-audited phi3; if it carries \
1369 split attn_q/attn_k/attn_v it is a Llama-shaped graph and belongs on a \
1370 different row",
1371 ),
1372 );
1373 // Llama 4: MoE + interleaved / non-generic attention graph -- not
1374 // safe to admit as GenericGqa (was wrongly listed with plain llama).
1375 v.push(prof(
1376 "llama4",
1377 TextGeneration,
1378 Dedicated,
1379 KvGqa,
1380 Norm,
1381 ArchPath::DedicatedOnly {
1382 reason: "llama4 MoE + non-GQA attn -- see llama4_engine.rs tensor list",
1383 },
1384 WholeVector,
1385 ));
1386 // MiniMax M2 and M3 are two DIFFERENT architectures and were
1387 // wrong to share one reason. Both used to refuse with "256-expert
1388 // sigmoid MoE + MTP"; neither clause is true.
1389 //
1390 // MTP: `minimax-m2.cpp` and `minimax-m3.cpp` create no `nextn.*`
1391 // tensor at all, and `gguf-py/gguf/constants.py`'s
1392 // `MODEL_ARCH.MINIMAXM2` / `.MINIMAXM3` tensor lists contain no
1393 // `NEXTN_*` entry -- so no converter can even emit MTP weights for
1394 // these files. `minimax-m3.cpp:9` says it outright: "MTP is not
1395 // in released model weights."
1396 //
1397 // Sigmoid MoE: ferrox HAS it. `loader.rs` reads
1398 // `{arch}.expert_gating_func` into `GatingFunction::Sigmoid`,
1399 // loads `blk.N.exp_probs_b.bias`, and reads
1400 // `expert_weights_scale` / `expert_weights_norm`. Expert count is
1401 // an hparam, not a ceiling.
1402 //
1403 // llama-arch.cpp puts both in the NEOX RoPE group.
1404 v.push(prof(
1405 "minimax-m2",
1406 TextGeneration,
1407 Dedicated,
1408 KvGqa,
1409 Neox,
1410 ArchPath::DedicatedOnly {
1411 // `minimax-m2.cpp` is plain GQA: `create_tensor_qkv` at
1412 // :26, whole-vector Q/K norm at :30-31 (`attn_q_norm` is
1413 // `n_embd_head_k * n_head` wide, NOT per-head), partial
1414 // NEOX RoPE at :96-106 (:51 notes head_dim=128 but
1415 // n_rot=64), and one SiLU MoE with `exp_probs_b`,
1416 // `expert_weights_scale` and norm_w=true at :131-141.
1417 // ferrox implements every one of those on the generic
1418 // path. What is missing is EVIDENCE, not capability.
1419 reason: "minimax-m2 is UNAUDITED, not unimplemented: llama.cpp's minimax-m2.cpp \
1420 builds plain GQA + whole-vector QK-norm + partial NEOX RoPE (n_rot=64 < \
1421 head_dim=128) + a SiLU sigmoid MoE with exp_probs_b, all of which the \
1422 generic path already has. Admitting it needs a fixture or a parity run \
1423 against llama.cpp, not new code",
1424 },
1425 // `attn_q_norm` is `{n_embd_head_k * n_head}` wide
1426 // (minimax-m2.cpp:30) -- one RMSNorm over the whole Q
1427 // projection, OLMoE's style, not Qwen3's per-head.
1428 WholeVector,
1429 ));
1430 v.push(prof(
1431 "minimax-m3",
1432 TextGeneration,
1433 Dedicated,
1434 KvGqa,
1435 Neox,
1436 ArchPath::DedicatedOnly {
1437 reason: "minimax-m3 needs MiniMax Sparse Attention: a per-layer indexer \
1438 (index_q_proj/index_k_proj/index_q_norm/index_k_norm, minimax-m3.cpp:76-82) \
1439 driving its own MSA KV cache (llama-kv-cache-msa.h) with position<->cell \
1440 maps, plus SWIGLU_OAI experts and shared experts. ferrox has only the \
1441 block-selection rule (ferrox_core::block_sparse), none of the rest",
1442 },
1443 // minimax-m3.cpp:53-55 -- `{n_embd_head_k}`, with llama.cpp's
1444 // own comment "per-head QK-norm: a single head_dim vector
1445 // applied to every head". M2 and M3 DIFFER here, which is why
1446 // the shared entry was wrong for M3.
1447 PerHead,
1448 ));
1449 // MiniCPM3 is MLA, not generic GQA, and the catalog said
1450 // otherwise: it claimed `StandardGqa`/`KvGqa`, which is false
1451 // about the model rather than merely unaudited.
1452 // `src/models/minicpm3.cpp:5-6` requires `q_lora_rank` and
1453 // `kv_lora_rank`, and `:41-46` creates
1454 // `attn_q_a`/`attn_q_b`/`attn_kv_a_mqa`/`attn_kv_b` -- the
1455 // DeepSeek-2 tensor set. There is no `attn_q.weight` in any
1456 // MiniCPM3 checkpoint, so the generic path could never have
1457 // loaded one whatever the audit said.
1458 //
1459 // Reclassified 2026-09-01 by the unaudited-refusal triage. This
1460 // is a MESSAGE-QUALITY fix, not a correctness one: the old
1461 // failure was already a clean missing-tensor error. It stops the
1462 // user being told "unaudited" for something that is not merely
1463 // unaudited.
1464 v.push(prof(
1465 "minicpm3",
1466 TextGeneration,
1467 Mla,
1468 KvMla,
1469 Neox,
1470 ArchPath::DedicatedOnly {
1471 reason: "MiniCPM3 is an MLA model (src/models/minicpm3.cpp:5-6,41-46 -- \
1472 q_lora_rank/kv_lora_rank and the attn_q_a/attn_q_b/attn_kv_a_mqa/\
1473 attn_kv_b tensor set), so it needs the MLA engine and not the \
1474 generic GQA decoder. It ALSO hardcodes MiniCPM's multipliers with \
1475 no GGUF key to read them from -- scale_embd = 12.0, \
1476 scale_depth = 1.4, n_embd_base = 256 at :65-67, applied at :81 -- \
1477 which is the same blind spot `minicpm` is refused for",
1478 },
1479 WholeVector,
1480 ));
1481 v.push(prof(
1482 "deepseek2",
1483 TextGeneration,
1484 Mla,
1485 KvMla,
1486 Norm,
1487 ArchPath::DedicatedOnly {
1488 reason: "DeepSeek-2 MLA needs the MLA engine, not generic GQA",
1489 },
1490 WholeVector,
1491 ));
1492 v.push(prof(
1493 "deepseek32",
1494 TextGeneration,
1495 Mla,
1496 KvDsa,
1497 Norm,
1498 ArchPath::DedicatedOnly {
1499 reason: "DeepSeek-3.2 DSA/MLA needs the dedicated sparse/MLA stack",
1500 },
1501 WholeVector,
1502 ));
1503 v.push(prof(
1504 "mistral4",
1505 TextGeneration,
1506 Mla,
1507 KvMla,
1508 Norm,
1509 ArchPath::DedicatedOnly {
1510 reason: "mistral4 reuses DeepSeek-2 MLA loader/graph in llama.cpp",
1511 },
1512 WholeVector,
1513 ));
1514 v.push(dedicated(
1515 "glm-dsa",
1516 "use ferrox_models::glm52_decoder / glm52_gguf_loader (DSA), not the generic GQA Decoder",
1517 ));
1518 v.push(dedicated(
1519 "glm4",
1520 "use ferrox_models::glm52_decoder / glm52_gguf_loader, not the generic GQA Decoder",
1521 ));
1522 // GLM-4.5 / GLM-4.5-Air / GLM-4.6 tag `glm4moe`, and the reason
1523 // here used to point at `glm52_gguf_loader` the way `glm-dsa`
1524 // does. It cannot load one: `read_glm52_hparams` requires
1525 // `{arch}.attention.q_lora_rank`, `.kv_lora_rank`,
1526 // `.qk_nope_head_dim` and `.qk_rope_head_dim`, and glm4moe is
1527 // NOT an MLA model -- `src/models/glm4-moe.cpp`'s
1528 // `load_arch_hparams` never reads any of the four and its
1529 // `load_arch_tensors` calls `create_tensor_qkv` (plain Q/K/V)
1530 // with no `attn_kv_a_mqa` / `attn_kv_b` / `attn_q_a` /
1531 // `attn_q_b` anywhere. So `ferrox run` on a real GLM-4.5-Air
1532 // answered "missing hparam glm4moe.attention.q_lora_rank" for a
1533 // model that has no MLA at all.
1534 //
1535 // What it actually is: plain GQA + DeepSeek-V3-shaped sigmoid
1536 // MoE (`exp_probs_b`, shared expert, leading dense,
1537 // `expert_weights_scale`), all of which the generic decoder
1538 // already computes and `dots1` already pins. The one thing that
1539 // does not fit is the norm slot, and it is a real divergence
1540 // rather than a missing key -- see the reason string. Pinned by
1541 // `tests/glm4moe_refusal.rs` against a synthetic checkpoint
1542 // llama.cpp itself loads and decodes.
1543 v.push(dedicated(
1544 "glm4moe",
1545 "GLM-4.5-MoE stores its pre-FFN norm as `blk.N.post_attention_norm.weight` and \
1546 carries NO `blk.N.ffn_norm.weight` (src/models/glm4-moe.cpp:75, applied to \
1547 `ffn_inp` at :215 -- i.e. AFTER the attention residual). The generic decoder \
1548 requires `ffn_norm` and puts `post_attention_norm` in Gemma's other slot, on the \
1549 attention branch BEFORE the residual add, so it would both fail to find its \
1550 tensors and compute a different graph. This is gpt-oss's norm slot exactly, and \
1551 `loader.rs` already implements it behind an `is_gpt_oss` flag; widening that flag \
1552 is what admits glm4moe. It is NOT MLA -- do not send it to glm52_gguf_loader, \
1553 which asks for a `q_lora_rank` no glm4moe checkpoint carries",
1554 ));
1555 v.push(dedicated(
1556 "deepseek4",
1557 "DeepSeek V4 needs CSA/HCA + mHC assembly; generic GQA Decoder is not valid",
1558 ));
1559 v.push(dedicated(
1560 "kimi-linear",
1561 "use ferrox_models::kimi_decoder / kimi_loader, not the generic GQA Decoder",
1562 ));
1563 v.push(dedicated(
1564 "kimi_k3",
1565 "use ferrox_models::kimi_decoder / kimi_loader, not the generic GQA Decoder",
1566 ));
1567 for (n, rope) in [
1568 ("jamba", Neox),
1569 ("falcon-h1", Neox),
1570 ("plamo2", Neox),
1571 ("granitehybrid", Norm),
1572 ("granite-hybrid", Norm),
1573 ("lfm2", Neox),
1574 ("lfm2moe", Neox),
1575 ("nemotron_h", Neox),
1576 ("nemotron_h_moe", Neox),
1577 ("qwen3next", Neox),
1578 ("qwen35", Neox),
1579 ("qwen35moe", Neox),
1580 ] {
1581 let qk = if n.starts_with("qwen3") {
1582 PerHead
1583 } else {
1584 WholeVector
1585 };
1586 v.push(prof(
1587 n,
1588 TextGeneration,
1589 DecoderFamily::Hybrid,
1590 MemoryKind::Hybrid,
1591 rope,
1592 ArchPath::DedicatedOnly {
1593 reason: "hybrid attn+SSM/delta-net engine not yet on the serve path",
1594 },
1595 qk,
1596 ));
1597 }
1598 for n in ["mamba", "mamba2", "rwkv6", "rwkv6qwen2", "rwkv7", "arwkv7"] {
1599 v.push(prof(
1600 n,
1601 TextGeneration,
1602 DecoderFamily::Recurrent,
1603 MemoryKind::Recurrent,
1604 Neox,
1605 ArchPath::DedicatedOnly {
1606 reason: "recurrent engine not yet on the serve path",
1607 },
1608 WholeVector,
1609 ));
1610 }
1611 v.push(prof(
1612 "t5",
1613 TextGeneration,
1614 EncoderDecoder,
1615 None,
1616 Neox,
1617 ArchPath::DedicatedOnly {
1618 reason: "T5 encoder-decoder engine not yet on the serve path",
1619 },
1620 WholeVector,
1621 ));
1622 for (n, scope, reason) in [
1623 (
1624 "t5encoder",
1625 DeferredEncoderEmbedding,
1626 "encoder-only; deferred from text-generation parity",
1627 ),
1628 // Deferred from the *decoder* path, and that is still
1629 // right: a `bert` GGUF has no output head, so
1630 // `ensure_generic_decoder` must keep refusing it. It is no
1631 // longer deferred outright -- it loads and embeds through
1632 // `bert_gguf_loader` / `bert_encoder`, checked against
1633 // llama.cpp by `tests/bert_llama_cpp_parity.rs`.
1634 (
1635 "bert",
1636 DeferredEncoderEmbedding,
1637 "encoder; no output head, so never a decoder -- served by \
1638 ferrox_models::EmbeddingModel on /v1/embeddings",
1639 ),
1640 (
1641 "modern-bert",
1642 DeferredEncoderEmbedding,
1643 "encoder/embedding; deferred",
1644 ),
1645 (
1646 "nomic-bert",
1647 DeferredEncoderEmbedding,
1648 "encoder/embedding; deferred",
1649 ),
1650 (
1651 "nomic-bert-moe",
1652 DeferredEncoderEmbedding,
1653 "encoder/embedding; deferred",
1654 ),
1655 (
1656 "neo-bert",
1657 DeferredEncoderEmbedding,
1658 "encoder/embedding; deferred",
1659 ),
1660 (
1661 "jina-bert-v2",
1662 DeferredEncoderEmbedding,
1663 "encoder/embedding; deferred",
1664 ),
1665 (
1666 "jina-bert-v3",
1667 DeferredEncoderEmbedding,
1668 "encoder/embedding; deferred",
1669 ),
1670 (
1671 "eurobert",
1672 DeferredEncoderEmbedding,
1673 "encoder/embedding; deferred",
1674 ),
1675 (
1676 "llama-embed",
1677 DeferredEncoderEmbedding,
1678 "embedding variant; deferred",
1679 ),
1680 (
1681 "gemma-embedding",
1682 DeferredEncoderEmbedding,
1683 "embedding variant; deferred",
1684 ),
1685 (
1686 "pangu-embedded",
1687 DeferredEncoderEmbedding,
1688 "embedding variant; deferred",
1689 ),
1690 ("yi-vl", DeferredMultimodal, "Yi vision-language; deferred"),
1691 ("qwen2vl", DeferredMultimodal, "vision-language; deferred"),
1692 ("qwen3vl", DeferredMultimodal, "vision-language; deferred"),
1693 ("qwen3vlmoe", DeferredMultimodal, "vision-language; deferred"),
1694 ("cogvlm", DeferredMultimodal, "vision-language; deferred"),
1695 ("chameleon", DeferredMultimodal, "multimodal; deferred"),
1696 ("hunyuan_vl", DeferredMultimodal, "vision-language; deferred"),
1697 ("paddleocr", DeferredMultimodal, "OCR multimodal; deferred"),
1698 ("hy_v3", DeferredMultimodal, "multimodal; deferred"),
1699 ("deepseek2-ocr", DeferredMultimodal, "OCR multimodal; deferred"),
1700 ("dream", DeferredDiffusion, "diffusion LM; deferred"),
1701 ("llada", DeferredDiffusion, "diffusion LM; deferred"),
1702 ("llada-moe", DeferredDiffusion, "diffusion LM; deferred"),
1703 ("rnd1", DeferredDiffusion, "diffusion LM; deferred"),
1704 (
1705 "wavtokenizer-dec",
1706 DeferredAudio,
1707 "audio tokenizer; deferred",
1708 ),
1709 (
1710 "eagle3",
1711 EnumOnly,
1712 "speculative draft head; not a standalone decoder target",
1713 ),
1714 (
1715 "dflash",
1716 EnumOnly,
1717 "speculative draft head; not a standalone decoder target",
1718 ),
1719 ("clip", EnumOnly, "quantize dummy only"),
1720 ("gptj", EnumOnly, "enum-only in llama.cpp factory gap"),
1721 ("(unknown)", EnumOnly, "llama.cpp unknown sentinel"),
1722 ] {
1723 v.push(deferred_scope(n, scope, reason));
1724 }
1725 v.push(prof(
1726 "gemma3n",
1727 TextGeneration,
1728 GemmaFamily,
1729 KvIswa,
1730 Neox,
1731 ArchPath::DedicatedOnly {
1732 reason: "gemma3n AltUp/Laurel tensors not implemented in the generic decoder",
1733 },
1734 PerHead,
1735 ));
1736 for n in ["ferroxtest", "ferroxtestmoe", "ferroxtestmixed"] {
1737 v.push(prof(
1738 n,
1739 TextGeneration,
1740 TestFixture,
1741 KvGqa,
1742 Neox,
1743 ArchPath::TestFixture { rope: Neox },
1744 WholeVector,
1745 ));
1746 }
1747 v
1748 })
1749 .as_slice()
1750}
1751
1752/// Resolve a GGUF `general.architecture` value to its profile.
1753pub fn resolve_profile(arch: &str) -> Option<&'static ArchProfile> {
1754 architecture_catalog().iter().find(|p| p.gguf_name == arch)
1755}
1756
1757/// Resolve a GGUF `general.architecture` value. `None` means the string
1758/// is not in the registry -- callers must fail closed rather than guess.
1759pub fn resolve_architecture(arch: &str) -> Option<ArchPath> {
1760 resolve_profile(arch).map(|p| p.path)
1761}
1762
1763/// llama.cpp's hardcoded alternating sliding-window layout for one
1764/// architecture: the period, *and* which end of each period is the
1765/// full-attention layer.
1766///
1767/// `llama_hparams::set_swa_pattern` (`src/llama-hparams.cpp:8-22`) has
1768/// two phases, and they are not interchangeable:
1769///
1770/// - `dense_first = false`: `is_swa[il] = il % p < (p - 1)` -- the
1771/// **last** layer of every period is full attention.
1772/// - `dense_first = true`: `is_swa[il] = il % p != 0` -- the **first**
1773/// layer of every period is full attention.
1774///
1775/// For a 32-layer period-4 model the two disagree on 16 of the 32
1776/// layers. Storing only the period would therefore not be a partial
1777/// transcription, it would be a wrong one for the four architectures
1778/// llama.cpp passes `dense_first = true`.
1779#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1780pub struct SwaPattern {
1781 /// llama.cpp's `swa_period` seed literal.
1782 pub period: usize,
1783 /// llama.cpp's `dense_first` argument to `set_swa_pattern`.
1784 pub dense_first: bool,
1785}
1786
1787/// Every architecture for which llama.cpp seeds a sliding-window period
1788/// *before* letting `{arch}.attention.sliding_window_pattern` override
1789/// it, transcribed from `src/models/*.cpp`.
1790///
1791/// The period is not in the file for these families -- llama.cpp
1792/// hardcodes it per architecture and only lets the metadata key override
1793/// it (`ml.get_key_or_arr(LLM_KV_ATTENTION_SLIDING_WINDOW_PATTERN,
1794/// swa_period, false)` after seeding `swa_period` with the literal
1795/// below). A missing key therefore does **not** mean "every layer is
1796/// windowed", which is what ferrox assumed: `layer_sliding_window`
1797/// returns the window for all layers when `swa_pattern` is `None`, so a
1798/// gpt-oss or cohere2 checkpoint ran its full-attention layers through a
1799/// 128-token window and answered from a truncated history.
1800///
1801/// Two llama.cpp spellings are deliberately absent, because neither is
1802/// a per-arch *period*:
1803///
1804/// - `set_swa_pattern(0)` (`deepseek4.cpp:68`, `dflash.cpp:54`) makes
1805/// **every** layer sliding, which is what ferrox already does for a
1806/// declared window with no pattern.
1807/// - `set_swa_pattern(1)` (`phi3.cpp:23`) makes **no** layer sliding,
1808/// and phi3 zeroes `n_swa` and sets `swa_type = NONE` on the same
1809/// branch, so there is no window left to place.
1810///
1811/// Architectures that only ever read a per-layer *array*
1812/// (`get_key_or_arr(..., hparams.is_swa_impl, n_layer)`: `gemma4`,
1813/// `gemma4-assistant`, `step35`, `mimo2`, `dflash`) seed no scalar and
1814/// so have no default to pin.
1815///
1816/// Pinned by `tests/swa_pattern.rs`.
1817/// Architectures where llama.cpp DISABLES sliding-window attention even
1818/// though the checkpoint declares a window.
1819///
1820/// `src/models/phi3.cpp:12-24`: if `attention.sliding_window` is present
1821/// and non-zero, llama.cpp warns, then sets `n_swa = 0`,
1822/// `swa_type = LLAMA_SWA_TYPE_NONE` and `set_swa_pattern(1)` -- i.e. NO
1823/// layer slides. Its own comment says the conversion scripts populate
1824/// the key wrongly and links the PR that turned it off.
1825///
1826/// ferrox read the key and, having no per-architecture period for
1827/// `phi3`, windowed EVERY layer. So a Phi-3 or Phi-4 model attended over
1828/// a truncated history on every layer where llama.cpp attends over the
1829/// whole context. `phi3` is in [`AUDITED_GENERIC_GQA`], and
1830/// `models/Phi-4-mini-instruct-Q4_K_M.gguf` really does declare
1831/// `phi3.attention.sliding_window = 262144` -- so this was live on a
1832/// model in the benchmark suite, not hypothetical.
1833///
1834/// This is deliberately a REFUSAL TO HONOUR the key rather than a
1835/// transcribed period: llama.cpp is not choosing a different window
1836/// here, it is declining to use the one in the file.
1837pub fn swa_disabled_by_arch(arch: &str) -> bool {
1838 matches!(arch, "phi3")
1839}
1840
1841/// Architectures whose FFN gate uses GELU rather than SiLU, i.e. GeGLU
1842/// rather than SwiGLU.
1843///
1844/// llama.cpp picks this PER ARCHITECTURE -- it is the `LLM_FFN_GELU` vs
1845/// `LLM_FFN_SILU` argument each `src/models/*.cpp` passes to `build_ffn`
1846/// / `build_moe_ffn` -- and ferrox picked it per FAMILY, which is not
1847/// the same partition. `grok` is the case that proves it:
1848/// `src/models/grok.cpp:165` passes `LLM_FFN_GELU` to `build_moe_ffn`,
1849/// but `grok` is `DecoderFamily::StandardGqa`, so ferrox handed it
1850/// SwiGLU and would have computed a different FFN on every layer.
1851///
1852/// Latent only because `grok` is not in [`AUDITED_GENERIC_GQA`] and so
1853/// refuses today. It would have become wrong the moment somebody
1854/// audited it, which is the worst possible time to find out.
1855///
1856/// The other `LLM_FFN_GELU` users upstream -- `bert`, `bloom`,
1857/// `codeshell`, `falcon`, `gpt2`, `gptneox`, `mpt`, `phi2`, `starcoder`,
1858/// `starcoder2`, `t5`, `wavtokenizer-dec` -- are all `Deferred` or
1859/// `DedicatedOnly` here, so none reaches the generic path and none is
1860/// listed. The Gemma lineage is GELU too and stays on the family rule,
1861/// because every Gemma row IS `GemmaFamily`.
1862pub fn uses_geglu(arch: &str) -> bool {
1863 matches!(arch, "grok")
1864}
1865
1866pub fn default_swa_layout(arch: &str) -> Option<SwaPattern> {
1867 let last_dense = |period| {
1868 Some(SwaPattern {
1869 period,
1870 dense_first: false,
1871 })
1872 };
1873 let dense_first = |period| {
1874 Some(SwaPattern {
1875 period,
1876 dense_first: true,
1877 })
1878 };
1879 match arch {
1880 // src/models/openai-moe.cpp:9
1881 "gpt-oss" => last_dense(2),
1882 // src/models/gemma2.cpp:6
1883 "gemma2" => last_dense(2),
1884 // src/models/gemma3.cpp:7
1885 "gemma3" => last_dense(6),
1886 // src/models/gemma3n.cpp:4 says 5, NOT 6. This was transcribed
1887 // as 6 alongside gemma3 and is simply wrong. Inert only because
1888 // `gemma3n` refuses for other reasons today.
1889 "gemma3n" => last_dense(5),
1890 // src/models/gemma-embedding.cpp:5. Deferred (embedding scope),
1891 // so latent rather than live.
1892 "gemma-embedding" => last_dense(6),
1893 // src/models/cohere2.cpp:5, exaone4.cpp:7, olmo2.cpp:9
1894 "cohere2" | "exaone4" | "olmo2" => last_dense(4),
1895 // Added after an audit found this table covered 6 architectures
1896 // where llama.cpp hardcodes a period for 17. A MISSING entry is
1897 // not neutral: with no period, every layer gets windowed, so a
1898 // model whose full-attention layers should see the whole context
1899 // sees only a window instead. That is a different model, and it
1900 // fails silently.
1901 //
1902 // src/models/mellum.cpp:11
1903 "mellum" => last_dense(4),
1904 // src/models/exaone-moe.cpp:6. SWA is unconditional there
1905 // with n_swa = 128, so without this every layer ran with a
1906 // 128-token history.
1907 "exaone-moe" => last_dense(4),
1908 // src/models/afmoe.cpp:17. `afmoe` refuses for other reasons
1909 // today, so this one is latent rather than live, and pinned
1910 // here so it stays right if that changes.
1911 "afmoe" => last_dense(4),
1912 // src/models/plamo3.cpp:9. LIVE: `plamo3` is audited, and its
1913 // fixture drives a period of 2 from the file with a window
1914 // narrower than the prompt, so both the period override and
1915 // this phase are exercised end to end against libllama.
1916 "plamo3" => last_dense(8),
1917 // src/models/llama4.cpp:19 ("pattern: 3 chunked - 1 full").
1918 // `llama4` is `DedicatedOnly` today, so latent.
1919 "llama4" => last_dense(4),
1920 // --- dense_first = true -----------------------------------
1921 //
1922 // These four put the full-attention layer at `il % p == 0`, not
1923 // at `il % p == p - 1`. `ModelConfig::layer_sliding_window`
1924 // implements BOTH phases and carries this flag as
1925 // `swa_dense_first`; it used to implement only the first, which
1926 // is why `smallthinker` and `laguna` windowed every layer.
1927 //
1928 // src/models/smallthinker.cpp:9-11. LIVE: `smallthinker` is on
1929 // the generic GQA path.
1930 "smallthinker" => dense_first(4),
1931 // src/models/laguna.cpp:39-41 (its own comment: "XS.2: FULL at
1932 // il%4==0"). LIVE: `laguna` is on the generic GQA path.
1933 "laguna" => dense_first(4),
1934 // src/models/cohere2moe.cpp:31-33. `DedicatedOnly` today
1935 // (parallel attention+FFN residual), so latent.
1936 "cohere2moe" => dense_first(4),
1937 // src/models/modern-bert.cpp:8-10. Deferred (encoder scope), so
1938 // latent.
1939 "modern-bert" => dense_first(3),
1940 _ => None,
1941 }
1942}
1943
1944/// True when this architecture's SWA layers use the model's own RoPE
1945/// base rather than llama.cpp's `rope_freq_base_train_swa` default of
1946/// `10000`.
1947///
1948/// `llama_hparams` defaults that field to `10000.0f`
1949/// (`src/llama-hparams.h:127`) and the Gemma-3 lineage relies on the
1950/// default; the architectures listed here instead open with
1951/// `hparams.rope_freq_base_train_swa = hparams.rope_freq_base_train;`
1952/// before letting `rope.freq_base_swa` override it. ferrox applied the
1953/// Gemma default to everything, which rotates a gpt-oss SWA layer at
1954/// theta 10000 instead of its real 150000.
1955pub fn swa_rope_base_follows_model(arch: &str) -> bool {
1956 matches!(
1957 arch,
1958 "afmoe"
1959 | "cohere2"
1960 | "cohere2moe"
1961 | "dflash"
1962 | "exaone-moe"
1963 | "exaone4"
1964 | "gemma2"
1965 | "laguna"
1966 | "llama4"
1967 | "mellum"
1968 | "olmo2"
1969 | "gpt-oss"
1970 | "smallthinker"
1971 )
1972}
1973
1974/// True when this architecture's SWA layers inherit the model's TRAINED
1975/// RoPE position scale rather than llama.cpp's
1976/// `rope_freq_scale_train_swa` default of `1.0`.
1977///
1978/// The sibling of [`swa_rope_base_follows_model`], and deliberately NOT
1979/// derived from it: llama.cpp defaults both fields
1980/// (`src/llama-hparams.h:127,129`) and each architecture assigns them
1981/// independently, so the two lists differ. `olmo2.cpp:13-14` and
1982/// `laguna.cpp:47-48` seed the BASE from the model and then pin the
1983/// SCALE to `1.0` -- laguna's own comment is "SWA uses plain RoPE (no
1984/// YaRN scaling); do NOT inherit full layers 1/factor". Collapsing the
1985/// two tables into one would rope those two architectures wrong in
1986/// exactly the way this function exists to stop.
1987///
1988/// The default matters more than the list. `gemma3.cpp:11` reads only
1989/// `LLM_KV_ROPE_FREQ_BASE_SWA` and never touches
1990/// `rope_freq_scale_train_swa`, so Gemma-3's sliding layers rope at
1991/// scale `1.0` while its full-attention layers use the trained scale --
1992/// and the converter agrees, writing `rope.scaling.factor` from
1993/// `rope_parameters["full_attention"]` alone (`conversion/base.py:1222`,
1994/// whose own comment is "TODO: Handle sliding_attention similarly when
1995/// models start implementing it").
1996///
1997/// Every name here is a `hparams.rope_freq_scale_train_swa =
1998/// hparams.rope_freq_scale_train;` in `src/models/`, at the line given.
1999pub fn swa_rope_scale_follows_model(arch: &str) -> bool {
2000 matches!(
2001 arch,
2002 "afmoe" // afmoe.cpp:22
2003 | "cohere2" // cohere2.cpp:10
2004 | "cohere2moe" // cohere2moe.cpp:39
2005 | "dflash" // dflash.cpp:59, :71
2006 | "exaone-moe" // exaone-moe.cpp:10
2007 | "exaone4" // exaone4.cpp:12
2008 | "gemma2" // gemma2.cpp:11
2009 | "llama4" // llama4.cpp:24
2010 | "mellum" // mellum.cpp:20
2011 | "gpt-oss" // openai-moe.cpp:14
2012 | "smallthinker" // smallthinker.cpp:14
2013 )
2014}
2015
2016/// llama.cpp's `hparams.f_attention_scale`, but only when it DIFFERS
2017/// from the `1/sqrt(head_dim)` every ferrox attention kernel already
2018/// applies. `None` means "the kernels' own scale is already right", so
2019/// a caller stores it straight into `ModelConfig::attention_scale`.
2020///
2021/// Only the Gemma-2 and Gemma-3 27B checkpoints answer `Some`:
2022///
2023/// ```cpp
2024/// // src/models/gemma3.cpp:30-33 (src/models/gemma2.cpp:26-29 identical in shape)
2025/// hparams.f_attention_scale = type == LLM_TYPE_27B
2026/// ? 1.0f / std::sqrt(float(hparams.n_embd / hparams.n_head(0)))
2027/// : 1.0f / std::sqrt(float(hparams.n_embd_head_k()));
2028/// ```
2029///
2030/// and llama.cpp applies it as an explicit `ggml_scale` on Q followed by
2031/// `build_attn(..., 1.0f)` (`gemma3.cpp:154`, `gemma2.cpp:110`), which is
2032/// what [`crate::config::ModelConfig::attention_scale`] means here.
2033///
2034/// **The selector is the LAYER COUNT, not a comparison of the two
2035/// widths.** `LLM_TYPE_27B` comes from `switch (hparams.n_layer())`
2036/// (`gemma3.cpp:20-28` `case 62`, `gemma2.cpp:19-23` `case 46`), and
2037/// deriving it instead from `n_embd / n_head != head_dim` would be
2038/// wrong for EVERY other Gemma size -- all of them have
2039/// `n_embd / n_head != head_dim` too, and all of them take llama.cpp's
2040/// `1/sqrt(n_embd_head_k)` branch. See
2041/// `gemma_27b_is_the_only_size_that_overrides_the_kernel_scale`.
2042///
2043/// `hidden_dim / n_heads` is integer division on purpose: llama.cpp
2044/// divides two `uint32_t` and only then converts to float.
2045pub fn attention_scale_override(
2046 arch: &str,
2047 n_layers: usize,
2048 hidden_dim: usize,
2049 n_heads: usize,
2050 head_dim: usize,
2051) -> Option<f32> {
2052 // `case 62` / `case 46` in the `switch (hparams.n_layer())` that
2053 // picks `LLM_TYPE_27B`. Every other Gemma architecture
2054 // (`gemma-embedding`, `gemma3n`, `gemma4`) sets `f_attention_scale`
2055 // unconditionally and has no 27B branch at all.
2056 let is_27b = match arch {
2057 "gemma2" => n_layers == 46,
2058 "gemma3" => n_layers == 62,
2059 _ => false,
2060 };
2061 if !is_27b || n_heads == 0 || head_dim == 0 {
2062 return None;
2063 }
2064 let scale = 1.0 / ((hidden_dim / n_heads) as f32).sqrt();
2065 let kernel_scale = 1.0 / (head_dim as f32).sqrt();
2066 (scale != kernel_scale).then_some(scale)
2067}
2068
2069/// Metadata keys that, when present with a nonzero value, require math
2070/// ferrox's generic decoder does not implement *unless* the architecture
2071/// profile opts into those features (Gemma family).
2072pub fn unsupported_feature_keys(arch: &str) -> Vec<(String, &'static str)> {
2073 let profile = resolve_profile(arch);
2074 // Gemma family implements softcap + SWA pattern; others still refuse.
2075 if matches!(profile.map(|p| p.family), Some(DecoderFamily::GemmaFamily)) {
2076 return Vec::new();
2077 }
2078 let key = |suffix: &str| format!("{arch}.{suffix}");
2079 vec![
2080 (
2081 key("attention.logit_softcapping"),
2082 "attention logit soft-capping (Gemma 2+); not implemented in the generic decoder",
2083 ),
2084 // The spelling llama.cpp's converters ACTUALLY write
2085 // (`llama-arch.cpp:213` is `%s.attn_logit_softcapping`). The
2086 // line above is a spelling no converter emits, so this gate has
2087 // never fired for any non-Gemma architecture -- while
2088 // `loader.rs` reads BOTH spellings and applies the value.
2089 //
2090 // A checkpoint declaring an attention softcap was therefore not
2091 // refused; it ran with the generic formula. For `grok` that is a
2092 // wrong answer rather than an approximation: `grok.cpp` folds
2093 // the real attention scale INTO the softcap and passes
2094 // `kq_scale = 1.0f`, which the generic path does not do.
2095 //
2096 // A gate that cannot fire is not a gate, and it looked exactly
2097 // like one.
2098 (
2099 key("attn_logit_softcapping"),
2100 "attention logit soft-capping (Gemma 2+); not implemented in the generic decoder",
2101 ),
2102 (
2103 key("final_logit_softcapping"),
2104 "final logit soft-capping (Gemma 2+); not implemented in the generic decoder",
2105 ),
2106 // `{arch}.attention.sliding_window_pattern` WAS refused here,
2107 // with the reason "not implemented in the generic decoder".
2108 // That reason was false, and had been for some time: the
2109 // alternating pattern lives in `ModelConfig::layer_sliding_window`,
2110 // which implements BOTH phases and which `gpt-oss` -- a
2111 // `StandardGqa` row, not a Gemma one -- has relied on since it
2112 // was audited against libllama.
2113 //
2114 // What the gate really did was make the loader's own read of
2115 // that key (`swa_pattern`) unreachable for every non-Gemma
2116 // architecture: llama.cpp lets the file override the
2117 // architecture's hardcoded period, ferrox refused any file that
2118 // tried. `plamo3` is the case that proves it -- its converter
2119 // writes the key verbatim (`conversion/plamo.py:178`) -- and
2120 // `tests/fixture_away_graphs.rs` now drives a period of 2 out of
2121 // a plamo3 fixture and compares against llama.cpp's own graph on
2122 // all three forward paths, with the phase and the window
2123 // sabotaged separately.
2124 //
2125 // The real gap the key can hide is NOT the pattern: it is that
2126 // llama.cpp accepts the value as a scalar OR an n_layer-long
2127 // ARRAY (`ml.get_key_or_arr`), and ferrox carries one scalar
2128 // period. `loader.rs` refuses an array-valued pattern by name,
2129 // where the value can actually be inspected, instead of
2130 // refusing every file that has the key at all.
2131 ]
2132}
2133
2134/// Scalar multipliers a checkpoint can declare in **metadata** that the
2135/// generic decoder does not apply, with the value that means "no-op".
2136///
2137/// These are the blind spot left by
2138/// [`crate::loader::assert_every_tensor_consumed`]: that gate catches a
2139/// missing *tensor*, but Granite / MiniCPM / Command-R style multipliers
2140/// are hparams, not weights, so a checkpoint carrying them loads
2141/// cleanly, runs at full speed, and computes a graph scaled differently
2142/// from the one the checkpoint was trained as. Nothing says so.
2143///
2144/// llama.cpp key names (`llama-arch.cpp`):
2145/// `%s.logit_scale` (`LLM_KV_LOGIT_SCALE`), `%s.residual_scale`,
2146/// `%s.embedding_scale`, `%s.attention.scale`. Granite reads all four
2147/// (`src/models/granite.cpp::load_arch_hparams`); MiniCPM and
2148/// Command-R/Cohere2 read the subset they use.
2149///
2150/// **This is a refusal, not an implementation.** `residual_scale` in
2151/// particular multiplies the attention and FFN branch outputs before
2152/// every residual add, which in ferrox means every CPU decode/prefill/
2153/// multi-seq path *and* the fused Metal kernels that fold the residual
2154/// in -- landing it half-way would be exactly the silent divergence this
2155/// list exists to stop. Until the math is there, a checkpoint that
2156/// declares one of these is refused by name.
2157///
2158/// The no-op value differs by key: the three `*_scale` multipliers are
2159/// `1.0`, while llama.cpp's `f_attention_scale` uses `0.0` as its
2160/// "unset, use 1/sqrt(head_dim)" sentinel.
2161pub fn unsupported_scaling_keys(arch: &str) -> Vec<(String, &'static str, f32)> {
2162 let profile = resolve_profile(arch);
2163 // Gemma implements its own embedding scale (`loader.rs`
2164 // `embedding_scale`) and its own attention scale
2165 // (`attention_scale_override`, including the 27B branch llama.cpp
2166 // takes at `gemma3.cpp:30-33` / `gemma2.cpp:26-29`). That second
2167 // half was an unimplemented claim until the 27B fix; the exemption
2168 // is only honest while `attention_scale_override` covers it, which
2169 // `gemma_27b_is_the_only_size_that_overrides_the_kernel_scale`
2170 // pins.
2171 if matches!(profile.map(|p| p.family), Some(DecoderFamily::GemmaFamily)) {
2172 return Vec::new();
2173 }
2174 let key = |suffix: &str| format!("{arch}.{suffix}");
2175 vec![
2176 (
2177 key("logit_scale"),
2178 "logit multiplier (Granite / Command-R `logits_scaling`); not applied by the generic decoder",
2179 1.0,
2180 ),
2181 (
2182 key("residual_scale"),
2183 "residual multiplier (Granite `residual_multiplier`); not applied by the generic decoder",
2184 1.0,
2185 ),
2186 (
2187 key("embedding_scale"),
2188 "embedding multiplier (Granite / MiniCPM `embedding_multiplier`); the generic decoder only scales embeddings for the Gemma family",
2189 1.0,
2190 ),
2191 (
2192 key("attention.scale"),
2193 "explicit attention score scale (Granite `attention_multiplier`); the generic decoder always uses 1/sqrt(head_dim)",
2194 0.0,
2195 ),
2196 ]
2197}
2198
2199/// Markdown coverage table for docs / CI drift checks.
2200pub fn coverage_report_markdown() -> String {
2201 let mut lines = vec![
2202 "# Architecture coverage manifest".to_string(),
2203 String::new(),
2204 "Generated from `ferrox_models::capability::architecture_catalog`.".to_string(),
2205 "Source of truth for names: pinned llama.cpp `LLM_ARCH_NAMES`.".to_string(),
2206 String::new(),
2207 "| GGUF arch | Scope | Family | Memory | Path |".to_string(),
2208 "|---|---|---|---|---|".to_string(),
2209 ];
2210 for p in architecture_catalog() {
2211 let path = match p.path {
2212 ArchPath::GenericGqa { .. } => "generic-gqa",
2213 ArchPath::TestFixture { .. } => "test-fixture",
2214 ArchPath::DedicatedOnly { .. } => "dedicated",
2215 ArchPath::Deferred { .. } => "deferred",
2216 };
2217 lines.push(format!(
2218 "| `{}` | {:?} | {:?} | {:?} | {} |",
2219 p.gguf_name, p.scope, p.family, p.memory, path
2220 ));
2221 }
2222 lines.push(String::new());
2223 lines.join("\n")
2224}
2225
2226#[cfg(test)]
2227mod audit_tests {
2228 use super::*;
2229
2230 /// Every audited name must actually be on the generic path.
2231 ///
2232 /// A name here that resolves to a dedicated engine, or to nothing,
2233 /// is a stale entry claiming evidence for a path it does not use.
2234 #[test]
2235 fn every_audited_name_is_actually_on_the_generic_path() {
2236 for name in AUDITED_GENERIC_GQA {
2237 let profile = resolve_profile(name)
2238 .unwrap_or_else(|| panic!("audited arch `{name}` is not in the catalog"));
2239 assert!(
2240 matches!(profile.path, ArchPath::GenericGqa { .. }),
2241 "`{name}` is listed as an audited GENERIC-path arch but resolves to {:?}",
2242 profile.path
2243 );
2244 }
2245 }
2246
2247 /// The five architectures that were caught computing the wrong
2248 /// thing must never appear here.
2249 ///
2250 /// They are refused outright now, but this pins the intent: the
2251 /// audited list is evidence of correctness, and these are the
2252 /// counter-examples that motivated it.
2253 #[test]
2254 fn the_architectures_that_were_wrong_are_not_claimed_as_audited() {
2255 for name in ["gpt2", "mpt", "refact", "bloom", "jais"] {
2256 assert!(
2257 !is_audited_generic(name),
2258 "`{name}` was found computing ALiBi or learned position embeddings as \
2259 though it were RoPE; it cannot be on the audited list"
2260 );
2261 }
2262 }
2263
2264 /// Every unaudited generic-path architecture either carries a
2265 /// triage verdict or is named on [`TRIAGE_PENDING`] -- never both,
2266 /// never neither.
2267 ///
2268 /// This is the anti-drift gate. Adding a new architecture to the
2269 /// generic catalog without either reading it against llama.cpp or
2270 /// admitting on the pending list that nobody has, fails here.
2271 #[test]
2272 fn every_unaudited_generic_architecture_is_triaged_or_listed_as_pending() {
2273 for p in architecture_catalog() {
2274 if !matches!(p.path, ArchPath::GenericGqa { .. }) || is_audited_generic(p.gguf_name) {
2275 continue;
2276 }
2277 let pending = TRIAGE_PENDING.contains(&p.gguf_name);
2278 match (p.triage, pending) {
2279 (Some(_), false) | (None, true) => {}
2280 (Some(t), true) => panic!(
2281 "`{}` carries a {:?} verdict AND is still on TRIAGE_PENDING; remove it \
2282 from the pending list",
2283 p.gguf_name, t.class
2284 ),
2285 (None, false) => panic!(
2286 "`{}` is on the generic path, is not audited, has no triage verdict and \
2287 is not on TRIAGE_PENDING. Read \
2288 .scratch/llama.cpp/src/models/ for it, or say so on the pending list",
2289 p.gguf_name
2290 ),
2291 }
2292 }
2293 }
2294
2295 /// A name on [`TRIAGE_PENDING`] that is not an unaudited generic row
2296 /// is a stale to-do: it would keep claiming work that no longer
2297 /// exists, or point at an architecture the loader never asks about.
2298 #[test]
2299 fn nothing_on_the_pending_list_is_stale() {
2300 for name in TRIAGE_PENDING {
2301 let p = resolve_profile(name)
2302 .unwrap_or_else(|| panic!("TRIAGE_PENDING names `{name}`, not in the catalog"));
2303 assert!(
2304 matches!(p.path, ArchPath::GenericGqa { .. }),
2305 "`{name}` is on TRIAGE_PENDING but resolves to {:?}, which never reaches the \
2306 unaudited refusal",
2307 p.path
2308 );
2309 assert!(
2310 !is_audited_generic(name),
2311 "`{name}` is audited and runs; it does not need a triage verdict"
2312 );
2313 }
2314 // The list is empty because the triage finished, not because it
2315 // was never populated. If a future architecture lands on the
2316 // generic path with no verdict, it belongs here and
2317 // `every_unaudited_generic_architecture_is_triaged_or_listed_as_pending`
2318 // will say so; until then, empty is the completed state.
2319 assert!(
2320 TRIAGE_PENDING.is_empty(),
2321 "TRIAGE_PENDING regrew to {:?}; that is fine, but say so in docs/MODELS.md too",
2322 TRIAGE_PENDING
2323 );
2324 }
2325
2326 /// An audited architecture runs. A triage verdict on one would be a
2327 /// refusal class attached to something that never refuses.
2328 #[test]
2329 fn an_audited_architecture_carries_no_triage_verdict() {
2330 for name in AUDITED_GENERIC_GQA {
2331 assert!(
2332 unaudited_triage(name).is_none(),
2333 "`{name}` is audited and runs, so it must not carry a triage verdict"
2334 );
2335 }
2336 }
2337
2338 /// A verdict has to say something. An empty blocker, or one that
2339 /// cites no llama.cpp source line, is the failure mode this whole
2340 /// item exists to prevent: a refusal that names a blocker nobody
2341 /// checked.
2342 #[test]
2343 fn every_triage_verdict_cites_the_llama_cpp_line_that_decides_it() {
2344 let mut seen = 0;
2345 for p in architecture_catalog() {
2346 let Some(t) = p.triage else { continue };
2347 seen += 1;
2348 assert!(
2349 t.blocker.len() > 80,
2350 "`{}`'s blocker is too short to name anything: {:?}",
2351 p.gguf_name,
2352 t.blocker
2353 );
2354 let cites_llama_cpp =
2355 t.blocker.contains("src/models/") || t.blocker.contains("src/llama-arch.cpp");
2356 assert!(
2357 cites_llama_cpp,
2358 "`{}`'s blocker cites no llama.cpp source: {}",
2359 p.gguf_name, t.blocker
2360 );
2361 if t.class == TriageClass::Unknown {
2362 assert!(
2363 t.blocker.contains("WOULD SETTLE IT"),
2364 "`{}` is UNKNOWN but does not say what would settle it",
2365 p.gguf_name
2366 );
2367 }
2368 }
2369 assert!(
2370 seen == 31,
2371 "every unaudited generic architecture is triaged; found {seen}. \
2372 It was 47 until the triage found `minicpm3` was an MLA model on the \
2373 generic-GQA row and it moved to DedicatedOnly, 46 until five ONE MATCH ARM \
2374 rows -- deepseek, bailingmoe, seed_oss, maincoder, hunyuan-moe -- were admitted \
2375 with libllama-golden fixtures, 41 until seven FIXTURE-AWAY rows -- \
2376 internlm2, xverse, ernie4_5, baichuan, exaone, bailingmoe2, plamo3 -- got \
2377 theirs (tests/fixture_away_graphs.rs), and 34 until `gemma`, `hunyuan-dense` \
2378 and `ernie4_5-moe` got theirs. `gemma` was the LAST fixture-away row, so that \
2379 class is empty now and everything left needs code"
2380 );
2381 }
2382
2383 /// The class reaches the message. Two architectures in different
2384 /// classes must not read the same, which is the defect being fixed.
2385 #[test]
2386 fn the_refusal_detail_distinguishes_the_classes() {
2387 // `chatglm`, not `ernie4_5-moe`: that one was ONE MATCH ARM here
2388 // until its arm landed as a refusal and its step-1 fixture was
2389 // measured against libllama, so it is audited now and renders no
2390 // detail at all. There is deliberately no FIXTURE-AWAY sample:
2391 // `gemma` was the last row in that class and
2392 // `every_unaudited_row_is_triaged_and_the_distribution_is_pinned`
2393 // now pins it at zero. Everything left needs code.
2394 let arm = unaudited_refusal_detail("chatglm");
2395 let new_code = unaudited_refusal_detail("olmo2");
2396 // TRIAGE_PENDING is empty now that all 47 are read, so the
2397 // untriaged branch is exercised through a name the catalog does
2398 // not carry. The branch has to keep working: it is what a NEW
2399 // architecture added to the catalog would render until somebody
2400 // reads it.
2401 let untriaged = unaudited_refusal_detail("an-arch-nobody-has-read");
2402 assert!(arm.contains("ONE MATCH ARM"), "{arm}");
2403 assert!(new_code.contains("NEW CODE"), "{new_code}");
2404 assert!(
2405 untriaged.contains("not done for `an-arch-nobody-has-read` yet"),
2406 "{untriaged}"
2407 );
2408 for a in [&arm, &new_code, &untriaged] {
2409 for b in [&arm, &new_code, &untriaged] {
2410 if !std::ptr::eq(a, b) {
2411 assert_ne!(a, b, "two refusal details are identical");
2412 }
2413 }
2414 }
2415 // The blocker itself, not only the class label, has to be in the
2416 // message -- a class with no specifics is the old refusal with a
2417 // new adjective.
2418 assert!(arm.contains("attn_qkv.bias"), "{arm}");
2419 assert!(new_code.contains("olmo2.cpp:47,52"), "{new_code}");
2420 }
2421
2422 /// An architecture nobody has checked is not audited, which is the
2423 /// whole point of the inversion.
2424 #[test]
2425 fn an_unchecked_architecture_is_not_audited() {
2426 assert!(!is_audited_generic("smallthinker"));
2427 assert!(!is_audited_generic("mellum"));
2428 assert!(!is_audited_generic("an-arch-that-does-not-exist"));
2429 }
2430}
2431
2432#[cfg(test)]
2433mod tests {
2434 use super::*;
2435
2436 #[test]
2437 fn known_mainstream_families_resolve() {
2438 assert_eq!(
2439 resolve_architecture("llama"),
2440 Some(ArchPath::GenericGqa {
2441 rope: RopeLayout::Norm
2442 })
2443 );
2444 assert_eq!(
2445 resolve_architecture("qwen2moe"),
2446 Some(ArchPath::GenericGqa {
2447 rope: RopeLayout::Neox
2448 })
2449 );
2450 assert_eq!(
2451 resolve_architecture("mistral"),
2452 Some(ArchPath::GenericGqa {
2453 rope: RopeLayout::Neox
2454 })
2455 );
2456 assert_eq!(
2457 resolve_architecture("yi"),
2458 Some(ArchPath::GenericGqa {
2459 rope: RopeLayout::Neox
2460 })
2461 );
2462 assert_eq!(
2463 resolve_architecture("mixtral"),
2464 Some(ArchPath::GenericGqa {
2465 rope: RopeLayout::Neox
2466 })
2467 );
2468 assert_eq!(
2469 resolve_architecture("phi3"),
2470 Some(ArchPath::GenericGqa {
2471 rope: RopeLayout::Neox
2472 })
2473 );
2474 assert_eq!(
2475 resolve_architecture("phi4"),
2476 Some(ArchPath::GenericGqa {
2477 rope: RopeLayout::Neox
2478 })
2479 );
2480 assert_eq!(
2481 resolve_profile("phi4").map(|p| p.family),
2482 Some(DecoderFamily::PhiFamily)
2483 );
2484 assert_eq!(
2485 resolve_architecture("gemma3"),
2486 Some(ArchPath::GenericGqa {
2487 rope: RopeLayout::Neox
2488 })
2489 );
2490 for arch in ["gemma4", "gemma4-assistant"] {
2491 assert!(
2492 matches!(
2493 resolve_architecture(arch),
2494 Some(ArchPath::DedicatedOnly { .. })
2495 ),
2496 "{arch} uses dedicated Gemma4 engine"
2497 );
2498 assert_eq!(
2499 resolve_profile(arch).map(|p| p.family),
2500 Some(DecoderFamily::GemmaFamily)
2501 );
2502 }
2503 assert!(matches!(
2504 resolve_architecture("gemma3n"),
2505 Some(ArchPath::DedicatedOnly { .. })
2506 ));
2507 assert_eq!(
2508 resolve_architecture("deepseek"),
2509 Some(ArchPath::GenericGqa {
2510 rope: RopeLayout::Norm
2511 })
2512 );
2513 assert_eq!(
2514 resolve_profile("qwen3").map(|p| p.qk_norm),
2515 Some(QkNormStyle::PerHead)
2516 );
2517 }
2518
2519 #[test]
2520 fn deepseek2_is_dedicated_mla_not_generic() {
2521 assert!(matches!(
2522 resolve_architecture("deepseek2"),
2523 Some(ArchPath::DedicatedOnly { .. })
2524 ));
2525 }
2526
2527 #[test]
2528 fn unknown_architecture_is_none() {
2529 assert_eq!(resolve_architecture("totally-unknown-arch"), None);
2530 // t5 is registered as dedicated encoder-decoder stub
2531 assert!(matches!(
2532 resolve_architecture("t5"),
2533 Some(ArchPath::DedicatedOnly { .. })
2534 ));
2535 }
2536
2537 #[test]
2538 fn dedicated_paths_are_not_generic() {
2539 assert!(matches!(
2540 resolve_architecture("glm-dsa"),
2541 Some(ArchPath::DedicatedOnly { .. })
2542 ));
2543 assert!(matches!(
2544 resolve_architecture("deepseek4"),
2545 Some(ArchPath::DedicatedOnly { .. })
2546 ));
2547 for arch in ["minimax-m2", "minimax-m3"] {
2548 assert!(
2549 matches!(
2550 resolve_architecture(arch),
2551 Some(ArchPath::DedicatedOnly { .. })
2552 ),
2553 "{arch} must fail closed, not silent generic GQA"
2554 );
2555 }
2556 assert!(
2557 matches!(
2558 resolve_architecture("llama4"),
2559 Some(ArchPath::DedicatedOnly {
2560 reason: "llama4 MoE + non-GQA attn -- see llama4_engine.rs tensor list"
2561 })
2562 ),
2563 "llama4 must fail closed, not silent generic GQA"
2564 );
2565 assert!(matches!(
2566 resolve_architecture("glm4"),
2567 Some(ArchPath::DedicatedOnly { .. })
2568 ));
2569 assert!(matches!(
2570 resolve_architecture("glm4moe"),
2571 Some(ArchPath::DedicatedOnly { .. })
2572 ));
2573 }
2574
2575 #[test]
2576 fn test_fixtures_remain_loadable() {
2577 for arch in ["ferroxtest", "ferroxtestmoe", "ferroxtestmixed"] {
2578 assert!(matches!(
2579 resolve_architecture(arch),
2580 Some(ArchPath::TestFixture { .. })
2581 ));
2582 }
2583 }
2584
2585 #[test]
2586 fn catalog_has_unique_names() {
2587 let mut seen = std::collections::HashSet::new();
2588 for p in architecture_catalog() {
2589 assert!(
2590 seen.insert(p.gguf_name),
2591 "duplicate arch name {}",
2592 p.gguf_name
2593 );
2594 }
2595 }
2596
2597 #[test]
2598 fn gemma_family_does_not_fail_closed_on_softcap_keys() {
2599 assert!(unsupported_feature_keys("gemma3").is_empty());
2600 assert!(!unsupported_feature_keys("llama").is_empty());
2601 }
2602
2603 /// Parallel attention+FFN residual is not a tensor and, for MiniCPM,
2604 /// not even a metadata key -- llama.cpp hardcodes MiniCPM's three
2605 /// multipliers. Neither the tensor-consumption gate nor
2606 /// `unsupported_scaling_keys` can see the difference, so these
2607 /// architectures must not be admitted to the generic decoder at all.
2608 #[test]
2609 fn architectures_with_a_different_residual_topology_are_refused() {
2610 for arch in [
2611 "command-r",
2612 "cohere2",
2613 "cohere2moe",
2614 "falcon",
2615 "gptneox",
2616 "phi2",
2617 "plamo",
2618 "minicpm",
2619 ] {
2620 match resolve_architecture(arch) {
2621 Some(ArchPath::DedicatedOnly { reason }) => {
2622 assert!(!reason.is_empty(), "{arch} must say why");
2623 }
2624 other => panic!("{arch} must be refused, got {other:?}"),
2625 }
2626 }
2627 // The sequential-residual siblings stay on the generic path --
2628 // this is a named list, not a family-wide ban.
2629 //
2630 // `phimoe`, `starcoder2` and `nemotron` used to be checked here
2631 // too. They left the generic path for an unrelated reason (the
2632 // required bias tensors pinned by `tests/attn_bias.rs`), so
2633 // asserting them generic would now assert the wrong thing; what
2634 // still has to hold is that neither they nor the archs below are
2635 // refused for a *residual* reason they do not have.
2636 for arch in ["phi3", "plamo3", "qwen2", "llama"] {
2637 assert!(
2638 matches!(
2639 resolve_architecture(arch),
2640 Some(ArchPath::GenericGqa { .. })
2641 ),
2642 "{arch} must stay generic"
2643 );
2644 }
2645 for arch in ["phimoe", "starcoder2", "nemotron"] {
2646 match resolve_architecture(arch) {
2647 Some(ArchPath::DedicatedOnly { reason }) => assert!(
2648 reason.contains("bias"),
2649 "{arch} is refused for the wrong reason: {reason}"
2650 ),
2651 other => panic!("{arch} must be refused for its biases, got {other:?}"),
2652 }
2653 }
2654 }
2655
2656 /// Every architecture appears exactly once, so a refusal added next
2657 /// to an existing entry cannot be shadowed by whichever the lookup
2658 /// happens to find first.
2659 #[test]
2660 fn no_architecture_is_listed_twice() {
2661 let mut seen = std::collections::HashSet::new();
2662 for p in architecture_catalog() {
2663 assert!(seen.insert(p.gguf_name), "{} listed twice", p.gguf_name);
2664 }
2665 }
2666
2667 /// Every key this gate refuses must be a key a converter actually
2668 /// writes, or the gate cannot fire.
2669 ///
2670 /// `unsupported_feature_keys` listed `{arch}.attention.logit_softcapping`.
2671 /// llama.cpp writes `{arch}.attn_logit_softcapping`
2672 /// (`llama-arch.cpp:213`), and no converter emits the first
2673 /// spelling -- so that arm never matched anything, for any non-Gemma
2674 /// architecture, ever. Meanwhile `loader.rs` reads BOTH spellings,
2675 /// so the value was read and applied with the generic formula
2676 /// instead of being refused. For `grok` that is a wrong answer:
2677 /// `grok.cpp` folds the real attention scale into the softcap and
2678 /// passes `kq_scale = 1.0f`.
2679 ///
2680 /// A gate that cannot fire is worse than a missing gate, because it
2681 /// reads as coverage.
2682 #[test]
2683 fn every_refused_key_is_one_a_converter_actually_writes() {
2684 let keys: Vec<String> = unsupported_feature_keys("llama")
2685 .into_iter()
2686 .map(|(k, _)| k)
2687 .collect();
2688
2689 // Transcribed from `llama-arch.cpp`'s LLM_KV_NAMES.
2690 // `llama.attention.sliding_window_pattern` was on this list and
2691 // is deliberately off it: the alternating pattern IS
2692 // implemented (`ModelConfig::layer_sliding_window`, both
2693 // phases), so refusing it was a gate with a false reason that
2694 // also made the loader's own read of the key unreachable. See
2695 // the comment where it used to be. The array-valued case, which
2696 // ferrox genuinely cannot express, is refused in `loader.rs`
2697 // where the value can be inspected.
2698 for real in [
2699 "llama.attn_logit_softcapping",
2700 "llama.final_logit_softcapping",
2701 ] {
2702 assert!(
2703 keys.iter().any(|k| k == real),
2704 "{real} is a key llama.cpp writes and this gate must refuse it; \
2705 gate currently holds {keys:?}"
2706 );
2707 }
2708
2709 // Gemma implements all three, so it must still be exempt --
2710 // otherwise "fix the spelling" would have turned into "refuse
2711 // every Gemma checkpoint".
2712 assert!(
2713 unsupported_feature_keys("gemma2").is_empty(),
2714 "the Gemma family implements softcap and the SWA pattern"
2715 );
2716 // And the pattern key must not come back: a file carrying it
2717 // gets its period READ, which is what llama.cpp does.
2718 assert!(
2719 !keys.iter().any(|k| k.ends_with("sliding_window_pattern")),
2720 "the SWA pattern is implemented; refusing it makes the loader's read of the \
2721 key dead code: {keys:?}"
2722 );
2723 }
2724
2725 /// llama.cpp picks Gemma's `f_attention_scale` on the LAYER COUNT
2726 /// (`gemma3.cpp:20-33`, `gemma2.cpp:19-29`), and every published
2727 /// Gemma size -- not just 27B -- has `n_embd / n_head != head_dim`.
2728 /// An override derived from "the two widths disagree" would fire on
2729 /// all eight rows below and mis-scale six of them, which is why this
2730 /// walks the real sizes rather than asserting the 27B number alone.
2731 ///
2732 /// Shipped broken: `loader.rs` hardcoded `attention_scale = None`
2733 /// beside a comment naming the 27B exception, so Gemma-2-27B scored
2734 /// `sqrt(144/128)` and Gemma-3-27B `sqrt(168/128)` too large on
2735 /// every layer -- a sharper softmax than the trained one, with no
2736 /// error.
2737 #[test]
2738 fn gemma_27b_is_the_only_size_that_overrides_the_kernel_scale() {
2739 /// One published Gemma size, as its GGUF header declares it.
2740 struct Size {
2741 arch: &'static str,
2742 n_layers: usize,
2743 n_embd: usize,
2744 n_head: usize,
2745 /// `attention.key_length`, llama.cpp's `n_embd_head_k()`.
2746 head_dim: usize,
2747 /// The denominator llama.cpp's 27B branch produces, or
2748 /// `None` where it takes the `1/sqrt(n_embd_head_k)` branch.
2749 want_denom: Option<f32>,
2750 }
2751 let size = |arch, n_layers, n_embd, n_head, head_dim, want_denom| Size {
2752 arch,
2753 n_layers,
2754 n_embd,
2755 n_head,
2756 head_dim,
2757 want_denom,
2758 };
2759 let sizes = [
2760 size("gemma2", 26, 2304, 8, 256, None), // Gemma-2-2B
2761 size("gemma2", 42, 3584, 16, 256, None), // Gemma-2-9B
2762 size("gemma2", 46, 4608, 32, 128, Some(144.0)), // Gemma-2-27B
2763 size("gemma3", 18, 640, 4, 256, None), // Gemma-3-270M
2764 size("gemma3", 26, 1152, 4, 256, None), // Gemma-3-1B
2765 size("gemma3", 34, 2560, 8, 256, None), // Gemma-3-4B
2766 size("gemma3", 48, 3840, 16, 256, None), // Gemma-3-12B
2767 size("gemma3", 62, 5376, 32, 128, Some(168.0)), // Gemma-3-27B
2768 ];
2769 for &Size {
2770 arch,
2771 n_layers,
2772 n_embd,
2773 n_head,
2774 head_dim,
2775 want_denom,
2776 } in &sizes
2777 {
2778 // The premise of the whole test: no Gemma size has
2779 // `n_embd / n_head == head_dim`, so "the widths disagree"
2780 // cannot be the selector.
2781 assert_ne!(
2782 n_embd / n_head,
2783 head_dim,
2784 "{arch}/{n_layers}L: if this ever holds, re-read the derivation"
2785 );
2786 let got = attention_scale_override(arch, n_layers, n_embd, n_head, head_dim);
2787 match want_denom {
2788 None => assert_eq!(
2789 got, None,
2790 "{arch}/{n_layers}L takes llama.cpp's 1/sqrt(n_embd_head_k) branch, \
2791 which the attention kernels already apply"
2792 ),
2793 Some(denom) => {
2794 let want = 1.0 / denom.sqrt();
2795 let got = got.unwrap_or_else(|| {
2796 panic!("{arch}/{n_layers}L is llama.cpp's LLM_TYPE_27B; scale must be set")
2797 });
2798 assert!(
2799 (got - want).abs() < 1e-7,
2800 "{arch}/{n_layers}L: want 1/sqrt({denom}) = {want}, got {got}"
2801 );
2802 // The direction of the correction: the kernels' own
2803 // scale is the LARGER one, so the override shrinks
2804 // the scores rather than growing them.
2805 let kernel = 1.0f32 / (head_dim as f32).sqrt();
2806 assert!(
2807 kernel > got,
2808 "{arch}/{n_layers}L: kernel scale {kernel} must exceed {got}"
2809 );
2810 }
2811 }
2812 }
2813 // `gemma-embedding`, `gemma3n` and `gemma4` set
2814 // `f_attention_scale` unconditionally in llama.cpp and have no
2815 // `LLM_TYPE_27B` branch; nothing outside gemma2/gemma3 reaches
2816 // this at all.
2817 for arch in ["gemma-embedding", "gemma3n", "gemma4", "llama", "qwen3"] {
2818 assert_eq!(
2819 attention_scale_override(arch, 62, 5376, 32, 128),
2820 None,
2821 "{arch} has no LLM_TYPE_27B branch in llama.cpp"
2822 );
2823 }
2824 }
2825}