Skip to main content

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