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/// Architectures on the shared generic-GQA path that somebody has
83/// actually PROVEN, and the evidence for each.
84///
85/// The generic path is a guess: it assumes an architecture is plain GQA
86/// because nothing said otherwise. That guess has already been wrong
87/// five times. `gpt2`, `mpt`, `refact`, `bloom` and `jais` all sat here
88/// computing ALiBi or learned absolute position embeddings as though
89/// they were NEOX RoPE, and every downstream guard missed them: two
90/// hardcode their ALiBi slope with no GGUF key, one leaves no unread
91/// tensor, and the RoPE pin excluded their group by construction.
92///
93/// So membership here is not "we think this works", it is "there is a
94/// benchmark row, a pinned logit comparison against llama.cpp, or a
95/// fixture". Everything else on the generic path is UNAUDITED and says
96/// so at load time rather than running and hoping.
97///
98/// Adding a name here without evidence defeats the entire point.
99pub const AUDITED_GENERIC_GQA: &[&str] = &[
100 // Bench rows in benchmarks/suite.json, measured against llama.cpp
101 // on the same host and file.
102 "llama", // TinyLlama, Mistral, Mixtral, SmolLM2, Llama-3.x all tag llama
103 "qwen2", // Qwen2.5-0.5B
104 "qwen2moe", // Qwen1.5-MoE-A2.7B
105 "qwen3", // Qwen3-0.6B
106 "olmoe", // OLMoE-1B-7B
107 "gemma2", // Gemma-2-2B
108 "gemma3", // Gemma-3-1B
109 "phi3", // Phi-4-mini tags phi3
110 // Pinned against real libllama logits in tests/.
111 "gpt-oss", "dots1",
112];
113
114/// Is this architecture's use of the shared generic path backed by
115/// evidence?
116pub fn is_audited_generic(arch: &str) -> bool {
117 AUDITED_GENERIC_GQA.contains(&arch)
118}
119
120/// How the generic `Decoder` / `ModelConfig::from_gguf` path treats a
121/// GGUF architecture string.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum ArchPath {
124 /// Standard GQA (+ optional MoE) decoder; RoPE layout is known.
125 GenericGqa { rope: RopeLayout },
126 /// In-repo test fixtures (`ferroxtest*`) — not a real model family.
127 TestFixture { rope: RopeLayout },
128 /// Real architecture, but must not be loaded through the generic
129 /// GQA decoder (wrong attention / residual math).
130 DedicatedOnly { reason: &'static str },
131 /// In the llama.cpp inventory but out of Ferrox scope for now.
132 Deferred { reason: &'static str },
133}
134
135/// Load-time resolved profile for one GGUF `general.architecture` string.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub struct ArchProfile {
138 pub gguf_name: &'static str,
139 pub scope: ArchScope,
140 pub family: DecoderFamily,
141 pub memory: MemoryKind,
142 pub rope: RopeLayout,
143 pub path: ArchPath,
144 /// Default QK-norm style when norm tensors are present; loader may
145 /// refine from tensor length.
146 pub qk_norm: QkNormStyle,
147}
148
149fn prof(
150 name: &'static str,
151 scope: ArchScope,
152 fam: DecoderFamily,
153 mem: MemoryKind,
154 rope: RopeLayout,
155 path: ArchPath,
156 qk: QkNormStyle,
157) -> ArchProfile {
158 ArchProfile {
159 gguf_name: name,
160 scope,
161 family: fam,
162 memory: mem,
163 rope,
164 path,
165 qk_norm: qk,
166 }
167}
168
169fn gqa_norm(name: &'static str) -> ArchProfile {
170 prof(
171 name,
172 ArchScope::TextGeneration,
173 DecoderFamily::StandardGqa,
174 MemoryKind::KvGqa,
175 RopeLayout::Norm,
176 ArchPath::GenericGqa {
177 rope: RopeLayout::Norm,
178 },
179 QkNormStyle::WholeVector,
180 )
181}
182
183fn gqa_neox(name: &'static str) -> ArchProfile {
184 prof(
185 name,
186 ArchScope::TextGeneration,
187 DecoderFamily::StandardGqa,
188 MemoryKind::KvGqa,
189 RopeLayout::Neox,
190 ArchPath::GenericGqa {
191 rope: RopeLayout::Neox,
192 },
193 QkNormStyle::WholeVector,
194 )
195}
196
197fn dedicated(name: &'static str, reason: &'static str) -> ArchProfile {
198 prof(
199 name,
200 ArchScope::TextGeneration,
201 DecoderFamily::Dedicated,
202 MemoryKind::KvGqa,
203 RopeLayout::Norm,
204 ArchPath::DedicatedOnly { reason },
205 QkNormStyle::WholeVector,
206 )
207}
208
209fn deferred_scope(name: &'static str, scope: ArchScope, reason: &'static str) -> ArchProfile {
210 prof(
211 name,
212 scope,
213 DecoderFamily::StandardGqa,
214 MemoryKind::None,
215 RopeLayout::Neox,
216 ArchPath::Deferred { reason },
217 QkNormStyle::WholeVector,
218 )
219}
220
221/// Full inventory keyed by GGUF `general.architecture` string.
222/// Kept in sync with `.scratch/llama.cpp/src/llama-arch.cpp` `LLM_ARCH_NAMES`.
223pub fn architecture_catalog() -> &'static [ArchProfile] {
224 use std::sync::OnceLock;
225 use ArchScope::*;
226 use DecoderFamily::*;
227 use MemoryKind::*;
228 use QkNormStyle::*;
229 use RopeLayout::*;
230
231 static CAT: OnceLock<Vec<ArchProfile>> = OnceLock::new();
232 CAT.get_or_init(|| {
233 let mut v = Vec::with_capacity(160);
234 // --- Verified / standard GQA (Norm RoPE) ---
235 for n in [
236 "llama",
237 "deci",
238 "baichuan",
239 "starcoder",
240 "internlm2",
241 "xverse",
242 "olmo",
243 "arctic",
244 "deepseek",
245 "chatglm",
246 "granite",
247 "granitemoe",
248 "granite-moe",
249 "mistral3",
250 "maincoder",
251 "arcee",
252 "ernie4_5",
253 "ernie4_5-moe",
254 "bailingmoe",
255 "nanbeige",
256 "plm",
257 ] {
258 v.push(gqa_norm(n));
259 }
260 for n in [
261 "olmoe", "qwen", "qwen2", "qwen2moe", "stablelm", "mistral",
262 "mixtral", "olmo2", "bitnet", "jais2",
263 "grok", "dbrx", "exaone4", "yi",
264 // llama-model.cpp `llama_model_rope_type`: LLM_ARCH_OPENAI_MOE
265 // falls in the `return LLAMA_ROPE_TYPE_NEOX` group, and a live
266 // load of a gpt-oss GGUF prints `rope type = 2` (= NEOX).
267 // ferrox had it on the interleaved (NORM) list, which rotates
268 // the wrong pairs of every Q/K head.
269 "gpt-oss",
270 // Same audit, run over every arch at once against
271 // `llama_model_rope_type`'s NEOX group
272 // (llama-model.cpp:2613-2683). These 24 were on ferrox's
273 // interleaved (NORM) list and reach the generic GQA decoder,
274 // so every one of them rotated the wrong pairs of every Q/K
275 // head and answered fluently and wrongly. Pinned by
276 // `rope_layout_matches_llama_cpp` below; dots1 additionally
277 // checked end-to-end against llama.cpp's own logits in
278 // `tests/moe_routing_bias.rs`.
279 "afmoe",
280 "apertus",
281 "bailingmoe2",
282 "codeshell",
283 "dots1",
284 "exaone",
285 "exaone-moe",
286 "grovemoe",
287 "hunyuan-dense",
288 "hunyuan-moe",
289 "laguna",
290 "mellum",
291 "mimo2",
292 "minicpm3",
293 "nemotron",
294 "openelm",
295 "orion",
296 "plamo3",
297 "seed_oss",
298 "smallthinker",
299 "starcoder2",
300 "step35",
301 "talkie",
302 ] {
303 v.push(gqa_neox(n));
304 }
305 // --- No RoPE at all: refused, not rotated ------------------
306 //
307 // `llama_model_rope_type` opens with a `LLAMA_ROPE_TYPE_NONE`
308 // group, and these five sat on ferrox's NEOX list instead. The
309 // generic decoder rotates every Q/K head of every layer, so each
310 // of them loaded, ran at full speed, and answered fluently from
311 // positions the checkpoint never encodes that way, the same
312 // silent failure the 24-arch RoPE audit found, one level worse,
313 // because here the right answer is *no rotation*.
314 //
315 // Worse still for a metadata gate: `bloom` and `refact` hardcode
316 // `f_max_alibi_bias = 8.0f` in `load_arch_hparams` and carry no
317 // key at all, so `unsupported_feature_keys` could never have seen
318 // them. Only the registry can. `tests/rope_layout.rs`'s
319 // `LLAMA_NO_ROPE` pins the group so a later edit cannot quietly
320 // put one back on a rotating path.
321 for (n, reason) in [
322 (
323 "smollm3",
324 "a NoPE layer pattern: llama.cpp hardcodes \
325 `hparams.n_no_rope_layer_step = 4` (src/models/smollm3.cpp:5) and \
326 skips RoPE where `(il + 1) % 4 == 0` (:69), so 9 of a 36-layer \
327 SmolLM3-3B's layers get NO rotation at all. There is NO GGUF key \
328 for it, so no metadata gate could see it: the tensor set matches \
329 the generic llama set exactly and the file loads clean. The \
330 generic decoder rotates every layer, which is a different model. \
331 Same shape as the ALiBi group below, and found the same way",
332 ),
333 (
334 "gpt2",
335 "learned absolute position embeddings (`position_embd.weight`, \
336 src/models/gpt2.cpp:19,74) and no RoPE; the generic decoder has no \
337 slot for them and rotates instead",
338 ),
339 (
340 "mpt",
341 "ALiBi attention bias (src/models/mpt.cpp:6), plus an optional \
342 learned `position_embd` and an optional QKV clamp; the generic \
343 decoder implements none of the three and applies RoPE instead",
344 ),
345 (
346 "refact",
347 "ALiBi attention bias, hardcoded `f_max_alibi_bias = 8.0f` with no \
348 GGUF key to detect it (src/models/refact.cpp:12); the generic \
349 decoder applies RoPE instead",
350 ),
351 (
352 "bloom",
353 "ALiBi attention bias, hardcoded `f_max_alibi_bias = 8.0f` with no \
354 GGUF key (src/models/bloom.cpp:18), plus a `token_embd_norm` the \
355 generic decoder never applies; RoPE is applied instead",
356 ),
357 (
358 "jais",
359 "ALiBi attention bias (src/models/jais.cpp:5); the generic decoder \
360 applies RoPE instead",
361 ),
362 ] {
363 v.push(prof(
364 n,
365 TextGeneration,
366 StandardGqa,
367 KvGqa,
368 // No layout is right here. `Norm` is the struct's least
369 // surprising filler and nothing reads it: the load
370 // refuses in `ModelConfig::from_gguf` before any graph
371 // asks. `rope_layout_matches_llama_cpp` skips
372 // non-generic paths for exactly this reason.
373 Norm,
374 ArchPath::DedicatedOnly { reason },
375 WholeVector,
376 ));
377 }
378 v.push(prof(
379 "qwen3",
380 TextGeneration,
381 Qwen3Family,
382 KvGqa,
383 Neox,
384 ArchPath::GenericGqa { rope: Neox },
385 PerHead,
386 ));
387 v.push(prof(
388 "qwen3moe",
389 TextGeneration,
390 Qwen3Family,
391 KvGqa,
392 Neox,
393 ArchPath::GenericGqa { rope: Neox },
394 PerHead,
395 ));
396 v.push(prof(
397 "gemma",
398 TextGeneration,
399 GemmaFamily,
400 KvGqa,
401 Neox,
402 ArchPath::GenericGqa { rope: Neox },
403 PerHead,
404 ));
405 v.push(prof(
406 "gemma2",
407 TextGeneration,
408 GemmaFamily,
409 KvIswa,
410 Neox,
411 ArchPath::GenericGqa { rope: Neox },
412 PerHead,
413 ));
414 v.push(prof(
415 "gemma3",
416 TextGeneration,
417 GemmaFamily,
418 KvIswa,
419 Neox,
420 ArchPath::GenericGqa { rope: Neox },
421 PerHead,
422 ));
423 // Gemma-4 text GGUFs (E2B): per-layer embeddings, shared-KV
424 // layers, and split SWA/full head dims — dedicated
425 // [`crate::gemma4_engine::Gemma4Engine`] (not GenericGqa).
426 for n in ["gemma4", "gemma4-assistant"] {
427 v.push(prof(
428 n,
429 TextGeneration,
430 GemmaFamily,
431 KvIswa,
432 Neox,
433 ArchPath::DedicatedOnly {
434 reason: "use load_gemma4_engine_from_path / ServedEngine::Gemma4",
435 },
436 PerHead,
437 ));
438 }
439 // Refused, not implemented: the generic decoder computes
440 // `x + attn(norm(x))` then `y + ffn(norm(y))`, and every arch
441 // here computes something else that no tensor and (for MiniCPM)
442 // no metadata key makes visible. See
443 // `unsupported_scaling_keys` for the metadata-visible half of
444 // the same class.
445 const PARALLEL_RESIDUAL: &str =
446 "parallel attention+FFN residual -- llama.cpp feeds both branches the *same* \
447 normed input and sums `inpL + attn_out + ffn_out` once; the generic decoder \
448 computes the sequential form, which is a different graph";
449 for (n, rope, fam) in [
450 // src/models/cohere2.cpp:120-134, cohere2moe.cpp:222-266,
451 // command-r.cpp:106-119. All three also carry a
452 // `logit_scale` the generic decoder does not apply.
453 ("command-r", Norm, StandardGqa),
454 ("cohere2", Norm, StandardGqa),
455 ("cohere2moe", Norm, StandardGqa),
456 // src/models/falcon.cpp:121-135 (and an `attn_norm_2` the
457 // generic decoder has no slot for).
458 ("falcon", Neox, StandardGqa),
459 // src/models/gptneox.cpp:147-195 -- parallel or sequential
460 // per `use_par_res`, and the generic decoder implements
461 // neither branch of that choice.
462 ("gptneox", Neox, StandardGqa),
463 // src/models/phi2.cpp:116-117, plamo.cpp:97-112.
464 ("phi2", Neox, PhiFamily),
465 ("plamo", Neox, StandardGqa),
466 ] {
467 v.push(prof(
468 n,
469 TextGeneration,
470 fam,
471 KvGqa,
472 rope,
473 ArchPath::DedicatedOnly {
474 reason: PARALLEL_RESIDUAL,
475 },
476 WholeVector,
477 ));
478 }
479 // MiniCPM is the case `unsupported_scaling_keys` cannot catch:
480 // `src/models/minicpm.cpp:4-14` *hardcodes* an embedding
481 // multiplier of 12.0, a residual multiplier of
482 // `1.4/sqrt(n_layer)` and a logit multiplier of `256/n_embd`,
483 // and only then lets the GGUF override them. An older MiniCPM
484 // export carrying none of the three keys is still scaled by all
485 // three, so a key-presence gate sees nothing and the generic
486 // decoder computes an unscaled graph.
487 v.push(prof(
488 "minicpm",
489 TextGeneration,
490 StandardGqa,
491 KvGqa,
492 Norm,
493 ArchPath::DedicatedOnly {
494 reason: "unconditional embedding/residual/logit multipliers that llama.cpp \
495 applies even when the GGUF omits every key; not applied by the \
496 generic decoder",
497 },
498 WholeVector,
499 ));
500 for (n, fam) in [("phi3", PhiFamily), ("phimoe", PhiFamily)] {
501 v.push(prof(
502 n,
503 TextGeneration,
504 fam,
505 KvGqa,
506 Neox,
507 ArchPath::GenericGqa { rope: Neox },
508 WholeVector,
509 ));
510 }
511 // Phi-4 GGUFs share the phi3 fused-QKV / fused gate+up graph
512 // (PhiFamily). Many community checkpoints still tag `phi3`; admit
513 // `phi4` the same way so either string can load. Receipts / head-dim
514 // FA-vec coverage remain P6 evidence work — not a speed claim.
515 v.push(prof(
516 "phi4",
517 TextGeneration,
518 PhiFamily,
519 KvGqa,
520 Neox,
521 ArchPath::GenericGqa { rope: Neox },
522 WholeVector,
523 ));
524 // Llama 4: MoE + interleaved / non-generic attention graph — not
525 // safe to admit as GenericGqa (was wrongly listed with plain llama).
526 v.push(prof(
527 "llama4",
528 TextGeneration,
529 Dedicated,
530 KvGqa,
531 Norm,
532 ArchPath::DedicatedOnly {
533 reason: "llama4 MoE + non-GQA attn — see llama4_engine.rs tensor list",
534 },
535 WholeVector,
536 ));
537 // MiniMax M2/M3: 256-expert sigmoid MoE + MTP — not generic GQA.
538 for n in ["minimax-m2", "minimax-m3"] {
539 v.push(prof(
540 n,
541 TextGeneration,
542 Dedicated,
543 KvGqa,
544 // llama-model.cpp puts both in the NEOX group. Inert
545 // today (minimax_engine.rs carries its own RoPE and
546 // never reads this field), but the table advertises
547 // itself as a mirror of llama.cpp's, so it says NEOX.
548 Neox,
549 ArchPath::DedicatedOnly {
550 reason: "MiniMax 256-expert sigmoid MoE + MTP — see minimax_engine.rs",
551 },
552 WholeVector,
553 ));
554 }
555 v.push(prof(
556 "deepseek2",
557 TextGeneration,
558 Mla,
559 KvMla,
560 Norm,
561 ArchPath::DedicatedOnly {
562 reason: "DeepSeek-2 MLA needs the MLA engine, not generic GQA",
563 },
564 WholeVector,
565 ));
566 v.push(prof(
567 "deepseek32",
568 TextGeneration,
569 Mla,
570 KvDsa,
571 Norm,
572 ArchPath::DedicatedOnly {
573 reason: "DeepSeek-3.2 DSA/MLA needs the dedicated sparse/MLA stack",
574 },
575 WholeVector,
576 ));
577 v.push(prof(
578 "mistral4",
579 TextGeneration,
580 Mla,
581 KvMla,
582 Norm,
583 ArchPath::DedicatedOnly {
584 reason: "mistral4 reuses DeepSeek-2 MLA loader/graph in llama.cpp",
585 },
586 WholeVector,
587 ));
588 v.push(dedicated(
589 "glm-dsa",
590 "use ferrox_models::glm52_decoder / glm52_gguf_loader (DSA), not the generic GQA Decoder",
591 ));
592 v.push(dedicated(
593 "glm4",
594 "use ferrox_models::glm52_decoder / glm52_gguf_loader, not the generic GQA Decoder",
595 ));
596 v.push(dedicated(
597 "glm4moe",
598 "use ferrox_models::glm52_decoder / glm52_gguf_loader, not the generic GQA Decoder",
599 ));
600 v.push(dedicated(
601 "deepseek4",
602 "DeepSeek V4 needs CSA/HCA + mHC assembly; generic GQA Decoder is not valid",
603 ));
604 v.push(dedicated(
605 "kimi-linear",
606 "use ferrox_models::kimi_decoder / kimi_loader, not the generic GQA Decoder",
607 ));
608 v.push(dedicated(
609 "kimi_k3",
610 "use ferrox_models::kimi_decoder / kimi_loader, not the generic GQA Decoder",
611 ));
612 for (n, rope) in [
613 ("jamba", Neox),
614 ("falcon-h1", Neox),
615 ("plamo2", Neox),
616 ("granitehybrid", Norm),
617 ("granite-hybrid", Norm),
618 ("lfm2", Neox),
619 ("lfm2moe", Neox),
620 ("nemotron_h", Neox),
621 ("nemotron_h_moe", Neox),
622 ("qwen3next", Neox),
623 ("qwen35", Neox),
624 ("qwen35moe", Neox),
625 ] {
626 let qk = if n.starts_with("qwen3") {
627 PerHead
628 } else {
629 WholeVector
630 };
631 v.push(prof(
632 n,
633 TextGeneration,
634 DecoderFamily::Hybrid,
635 MemoryKind::Hybrid,
636 rope,
637 ArchPath::DedicatedOnly {
638 reason: "hybrid attn+SSM/delta-net engine not yet on the serve path",
639 },
640 qk,
641 ));
642 }
643 for n in ["mamba", "mamba2", "rwkv6", "rwkv6qwen2", "rwkv7", "arwkv7"] {
644 v.push(prof(
645 n,
646 TextGeneration,
647 DecoderFamily::Recurrent,
648 MemoryKind::Recurrent,
649 Neox,
650 ArchPath::DedicatedOnly {
651 reason: "recurrent engine not yet on the serve path",
652 },
653 WholeVector,
654 ));
655 }
656 v.push(prof(
657 "t5",
658 TextGeneration,
659 EncoderDecoder,
660 None,
661 Neox,
662 ArchPath::DedicatedOnly {
663 reason: "T5 encoder-decoder engine not yet on the serve path",
664 },
665 WholeVector,
666 ));
667 for (n, scope, reason) in [
668 (
669 "t5encoder",
670 DeferredEncoderEmbedding,
671 "encoder-only; deferred from text-generation parity",
672 ),
673 ("bert", DeferredEncoderEmbedding, "encoder/embedding; deferred"),
674 (
675 "modern-bert",
676 DeferredEncoderEmbedding,
677 "encoder/embedding; deferred",
678 ),
679 (
680 "nomic-bert",
681 DeferredEncoderEmbedding,
682 "encoder/embedding; deferred",
683 ),
684 (
685 "nomic-bert-moe",
686 DeferredEncoderEmbedding,
687 "encoder/embedding; deferred",
688 ),
689 (
690 "neo-bert",
691 DeferredEncoderEmbedding,
692 "encoder/embedding; deferred",
693 ),
694 (
695 "jina-bert-v2",
696 DeferredEncoderEmbedding,
697 "encoder/embedding; deferred",
698 ),
699 (
700 "jina-bert-v3",
701 DeferredEncoderEmbedding,
702 "encoder/embedding; deferred",
703 ),
704 (
705 "eurobert",
706 DeferredEncoderEmbedding,
707 "encoder/embedding; deferred",
708 ),
709 (
710 "llama-embed",
711 DeferredEncoderEmbedding,
712 "embedding variant; deferred",
713 ),
714 (
715 "gemma-embedding",
716 DeferredEncoderEmbedding,
717 "embedding variant; deferred",
718 ),
719 (
720 "pangu-embedded",
721 DeferredEncoderEmbedding,
722 "embedding variant; deferred",
723 ),
724 ("yi-vl", DeferredMultimodal, "Yi vision-language; deferred"),
725 ("qwen2vl", DeferredMultimodal, "vision-language; deferred"),
726 ("qwen3vl", DeferredMultimodal, "vision-language; deferred"),
727 ("qwen3vlmoe", DeferredMultimodal, "vision-language; deferred"),
728 ("cogvlm", DeferredMultimodal, "vision-language; deferred"),
729 ("chameleon", DeferredMultimodal, "multimodal; deferred"),
730 ("hunyuan_vl", DeferredMultimodal, "vision-language; deferred"),
731 ("paddleocr", DeferredMultimodal, "OCR multimodal; deferred"),
732 ("hy_v3", DeferredMultimodal, "multimodal; deferred"),
733 ("deepseek2-ocr", DeferredMultimodal, "OCR multimodal; deferred"),
734 ("dream", DeferredDiffusion, "diffusion LM; deferred"),
735 ("llada", DeferredDiffusion, "diffusion LM; deferred"),
736 ("llada-moe", DeferredDiffusion, "diffusion LM; deferred"),
737 ("rnd1", DeferredDiffusion, "diffusion LM; deferred"),
738 (
739 "wavtokenizer-dec",
740 DeferredAudio,
741 "audio tokenizer; deferred",
742 ),
743 (
744 "eagle3",
745 EnumOnly,
746 "speculative draft head; not a standalone decoder target",
747 ),
748 (
749 "dflash",
750 EnumOnly,
751 "speculative draft head; not a standalone decoder target",
752 ),
753 ("clip", EnumOnly, "quantize dummy only"),
754 ("gptj", EnumOnly, "enum-only in llama.cpp factory gap"),
755 ("(unknown)", EnumOnly, "llama.cpp unknown sentinel"),
756 ] {
757 v.push(deferred_scope(n, scope, reason));
758 }
759 v.push(prof(
760 "gemma3n",
761 TextGeneration,
762 GemmaFamily,
763 KvIswa,
764 Neox,
765 ArchPath::DedicatedOnly {
766 reason: "gemma3n AltUp/Laurel tensors not implemented in the generic decoder",
767 },
768 PerHead,
769 ));
770 for n in ["ferroxtest", "ferroxtestmoe", "ferroxtestmixed"] {
771 v.push(prof(
772 n,
773 TextGeneration,
774 TestFixture,
775 KvGqa,
776 Neox,
777 ArchPath::TestFixture { rope: Neox },
778 WholeVector,
779 ));
780 }
781 v
782 })
783 .as_slice()
784}
785
786/// Resolve a GGUF `general.architecture` value to its profile.
787pub fn resolve_profile(arch: &str) -> Option<&'static ArchProfile> {
788 architecture_catalog().iter().find(|p| p.gguf_name == arch)
789}
790
791/// Resolve a GGUF `general.architecture` value. `None` means the string
792/// is not in the registry — callers must fail closed rather than guess.
793pub fn resolve_architecture(arch: &str) -> Option<ArchPath> {
794 resolve_profile(arch).map(|p| p.path)
795}
796
797/// The alternating sliding-window period an architecture uses when its
798/// GGUF carries `{arch}.attention.sliding_window` but *not*
799/// `{arch}.attention.sliding_window_pattern`.
800///
801/// The period is not in the file for these families — llama.cpp
802/// hardcodes it per architecture and only lets the metadata key override
803/// it (`ml.get_key_or_arr(LLM_KV_ATTENTION_SLIDING_WINDOW_PATTERN,
804/// swa_period, false)` after seeding `swa_period` with the literal
805/// below). A missing key therefore does **not** mean "every layer is
806/// windowed", which is what ferrox assumed: `layer_sliding_window`
807/// returns the window for all layers when `swa_pattern` is `None`, so a
808/// gpt-oss or cohere2 checkpoint ran its full-attention layers through a
809/// 128-token window and answered from a truncated history.
810///
811/// Values transcribed from each arch's `load_arch_hparams`
812/// (`src/models/*.cpp`); `None` means "no per-arch default", i.e. the
813/// window applies uniformly when one is declared.
814pub fn default_swa_pattern(arch: &str) -> Option<usize> {
815 match arch {
816 // src/models/openai-moe.cpp:10
817 "gpt-oss" => Some(2),
818 // src/models/gemma2.cpp:8
819 "gemma2" => Some(2),
820 // src/models/gemma3.cpp:7
821 "gemma3" => Some(6),
822 // src/models/gemma3n.cpp:4 says 5, NOT 6. This was transcribed
823 // as 6 alongside gemma3 and is simply wrong. Inert only because
824 // `gemma3n` refuses for other reasons today.
825 "gemma3n" => Some(5),
826 // src/models/cohere2.cpp:5, exaone4.cpp:7, olmo2.cpp:9
827 "cohere2" | "exaone4" | "olmo2" => Some(4),
828 // Added after an audit found this table covered 6 architectures
829 // where llama.cpp hardcodes a period for 18. A MISSING entry is
830 // not neutral: with no period, every layer gets windowed, so a
831 // model whose full-attention layers should see the whole context
832 // sees only a window instead. That is a different model, and it
833 // fails silently.
834 //
835 // src/models/mellum.cpp:11
836 "mellum" => Some(4),
837 // src/models/exaone-moe.cpp:4-8. SWA is unconditional there
838 // with n_swa = 128, so without this every layer ran with a
839 // 128-token history.
840 "exaone-moe" => Some(4),
841 // src/models/afmoe.cpp, plamo3.cpp. Both refuse for other
842 // reasons today, so these are latent rather than live, and
843 // pinned here so they stay right if that changes.
844 "afmoe" => Some(4),
845 "plamo3" => Some(8),
846 _ => None,
847 }
848}
849
850/// True when this architecture's SWA layers use the model's own RoPE
851/// base rather than llama.cpp's `rope_freq_base_train_swa` default of
852/// `10000`.
853///
854/// `llama_hparams` defaults that field to `10000.0f`
855/// (`src/llama-hparams.h:127`) and the Gemma-3 lineage relies on the
856/// default; the architectures listed here instead open with
857/// `hparams.rope_freq_base_train_swa = hparams.rope_freq_base_train;`
858/// before letting `rope.freq_base_swa` override it. ferrox applied the
859/// Gemma default to everything, which rotates a gpt-oss SWA layer at
860/// theta 10000 instead of its real 150000.
861pub fn swa_rope_base_follows_model(arch: &str) -> bool {
862 matches!(
863 arch,
864 "afmoe"
865 | "cohere2"
866 | "cohere2moe"
867 | "dflash"
868 | "exaone-moe"
869 | "exaone4"
870 | "gemma2"
871 | "laguna"
872 | "llama4"
873 | "mellum"
874 | "olmo2"
875 | "gpt-oss"
876 | "smallthinker"
877 )
878}
879
880/// Metadata keys that, when present with a nonzero value, require math
881/// ferrox's generic decoder does not implement *unless* the architecture
882/// profile opts into those features (Gemma family).
883pub fn unsupported_feature_keys(arch: &str) -> Vec<(String, &'static str)> {
884 let profile = resolve_profile(arch);
885 // Gemma family implements softcap + SWA pattern; others still refuse.
886 if matches!(profile.map(|p| p.family), Some(DecoderFamily::GemmaFamily)) {
887 return Vec::new();
888 }
889 let key = |suffix: &str| format!("{arch}.{suffix}");
890 vec![
891 (
892 key("attention.logit_softcapping"),
893 "attention logit soft-capping (Gemma 2+); not implemented in the generic decoder",
894 ),
895 (
896 key("final_logit_softcapping"),
897 "final logit soft-capping (Gemma 2+); not implemented in the generic decoder",
898 ),
899 (
900 key("attention.sliding_window_pattern"),
901 "alternating sliding-window pattern (Gemma 2+); not implemented in the generic decoder",
902 ),
903 ]
904}
905
906/// Scalar multipliers a checkpoint can declare in **metadata** that the
907/// generic decoder does not apply, with the value that means "no-op".
908///
909/// These are the blind spot left by
910/// [`crate::loader::assert_every_tensor_consumed`]: that gate catches a
911/// missing *tensor*, but Granite / MiniCPM / Command-R style multipliers
912/// are hparams, not weights, so a checkpoint carrying them loads
913/// cleanly, runs at full speed, and computes a graph scaled differently
914/// from the one the checkpoint was trained as. Nothing says so.
915///
916/// llama.cpp key names (`llama-arch.cpp`):
917/// `%s.logit_scale` (`LLM_KV_LOGIT_SCALE`), `%s.residual_scale`,
918/// `%s.embedding_scale`, `%s.attention.scale`. Granite reads all four
919/// (`src/models/granite.cpp::load_arch_hparams`); MiniCPM and
920/// Command-R/Cohere2 read the subset they use.
921///
922/// **This is a refusal, not an implementation.** `residual_scale` in
923/// particular multiplies the attention and FFN branch outputs before
924/// every residual add, which in ferrox means every CPU decode/prefill/
925/// multi-seq path *and* the fused Metal kernels that fold the residual
926/// in — landing it half-way would be exactly the silent divergence this
927/// list exists to stop. Until the math is there, a checkpoint that
928/// declares one of these is refused by name.
929///
930/// The no-op value differs by key: the three `*_scale` multipliers are
931/// `1.0`, while llama.cpp's `f_attention_scale` uses `0.0` as its
932/// "unset, use 1/sqrt(head_dim)" sentinel.
933pub fn unsupported_scaling_keys(arch: &str) -> Vec<(String, &'static str, f32)> {
934 let profile = resolve_profile(arch);
935 // Gemma implements its own embedding scale and attention scale.
936 if matches!(profile.map(|p| p.family), Some(DecoderFamily::GemmaFamily)) {
937 return Vec::new();
938 }
939 let key = |suffix: &str| format!("{arch}.{suffix}");
940 vec![
941 (
942 key("logit_scale"),
943 "logit multiplier (Granite / Command-R `logits_scaling`); not applied by the generic decoder",
944 1.0,
945 ),
946 (
947 key("residual_scale"),
948 "residual multiplier (Granite `residual_multiplier`); not applied by the generic decoder",
949 1.0,
950 ),
951 (
952 key("embedding_scale"),
953 "embedding multiplier (Granite / MiniCPM `embedding_multiplier`); the generic decoder only scales embeddings for the Gemma family",
954 1.0,
955 ),
956 (
957 key("attention.scale"),
958 "explicit attention score scale (Granite `attention_multiplier`); the generic decoder always uses 1/sqrt(head_dim)",
959 0.0,
960 ),
961 ]
962}
963
964/// Markdown coverage table for docs / CI drift checks.
965pub fn coverage_report_markdown() -> String {
966 let mut lines = vec![
967 "# Architecture coverage manifest".to_string(),
968 String::new(),
969 "Generated from `ferrox_models::capability::architecture_catalog`.".to_string(),
970 "Source of truth for names: pinned llama.cpp `LLM_ARCH_NAMES`.".to_string(),
971 String::new(),
972 "| GGUF arch | Scope | Family | Memory | Path |".to_string(),
973 "|---|---|---|---|---|".to_string(),
974 ];
975 for p in architecture_catalog() {
976 let path = match p.path {
977 ArchPath::GenericGqa { .. } => "generic-gqa",
978 ArchPath::TestFixture { .. } => "test-fixture",
979 ArchPath::DedicatedOnly { .. } => "dedicated",
980 ArchPath::Deferred { .. } => "deferred",
981 };
982 lines.push(format!(
983 "| `{}` | {:?} | {:?} | {:?} | {} |",
984 p.gguf_name, p.scope, p.family, p.memory, path
985 ));
986 }
987 lines.push(String::new());
988 lines.join("\n")
989}
990
991#[cfg(test)]
992mod audit_tests {
993 use super::*;
994
995 /// Every audited name must actually be on the generic path.
996 ///
997 /// A name here that resolves to a dedicated engine, or to nothing,
998 /// is a stale entry claiming evidence for a path it does not use.
999 #[test]
1000 fn every_audited_name_is_actually_on_the_generic_path() {
1001 for name in AUDITED_GENERIC_GQA {
1002 let profile = resolve_profile(name)
1003 .unwrap_or_else(|| panic!("audited arch `{name}` is not in the catalog"));
1004 assert!(
1005 matches!(profile.path, ArchPath::GenericGqa { .. }),
1006 "`{name}` is listed as an audited GENERIC-path arch but resolves to {:?}",
1007 profile.path
1008 );
1009 }
1010 }
1011
1012 /// The five architectures that were caught computing the wrong
1013 /// thing must never appear here.
1014 ///
1015 /// They are refused outright now, but this pins the intent: the
1016 /// audited list is evidence of correctness, and these are the
1017 /// counter-examples that motivated it.
1018 #[test]
1019 fn the_architectures_that_were_wrong_are_not_claimed_as_audited() {
1020 for name in ["gpt2", "mpt", "refact", "bloom", "jais"] {
1021 assert!(
1022 !is_audited_generic(name),
1023 "`{name}` was found computing ALiBi or learned position embeddings as \
1024 though it were RoPE; it cannot be on the audited list"
1025 );
1026 }
1027 }
1028
1029 /// An architecture nobody has checked is not audited, which is the
1030 /// whole point of the inversion.
1031 #[test]
1032 fn an_unchecked_architecture_is_not_audited() {
1033 assert!(!is_audited_generic("smallthinker"));
1034 assert!(!is_audited_generic("mellum"));
1035 assert!(!is_audited_generic("an-arch-that-does-not-exist"));
1036 }
1037}
1038
1039#[cfg(test)]
1040mod tests {
1041 use super::*;
1042
1043 #[test]
1044 fn known_mainstream_families_resolve() {
1045 assert_eq!(
1046 resolve_architecture("llama"),
1047 Some(ArchPath::GenericGqa {
1048 rope: RopeLayout::Norm
1049 })
1050 );
1051 assert_eq!(
1052 resolve_architecture("qwen2moe"),
1053 Some(ArchPath::GenericGqa {
1054 rope: RopeLayout::Neox
1055 })
1056 );
1057 assert_eq!(
1058 resolve_architecture("mistral"),
1059 Some(ArchPath::GenericGqa {
1060 rope: RopeLayout::Neox
1061 })
1062 );
1063 assert_eq!(
1064 resolve_architecture("yi"),
1065 Some(ArchPath::GenericGqa {
1066 rope: RopeLayout::Neox
1067 })
1068 );
1069 assert_eq!(
1070 resolve_architecture("mixtral"),
1071 Some(ArchPath::GenericGqa {
1072 rope: RopeLayout::Neox
1073 })
1074 );
1075 assert_eq!(
1076 resolve_architecture("phi3"),
1077 Some(ArchPath::GenericGqa {
1078 rope: RopeLayout::Neox
1079 })
1080 );
1081 assert_eq!(
1082 resolve_architecture("phi4"),
1083 Some(ArchPath::GenericGqa {
1084 rope: RopeLayout::Neox
1085 })
1086 );
1087 assert_eq!(
1088 resolve_profile("phi4").map(|p| p.family),
1089 Some(DecoderFamily::PhiFamily)
1090 );
1091 assert_eq!(
1092 resolve_architecture("gemma3"),
1093 Some(ArchPath::GenericGqa {
1094 rope: RopeLayout::Neox
1095 })
1096 );
1097 for arch in ["gemma4", "gemma4-assistant"] {
1098 assert!(
1099 matches!(
1100 resolve_architecture(arch),
1101 Some(ArchPath::DedicatedOnly { .. })
1102 ),
1103 "{arch} uses dedicated Gemma4 engine"
1104 );
1105 assert_eq!(
1106 resolve_profile(arch).map(|p| p.family),
1107 Some(DecoderFamily::GemmaFamily)
1108 );
1109 }
1110 assert!(matches!(
1111 resolve_architecture("gemma3n"),
1112 Some(ArchPath::DedicatedOnly { .. })
1113 ));
1114 assert_eq!(
1115 resolve_architecture("deepseek"),
1116 Some(ArchPath::GenericGqa {
1117 rope: RopeLayout::Norm
1118 })
1119 );
1120 assert_eq!(
1121 resolve_profile("qwen3").map(|p| p.qk_norm),
1122 Some(QkNormStyle::PerHead)
1123 );
1124 }
1125
1126 #[test]
1127 fn deepseek2_is_dedicated_mla_not_generic() {
1128 assert!(matches!(
1129 resolve_architecture("deepseek2"),
1130 Some(ArchPath::DedicatedOnly { .. })
1131 ));
1132 }
1133
1134 #[test]
1135 fn unknown_architecture_is_none() {
1136 assert_eq!(resolve_architecture("totally-unknown-arch"), None);
1137 // t5 is registered as dedicated encoder-decoder stub
1138 assert!(matches!(
1139 resolve_architecture("t5"),
1140 Some(ArchPath::DedicatedOnly { .. })
1141 ));
1142 }
1143
1144 #[test]
1145 fn dedicated_paths_are_not_generic() {
1146 assert!(matches!(
1147 resolve_architecture("glm-dsa"),
1148 Some(ArchPath::DedicatedOnly { .. })
1149 ));
1150 assert!(matches!(
1151 resolve_architecture("deepseek4"),
1152 Some(ArchPath::DedicatedOnly { .. })
1153 ));
1154 for arch in ["minimax-m2", "minimax-m3"] {
1155 assert!(
1156 matches!(
1157 resolve_architecture(arch),
1158 Some(ArchPath::DedicatedOnly {
1159 reason: "MiniMax 256-expert sigmoid MoE + MTP — see minimax_engine.rs"
1160 })
1161 ),
1162 "{arch} must fail closed, not silent generic GQA"
1163 );
1164 }
1165 assert!(
1166 matches!(
1167 resolve_architecture("llama4"),
1168 Some(ArchPath::DedicatedOnly {
1169 reason: "llama4 MoE + non-GQA attn — see llama4_engine.rs tensor list"
1170 })
1171 ),
1172 "llama4 must fail closed, not silent generic GQA"
1173 );
1174 assert!(matches!(
1175 resolve_architecture("glm4"),
1176 Some(ArchPath::DedicatedOnly { .. })
1177 ));
1178 assert!(matches!(
1179 resolve_architecture("glm4moe"),
1180 Some(ArchPath::DedicatedOnly { .. })
1181 ));
1182 }
1183
1184 #[test]
1185 fn test_fixtures_remain_loadable() {
1186 for arch in ["ferroxtest", "ferroxtestmoe", "ferroxtestmixed"] {
1187 assert!(matches!(
1188 resolve_architecture(arch),
1189 Some(ArchPath::TestFixture { .. })
1190 ));
1191 }
1192 }
1193
1194 #[test]
1195 fn catalog_has_unique_names() {
1196 let mut seen = std::collections::HashSet::new();
1197 for p in architecture_catalog() {
1198 assert!(
1199 seen.insert(p.gguf_name),
1200 "duplicate arch name {}",
1201 p.gguf_name
1202 );
1203 }
1204 }
1205
1206 #[test]
1207 fn gemma_family_does_not_fail_closed_on_softcap_keys() {
1208 assert!(unsupported_feature_keys("gemma3").is_empty());
1209 assert!(!unsupported_feature_keys("llama").is_empty());
1210 }
1211
1212 /// Parallel attention+FFN residual is not a tensor and, for MiniCPM,
1213 /// not even a metadata key -- llama.cpp hardcodes MiniCPM's three
1214 /// multipliers. Neither the tensor-consumption gate nor
1215 /// `unsupported_scaling_keys` can see the difference, so these
1216 /// architectures must not be admitted to the generic decoder at all.
1217 #[test]
1218 fn architectures_with_a_different_residual_topology_are_refused() {
1219 for arch in [
1220 "command-r",
1221 "cohere2",
1222 "cohere2moe",
1223 "falcon",
1224 "gptneox",
1225 "phi2",
1226 "plamo",
1227 "minicpm",
1228 ] {
1229 match resolve_architecture(arch) {
1230 Some(ArchPath::DedicatedOnly { reason }) => {
1231 assert!(!reason.is_empty(), "{arch} must say why");
1232 }
1233 other => panic!("{arch} must be refused, got {other:?}"),
1234 }
1235 }
1236 // The sequential-residual siblings stay on the generic path --
1237 // this is a named list, not a family-wide ban.
1238 for arch in ["phi3", "phimoe", "plamo3", "starcoder2", "nemotron"] {
1239 assert!(
1240 matches!(
1241 resolve_architecture(arch),
1242 Some(ArchPath::GenericGqa { .. })
1243 ),
1244 "{arch} must stay generic"
1245 );
1246 }
1247 }
1248
1249 /// Every architecture appears exactly once, so a refusal added next
1250 /// to an existing entry cannot be shadowed by whichever the lookup
1251 /// happens to find first.
1252 #[test]
1253 fn no_architecture_is_listed_twice() {
1254 let mut seen = std::collections::HashSet::new();
1255 for p in architecture_catalog() {
1256 assert!(seen.insert(p.gguf_name), "{} listed twice", p.gguf_name);
1257 }
1258 }
1259}