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            "minicpm",
204            "xverse",
205            "command-r",
206            "cohere2",
207            "cohere2moe",
208            "olmo",
209            "arctic",
210            "deepseek",
211            "chatglm",
212            "granite",
213            "granitemoe",
214            "granite-moe",
215            "mistral3",
216            "maincoder",
217            "smollm3",
218            "arcee",
219            "ernie4_5",
220            "ernie4_5-moe",
221            "seed_oss",
222            "dots1",
223            "bailingmoe",
224            "bailingmoe2",
225            "orion",
226            "codeshell",
227            "openelm",
228            "nemotron",
229            "exaone",
230            "exaone-moe",
231            "apertus",
232            "laguna",
233            "nanbeige",
234            "talkie",
235            "step35",
236            "mimo2",
237            "mellum",
238            "hunyuan-dense",
239            "hunyuan-moe",
240            "afmoe",
241            "grovemoe",
242            "smallthinker",
243            "plamo",
244            "plamo3",
245            "minicpm3",
246            "starcoder2",
247            "plm",
248        ] {
249            v.push(gqa_norm(n));
250        }
251        for n in [
252            "olmoe", "qwen", "qwen2", "qwen2moe", "falcon", "gptneox", "stablelm", "mistral",
253            "mixtral", "olmo2", "gpt2", "bloom", "mpt", "refact", "bitnet", "jais", "jais2",
254            "grok", "dbrx", "exaone4", "yi",
255            // llama-model.cpp `llama_model_rope_type`: LLM_ARCH_OPENAI_MOE
256            // falls in the `return LLAMA_ROPE_TYPE_NEOX` group, and a live
257            // load of a gpt-oss GGUF prints `rope type = 2` (= NEOX).
258            // ferrox had it on the interleaved (NORM) list, which rotates
259            // the wrong pairs of every Q/K head.
260            "gpt-oss",
261        ] {
262            v.push(gqa_neox(n));
263        }
264        v.push(prof(
265            "qwen3",
266            TextGeneration,
267            Qwen3Family,
268            KvGqa,
269            Neox,
270            ArchPath::GenericGqa { rope: Neox },
271            PerHead,
272        ));
273        v.push(prof(
274            "qwen3moe",
275            TextGeneration,
276            Qwen3Family,
277            KvGqa,
278            Neox,
279            ArchPath::GenericGqa { rope: Neox },
280            PerHead,
281        ));
282        v.push(prof(
283            "gemma",
284            TextGeneration,
285            GemmaFamily,
286            KvGqa,
287            Neox,
288            ArchPath::GenericGqa { rope: Neox },
289            PerHead,
290        ));
291        v.push(prof(
292            "gemma2",
293            TextGeneration,
294            GemmaFamily,
295            KvIswa,
296            Neox,
297            ArchPath::GenericGqa { rope: Neox },
298            PerHead,
299        ));
300        v.push(prof(
301            "gemma3",
302            TextGeneration,
303            GemmaFamily,
304            KvIswa,
305            Neox,
306            ArchPath::GenericGqa { rope: Neox },
307            PerHead,
308        ));
309        // Gemma-4 text GGUFs (E2B): per-layer embeddings, shared-KV
310        // layers, and split SWA/full head dims — dedicated
311        // [`crate::gemma4_engine::Gemma4Engine`] (not GenericGqa).
312        for n in ["gemma4", "gemma4-assistant"] {
313            v.push(prof(
314                n,
315                TextGeneration,
316                GemmaFamily,
317                KvIswa,
318                Neox,
319                ArchPath::DedicatedOnly {
320                    reason: "use load_gemma4_engine_from_path / ServedEngine::Gemma4",
321                },
322                PerHead,
323            ));
324        }
325        for (n, fam) in [("phi2", PhiFamily), ("phi3", PhiFamily), ("phimoe", PhiFamily)] {
326            v.push(prof(
327                n,
328                TextGeneration,
329                fam,
330                KvGqa,
331                Neox,
332                ArchPath::GenericGqa { rope: Neox },
333                WholeVector,
334            ));
335        }
336        // Phi-4 GGUFs share the phi3 fused-QKV / fused gate+up graph
337        // (PhiFamily). Many community checkpoints still tag `phi3`; admit
338        // `phi4` the same way so either string can load. Receipts / head-dim
339        // FA-vec coverage remain P6 evidence work — not a speed claim.
340        v.push(prof(
341            "phi4",
342            TextGeneration,
343            PhiFamily,
344            KvGqa,
345            Neox,
346            ArchPath::GenericGqa { rope: Neox },
347            WholeVector,
348        ));
349        // Llama 4: MoE + interleaved / non-generic attention graph — not
350        // safe to admit as GenericGqa (was wrongly listed with plain llama).
351        v.push(prof(
352            "llama4",
353            TextGeneration,
354            Dedicated,
355            KvGqa,
356            Norm,
357            ArchPath::DedicatedOnly {
358                reason: "llama4 MoE + non-GQA attn — see llama4_engine.rs tensor list",
359            },
360            WholeVector,
361        ));
362        // MiniMax M2/M3: 256-expert sigmoid MoE + MTP — not generic GQA.
363        for n in ["minimax-m2", "minimax-m3"] {
364            v.push(prof(
365                n,
366                TextGeneration,
367                Dedicated,
368                KvGqa,
369                Norm,
370                ArchPath::DedicatedOnly {
371                    reason: "MiniMax 256-expert sigmoid MoE + MTP — see minimax_engine.rs",
372                },
373                WholeVector,
374            ));
375        }
376        v.push(prof(
377            "deepseek2",
378            TextGeneration,
379            Mla,
380            KvMla,
381            Norm,
382            ArchPath::DedicatedOnly {
383                reason: "DeepSeek-2 MLA needs the MLA engine, not generic GQA",
384            },
385            WholeVector,
386        ));
387        v.push(prof(
388            "deepseek32",
389            TextGeneration,
390            Mla,
391            KvDsa,
392            Norm,
393            ArchPath::DedicatedOnly {
394                reason: "DeepSeek-3.2 DSA/MLA needs the dedicated sparse/MLA stack",
395            },
396            WholeVector,
397        ));
398        v.push(prof(
399            "mistral4",
400            TextGeneration,
401            Mla,
402            KvMla,
403            Norm,
404            ArchPath::DedicatedOnly {
405                reason: "mistral4 reuses DeepSeek-2 MLA loader/graph in llama.cpp",
406            },
407            WholeVector,
408        ));
409        v.push(dedicated(
410            "glm-dsa",
411            "use ferrox_models::glm52_decoder / glm52_gguf_loader (DSA), not the generic GQA Decoder",
412        ));
413        v.push(dedicated(
414            "glm4",
415            "use ferrox_models::glm52_decoder / glm52_gguf_loader, not the generic GQA Decoder",
416        ));
417        v.push(dedicated(
418            "glm4moe",
419            "use ferrox_models::glm52_decoder / glm52_gguf_loader, not the generic GQA Decoder",
420        ));
421        v.push(dedicated(
422            "deepseek4",
423            "DeepSeek V4 needs CSA/HCA + mHC assembly; generic GQA Decoder is not valid",
424        ));
425        v.push(dedicated(
426            "kimi-linear",
427            "use ferrox_models::kimi_decoder / kimi_loader, not the generic GQA Decoder",
428        ));
429        v.push(dedicated(
430            "kimi_k3",
431            "use ferrox_models::kimi_decoder / kimi_loader, not the generic GQA Decoder",
432        ));
433        for (n, rope) in [
434            ("jamba", Neox),
435            ("falcon-h1", Neox),
436            ("plamo2", Neox),
437            ("granitehybrid", Norm),
438            ("granite-hybrid", Norm),
439            ("lfm2", Neox),
440            ("lfm2moe", Neox),
441            ("nemotron_h", Neox),
442            ("nemotron_h_moe", Neox),
443            ("qwen3next", Neox),
444            ("qwen35", Neox),
445            ("qwen35moe", Neox),
446        ] {
447            let qk = if n.starts_with("qwen3") {
448                PerHead
449            } else {
450                WholeVector
451            };
452            v.push(prof(
453                n,
454                TextGeneration,
455                DecoderFamily::Hybrid,
456                MemoryKind::Hybrid,
457                rope,
458                ArchPath::DedicatedOnly {
459                    reason: "hybrid attn+SSM/delta-net engine not yet on the serve path",
460                },
461                qk,
462            ));
463        }
464        for n in ["mamba", "mamba2", "rwkv6", "rwkv6qwen2", "rwkv7", "arwkv7"] {
465            v.push(prof(
466                n,
467                TextGeneration,
468                DecoderFamily::Recurrent,
469                MemoryKind::Recurrent,
470                Neox,
471                ArchPath::DedicatedOnly {
472                    reason: "recurrent engine not yet on the serve path",
473                },
474                WholeVector,
475            ));
476        }
477        v.push(prof(
478            "t5",
479            TextGeneration,
480            EncoderDecoder,
481            None,
482            Neox,
483            ArchPath::DedicatedOnly {
484                reason: "T5 encoder-decoder engine not yet on the serve path",
485            },
486            WholeVector,
487        ));
488        for (n, scope, reason) in [
489            (
490                "t5encoder",
491                DeferredEncoderEmbedding,
492                "encoder-only; deferred from text-generation parity",
493            ),
494            ("bert", DeferredEncoderEmbedding, "encoder/embedding; deferred"),
495            (
496                "modern-bert",
497                DeferredEncoderEmbedding,
498                "encoder/embedding; deferred",
499            ),
500            (
501                "nomic-bert",
502                DeferredEncoderEmbedding,
503                "encoder/embedding; deferred",
504            ),
505            (
506                "nomic-bert-moe",
507                DeferredEncoderEmbedding,
508                "encoder/embedding; deferred",
509            ),
510            (
511                "neo-bert",
512                DeferredEncoderEmbedding,
513                "encoder/embedding; deferred",
514            ),
515            (
516                "jina-bert-v2",
517                DeferredEncoderEmbedding,
518                "encoder/embedding; deferred",
519            ),
520            (
521                "jina-bert-v3",
522                DeferredEncoderEmbedding,
523                "encoder/embedding; deferred",
524            ),
525            (
526                "eurobert",
527                DeferredEncoderEmbedding,
528                "encoder/embedding; deferred",
529            ),
530            (
531                "llama-embed",
532                DeferredEncoderEmbedding,
533                "embedding variant; deferred",
534            ),
535            (
536                "gemma-embedding",
537                DeferredEncoderEmbedding,
538                "embedding variant; deferred",
539            ),
540            (
541                "pangu-embedded",
542                DeferredEncoderEmbedding,
543                "embedding variant; deferred",
544            ),
545            ("yi-vl", DeferredMultimodal, "Yi vision-language; deferred"),
546            ("qwen2vl", DeferredMultimodal, "vision-language; deferred"),
547            ("qwen3vl", DeferredMultimodal, "vision-language; deferred"),
548            ("qwen3vlmoe", DeferredMultimodal, "vision-language; deferred"),
549            ("cogvlm", DeferredMultimodal, "vision-language; deferred"),
550            ("chameleon", DeferredMultimodal, "multimodal; deferred"),
551            ("hunyuan_vl", DeferredMultimodal, "vision-language; deferred"),
552            ("paddleocr", DeferredMultimodal, "OCR multimodal; deferred"),
553            ("hy_v3", DeferredMultimodal, "multimodal; deferred"),
554            ("deepseek2-ocr", DeferredMultimodal, "OCR multimodal; deferred"),
555            ("dream", DeferredDiffusion, "diffusion LM; deferred"),
556            ("llada", DeferredDiffusion, "diffusion LM; deferred"),
557            ("llada-moe", DeferredDiffusion, "diffusion LM; deferred"),
558            ("rnd1", DeferredDiffusion, "diffusion LM; deferred"),
559            (
560                "wavtokenizer-dec",
561                DeferredAudio,
562                "audio tokenizer; deferred",
563            ),
564            (
565                "eagle3",
566                EnumOnly,
567                "speculative draft head; not a standalone decoder target",
568            ),
569            (
570                "dflash",
571                EnumOnly,
572                "speculative draft head; not a standalone decoder target",
573            ),
574            ("clip", EnumOnly, "quantize dummy only"),
575            ("gptj", EnumOnly, "enum-only in llama.cpp factory gap"),
576            ("(unknown)", EnumOnly, "llama.cpp unknown sentinel"),
577        ] {
578            v.push(deferred_scope(n, scope, reason));
579        }
580        v.push(prof(
581            "gemma3n",
582            TextGeneration,
583            GemmaFamily,
584            KvIswa,
585            Neox,
586            ArchPath::DedicatedOnly {
587                reason: "gemma3n AltUp/Laurel tensors not implemented in the generic decoder",
588            },
589            PerHead,
590        ));
591        for n in ["ferroxtest", "ferroxtestmoe", "ferroxtestmixed"] {
592            v.push(prof(
593                n,
594                TextGeneration,
595                TestFixture,
596                KvGqa,
597                Neox,
598                ArchPath::TestFixture { rope: Neox },
599                WholeVector,
600            ));
601        }
602        v
603    })
604    .as_slice()
605}
606
607/// Resolve a GGUF `general.architecture` value to its profile.
608pub fn resolve_profile(arch: &str) -> Option<&'static ArchProfile> {
609    architecture_catalog().iter().find(|p| p.gguf_name == arch)
610}
611
612/// Resolve a GGUF `general.architecture` value. `None` means the string
613/// is not in the registry — callers must fail closed rather than guess.
614pub fn resolve_architecture(arch: &str) -> Option<ArchPath> {
615    resolve_profile(arch).map(|p| p.path)
616}
617
618/// The alternating sliding-window period an architecture uses when its
619/// GGUF carries `{arch}.attention.sliding_window` but *not*
620/// `{arch}.attention.sliding_window_pattern`.
621///
622/// The period is not in the file for these families — llama.cpp
623/// hardcodes it per architecture and only lets the metadata key override
624/// it (`ml.get_key_or_arr(LLM_KV_ATTENTION_SLIDING_WINDOW_PATTERN,
625/// swa_period, false)` after seeding `swa_period` with the literal
626/// below). A missing key therefore does **not** mean "every layer is
627/// windowed", which is what ferrox assumed: `layer_sliding_window`
628/// returns the window for all layers when `swa_pattern` is `None`, so a
629/// gpt-oss or cohere2 checkpoint ran its full-attention layers through a
630/// 128-token window and answered from a truncated history.
631///
632/// Values transcribed from each arch's `load_arch_hparams`
633/// (`src/models/*.cpp`); `None` means "no per-arch default", i.e. the
634/// window applies uniformly when one is declared.
635pub fn default_swa_pattern(arch: &str) -> Option<usize> {
636    match arch {
637        // src/models/openai-moe.cpp:10
638        "gpt-oss" => Some(2),
639        // src/models/gemma2.cpp:8
640        "gemma2" => Some(2),
641        // src/models/gemma3.cpp:7, gemma3n.cpp:6
642        "gemma3" | "gemma3n" => Some(6),
643        // src/models/cohere2.cpp:5, exaone4.cpp:7, olmo2.cpp:9
644        "cohere2" | "exaone4" | "olmo2" => Some(4),
645        _ => None,
646    }
647}
648
649/// True when this architecture's SWA layers use the model's own RoPE
650/// base rather than llama.cpp's `rope_freq_base_train_swa` default of
651/// `10000`.
652///
653/// `llama_hparams` defaults that field to `10000.0f`
654/// (`src/llama-hparams.h:127`) and the Gemma-3 lineage relies on the
655/// default; the architectures listed here instead open with
656/// `hparams.rope_freq_base_train_swa = hparams.rope_freq_base_train;`
657/// before letting `rope.freq_base_swa` override it. ferrox applied the
658/// Gemma default to everything, which rotates a gpt-oss SWA layer at
659/// theta 10000 instead of its real 150000.
660pub fn swa_rope_base_follows_model(arch: &str) -> bool {
661    matches!(
662        arch,
663        "afmoe"
664            | "cohere2"
665            | "cohere2moe"
666            | "dflash"
667            | "exaone-moe"
668            | "exaone4"
669            | "gemma2"
670            | "laguna"
671            | "llama4"
672            | "mellum"
673            | "olmo2"
674            | "gpt-oss"
675            | "smallthinker"
676    )
677}
678
679/// Metadata keys that, when present with a nonzero value, require math
680/// ferrox's generic decoder does not implement *unless* the architecture
681/// profile opts into those features (Gemma family).
682pub fn unsupported_feature_keys(arch: &str) -> Vec<(String, &'static str)> {
683    let profile = resolve_profile(arch);
684    // Gemma family implements softcap + SWA pattern; others still refuse.
685    if matches!(profile.map(|p| p.family), Some(DecoderFamily::GemmaFamily)) {
686        return Vec::new();
687    }
688    let key = |suffix: &str| format!("{arch}.{suffix}");
689    vec![
690        (
691            key("attention.logit_softcapping"),
692            "attention logit soft-capping (Gemma 2+); not implemented in the generic decoder",
693        ),
694        (
695            key("final_logit_softcapping"),
696            "final logit soft-capping (Gemma 2+); not implemented in the generic decoder",
697        ),
698        (
699            key("attention.sliding_window_pattern"),
700            "alternating sliding-window pattern (Gemma 2+); not implemented in the generic decoder",
701        ),
702    ]
703}
704
705/// Markdown coverage table for docs / CI drift checks.
706pub fn coverage_report_markdown() -> String {
707    let mut lines = vec![
708        "# Architecture coverage manifest".to_string(),
709        String::new(),
710        "Generated from `ferrox_models::capability::architecture_catalog`.".to_string(),
711        "Source of truth for names: pinned llama.cpp `LLM_ARCH_NAMES`.".to_string(),
712        String::new(),
713        "| GGUF arch | Scope | Family | Memory | Path |".to_string(),
714        "|---|---|---|---|---|".to_string(),
715    ];
716    for p in architecture_catalog() {
717        let path = match p.path {
718            ArchPath::GenericGqa { .. } => "generic-gqa",
719            ArchPath::TestFixture { .. } => "test-fixture",
720            ArchPath::DedicatedOnly { .. } => "dedicated",
721            ArchPath::Deferred { .. } => "deferred",
722        };
723        lines.push(format!(
724            "| `{}` | {:?} | {:?} | {:?} | {} |",
725            p.gguf_name, p.scope, p.family, p.memory, path
726        ));
727    }
728    lines.push(String::new());
729    lines.join("\n")
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735
736    #[test]
737    fn known_mainstream_families_resolve() {
738        assert_eq!(
739            resolve_architecture("llama"),
740            Some(ArchPath::GenericGqa {
741                rope: RopeLayout::Norm
742            })
743        );
744        assert_eq!(
745            resolve_architecture("qwen2moe"),
746            Some(ArchPath::GenericGqa {
747                rope: RopeLayout::Neox
748            })
749        );
750        assert_eq!(
751            resolve_architecture("mistral"),
752            Some(ArchPath::GenericGqa {
753                rope: RopeLayout::Neox
754            })
755        );
756        assert_eq!(
757            resolve_architecture("yi"),
758            Some(ArchPath::GenericGqa {
759                rope: RopeLayout::Neox
760            })
761        );
762        assert_eq!(
763            resolve_architecture("mixtral"),
764            Some(ArchPath::GenericGqa {
765                rope: RopeLayout::Neox
766            })
767        );
768        assert_eq!(
769            resolve_architecture("phi3"),
770            Some(ArchPath::GenericGqa {
771                rope: RopeLayout::Neox
772            })
773        );
774        assert_eq!(
775            resolve_architecture("phi4"),
776            Some(ArchPath::GenericGqa {
777                rope: RopeLayout::Neox
778            })
779        );
780        assert_eq!(
781            resolve_profile("phi4").map(|p| p.family),
782            Some(DecoderFamily::PhiFamily)
783        );
784        assert_eq!(
785            resolve_architecture("gemma3"),
786            Some(ArchPath::GenericGqa {
787                rope: RopeLayout::Neox
788            })
789        );
790        for arch in ["gemma4", "gemma4-assistant"] {
791            assert!(
792                matches!(
793                    resolve_architecture(arch),
794                    Some(ArchPath::DedicatedOnly { .. })
795                ),
796                "{arch} uses dedicated Gemma4 engine"
797            );
798            assert_eq!(
799                resolve_profile(arch).map(|p| p.family),
800                Some(DecoderFamily::GemmaFamily)
801            );
802        }
803        assert!(matches!(
804            resolve_architecture("gemma3n"),
805            Some(ArchPath::DedicatedOnly { .. })
806        ));
807        assert_eq!(
808            resolve_architecture("deepseek"),
809            Some(ArchPath::GenericGqa {
810                rope: RopeLayout::Norm
811            })
812        );
813        assert_eq!(
814            resolve_profile("qwen3").map(|p| p.qk_norm),
815            Some(QkNormStyle::PerHead)
816        );
817    }
818
819    #[test]
820    fn deepseek2_is_dedicated_mla_not_generic() {
821        assert!(matches!(
822            resolve_architecture("deepseek2"),
823            Some(ArchPath::DedicatedOnly { .. })
824        ));
825    }
826
827    #[test]
828    fn unknown_architecture_is_none() {
829        assert_eq!(resolve_architecture("totally-unknown-arch"), None);
830        // t5 is registered as dedicated encoder-decoder stub
831        assert!(matches!(
832            resolve_architecture("t5"),
833            Some(ArchPath::DedicatedOnly { .. })
834        ));
835    }
836
837    #[test]
838    fn dedicated_paths_are_not_generic() {
839        assert!(matches!(
840            resolve_architecture("glm-dsa"),
841            Some(ArchPath::DedicatedOnly { .. })
842        ));
843        assert!(matches!(
844            resolve_architecture("deepseek4"),
845            Some(ArchPath::DedicatedOnly { .. })
846        ));
847        for arch in ["minimax-m2", "minimax-m3"] {
848            assert!(
849                matches!(
850                    resolve_architecture(arch),
851                    Some(ArchPath::DedicatedOnly {
852                        reason: "MiniMax 256-expert sigmoid MoE + MTP — see minimax_engine.rs"
853                    })
854                ),
855                "{arch} must fail closed, not silent generic GQA"
856            );
857        }
858        assert!(
859            matches!(
860                resolve_architecture("llama4"),
861                Some(ArchPath::DedicatedOnly {
862                    reason: "llama4 MoE + non-GQA attn — see llama4_engine.rs tensor list"
863                })
864            ),
865            "llama4 must fail closed, not silent generic GQA"
866        );
867        assert!(matches!(
868            resolve_architecture("glm4"),
869            Some(ArchPath::DedicatedOnly { .. })
870        ));
871        assert!(matches!(
872            resolve_architecture("glm4moe"),
873            Some(ArchPath::DedicatedOnly { .. })
874        ));
875    }
876
877    #[test]
878    fn test_fixtures_remain_loadable() {
879        for arch in ["ferroxtest", "ferroxtestmoe", "ferroxtestmixed"] {
880            assert!(matches!(
881                resolve_architecture(arch),
882                Some(ArchPath::TestFixture { .. })
883            ));
884        }
885    }
886
887    #[test]
888    fn catalog_has_unique_names() {
889        let mut seen = std::collections::HashSet::new();
890        for p in architecture_catalog() {
891            assert!(
892                seen.insert(p.gguf_name),
893                "duplicate arch name {}",
894                p.gguf_name
895            );
896        }
897    }
898
899    #[test]
900    fn gemma_family_does_not_fail_closed_on_softcap_keys() {
901        assert!(unsupported_feature_keys("gemma3").is_empty());
902        assert!(!unsupported_feature_keys("llama").is_empty());
903    }
904}