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/// 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}