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